Use BluetoothLeScanner directly to bypass GATT denylist

The AltBeacon library's results were being filtered by Android's
GATT denylist ("Skipping data matching denylist"). Switch to using
BluetoothLeScanner directly with a ScanFilter for iBeacon manufacturer
data (0x004C). This bypasses the denylist and successfully detects
all beacons.

- SCAN_MODE_LOW_LATENCY for fastest detection
- ScanFilter matches iBeacon type prefix (0x02 0x15)
- 2-second scan captures ~10 samples per beacon
- Parses UUID directly from manufacturer data bytes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-27 00:59:40 -08:00
parent bc81ca98b0
commit d5f0721215

View file

@ -3,6 +3,12 @@ package com.payfrit.app
import android.Manifest import android.Manifest
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
@ -10,19 +16,21 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.altbeacon.beacon.*
/** /**
* Native beacon scanner using AltBeacon library. * Native beacon scanner using BluetoothLeScanner directly.
* Scans for iBeacons and reports UUID + RSSI to Flutter. * Bypasses AltBeacon to avoid GATT denylist issues.
*/ */
class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifier { class BeaconScanner(private val context: Context) {
companion object { companion object {
private const val TAG = "BeaconScanner" private const val TAG = "BeaconScanner"
// iBeacon layout // iBeacon manufacturer ID (Apple)
private const val IBEACON_LAYOUT = "m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24" private const val IBEACON_MANUFACTURER_ID = 0x004C
// iBeacon type identifier (first two bytes after manufacturer ID)
private const val IBEACON_TYPE = 0x0215
// Scan duration in milliseconds (beacons broadcast at 200ms, so 2s = ~10 samples) // Scan duration in milliseconds (beacons broadcast at 200ms, so 2s = ~10 samples)
private const val SCAN_DURATION_MS = 2000L private const val SCAN_DURATION_MS = 2000L
@ -31,7 +39,7 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
private const val MIN_RSSI = -90 private const val MIN_RSSI = -90
} }
private var beaconManager: BeaconManager? = null private var bluetoothLeScanner: BluetoothLeScanner? = null
private var isScanning = false private var isScanning = false
private var scanCallback: ((List<Map<String, Any>>) -> Unit)? = null private var scanCallback: ((List<Map<String, Any>>) -> Unit)? = null
private var errorCallback: ((String) -> Unit)? = null private var errorCallback: ((String) -> Unit)? = null
@ -41,6 +49,25 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val bleScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
processResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
for (result in results) {
processResult(result)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE Scan failed with error code: $errorCode")
mainHandler.post {
errorCallback?.invoke("BLE scan failed: error $errorCode")
}
}
}
/** /**
* Check if Bluetooth permissions are granted * Check if Bluetooth permissions are granted
*/ */
@ -73,7 +100,7 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
/** /**
* Start scanning for beacons * Start scanning for beacons
* @param regions List of UUID strings to scan for * @param regions List of UUID strings to scan for (not used for filtering, scan all iBeacons)
* @param onComplete Callback with list of detected beacons [{uuid, rssi, samples}] * @param onComplete Callback with list of detected beacons [{uuid, rssi, samples}]
* @param onError Callback for errors * @param onError Callback for errors
*/ */
@ -97,7 +124,16 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
return return
} }
Log.d(TAG, "Starting beacon scan for ${regions.size} regions") val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
val adapter = bluetoothManager?.adapter
bluetoothLeScanner = adapter?.bluetoothLeScanner
if (bluetoothLeScanner == null) {
onError("Bluetooth LE Scanner not available")
return
}
Log.d(TAG, "Starting BLE beacon scan")
isScanning = true isScanning = true
scanCallback = onComplete scanCallback = onComplete
@ -105,28 +141,76 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
beaconSamples.clear() beaconSamples.clear()
try { try {
// Initialize beacon manager // Create scan filter for iBeacon manufacturer data (Apple 0x004C)
beaconManager = BeaconManager.getInstanceForApplication(context).apply { // This filters at the BLE level and should bypass the GATT denylist
// Configure for iBeacon val scanFilter = ScanFilter.Builder()
beaconParsers.clear() .setManufacturerData(
beaconParsers.add(BeaconParser().setBeaconLayout(IBEACON_LAYOUT)) IBEACON_MANUFACTURER_ID,
byteArrayOf(0x02, 0x15), // iBeacon type prefix
byteArrayOf(0xFF.toByte(), 0xFF.toByte()) // Mask: match both bytes
)
.build()
// Faster scan settings for immediate detection // Use LOW_LATENCY mode for fastest detection
foregroundScanPeriod = SCAN_DURATION_MS val scanSettings = ScanSettings.Builder()
foregroundBetweenScanPeriod = 0 .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setReportDelay(0) // Report immediately
.build()
addRangeNotifier(this@BeaconScanner) Log.d(TAG, "Starting BLE scan with iBeacon filter")
} bluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, bleScanCallback)
beaconManager?.bind(this) // Schedule scan completion
mainHandler.postDelayed({
completeScan()
}, SCAN_DURATION_MS)
} catch (e: Exception) { } catch (e: SecurityException) {
Log.e(TAG, "Failed to initialize beacon scanner", e) Log.e(TAG, "Security exception starting scan", e)
isScanning = false isScanning = false
onError("Failed to initialize scanner: ${e.message}") onError("Permission denied: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Failed to start beacon scan", e)
isScanning = false
onError("Failed to start scan: ${e.message}")
} }
} }
/**
* Process a single scan result and extract iBeacon UUID
*/
private fun processResult(result: ScanResult) {
val rssi = result.rssi
if (rssi < MIN_RSSI) return
val scanRecord = result.scanRecord ?: return
val uuid = parseIBeaconUuid(scanRecord) ?: return
Log.d(TAG, "Detected iBeacon: $uuid RSSI=$rssi")
synchronized(beaconSamples) {
beaconSamples.getOrPut(uuid) { mutableListOf() }.add(rssi)
}
}
/**
* Parse iBeacon UUID from scan record manufacturer data
*/
private fun parseIBeaconUuid(scanRecord: ScanRecord): String? {
val manufacturerData = scanRecord.getManufacturerSpecificData(IBEACON_MANUFACTURER_ID)
if (manufacturerData == null || manufacturerData.size < 23) {
return null
}
// Check iBeacon type (0x02 0x15)
if (manufacturerData[0] != 0x02.toByte() || manufacturerData[1] != 0x15.toByte()) {
return null
}
// Extract UUID (bytes 2-17)
val uuidBytes = manufacturerData.sliceArray(2..17)
return uuidBytes.joinToString("") { "%02X".format(it) }
}
/** /**
* Stop scanning and clean up * Stop scanning and clean up
*/ */
@ -135,64 +219,26 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
isScanning = false isScanning = false
try { try {
beaconManager?.removeAllRangeNotifiers() bluetoothLeScanner?.stopScan(bleScanCallback)
beaconManager?.unbind(this) } catch (e: SecurityException) {
Log.w(TAG, "Security exception stopping scan", e)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error stopping scan", e) Log.w(TAG, "Error stopping scan", e)
} }
beaconManager = null bluetoothLeScanner = null
}
// BeaconConsumer implementation
override fun onBeaconServiceConnect() {
Log.d(TAG, "Beacon service connected")
try {
// Start ranging for all iBeacons (wildcard region)
val region = Region("all-beacons", null, null, null)
beaconManager?.startRangingBeacons(region)
// Schedule scan completion
mainHandler.postDelayed({
completeScan()
}, SCAN_DURATION_MS)
} catch (e: Exception) {
Log.e(TAG, "Failed to start ranging", e)
errorCallback?.invoke("Failed to start ranging: ${e.message}")
stopScan()
}
}
override fun getApplicationContext(): Context = context.applicationContext
override fun unbindService(connection: android.content.ServiceConnection) {
context.unbindService(connection)
}
override fun bindService(intent: android.content.Intent, connection: android.content.ServiceConnection, flags: Int): Boolean {
return context.bindService(intent, connection, flags)
}
// RangeNotifier implementation
override fun didRangeBeaconsInRegion(beacons: MutableCollection<Beacon>, region: Region) {
for (beacon in beacons) {
val uuid = beacon.id1.toString().uppercase().replace("-", "")
val rssi = beacon.rssi
if (rssi >= MIN_RSSI) {
Log.d(TAG, "Detected beacon: $uuid RSSI=$rssi")
beaconSamples.getOrPut(uuid) { mutableListOf() }.add(rssi)
}
}
} }
private fun completeScan() { private fun completeScan() {
Log.d(TAG, "Scan complete. Found ${beaconSamples.size} unique beacons") val samplesCopy: Map<String, List<Int>>
synchronized(beaconSamples) {
samplesCopy = beaconSamples.mapValues { it.value.toList() }
}
Log.d(TAG, "Scan complete. Found ${samplesCopy.size} unique beacons")
// Build results // Build results
val results = beaconSamples.map { (uuid, samples) -> val results = samplesCopy.map { (uuid, samples) ->
val avgRssi = samples.average().toInt() val avgRssi = samples.average().toInt()
mapOf<String, Any>( mapOf<String, Any>(
"uuid" to uuid, "uuid" to uuid,
@ -201,14 +247,6 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
) )
}.sortedByDescending { it["rssi"] as Int } }.sortedByDescending { it["rssi"] as Int }
// Stop scanning
try {
val region = Region("all-beacons", null, null, null)
beaconManager?.stopRangingBeacons(region)
} catch (e: Exception) {
Log.w(TAG, "Error stopping ranging", e)
}
stopScan() stopScan()
// Return results on main thread // Return results on main thread