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.bluetooth.BluetoothAdapter
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.pm.PackageManager
import android.os.Build
@ -10,19 +16,21 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import org.altbeacon.beacon.*
/**
* Native beacon scanner using AltBeacon library.
* Scans for iBeacons and reports UUID + RSSI to Flutter.
* Native beacon scanner using BluetoothLeScanner directly.
* Bypasses AltBeacon to avoid GATT denylist issues.
*/
class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifier {
class BeaconScanner(private val context: Context) {
companion object {
private const val TAG = "BeaconScanner"
// iBeacon layout
private const val IBEACON_LAYOUT = "m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24"
// iBeacon manufacturer ID (Apple)
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)
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 var beaconManager: BeaconManager? = null
private var bluetoothLeScanner: BluetoothLeScanner? = null
private var isScanning = false
private var scanCallback: ((List<Map<String, Any>>) -> 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 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
*/
@ -73,7 +100,7 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
/**
* 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 onError Callback for errors
*/
@ -97,7 +124,16 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
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
scanCallback = onComplete
@ -105,28 +141,76 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
beaconSamples.clear()
try {
// Initialize beacon manager
beaconManager = BeaconManager.getInstanceForApplication(context).apply {
// Configure for iBeacon
beaconParsers.clear()
beaconParsers.add(BeaconParser().setBeaconLayout(IBEACON_LAYOUT))
// Create scan filter for iBeacon manufacturer data (Apple 0x004C)
// This filters at the BLE level and should bypass the GATT denylist
val scanFilter = ScanFilter.Builder()
.setManufacturerData(
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
foregroundScanPeriod = SCAN_DURATION_MS
foregroundBetweenScanPeriod = 0
// Use LOW_LATENCY mode for fastest detection
val scanSettings = ScanSettings.Builder()
.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) {
Log.e(TAG, "Failed to initialize beacon scanner", e)
} catch (e: SecurityException) {
Log.e(TAG, "Security exception starting scan", e)
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
*/
@ -135,64 +219,26 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
isScanning = false
try {
beaconManager?.removeAllRangeNotifiers()
beaconManager?.unbind(this)
bluetoothLeScanner?.stopScan(bleScanCallback)
} catch (e: SecurityException) {
Log.w(TAG, "Security exception stopping scan", e)
} catch (e: Exception) {
Log.w(TAG, "Error stopping scan", e)
}
beaconManager = 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)
}
}
bluetoothLeScanner = null
}
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
val results = beaconSamples.map { (uuid, samples) ->
val results = samplesCopy.map { (uuid, samples) ->
val avgRssi = samples.average().toInt()
mapOf<String, Any>(
"uuid" to uuid,
@ -201,14 +247,6 @@ class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifie
)
}.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()
// Return results on main thread