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:
- Limited Scan Duration: Background scans initiated via
ScanCallbackare severely limited, often terminating after a few minutes, sometimes even faster depending on device OEM optimizations and battery saver modes. - 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.
- Location Access Nuances: BLE scanning relies on location permissions. While
ACCESS_FINE_LOCATIONcovers 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:
-
Foreground Service: This is Android's explicit mechanism for performing long-running operations that are noticeable to the user. A
ForegroundServiceruns 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) | | | +-----------------+ | +-----------------------+ -
BluetoothLeScanner.startScan(List<ScanFilter>, ScanSettings, PendingIntent): This specific overload of thestartScanmethod is crucial. Instead of providing aScanCallbackthat runs directly within your app's process (and is therefore susceptible to OS termination), you provide aPendingIntent. The system then takes ownership of the scan. When scan results are available, the system fires yourPendingIntent, typically targeting aBroadcastReceiver. 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
PendingIntentcarries 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
ForegroundServicestability and background restrictions. Android 12 (API 31) forBLUETOOTH_SCANpermission. -
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(withFOREGROUND_SERVICE_SPECIAL_USEpermission) orshortServicemight be considered, butconnectedDeviceremains generally applicable.
-
Step-by-Step Implementation
-
Define your
BroadcastReceiver: This receiver will be the target of yourPendingIntentand 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) } } } -
Create your
ForegroundService: This service will initiate and manage the BLE scan via thePendingIntent.
// 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 } } -
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).
Best Practices
Implementing persistent BLE scanning requires careful consideration to avoid battery drain, ANRs, and poor user experience.
-
Refined Scan Filters and Settings:
- Pitfall: Scanning for all BLE devices continuously with
SCAN_MODE_LOW_LATENCYrapidly drains battery and generates excessive callbacks. - Fix: Always use
ScanFilterto narrow down the devices you're interested in (e.g., by service UUID, device name, or manufacturer data). ForScanSettings, preferSCAN_MODE_LOW_POWERorSCAN_MODE_BALANCED. If you only need periodic updates, leveragesetReportDelay(long)to batch results, significantly reducing app wake-ups. This is especially effective withPendingIntentscans. -
Example
ScanSettingsfor 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()
- Pitfall: Scanning for all BLE devices continuously with
-
Robust Service Lifecycle Management:
- Pitfall: Forgetting to properly stop the
ForegroundServicewhen 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 andstopSelf()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. UseSTART_STICKYfor resilience, but pair it with explicit stop logic.
- Pitfall: Forgetting to properly stop the
-
Correct
PendingIntentFlags and Security:- Pitfall: Using incorrect
PendingIntentflags can lead to security vulnerabilities (FLAG_IMMUTABLEvs.FLAG_MUTABLE) or prevent the intent from working as expected. - Fix: On Android 12 (API 31) and above, you must explicitly declare
FLAG_IMMUTABLEorFLAG_MUTABLE. For intents that will be filled by the system (like BLE scan results),FLAG_MUTABLEis necessary as the system needs to inject theScanResultdata. Always combine it withFLAG_UPDATE_CURRENTto ensure subsequentstartScancalls update the existingPendingIntentinstead of creating new ones. -
Correct flags for
PendingIntenttoBroadcastReceiverreceiving system data:
PendingIntent.getBroadcast( context, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE )
- Pitfall: Using incorrect
-
Bluetooth State Monitoring:
- Pitfall: Not handling Bluetooth adapter being disabled or unavailable can cause your scan to silently fail or crash.
- Fix: Register a
BroadcastReceiverforBluetoothAdapter.ACTION_STATE_CHANGEDto 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)