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:
parent
bc81ca98b0
commit
d5f0721215
1 changed files with 118 additions and 80 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue