From 156231f8aab8262cadf0651bd3e4379658889d21 Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Mon, 13 Apr 2026 13:38:39 +0000 Subject: [PATCH] Handle VoIP and APNs push received before shared filesystem is available (BFU) Delay CoreContext initialization until AppServices.config is available. When a VoIP push arrives before that, an EarlyPushkitDelegate reports a temporary CallKit call, ends it as unanswered after 4s, and posts a missed call notification to the user. Handle message APNs in service extension without Core/Config availability --- Linphone/Core/CoreContext.swift | 33 +++++--- Linphone/LinphoneApp.swift | 72 +++++++++++++---- .../Localizable/en.lproj/Localizable.strings | 3 + .../Localizable/fr.lproj/Localizable.strings | 3 + Linphone/SplashScreen.swift | 8 +- .../TelecomManager/EarlyPushkitDelegate.swift | 81 +++++++++++++++++++ LinphoneApp.xcodeproj/project.pbxproj | 4 + .../NotificationService.swift | 25 +++--- 8 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 Linphone/TelecomManager/EarlyPushkitDelegate.swift diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index b73fb63b0..12d054ce8 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -566,15 +566,30 @@ class CoreContext: ObservableObject { } enum AppServices { - static let config = Config.newForSharedCore( - appGroupId: Bundle.main.object(forInfoDictionaryKey: "APP_GROUP_NAME") as? String - ?? { - fatalError("APP_GROUP_NAME not defined in Info.plist") - }(), - configFilename: "linphonerc", - factoryConfigFilename: FileUtil.bundleFilePath("linphonerc-factory") - )! - + private static var _config: Config? + + static var configIfAvailable: Config? { + if let existing = _config { + return existing + } + _config = Config.newForSharedCore( + appGroupId: Bundle.main.object(forInfoDictionaryKey: "APP_GROUP_NAME") as? String + ?? { + fatalError("APP_GROUP_NAME not defined in Info.plist") + }(), + configFilename: "linphonerc", + factoryConfigFilename: FileUtil.bundleFilePath("linphonerc-factory") + ) + return _config + } + + static var config: Config { + guard let config = configIfAvailable else { + fatalError("AppServices.config accessed before it was available") + } + return config + } + static let corePreferences = CorePreferences(config: config) } diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 9827e0693..4dfdb137c 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -21,6 +21,7 @@ import SwiftUI import linphonesw import UserNotifications import Intents +import PushKit let accountTokenNotification = Notification.Name("AccountCreationTokenReceived") var displayedChatroomPeerAddr: String? @@ -151,28 +152,69 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele @main struct LinphoneApp: App { - @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @State private var configAvailable = AppServices.configIfAvailable != nil + private let earlyPushDelegate = EarlyPushkitDelegate() + private let voipRegistry = PKPushRegistry(queue: coreQueue) + + init() { + if !configAvailable { + voipRegistry.delegate = earlyPushDelegate + voipRegistry.desiredPushTypes = [.voIP] + waitForConfig() + } else { + let _ = CoreContext.shared + } + } + + var body: some Scene { + WindowGroup { + if configAvailable { + AppView(delegate: delegate) + } else { + SplashScreen(showSpinner: true) + .onAppear { + waitForConfig() + } + } + } + } + + private func waitForConfig() { + if AppServices.configIfAvailable != nil { + let _ = CoreContext.shared + configAvailable = true + } else { + Log.warn("AppServices.config not available yet, retrying in 1s...") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + waitForConfig() + } + } + } +} + +struct AppView: View { + @Environment(\.scenePhase) var scenePhase + let delegate: AppDelegate + @StateObject private var coreContext = CoreContext.shared @StateObject private var navigationManager = NavigationManager() @StateObject private var telecomManager = TelecomManager.shared @StateObject private var sharedMainViewModel = SharedMainViewModel.shared - var body: some Scene { - WindowGroup { - RootView( - coreContext: coreContext, - telecomManager: telecomManager, - sharedMainViewModel: sharedMainViewModel, - navigationManager: navigationManager, - appDelegate: delegate - ) - .environmentObject(coreContext) - .environmentObject(navigationManager) - .environmentObject(telecomManager) - .environmentObject(sharedMainViewModel) - } + var body: some View { + RootView( + coreContext: coreContext, + telecomManager: telecomManager, + sharedMainViewModel: sharedMainViewModel, + navigationManager: navigationManager, + appDelegate: delegate + ) + .environmentObject(coreContext) + .environmentObject(navigationManager) + .environmentObject(telecomManager) + .environmentObject(sharedMainViewModel) .onChange(of: scenePhase) { newPhase in if !telecomManager.callInProgress { switch newPhase { diff --git a/Linphone/Localizable/en.lproj/Localizable.strings b/Linphone/Localizable/en.lproj/Localizable.strings index 3fc91a12c..f9ac3f3e8 100644 --- a/Linphone/Localizable/en.lproj/Localizable.strings +++ b/Linphone/Localizable/en.lproj/Localizable.strings @@ -616,3 +616,6 @@ "welcome_page_title" = "Welcome"; "You will change this mode later" = "You will change this mode later"; "ZRTP" = "ZRTP"; +"early_push_unknown_caller" = "Unknown"; +"early_push_missed_call_title" = "Missed call"; +"early_push_missed_call_body" = "You received a call while your device was locked. Please unlock and reopen the app."; diff --git a/Linphone/Localizable/fr.lproj/Localizable.strings b/Linphone/Localizable/fr.lproj/Localizable.strings index 77015b251..d1cab3098 100644 --- a/Linphone/Localizable/fr.lproj/Localizable.strings +++ b/Linphone/Localizable/fr.lproj/Localizable.strings @@ -616,3 +616,6 @@ "welcome_page_title" = "Bienvenue"; "You will change this mode later" = "You will change this mode later"; "ZRTP" = "ZRTP"; +"early_push_unknown_caller" = "Inconnu"; +"early_push_missed_call_title" = "Appel manqué"; +"early_push_missed_call_body" = "Vous avez reçu un appel alors que votre appareil était verrouillé. Veuillez déverrouiller et rouvrir l'application."; diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index e0959326f..ee57021e3 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -20,17 +20,23 @@ import SwiftUI struct SplashScreen: View { + var showSpinner: Bool = false + var body: some View { ZStack { Color.white .ignoresSafeArea() - + Image("linphone") .resizable() .renderingMode(.template) .aspectRatio(contentMode: .fit) .frame(width: 240, height: 128) .foregroundColor(ThemeManager.shared.currentTheme.main500) + + if showSpinner { + ProgressView() + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea(.all) diff --git a/Linphone/TelecomManager/EarlyPushkitDelegate.swift b/Linphone/TelecomManager/EarlyPushkitDelegate.swift new file mode 100644 index 000000000..cc875a32f --- /dev/null +++ b/Linphone/TelecomManager/EarlyPushkitDelegate.swift @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import PushKit +import CallKit +import UserNotifications + +class EarlyPushkitDelegate: NSObject, PKPushRegistryDelegate, CXProviderDelegate { + private var activeCalls: [UUID: CXProvider] = [:] + + func providerDidReset(_ provider: CXProvider) {} + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + Log.info("[EarlyPushkitDelegate] User tried to answer, ending call as device is locked") + action.fail() + provider.reportCall(with: action.callUUID, endedAt: .init(), reason: .unanswered) + activeCalls.removeValue(forKey: action.callUUID) + postMissedCallNotification() + } + + func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { + Log.info("[EarlyPushkitDelegate] Received push credentials, ignoring until core is ready") + } + + func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + Log.info("[EarlyPushkitDelegate] Received incoming push while core is not ready, reporting call to CallKit") + let providerConfig = CXProviderConfiguration() + providerConfig.supportsVideo = false + let provider = CXProvider(configuration: providerConfig) + provider.setDelegate(self, queue: .main) + + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: NSLocalizedString("early_push_unknown_caller", comment: "")) + update.hasVideo = false + let uuid = UUID() + activeCalls[uuid] = provider + + provider.reportNewIncomingCall(with: uuid, update: update) { error in + if let error = error { + Log.error("[EarlyPushkitDelegate] Failed to report call to CallKit: \(error.localizedDescription)") + } + completion() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in + guard let self = self, let provider = self.activeCalls.removeValue(forKey: uuid) else { return } + Log.info("[EarlyPushkitDelegate] Ending unanswered call after timeout") + provider.reportCall(with: uuid, endedAt: .init(), reason: .unanswered) + self.postMissedCallNotification() + } + } + + private func postMissedCallNotification() { + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("early_push_missed_call_title", comment: "") + content.body = NSLocalizedString("early_push_missed_call_body", comment: "") + content.sound = .default + let request = UNNotificationRequest(identifier: "early_push_missed_call", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + Log.error("[EarlyPushkitDelegate] Failed to post missed call notification: \(error.localizedDescription)") + } + } + } +} diff --git a/LinphoneApp.xcodeproj/project.pbxproj b/LinphoneApp.xcodeproj/project.pbxproj index c2d9fe18f..593f47d09 100644 --- a/LinphoneApp.xcodeproj/project.pbxproj +++ b/LinphoneApp.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ C642277B2E8E4AC50094FEDC /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C642277A2E8E4AC50094FEDC /* ThemeManager.swift */; }; C642277C2E8E4D900094FEDC /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C642277A2E8E4AC50094FEDC /* ThemeManager.swift */; }; C642277D2E8E4E2B0094FEDC /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; + C65270F82F879D2700FF248C /* EarlyPushkitDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65270F72F879D2700FF248C /* EarlyPushkitDelegate.swift */; }; C67586AE2C09F23C002E77BF /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AD2C09F23C002E77BF /* URLExtension.swift */; }; C67586B02C09F247002E77BF /* URIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AF2C09F247002E77BF /* URIHandler.swift */; }; C67586B52C09F617002E77BF /* SingleSignOnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586B22C09F617002E77BF /* SingleSignOnManager.swift */; }; @@ -307,6 +308,7 @@ C62817312C1C400A00DBA646 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; C62817332C1C7C7400DBA646 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpView.swift; sourceTree = ""; }; C642277A2E8E4AC50094FEDC /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; + C65270F72F879D2700FF248C /* EarlyPushkitDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyPushkitDelegate.swift; sourceTree = ""; }; C67586AD2C09F23C002E77BF /* URLExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; }; C67586AF2C09F247002E77BF /* URIHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URIHandler.swift; sourceTree = ""; }; C67586B22C09F617002E77BF /* SingleSignOnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleSignOnManager.swift; sourceTree = ""; }; @@ -593,6 +595,7 @@ 662B69D72B25DDF6007118BF /* TelecomManager */ = { isa = PBXGroup; children = ( + C65270F72F879D2700FF248C /* EarlyPushkitDelegate.swift */, 662B69D82B25DE18007118BF /* TelecomManager.swift */, 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */, ); @@ -1525,6 +1528,7 @@ D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D7AEB9742F324A6F00298546 /* ConversationDocumentsListFragment.swift in Sources */, D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */, + C65270F82F879D2700FF248C /* EarlyPushkitDelegate.swift in Sources */, D7C2DA1D2CA44DE400A2441B /* EventModel.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */, diff --git a/msgNotificationService/NotificationService.swift b/msgNotificationService/NotificationService.swift index a6437fac1..3678548d0 100644 --- a/msgNotificationService/NotificationService.swift +++ b/msgNotificationService/NotificationService.swift @@ -112,7 +112,11 @@ class NotificationService: UNNotificationServiceExtension { return } - createCore() + if !createCore() { + bestAttemptContent.title = String(localized: "notification_chat_message_received_title") + contentHandler(bestAttemptContent) + return + } if !lc!.config!.getBool(section: "app", key: "disable_chat_feature", defaultValue: false) { Log.info("received push payload : \(bestAttemptContent.userInfo.debugDescription)") @@ -314,14 +318,17 @@ class NotificationService: UNNotificationServiceExtension { return msgData } - func createCore() { - Log.info("[msgNotificationService] create core") - - let factoryPath = FileUtil.bundleFilePath("linphonerc-factory")! - let config = Config.newForSharedCore(appGroupId: appGroupName, configFilename: "linphonerc", factoryConfigFilename: factoryPath)! - - lc = try? Factory.Instance.createSharedCoreWithConfig(config: config, systemContext: nil, appGroupId: appGroupName, mainCore: false) - } + func createCore() -> Bool { + Log.info("[msgNotificationService] create core") + + let factoryPath = FileUtil.bundleFilePath("linphonerc-factory")! + if let config = Config.newForSharedCore(appGroupId: appGroupName, configFilename: "linphonerc", factoryConfigFilename: factoryPath) { + lc = try? Factory.Instance.createSharedCoreWithConfig(config: config, systemContext: nil, appGroupId: appGroupName, mainCore: false) + return lc != nil + } else { + return false + } + } func stopCore() { Log.info("stop core")