DEV Community

Ble Advertiser
Ble Advertiser

Posted on

Robust BLE: Preventing Disconnections and Implementing Auto-Reconnect on Android 12+ with Foreground Services

Ever had your critical BLE connection drop silently in the background on Android, only to realize your IoT device stopped syncing data? You've spent countless hours debugging, only to find the OS simply killed your background process. This isn't just frustrating; it's a critical reliability issue for any serious IoT application. On Android 12 and beyond, the operating system's aggressive power management significantly tightens background execution limits, making persistent BLE connections even more challenging.

This article dives deep into the strategic use of Foreground Services, coupled with a robust auto-reconnection mechanism, to ensure your Android application maintains a reliable BLE connection to your IoT devices, even when your app isn't actively in use. You'll learn how to leverage the system correctly, not fight it.

Core Concepts: Why BLE Connections Die and How to Keep Them Alive

Before we dive into code, let's understand the underlying challenges. BLE connections are inherently fragile. Several factors can lead to an unexpected disconnection:

  1. Physical Environment: Distance from the peripheral, physical obstructions, and electromagnetic interference.
  2. Peripheral Behavior: The IoT device itself might temporarily lose power, reset, or intentionally disconnect.
  3. Android OS Process Management: This is where most developers struggle. Android aggressively manages background processes to conserve battery and memory.
    • Prior to Android 8 (Oreo): Background services had more leeway.
    • Android 8-11: Background execution limits were introduced, curtailing services that weren't "user-perceivable."
    • Android 12+: Further restrictions apply. Services started from the background are often killed quickly unless they elevate to a Foreground Service within a few seconds. Moreover, even Foreground Services are subject to specific types to clarify their purpose.

When your app is in the background and not holding a Foreground Service, the OS can terminate its process at any time, leading to an immediate and silent BLE disconnection.

Foreground Services: Your Lifeline for Persistent BLE

A Foreground Service tells the Android OS, "Hey, this app is doing something important that the user is actively aware of, even if the app isn't open on screen." This "user awareness" is crucial and is conveyed via a persistent notification in the status bar. The OS is far less likely to kill a process running a Foreground Service.

For BLE, a Foreground Service is the only reliable way to maintain connections in the background on modern Android versions. Specifically for Android 12+, a new foreground service type, connectedDevice, is highly relevant. It signifies that your service is interacting with a connected hardware device, which further clarifies its purpose to the system.

How Foreground Services work for BLE:

[Your App Process]
      |
      +---- Starts Foreground Service (with Notification)
      |
[Foreground Service] ---- (BLE Connection Logic)
      |                         |
      +---- BluetoothManager ---- BluetoothAdapter ---- BluetoothGatt (Connection to Peripheral)
      |
      +---- System Notification (User awareness)
Enter fullscreen mode Exit fullscreen mode

The service runs your BLE connection logic. If the connection drops, your service's logic is still active, allowing you to implement a robust auto-reconnect strategy.

Auto-Reconnect: Beyond autoConnect=true

The BluetoothGatt.connectGatt(..., autoConnect = true) parameter is often misunderstood. When autoConnect is true, the system attempts to connect to the device whenever it becomes available, even if it's not nearby initially. This is useful for passive, power-efficient reconnection attempts but can be slow and less predictable for immediate re-establishment after an unexpected disconnection. For a responsive IoT application, you need a more proactive approach.

A robust auto-reconnect mechanism involves:

  1. Detecting Disconnection: Relying on BluetoothGattCallback.onConnectionStateChange.
  2. Implementing Retry Logic: A loop with delays, possibly exponential backoff, attempting to re-establish the connection using connectGatt(..., autoConnect = false).
  3. Proper BluetoothGatt Management: Closing and nulling out old GATT instances before attempting a new connection to avoid stale states.

Implementation: Building a Robust BLE Service

