fix: overhaul beacon discovery to match Android detection logic

Major detection gaps were causing iOS to miss 7-8 out of 8-9 nearby DX beacons:

1. FFF0 service UUID was incorrectly mapped exclusively to BlueCharm.
   Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 to DXSMART.
   Now checks device name to disambiguate FFF0 between DX and BlueCharm,
   defaulting to DXSmart (matching Android behavior).

2. Added DX factory default UUID detection (E2C56DB5-DFFB-48D2-B060-D0F5A71096E0).
   Android catches DX beacons by this UUID on line 130 of BeaconScanner.kt.
   iOS was missing this entirely.

3. Added Payfrit shard UUID detection — already-provisioned DX beacons
   broadcasting a shard UUID are now recognized.

4. Added iBeacon manufacturer data parsing with proper UUID extraction.
   Any device broadcasting valid iBeacon data is now included (not just
   those with minor > 10000).

5. Added permissive fallback matching Android lines 164-169: connectable
   devices with names are included even if type is unknown, so they're
   at least visible to the user.

6. Added FEA0 service UUID for BlueCharm (Android line 124).

7. Added "DX-CP" name pattern (Android line 138) that was missing.

Root cause: Android uses MAC OUI prefix 48:87:2D to catch all DX beacons
regardless of advertisement contents. iOS can't do this (CoreBluetooth
doesn't expose MAC addresses), so we compensate with broader iBeacon
UUID matching and more permissive device inclusion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-22 22:55:35 +00:00
parent a6ba88803c
commit 174240c13e

View file

@ -22,6 +22,10 @@ final class BLEManager: NSObject, ObservableObject {
// GATT Service UUIDs // GATT Service UUIDs
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB")
// DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Private // MARK: - Private
@ -88,10 +92,34 @@ final class BLEManager: NSObject, ObservableObject {
) )
} }
// MARK: - iBeacon Manufacturer Data Parsing
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00)
/// Returns (uuid, major, minor) if valid iBeacon advertisement found
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? {
// iBeacon format in manufacturer data:
// [0x4C 0x00] (Apple company ID) [0x02 0x15] (iBeacon type+length)
// [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte]
guard mfgData.count >= 25 else { return nil }
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
// Extract UUID (bytes 4-19)
let uuidBytes = mfgData.subdata(in: 4..<20)
let hex = uuidBytes.map { String(format: "%02X", $0) }.joined()
let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))"
// Extract Major (bytes 20-21) and Minor (bytes 22-23)
let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21])
let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
return (uuid: uuid, major: major, minor: minor)
}
// MARK: - Beacon Type Detection (matches Android BeaconScanner.kt) // MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
// NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D. // NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D.
// CoreBluetooth does not expose raw MAC addresses, so this detection // CoreBluetooth does not expose raw MAC addresses, so we compensate
// path is unavailable on iOS. We rely on service UUID + device name instead. // with broader iBeacon UUID detection and more permissive inclusion.
func detectBeaconType( func detectBeaconType(
name: String?, name: String?,
@ -100,12 +128,19 @@ final class BLEManager: NSObject, ObservableObject {
) -> BeaconType { ) -> BeaconType {
let deviceName = (name ?? "").lowercased() let deviceName = (name ?? "").lowercased()
// 1. Service UUID matching // Parse iBeacon data if available (needed for UUID-based detection)
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
if let mfgData = manufacturerData {
iBeaconData = parseIBeaconData(mfgData)
} else {
iBeaconData = nil
}
// 1. Service UUID matching (matches Android lines 122-126)
if let services = serviceUUIDs { if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() } let serviceStrings = services.map { $0.uuidString.uppercased() }
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
return .bluecharm // Android: KBeacon uses FFE0 as primary service
}
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// Could be KBeacon or DXSmart check name to differentiate // Could be KBeacon or DXSmart check name to differentiate
if deviceName.contains("cp28") || deviceName.contains("cp-28") || if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
@ -114,9 +149,45 @@ final class BLEManager: NSObject, ObservableObject {
} }
return .kbeacon return .kbeacon
} }
// Android: DXSmart also uses FFF0 (line 125)
// FIXED: Was incorrectly mapping FFF0 BlueCharm only.
// Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 DXSMART
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
// Check name patterns to decide: DXSmart or BlueCharm
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") ||
deviceName.isEmpty {
// DX beacons often have no name or DX-prefixed names
return .dxsmart
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
}
// Default FFF0 to DXSmart (matching Android behavior)
return .dxsmart
}
// Android: BlueCharm uses FEA0 (line 124)
if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) {
return .bluecharm
}
} }
// 2. Device name patterns // 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130)
// This is critical catches DX beacons that don't advertise service UUIDs
if let ibeacon = iBeaconData {
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
return .dxsmart
}
// Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon)
if BeaconShardPool.isPayfrit(ibeacon.uuid) {
return .dxsmart
}
}
// 3. Device name patterns (Android lines 131-147)
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") || if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
deviceName.hasPrefix("kb") { deviceName.hasPrefix("kb") {
return .kbeacon return .kbeacon
@ -126,29 +197,25 @@ final class BLEManager: NSObject, ObservableObject {
return .bluecharm return .bluecharm
} }
if deviceName.contains("cp28") || deviceName.contains("cp-28") || if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
deviceName.contains("pddaxlque") { deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") {
return .dxsmart return .dxsmart
} }
// 3. Generic beacon patterns // 4. Detect by iBeacon minor in high range (Android line 143)
if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
return .dxsmart
}
// 5. Generic beacon patterns (Android lines 145-147)
if deviceName.contains("ibeacon") || deviceName.contains("beacon") || if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
deviceName.hasPrefix("ble") { deviceName.hasPrefix("ble") {
return .dxsmart // Default to DXSmart like Android return .dxsmart // Default to DXSmart like Android
} }
// 4. Check manufacturer data for iBeacon advertisement // 6. Any remaining iBeacon advertisement still a beacon we should show
if let mfgData = manufacturerData, mfgData.count >= 23 { if iBeaconData != nil {
// Apple iBeacon prefix: 0x4C00 0215 return .dxsmart
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 return .unknown
@ -175,11 +242,21 @@ extension BLEManager: CBCentralManagerDelegate {
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
// Only show recognized beacons // Match Android behavior (lines 164-169):
guard type != .unknown else { return } // Include devices that have a recognized type, OR
// are broadcasting iBeacon data, OR
// are connectable with a name (potential configurable beacon)
if type == .unknown {
let hasName = !name.isEmpty
let hasIBeaconData = mfgData.flatMap { parseIBeaconData($0) } != nil
if !hasIBeaconData && !(isConnectable && hasName) {
return
}
}
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
// Update existing // Update existing