BLE Over-the-Air Firmware Updates on Android: DFU Implementation Guide
Shipping a BLE peripheral without over-the-air (OTA) firmware update capability is like deploying a server with no SSH access. Eventually something will need to be fixed, and if you cannot push a firmware update wirelessly, your only option is a physical recall or a manual USB update at a service center. For neurotechnology devices, wearables, and medical peripherals, OTA DFU (Device Firmware Update) is not optional.
The challenge is that BLE was not originally designed for large data transfers. Pushing a 200KB firmware image over a link that delivers maybe 20KB/s of real throughput, while handling Android's BLE stack quirks, connection drops, and the possibility of bricking a device mid-update, requires careful engineering. At DEVSFLOW in Toronto, we have implemented DFU across multiple neurotechnology and wearable projects for clients including RE-AK Technologies and CLEIO. This guide covers what we have learned about making OTA updates reliable on Android.
1. Nordic DFU Library: The Standard Starting Point
If your peripheral runs on a Nordic Semiconductor chipset (nRF52 or nRF53 series), the Nordic Android DFU Library is the best starting point. Nordic has invested significant effort into making DFU work reliably across the Android device landscape, and their library handles many edge cases that you would otherwise need to discover the hard way.
Adding the Nordic DFU library
// build.gradle.kts
dependencies {
implementation("no.nordicsemi.android:dfu:2.5.0")
}
Basic DFU implementation
class FirmwareUpdateManager(private val context: Context) {
fun startDfu(deviceAddress: String, firmwareUri: Uri, deviceName: String) {
val starter = DfuServiceInitiator(deviceAddress)
.setDeviceName(deviceName)
.setKeepBond(true)
.setForceDfu(false)
.setPacketsReceiptNotificationsEnabled(true)
.setPacketsReceiptNotificationsValue(12)
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
.setZip(firmwareUri)
// Set foreground service notification for Android 8+
starter.setForegroundService(true)
val controller = starter.start(context, DfuService::class.java)
}
}
class DfuService : DfuBaseService() {
override fun getNotificationTarget(): Class<out Activity> {
return MainActivity::class.java
}
override fun isDebug(): Boolean = BuildConfig.DEBUG
}
The setPacketsReceiptNotificationsEnabled(true) call is critical on Android. Without Packet Receipt Notifications (PRN), the Android BLE stack can overflow its internal buffer during fast writes, causing the DFU to fail. The value of 12 means the peripheral will send an acknowledgment every 12 packets, giving Android time to clear its write queue.
Listening to DFU progress
class DfuProgressListener : DfuProgressListenerAdapter() {
override fun onDeviceConnecting(deviceAddress: String) {
updateUi(DfuState.Connecting)
}
override fun onDfuProcessStarting(deviceAddress: String) {
updateUi(DfuState.Preparing)
}
override fun onProgressChanged(
deviceAddress: String,
percent: Int,
speed: Float,
avgSpeed: Float,
currentPart: Int,
partsTotal: Int
) {
updateUi(DfuState.Uploading(
percent = percent,
speedKbps = speed,
avgSpeedKbps = avgSpeed,
currentPart = currentPart,
totalParts = partsTotal
))
}
override fun onDfuCompleted(deviceAddress: String) {
updateUi(DfuState.Complete)
}
override fun onError(
deviceAddress: String,
error: Int,
errorType: Int,
message: String?
) {
updateUi(DfuState.Error(error, message ?: "Unknown error"))
}
}
Register the listener in your activity or fragment's onResume and unregister in onPause. The Nordic library runs the DFU in a foreground service, so the update continues even if the user navigates away from your app.
2. Designing a Custom DFU Protocol
Not every peripheral uses a Nordic chipset. If you are working with a Texas Instruments CC26xx, Dialog DA1469x, Espressif ESP32, or a custom ASIC, you will need to implement your own DFU protocol. Here is a proven architecture that handles the major failure modes.
Protocol overview
A robust DFU protocol needs these GATT characteristics:
- DFU Control Point (Write + Notify): Used to send commands (start DFU, validate, activate) and receive responses (ready, error codes, validation results).
- DFU Data (Write Without Response): Used for the actual firmware data transfer. Write Without Response is essential for throughput because it does not wait for an ATT acknowledgment per packet.
- DFU Version (Read): Reports the current DFU protocol version so the app can handle protocol changes across firmware generations.
DFU state machine
sealed class DfuCommand(val opCode: Byte) {
object StartDfu : DfuCommand(0x01)
data class InitPacket(val size: Int, val crc32: Long) : DfuCommand(0x02)
object RequestMtu : DfuCommand(0x03)
data class SendFirmware(val offset: Int) : DfuCommand(0x04)
object ValidateFirmware : DfuCommand(0x05)
object ActivateAndReset : DfuCommand(0x06)
object AbortDfu : DfuCommand(0x07)
}
sealed class DfuResponse(val opCode: Byte) {
object Ready : DfuResponse(0x01)
data class MtuResponse(val mtu: Int) : DfuResponse(0x03)
data class ReceiptNotification(val offset: Int, val crc32: Long) : DfuResponse(0x04)
object ValidationSuccess : DfuResponse(0x05)
data class Error(val code: Int) : DfuResponse(0xFF.toByte())
}
The update sequence
- Negotiate MTU: Request the largest MTU the peripheral supports. A 247-byte MTU gives you 244 bytes of ATT payload per packet (3 bytes for ATT header), which dramatically improves transfer speed.
- Send Init Packet: Transmit the firmware metadata (total size, CRC32, firmware version, hardware compatibility flags). The peripheral validates compatibility before accepting data.
- Transfer firmware data: Send the firmware binary in chunks matching the MTU. Use Write Without Response for speed. Every N packets, the peripheral sends a receipt notification with the current offset and a running CRC32.
- Validate: After all data is sent, the peripheral computes the full CRC32 and compares it to the value from the init packet. If validation passes, it signals ready to activate.
- Activate and reset: The peripheral copies the new firmware to the active partition, resets, and boots the new image. The app waits for the device to reappear with the new firmware version.
3. Handling Large Firmware Images Over BLE
A typical firmware image for a Cortex-M4 based peripheral is 100KB to 500KB. At realistic BLE throughput (10-25 KB/s depending on connection parameters), a 250KB image takes 10-25 seconds to transfer. This is long enough for many things to go wrong.
Chunking and flow control
class FirmwareUploader(
private val gatt: BluetoothGatt,
private val dataCharacteristic: BluetoothGattCharacteristic,
private val mtu: Int
) {
private val chunkSize = mtu - 3 // ATT header overhead
private var firmware: ByteArray = byteArrayOf()
private var offset = 0
private var pendingWrites = 0
private val maxPendingWrites = 8 // Flow control window
fun startUpload(firmwareData: ByteArray, startOffset: Int = 0) {
firmware = firmwareData
offset = startOffset
sendNextChunks()
}
private fun sendNextChunks() {
while (pendingWrites < maxPendingWrites && offset < firmware.size) {
val end = minOf(offset + chunkSize, firmware.size)
val chunk = firmware.copyOfRange(offset, end)
dataCharacteristic.value = chunk
dataCharacteristic.writeType =
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
if (gatt.writeCharacteristic(dataCharacteristic)) {
offset += chunk.size
pendingWrites++
} else {
// Write queue full, wait for onCharacteristicWrite callback
break
}
}
}
fun onWriteCompleted() {
pendingWrites--
sendNextChunks()
}
}
The maxPendingWrites window is essential. Android's BLE stack has an internal write queue, and if you flood it with Write Without Response operations, the stack silently drops packets. A window of 6-10 pending writes works well across most Android devices. On some Samsung devices, you may need to reduce this to 4.
Resume after interruption
The most important feature of any DFU implementation is the ability to resume a failed transfer. If the connection drops at 80% progress, the user should not have to start over from zero. The receipt notification mechanism enables this: the peripheral reports its current offset, and the app resumes from that point.
fun onReconnectAfterFailure(gatt: BluetoothGatt) {
// Ask the peripheral where it left off
writeControlPoint(DfuCommand.SendFirmware(offset = 0)) // offset 0 = "tell me yours"
}
fun onReceiptNotification(peripheralOffset: Int, peripheralCrc: Long) {
// Verify CRC matches our data up to that offset
val ourCrc = CRC32().apply {
update(firmware, 0, peripheralOffset)
}.value
if (ourCrc == peripheralCrc) {
// CRCs match, resume from peripheral's offset
startUpload(firmware, startOffset = peripheralOffset)
} else {
// CRC mismatch, data corruption, restart from beginning
startUpload(firmware, startOffset = 0)
}
}
4. Progress Tracking and UI Design for DFU
DFU is one of the most anxiety-inducing operations for users. They are updating the brain of their device over a wireless link, and if something goes wrong, they fear the device might be bricked. Good progress UI is critical for user confidence.
What to show the user
- Phase indicator: Show the current phase (Preparing, Uploading, Validating, Completing) so users understand the process has multiple steps.
- Progress percentage: A smooth progress bar based on bytes transferred, not packet count. Update it frequently (every 1-2%) to show continuous activity.
- Estimated time remaining: Calculate from average throughput over the last 5 seconds. Smooth the estimate to avoid jittery countdowns.
- Speed indicator: Show current transfer speed in KB/s. This helps developers during testing and reassures users that data is flowing.
- Clear warnings: Display prominent warnings not to close the app, turn off Bluetooth, or move away from the device during the update.
data class DfuUiState(
val phase: DfuPhase = DfuPhase.Idle,
val progressPercent: Float = 0f,
val speedKbps: Float = 0f,
val estimatedSecondsRemaining: Int = 0,
val bytesTransferred: Long = 0,
val totalBytes: Long = 0,
val errorMessage: String? = null
)
enum class DfuPhase(val displayName: String) {
Idle("Ready"),
Connecting("Connecting to device..."),
Preparing("Preparing update..."),
Uploading("Uploading firmware..."),
Validating("Validating firmware..."),
Activating("Activating new firmware..."),
Complete("Update complete!"),
Error("Update failed")
}
class DfuViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DfuUiState())
val uiState: StateFlow<DfuUiState> = _uiState.asStateFlow()
private val speedSamples = ArrayDeque<Pair<Long, Long>>(10) // timestamp, bytes
fun onProgress(bytesTransferred: Long, totalBytes: Long) {
val now = System.currentTimeMillis()
speedSamples.addLast(now to bytesTransferred)
if (speedSamples.size > 10) speedSamples.removeFirst()
val speed = calculateSpeed()
val remaining = if (speed > 0) {
((totalBytes - bytesTransferred) / speed / 1000).toInt()
} else 0
_uiState.update {
it.copy(
phase = DfuPhase.Uploading,
progressPercent = bytesTransferred.toFloat() / totalBytes * 100,
speedKbps = speed / 1024f,
estimatedSecondsRemaining = remaining,
bytesTransferred = bytesTransferred,
totalBytes = totalBytes
)
}
}
private fun calculateSpeed(): Float {
if (speedSamples.size < 2) return 0f
val first = speedSamples.first()
val last = speedSamples.last()
val timeDelta = last.first - first.first
if (timeDelta == 0L) return 0f
return (last.second - first.second).toFloat() / timeDelta * 1000
}
}
5. Recovering from Failed Updates
DFU failures are inevitable. The user walks out of range. The phone's Bluetooth stack crashes. The battery dies mid-transfer. A robust DFU implementation must handle every failure mode without bricking the device.
Dual-bank (A/B) firmware architecture
The gold standard for safe OTA updates is a dual-bank architecture. The peripheral has two firmware slots: Bank A (active) and Bank B (staging). The new firmware is written to Bank B while Bank A continues running. Only after Bank B is fully validated does the bootloader swap the active bank. If the new firmware fails to boot, the bootloader automatically falls back to the previous version in Bank A.
From the Android app's perspective, this means:
- The device is always usable, even during a firmware update (the current firmware keeps running in Bank A)
- If the update fails at any point, the device continues running the old firmware
- If the new firmware crashes on first boot, the device reverts automatically
Retry logic on the Android side
class DfuRetryManager(
private val maxRetries: Int = 3,
private val retryDelayMs: Long = 2000
) {
private var retryCount = 0
private var lastSuccessfulOffset = 0
suspend fun executeWithRetry(
startDfu: suspend (resumeOffset: Int) -> DfuResult
): DfuResult {
while (retryCount < maxRetries) {
val result = startDfu(lastSuccessfulOffset)
when (result) {
is DfuResult.Success -> return result
is DfuResult.PartialSuccess -> {
lastSuccessfulOffset = result.offset
retryCount++
Log.w(TAG, "DFU failed at offset ${result.offset}, " +
"retry $retryCount/$maxRetries")
delay(retryDelayMs * retryCount) // Exponential-ish backoff
}
is DfuResult.FatalError -> {
// Non-recoverable error (wrong firmware, incompatible hardware)
return result
}
}
}
return DfuResult.MaxRetriesExceeded(retryCount, lastSuccessfulOffset)
}
}
Distinguish between recoverable errors (connection drops, timeouts) and fatal errors (CRC mismatch after full transfer, incompatible firmware version). Only retry on recoverable errors. A CRC mismatch after a complete transfer suggests a corrupted firmware file, and retrying will not fix it.
6. Validating Firmware Integrity
Firmware integrity validation happens at multiple levels, and each level catches different classes of errors.
Level 1: File-level validation (before transfer)
Before starting the DFU, validate the firmware file on the Android side:
data class FirmwareMetadata(
val version: String,
val targetHardware: String,
val size: Int,
val crc32: Long,
val signature: ByteArray? = null
)
fun validateFirmwareFile(
file: ByteArray,
metadata: FirmwareMetadata,
currentHardwareVersion: String
): ValidationResult {
// Check file size matches metadata
if (file.size != metadata.size) {
return ValidationResult.SizeMismatch(file.size, metadata.size)
}
// Verify CRC32
val computedCrc = CRC32().apply { update(file) }.value
if (computedCrc != metadata.crc32) {
return ValidationResult.CrcMismatch(computedCrc, metadata.crc32)
}
// Check hardware compatibility
if (metadata.targetHardware != currentHardwareVersion) {
return ValidationResult.IncompatibleHardware(
metadata.targetHardware, currentHardwareVersion
)
}
// Verify cryptographic signature if present
metadata.signature?.let { sig ->
if (!verifySignature(file, sig)) {
return ValidationResult.InvalidSignature
}
}
return ValidationResult.Valid
}
Level 2: Transfer-level validation (during transfer)
Use running CRC32 checks during transfer via receipt notifications. Every N packets, compare the peripheral's running CRC with the app's computed CRC for the same byte range. This catches bit errors and dropped packets early, before the entire image has been transferred.
Level 3: Image-level validation (after transfer)
After the full image is transferred, the peripheral computes a CRC32 (or SHA-256 for higher assurance) over the entire received image and reports it back. The app compares this to its expected value. Only if they match does the app send the activate command.
Level 4: Boot-level validation (after activation)
The bootloader validates the firmware image before executing it. This typically involves checking a signature or hash stored in the image header. If validation fails, the bootloader stays in DFU mode or reverts to the previous firmware bank.
For medical device projects in Canada, where Health Canada regulations apply, cryptographic firmware signing is not just good practice but a regulatory expectation. Use ECDSA with P-256 or Ed25519 for firmware signatures. The signing key stays in your build server; the verification key is baked into the bootloader.
7. Android-Specific DFU Pitfalls
Android's BLE stack introduces several DFU-specific issues that you will not encounter on iOS. These are the ones that have caused the most debugging hours across our projects.
Connection interval during DFU
Android does not give you direct control over BLE connection parameters. The peripheral can request a specific connection interval, but Android may reject or modify the request. During DFU, you want the shortest possible connection interval (7.5ms to 15ms) for maximum throughput. However, some Android devices will force a longer interval (30ms or even 45ms), cutting your throughput in half.
The workaround is to use BluetoothGatt.requestConnectionPriority(CONNECTION_PRIORITY_HIGH) immediately after connecting for DFU:
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
// Request high priority for DFU throughput
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
// Wait briefly for parameters to update before starting DFU
handler.postDelayed({
gatt.requestMtu(517) // Request maximum MTU
}, 500)
}
}
MTU renegotiation after bonding
On some Android devices (notably Samsung Galaxy S series), the MTU reverts to the default 23 bytes after the device bonds during a secure DFU. You must re-request the MTU after bonding completes. Listen for ACTION_BOND_STATE_CHANGED broadcasts and re-request MTU when the bond state transitions to BOND_BONDED.
Foreground service requirements
On Android 8 and above, long-running BLE operations must run in a foreground service with an active notification. If your DFU runs in the background without a foreground service, Android will kill your process after approximately 10 minutes, mid-update. The Nordic DFU library handles this automatically, but custom implementations must explicitly start a foreground service:
class DfuForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, DFU_CHANNEL_ID)
.setContentTitle("Firmware Update in Progress")
.setContentText("Do not close the app or turn off Bluetooth")
.setSmallIcon(R.drawable.ic_update)
.setProgress(100, 0, false)
.setOngoing(true)
.build()
startForeground(DFU_NOTIFICATION_ID, notification)
return START_STICKY
}
fun updateProgress(percent: Int) {
val notification = NotificationCompat.Builder(this, DFU_CHANNEL_ID)
.setContentTitle("Firmware Update in Progress")
.setContentText("$percent% complete")
.setSmallIcon(R.drawable.ic_update)
.setProgress(100, percent, false)
.setOngoing(true)
.build()
NotificationManagerCompat.from(this).notify(DFU_NOTIFICATION_ID, notification)
}
companion object {
const val DFU_CHANNEL_ID = "dfu_channel"
const val DFU_NOTIFICATION_ID = 1001
}
}
Doze mode and App Standby
Even with a foreground service, Doze mode can interfere with BLE operations on Android 6+. When the screen turns off and the device enters Doze, BLE connection intervals may be extended by the system. Request that users keep the screen on during DFU, or acquire a partial wake lock (with appropriate battery usage disclosures).
8. Testing Your DFU Implementation
DFU is one of the hardest features to test thoroughly because failure cases are the ones that matter most. Here is our testing checklist, developed across multiple neurotech device projects at our Toronto lab.
Essential test scenarios
- Happy path: Full update from start to finish on at least 5 different Android device models spanning Qualcomm, Samsung Exynos, and MediaTek chipsets.
- Range boundary: Start a DFU at the edge of BLE range. The update should either complete or fail gracefully and resume after the user moves closer.
- App killed mid-transfer: Force-kill the app at 50% progress. Relaunch and verify the DFU resumes from the last checkpoint.
- Bluetooth toggled mid-transfer: Turn Bluetooth off and on during DFU. The app should detect the disconnection, wait for Bluetooth to re-enable, reconnect, and resume.
- Phone call during DFU: A phone call causes Android to reconfigure the Bluetooth link for SCO audio. Verify the DFU handles this gracefully.
- Low battery on peripheral: If the peripheral's battery is too low for a safe update, it should reject the DFU init command. The app should display a clear message telling the user to charge the device.
- Corrupted firmware file: Modify a byte in the firmware binary and verify that validation catches it, either before transfer or after.
- Downgrade prevention: Attempt to install an older firmware version and verify the peripheral or bootloader rejects it (if your protocol supports version enforcement).
- Power loss during activation: This is the scariest scenario. If the peripheral loses power while swapping firmware banks, the bootloader must recover. Test this by cutting power to the peripheral during the activation phase.
Automate as many of these tests as possible. For connection-drop scenarios, you can use a programmable BLE attenuator or simply wrap the peripheral in a metal enclosure to reduce signal strength on demand. For process-kill tests, use adb shell am kill to force-stop the app at specific DFU progress points.
Need to implement reliable OTA firmware updates for your BLE device? Talk to DEVSFLOW Neuro. We build BLE-connected mobile apps for neurotechnology and medical device companies across Canada.