diff --git a/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt b/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt index 4594afb..a35b645 100644 --- a/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt +++ b/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt @@ -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>) -> 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) { + 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, 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> + 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( "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