You'll need minSdkVersion 23 (for BluetoothGatt functionality) and targetSdkVersion 31 (for Android 12+ features and stricter checks). We'll use Kotlin.

1. Permissions and Manifest Configuration

First, declare necessary permissions in your AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.yourpackage.bleapp">

    <!-- Pre-Android 12 BLE Permissions -->
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    <!-- Needed for scanning if location isn't explicitly disabled for BLE -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <!-- Android 12+ BLE Permissions -->
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation" /> <!-- Important if not using location for scanning -->
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

    <!-- Foreground Service Permission -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <!-- Android 12+ Foreground Service Type -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

    <application
        ... >
        <service
            android:name=".services.BleConnectionService"
            android:foregroundServiceType="connectedDevice" />
        <!-- Declare service type for Android 12+ -->
    </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • BLUETOOTH_SCAN with android:usesPermissionFlags="neverForLocation" is crucial if your app doesn't actually need location for BLE scans but needs to bypass the location requirement on some devices/Android versions. If you perform location-dependent scans, omit this flag and handle location permissions.
  • BLUETOOTH_CONNECT is required for connecting to BLE devices on Android 12+.
  • FOREGROUND_SERVICE_CONNECTED_DEVICE is the specific type for services managing hardware connections on Android 12+. This helps the OS understand your service's intent and prioritize it correctly.

2. Runtime Permissions

For Android 12+, BLUETOOTH_SCAN and BLUETOOTH_CONNECT are runtime permissions. You must request these from the user before initiating any BLE operations.

