Content Providers & Broadcasts Explained
ANDROID › Components
Two Android components exist purely to cross a boundary most app code never has to think about: the wall between your process and someone else's. A ContentProvider is the standard way to expose your data across that wall, with permission control built in. A BroadcastReceiver is the standard way to react to an event that crosses it, whether the event comes from the system or another app. Interviewers reach for this topic because getting the boundary judgment wrong is a real production mistake, not a theoretical one: wrapping data in a ContentProvider you don't need, or trusting a broadcast that any app could have sent.
Start with the provider side. A ContentProvider earns its overhead when something outside your own process needs to read or write your data: another app querying it, Android's search suggestions, a home screen widget, or a sync adapter. If your data never leaves your own app, a provider adds ceremony for a problem you don't have. Room or DataStore already gives you querying power, and neither one forces you to think about cross-app permissions:
// App-private data: Room is enough, no ContentProvider needed
@Dao
interface NoteDao {
@Insert
suspend fun insert(note: Note): Long
@Query("SELECT * FROM notes")
fun getAll(): Flow<List<Note>>
}
So the question to ask before reaching for a provider isn't "could this be a provider", it's "does something outside my process actually need this data right now". If the answer is no, you don't need one.
When a provider does earn its keep, clients address its data through a content URI, something like content://user_dictionary/words/4. Four parts make up that string: the content:// scheme marking it as provider data, an authority, user_dictionary, that identifies which provider handles the request, a path, words, that usually maps to a table, and an optional trailing ID, 4, that narrows the request down to one row.
You could build that ID form yourself with string concatenation, but Android gives you a helper that does it correctly: ContentUris.withAppendedId takes a base URI and a row ID and appends the ID segment for you.
val wordUri: Uri = ContentUris.withAppendedId(WORDS_CONTENT_URI, 4)
// content://user_dictionary/words/4
Reaching for that helper instead of hand-building the string is a small detail, but it's the kind of detail that tells an interviewer you've actually worked with a provider rather than just read about one.
ContentResolver is how you talk to any provider, yours or someone else's, and its four CRUD methods each hand back something different, a favorite thing for interviewers to mix up on purpose:
val cursor: Cursor? = contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)
val newUri: Uri? = contentResolver.insert(uri, values)
val rowsUpdated: Int = contentResolver.update(uri, values, selection, selectionArgs)
val rowsDeleted: Int = contentResolver.delete(uri, selection, selectionArgs)
query() returns a Cursor over the matching rows. insert() returns the Uri of the row it just created, so the caller immediately knows where to find it. update() and delete() both return an Int, a count of affected rows, not the rows themselves.
The four query parameters map onto plain SQL if you squint: projection is the list of columns you want back, the equivalent of a SELECT list, and it has nothing to do with filtering. selection plus selectionArgs is the WHERE clause. sortOrder is ORDER BY. Mixing up projection with selection is an easy way to answer this kind of question wrong under pressure.
A provider's query() selection clause looks like SQL, and it's tempting to build it with string concatenation, which is exactly how you'd open a SQL injection hole. The fix is the same one Room and raw SQLite use: ? placeholders in selection, and the real values passed separately in selectionArgs, never inlined into the string:
// BAD: user input concatenated straight into the WHERE clause
val unsafe = contentResolver.query(uri, null, "name = '$userInput'", null, null)
// GOOD: placeholder plus bound argument, the provider binds it safely
val safe = contentResolver.query(uri, null, "name = ?", arrayOf(userInput), null)
Once a value goes through selectionArgs, the underlying database driver binds it as a literal, so a string like x' OR '1'='1 gets compared as plain text instead of being parsed as SQL syntax. That's what actually closes the hole, not anything about the value itself looking suspicious.
getType(uri) tells a caller what kind of data a URI points to, before it even runs a query, and Android has a fixed naming convention for the answer. A URI matching many rows, a directory, returns a MIME type starting with vnd.android.cursor.dir/. A URI matching exactly one row starts with vnd.android.cursor.item/:
override fun getType(uri: Uri): String? = when (uriMatcher.match(uri)) {
NOTES_DIR -> "vnd.android.cursor.dir/vnd.com.example.provider.notes"
NOTES_ITEM -> "vnd.android.cursor.item/vnd.com.example.provider.notes"
else -> throw IllegalArgumentException("Unknown URI: $uri")
}
A caller, a list-rendering adapter for instance, can check that prefix to know whether it's about to deal with a collection or a single row, without running anything yet.
Two separate provider questions get confused constantly: whether other apps can reach your provider at all, and whether one specific piece of data can be shared just this once. The first is android:exported, and you should set it explicitly rather than rely on the default:
<provider
android:name=".MyPrivateProvider"
android:authorities="com.example.app.provider"
android:exported="false" />
exported="false" blocks every other app regardless of what URI permissions exist elsewhere, which is the right posture for a provider your own app uses internally. Relying on the default instead of setting it is genuinely risky here: providers built against API levels before 17 defaulted to exported="true", so an app that hasn't set the attribute explicitly can end up exposed on older targets without anyone deciding that on purpose.
But sometimes you do want to hand one URI to one other app briefly, sharing a photo into a share sheet, say, without exposing the whole provider. For that you grant a temporary permission on the Intent itself, FLAG_GRANT_READ_URI_PERMISSION, usually paired with FileProvider. That grant lasts only as long as the receiving component's task, not forever, and it never touches the exported attribute at all.
val imageUri = FileProvider.getUriForFile(context, "com.example.fileprovider", imageFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(imageUri, "image/jpeg")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
A client that wants to know when provider data changes shouldn't poll it on a timer, that wastes battery and is always a little stale. ContentResolver doesn't hand you an automatic stream for every URI the way you might expect from a modern reactive API; the provider has to announce changes itself. The provider side of the fix is one call after every write, ContentResolver.notifyChange():
// Provider, after inserting, updating, or deleting a row
context.contentResolver.notifyChange(MyContract.CONTENT_URI, null)
The client side registers a ContentObserver against that same URI, and its onChange() callback fires only when notifyChange() runs, nothing else triggers it:
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = reloadData()
}
contentResolver.registerContentObserver(MyContract.CONTENT_URI, true, observer)
Remember to unregister it when you're done. An observer nobody unregisters is a small leak that outlives the screen that created it.
Providers have one more property that libraries lean on in a way you'd never guess from the CRUD API alone: the system instantiates every manifest-declared provider and calls its onCreate() before Application.onCreate() runs, as part of process startup. Jetpack App Startup, Firebase, and WorkManager all ship a zero-data stub provider purely to hook that early moment:
class AppStartupProvider : ContentProvider() {
override fun onCreate(): Boolean {
MyLibrary.init(requireNotNull(context))
return true
}
override fun query(uri: Uri, p: Array<String>?, s: String?, sa: Array<String>?, so: String?) = null
// insert/update/delete/getType are stubbed too, there's no real data here
}
Manifest merging pulls that stub provider's own manifest entry into your app automatically, so your app gets auto-initialized without you writing any setup code at all.
That's the provider half of the topic. Broadcasts are the other half, and they start with the same kind of boundary question: who can send this, and who's allowed to receive it.
Receivers come in two flavors. Manifest-declared receivers are registered in the manifest at install time and can wake your app even when it isn't running, useful for something like ACTION_BOOT_COMPLETED. Context-registered receivers are created and registered in code, live only as long as they stay registered, and must be unregistered, but they can react to broadcasts, including ones your own app sends internally, while your app is actually active:
private val receiver = ConnReceiver()
override fun onStart() {
super.onStart()
registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
}
override fun onStop() {
unregisterReceiver(receiver)
super.onStop()
}
Since Android 8.0, API 26, most implicit broadcasts can no longer reach manifest-declared receivers at all, part of the same background-execution crackdown that limits background services. A short exemption list survives, ACTION_BOOT_COMPLETED among them, but everything else needs a context-registered receiver, or scheduled work instead.
A normal sendBroadcast() delivers to every matching receiver in no defined order, and no receiver can affect any other. An ordered broadcast, sendOrderedBroadcast(), is different: it delivers to one receiver at a time, in android:priority order, and each receiver can read or overwrite the result data before it reaches the next one, or stop delivery outright:
class HighPriorityReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
setResultData("modified for next receiver")
// abortBroadcast() // or stop delivery here entirely
}
}
sendOrderedBroadcast(intent, null)
abortBroadcast() only does anything inside an ordered broadcast. Calling it during a normal broadcast has no effect on whether other receivers still get the intent.
Ordered broadcasts control the order receivers see an intent. A separate mechanism controls who sees it at all. sendBroadcast(intent, receiverPermission) takes a permission string as its second argument, and only receivers belonging to an app that holds that permission get the broadcast; everyone else is silently skipped:
val intent = Intent("com.example.ACTION_SENSITIVE")
sendBroadcast(intent, "com.example.permission.RECEIVE_SENSITIVE")
That's a different lever from the exported flags you'll see next: exported/not-exported is an all-or-nothing switch on a receiver's own registration, while a permission string lets you send something sensitive, a payment confirmation, a location update, and trust that only apps holding a permission you control can act on it.
onReceive() always runs on the main thread, and the system is free to kill your process shortly after it returns. That combination rules out two tempting shortcuts: blocking inside onReceive() until work finishes, and firing off a raw Thread and returning immediately, since that thread can get reclaimed mid-work with nothing tracking it. Starting an Activity just to host background work doesn't solve the problem either, an activity isn't a background execution mechanism, and launching one from a receiver is a separate, unrelated capability.
For a few extra seconds of real background work, call goAsync() to get a PendingResult that keeps the broadcast alive while you finish off the main thread:
class MyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
doHeavyWork()
} finally {
pendingResult.finish() // must call within ~10s
}
}
}
}
That budget is roughly ten seconds. Anything longer than that belongs in WorkManager or JobScheduler, not stretched inside a receiver.
One more version gate, and it's specific to context-registered receivers. On Android 13, API 33, registering a receiver in code requires you to pass an explicit export flag: RECEIVER_EXPORTED if it should receive broadcasts from other apps or the system, RECEIVER_NOT_EXPORTED if it's only ever fed by your own app. Omitting the flag throws.
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter("com.example.ACTION"),
ContextCompat.RECEIVER_NOT_EXPORTED // or RECEIVER_EXPORTED for system broadcasts
)
Use ContextCompat.registerReceiver rather than the plain Context method, it's the version that accepts the flag and stays compatible with API levels below 33 as well.
Step back and the two halves of this topic are asking the same question in different clothes: is this crossing a process boundary, and if so, exactly how exposed do you want it to be. A ContentProvider only earns its keep at that boundary; inside your own app, Room or a plain observable is simpler. A broadcast is either implicit and system-wide, gated by priority and permissions, or it's really just an in-process signal wearing broadcast machinery it doesn't need.
That last case used to lean on LocalBroadcastManager, sending events between components in your own app without the cost or exposure of a real broadcast. It's deprecated now: its lifecycle pitfalls, receivers registered and then forgotten, delivery timing surprises, outweighed the convenience once better tools existed. The modern replacement is a shared observable, a MutableSharedFlow or StateFlow exposed from a singleton or shared ViewModel, collected with proper lifecycle awareness:
private val _events = MutableSharedFlow<AppEvent>()
val events: SharedFlow<AppEvent> = _events.asSharedFlow()
// Emit:
viewModelScope.launch { _events.emit(AppEvent.UserLoggedOut) }
// Collect:
lifecycleScope.launch {
viewModel.events.collect { event -> handleEvent(event) }
}
No IntentFilter, no manifest entry, no broadcast machinery at all, just a typed stream that coroutines already know how to consume safely. If an interviewer asks you to justify either a ContentProvider or a BroadcastReceiver, that's the answer they're listening for: name the process boundary being crossed, and show you reached for the lightest tool that actually crosses it.