Scooter Unlock via BLE/QR in Mobile App
We develop unlock systems for electric scooters via mobile app. The user scans a QR code, our app communicates with the server and sends a BLE command—all within seconds. With 5+ years of work on 10+ sharing projects, we know: any failure in this path immediately erodes trust in the service. Any delay is perceived as a bug, so we pay attention to every millisecond. One of our clients, launching a kick-scooter sharing service in a city with 500 scooters, encountered that 15% of users abandoned the rental at the unlock stage—due to slow BLE connection. We optimized the protocol, and the churn dropped to 3%.
How QR Unlock Works
The QR on the scooter encodes the device ID: a string like https://ride.example.com/unlock?id=SC-00234 or simply SC-00234. We scan via ML Kit Barcode Scanning (Android/iOS) or AVFoundation AVCaptureMetadataOutput. ML Kit is preferred—it works without a network connection and is faster in low-light conditions.
After obtaining the ID, we send a request to the server: POST /api/v1/unlock with {scooterId, userId, sessionToken}. The server checks the balance/subscription, reserves the scooter, and returns {bleUnlockToken, bleDeviceAddress, lockServiceUUID, lockCharacteristicUUID, expiresAt}. The token is one-time, valid for 30–60 seconds—if the BLE connection fails in time, the user receives an error and initiates a new request.
// iOS: QR scanning via AVFoundation
class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard let readableObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue,
let scooterId = parseScooterId(from: stringValue) else { return }
captureSession.stopRunning()
Task { await viewModel.initiateUnlock(scooterId: scooterId) }
}
}
Why BLE Token Expires
Scooters in sharing use BLE modules based on Nordic nRF52 or ESP32 with custom GATT profile. The scheme is simple: we write the token to a WriteCharacteristic, the controller verifies the HMAC signature (shared secret embedded in firmware), and if matches, releases the electromagnetic lock and allows the motor.
// Android: writing unlock token to BLE characteristic
class ScooterUnlockManager(private val context: Context) {
private var gatt: BluetoothGatt? = null
suspend fun unlock(address: String, token: UnlockToken): UnlockResult {
return suspendCancellableCoroutine { cont ->
val device = bluetoothAdapter.getRemoteDevice(address)
gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, state: Int) {
if (state == BluetoothProfile.STATE_CONNECTED) g.discoverServices()
else if (status != BluetoothGatt.GATT_SUCCESS) {
cont.resume(UnlockResult.BleConnectionFailed(status))
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val characteristic = g
.getService(token.serviceUUID)
?.getCharacteristic(token.characteristicUUID)
?: run { cont.resume(UnlockResult.ServiceNotFound); return }
characteristic.value = token.payload.toByteArray()
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
g.writeCharacteristic(characteristic)
}
override fun onCharacteristicWrite(g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic, status: Int) {
val result = if (status == BluetoothGatt.GATT_SUCCESS)
UnlockResult.Success else UnlockResult.WriteFailed(status)
cont.resume(result)
g.disconnect()
}
}, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { gatt?.disconnect() }
}
}
}
Critical detail: TRANSPORT_LE in connectGatt—without this flag, Android might try to connect via Classic Bluetooth, resulting in status=133 on BLE-only devices.
Problems in Real Conditions
Poor BLE signal. The scooter is in an underground parking lot, surrounded by 20 other scooters with the same GATT services. We do not scan all devices indiscriminately; we connect directly: bluetoothAdapter.getRemoteDevice(address) using the MAC from the server response. This is faster than scanning the environment.
Expired token. The user scanned the QR, then got distracted for 2 minutes. The token expired. When receiving GATT_SUCCESS on write, but the scooter did not unlock—we need a Notify Characteristic for the controller's response. The controller writes back: 0x01 (OK), 0x02 (token expired), 0x03 (already locked by another session).
Android 12+ Bluetooth permissions. BLUETOOTH_SCAN and BLUETOOTH_CONNECT are now runtime permissions. If the user denied and selected 'Don't ask again', shouldShowRequestPermissionRationale returns false—we lead them to Settings via Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).
UX Details That Make a Difference
Parallel launch of server and BLE: as soon as the server response with the device address is received, we start BLE connection without waiting for the final UI animation. The user sees a spinner, but the BLE handshake is already happening. Saves ~300-500 ms. Also worth caching tokens: if the scooter was previously connected, we can try to restore the connection without a repeated QR scan. This reduces average unlock time by 20-30%.
Comparison of Unlock Approaches
| Characteristic | BLE | NFC | QR + BLE |
|---|---|---|---|
| Speed | 1-2 sec | 0.5 sec | 1-3 sec |
| Security | HMAC token | Crypto key | Token + HMAC |
| Compatibility | iOS/Android | iPhone 7+, Android 5+ | Almost all smartphones |
| Extra hardware | None | NFC module | BLE only |
| Reliability in noise | Medium (address connection) | High | High (address) |
Based on research from Bluetooth SIG and Apple Core Bluetooth documentation.
Common Mistakes in BLE Unlock Implementation
| Mistake | Consequence | Solution |
|---|---|---|
Missing TRANSPORT_LE |
Connection error on Android | Specify the parameter explicitly |
| Simultaneous requests from multiple devices | Token conflict | Reserve scooter on server |
| Lack of notifications after write | Unable to know result | Use Notify characteristic |
| Token without timestamp | Reuse possibility | HMAC with timestamp |
What the Work Includes
- QR scanning module for iOS and Android (ML Kit / AVFoundation)
- BLE unlock command with GATT profile support
- Backend service for authentication and reservation
- Testing on real scooters (Nordic nRF52, ESP32)
- Integration documentation and team training
Timelines and Cost
Development of QR scanning + BLE unlock module for a sharing platform (iOS + Android): 3-5 weeks. With complete fleet backend (reservation, billing, geofencing): 3-5 months. Cost starts from $15,000 for the unlock module alone, typically $25,000–$50,000 for full integration depending on complexity. Contact us for a free, no-obligation consultation—we'll provide a detailed quote within 1 business day.
Implementation Stages
- Audit of current scooter hardware (BLE module, firmware)
- Design of GATT profile and protocol
- Development of iOS module (Swift, Combine)
- Development of Android module (Kotlin, Coroutines)
- Integration with server (REST/GraphQL)
- Testing (unit, integration, field)
- Deploy to App Store and Google Play
- Monitoring and support after launch
With over 5 years of experience and 10+ successful projects, we guarantee a robust and secure solution. We provide a 12-month warranty on all code and full documentation. Assess your project within 1 business day. Get a consultation right now—write to us!