// In your Activity where BLE operations are initiated
fun requestBlePermissions() {
    val permissions = mutableListOf<String>()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        permissions.add(Manifest.permission.BLUETOOTH_SCAN)
        permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
        permissions.add(Manifest.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE) // Not runtime, but good to check if declared
    } else {
        permissions.add(Manifest.permission.BLUETOOTH)
        permissions.add(Manifest.permission.BLUETOOTH_ADMIN)
        permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) // if pre-S and scanning
    }

    // Add generic Foreground Service permission (not runtime but good for clarity)
    permissions.add(Manifest.permission.FOREGROUND_SERVICE)

    val permissionsToRequest = permissions.filter {
        ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
    }.toTypedArray()

    if (permissionsToRequest.isNotEmpty()) {
        ActivityCompat.requestPermissions(this, permissionsToRequest, REQUEST_BLE_PERMISSIONS)
    } else {
        // Permissions already granted, proceed with BLE operations
        startBleConnectionService()
    }
}

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == REQUEST_BLE_PERMISSIONS) {
        if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
            startBleConnectionService()
        } else {
            // Handle cases where permissions are not granted
            Toast.makeText(this, "BLE permissions are required.", Toast.LENGTH_SHORT).show()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. The BleConnectionService

This is the heart of your persistent BLE connection.

// services/BleConnectionService.kt
package com.yourpackage.bleapp.services

import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.*
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.*
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.yourpackage.bleapp.MainActivity
import com.yourpackage.bleapp.R // Assume you have a layout/drawable

class BleConnectionService : Service() {

    private val TAG = "BleConnectionService"
    private var bluetoothAdapter: BluetoothAdapter? = null
    private var bluetoothGatt: BluetoothGatt? = null
    private var deviceAddress: String? = null
    private var reconnectAttempts = 0
    private val MAX_RECONNECT_ATTEMPTS = 5
    private val RECONNECT_DELAY_MS = 5000L // Initial delay
    private val NOTIFICATION_CHANNEL_ID = "BleConnectionChannel"
    private val NOTIFICATION_ID = 101

    // Handler for managing reconnect attempts
    private val handler = Handler(Looper.getMainLooper())

    private val reconnectRunnable = Runnable {
        attemptReconnect()
    }

    override fun onCreate() {
        super.onCreate()
        Log.d(TAG, "Service created")
        initializeBluetooth()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d(TAG, "Service onStartCommand")
        deviceAddress = intent?.getStringExtra("DEVICE_ADDRESS")
        if (deviceAddress == null) {
            Log.e(TAG, "Device address not provided. Stopping service.")
            stopSelf()
            return START_NOT_STICKY
        }

        // Start Foreground Service
        startForeground(NOTIFICATION_ID, createNotification("Connecting to $deviceAddress"))

        // If not already connected, attempt connection
        if (bluetoothGatt == null || bluetoothGatt?.readPhy() == null) { // Simple check if GATT is active
            connectToDevice(deviceAddress!!)
        }

        return START_REDELIVER_INTENT // If service is killed, try to restart with last intent
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "Service destroyed")
        disconnectGatt()
        handler.removeCallbacks(reconnectRunnable)
        // Stop foreground service without removing notification, allowing system to clean up
        stopForeground(STOP_FOREGROUND_DETACH)
        // For older Android versions (< API 24), use stopForeground(true)
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null // Not providing binding interface for this example
    }

    private fun initializeBluetooth(): Boolean {
        if (bluetoothAdapter == null) {
            val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
            bluetoothAdapter = bluetoothManager.adapter
            if (bluetoothAdapter == null) {
                Log.e(TAG, "Bluetooth not supported on this device.")
                stopSelf()
                return false
            }
        }
        return true
    }

    // --- BLE Connection Logic ---

    private fun connectToDevice(address: String) {
        if (!initializeBluetooth()) return

        if (bluetoothAdapter?.isEnabled == false) {
            Log.e(TAG, "Bluetooth is not enabled.")
            // Consider sending a broadcast to UI to prompt user to enable Bluetooth
            scheduleReconnect(RECONNECT_DELAY_MS)
            return
        }

        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
            Log.e(TAG, "BLUETOOTH_CONNECT permission not granted.")
            // This should ideally be handled before service starts, but good to check
            stopSelf()
            return
        }

        val device = bluetoothAdapter?.getRemoteDevice(address)
        if (device == null) {
            Log.e(TAG, "Device not found with address: $address")
            stopSelf()
            return
        }

        disconnectGatt() // Ensure any existing GATT connection is closed
        Log.d(TAG, "Attempting to connect to $address")

        // autoConnect = false for direct, immediate connection attempt
        bluetoothGatt = device.connectGatt(this, false, gattCallback)
        updateNotification("Connecting to ${device.name ?: device.address}...")
    }

    private fun disconnectGatt() {
        bluetoothGatt?.apply {
            Log.d(TAG, "Closing existing GATT connection.")
            if (ActivityCompat.checkSelfPermission(this@BleConnectionService, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
                disconnect() // Explicitly disconnect
            }
            close()
        }
        bluetoothGatt = null
    }

    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            super.onConnectionStateChange(gatt, status, newState)
            val deviceName = gatt.device.name ?: gatt.device.address
            Log.d(TAG, "Connection state changed for $deviceName, status: $status, newState: $newState")

            if (status == BluetoothGatt.GATT_SUCCESS) {
                when (newState) {
                    BluetoothProfile.STATE_CONNECTED -> {
                        Log.i(TAG, "Connected to $deviceName")
                        reconnectAttempts = 0 // Reset attempts on successful connection
                        updateNotification("Connected to $deviceName")
                        // Discover services after connection, and update UI
                        gatt.discoverServices()
                    }
                    BluetoothProfile.STATE_DISCONNECTED -> {
                        Log.w(TAG, "Disconnected from $deviceName. Attempting reconnect.")
                        updateNotification("Disconnected from $deviceName. Reconnecting...")
                        scheduleReconnect(RECONNECT_DELAY_MS)
                    }
                }
            } else {
                Log.e(TAG, "Connection state change error for $deviceName. Status: $status, new state: $newState. Attempting reconnect.")
                updateNotification("Connection error with $deviceName. Reconnecting...")
                disconnectGatt() // Close GATT on error to avoid stale state
                scheduleReconnect(RECONNECT_DELAY_MS)
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            super.onServicesDiscovered(gatt, status)
            if (status == BluetoothGatt.GATT_SUCCESS) {
                Log.i(TAG, "Services discovered for ${gatt.device.name ?: gatt.device.address}")
                // Here you would typically read/write characteristics, subscribe to notifications
                // For example: readCharacteristic(someCharacteristic)
            } else {
                Log.e(TAG, "Service discovery failed with status $status for ${gatt.device.name ?: gatt.device.address}")
            }
        }

        // Implement other callbacks like onCharacteristicRead, onCharacteristicWrite, onCharacteristicChanged etc.
    }

    private fun attemptReconnect() {
        if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
            reconnectAttempts++
            val currentDelay = RECONNECT_DELAY_MS * reconnectAttempts // Simple linear backoff
            Log.d(TAG, "Reconnect attempt $reconnectAttempts of $MAX_RECONNECT_ATTEMPTS with delay $currentDelay ms.")
            updateNotification("Reconnecting (attempt $reconnectAttempts)...")
            handler.postDelayed({
                deviceAddress?.let { connectToDevice(it) } ?: stopSelf()
            }, currentDelay)
        } else {
            Log.e(TAG, "Max reconnect attempts reached. Stopping service.")
            updateNotification("Failed to connect. Tap to retry.", true) // Indicate failure, allow user to interact
            stopSelf() // Stop service if unable to reconnect
        }
    }

    private fun scheduleReconnect(delay: Long) {
        handler.removeCallbacks(reconnectRunnable) // Remove any pending reconnects
        handler.postDelayed(reconnectRunnable, delay)
    }

    // --- Foreground Service Notification ---

    private fun createNotification(message: String, isError: Boolean = false): Notification {
        createNotificationChannel()

        val notificationIntent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        val pendingIntent = PendingIntent.getActivity(
            this,
            0,
            notificationIntent,
            PendingIntent.FLAG_IMMUTABLE // Required for Android S+
        )

        val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
            .setContentTitle("BLE Connection Service")
            .setContentText(message)
            .setSmallIcon(R.drawable.ic_ble_connected) // Replace with your icon
            .setContentIntent(pendingIntent)
            .setOngoing(!isError) // Keep ongoing unless it's an error state
            .setPriority(NotificationCompat.PRIORITY_LOW) // Use LOW for background connection
            .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) // For Android 12+

        return builder.build()
    }

    private fun updateNotification(message: String, isError: Boolean = false) {
        val notification = createNotification(message, isError)
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.notify(NOTIFICATION_ID, notification)
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val serviceChannel = NotificationChannel(
                NOTIFICATION_CHANNEL_ID,
                "BLE Connection Service Channel",
                NotificationManager.IMPORTANCE_LOW
            )
            val manager = getSystemService(NotificationManager::class.java)
            manager.createNotificationChannel(serviceChannel)
        }
    }

    companion object {
        const val REQUEST_BLE_PERMISSIONS = 1001

        fun startService(context: Context, deviceAddress: String) {
            val startIntent = Intent(context, BleConnectionService::class.java).apply {
                putExtra("DEVICE_ADDRESS", deviceAddress)
            }
            // Use ContextCompat.startForegroundService to ensure correct behavior on modern Android
            ContextCompat.startForegroundService(context, startIntent)
        }

        fun stopService(context: Context) {
            val stopIntent = Intent(context, BleConnectionService::class.java)
            context.stopService(stopIntent)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Starting and Stopping the Service (from your Activity/Fragment)

// In your MainActivity.kt (or relevant Activity/Fragment)
import com.yourpackage.bleapp.services.BleConnectionService

// ... inside your activity or fragment
private fun startBleConnectionService() {
    val deviceAddress = "XX:XX:XX:XX:XX:XX" // Replace with your actual device address
    BleConnectionService.startService(this, deviceAddress)
}

private fun stopBleConnectionService() {
    BleConnectionService.stopService(this)
}

// Call startBleConnectionService() after permissions are granted and you have the device address.
Enter fullscreen mode Exit fullscreen mode

Best Practices: Sharpening Your BLE Implementation

Merely implementing the above isn't enough for a production-grade solution. You need to consider edge cases and common pitfalls.

  1. Don't rely solely on BluetoothGatt.connectGatt(..., autoConnect = true) for immediate reconnects.

    • Pitfall: While autoConnect=true seems convenient, it tells the system to passively scan for the device in the background and connect when found. This process can be slow and less predictable, especially after an active disconnection from a previously connected device. It's designed for situations where the device might not be immediately available (e.g., waking up later).
    • Fix: For robust and immediate auto-reconnection attempts after an unexpected disconnect, always use BluetoothGatt.connectGatt(..., autoConnect = false). Implement a manual retry loop with a backoff strategy, as shown in the attemptReconnect() function. If several direct attempts fail, you could then fall back to a single autoConnect=true attempt to passively wait for the device, but this should be a secondary strategy.
  2. Improper BluetoothGatt Instance Management.

    • Pitfall: Developers often forget to call bluetoothGatt.close() or call connectGatt() multiple times on the same BluetoothGatt object without proper cleanup. This leads to stale connections, resource leaks, and unpredictable BLE behavior (e.g., callbacks not firing, inability to connect).
    • Fix: Always ensure that bluetoothGatt.close() is called when the service is destroyed, or when explicitly attempting to reconnect after a failure. Before initiating a new connection with connectGatt(), ensure any existing BluetoothGatt instance is close()d and nulled out. This resets the internal state and allows for a clean connection attempt. The disconnectGatt() helper function in the example demonstrates this.
  3. Ignoring Connection status Codes in onConnectionStateChange.

    • Pitfall: Many implementations only check newState (e.g., STATE_CONNECTED, STATE_DISCONNECTED) and ignore the status parameter in onConnectionStateChange. The status provides critical information about why the connection state changed (e.g., GATT_FAILURE, GATT_INSUFFICIENT_AUTHENTICATION, specific error codes).
    • Fix: Log and, if necessary, react differently to specific status codes. A GATT_FAILURE (status 133) or similar error might indicate a more severe issue (e.g., peripheral went out of range abruptly, bonding issue) that warrants a longer backoff, a different type of retry (e.g., full BLE power cycle if severe enough, though this is disruptive), or even prompting the user to restart the peripheral. Distinguishing between a clean disconnect (GATT_SUCCESS status with STATE_DISCONNECTED) and an error-driven one can inform your reconnection strategy.
  4. Not handling BluetoothAdapter state changes.

    • Pitfall: Your service might be running, but the user could disable Bluetooth from Quick Settings. Your service would keep trying to connect without success.
    • Fix: Register a BroadcastReceiver in your BleConnectionService to listen for BluetoothAdapter.ACTION_STATE_CHANGED intents. When Bluetooth is turned off, stop your connection attempts, update your notification to inform the user, and perhaps restart connection attempts only when Bluetooth is re-enabled.

Conclusion

Maintaining robust BLE connections on Android 12+ requires a deep understanding of the OS's background execution limits and a deliberate strategy. By leveraging Foreground Services with the connectedDevice type and implementing a meticulous manual auto-reconnect mechanism, you can significantly enhance the reliability of your IoT applications. Remember to manage your BluetoothGatt instances judiciously and pay attention to specific status codes to build a truly resilient system.

Your next step should be to integrate this service into your existing application, adapting the reconnection logic and error handling to your specific IoT device's behavior. Consider adding a mechanism to notify the UI of connection status changes (e.g., via LocalBroadcastManager or a SharedFlow/LiveData) for a richer user experience.


Top comments (0)