payfrit-beacon-ios/PayfritBeacon/Services/BLEManager.swift
Schwifty cfa78679be feat: complete rebuild of PayfritBeacon iOS from scratch
100% fresh codebase — no legacy code carried over. Built against
the Android beacon app as the behavioral spec.

Architecture:
- App: SwiftUI @main, AppState-driven navigation, Keychain storage
- Views: LoginView (OTP + biometric), BusinessListView, ScanView (provisioning hub)
- Models: Business, ServicePoint, BeaconConfig, BeaconType, DiscoveredBeacon
- Services: APIClient (actor, async/await), BLEManager (CoreBluetooth scanner)
- Provisioners: KBeacon, DXSmart (2-step auth + flashing), BlueCharm
- Utils: UUIDFormatting, BeaconBanList, BeaconShardPool (64 shards)

Matches Android feature parity:
- 4-screen flow: Login → Business Select → Scan/Provision
- 3 beacon types with correct GATT protocols and timeouts
- Namespace allocation via beacon-sharding API
- Smart service point naming (Table N auto-increment)
- DXSmart special flow (connect → flash → user confirms → write)
- Biometric auth, dev/prod build configs, DEV banner overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:13:36 +00:00

199 lines
7.1 KiB
Swift

import Foundation
import CoreBluetooth
import Combine
/// Central BLE manager handles scanning and beacon type detection
/// Matches Android's BeaconScanner.kt behavior
@MainActor
final class BLEManager: NSObject, ObservableObject {
// MARK: - Published State
@Published var isScanning = false
@Published var discoveredBeacons: [DiscoveredBeacon] = []
@Published var bluetoothState: CBManagerState = .unknown
// MARK: - Constants (matching Android)
static let scanDuration: TimeInterval = 5.0
static let verifyScanDuration: TimeInterval = 15.0
static let verifyPollInterval: TimeInterval = 0.5
// GATT Service UUIDs
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
// MARK: - Private
private(set) var centralManager: CBCentralManager!
private var scanTimer: Timer?
private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>?
// MARK: - Init
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
// MARK: - Scanning
/// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI.
func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] {
guard bluetoothState == .poweredOn else { return [] }
discoveredBeacons = []
isScanning = true
let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in
scanContinuation = continuation
centralManager.scanForPeripherals(withServices: nil, options: [
CBCentralManagerScanOptionAllowDuplicatesKey: true
])
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.stopScan()
}
}
}
return results.sorted { $0.rssi > $1.rssi }
}
func stopScan() {
centralManager.stopScan()
scanTimer?.invalidate()
scanTimer = nil
isScanning = false
let results = discoveredBeacons
if let cont = scanContinuation {
scanContinuation = nil
cont.resume(returning: results)
}
}
/// Verify a beacon is broadcasting expected iBeacon values.
/// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor.
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
// TODO: Implement iBeacon region monitoring via CLLocationManager
// CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts
// For now, return a placeholder that prompts manual verification
return VerifyResult(
found: false,
rssi: nil,
message: "iBeacon verification requires CLLocationManager — coming soon"
)
}
// MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
func detectBeaconType(
name: String?,
serviceUUIDs: [CBUUID]?,
manufacturerData: Data?
) -> BeaconType {
let deviceName = (name ?? "").lowercased()
// 1. Service UUID matching
if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() }
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
return .bluecharm
}
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// Could be KBeacon or DXSmart check name to differentiate
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
return .dxsmart
}
return .kbeacon
}
}
// 2. Device name patterns
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
deviceName.hasPrefix("kb") {
return .kbeacon
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
}
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-smart") || deviceName.contains("dxsmart") ||
deviceName.contains("pddaxlque") {
return .dxsmart
}
// 3. Generic beacon patterns
if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
deviceName.hasPrefix("ble") {
return .dxsmart // Default to DXSmart like Android
}
// 4. Check manufacturer data for iBeacon advertisement
if let mfgData = manufacturerData, mfgData.count >= 23 {
// Apple iBeacon prefix: 0x4C00 0215
if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 {
// Extract minor (bytes 22-23) high minors suggest DXSmart factory defaults
if mfgData.count >= 24 {
let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
if minorVal > 10000 { return .dxsmart }
}
return .kbeacon
}
}
return .unknown
}
}
// MARK: - CBCentralManagerDelegate
extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
bluetoothState = central.state
}
}
nonisolated func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
Task { @MainActor in
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
// Only show recognized beacons
guard type != .unknown else { return }
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
// Update existing
discoveredBeacons[idx].rssi = RSSI.intValue
discoveredBeacons[idx].lastSeen = Date()
} else {
// New beacon
let beacon = DiscoveredBeacon(
id: peripheral.identifier,
peripheral: peripheral,
name: name,
type: type,
rssi: RSSI.intValue,
lastSeen: Date()
)
discoveredBeacons.append(beacon)
}
}
}
}