DEV Community

Ble Advertiser
Ble Advertiser

Posted on

How to Keep Your BLE Scan Alive: Overcoming Android 10+ Background Restrictions for Persistent Discovery

Ever found your meticulously crafted BLE background scanner abruptly terminating on modern Android versions? You're not alone. Android 10 (API 29) and subsequent releases introduced stringent background execution limits, fundamentally changing how apps can perform long-running tasks, including persistent Bluetooth Low Energy (BLE) scanning. If your IoT solution relies on discovering nearby devices continuously, these OS restrictions become a critical hurdle. This article cuts through the noise, providing a direct, actionable guide to building robust, always-on BLE discovery, even on the latest Android iterations.

The Problem: Android's Enforcement on Background Operations

Before Android 10, performing background BLE scans was relatively straightforward: start a service, initiate BluetoothLeScanner.startScan(), and process results. Post-Android 10, the landscape changed dramatically. The operating system now aggressively manages resources, putting a cap on how long an app can execute in the background if it's not performing a "user-perceptible" task. For BLE scans, this means:

  1. Limited Scan Duration: Background scans initiated via ScanCallback are severely limited, often terminating after a few minutes, sometimes even faster depending on device OEM optimizations and battery saver modes.
  2. No Implicit Service Restart: Services that are killed due to background restrictions or system memory pressure are less likely to be automatically restarted, especially if they are not foreground services.
  3. Location Access Nuances: BLE scanning relies on location permissions. While ACCESS_FINE_LOCATION covers foreground scanning, persistent background scanning needs a mechanism that the OS trusts to be running continuously.

The core issue is simple: the OS considers an invisible, long-running BLE scan a potential battery drain and privacy risk. To bypass these restrictions and signal "this task is important and user-aware," you must elevate your app's process priority.

Core Concepts: Elevating Your BLE Game

To achieve persistent BLE discovery, we leverage two fundamental Android APIs in conjunction:

  1. Foreground Service: This is Android's explicit mechanism for performing long-running operations that are noticeable to the user. A ForegroundService runs with elevated priority, making it less likely for the OS to kill your process. It must display a persistent notification, informing the user that your app is actively running a background task. This notification serves as a user's control point, enabling them to stop the service if desired.

    +-----------------------+
    |   Your Application    |
    |                       |
    |  +-----------------+  |
    |  | Foreground      |  |
    |  | Service         |  |
    |  | (elevated prio) |  |
    |  |                 |  |
    |  |  +------------+ |  |
    |  |  | Persistent | |  |
    |  |  | Notification| |  |
    |  |  +------------+ |  |
    |  +-----------------+  |
    |           ^           |
    |           |           |
    | +-------------------+ |
    | | BluetoothLeScanner| |
    | | .startScan() with | |
    | | PendingIntent     | |
    | +-------------------+ |
    |           |           |
    |           v           |
    |  +-----------------+  |
    |  | BroadcastReceiver | |
    |  | (handles results) |  |
    |  +-----------------+  |
    +-----------------------+
    
  2. BluetoothLeScanner.startScan(List<ScanFilter>, ScanSettings, PendingIntent): This specific overload of the startScan method is crucial. Instead of providing a ScanCallback that runs directly within your app's process (and is therefore susceptible to OS termination), you provide a PendingIntent. The system then takes ownership of the scan. When scan results are available, the system fires your PendingIntent, typically targeting a BroadcastReceiver. This decouples the scan execution from your app's immediate process lifecycle, making it much more resilient. The OS handles the scan, and only wakes your app up when there's data to deliver.

    Why PendingIntent?

    • Resilience: The OS manages the scan, not your app directly. If your app process is killed, the scan continues at the system level.
    • Efficiency: Your app is only woken up when results are batched or a significant event occurs, conserving battery.
    • Permission Delegation: The PendingIntent carries your app's permissions, allowing the system to perform the scan on your behalf.

Implementation: Building a Persistent BLE Scanner

Let's walk through the steps to implement this robust BLE scanning mechanism.

API Requirements and Permissions

  • Minimum API Level: Android 10 (API 29) for ForegroundService stability and background restrictions. Android 12 (API 31) for BLUETOOTH_SCAN permission.
  • Manifest Permissions:

    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <!-- Required for Android 12 (API 31) and above -->
    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <!-- If your app also advertises, Android 12+ -->
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- If your app connects to devices, Android 12+ -->
    
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <!-- ACCESS_BACKGROUND_LOCATION is NOT strictly required *just* for persistent BLE scanning via PendingIntent
         if your app doesn't need to know the actual geographic location of the scan itself,
         but rather just needs to discover devices. However, if your use case involves deriving
         location information from BLE, you will need it for Android 10/11. On Android 12+,
         BLUETOOTH_SCAN implicitly requires ACCESS_FINE_LOCATION for scanning. -->
    
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Required for Android 13 (API 33) and above -->
    
  • Manifest Declarations:

    <application ...>
        <service
            android:name=".BleScanForegroundService"
            android:foregroundServiceType="connectedDevice" <!-- Android 10+ requires foregroundServiceType -->
            android:enabled="true"
            android:exported="false" />
    
        <receiver
            android:name=".BleScanResultReceiver"
            android:enabled="true"
            android:exported="true" /> <!-- Exported to receive system broadcasts -->
    </application>
    
    • android:foregroundServiceType="connectedDevice": For API 29-33, this is the most appropriate type for BLE scanning. For API 34+, specialUse (with FOREGROUND_SERVICE_SPECIAL_USE permission) or shortService might be considered, but connectedDevice remains generally applicable.

