mattermost-mobile-upstream/ios/Mattermost/AppDelegate.swift
Elias Nahum bc84b6ee3a
Guard long-running DB chains against RUNNINGBOARD 0xdead10cc on iOS (#10110)
* Guard long-running DB chains against RUNNINGBOARD 0xdead10cc on iOS

iOS can terminate the app if it's suspended while a SQLite statement
still holds the shared App Group database lock. AppDelegate already
held a single background-task assertion across the background
transition, but it was a blind, fixed-duration timer: it never
tracked whether a DB operation was actually still running, and a
chain that started after the timer had already begun (or ran long
enough to outlast it) got no protection.

Add DatabaseActivityAssertion (a Swift port of Quinn "The Eskimo!"'s
QRunInBackgroundAssertion) and DatabaseLockProtectionManager, which
lets any caller - the app lifecycle transition, or a specific
long-running chain - acquire its own independent background-task
job and release it exactly when its own work finishes. Wire this
into the websocket reconnect sync (doReconnect) and the deferred
per-team entry sync (restDeferredAppEntryActions), the two longest
sequential local-DB chains in the app, via a new pair of RNUtils
methods (iOS only; Android is a no-op, since 0xdead10cc has no
Android equivalent).

* fix tests
2026-09-02 15:07:34 +08:00

347 lines
13 KiB
Swift

import AVFoundation
internal import Expo
import React
import ReactAppDependencyProvider
import RNKeychain
import RNNotifications
import RNSentry
import react_native_paste_input
import mattermost_calls_native
import mattermost_rnutils
import mattermost_hardware_keyboard
import TurboLogIOSNative
import UIKit
import UserNotifications
import os.log
#if canImport(MSAL)
import MSAL
#endif
#if canImport(mattermost_intune)
import mattermost_intune
#endif
private let notificationClearAction = "clear"
private let notificationSessionAction = "session"
private let notificationTestAction = "test"
@main
class AppDelegate: ExpoAppDelegate, OrientationLockable {
@objc var orientationLock: UIInterfaceOrientationMask = .allButUpsideDown
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
private var databaseLockProtectionToken: String?
override func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
os_log("Mattermost will attach session from handleEventsForBackgroundURLSession!! identifier=%{public}@", identifier)
GekidouWrapper.default.attachSession(identifier, completionHandler: completionHandler)
os_log("Mattermost session ATTACHED from handleEventsForBackgroundURLSession!! identifier=%{public}@", identifier)
}
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Initialize Sentry early for native crash reporting (only when DSN is configured)
if let optionsPath = Bundle.main.path(forResource: "sentry.options", ofType: "json"),
let data = try? Data(contentsOf: URL(fileURLWithPath: optionsPath)),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dsn = json["dsn"] as? String, !dsn.isEmpty {
RNSentrySDK.start()
}
// Configure TurboLog
if let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String,
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) {
let logsURL = containerURL.appendingPathComponent("Logs")
do {
try TurboLogger.configure(
dailyRolling: false,
maximumFileSize: 1024 * 1024,
maximumNumberOfFiles: 2,
logsDirectory: logsURL.path,
logsFilename: "MMLogs"
)
} catch {
NSLog("Failed to configure TurboLog: %@", error.localizedDescription)
}
TurboLogger.write(level: .info, message: "Configured turbolog")
}
// Configure Gekidou to use TurboLog via wrapper
GekidouWrapper.default.configureTurboLogForGekidou()
GekidouWrapper.default.registerSessionAttributesOutboundHeader()
// Bootstrap @mattermost/calls-native: allocates the singleton
// PKPushRegistry + CXProvider on the main queue, synchronously,
// before any VoIP push delegate can fire.
CallsBridge.shared.bootstrap()
#if canImport(mattermost_intune)
// Initialize Intune MAM delegates BEFORE React Native
IntuneAccess.initializeIntuneDelegates()
#endif
OrientationManager.shared.delegate = self
// Clear keychain on first run in case of reinstallation
if UserDefaults.standard.object(forKey: "FirstRun") == nil {
let keychain = RNKeychainManager()
if let servers = keychain.getAllServersForInternetPasswords() {
TurboLogger.write(level: .info, message: "Servers \(servers)")
for server in servers {
keychain.deleteCredentials(forServer: server, withOptions: nil)
}
}
UserDefaults.standard.set(true, forKey: "FirstRun")
UserDefaults.standard.synchronize()
}
try? AVAudioSession.sharedInstance().setCategory(.playback)
GekidouWrapper.default.setPreference("true", forKey: "ApplicationIsRunning")
RNNotifications.startMonitorNotifications()
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "Mattermost",
in: window,
launchOptions: launchOptions
)
#endif
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
os_log("Mattermost started!!")
if let factory = reactNativeFactory {
PasteInputModule.setup(factory.rootViewFactory)
}
#if canImport(mattermost_intune)
// Restore enrollments if needed (silent, non-blocking)
IntuneAccess.checkAndRestoreEnrollmentOnLaunch()
#endif
return result
}
// MARK: - Remote Notifications
override func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
RNNotifications.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
}
override func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
RNNotifications.didFailToRegisterForRemoteNotificationsWithError(error)
}
override func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let state = UIApplication.shared.applicationState
let action = userInfo["type"] as? String
let isClearAction = action == notificationClearAction
let isTestAction = action == notificationTestAction
let isSessionAction = action == notificationSessionAction
if isTestAction {
completionHandler(.noData)
return
}
if !GekidouWrapper.default.verifySignature(userInfo) {
var notification = userInfo
notification["verified"] = "false"
RNNotifications.didReceiveBackgroundNotification(notification, withCompletionHandler: completionHandler)
return
}
if isClearAction {
NotificationHelper.default.clearChannelOrThreadNotifications(userInfo: userInfo as NSDictionary)
GekidouWrapper.default.postNotificationReceipt(userInfo)
RNNotifications.didReceiveBackgroundNotification(userInfo, withCompletionHandler: completionHandler)
return
}
if isSessionAction {
GekidouWrapper.default.postNotificationReceipt(userInfo)
RNNotifications.didReceiveBackgroundNotification(userInfo, withCompletionHandler: completionHandler)
return
}
if state != .active {
GekidouWrapper.default.fetchDataForPushNotification(userInfo) { data in
var notification = userInfo
if let data = data {
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
notification["data"] = json
} else {
TurboLogger.write(level: .warning, message: "Mattermost AppDelegate: JSON serialization returned nil without error")
}
} catch {
TurboLogger.write(level: .error, message: "Mattermost AppDelegate: JSON serialization error \(error.localizedDescription)")
}
}
RNNotifications.didReceiveBackgroundNotification(notification, withCompletionHandler: completionHandler)
}
} else {
completionHandler(.newData)
}
}
// MARK: - Deep Linking
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleMSALURL(url) {
return true
}
return RCTLinkingManager.application(app, open: url, options: options)
}
func application(
_ application: UIApplication,
open url: URL,
sourceApplication: String?,
annotation: Any
) -> Bool {
if handleMSALURL(url) {
return true
}
return RCTLinkingManager.application(application, open: url, sourceApplication: sourceApplication, annotation: annotation)
}
private func handleMSALURL(_ url: URL) -> Bool {
#if canImport(MSAL)
if let bundleId = Bundle.main.bundleIdentifier,
url.absoluteString.hasPrefix("msauth.\(bundleId)") {
TurboLogger.write(level: .info, message: "[Intune] MSAL URL handled")
return MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: nil)
}
#endif
return false
}
override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
// MARK: - App Lifecycle
override func applicationDidBecomeActive(_ application: UIApplication) {
super.applicationDidBecomeActive(application)
GekidouWrapper.default.setPreference("true", forKey: "ApplicationIsForeground")
endDatabaseLockProtection()
}
override func applicationWillResignActive(_ application: UIApplication) {
super.applicationWillResignActive(application)
GekidouWrapper.default.setPreference("false", forKey: "ApplicationIsForeground")
}
override func applicationDidEnterBackground(_ application: UIApplication) {
super.applicationDidEnterBackground(application)
GekidouWrapper.default.setPreference("false", forKey: "ApplicationIsForeground")
beginDatabaseLockProtection()
}
override func applicationWillTerminate(_ application: UIApplication) {
super.applicationWillTerminate(application)
GekidouWrapper.default.setPreference("false", forKey: "ApplicationIsForeground")
GekidouWrapper.default.setPreference("false", forKey: "ApplicationIsRunning")
endDatabaseLockProtection()
}
// MARK: - Database lock protection (prevents RUNNINGBOARD 0xdead10cc)
// Baseline safety net for the background transition; specific long-running
// chains hold their own independent job via DatabaseLockProtectionManager.
private func beginDatabaseLockProtection() {
guard databaseLockProtectionToken == nil else { return }
databaseLockProtectionToken = DatabaseLockProtectionManager.shared.begin("lifecycle", task: "MMDatabaseLockProtection")
}
private func endDatabaseLockProtection() {
guard let token = databaseLockProtectionToken else { return }
DatabaseLockProtectionManager.shared.end(token)
databaseLockProtectionToken = nil
}
// MARK: - Orientation
override func application(
_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?
) -> UIInterfaceOrientationMask {
return orientationLock
}
// MARK: - Hardware Keyboard
override var keyCommands: [UIKeyCommand]? {
return MattermostHardwareKeyboardWrapper.registerKeyCommands(
enterPressed: #selector(sendEnter(_:)),
shiftEnterPressed: #selector(sendShiftEnter(_:)),
findChannels: #selector(sendFindChannels(_:))
) as? [UIKeyCommand]
}
@objc private func sendEnter(_ sender: UIKeyCommand) {
MattermostHardwareKeyboardWrapper.enterKeyPressed()
}
@objc private func sendShiftEnter(_ sender: UIKeyCommand) {
MattermostHardwareKeyboardWrapper.shiftEnterKeyPressed()
}
@objc private func sendFindChannels(_ sender: UIKeyCommand) {
MattermostHardwareKeyboardWrapper.findChannels()
}
}
// MARK: - ReactNativeDelegate
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}