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
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
|
||||||
|
private let channelName = "com.payfrit.app/beacon"
|
||||||
|
private var beaconScanner: BeaconScanner?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
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)
|
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
|
/// 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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue