DEV Community

Ble Advertiser
Ble Advertiser

Posted on

Beyond the Foreground Service: Reliable Background BLE Connection Management on Android 12+

Your IoT app drops its BLE connection the moment it's backgrounded. You've tried a ForegroundService, but users still complain about intermittent data loss, especially after prolonged inactivity or device reboots. The system mercilessly kills your service, or users swipe your app away, severing that critical link to your embedded device. Sound familiar? This isn't a fluke; it's the harsh reality of Android 12+ and its tightened restrictions on background execution and power management. Relying solely on a long-running ForegroundService for persistent BLE connectivity is a losing battle.

This article will show you how to build a resilient, system-compliant background BLE connection management strategy for Android 12+ by combining the power of WorkManager, the robustness of PendingIntent for GATT callbacks, and judicious, short-lived ForegroundService usage. We'll move beyond the common pitfalls and establish a pattern that keeps your devices connected, even when your app process is dead.

Core Concepts: Navigating Android's Background Restrictions

Before diving into code, let's dissect the core challenges and the tools that will solve them.

The Problem with ForegroundService for Persistent BLE

On older Android versions, a ForegroundService was often sufficient for long-running tasks. On Android 12 (API 31) and higher, its utility for persistent background connectivity is severely limited:

  1. System Killing: Android aggressively manages resources. Even with a ForegroundService, the system can terminate your process if memory is low, or if the service runs for too long without user interaction. When the process dies, your BluetoothGattCallback (which runs in your app's process) is gone, and the connection will eventually drop.
  2. User Dismissal: Users can swipe away foreground notifications, stopping your ForegroundService and your app process.
  3. New Restrictions: Android 12 requires a foregroundServiceType in the manifest and at service start. Android 13 (API 33) requires POST_NOTIFICATIONS permission to show the notification. Android 14 (API 34) introduces ForegroundServiceType.CONNECTED_DEVICE, explicitly for apps managing connections to external devices. While these additions help categorize, they don't grant immunity from system termination.
  4. API 31+ Background BLE Access: To initiate or interact with BLE in the background (when your app is not visible), you must have an active ForegroundService running. This is a critical rule:
    • BluetoothAdapter.startLeScan() / BluetoothLeScanner.startScan()
    • BluetoothDevice.connectGatt()
    • BluetoothGatt operations (read/write characteristic, enable notifications)

This implies a paradox: you need a ForegroundService for background BLE, but a ForegroundService alone isn't reliable for persistent background operations. The solution lies in using it judiciously and in conjunction with other components.

Enter WorkManager: The Resilient Task Orchestrator

WorkManager is the recommended library for deferrable, guaranteed background work. Key benefits:

  • Persistence: WorkManager ensures your tasks run even if the app process is killed or the device reboots.
  • Constraints: You can define constraints (e.g., network available, device charging) for when work should run.
  • Retries: Built-in exponential backoff and retry policies.
  • System-aware: Integrates with Doze mode and App Standby, scheduling work efficiently.

We'll use WorkManager to orchestrate our BLE connection attempts and re-establish the ForegroundService when necessary.

The Power of PendingIntent for BluetoothGatt

This is the hidden gem for truly resilient background BLE. The BluetoothDevice.connectGatt() method has an often-overlooked overload:

fun connectGatt(
    context: Context,
    autoConnect: Boolean,
    callback: BluetoothGattCallback,
    transport: Int,
    phy: Int,
    handler: Handler?,
    callbackIntent: PendingIntent? // This is the key!
): BluetoothGatt
Enter fullscreen mode Exit fullscreen mode

When you provide a PendingIntent to connectGatt(), the Android system itself takes ownership of the GATT connection process. If the app process dies after connectGatt() is called, the system will still notify your PendingIntent (e.g., a BroadcastReceiver) about connection state changes. This is crucial for detecting disconnects and initiating reconnection attempts even when your app process is not running.

Putting it Together: A Hybrid Approach

Our strategy will be a hybrid:

  1. WorkManager for Orchestration: Schedules and retries the BLE connection process.
  2. PendingIntent for System-Level Disconnects: Registers a BroadcastReceiver with the system to capture BluetoothGattCallback events, especially connection state changes, even when the app is dead.
  3. Short-Lived ForegroundService for Active BLE Operations: When WorkManager's Worker needs to actually call connectGatt() or perform any GATT operations in the background, it temporarily promotes itself to a ForegroundService to comply with API 31+ restrictions. Once the operation completes (e.g., connection established, initial handshake done), the ForegroundService is stopped, and WorkManager continues managing the task.

This ensures:

  • Resilience: Connection attempts persist across process deaths and reboots.
  • System Compliance: Adheres to Android 12+ background BLE access rules.
  • Efficiency: Avoids unnecessarily long-running ForegroundService that drains battery and annoys users.

Implementation: Building the Resilient BLE Connector

Let's walk through the implementation details.

API Requirements, Permissions, and Manifest Configuration

Minimum API: Android 12 (API 31)

Permissions (in AndroidManifest.xml):

<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" android:minSdkVersion="34" />
Enter fullscreen mode Exit fullscreen mode
  • BLUETOOTH_SCAN: Required if you need to scan for devices in the background (though for reconnecting to a known device by address, it might not be strictly necessary, but often useful). neverForLocation flag is important.
  • BLUETOOTH_CONNECT: Essential for connecting to BLE devices.
  • POST_NOTIFICATIONS: For Android 13+ (API 33) to show the foreground service notification.
  • FOREGROUND_SERVICE: Standard permission.
  • FOREGROUND_SERVICE_CONNECTED_DEVICE: For Android 14+ (API 34) specifically when using ForegroundServiceType.CONNECTED_DEVICE. This helps the system understand your service's purpose.

Runtime Permissions: Remember to request BLUETOOTH_CONNECT and BLUETOOTH_SCAN (if used) at runtime from the user. For API 33+, POST_NOTIFICATIONS must also be requested.

Manifest Service Declaration:

Declare your ForegroundService and BroadcastReceiver in AndroidManifest.xml:

<service
    android:name=".ble.BleConnectionForegroundService"
    android:enabled="true"
    android:exported="false"
    android:foregroundServiceType="connectedDevice|dataSync" /> <!-- connectedDevice for API 34+, dataSync for older -->

<receiver
    android:name=".ble.BleGattConnectionReceiver"
    android:enabled="true"
    android:exported="true">
    <intent-filter>
        <action android:name="com.example.app.BLE_GATT_CALLBACK" />
    </intent-filter>
</receiver>
Enter fullscreen mode Exit fullscreen mode
  • The foregroundServiceType is crucial. Use connectedDevice for API 34+ when available. For API 31-33, you might choose dataSync or location (if location is genuinely part of your use case). You can declare multiple types separated by |.
  • The BroadcastReceiver must be exported="true" for the system to deliver the PendingIntent callback when your app process is dead. Its intent-filter defines the action string it listens for.

Step-by-Step Implementation

We'll define a BluetoothWorker that initiates connection attempts and a BleGattConnectionReceiver that processes connection state changes from the system.

1. The BleGattConnectionReceiver

This BroadcastReceiver is the entry point for system-level GATT events.

// ble/BleGattConnectionReceiver.kt
package com.example.app.ble

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.example.app.workers.BluetoothWorker

/**
 * Receives BLE GATT events from the system when the app process might be dead.
 * This is crucial for robust background connection management.
 */
class BleGattConnectionReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        val action = intent.action
        if (action == BLE_GATT_CALLBACK_ACTION) {
            val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
            val status: Int = intent.getIntExtra(BluetoothGatt.EXTRA_STATUS, BluetoothGatt.GATT_FAILURE)
            val newState: Int = intent.getIntExtra(BluetoothGatt.EXTRA_NEW_STATE, -1)

            if (device == null) {
                Log.e(TAG, "Received BLE GATT callback for null device.")
                return
            }

            Log.i(TAG, "BLE GATT Callback for ${device.address}: Status=$status, NewState=$newState")

            when (newState) {
                BluetoothGatt.STATE_CONNECTED -> {
                    Log.d(TAG, "Device ${device.address} CONNECTED (via PendingIntent)")
                    // Optionally, trigger a worker to perform post-connection setup
                    // Or, if WorkManager already requested connection, it will handle it.
                }
                BluetoothGatt.STATE_DISCONNECTED -> {
                    Log.w(TAG, "Device ${device.address} DISCONNECTED (via PendingIntent). Status: $status")
                    // The device disconnected, possibly unexpectedly.
                    // Schedule a WorkManager task to attempt reconnection.
                    val reconnectWorkRequest = OneTimeWorkRequestBuilder<BluetoothWorker>()
                        .setInputData(workDataOf(BluetoothWorker.KEY_DEVICE_ADDRESS to device.address))
                        .addTag(BluetoothWorker.TAG_RECONNECT_WORK)
                        .setInitialDelay(5, java.util.concurrent.TimeUnit.SECONDS) // Short delay before retrying
                        .build()
                    WorkManager.getInstance(context).enqueue(reconnectWorkRequest)
                    Log.d(TAG, "Scheduled BluetoothWorker for reconnection attempt for ${device.address}.")
                }
                // Handle other states if needed, e.g., connecting, disconnecting
            }
        }
    }

    companion object {
        const val BLE_GATT_CALLBACK_ACTION = "com.example.app.BLE_GATT_CALLBACK"
        private const val TAG = "BleGattConnectionReceiver"
    }
}
Enter fullscreen mode Exit fullscreen mode

This receiver's primary job is to listen for STATE_DISCONNECTED events and then enqueue a BluetoothWorker to handle the reconnection logic. This ensures that even if your app's process is dead, the system can trigger a reconnection attempt.

2. The BluetoothWorker

This Worker will encapsulate the BLE connection logic, including starting and stopping the temporary ForegroundService.

// workers/BluetoothWorker.kt
package com.example.app.workers

import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.example.app.R // Assuming you have a R.string.app_name, R.drawable.ic_ble, etc.
import com.example.app.ble.BleGattConnectionReceiver
import com.example.app.ble.BleConnectionForegroundService
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit

/**
 * Worker responsible for establishing and managing a BLE connection in the background.
 * It uses a temporary ForegroundService to comply with Android 12+ background BLE rules.
 */
class BluetoothWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {

    private val bluetoothManager: BluetoothManager =
        appContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
    private val bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter

    // CompletableDeferred to await GATT connection result
    private var gattConnectDeferred: CompletableDeferred<BluetoothGatt?>? = null
    private var currentGatt: BluetoothGatt? = null

    @SuppressLint("MissingPermission") // Permissions handled at runtime by the app
    override suspend fun doWork(): Result {
        val deviceAddress = inputData.getString(KEY_DEVICE_ADDRESS)
        if (deviceAddress == null) {
            Log.e(TAG, "Device address not provided to BluetoothWorker.")
            return Result.failure()
        }

        if (!hasBleConnectPermission()) {
            Log.e(TAG, "BLUETOOTH_CONNECT permission not granted.")
            return Result.failure() // Permissions should be handled in the UI layer
        }

        Log.d(TAG, "BluetoothWorker started for device: $deviceAddress")

        // 1. Start a temporary ForegroundService to allow background BLE operations
        setForeground(createForegroundInfo(deviceAddress))

        // Give the service a moment to start, though setForeground() handles it.
        // For actual connection logic, we want to run it on the main thread for BluetoothGatt.
        return try {
            val device = bluetoothAdapter.getRemoteDevice(deviceAddress)
            gattConnectDeferred = CompletableDeferred()

            // Create a PendingIntent to receive GATT callbacks even if our process dies.
            val pendingIntent = PendingIntent.getBroadcast(
                applicationContext,
                REQUEST_CODE_GATT_CALLBACK,
                Intent(BleGattConnectionReceiver.BLE_GATT_CALLBACK_ACTION),
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )

            // Connect using the overload with PendingIntent
            // Ensure this runs on the main looper for BLE callbacks to be reliable
            Handler(Looper.getMainLooper()).post {
                Log.d(TAG, "Attempting to connect to ${device.address} with PendingIntent.")
                currentGatt = device.connectGatt(
                    applicationContext,
                    false, // autoConnect should generally be false for reliability
                    bleGattCallback,
                    BluetoothDevice.TRANSPORT_AUTO,
                    BluetoothDevice.PHY_LE_1M_MASK,
                    Handler(Looper.getMainLooper()), // callbacks on main thread
                    pendingIntent // Critical for system-level callbacks
                )
            }

            // Wait for connection attempt result, with a timeout
            val connectedGatt = withTimeoutOrNull(CONNECTION_TIMEOUT_MS) {
                gattConnectDeferred?.await()
            }

            if (connectedGatt != null && connectedGatt.discoverServices()) { // Discover services immediately after connection
                Log.d(TAG, "Device ${device.address} connected and services discovered.")
                Result.success()
            } else {
                Log.e(TAG, "Failed to connect or discover services for ${device.address}.")
                Result.retry() // Retry connection later
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error in BluetoothWorker: ${e.message}", e)
            Result.retry()
        } finally {
            // Ensure GATT is closed and ForegroundService is stopped regardless of outcome
            currentGatt?.close()
            currentGatt = null
            Log.d(TAG, "BluetoothWorker finished for ${deviceAddress}. GATT closed.")
            // No need to stop ForegroundService manually. WorkManager handles its lifecycle.
            // If the worker completes successfully/fails/retries, WorkManager manages the ForegroundInfo.
        }
    }

    @RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)
    private val bleGattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            super.onConnectionStateChange(gatt, status, newState)
            Log.d(TAG, "Internal GATT Callback: ${gatt.device.address}, Status: $status, NewState: $newState")

            when (newState) {
                BluetoothProfile.STATE_CONNECTED -> {
                    if (status == BluetoothGatt.GATT_SUCCESS) {
                        Log.i(TAG, "Successfully connected to ${gatt.device.address}.")
                        gattConnectDeferred?.complete(gatt)
                    } else {
                        Log.e(TAG, "Connection failed for ${gatt.device.address} with status $status.")
                        gattConnectDeferred?.complete(null)
                        gatt.close() // Close GATT on failure
                    }
                }
                BluetoothProfile.STATE_DISCONNECTED -> {
                    Log.w(TAG, "Disconnected from ${gatt.device.address}. Status: $status.")
                    gattConnectDeferred?.complete(null)
                    gatt.close() // Always close GATT on disconnect
                }
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
            super.onServicesDiscovered(gatt, status)
            if (status == BluetoothGatt.GATT_SUCCESS) {
                Log.d(TAG, "Services discovered for ${gatt?.device?.address}.")
                // You would typically handle service parsing here or delegate to a manager.
            } else {
                Log.e(TAG, "Service discovery failed for ${gatt?.device?.address} with status $status.")
            }
        }

        // Implement other GATT callbacks (onCharacteristicRead, onCharacteristicWrite, onCharacteristicChanged, etc.)
        // These would typically be handled by a higher-level BLE manager, not directly in the worker.
    }

    private fun createForegroundInfo(deviceAddress: String): ForegroundInfo {
        val channelId = "ble_connection_channel"
        val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId,
                applicationContext.getString(R.string.ble_connection_notification_channel_name),
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = applicationContext.getString(R.string.ble_connection_notification_channel_description)
            }
            notificationManager.createNotificationChannel(channel)
        }

        val notification = NotificationCompat.Builder(applicationContext, channelId)
            .setContentTitle(applicationContext.getString(R.string.app_name))
            .setContentText("Connecting to BLE device: $deviceAddress...")
            .setSmallIcon(R.drawable.ic_ble) // Replace with your app's icon
            .setOngoing(true)
            .build()

        return ForegroundInfo(NOTIFICATION_ID, notification)
    }

    private fun hasBleConnectPermission(): Boolean {
        return applicationContext.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
    }

    companion object {
        const val KEY_DEVICE_ADDRESS = "device_address"
        const val TAG_RECONNECT_WORK = "ble_reconnect_work"
        private const val TAG = "BluetoothWorker"
        private const val NOTIFICATION_ID = 1001 // Unique ID for the foreground service notification
        private const val REQUEST_CODE_GATT_CALLBACK = 2001 // Unique request code for PendingIntent
        private const val CONNECTION_TIMEOUT_MS = 30_000L // 30 seconds for connection attempt
    }
}
Enter fullscreen mode Exit fullscreen mode

Key aspects of BluetoothWorker:

  • setForeground(): This critical call promotes the Worker to a ForegroundService for the duration of its active BLE operation. It takes a ForegroundInfo object, which includes a notification. This satisfies Android 12+ background BLE access requirements.
  • gattConnectDeferred: A CompletableDeferred (from Kotlin Coroutines) is used to asynchronously wait for the GATT connection result. This allows the Worker to suspend execution until the onConnectionStateChange callback is received.
  • PendingIntent for connectGatt: The pendingIntent passed to device.connectGatt() ensures that even if this Worker's process is killed mid-connection, the system will still deliver the connection state change to our BleGattConnectionReceiver.
  • BluetoothGattCallback: The internal bleGattCallback is mainly for initial connection handshake within the Worker's context. The PendingIntent is the truly robust mechanism for ongoing monitoring.
  • currentGatt?.close(): Absolutely essential to close the GATT instance when done to release resources.
  • Error Handling and Retries: The Result.retry() mechanism of WorkManager is leveraged for failed connection attempts. WorkManager will reschedule the worker with exponential backoff.
  • Handler(Looper.getMainLooper()).post { ... }: BLE operations, especially connectGatt, often prefer or require to be called on the main application thread to ensure callbacks are delivered consistently.

3. Enqueuing the Worker

To start a connection or initiate a reconnection, you'd enqueue BluetoothWorker:

// Example from an Activity or a disconnected BroadcastReceiver
fun startBleConnection(context: Context, deviceAddress: String) {
    val connectWorkRequest = OneTimeWorkRequestBuilder<BluetoothWorker>()
        .setInputData(workDataOf(BluetoothWorker.KEY_DEVICE_ADDRESS to deviceAddress))
        .addTag(BluetoothWorker.TAG_RECONNECT_WORK) // Use a tag to identify and cancel existing work
        .setBackoffCriteria(
            androidx.work.BackoffPolicy.EXPONENTIAL,
            androidx.work.WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS,
            java.util.concurrent.TimeUnit.MILLISECONDS
        )
        .build()

    WorkManager.getInstance(context).enqueueUniqueWork(
        "connect_$deviceAddress", // Unique name for this device's connection
        androidx.work.ExistingWorkPolicy.REPLACE, // If a prior connection attempt is pending, replace it
        connectWorkRequest
    )
    Log.d("App", "Enqueued BluetoothWorker for $deviceAddress")
}
Enter fullscreen mode Exit fullscreen mode

This ensures that connection attempts are managed reliably by WorkManager.

Best Practices for Robustness

Beyond the core implementation, consider these best practices to solidify your background BLE connectivity:

  1. Don't autoConnect for Initial Connections: While BluetoothDevice.connectGatt(..., autoConnect: true, ...) seems convenient, it's often unreliable and can lead to longer connection times or sticky connection issues. For initial connection attempts, use autoConnect: false. For subsequent reconnection attempts after a known disconnect, autoConnect: true might be acceptable for its passive nature, but often explicit connectGatt(false) combined with WorkManager retries yields better control and reliability. Our example uses false.

  2. Handle GATT Resources Meticulously: Always call BluetoothGatt.close() when a connection is no longer needed, or after a disconnection. Failing to do so leaks resources and can prevent future connections. The finally block in our Worker ensures this.

  3. Implement a Comprehensive Connection State Machine: While the BluetoothWorker handles basic connection/reconnection, a real-world application requires a more sophisticated state machine for each connected device. This state machine should live outside the Worker (e.g., in a singleton BleConnectionManager or a ViewModel) and handle:

    • IDLE
    • CONNECTING (triggered by WorkManager)
    • CONNECTED (upon successful onConnectionStateChange)
    • DISCOVERING_SERVICES
    • READY (after services discovered and initial characteristic setup)
    • DISCONNECTING
    • DISCONNECTED (triggers WorkManager for reconnection)

    This state machine would update persistent storage (e.g., DataStore or Room) about device status, allowing your UI and other components to react appropriately. The BroadcastReceiver would merely signal changes to this central manager.

  4. Graceful Permission Handling: Your app must have BLUETOOTH_CONNECT and POST_NOTIFICATIONS (API 33+) permissions granted by the user. If they are denied, your Worker will fail. Design your UI to guide users through granting these essential permissions. Consider a dedicated screen or a persistent notification to prompt for permissions.

  5. Inform the User: Even with a ForegroundService notification, persistent connection failures should be communicated clearly to the user, perhaps through a more prominent notification or within the app's UI. Allow them to manually retry or troubleshoot.

Conclusion

Building reliable background BLE connectivity on Android 12+ requires a fundamental shift from perpetual ForegroundService usage to a more orchestrated, system-compliant approach. By leveraging WorkManager for resilient task scheduling, PendingIntent for crucial system-level GATT callbacks, and initiating a ForegroundService only during active BLE operations within a Worker, you can establish a robust framework that truly keeps your IoT devices connected. This pattern ensures your app gracefully handles process terminations, device reboots, and adheres to Android's stringent power management policies. Integrate this hybrid strategy into your next IoT project to deliver a seamless and dependable user experience.

Top comments (0)