payfrit-beacon-ios/_backup/Services/BeaconScanner.swift
John Pinkyfloyd 8c2320da44 Add ios-marketing idiom, iPad orientations, launch screen
- Fixed App Store icon display with ios-marketing idiom
- Added iPad orientation support for multitasking
- Added UILaunchScreen for iPad requirements
- Removed unused BLE permissions and files from build

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:38:11 -08:00

262 lines
8.7 KiB
Swift

import UIKit
import CoreBluetooth
import CoreLocation
/// Beacon scanner for detecting BLE beacons by UUID.
/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement.
/// All mutable state is confined to the main thread via @MainActor.
@MainActor
final class BeaconScanner: NSObject, ObservableObject {
private let targetUUID: String
private let normalizedTargetUUID: String
private let onBeaconDetected: (Double) -> Void
private let onRSSIUpdate: ((Int, Int) -> Void)?
private let onBluetoothOff: (() -> Void)?
private let onPermissionDenied: (() -> Void)?
private let onError: ((String) -> Void)?
@Published var isScanning = false
private var locationManager: CLLocationManager?
private var activeConstraint: CLBeaconIdentityConstraint?
private var checkTimer: Timer?
private var bluetoothManager: CBCentralManager?
// RSSI samples for dwell time enforcement
private var rssiSamples: [Int] = []
private let minSamplesToConfirm = 5 // ~5 seconds
private let rssiThreshold = -75
private var hasConfirmed = false
private var isPendingPermission = false
init(targetUUID: String,
onBeaconDetected: @escaping (Double) -> Void,
onRSSIUpdate: ((Int, Int) -> Void)? = nil,
onBluetoothOff: (() -> Void)? = nil,
onPermissionDenied: (() -> Void)? = nil,
onError: ((String) -> Void)? = nil) {
self.targetUUID = targetUUID
self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
self.onBeaconDetected = onBeaconDetected
self.onRSSIUpdate = onRSSIUpdate
self.onBluetoothOff = onBluetoothOff
self.onPermissionDenied = onPermissionDenied
self.onError = onError
super.init()
}
// MARK: - UUID formatting
private nonisolated func formatUUID(_ uuid: String) -> String {
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
guard clean.count == 32 else { return uuid }
let i = clean.startIndex
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
}
// MARK: - Start/Stop
func startScanning() {
guard !isScanning else { return }
let formatted = formatUUID(targetUUID)
guard let uuid = UUID(uuidString: formatted) else {
onError?("Invalid beacon UUID format")
return
}
let lm = CLLocationManager()
lm.delegate = self
locationManager = lm
let status = lm.authorizationStatus
if status == .notDetermined {
isPendingPermission = true
lm.requestWhenInUseAuthorization()
// Delegate will call locationManagerDidChangeAuthorization
return
}
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
onPermissionDenied?()
return
}
beginRanging(uuid: uuid)
}
private func beginRanging(uuid: UUID) {
guard let lm = locationManager else { return }
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
activeConstraint = constraint
lm.startRangingBeacons(satisfying: constraint)
isScanning = true
rssiSamples.removeAll()
hasConfirmed = false
UIApplication.shared.isIdleTimerDisabled = true
// Monitor Bluetooth power state with a real CBCentralManager
bluetoothManager = CBCentralManager(delegate: self, queue: .main)
checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkBluetoothState()
}
}
}
private func checkBluetoothState() {
if let bm = bluetoothManager, bm.state == .poweredOff {
stopScanning()
onBluetoothOff?()
}
if CBCentralManager.authorization == .denied ||
CBCentralManager.authorization == .restricted {
stopScanning()
onBluetoothOff?()
}
}
func stopScanning() {
isPendingPermission = false
guard isScanning else { return }
isScanning = false
if let constraint = activeConstraint {
locationManager?.stopRangingBeacons(satisfying: constraint)
}
activeConstraint = nil
checkTimer?.invalidate()
checkTimer = nil
bluetoothManager = nil
rssiSamples.removeAll()
hasConfirmed = false
UIApplication.shared.isIdleTimerDisabled = false
}
func resetSamples() {
rssiSamples.removeAll()
hasConfirmed = false
}
func dispose() {
stopScanning()
locationManager?.delegate = nil
locationManager = nil
}
deinit {
// Safety net: clean up resources
checkTimer?.invalidate()
locationManager?.delegate = nil
Task { @MainActor in
UIApplication.shared.isIdleTimerDisabled = false
}
}
// MARK: - Delegate handling (called on main thread, forwarded from nonisolated delegate)
fileprivate func handleRangedBeacons(_ beacons: [CLBeacon]) {
guard isScanning, !hasConfirmed else { return }
var foundThisCycle = false
for beacon in beacons {
let rssi = beacon.rssi
guard rssi != 0 else { continue }
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
guard detectedUUID == normalizedTargetUUID else { continue }
foundThisCycle = true
if rssi >= rssiThreshold {
rssiSamples.append(rssi)
onRSSIUpdate?(rssi, rssiSamples.count)
if rssiSamples.count >= minSamplesToConfirm {
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
hasConfirmed = true
onBeaconDetected(avg)
return
}
} else {
if !rssiSamples.isEmpty {
rssiSamples.removeAll()
onRSSIUpdate?(rssi, 0)
}
}
}
if !foundThisCycle && !rssiSamples.isEmpty {
rssiSamples.removeAll()
onRSSIUpdate?(0, 0)
}
}
fileprivate func handleRangingError(_ error: Error) {
onError?("Beacon ranging failed: \(error.localizedDescription)")
}
fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) {
if status == .authorizedWhenInUse || status == .authorizedAlways {
// Permission granted start ranging only if we were waiting for permission
if isPendingPermission && !isScanning {
isPendingPermission = false
let formatted = formatUUID(targetUUID)
if let uuid = UUID(uuidString: formatted) {
beginRanging(uuid: uuid)
}
}
} else if status == .denied || status == .restricted {
isPendingPermission = false
stopScanning()
onPermissionDenied?()
}
}
}
// MARK: - CLLocationManagerDelegate
// These delegate callbacks arrive on the main thread since CLLocationManager was created on main.
// We forward to @MainActor methods above.
extension BeaconScanner: CLLocationManagerDelegate {
nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
satisfying constraint: CLBeaconIdentityConstraint) {
Task { @MainActor in
self.handleRangedBeacons(beacons)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
Task { @MainActor in
self.handleRangingError(error)
}
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
self.handleAuthorizationChange(manager.authorizationStatus)
}
}
}
// MARK: - CBCentralManagerDelegate
extension BeaconScanner: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
if central.state == .poweredOff {
self.stopScanning()
self.onBluetoothOff?()
}
}
}
}