Skip to main content

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_COMPLETED
  • ACTION_LOCALE_CHANGED, ACTION_TIMEZONE_CHANGED
  • ACTION_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: RemoteInput broadcast
  • App Widget (Glance or legacy) updates

For everything else, prefer:

Use caseModern alternative
Network stateConnectivityManager.NetworkCallback
Battery stateBatteryManager + polling / Flow
Config changesLifecycle callbacks
Time changedClock injection + periodic refresh
Package install/removePackageManager query
In-process eventsSharedFlow / 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

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
Best practices

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

  1. 01

    Migrate away from LocalBroadcastManager

    Find any LocalBroadcastManager usage. Replace with a @Singleton event bus exposing SharedFlow<Event>.

  2. 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.

  3. 03

    Connectivity via NetworkCallback

    Replace a network-state BroadcastReceiver with ConnectivityManager.NetworkCallback wrapped in a Flow.

  4. 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.

  5. 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.