BroadcastReceivers & ContentProviders
Both are legacy-adjacent Android primitives. They still matter for specific use cases (system events, cross-process file sharing, content exposure), but 2025 best practices route most use cases through more modern alternatives. This chapter explains what each is, when to use it, and the modern replacement.
BroadcastReceivers
What they are
A BroadcastReceiver is a callback registered with the system (or another
app) to receive messages. Two flavors:
- Manifest-declared — always listening (with Android 8+ restrictions)
- Context-registered — registered at runtime, tied to a scope
Manifest-declared (limited since Android 8)
<receiver
android:name=".BootCompletedReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
WorkManager.getInstance(context).enqueue(
OneTimeWorkRequestBuilder<SyncWorker>().build()
)
}
}
}
Required permission:
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
Android 8+ implicit broadcast restrictions
Manifest-declared receivers can no longer register for most implicit broadcasts (network state, battery level, etc.). A short allowlist of broadcasts still works:
BOOT_COMPLETED,LOCKED_BOOT_COMPLETEDACTION_LOCALE_CHANGED,ACTION_TIMEZONE_CHANGEDACTION_USB_DEVICE_ATTACHED- Package install/remove events
For everything else, register at runtime while the app is active:
class NetworkStateObserver @Inject constructor(
@ApplicationContext private val context: Context
) : DefaultLifecycleObserver {
private val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
// connectivity changed
}
}
override fun onStart(owner: LifecycleOwner) {
context.registerReceiver(
receiver,
IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
)
}
override fun onStop(owner: LifecycleOwner) {
context.unregisterReceiver(receiver)
}
}
Modern replacement — ConnectivityManager.NetworkCallback
fun Context.connectivityFlow(): Flow<NetworkStatus> = callbackFlow {
val cm = getSystemService(ConnectivityManager::class.java)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(NetworkStatus.Online) }
override fun onLost(network: Network) { trySend(NetworkStatus.Offline) }
}
cm.registerDefaultNetworkCallback(callback)
trySend(if (cm.activeNetwork != null) NetworkStatus.Online else NetworkStatus.Offline)
awaitClose { cm.unregisterNetworkCallback(callback) }
}
Callback-based APIs are both cheaper (no Intent parsing) and more reactive. Use them whenever a dedicated API exists.
Custom broadcasts (discouraged for in-process)
// Send
context.sendBroadcast(Intent("com.myapp.CART_UPDATED"))
// Receive (context-registered)
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) { /* ... */ }
}
context.registerReceiver(receiver, IntentFilter("com.myapp.CART_UPDATED"), Context.RECEIVER_NOT_EXPORTED)
Android 13+ requires RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED flag
on every runtime registration. Default to NOT_EXPORTED — only the
current app can send.
Don't do this for in-process events. Use SharedFlow or StateFlow
in a @Singleton repository. Cheaper, type-safe, easier to test.
LocalBroadcastManager — deprecated
// DO NOT use in new code
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
Deprecated since 2020. Replace with SharedFlow across Hilt-injected
dependencies:
@Singleton
class CartEventBus @Inject constructor() {
private val _events = MutableSharedFlow<CartEvent>(extraBufferCapacity = 16)
val events: SharedFlow<CartEvent> = _events.asSharedFlow()
suspend fun emit(event: CartEvent) { _events.emit(event) }
}
// Emitter
cartEventBus.emit(CartEvent.ItemAdded(item))
// Subscriber (in ViewModel)
cartEventBus.events.onEach { event ->
/* handle */
}.launchIn(viewModelScope)
When to still use a BroadcastReceiver
- System broadcasts you can't avoid:
BOOT_COMPLETED,LOCALE_CHANGED,MY_PACKAGE_REPLACED, USB device attach - Cross-app integration: your app expects another app to send an Intent
- Direct Reply notifications:
RemoteInputbroadcast - App Widget (Glance or legacy) updates
For everything else, prefer:
| Use case | Modern alternative |
|---|---|
| Network state | ConnectivityManager.NetworkCallback |
| Battery state | BatteryManager + polling / Flow |
| Config changes | Lifecycle callbacks |
| Time changed | Clock injection + periodic refresh |
| Package install/remove | PackageManager query |
| In-process events | SharedFlow / StateFlow |
ContentProviders
What they are
A ContentProvider exposes data through a URI with a defined schema, so
other apps can read/write your data with permissions. Classic examples:
Contacts, Calendar, MediaStore, Downloads.
Consuming a ContentProvider (common)
Reading system contacts:
val cursor = contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(
ContactsContract.Contacts._ID,
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.Contacts.PHOTO_URI
),
null, null,
ContactsContract.Contacts.DISPLAY_NAME + " ASC"
)
cursor?.use {
while (it.moveToNext()) {
val id = it.getLong(0)
val name = it.getString(1)
/* ... */
}
}
Exposing data via a ContentProvider (rare)
Most apps shouldn't expose a ContentProvider unless they specifically need:
- Other apps to query your data
- System integration (e.g., appwidget host, keyboard suggestions)
- A file-sharing surface
Declaring:
<provider
android:name=".OrderContentProvider"
android:authorities="${applicationId}.orders"
android:exported="true"
android:readPermission="com.myapp.permission.READ_ORDERS"/>
class OrderContentProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun query(
uri: Uri, projection: Array<out String>?, selection: String?,
selectionArgs: Array<out String>?, sortOrder: String?
): Cursor? {
val dao = EntryPointAccessors.fromApplication<ProviderEntryPoint>(context!!).orderDao()
return when (uriMatcher.match(uri)) {
MATCH_ORDER_LIST -> dao.cursorAll()
MATCH_ORDER_ITEM -> dao.cursorById(uri.lastPathSegment!!)
else -> null
}
}
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, args: Array<out String>?): Int = 0
override fun delete(uri: Uri, selection: String?, args: Array<out String>?): Int = 0
override fun getType(uri: Uri): String? = null
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
addURI("com.myapp.orders", "orders", MATCH_ORDER_LIST)
addURI("com.myapp.orders", "orders/*", MATCH_ORDER_ITEM)
}
}
Permissions on exported providers
<permission
android:name="com.myapp.permission.READ_ORDERS"
android:protectionLevel="signature"/>
<provider
android:authorities="..."
android:readPermission="com.myapp.permission.READ_ORDERS"/>
signature level means only apps signed with the same key can use
the permission — safe for a suite of apps you control.
FileProvider — secure file sharing
FileProvider is a pre-built ContentProvider that lets your app share
internal files with other apps via content:// URIs.
Declaration
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- res/xml/file_paths.xml -->
<paths>
<files-path name="exports" path="exports/"/>
<cache-path name="thumbs" path="thumbs/"/>
<external-files-path name="shared" path="shared/"/>
</paths>
Sharing a file
val file = File(context.filesDir, "exports/order-$orderId.pdf")
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share"))
The FLAG_GRANT_READ_URI_PERMISSION gives the target app temporary read
access to the specific file — no permission dialogs, no broad access.
Reading a shared URI
When receiving a content:// URI (e.g., from another app):
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
Don't convert to a File — the URI may not map to a real file (cloud
provider, encrypted storage).
Modern Android — fewer providers needed
Content providers were dominant when cross-app integration was common. Today:
- Photo Picker (no permission needed) replaces media queries
- Storage Access Framework (SAF) replaces custom file providers
- Clipboard / ShareSheet replaces custom data sharing
- IntentFilters + PendingIntents replace broadcast-based IPC
- Firebase / gRPC replaces cross-process data sync
For a greenfield app, you'll rarely declare a custom ContentProvider
outside of FileProvider and InitializationProvider (startup library).
BroadcastReceiver + ContentProvider in tests
Testing broadcasts
@RunWith(AndroidJUnit4::class)
class BootCompletedReceiverTest {
@Test fun enqueues_sync_work() {
val context = ApplicationProvider.getApplicationContext<Context>()
WorkManagerTestInitHelper.initializeTestWorkManager(context)
BootCompletedReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
val info = WorkManager.getInstance(context)
.getWorkInfosByTag("sync").get().first()
assertEquals(WorkInfo.State.ENQUEUED, info.state)
}
}
Testing a ContentProvider
@RunWith(AndroidJUnit4::class)
class OrderContentProviderTest {
@Test fun query_returns_rows() {
val context = ApplicationProvider.getApplicationContext<Context>()
val provider = OrderContentProvider().apply { attachInfo(context, ProviderInfo()) }
val cursor = provider.query(Uri.parse("content://com.myapp.orders/orders"), null, null, null, null)
assertNotNull(cursor)
}
}
Common anti-patterns
Problems
- Manifest-declared receivers for implicit broadcasts (blocked on 8+)
- LocalBroadcastManager for in-process events
- Custom ContentProvider for app-private data
- Raw file:// URIs instead of FileProvider
- Runtime-registered receivers that never unregister (leaks)
- Exporting providers without permission
Production patterns
- Runtime registration via lifecycle observers
- SharedFlow / StateFlow for in-process events
- Room + Hilt inside the app; provider only if external apps need it
- FileProvider + FLAG_GRANT_READ_URI_PERMISSION for sharing
- Register/unregister paired with onStart/onStop
- android:exported=false and permission-gated unless necessary
Key takeaways
Practice exercises
- 01
Migrate away from LocalBroadcastManager
Find any LocalBroadcastManager usage. Replace with a @Singleton event bus exposing SharedFlow<Event>.
- 02
Handle BOOT_COMPLETED
Add a manifest receiver for BOOT_COMPLETED that enqueues a SyncWorker. Test with adb shell am broadcast -a android.intent.action.BOOT_COMPLETED.
- 03
Connectivity via NetworkCallback
Replace a network-state BroadcastReceiver with ConnectivityManager.NetworkCallback wrapped in a Flow.
- 04
Share a PDF via FileProvider
Generate a PDF in context.filesDir. Share via FileProvider URI + ACTION_SEND. Verify the receiving app can open it.
- 05
Safe runtime registration
Register a BroadcastReceiver using DefaultLifecycleObserver tied to a LifecycleOwner. Confirm it unregisters on onStop.
Next
Continue to Resources & Localization for string handling, locale, configuration-qualified resources, and RTL.