feat(healthchat): ring calls via gateway VoIP push

CallKit rejects the NSE hand-off without the filtering entitlement.
This commit is contained in:
Sedat Qaja
2026-10-12 11:42:53 +02:00
parent 71abd2097e
commit 2d86c4b92c
4 changed files with 230 additions and 607 deletions
@@ -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<TraceLogPack> { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
var contentScannerURL: RemotePreference<URL?> { get }
var forceDisableE2EE: RemotePreference<Bool> { get }
var mapTilerConfiguration: RemotePreference<MapTilerConfiguration> { 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 { }