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
This commit is contained in:
Christophe Deschamps 2026-04-13 13:38:39 +00:00
parent 0132c5253f
commit 156231f8aa
8 changed files with 195 additions and 34 deletions

View file

@ -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)
}

View file

@ -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 {

View file

@ -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.";

View file

@ -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.";

View file

@ -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)

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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)")
}
}
}
}

View file

@ -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 = "<group>"; };
C62817332C1C7C7400DBA646 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpView.swift; sourceTree = "<group>"; };
C642277A2E8E4AC50094FEDC /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
C65270F72F879D2700FF248C /* EarlyPushkitDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyPushkitDelegate.swift; sourceTree = "<group>"; };
C67586AD2C09F23C002E77BF /* URLExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = "<group>"; };
C67586AF2C09F247002E77BF /* URIHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URIHandler.swift; sourceTree = "<group>"; };
C67586B22C09F617002E77BF /* SingleSignOnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleSignOnManager.swift; sourceTree = "<group>"; };
@ -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 */,

View file

@ -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")