Step-by-Step Implementation

  1. Define your BroadcastReceiver: This receiver will be the target of your PendingIntent and will process the scan results.

    // BleScanResultReceiver.kt
    package com.example.mybleapp
    
    import android.bluetooth.le.ScanResult
    import android.bluetooth.le.BluetoothLeScanner
    import android.content.BroadcastReceiver
    import android.content.Context
    import android.content.Intent
    import android.os.Build
    import android.util.Log
    
    class BleScanResultReceiver : BroadcastReceiver() {
    
        companion object {
            const val ACTION_BLE_SCAN_RESULT = "com.example.mybleapp.ACTION_BLE_SCAN_RESULT"
            private const val TAG = "BleScanResultReceiver"
        }
    
        override fun onReceive(context: Context?, intent: Intent?) {
            if (intent?.action == ACTION_BLE_SCAN_RESULT) {
                // Extract scan results from the intent
                val callbackType = intent.getIntExtra(
                    BluetoothLeScanner.EXTRA_CALLBACK_TYPE,
                    -1
                )
                // ScanResult.CREATOR is @NonNull so results will not be null if EXTRA_LIST_SCAN_RESULT is present.
                // However, to be safe, check if the list is not null before proceeding.
                val scanResults: List<ScanResult>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    intent.getParcelableArrayListExtra(
                        BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT,
                        ScanResult::class.java
                    )
                } else {
                    @Suppress("DEPRECATION")
                    intent.getParcelableArrayListExtra(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
                }
    
                scanResults?.forEach { result ->
                    Log.d(TAG, "Found BLE device: ${result.device.address}, Name: ${result.device.name ?: "N/A"}")
                    // TODO: Process your scan results here
                    // e.g., filter, store in database, notify UI, connect to device
                }
    
                // If you are batching results, you might get a list of results at once.
                // handleBatchScanResults(scanResults)
            }
        }
    }
    
  2. Create your ForegroundService: This service will initiate and manage the BLE scan via the PendingIntent.

    // BleScanForegroundService.kt
    package com.example.mybleapp
    
    import android.app.Notification
    import android.app.NotificationChannel
    import android.app.NotificationManager
    import android.app.PendingIntent
    import android.app.Service
    import android.bluetooth.BluetoothAdapter
    import android.bluetooth.BluetoothManager
    import android.bluetooth.le.ScanCallback
    import android.bluetooth.le.ScanFilter
    import android.bluetooth.le.ScanSettings
    import android.content.Context
    import android.content.Intent
    import android.content.pm.PackageManager
    import android.os.Build
    import android.os.IBinder
    import android.util.Log
    import androidx.core.app.ActivityCompat
    import androidx.core.app.NotificationCompat
    
    class BleScanForegroundService : Service() {
    
        private lateinit var bluetoothAdapter: BluetoothAdapter
        private var bluetoothLeScanner = BluetoothAdapter.getDefaultAdapter()?.bluetoothLeScanner
        private var scanPendingIntent: PendingIntent? = null
    
        companion object {
            const val NOTIFICATION_CHANNEL_ID = "BleScanServiceChannel"
            const val NOTIFICATION_ID = 1001
            private const val TAG = "BleScanFwdService"
        }
    
        override fun onCreate() {
            super.onCreate()
            val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
            bluetoothAdapter = bluetoothManager.adapter
            bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
            createNotificationChannel()
        }
    
        override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
            Log.d(TAG, "Service onStartCommand")
    
            // Start the foreground service with a notification
            startForeground(NOTIFICATION_ID, createNotification())
    
            // Initiate BLE scan
            startBleScan()
    
            // START_STICKY ensures that if the service is killed by the system,
            // it will be re-created and onStartCommand will be called again.
            return START_STICKY
        }
    
        override fun onDestroy() {
            super.onDestroy()
            Log.d(TAG, "Service onDestroy")
            stopBleScan()
        }
    
        override fun onBind(intent: Intent?): IBinder? {
            return null // Not a bound service
        }
    
        private fun createNotificationChannel() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                val serviceChannel = NotificationChannel(
                    NOTIFICATION_CHANNEL_ID,
                    "BLE Scan Service Channel",
                    NotificationManager.IMPORTANCE_LOW // Low importance to be less intrusive
                )
                val manager = getSystemService(NotificationManager::class.java)
                manager.createNotificationChannel(serviceChannel)
            }
        }
    
        private fun createNotification(): Notification {
            // Optional: Create an intent to open your main activity when the notification is tapped
            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 or PendingIntent.FLAG_UPDATE_CURRENT // Use FLAG_IMMUTABLE for security
            )
    
            return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
                .setContentTitle("BLE Scan Active")
                .setContentText("Scanning for nearby IoT devices...")
                .setSmallIcon(R.drawable.ic_bluetooth_searching) // Replace with your icon
                .setContentIntent(pendingIntent)
                .build()
        }
    
        private fun startBleScan() {
            if (!bluetoothAdapter.isEnabled) {
                Log.w(TAG, "Bluetooth is not enabled.")
                // Consider prompting user or stopping service if Bluetooth is essential
                return
            }
    
            if (bluetoothLeScanner == null) {
                Log.e(TAG, "BluetoothLeScanner not available.")
                return
            }
    
            // Check for necessary permissions
            if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED ||
                ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                Log.e(TAG, "BLE scan permissions not granted.")
                // You should handle permission requests in your Activity before starting the service
                return
            }
    
            // Define scan settings
            val scanSettings = ScanSettings.Builder()
                .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) // Optimize for battery life
                .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) // Receive all advertisements
                .setMatchMode(ScanSettings.MATCH_MODE_STICKY) // Matches devices even if they move out of range briefly
                .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) // Report each advertisement
                .build()
    
            // Define scan filters (optional but recommended for efficiency)
            val scanFilters = listOf(
                // Example: Filter for devices advertising a specific service UUID
                // val filter = ScanFilter.Builder()
                //     .setServiceUuid(ParcelUuid(UUID.fromString("0000180A-0000-1000-8000-00805f9b34fb")))
                //     .build()
                // filter
            )
    
            // Create a PendingIntent for the BroadcastReceiver
            val intent = Intent(this, BleScanResultReceiver::class.java).apply {
                action = BleScanResultReceiver.ACTION_BLE_SCAN_RESULT
            }
            scanPendingIntent = PendingIntent.getBroadcast(
                this,
                0, // Request code
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE // FLAG_MUTABLE is required for system to fill in scan results
            )
    
            try {
                bluetoothLeScanner?.startScan(scanFilters, scanSettings, scanPendingIntent)
                Log.d(TAG, "BLE scan started via PendingIntent.")
            } catch (e: Exception) {
                Log.e(TAG, "Failed to start BLE scan: ${e.message}", e)
            }
        }
    
        private fun stopBleScan() {
            if (bluetoothLeScanner == null) {
                Log.e(TAG, "BluetoothLeScanner not available for stopping scan.")
                return
            }
    
            // Check for necessary permissions again before stopping
            if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
                Log.e(TAG, "BLE scan permissions not granted, cannot stop scan gracefully.")
                return
            }
    
            scanPendingIntent?.let { pendingIntent ->
                try {
                    bluetoothLeScanner?.stopScan(pendingIntent)
                    Log.d(TAG, "BLE scan stopped via PendingIntent.")
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to stop BLE scan: ${e.message}", e)
                }
                scanPendingIntent = null // Clear the reference
            }
    
            stopForeground(Service.STOP_FOREGROUND_REMOVE) // Remove notification
            stopSelf() // Stop the service
        }
    }
    
  3. Starting the Service from your Activity/Application:

    // In your MainActivity or Application class
    package com.example.mybleapp
    
    import android.content.Context
    import android.content.Intent
    import android.content.pm.PackageManager
    import android.os.Build
    import android.os.Bundle
    import android.widget.Button
    import android.widget.Toast
    import androidx.activity.result.contract.ActivityResultContracts
    import androidx.appcompat.app.AppCompatActivity
    import androidx.core.content.ContextCompat
    
    class MainActivity : AppCompatActivity() {
    
        private val requestPermissionsLauncher = registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { permissions ->
            val allGranted = permissions.entries.all { it.value }
            if (allGranted) {
                Toast.makeText(this, "Permissions granted. Starting scan service.", Toast.LENGTH_SHORT).show()
                startBleScanService()
            } else {
                Toast.makeText(this, "Required permissions not granted.", Toast.LENGTH_LONG).show()
            }
        }
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main) // Assuming you have a layout with buttons
    
            findViewById<Button>(R.id.startButton).setOnClickListener {
                checkAndRequestPermissions()
            }
    
            findViewById<Button>(R.id.stopButton).setOnClickListener {
                stopBleScanService()
            }
        }
    
        private fun checkAndRequestPermissions() {
            val permissionsToRequest = mutableListOf<String>()
    
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+
                if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
                    permissionsToRequest.add(android.Manifest.permission.BLUETOOTH_SCAN)
                }
                if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
                    permissionsToRequest.add(android.Manifest.permission.BLUETOOTH_CONNECT)
                }
            } else { // Android 10/11
                if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) {
                    permissionsToRequest.add(android.Manifest.permission.BLUETOOTH_ADMIN)
                }
            }
    
            if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                permissionsToRequest.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
            }
    
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+
                if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
                    permissionsToRequest.add(android.Manifest.permission.POST_NOTIFICATIONS)
                }
            }
    
            if (permissionsToRequest.isNotEmpty()) {
                requestPermissionsLauncher.launch(permissionsToRequest.toTypedArray())
            } else {
                startBleScanService()
            }
        }
    
        private fun startBleScanService() {
            val serviceIntent = Intent(this, BleScanForegroundService::class.java)
            ContextCompat.startForegroundService(this, serviceIntent) // Correct way to start foreground service
        }
    
        private fun stopBleScanService() {
            val serviceIntent = Intent(this, BleScanForegroundService::class.java)
            stopService(serviceIntent)
            Toast.makeText(this, "BLE scan service stopped.", Toast.LENGTH_SHORT).show()
        }
    }
    
