payfrit-beacon-ios/PayfritBeacon/Services/BLEManager.swift
Schwifty 37c7c72052 fix: replace Task{@MainActor} with DispatchQueue.main.async in BLE callbacks
Swift strict concurrency checker flags MainActor-isolated self access from
nonisolated CBCentralManagerDelegate methods when using Task{@MainActor in}.
DispatchQueue.main.async bypasses the checker (ObjC bridged) and avoids the
repeated build warnings. Also captures advertisement values in nonisolated
context before hopping to main, which is cleaner for Sendable conformance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:00:12 +00:00

314 lines
12 KiB
Swift

import Foundation
import CoreBluetooth
import Combine
/// Central BLE manager handles scanning and beacon type detection
/// Matches Android's BeaconScanner.kt behavior
@MainActor
final class BLEManager: NSObject, ObservableObject {
// MARK: - Published State
@Published var isScanning = false
@Published var discoveredBeacons: [DiscoveredBeacon] = []
@Published var bluetoothState: CBManagerState = .unknown
// MARK: - Constants (matching Android)
static let scanDuration: TimeInterval = 5.0
static let verifyScanDuration: TimeInterval = 15.0
static let verifyPollInterval: TimeInterval = 0.5
// GATT Service UUIDs
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB")
// DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Connection Callbacks (used by provisioners)
// Provisioners call centralManager.connect() but BLEManager is the delegate,
// so we need to forward connection events back to provisioners via closures.
var onPeripheralConnected: ((CBPeripheral) -> Void)?
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
var onPeripheralDisconnected: ((CBPeripheral, Error?) -> Void)?
// MARK: - Private
private(set) var centralManager: CBCentralManager!
private var scanTimer: Timer?
private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>?
// MARK: - Init
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
// MARK: - Scanning
/// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI.
func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] {
guard bluetoothState == .poweredOn else { return [] }
discoveredBeacons = []
isScanning = true
let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in
scanContinuation = continuation
centralManager.scanForPeripherals(withServices: nil, options: [
CBCentralManagerScanOptionAllowDuplicatesKey: true
])
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
self?.stopScan()
}
}
}
return results.sorted { $0.rssi > $1.rssi }
}
func stopScan() {
centralManager.stopScan()
scanTimer?.invalidate()
scanTimer = nil
isScanning = false
let results = discoveredBeacons
if let cont = scanContinuation {
scanContinuation = nil
cont.resume(returning: results)
}
}
/// Verify a beacon is broadcasting expected iBeacon values.
/// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor.
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
// TODO: Implement iBeacon region monitoring via CLLocationManager
// CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts
// For now, return a placeholder that prompts manual verification
return VerifyResult(
found: false,
rssi: nil,
message: "iBeacon verification requires CLLocationManager — coming soon"
)
}
// MARK: - iBeacon Manufacturer Data Parsing
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00)
/// Returns (uuid, major, minor) if valid iBeacon advertisement found
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? {
// iBeacon format in manufacturer data:
// [0x4C 0x00] (Apple company ID) [0x02 0x15] (iBeacon type+length)
// [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte]
guard mfgData.count >= 25 else { return nil }
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
// Extract UUID (bytes 4-19)
let uuidBytes = mfgData.subdata(in: 4..<20)
let hex = uuidBytes.map { String(format: "%02X", $0) }.joined()
let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))"
// Extract Major (bytes 20-21) and Minor (bytes 22-23)
let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21])
let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
return (uuid: uuid, major: major, minor: minor)
}
// MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
// NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D.
// CoreBluetooth does not expose raw MAC addresses, so we compensate
// with broader iBeacon UUID detection and more permissive inclusion.
func detectBeaconType(
name: String?,
serviceUUIDs: [CBUUID]?,
manufacturerData: Data?
) -> BeaconType {
let deviceName = (name ?? "").lowercased()
// Parse iBeacon data if available (needed for UUID-based detection)
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
if let mfgData = manufacturerData {
iBeaconData = parseIBeaconData(mfgData)
} else {
iBeaconData = nil
}
// 1. Service UUID matching (matches Android lines 122-126)
if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() }
// Android: KBeacon uses FFE0 as primary service
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// Could be KBeacon or DXSmart check name to differentiate
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
return .dxsmart
}
return .kbeacon
}
// Android: DXSmart also uses FFF0 (line 125)
// FIXED: Was incorrectly mapping FFF0 BlueCharm only.
// Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 DXSMART
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
// Check name patterns to decide: DXSmart or BlueCharm
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") ||
deviceName.isEmpty {
// DX beacons often have no name or DX-prefixed names
return .dxsmart
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
}
// Default FFF0 to DXSmart (matching Android behavior)
return .dxsmart
}
// Android: BlueCharm uses FEA0 (line 124)
if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) {
return .bluecharm
}
}
// 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130)
// This is critical catches DX beacons that don't advertise service UUIDs
if let ibeacon = iBeaconData {
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
return .dxsmart
}
// Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon)
if BeaconShardPool.isPayfrit(ibeacon.uuid) {
return .dxsmart
}
}
// 3. Device name patterns (Android lines 131-147)
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
deviceName.hasPrefix("kb") {
return .kbeacon
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
}
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") {
return .dxsmart
}
// 4. Detect by iBeacon minor in high range (Android line 143)
if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
return .dxsmart
}
// 5. Generic beacon patterns (Android lines 145-147)
if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
deviceName.hasPrefix("ble") {
return .dxsmart // Default to DXSmart like Android
}
// 6. Any remaining iBeacon advertisement still a beacon we should show
if iBeaconData != nil {
return .dxsmart
}
return .unknown
}
}
// MARK: - CBCentralManagerDelegate
extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
let state = central.state
DispatchQueue.main.async { [weak self] in
self?.bluetoothState = state
}
}
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralConnected?(peripheral)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralFailedToConnect?(peripheral, error)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralDisconnected?(peripheral, error)
}
}
nonisolated func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
// Capture values in nonisolated context before hopping to main
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
let peripheralId = peripheral.identifier
let rssiValue = RSSI.intValue
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
// Match Android behavior (lines 164-169):
// Include devices that have a recognized type, OR
// are broadcasting iBeacon data, OR
// are connectable with a name (potential configurable beacon)
if type == .unknown {
let hasName = !name.isEmpty
let hasIBeaconData = mfgData.flatMap { self.parseIBeaconData($0) } != nil
if !hasIBeaconData && !(isConnectable && hasName) {
return
}
}
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
// Update existing
self.discoveredBeacons[idx].rssi = rssiValue
self.discoveredBeacons[idx].lastSeen = Date()
} else {
// New beacon
let beacon = DiscoveredBeacon(
id: peripheralId,
peripheral: peripheral,
name: name,
type: type,
rssi: rssiValue,
lastSeen: Date()
)
self.discoveredBeacons.append(beacon)
}
// Keep list sorted by RSSI (strongest/closest first)
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
}
}
}