androidengineers.Book a session

Core Android Components Deep Dive

BroadcastReceiver Patterns & Security

article20 minMedium

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

BroadcastRestrictionUse case
BOOT_COMPLETEDStatic OK; needs RECEIVE_BOOT_COMPLETED permissionSchedule WorkManager on boot
ACTION_POWER_CONNECTED/DISCONNECTEDDynamic onlyTrigger sync when charging
ACTION_SCREEN_ON/OFFDynamic onlyPause/resume background work
PACKAGE_REPLACEDStatic OKReschedule work after app update
CONNECTIVITY_CHANGEDynamic 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

ConceptRule
Static receiversMostly blocked in Android 8+; use dynamic registration
android:exportedAlways set explicitly; default to false for private receivers
goAsync()Use for > a few ms of work; always call finish()
In-process eventsReplace LocalBroadcastManager with SharedFlow
Network changesUse NetworkCallback instead of CONNECTIVITY_CHANGE broadcast

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
BroadcastReceiver Patterns & Security | Android System Design | Android Engineers