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:
parent
13b7004fa4
commit
56f1e1cf63
4 changed files with 298 additions and 15 deletions
|
|
@ -3,11 +3,60 @@ import UIKit
|
|||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
private let channelName = "com.payfrit.app/beacon"
|
||||
private var beaconScanner: BeaconScanner?
|
||||
|
||||
override func application(
|
||||
_ 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
234
ios/Runner/BeaconScanner.swift
Normal file
234
ios/Runner/BeaconScanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,12 +26,12 @@ class DetectedBeacon {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
static const _channel = MethodChannel("com.payfrit.app/beacon");
|
||||
|
||||
/// Check if running on Android (native scanner only works there)
|
||||
static bool get isSupported => Platform.isAndroid;
|
||||
/// Check if native scanner is supported on this platform
|
||||
static bool get isSupported => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
/// Check if Bluetooth permissions are granted
|
||||
static Future<bool> hasPermissions() async {
|
||||
|
|
|
|||
|
|
@ -90,17 +90,17 @@ class BeaconScannerService {
|
|||
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 = [];
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
debugPrint('[BeaconScanner] Using native Android scanner...');
|
||||
if (BeaconChannel.isSupported) {
|
||||
debugPrint('[BeaconScanner] Using native scanner...');
|
||||
detectedBeacons = await BeaconChannel.startScan(
|
||||
regions: knownBeacons.keys.toList(),
|
||||
);
|
||||
} else {
|
||||
// iOS: use Flutter plugin
|
||||
debugPrint('[BeaconScanner] Using Flutter plugin for iOS...');
|
||||
// Fallback: use Flutter plugin
|
||||
debugPrint('[BeaconScanner] Using Flutter plugin fallback...');
|
||||
detectedBeacons = await _scanWithFlutterPlugin(knownBeacons);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue