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.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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue