BLE Broadcast Cannot Relaunch User-Force-Quit App via AccessorySetupKit (iOS 26+)

Hi everyone,

I am trying to wake up/relaunch an app that was force-quit by the user via a BLE advertisement packet.

According to TN3115 ("App Force Quit by the user" section), an app generally cannot be woken up after a user force-quit. However, Note 5 states that starting in iOS 26, an app authorized via AccessorySetupKit can indeed be relaunched.

Environment

  • iOS Version: iOS 26.5 (Note: revised to standard versioning)
  • Xcode Version: Xcode 26.3

Implementation Details

1. Info.plist Configuration

<key>NSBluetoothAlwaysUsageDescription</key>
<string>We need Bluetooth to discover and connect to your accessory.</string>

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
</array>

<key>NSAccessorySetupKitSupports</key>
<array>
    <string>Bluetooth</string>
</array>

<key>NSAccessorySetupBluetoothServices</key>
<array>
    <string>0000XXXX-0000-1000-8000-00805F9B34FB</string>
</array>

<key>NSAccessorySetupBluetoothNames</key>
<array>
    <string>MyDeviceName</string>
</array>

2. Workflow & Code Steps

  1. Initialize ASAccessorySession and call activate().
  2. Pair/authorize the BLE peripheral using ASPickerDisplayItem.
  3. Initialize CBCentralManager with state restoration:
let options: [String: Any] = [
    CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier,
    CBCentralManagerOptionShowPowerAlertKey: true
]
centralManager = CBCentralManager(delegate: self, queue: nil, options: options)

  1. Start scanning:
let scanOptions = [CBCentralManagerScanOptionAllowDuplicatesKey: true]
centralManager?.scanForPeripherals(withServices: serviceUUIDs, options: scanOptions)

  1. Handle state restoration:
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
    if let services = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID],
       let options = dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String : Any] {
        central.scanForPeripherals(withServices: services, options: options)
    }
}

  1. Receive discovery callback:
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    if peripheral.name == "MyDeviceName" {
        // Send a local notification
    }
}


Current Behavior

  • Foreground: Local notification triggers as expected.
  • Background: Local notification triggers as expected.
  • Force Quit by User: No notification is received / App is not relaunched.

Issue

The app fails to relaunch when force-quit by the user, which seems to contradict the behavior described in TN3115 Note 5.

Is there a specific configuration, entitlement, or additional CBCentralManager setup required to allow BLE advertisements to relaunch the app after a user force-quit via AccessorySetupKit?

Any guidance would be greatly appreciated!

According to TN3115 ("App Force Quit by the user" section), an app generally cannot be woken up after a user force-quits. However, Note 5 states that starting in iOS 26, an app authorized via AccessorySetupKit can indeed be relaunched.

Ruling out the easy issue first, have you confirmed that this is specifically about force quit and NOT an issue with your app’s ability to test state? Two suggestions in that regard:

  1. In the "Developer" settings, there's an option you can enable called "Fast App Termination", which specifically exits apps shortly after they background (instead of just suspending them). Its primary purpose is to simplify testing state restoration, but it can also be used to simulate "natural" process death when testing things like this.

  2. I'd recommend having your app immediately post a local notification as early as possible (typically in applicationDidFinishLaunching) without any Bluetooth check or other "gate". From experience, many developers end up putting their checks/test code relatively "deep" in their app’s initialization and end up assuming there was/is a launch issue when what ACTUALLY happened is that the system launched their app as expected but issues in their code mean they never reached the point they expected.

Related to that last point, what's your "restoreIdentifier" set to? A non-constant value can end up generating the problem you're seeing, as you end up trying to restore a different central than your original one.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Info.plist Configuration

<key>NSAccessorySetupBluetoothNames</key>
<array>
    <string>My-Device</string>
</array>
<key>NSAccessorySetupBluetoothServices</key>
<array>
    <string>1111xxxx-xxxxx-xxxx-xxxx-xxxxxxxxxxxx</string>
</array>
<key>NSAccessorySetupKitSupports</key>
<array>
    <string>Bluetooth</string>
</array>
<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
    <string>remote-notification</string>
</array>

Relevant Code

AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    BluetoothService.share.activeSession()
    
    let center = UNUserNotificationCenter.current()
    center.delegate = self
    
    let options: UNAuthorizationOptions = [.badge, .alert, .sound]
    center.requestAuthorization(options: options) { granted, error in }
    
    UIApplication.shared.registerForRemoteNotifications()
    
    let notice = LocalNotification.defaultSound
    notice.body = "Launch App Success!"
    notice.play()
    
    return true
}

BluetoothService.swift

public class BluetoothService: NSObject, CBCentralManagerDelegate {

    public static let share = BluetoothService()
    
    private let restoreIdentifier = "com.yourcompany.yourapp.bluetoothCentral"
    
    private lazy var centralManager = {
        let options: [String: Any] = [
            CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier,
            CBCentralManagerOptionShowPowerAlertKey: true
        ]
        let centralManager = CBCentralManager(delegate: self, queue: nil, options: options)
        return centralManager
    }()
    
    public var options: [String: Any] = [CBCentralManagerScanOptionAllowDuplicatesKey: true]
    private var session: Any?
    private let bleName = "My-Device"
    private let serviceUUIDs = [CBUUID(string: "1111xxxx-xxxxx-xxxx-xxxx-xxxxxxxxxxxx")]
    private var tagTime: TimeInterval?
    
    override private init() {
        super.init()
        centralManager.delegate = self
    }

