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 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

  1. 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.
  2. Send Init Packet: Transmit the firmware metadata (total size, CRC32, firmware version, hardware compatibility flags). The peripheral validates compatibility before accepting data.
  3. 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.
  4. 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.
  5. 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

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:

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

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.