- 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>
234 lines
7.7 KiB
Swift
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)
|
|
}
|
|
}
|