BroadcastReceiver is the most abused Android component for inter-app communication — and the most restricted. Modern Android has blocked most background broadcast use cases. Here's what still works and how to do it safely.
Static vs Dynamic Registration
Static (Manifest-declared):
<receiver android:name=".BootReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
Since Android 8.0 (API 26), most implicit broadcasts can no longer be received statically. Only a few exemptions remain (BOOT_COMPLETED, PACKAGE_REPLACED, etc.).
Dynamic (runtime registration):
class MyActivity : AppCompatActivity() {
private val networkReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Handle network change
}
}
override fun onStart() {
super.onStart()
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
registerReceiver(networkReceiver, filter)
}
override fun onStop() {
super.onStop()
unregisterReceiver(networkReceiver) // ALWAYS unregister
}
}
For Android 13+ (API 33), dynamic receivers must declare export status:
// Android 13+ requires RECEIVER_NOT_EXPORTED or RECEIVER_EXPORTED
ContextCompat.registerReceiver(
context,
receiver,
intentFilter,
ContextCompat.RECEIVER_NOT_EXPORTED // can't receive broadcasts from other apps
)
Security: exported vs unexported
<!-- ❌ Dangerous: any app can send broadcasts to this receiver -->
<receiver android:name=".PaymentReceiver" android:exported="true" />
<!-- ✅ Private: only your app can trigger this -->
<receiver android:name=".PaymentReceiver" android:exported="false" />
<!-- ✅ With permission: only senders with this permission can trigger -->
<receiver android:name=".PushReceiver"
android:exported="true"
android:permission="com.google.android.c2dm.permission.SEND" />
When sending broadcasts with a permission requirement:
// Only receivers that have declared this permission will receive it
sendBroadcast(intent, "com.example.MY_PERMISSION")
Long-Running Work: goAsync()
onReceive() runs on the main thread and has only ~10 seconds before the system considers it too slow. For longer work:
class SyncReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pendingResult = goAsync() // tells system "I'm not done yet"
CoroutineScope(Dispatchers.IO).launch {
try {
performSync()
} finally {
pendingResult.finish() // must call this when done
}
}
}
}
For anything beyond a few seconds, use WorkManager — BroadcastReceiver is just the trigger.
The Modern Alternative: Flow / Kotlin Channel
LocalBroadcastManager was deprecated in 2020. For in-process event broadcasting, use a SharedFlow:
// Instead of LocalBroadcastManager
object AppEvents {
private val _events = MutableSharedFlow<AppEvent>(extraBufferCapacity = 1)
val events: SharedFlow<AppEvent> = _events.asSharedFlow()
fun emit(event: AppEvent) {
_events.tryEmit(event)
}
}
// Sender
AppEvents.emit(AppEvent.UserLoggedIn)
// Receiver (in a coroutine scope)
lifecycleScope.launch {
AppEvents.events.collect { event ->
when (event) {
AppEvent.UserLoggedIn -> refreshUI()
}
}
}
Common System Broadcasts Still Useful
| Broadcast | Restriction | Use case |
|---|---|---|
BOOT_COMPLETED | Static OK; needs RECEIVE_BOOT_COMPLETED permission | Schedule WorkManager on boot |
ACTION_POWER_CONNECTED/DISCONNECTED | Dynamic only | Trigger sync when charging |
ACTION_SCREEN_ON/OFF | Dynamic only | Pause/resume background work |
PACKAGE_REPLACED | Static OK | Reschedule work after app update |
CONNECTIVITY_CHANGE | Dynamic only (API 26+) | Better: use NetworkCallback |
For network state, prefer ConnectivityManager.NetworkCallback over CONNECTIVITY_CHANGE:
val request = NetworkRequest.Builder()
.addCapability(NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, object : NetworkCallback() {
override fun onAvailable(network: Network) { /* online */ }
override fun onLost(network: Network) { /* offline */ }
})
Key Takeaways
| Concept | Rule |
|---|---|
| Static receivers | Mostly blocked in Android 8+; use dynamic registration |
android:exported | Always set explicitly; default to false for private receivers |
goAsync() | Use for > a few ms of work; always call finish() |
| In-process events | Replace LocalBroadcastManager with SharedFlow |
| Network changes | Use NetworkCallback instead of CONNECTIVITY_CHANGE broadcast |