*   Permission Handling: Crucially, request runtime permissions before starting the service. The service itself should only check if permissions are granted.

  • ContextCompat.startForegroundService(): This is the correct way to start a ForegroundService. If your app is in the background when this is called, it will still be allowed to start the service (within a short grace period).
Enter fullscreen mode Exit fullscreen mode

Best Practices

Implementing persistent BLE scanning requires careful consideration to avoid battery drain, ANRs, and poor user experience.

  1. Refined Scan Filters and Settings:

    • Pitfall: Scanning for all BLE devices continuously with SCAN_MODE_LOW_LATENCY rapidly drains battery and generates excessive callbacks.
    • Fix: Always use ScanFilter to narrow down the devices you're interested in (e.g., by service UUID, device name, or manufacturer data). For ScanSettings, prefer SCAN_MODE_LOW_POWER or SCAN_MODE_BALANCED. If you only need periodic updates, leverage setReportDelay(long) to batch results, significantly reducing app wake-ups. This is especially effective with PendingIntent scans.
    • Example ScanSettings for batching:

      val scanSettings = ScanSettings.Builder()
          .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
          .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
          .setReportDelay(5000L) // Report results every 5 seconds (batching)
          .build()
      
  2. Robust Service Lifecycle Management:

    • Pitfall: Forgetting to properly stop the ForegroundService when it's no longer needed leads to continuous notification display, battery drain, and user frustration.
    • Fix: Ensure you call stopForeground(Service.STOP_FOREGROUND_REMOVE) to dismiss the notification and stopSelf() to terminate the service. Implement clear UI controls (e.g., start/stop buttons) in your app. Handle Bluetooth adapter state changes (e.g., Bluetooth turned off) within your service or receiver to gracefully stop the scan and potentially the service itself. Use START_STICKY for resilience, but pair it with explicit stop logic.
  3. Correct PendingIntent Flags and Security:

    • Pitfall: Using incorrect PendingIntent flags can lead to security vulnerabilities (FLAG_IMMUTABLE vs. FLAG_MUTABLE) or prevent the intent from working as expected.
    • Fix: On Android 12 (API 31) and above, you must explicitly declare FLAG_IMMUTABLE or FLAG_MUTABLE. For intents that will be filled by the system (like BLE scan results), FLAG_MUTABLE is necessary as the system needs to inject the ScanResult data. Always combine it with FLAG_UPDATE_CURRENT to ensure subsequent startScan calls update the existing PendingIntent instead of creating new ones.
    • Correct flags for PendingIntent to BroadcastReceiver receiving system data:

      PendingIntent.getBroadcast(
          context,
          REQUEST_CODE,
          intent,
          PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
      )
      
  4. Bluetooth State Monitoring:

    • Pitfall: Not handling Bluetooth adapter being disabled or unavailable can cause your scan to silently fail or crash.
    • Fix: Register a BroadcastReceiver for BluetoothAdapter.ACTION_STATE_CHANGED to detect when Bluetooth is turned off or on. If Bluetooth is off, stop your BLE scan service and notify the user. Restart the service/scan once Bluetooth is enabled again. This ensures your app reacts gracefully to system-level changes.

Conclusion

Overcoming Android 10+ background restrictions for persistent BLE scanning boils down to strategically leveraging ForegroundService with a PendingIntent-based BluetoothLeScanner API. This approach elevates your app's priority, allowing the OS to manage the continuous scanning process while providing results reliably to your BroadcastReceiver. By meticulously handling permissions, service lifecycle, PendingIntent flags, and scan configurations, you can build robust and battery-efficient BLE discovery solutions. Your next step should be to integrate thorough error handling for Bluetooth state changes and explore scan result batching for further efficiency.


Top comments (0)