    public func activeSession() {
        if #available(iOS 18.0, *) {
            let session = ASAccessorySession()
            self.session = session
            session.activate(on: .main) { [weak self] event in
                guard let self = self else { return }
                self.handleSessionEvent(event)
            }
        } else {
            self.start()
        }
    }

    @available(iOS 18.0, *)
    private func handleSessionEvent(_ event: ASAccessoryEvent) {
        switch event.eventType {
        case .activated:
            guard let session = self.session as? ASAccessorySession else { return }
            if session.accessories.isEmpty {
                self.showPicker()
            } else {
                self.start()
            }
            
        case .accessoryAdded:
            self.start()
            
        default:
            break
        }
    }
    
    @available(iOS 18.0, *)
    public func showPicker() {
        guard let session = session as? ASAccessorySession else { return }
        
        var pickerItems: [ASPickerDisplayItem] = []
        let image = UIImage(systemName: "lanyardcard") ?? UIImage()
        
        for uuid in serviceUUIDs {
            let descriptor = ASDiscoveryDescriptor()
            descriptor.bluetoothServiceUUID = uuid
            
            let displayItem = ASPickerDisplayItem(
                name: self.bleName,
                productImage: image,
                descriptor: descriptor
            )
            pickerItems.append(displayItem)
        }

        session.showPicker(for: pickerItems) { [weak self] error in
            if let error = error {
                print("\(error)")
            } else {
                self?.start()
            }
        }
    }

    public func start() {
        if centralManager.isScanning { return }
        centralManager.scanForPeripherals(withServices: serviceUUIDs, options: options)
        
        let notice = LocalNotification.defaultSound
        notice.title = "ble start"
        notice.play()
    }

    // MARK: - CBCentralManagerDelegate
    public func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
        let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? self.serviceUUIDs
        let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String : Any]) ?? self.options
        
        let notice = LocalNotification.defaultSound
        notice.title = "willRestoreState"
        notice.body = "\(dict)"
        notice.play()
        
        central.scanForPeripherals(withServices: restoredServices, options: restoredOptions)
    }
    
    public func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print(">>> CentralManagerDidUpdateState: \(central.state.rawValue)")
        if central.state == .poweredOn {
            self.start()
        }
    }
    
    public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        var name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name
        guard let name = name, name == self.bleName else { return }
        
        let current = Date().timeIntervalSince1970
        if current - (self.tagTime ?? 0.0) < 3.0 { return }
        
        self.tagTime = current
        
        let notice = LocalNotification.defaultSound
        notice.title = name
        notice.play()
    }
}

Test Results

  1. Foreground: Scanning works normally. didDiscover is triggered continuously as advertising packets arrive.
  2. Background: Scanning works, but didDiscover is triggered only once when a peripheral is first discovered, even though the peripheral advertises continuously.
  3. User Force-Kill (Swiped Away): When the app is force-killed by the user, BLE events fail to wake/relaunch the app into the background.

Questions

  1. Why is didDiscover called only once in the background?

Our use case requires receiving multiple scan callbacks in the background. Is CBCentralManagerScanOptionAllowDuplicatesKey completely ignored by the system in the background even when targeting specific Service UUIDs? Is there any API or recommended strategy (e.g., within AccessorySetupKit) to receive repeated discovery callbacks in the background? 2. Can an app be woken up/relaunched after being force-closed by the user? When the user manually terminates the app via the App Switcher, CoreBluetooth state restoration does not seem to relaunch it. Does ASAccessorySession change or override this restriction, or is force-kill termination strictly enforced across iOS?

Any insight or documentation reference would be greatly appreciated!

Test Results

As I mentioned above, it looks like you've only tested force quit, NOT normal termination. The place to start here is validating normal exit behavior, NOT termination.

Our use case requires receiving multiple scan callbacks in the background. Is CBCentralManagerScanOptionAllowDuplicatesKey completely ignored by the system in the background even when targeting specific Service UUIDs?

Yes:

"The CBCentralManagerScanOptionAllowDuplicatesKey scan option key is ignored, and multiple discoveries of an advertising peripheral are coalesced into a single discovery event."

Is there any API or recommended strategy (e.g., within AccessorySetupKit) to receive repeated discovery callbacks in the background?

No. The solution here is for your app to connect with your accessory and directly interact with it instead of trying to rely on passive scanning.

Reordering things a bit:

Is force-kill termination strictly enforced across iOS?

The general behavior of iOS has always been that it avoids launching apps into the background if they've been force quit. However, there have been exceptions to this, for example:

  • VOIP pushes.

  • Beacon regions.

  • BGProcessingTask.

...are all intended to relaunch force-quit apps. Note that these cases typically involve special circumstances where the standard behavior has issues with typical use cases and app usage patterns.

Shifting to here:

  1. Can an app be woken up/relaunched after being force-closed by the user? When the user manually terminates the app via the App Switcher, CoreBluetooth state restoration does not seem to relaunch it.

The default behavior of CoreBluetooth is that it would not relaunch apps that have been force-quit.

Does ASAccessorySession change or override this restriction?

My understanding, per TN3115[1], is that AccessorySetupKit will relaunch force-quit apps.

[1] I wasn't particularly involved with TN3115, but I worked very closely with who did write it and I am confident that he would not have documented that detail unless the engineering team had specifically confirmed that this was the behavior they intended to support.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

BLE Broadcast Cannot Relaunch User-Force-Quit App via AccessorySetupKit (iOS 26+)
 
 
Q