payfrit-works-ios/PayfritWorks/Services/BeaconScanner.swift
John Pinkyfloyd c71b9f7dea Add ios-marketing idiom for App Store icon display
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:37:59 -08:00

215 lines
7.4 KiB
Swift

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 = 5 // ~5 seconds at 1Hz ranging
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<String> = []
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)")
}
}