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>
This commit is contained in:
John Mizerek 2026-01-27 08:36:40 -08:00
parent 13b7004fa4
commit 56f1e1cf63
4 changed files with 298 additions and 15 deletions

View file

@ -3,11 +3,60 @@ import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication, private let channelName = "com.payfrit.app/beacon"
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? private var beaconScanner: BeaconScanner?
) -> Bool {
GeneratedPluginRegistrant.register(with: self) override func application(
return super.application(application, didFinishLaunchingWithOptions: launchOptions) _ application: UIApplication,
} didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
// Set up beacon scanner method channel
guard let controller = window?.rootViewController as? FlutterViewController else {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
beaconScanner = BeaconScanner()
let channel = FlutterMethodChannel(name: channelName, binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
guard let self = self, let scanner = self.beaconScanner else {
result(FlutterError(code: "UNAVAILABLE", message: "Beacon scanner not initialized", details: nil))
return
}
switch call.method {
case "hasPermissions":
result(scanner.hasPermissions())
case "isBluetoothEnabled":
result(scanner.isBluetoothEnabled())
case "startScan":
var regions: [String] = []
if let args = call.arguments as? [String: Any],
let regionList = args["regions"] as? [String] {
regions = regionList
}
scanner.startScan(regions: regions) { beacons in
result(beacons)
} onError: { error in
result(FlutterError(code: "SCAN_ERROR", message: error, details: nil))
}
case "stopScan":
scanner.stopScan()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
} }

View file

@ -0,0 +1,234 @@
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)
}
}

View file

@ -26,12 +26,12 @@ class DetectedBeacon {
} }
/// Native beacon scanner via MethodChannel /// Native beacon scanner via MethodChannel
/// Only works on Android. iOS falls back to Flutter plugin. /// Works on both Android and iOS using platform-specific implementations.
class BeaconChannel { class BeaconChannel {
static const _channel = MethodChannel("com.payfrit.app/beacon"); static const _channel = MethodChannel("com.payfrit.app/beacon");
/// Check if running on Android (native scanner only works there) /// Check if native scanner is supported on this platform
static bool get isSupported => Platform.isAndroid; static bool get isSupported => Platform.isAndroid || Platform.isIOS;
/// Check if Bluetooth permissions are granted /// Check if Bluetooth permissions are granted
static Future<bool> hasPermissions() async { static Future<bool> hasPermissions() async {

View file

@ -90,17 +90,17 @@ class BeaconScannerService {
return const BeaconScanResult(error: "No beacons configured"); return const BeaconScanResult(error: "No beacons configured");
} }
// Use native scanner on Android, Flutter plugin on iOS // Use native scanner on both Android and iOS
List<DetectedBeacon> detectedBeacons = []; List<DetectedBeacon> detectedBeacons = [];
if (Platform.isAndroid) { if (BeaconChannel.isSupported) {
debugPrint('[BeaconScanner] Using native Android scanner...'); debugPrint('[BeaconScanner] Using native scanner...');
detectedBeacons = await BeaconChannel.startScan( detectedBeacons = await BeaconChannel.startScan(
regions: knownBeacons.keys.toList(), regions: knownBeacons.keys.toList(),
); );
} else { } else {
// iOS: use Flutter plugin // Fallback: use Flutter plugin
debugPrint('[BeaconScanner] Using Flutter plugin for iOS...'); debugPrint('[BeaconScanner] Using Flutter plugin fallback...');
detectedBeacons = await _scanWithFlutterPlugin(knownBeacons); detectedBeacons = await _scanWithFlutterPlugin(knownBeacons);
} }