diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index ecccd99e1..0a32e9622 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -15,6 +15,27 @@ import Foundation import Macros import SwiftUI +/// Common settings between app and NSE +nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable { + var lastNotificationBootTime: TimeInterval? { get set } + var selectedNotificationTone: NotificationTone? { get set } + + var logLevel: LogLevel { get } + var traceLogPacks: Set { get } + var bugReportRageshakeURL: RemotePreference { get } + var contentScannerURL: RemotePreference { get } + var forceDisableE2EE: RemotePreference { get } + var mapTilerConfiguration: RemotePreference { get } + + var enableOnlySignedDeviceIsolationMode: Bool { get } + var threadsEnabled: Bool { get } + var hideQuietNotificationAlerts: Bool { get } + + var voipPusherAppID: String { get } + var pushKitDeviceToken: String? { get } + var voipPushGatewayNotifyURL: String? { get } +} + nonisolated enum AppBuildType { case debug case nightly @@ -51,10 +72,15 @@ final nonisolated class AppSettings: @unchecked Sendable { store.reset() } + func resetSessionSpecificSettings() { + MXLog.warning("Resetting the user session specific AppSettings.") + resetHasRunIdentityConfirmationOnboarding() + } + // MARK: - Hooks // swiftlint:disable:next function_parameter_count - func override(accountProviders: [AccountProvider], + func override(accountProviders: [String], allowOtherAccountProviders: Bool, hideBrandChrome: Bool, pushGatewayBaseURL: URL, @@ -115,15 +141,11 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: true) var hasSeenNewSoundBanner: Bool - /// Defaults to `true` so new users never see it. Developer options arms it for now, an upgrade migration will at launch. - @UserPreference(defaultValue: true) - var hasSeenMultiAccountAnnouncement: Bool - /// The initial set of account providers shown to the user in the authentication flow. /// /// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should /// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`. - private(set) var accountProviders: [AccountProvider] = [.managed(serverName: "matrix.org", baseURL: "https://matrix-client.matrix.org")] + private(set) var accountProviders = ["matrix.org"] /// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`. private(set) var allowOtherAccountProviders = true /// Whether the components surrounding the app brand/logo should be hidden or not @@ -131,8 +153,6 @@ final nonisolated class AppSettings: @unchecked Sendable { /// The task identifier used for background app refresh. Also used in main target's the Info.plist let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh" - /// The task identifier used for the search index backfill. Also used in main target's the Info.plist - let searchBackfillTaskIdentifier = "io.element.elementx.background.search-backfill" /// A URL where users can go read more about the app. private(set) var websiteURL: URL = "https://element.io" @@ -170,12 +190,8 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(key: "previousServers", defaultValue: []) var previousServers: [String] - var defaultAccountProvider: AccountProvider { - if allowOtherAccountProviders { - previousServers.first.map { .generic($0) } ?? accountProviders[0] - } else { - accountProviders[0] - } + var defaultServer: String { + previousServers.first ?? accountProviders[0] } // MARK: - Security @@ -232,6 +248,34 @@ final nonisolated class AppSettings: @unchecked Sendable { pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify") } + // HealthChat: the NSE lacks the notification filtering entitlement, so it cannot hand ringing + // calls to CallKit directly. Instead it asks the push gateway for a PushKit (VoIP) push, the + // way the legacy element-ios app did. The app persists what the NSE needs for that request + // here, because the NSE neither receives PushKit credentials nor runs the app settings hook. + + /// The push gateway app ID that delivers PushKit pushes (`push_type: voip` in Sygnal). + var voipPusherAppID: String { + #if DEBUG + InfoPlistReader.main.baseBundleIdentifier + ".ios.voip.dev" + #else + InfoPlistReader.main.baseBundleIdentifier + ".ios.voip.prod" + #endif + } + + /// The device's PushKit token, base64 encoded like the APNs pushkey. + @UserPreference + var pushKitDeviceToken: String? + + /// `pushGatewayNotifyEndpoint` as configured in the app, for the NSE. + @UserPreference + var voipPushGatewayNotifyURL: String? + + @UserPreference(defaultValue: true) + var enableNotifications: Bool + + @UserPreference(defaultValue: true) + var enableInAppNotifications: Bool + @UserPreference(defaultValue: false) var hideQuietNotificationAlerts: Bool @@ -243,9 +287,9 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference var lastNotificationBootTime: TimeInterval? - /// The app icon badge value the app last computed from the SDK's unread notification counts. - @UserPreference(defaultValue: 0) - var lastKnownBadgeCount: Int + /// The sound played when delivering noisy notifications. If nil, use the ElementX default + @UserPreference + var selectedNotificationTone: NotificationTone? // MARK: - Logging @@ -304,6 +348,9 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var hasRunNotificationPermissionsOnboarding: Bool + @UserPreference(defaultValue: false) + var hasRunIdentityConfirmationOnboarding: Bool + @UserPreference(defaultValue: false) var hasRequestedLocationAlwaysLocationAuthorization: Bool @@ -321,6 +368,14 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var liveLocationDisclaimerDisplayed: Bool + // MARK: - Home Screen + + @UserPreference(defaultValue: RoomListActivityVisibility.current) + var roomListActivityVisibility: RoomListActivityVisibility + + @UserPreference(defaultValue: false) + var roomListNotificationCountEnabled: Bool + // MARK: - Room Screen @UserPreference(defaultValue: AppBuildType.current == .debug) @@ -367,6 +422,11 @@ final nonisolated class AppSettings: @unchecked Sendable { /// The MapTiler configuration used to build map URLs, which defaults to the bundled one. private(set) var mapTilerConfiguration = RemotePreference(AppSettings.bundledMapTilerConfiguration) + // MARK: - Presence + + @UserPreference(defaultValue: true) + var sharePresence: Bool + // MARK: - Feature Flags /// Others @@ -392,7 +452,7 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var messageMultiSelectEnabled: Bool - @UserPreference(key: "globalSearchEnabledV2", defaultValue: true) + @UserPreference(defaultValue: ProcessInfo().isiOSAppOnMac) var globalSearchEnabled: Bool @UserPreference(defaultValue: false) @@ -412,32 +472,22 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var linkNewDeviceEnabled: Bool + @UserPreference(defaultValue: false) + var automaticBackPaginationEnabled: Bool + @UserPreference(key: "clientPausingAndResumingEnabledV2", defaultValue: false, volatile: true) var clientPausingAndResumingEnabled: Bool @UserPreference(defaultValue: AppBuildType.current != .release) var developerOptionsEnabled: Bool - /// Runs calls through the native matrix-rust-rtc stack instead of the Element Call web view. - @UserPreference(defaultValue: false) - var nativeCallEnabled: Bool - - @UserPreference(defaultValue: false) - var multiAccountEnabled: Bool - - /// Lets the add account flow start once `multiAccountEnabled` shows its entry points. Not a user preference, so testers - /// can't add a second account while signing out of one isn't safe yet. Only set it to `true` locally, for development. - let addAccountFlowEnabled = false - init(store: UserDefaultsProtocol) { self.store = store } - func userSettings(for userID: String) -> UserSettings { - UserSettings(appSettings: self, accountSettings: .init(userID: userID, store: store)) - } - static func volatile() -> AppSettings { AppSettings(store: VolatileUserDefaults()) } } + +nonisolated extension AppSettings: CommonSettingsProtocol { } diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 3343081f8..3304baee9 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -9,7 +9,6 @@ import AVFoundation import CallKit import Combine -import ElementCall import Foundation import MatrixRustSDK import PushKit @@ -27,14 +26,13 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe let roomID: String let rtcNotificationID: String? let isVoiceCall: Bool - var isNative = false } - private let appSettings: AppSettings private let pushRegistry: PKPushRegistry - private let callController: CXCallControllerProtocol + private let callController = CXCallController() private let callProvider: CXProviderProtocol private let timeProvider: TimeProvider + private let appSettings: AppSettings private weak var clientProxy: ClientProxyProtocol? { didSet { @@ -54,10 +52,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var endUnansweredCallTask: Task? - /// A call answered for the native stack, until the stack takes it over. - private var answeredNativeCallID: CallID? - private var endPendingCallTask: Task? - private var ongoingCallID: CallID? { didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) } } @@ -74,26 +68,11 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var declineListenerHandle: TaskHandle? - /// The native call stack, for as long as there's a session running calls through it. - private var nativeCallStack: NativeCallStack? - private var nativeCallCancellable: AnyCancellable? - private var mediaProvider: MediaProviderProtocol? - - var nativeCallController: ElementCallController? { - nativeCallStack?.controller - } - - private var cancellables = Set() - - init(appSettings: AppSettings, - callProvider: CXProviderProtocol? = nil, - callController: CXCallControllerProtocol? = nil, - timeProvider: TimeProvider? = nil) { - self.appSettings = appSettings - self.callController = callController ?? CXCallController() + init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil, appSettings: AppSettings) { pushRegistry = PKPushRegistry(queue: nil) self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init) + self.appSettings = appSettings if let callProvider { self.callProvider = callProvider @@ -118,145 +97,45 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe pushRegistry.desiredPushTypes = [.voIP] self.callProvider.setDelegate(self, queue: nil) - - appSettings.nativeCallEnabledPublisher - .sink { [weak self] _ in - self?.updateNativeCallStack() - } - .store(in: &cancellables) } - func setUserSession(_ userSession: UserSessionProtocol?) { - // A stack is built against one client proxy and can't be handed to another, which is what a - // soft logout or a cache clear brings: a new session without the sign out that drops this one. - if userSession?.clientProxy !== clientProxy { - stopNativeCallStack() - } - - clientProxy = userSession?.clientProxy - mediaProvider = userSession?.mediaProvider - - updateNativeCallStack() + func setClientProxy(_ clientProxy: any ClientProxyProtocol) { + self.clientProxy = clientProxy } - @discardableResult - func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool { - guard let nativeCallStack else { - return false - } - - nativeCallStack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: isVoiceCall) - return true - } - - func minimizeNativeCall() { - nativeCallStack?.minimize() - } - - func restoreNativeCall() { - nativeCallStack?.restore() - } - - func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async { - // Unlike the push handling below, this always runs with a session, so the stack itself is - // the answer rather than the setting that would have built one. - let isNative = nativeCallStack != nil - MXLog.info("Setting up a call session, native: \(isNative), video: \(isVideo), replacing an ongoing call: \(ongoingCallID != nil)") - + func setupCallSession(roomID: String, roomDisplayName: String) async { // Drop any ongoing calls when starting a new one if ongoingCallID != nil { - tearDownCallSession(sendEndCallAction: true) + tearDownCallSession() } // If this starting from a ring reuse those identifiers // Make sure the roomID matches - var callID = if let incomingCallID, incomingCallID.roomID == roomID { + let callID = if let incomingCallID, incomingCallID.roomID == roomID { incomingCallID } else { - CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: !isVideo) + CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: false) } - callID.isNative = isNative - let isAnsweringIncomingCall = callID.rtcNotificationID != nil clearIncomingCallState() ongoingCallID = callID - // A web view call must not be tracked by CallKit, as that gives this process exclusive - // access to media and the web view runs in another (see `CXAnswerCallAction`). + // Don't bother starting another CallKit session as it won't work properly // https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022 - guard isNative else { - return - } - // Answering the ringing call: it is already reported, keep it. - guard !isAnsweringIncomingCall else { - return - } + // let handle = CXHandle(type: .generic, value: roomDisplayName) + // let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle) + // startCallAction.isVideo = true - let handle = CXHandle(type: .generic, value: roomID) - let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle) - startCallAction.contactIdentifier = roomDisplayName - startCallAction.isVideo = isVideo - - // Awaited because our membership goes out straight after: a peer that sees it before the - // system knows about the call reads the call as answered elsewhere. - await requestTransaction(CXTransaction(action: startCallAction), describedAs: "start call") - - // Without this the system call UI shows the handle, i.e. the room ID. - let update = CXCallUpdate() - update.localizedCallerName = roomDisplayName - update.remoteHandle = handle - update.hasVideo = isVideo - callProvider.reportCall(with: callID.callKitID, updated: update) + // do { + // try await callController.request(CXTransaction(action: startCallAction)) + // } catch { + // MXLog.error("Failed requesting start call action with error: \(error)") + // } } - func reportCallSessionConnected(roomID: String) { - guard let ongoingCallID, ongoingCallID.roomID == roomID else { - // The system ends an outgoing call that never reports connecting, so a silent guard - // here shows up much later as a call that hangs itself up. - MXLog.warning("Not reporting the call as connected, no matching ongoing call") - return - } - - guard ongoingCallID.isNative else { - MXLog.info("Not reporting a web view call as connected, CallKit doesn't track it") - return - } - - MXLog.info("Reporting the call as connected") - callProvider.reportOutgoingCall(with: ongoingCallID.callKitID, connectedAt: nil) - } - - func tearDownCallSession(roomID: String) { - if ongoingCallID?.roomID == roomID { - tearDownCallSession(sendEndCallAction: true) - return - } - - // Answered and handed over, but the stack couldn't take it. Ending it here rather than - // waiting for the watchdog takes the system call UI away straight away. - if answeredNativeCallID?.roomID == roomID { - MXLog.warning("Ending an answered call the native call stack didn't take over") - endPendingCall() - return - } - - MXLog.info("Not tearing down the call session, no call for room \(roomID)") - } - - func reportCallSessionEnded(roomID: String, reason: CXCallEndedReason) { - guard let ongoingCallID, ongoingCallID.roomID == roomID else { - MXLog.info("Not reporting the end of a call for \(roomID), it isn't the ongoing call") - return - } - - // A web view call is not tracked by CallKit, as in `reportCallSessionConnected`. - if ongoingCallID.isNative { - MXLog.info("Reporting the call as ended without us: \(reason.rawValue)") - callProvider.reportCall(with: ongoingCallID.callKitID, endedAt: nil, reason: reason) - } - - self.ongoingCallID = nil + func tearDownCallSession() { + tearDownCallSession(sendEndCallAction: true) } func setAudioEnabled(_ enabled: Bool, roomID: String) { @@ -278,60 +157,18 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe } } - // MARK: - Native calls - - /// Builds or releases the call stack to match the session and the setting. - private func updateNativeCallStack() { - guard appSettings.nativeCallEnabled, let clientProxy else { - stopNativeCallStack() - return - } - - guard nativeCallStack == nil else { return } - - guard let transport = clientProxy.makeNativeCallTransport() else { - MXLog.error("Cannot start the native call stack without a transport") - return - } - - let style = ElementCallStyle(theme: NativeCallTheme(), - icons: NativeCallIcons(), - avatars: NativeCallAvatars(mediaProvider: mediaProvider), - strings: .init(you: L10n.commonYou, - error: L10n.commonError, - stop: L10n.actionStop, - back: L10n.actionBack)) - - // The stats overlay is raw RTP counters, so it follows whatever already reveals developer - // surface. Screen sharing stays off until the broadcast extension that makes it work lands. - let options = ElementCallOptions(isDeveloperModeEnabled: appSettings.developerOptionsEnabled) - - let stack = NativeCallStack(transport: transport, - system: NativeCallSystem(service: self), - options: options, - style: style) - nativeCallStack = stack - - nativeCallCancellable = stack.actions - .sink { [weak self] presentation in - self?.actionsSubject.send(.nativeCall(presentation)) - } - - Task { await stack.start() } - } - - private func stopNativeCallStack() { - guard let nativeCallStack else { return } - self.nativeCallStack = nil - nativeCallCancellable = nil - - nativeCallStack.stop() - actionsSubject.send(.nativeCall(.dismiss)) - } - // MARK: - PKPushRegistryDelegate - func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { } + func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { + // HealthChat: stored for the NSE, which requests a VoIP push through the push gateway + // for ringing calls as long as the notification filtering entitlement is missing. + appSettings.pushKitDeviceToken = pushCredentials.token.base64EncodedString() + appSettings.voipPushGatewayNotifyURL = appSettings.pushGatewayNotifyEndpoint.absoluteString + } + + func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { + appSettings.pushKitDeviceToken = nil + } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else { @@ -363,7 +200,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID, isVoiceCall: isVoiceCall) incomingCallID = callID - guard let expirationDate = (payload.dictionaryPayload[ElementCallServiceNotificationKey.expirationDate.rawValue] as? Date) else { + guard let expirationDate = Self.expirationDate(from: payload.dictionaryPayload) else { MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)") assertionFailure("Incoming voip push is missing the expiration timestamp") clearIncomingCallState() @@ -389,7 +226,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // If not for audio call the app will not be put to foreground and the webview won't be able to handle the call... // Consequence: The call will be presented to the user as a video call in CallKit UI, // but once Element Call is launched it will correctly route to a voice-only call. - update.hasVideo = appSettings.nativeCallEnabled ? !isVoiceCall : true + update.hasVideo = true update.localizedCallerName = roomDisplayName // https://stackoverflow.com/a/41230020/730924 update.remoteHandle = .init(type: .generic, value: roomID) @@ -427,19 +264,10 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { MXLog.info("Call provider did activate audio session") - actionsSubject.send(.audioSessionActivated) } func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { MXLog.info("Call provider did deactivate audio session") - actionsSubject.send(.audioSessionDeactivated) - } - - func provider(_ provider: CXProvider, perform action: CXStartCallAction) { - // The native controller configured the audio session already; CallKit activates it and - // reports back through `didActivate`. - action.fulfill() - callProvider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) } func providerDidReset(_ provider: CXProvider) { @@ -470,15 +298,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // First fullfill the action action.fulfill() - if appSettings.nativeCallEnabled { - // The native stack owns media in this process, so the CallKit call stays up: the - // controller attaches to it in `setupCallSession`. - endUnansweredCallTask?.cancel() - endCallIfLeftPending(incomingCallID) - actionsSubject.send(.startCall(roomID: incomingCallID.roomID, isVoiceCall: incomingCallID.isVoiceCall)) - return - } - // And delay ending the call so that the app has enough time // to get deeplinked into DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { @@ -494,7 +313,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe if let ongoingCallID { actionsSubject.send(.setAudioEnabled(!action.isMuted, roomID: ongoingCallID.roomID)) } else { - MXLog.info("Ignoring a mute action without an ongoing call") + MXLog.error("Failed muting/unmuting call, missing ongoingCallID") } action.fulfill() @@ -505,56 +324,30 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // This gets called for no reason on simulators, where CallKit // isn't even supported, ignore it. #else - endCall(withCallKitID: action.callUUID) + if let ongoingCallID { + actionsSubject.send(.endCall(roomID: ongoingCallID.roomID)) + } + + if let incomingCallID { + Task { + await sendDeclineCallEvent(incomingCallID) + } + } + + if incomingCallID?.callKitID == action.callUUID { + clearIncomingCallState() + } + + tearDownCallSession(sendEndCallAction: false) + action.fulfill() #endif } - /// Ends the call the system identified, whether that's the ongoing one, the ringing one, or - /// neither because it has already been replaced. - /// - /// Kept out of the delegate method so that it's reachable on the simulator, where the provider - /// performs end call actions of its own and the delegate has to ignore them. - func endCall(withCallKitID callKitID: UUID) { - MXLog.info("Ending the call for \(callKitID), ongoing: \(ongoingCallID != nil), incoming: \(incomingCallID != nil)") - - // Both branches are gated on the identifier. Leaving a call to start one in another room - // requests an end call action for the old call and replaces `ongoingCallID` before the - // system gets round to performing it, so an ungated handler reads it as the new call ending - // and hangs up the call that was just started. - if let ongoingCallID, ongoingCallID.callKitID == callKitID { - actionsSubject.send(.endCall(roomID: ongoingCallID.roomID)) - tearDownCallSession(sendEndCallAction: false) - } - - if let incomingCallID, incomingCallID.callKitID == callKitID { - Task { - await sendDeclineCallEvent(incomingCallID) - } - - clearIncomingCallState() - } - } - // MARK: - Private - /// Bridges the controller's completion handler, which is all ``CXCallControllerProtocol`` can - /// carry, back to an awaitable call. - private func requestTransaction(_ transaction: CXTransaction, describedAs description: String) async { - await withCheckedContinuation { continuation in - callController.request(transaction) { error in - if let error { - MXLog.error("Failed requesting \(description) action with error: \(error)") - } - - continuation.resume() - } - } - } - private func tearDownCallSession(sendEndCallAction: Bool = true) { if sendEndCallAction, let ongoingCallID { - MXLog.info("Requesting an end call action for the ongoing call") let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID)) callController.request(transaction) { error in if let error { @@ -571,6 +364,19 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe /// a call and immediately end it. `callerInfo` names the call after the room so the brief /// system UI and the Recents entry show the room rather than "Unknown", and the entry can be /// tapped to call back. + /// CallKit hands the NSE's payload over as-is, so the expiration arrives as a `Date`. A VoIP push + /// requested from the push gateway travels as JSON, where the same key is seconds since 1970. + private static func expirationDate(from payload: [AnyHashable: Any]) -> Date? { + switch payload[ElementCallServiceNotificationKey.expirationDate.rawValue] { + case let date as Date: + date + case let secondsSince1970 as TimeInterval: + Date(timeIntervalSince1970: secondsSince1970) + default: + nil + } + } + private func reportImmediatelyEndedCall(reason: CXCallEndedReason, callerInfo: (roomID: String, roomDisplayName: String?)? = nil, completion: @escaping () -> Void) { @@ -599,32 +405,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe } } - /// Ends an answered call that the native stack never took over, which nothing else would: the - /// call is deliberately left up for the controller to attach to in `setupCallSession`. - private func endCallIfLeftPending(_ callID: CallID) { - answeredNativeCallID = callID - - endPendingCallTask = Task { [weak self] in - try? await self?.timeProvider.clock.sleep(for: .seconds(30)) - - guard let self, !Task.isCancelled, answeredNativeCallID == callID else { - return - } - - MXLog.error("The native call stack never took over the answered call, ending it") - endPendingCall() - } - } - - private func endPendingCall() { - guard let answeredNativeCallID else { - return - } - - callProvider.reportCall(with: answeredNativeCallID.callKitID, endedAt: nil, reason: .failed) - clearIncomingCallState() - } - private func sendDeclineCallEvent(_ incomingCallID: CallID) async { guard let rtcNotificationID = incomingCallID.rtcNotificationID else { MXLog.info("No rtc notification event to decline.") @@ -726,9 +506,6 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private func clearIncomingCallState() { endUnansweredCallTask?.cancel() endUnansweredCallTask = nil - endPendingCallTask?.cancel() - endPendingCallTask = nil - answeredNativeCallID = nil declineListenerHandle?.cancel() declineListenerHandle = nil incomingCallRoomInfoCancellable = nil diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index 847f3f1ac..85762bed0 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -256,13 +256,62 @@ nonisolated class NotificationHandler { try await CXProvider.reportNewIncomingVoIPPushPayload(payload) MXLog.info("Call notification delegated to CallKit") } catch { - MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification") + // HealthChat: without the notification filtering entitlement CallKit rejects the hand-off, + // so ask the push gateway for a PushKit push instead and keep the alert visible. + MXLog.error("Failed reporting voip call with error: \(error). Requesting a VoIP push instead") + await requestVoIPPush(roomID: roomID, + roomDisplayName: roomDisplayName, + rtcNotifyEventID: rtcNotifyEventID, + expirationDate: expirationDate, + isVoiceCall: callIntent == RtcCallIntent.audio) return .shouldDisplay } return .processedShouldDiscard } + /// Asks the push gateway to deliver a PushKit push with the call details, the way the legacy + /// element-ios app did. The app's `PKPushRegistryDelegate` then reports the call to CallKit. + private func requestVoIPPush(roomID: String, roomDisplayName: String, rtcNotifyEventID: String, expirationDate: Date, isVoiceCall: Bool) async { + guard let token = settings.pushKitDeviceToken, + let notifyURL = settings.voipPushGatewayNotifyURL.flatMap(URL.init(string:)) else { + MXLog.warning("No PushKit token or push gateway stored by the app, cannot request a VoIP push") + return + } + + // The gateway merges `default_payload` into the push verbatim, so the keys match what the app expects. + let defaultPayload: [String: Any] = [ElementCallServiceNotificationKey.roomID.rawValue: roomID, + ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName, + ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID, + ElementCallServiceNotificationKey.expirationDate.rawValue: expirationDate.timeIntervalSince1970, + ElementCallServiceNotificationKey.isVoiceCall.rawValue: isVoiceCall] + let body: [String: Any] = ["notification": ["event_id": rtcNotifyEventID, + "room_id": roomID, + "prio": "high", + "devices": [["app_id": settings.voipPusherAppID, + "pushkey": token, + "data": ["format": "event_id_only", + "default_payload": defaultPayload]]]]] + + var request = URLRequest(url: notifyURL, timeoutInterval: 5) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let rejected = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["rejected"] as? [String] ?? [] + guard statusCode == 200, rejected.isEmpty else { + MXLog.error("Push gateway refused the VoIP push: status \(statusCode), rejected \(rejected.count) pushkey(s)") + return + } + MXLog.info("Requested a VoIP push for the call in \(roomID)") + } catch { + MXLog.error("Failed requesting a VoIP push: \(error)") + } + } + private enum NotificationProcessingResult { case shouldDisplay case processedShouldDiscard diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index 9856cf009..ff6a00801 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -6,46 +6,36 @@ // import CallKit -import ElementCall @testable import ElementX import PushKit import Testing @MainActor final class ElementCallServiceTests { - private var appSettings: AppSettings! private var callProvider: CXProviderMock! - private var callController: CXCallControllerMock! private var currentDate: Date! private var clock: ManualClock! private var pushRegistry: PKPushRegistry! private var service: ElementCallService! - private var userSession: UserSessionMock! init() { - appSettings = AppSettings.volatile() pushRegistry = PKPushRegistry(queue: nil) callProvider = CXProviderMock(.init()) - callController = CXCallControllerMock(.init()) currentDate = Date() clock = ManualClock() let dateProvider: () -> Date = { self.currentDate } - service = ElementCallService(appSettings: appSettings, - callProvider: callProvider, - callController: callController, - timeProvider: TimeProvider(clock: clock, now: dateProvider)) + service = ElementCallService(callProvider: callProvider, + timeProvider: TimeProvider(clock: clock, now: dateProvider), + appSettings: AppSettings.volatile()) } isolated deinit { - appSettings = nil callProvider = nil - callController = nil currentDate = nil clock = nil pushRegistry = nil - userSession = nil } @Test @@ -69,6 +59,21 @@ final class ElementCallServiceTests { } } + /// HealthChat: a VoIP push requested from the push gateway carries the expiration as JSON seconds. + @Test + func incomingCallFromPushGatewayVoIPPush() async { + await waitForConfirmation { confirmation in + let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(secondsSince1970: currentDate.timeIntervalSince1970 + 30) + + service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) { + confirmation() + } + } + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + #expect(!callProvider.reportCallWithEndedAtReasonCalled) + } + @Test func incomingVoiceCall() async { #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) @@ -194,7 +199,7 @@ final class ElementCallServiceTests { } @Test - func setupCallSessionCancelsPendingUnansweredTimeout() async throws { + func setupCallSessionCancelsPendingUnansweredTimeout() async { // Schedule the 60s unanswered timer via an incoming push await waitForConfirmation { confirmation in let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) @@ -208,20 +213,22 @@ final class ElementCallServiceTests { // Simulate the answer flow handing off to setupCallSession, which must cancel // the pending endUnansweredCallTask as part of clearing the incoming state. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") - let (endedCalls, endedCallsContinuation) = AsyncStream.makeStream() + var unansweredFired = false callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in - endedCallsContinuation.yield(reason) + if reason == .unanswered { + unansweredFired = true + } } - let deferredUnansweredCall = deferFailure(endedCalls, - timeout: .milliseconds(100), - message: "endUnansweredCallTask should have been cancelled by setupCallSession") { $0 == .unanswered } // Advance past what would have been the 60s unanswered timeout clock.advance(by: .seconds(120)) + for _ in 0..<3 { + await Task.yield() + } - try await deferredUnansweredCall.fulfill() + #expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession") } @Test @@ -239,7 +246,7 @@ final class ElementCallServiceTests { @Test func duplicateRoomPushReportsCallAsHandled() async { // A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30) await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere) @@ -250,271 +257,6 @@ final class ElementCallServiceTests { #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") } - @Test - func webViewCallSessionIsNotTrackedByCallKit() async { - // CallKit tracking a call gives this process exclusive media access, which starves the web - // view. So a web view call is only ever tracked on the app side. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) - service.reportCallSessionConnected(roomID: "!room:example.com") - - #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") - #expect(!callProvider.reportCallWithUpdatedCalled) - #expect(!callProvider.reportOutgoingCallWithConnectedAtCalled) - } - - @Test - func endingAReplacedCallLeavesTheCallThatReplacedItUp() async throws { - enableNativeCalls() - - // Leaving a call to start one in another room requests an end call action for the first, - // then starts the second before the system performs it. - await service.setupCallSession(roomID: "!first:example.com", roomDisplayName: "first", isVideo: true) - let firstCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) - - service.tearDownCallSession(roomID: "!first:example.com") - await service.setupCallSession(roomID: "!second:example.com", roomDisplayName: "second", isVideo: true) - - var endedRooms: [String] = [] - let cancellable = service.actions.sink { action in - if case .endCall(let roomID) = action { - endedRooms.append(roomID) - } - } - defer { cancellable.cancel() } - - service.endCall(withCallKitID: firstCallID) - - #expect(endedRooms.isEmpty, "The first call's end action must not hang up the second call") - #expect(service.ongoingCallRoomIDPublisher.value == "!second:example.com") - - // And the second call still ends when it's the one the system means. - let secondCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) - service.endCall(withCallKitID: secondCallID) - - #expect(endedRooms == ["!second:example.com"]) - #expect(service.ongoingCallRoomIDPublisher.value == nil) - } - - /// A declined or unanswered call is the system's to log as such: reported with its reason, and - /// never as an end call action, which would log it as one we hung up. - @Test - func aCallThatEndedWithoutUsIsReportedRatherThanEnded() async throws { - enableNativeCalls() - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) - let callID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) - let requestsBeforeTheEnd = callController.requestCompletionCallsCount - - service.reportCallSessionEnded(roomID: "!other:example.com", reason: .unanswered) - #expect(!callProvider.reportCallWithEndedAtReasonCalled, "Another room's end is not this call's") - - service.reportCallSessionEnded(roomID: "!room:example.com", reason: .unanswered) - - #expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.uuid == callID) - #expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason == .unanswered) - #expect(callController.requestCompletionCallsCount == requestsBeforeTheEnd) - #expect(service.ongoingCallRoomIDPublisher.value == nil) - } - - @Test - func tearingDownAnotherRoomLeavesTheOngoingCallUp() async { - // A call screen that is dismissed after another room's call replaced it must not take the - // new call down with it. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) - - service.tearDownCallSession(roomID: "!other:example.com") - #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") - - service.tearDownCallSession(roomID: "!room:example.com") - #expect(service.ongoingCallRoomIDPublisher.value == nil) - } - - // MARK: - Native calls - - @Test - func nativeModeReportsVoiceCallsWithoutVideo() async { - appSettings.nativeCallEnabled = true - - await reportIncomingPush(isVoiceCall: true) - - #expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == false) - } - - @Test - func nativeModeStillReportsVideoCallsWithVideo() async { - appSettings.nativeCallEnabled = true - - await reportIncomingPush(isVoiceCall: false) - - #expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == true) - } - - @Test - func answeringInNativeModeKeepsTheCallUp() async throws { - appSettings.nativeCallEnabled = true - await reportIncomingPush(isVoiceCall: false) - await clock.waitForScheduledSleep() - - try await answerReportedCall() - - // The webview path ends the call a second after answering so that `setupCallSession` can - // start a new one. The native stack takes this one over instead, so it has to survive. - #expect(!callProvider.reportCallWithEndedAtReasonCalled) - } - - @Test - func answeredNativeCallEndsWhenNothingTakesItOver() async throws { - appSettings.nativeCallEnabled = true - await reportIncomingPush(isVoiceCall: false) - await clock.waitForScheduledSleep() - - let answeredUUID = try await answerReportedCall() - - let (endedCalls, endedCallsContinuation) = AsyncStream<(UUID, CXCallEndedReason)>.makeStream() - callProvider.reportCallWithEndedAtReasonClosure = { uuid, _, reason in - endedCallsContinuation.yield((uuid, reason)) - } - let deferredEndedCall = deferFulfillment(endedCalls) { _ in true } - - // Answering swaps the unanswered timer for the watchdog that catches a hand-off that never - // happens — the room isn't joined, there's no transport, a call is already running. - #expect(await clock.waitForScheduledSleep() == .seconds(30)) - clock.advance(by: .seconds(30)) - - let (uuid, reason) = try await deferredEndedCall.fulfill() - #expect(uuid == answeredUUID) - #expect(reason == .failed) - } - - @Test - func nativeCallStackTakesOverTheAnsweredCall() async throws { - enableNativeCalls() - await reportIncomingPush(isVoiceCall: false) - await clock.waitForScheduledSleep() - - try await answerReportedCall() - - // Answering starts the watchdog, so wait for it before asserting that the hand-over stops it. - await clock.waitForScheduledSleep() - - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) - - // Taking the answered call over means keeping its identifier rather than reporting a new - // outgoing call, which is what the update below would be part of. - #expect(!callProvider.reportCallWithUpdatedCalled) - #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") - - // And the watchdog must be gone, otherwise it ends the call that was just taken over. - #expect(!clock.hasPendingSleep, "The watchdog must not outlive the hand-over") - } - - @Test - func nativeCallStackForAnotherRoomReportsANewCall() async throws { - enableNativeCalls() - await reportIncomingPush(isVoiceCall: false) - await clock.waitForScheduledSleep() - - let answeredUUID = try await answerReportedCall() - - await service.setupCallSession(roomID: "!other:example.com", roomDisplayName: "other", isVideo: true) - - let update = try #require(callProvider.reportCallWithUpdatedReceivedArguments) - #expect(update.uuid != answeredUUID) - #expect(update.update.localizedCallerName == "other") - #expect(service.ongoingCallRoomIDPublisher.value == "!other:example.com") - - // Unlike a web view call, a native one is tracked by CallKit, so it gets the connected report too. - service.reportCallSessionConnected(roomID: "!other:example.com") - #expect(callProvider.reportOutgoingCallWithConnectedAtReceivedArguments?.uuid == update.uuid) - } - - @Test - func nativeCallStackNeedsBothTheSettingAndASession() { - #expect(service.nativeCallController == nil) - - appSettings.nativeCallEnabled = true - #expect(service.nativeCallController == nil, "The setting alone has no transport to run a call over") - - service.setUserSession(nativeUserSession()) - #expect(service.nativeCallController != nil) - - service.setUserSession(nil) - #expect(service.nativeCallController == nil, "Signing out has to release the stack's to-device subscription") - } - - @Test - func turningTheSettingOnMidSessionBuildsTheStack() { - service.setUserSession(nativeUserSession()) - #expect(service.nativeCallController == nil) - - // The transport is built on demand, so the setting takes effect without an app restart. - appSettings.nativeCallEnabled = true - #expect(service.nativeCallController != nil) - } - - @Test - func aNewSessionGetsANewStack() { - appSettings.nativeCallEnabled = true - service.setUserSession(nativeUserSession()) - let firstController = service.nativeCallController - - // A soft logout or a cache clear brings a new session without the sign out that drops this - // one, and the stack is built against the client proxy it was given. - service.setUserSession(nativeUserSession()) - - #expect(service.nativeCallController !== firstController) - } - - @Test - func callsFallBackToTheWebViewWithoutAStack() { - let roomProxy = JoinedRoomProxyMock(.init(id: "!room:example.com")) - - #expect(!service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false)) - - appSettings.nativeCallEnabled = true - service.setUserSession(nativeUserSession()) - - #expect(service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false)) - } - - /// Builds a session whose client proxy can serve the native call stack, and holds on to it: the - /// service only keeps a weak reference, as the app owns the session. - private func nativeUserSession() -> UserSessionMock { - let clientProxy = ClientProxyMock(.init()) - clientProxy.makeNativeCallTransportReturnValue = ElementCallFakeTransport() - let session = UserSessionMock(.init(clientProxy: clientProxy)) - userSession = session - return session - } - - /// Puts the service in native mode, with the stack the CallKit paths check for. - private func enableNativeCalls() { - appSettings.nativeCallEnabled = true - service.setUserSession(nativeUserSession()) - } - - /// Delivers a VoIP push and waits for it to be reported, returning once the call is ringing. - private func reportIncomingPush(isVoiceCall: Bool) async { - await waitForConfirmation { confirmation in - let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) - .updateIsVoice(isVoiceCall) - - service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) { - confirmation() - } - } - } - - /// Answers the call the provider last reported, returning its identifier. - @discardableResult - private func answerReportedCall() async throws -> UUID { - let uuid = try #require(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid) - let provider = CXProvider(configuration: CXProviderConfiguration()) - - service.provider(provider, perform: CXAnswerCallAction(call: uuid)) - - return uuid - } - private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock, expectedReason: CXCallEndedReason) async { let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount @@ -555,6 +297,11 @@ private nonisolated class PKPushPayloadMock: PKPushPayload { return self } + func updatingExpiration(secondsSince1970: TimeInterval) -> Self { + dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = secondsSince1970 + return self + } + func updateIsVoice(_ isVoice: Bool) -> Self { dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice return self