diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..43d4089 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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) + } } diff --git a/ios/Runner/BeaconScanner.swift b/ios/Runner/BeaconScanner.swift new file mode 100644 index 0000000..a70c6d2 --- /dev/null +++ b/ios/Runner/BeaconScanner.swift @@ -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) + } +} diff --git a/lib/services/beacon_channel.dart b/lib/services/beacon_channel.dart index 209b04f..c5da7c4 100644 --- a/lib/services/beacon_channel.dart +++ b/lib/services/beacon_channel.dart @@ -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 hasPermissions() async { diff --git a/lib/services/beacon_scanner_service.dart b/lib/services/beacon_scanner_service.dart index 2fab70a..33c3fd5 100644 --- a/lib/services/beacon_scanner_service.dart +++ b/lib/services/beacon_scanner_service.dart @@ -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 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); }