import UIKit import CoreBluetooth import CoreLocation /// Beacon scanner for task auto-completion using the Payfrit shard system. /// Scans for ALL shard UUIDs and resolves via API to find the target ServicePoint. final class BeaconScanner: NSObject, ObservableObject { private let targetServicePointId: Int private let onBeaconDetected: (Double) -> Void private let onBluetoothOff: (() -> Void)? private let onPermissionDenied: (() -> Void)? @Published var isScanning = false private var locationManager: CLLocationManager? private var activeConstraints: [CLBeaconIdentityConstraint] = [] private var checkTimer: Timer? // RSSI samples for dwell time enforcement private var rssiSamples: [Int] = [] private let minSamplesToConfirm = 30 // ~3 seconds to match Android (30 samples at ~100ms) private let rssiThreshold = -75 // Track resolved beacons to avoid repeated API calls private var resolvedBeacons: [String: Int] = [:] // "uuid-major-minor" -> servicePointId private var pendingResolutions: Set = [] init(targetServicePointId: Int, onBeaconDetected: @escaping (Double) -> Void, onBluetoothOff: (() -> Void)? = nil, onPermissionDenied: (() -> Void)? = nil) { self.targetServicePointId = targetServicePointId self.onBeaconDetected = onBeaconDetected self.onBluetoothOff = onBluetoothOff self.onPermissionDenied = onPermissionDenied super.init() } // Legacy init for backwards compatibility with UUID-based scanning convenience init(targetUUID: String, onBeaconDetected: @escaping (Double) -> Void, onBluetoothOff: (() -> Void)? = nil, onPermissionDenied: (() -> Void)? = nil) { // Use 0 as a placeholder - this path won't use ServicePoint matching self.init(targetServicePointId: 0, onBeaconDetected: onBeaconDetected, onBluetoothOff: onBluetoothOff, onPermissionDenied: onPermissionDenied) } // MARK: - Start/Stop func startScanning() { guard !isScanning else { return } locationManager = CLLocationManager() locationManager?.delegate = self let status = locationManager!.authorizationStatus if status == .notDetermined { locationManager?.requestWhenInUseAuthorization() return } guard status == .authorizedWhenInUse || status == .authorizedAlways else { onPermissionDenied?() return } // Start ranging for ALL shard UUIDs for uuid in BeaconShardPool.uuids { let constraint = CLBeaconIdentityConstraint(uuid: uuid) activeConstraints.append(constraint) locationManager?.startRangingBeacons(satisfying: constraint) } isScanning = true rssiSamples.removeAll() // Keep screen on during scanning DispatchQueue.main.async { UIApplication.shared.isIdleTimerDisabled = true } // Periodic Bluetooth check checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in if CBCentralManager.authorization == .denied || CBCentralManager.authorization == .restricted { self?.onBluetoothOff?() } } print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)") } func stopScanning() { isScanning = false for constraint in activeConstraints { locationManager?.stopRangingBeacons(satisfying: constraint) } activeConstraints.removeAll() checkTimer?.invalidate() checkTimer = nil rssiSamples.removeAll() DispatchQueue.main.async { UIApplication.shared.isIdleTimerDisabled = false } } func resetSamples() { rssiSamples.removeAll() } func dispose() { stopScanning() locationManager = nil } // MARK: - Beacon Resolution private func beaconKey(_ beacon: CLBeacon) -> String { return "\(beacon.uuid.uuidString)-\(beacon.major)-\(beacon.minor)" } private func resolveBeacon(_ beacon: CLBeacon) { let key = beaconKey(beacon) guard resolvedBeacons[key] == nil && !pendingResolutions.contains(key) else { return } pendingResolutions.insert(key) Task { do { let servicePointId = try await APIService.shared.resolveServicePoint( uuid: beacon.uuid.uuidString, major: beacon.major.intValue, minor: beacon.minor.intValue ) await MainActor.run { self.resolvedBeacons[key] = servicePointId self.pendingResolutions.remove(key) print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)") } } catch { await MainActor.run { self.pendingResolutions.remove(key) print("[BeaconScanner] Failed to resolve \(key): \(error)") } } } } } // MARK: - CLLocationManagerDelegate extension BeaconScanner: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) { var foundTarget = false for beacon in beacons { let rssi = beacon.rssi guard rssi != 0 else { continue } // Check if this is a Payfrit beacon guard BeaconShardPool.isPayfrit(beacon.uuid) else { continue } let key = beaconKey(beacon) // Try to resolve if not already resolved if resolvedBeacons[key] == nil { resolveBeacon(beacon) continue } // Check if this is our target service point guard resolvedBeacons[key] == targetServicePointId else { continue } foundTarget = true if rssi >= rssiThreshold { rssiSamples.append(rssi) if rssiSamples.count >= minSamplesToConfirm { let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)") DispatchQueue.main.async { [weak self] in self?.onBeaconDetected(avg) } } } else { // Signal too weak, reset if !rssiSamples.isEmpty { rssiSamples.removeAll() } } } // Target beacon lost this cycle if !foundTarget && !rssiSamples.isEmpty { rssiSamples.removeAll() } } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus if status == .authorizedWhenInUse || status == .authorizedAlways { if !isScanning { startScanning() } } else if status == .denied || status == .restricted { onPermissionDenied?() } } func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { print("[BeaconScanner] Ranging failed: \(error)") } }