payfrit-user/ios/Runner/BeaconScanner.swift
John Mizerek 56f1e1cf63 Add native iOS beacon scanner with CoreBluetooth
- BeaconScanner.swift: Native scanner using CBCentralManager
- AppDelegate.swift: Wire up MethodChannel (same API as Android)
- beacon_channel.dart: Support iOS in isSupported check
- beacon_scanner_service.dart: Use native scanner on both platforms

iOS now gets the same fast 2-second scan as Android.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:36:40 -08:00

234 lines
7.7 KiB
Swift

import Foundation
import CoreBluetooth
import CoreLocation
/// Native beacon scanner using CoreBluetooth.
/// Scans for iBeacon advertisements and returns UUID + RSSI.
class BeaconScanner: NSObject {
private static let TAG = "BeaconScanner"
// iBeacon manufacturer ID (Apple)
private static let IBEACON_MANUFACTURER_ID: UInt16 = 0x004C
// Scan duration in seconds
private static let SCAN_DURATION: TimeInterval = 2.0
// Minimum RSSI threshold
private static let MIN_RSSI: Int = -90
private var centralManager: CBCentralManager?
private var locationManager: CLLocationManager?
private var isScanning = false
// Callbacks
private var onComplete: (([[String: Any]]) -> Void)?
private var onError: ((String) -> Void)?
// Collected beacon data: UUID -> list of RSSI samples
private var beaconSamples: [String: [Int]] = [:]
// Timer for scan completion
private var scanTimer: Timer?
// Bluetooth state
private var bluetoothState: CBManagerState = .unknown
private var pendingScan = false
private var pendingRegions: [String]?
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil, options: [
CBCentralManagerOptionShowPowerAlertKey: false
])
locationManager = CLLocationManager()
}
/// Check if Bluetooth permissions are granted
func hasPermissions() -> Bool {
// Check Bluetooth authorization
if #available(iOS 13.1, *) {
let bluetoothAuth = CBCentralManager.authorization
if bluetoothAuth != .allowedAlways {
return false
}
}
// Check location authorization (required for beacon scanning)
let locationAuth = locationManager?.authorizationStatus ?? .notDetermined
return locationAuth == .authorizedWhenInUse || locationAuth == .authorizedAlways
}
/// Check if Bluetooth is enabled
func isBluetoothEnabled() -> Bool {
return bluetoothState == .poweredOn
}
/// Start scanning for beacons
func startScan(regions: [String], onComplete: @escaping ([[String: Any]]) -> Void, onError: @escaping (String) -> Void) {
if isScanning {
onError("Scan already in progress")
return
}
if !hasPermissions() {
onError("Bluetooth or location permissions not granted")
return
}
// If Bluetooth isn't ready yet, queue the scan
if bluetoothState != .poweredOn {
if bluetoothState == .poweredOff {
onError("Bluetooth is not enabled")
return
}
// State might be unknown/resetting, wait for it
pendingScan = true
pendingRegions = regions
self.onComplete = onComplete
self.onError = onError
return
}
performScan(regions: regions, onComplete: onComplete, onError: onError)
}
private func performScan(regions: [String], onComplete: @escaping ([[String: Any]]) -> Void, onError: @escaping (String) -> Void) {
NSLog("\(BeaconScanner.TAG): Starting CoreBluetooth beacon scan")
isScanning = true
self.onComplete = onComplete
self.onError = onError
beaconSamples.removeAll()
// Start scanning for all devices (we'll filter for iBeacons in didDiscover)
// Note: CoreBluetooth doesn't allow filtering by manufacturer data directly
centralManager?.scanForPeripherals(withServices: nil, options: [
CBCentralManagerScanOptionAllowDuplicatesKey: true
])
// Schedule scan completion
scanTimer = Timer.scheduledTimer(withTimeInterval: BeaconScanner.SCAN_DURATION, repeats: false) { [weak self] _ in
self?.completeScan()
}
}
/// Stop scanning
func stopScan() {
NSLog("\(BeaconScanner.TAG): Stopping beacon scan")
scanTimer?.invalidate()
scanTimer = nil
isScanning = false
pendingScan = false
centralManager?.stopScan()
}
private func completeScan() {
NSLog("\(BeaconScanner.TAG): Scan complete. Found \(beaconSamples.count) unique beacons")
centralManager?.stopScan()
isScanning = false
// Build results
var results: [[String: Any]] = []
for (uuid, samples) in beaconSamples {
let avgRssi = samples.reduce(0, +) / max(samples.count, 1)
results.append([
"uuid": uuid,
"rssi": avgRssi,
"samples": samples.count
])
}
// Sort by RSSI descending (strongest first)
results.sort { ($0["rssi"] as? Int ?? -100) > ($1["rssi"] as? Int ?? -100) }
onComplete?(results)
onComplete = nil
onError = nil
}
/// Parse iBeacon UUID from manufacturer data
private func parseIBeaconUuid(from advertisementData: [String: Any]) -> (uuid: String, rssi: Int)? {
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data else {
return nil
}
// iBeacon format:
// Bytes 0-1: Company ID (0x004C for Apple)
// Byte 2: Type (0x02)
// Byte 3: Length (0x15 = 21)
// Bytes 4-19: UUID (16 bytes)
// Bytes 20-21: Major
// Bytes 22-23: Minor
// Byte 24: TX Power
guard manufacturerData.count >= 25 else {
return nil
}
// Check Apple company ID (little endian)
let companyId = UInt16(manufacturerData[0]) | (UInt16(manufacturerData[1]) << 8)
guard companyId == BeaconScanner.IBEACON_MANUFACTURER_ID else {
return nil
}
// Check iBeacon type
guard manufacturerData[2] == 0x02 && manufacturerData[3] == 0x15 else {
return nil
}
// Extract UUID (bytes 4-19)
let uuidBytes = manufacturerData.subdata(in: 4..<20)
let uuid = uuidBytes.map { String(format: "%02X", $0) }.joined()
return (uuid: uuid, rssi: 0) // RSSI comes from the peripheral callback
}
}
// MARK: - CBCentralManagerDelegate
extension BeaconScanner: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
bluetoothState = central.state
NSLog("\(BeaconScanner.TAG): Bluetooth state changed to \(central.state.rawValue)")
// If we had a pending scan and Bluetooth is now ready, start it
if pendingScan && central.state == .poweredOn {
pendingScan = false
if let regions = pendingRegions, let onComplete = onComplete, let onError = onError {
performScan(regions: regions, onComplete: onComplete, onError: onError)
}
pendingRegions = nil
} else if pendingScan && central.state == .poweredOff {
pendingScan = false
onError?("Bluetooth is not enabled")
onError = nil
onComplete = nil
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
let rssiValue = RSSI.intValue
// Filter weak signals
guard rssiValue >= BeaconScanner.MIN_RSSI else {
return
}
// Try to parse as iBeacon
guard let beacon = parseIBeaconUuid(from: advertisementData) else {
return
}
NSLog("\(BeaconScanner.TAG): Detected iBeacon: \(beacon.uuid) RSSI=\(rssiValue)")
// Add sample
if beaconSamples[beacon.uuid] == nil {
beaconSamples[beacon.uuid] = []
}
beaconSamples[beacon.uuid]?.append(rssiValue)
}
}