BLE Device Scanning and Connection Implementation
The task seems simple: find a BLE device, connect. In practice — it's handling eight different adapter states, different scan modes, and quirks of specific chipsets. We'll cover only this part of the BLE stack, without reading characteristics.
iOS: Scanning via CBCentralManager
Scanning possible only when CBCentralManager.state == .poweredOn. Other states must be handled:
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
startScanning()
case .poweredOff:
showAlert("Enable Bluetooth in settings")
case .unauthorized:
// iOS 13+ — CBManager.authorization
if CBCentralManager.authorization == .denied {
showSettingsLink()
}
case .unsupported:
showAlert("Device does not support Bluetooth LE")
case .resetting:
// stack reloading, wait for .poweredOn
break
default:
break
}
}
Filtering During Scan
// Scan only devices with required service
let serviceUUIDs = [CBUUID(string: "YOUR-SERVICE-UUID")]
centralManager.scanForPeripherals(withServices: serviceUUIDs, options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
withServices: nil — scans all nearby BLE devices. Convenient during development. Don't use in production: uses more battery and clutters list with foreign devices.
Reconnecting to Known Device
If peripheral UUID saved (e.g., in UserDefaults), restore object without rescanning:
let knownUUID = UUID(uuidString: savedUUIDString)!
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [knownUUID])
if let peripheral = peripherals.first {
centralManager.connect(peripheral, options: nil)
} else {
// UUID outdated or device replaced — start full scan
startScanning()
}
Important for apps frequently reconnecting to one device (wristband, sensor). Without retrievePeripherals scan starts every time with delay.
Android: BluetoothLeScanner
val bluetoothAdapter = (getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
val scanner = bluetoothAdapter.bluetoothLeScanner
val filters = listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(UUID.fromString("YOUR-SERVICE-UUID")))
.build()
)
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // during active use
.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH)
.build()
val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val rssi = result.rssi
val advertisingData = result.scanRecord
// add to found devices list
}
override fun onScanFailed(errorCode: Int) {
// SCAN_FAILED_ALREADY_STARTED = 1 — scan already running
// SCAN_FAILED_APPLICATION_REGISTRATION_FAILED = 2 — too many scanners
// SCAN_FAILED_FEATURE_UNSUPPORTED = 4 — filters not supported (old devices)
}
}
scanner.startScan(filters, settings, scanCallback)
Scan Modes
| Mode | Frequency | Power | When to Use |
|---|---|---|---|
SCAN_MODE_LOW_POWER |
~512 ms | minimal | background search |
SCAN_MODE_BALANCED |
~512 ms / ~1.5s | medium | default |
SCAN_MODE_LOW_LATENCY |
continuous | high | active search in UI |
SCAN_MODE_OPPORTUNISTIC |
only if other scanner active | zero | passive monitoring |
Android 12+ Permissions
// Check before scanning
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
when {
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED -> {
requestPermissions(arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
), REQUEST_CODE)
}
}
}
BLUETOOTH_SCAN without neverForLocation flag requires ACCESS_FINE_LOCATION. If scanning not for location determination, in manifest:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
Then geolocation not needed and users won't be confused why Bluetooth app requests GPS.
Implementation timeframe: 2–3 days — scanning + connecting with all state and permission handling on both platforms. Cost calculated individually.







