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
Foreground: Scanning works normally. didDiscover is triggered continuously as advertising packets arrive.
Background: Scanning works, but didDiscover is triggered only once when a peripheral is first discovered, even though the peripheral advertises continuously.
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
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!
Topic:
App & System Services
SubTopic:
Hardware
Tags: