Service, BroadcastReceiver, ContentProvider — and how they fit in Clean Architecture
Android’s component model includes four application entry points the system knows how to start and route to: Activity, Service, BroadcastReceiver, and ContentProvider. Activity and Fragment lifecycles are covered in the main chapter. This section covers the other three — and closes with a concrete, real-world scenario that shows how they compose with Clean Architecture.
Service
A Service runs in the background without a UI. It is not a coroutine: coroutines belong to a scope (ViewModel or lifecycle scope) and are cancelled when the scope ends. A Service has its own lifecycle, managed by the system, and survives navigation away from the screen.
Three types of Service
Type
Started by
Lifecycle
Use for
Started (background)
startService
Runs until stopSelf() or stopService()
Fire-and-forget; severely restricted on API 26+
Foreground
startForegroundService + startForeground()
Runs until explicitly stopped; shows a persistent notification
Ongoing visible work: media playback, navigation, file download
Bound
bindService
Lives as long as at least one client is bound
Client-server within the app or across apps
Android 8+ background restriction: the system kills started background services shortly after the app enters the background. For deferrable background work, use WorkManager. For work that must run right now with no UI, use a Foreground Service.
Foreground Service
A Foreground Service is visible to the user through a persistent notification. The system will not kill it under normal memory pressure.
classMusicPlaybackService:Service(){privatelateinitvarplayer:MediaPlayeroverridefunonCreate(){super.onCreate()player=MediaPlayer()}overridefunonStartCommand(intent:Intent?,flags:Int,startId:Int):Int{// Must call startForeground within 5 seconds of the service startingstartForeground(NOTIFICATION_ID,buildNotification())when(intent?.action){ACTION_PLAY->player.start()ACTION_PAUSE->player.pause()ACTION_STOP->{player.stop();stopSelf()}}returnSTART_STICKY// system re-starts the service if killed}overridefunonBind(intent:Intent?)=null// not a bound serviceoverridefunonDestroy(){player.release()super.onDestroy()}privatefunbuildNotification():Notification=NotificationCompat.Builder(this,CHANNEL_ID).setContentTitle("Now Playing").setSmallIcon(R.drawable.ic_music).addAction(R.drawable.ic_pause,"Pause",PendingIntent.getService(this,0,Intent(this,MusicPlaybackService::class.java).apply{action=ACTION_PAUSE},PendingIntent.FLAG_IMMUTABLE)).build()companionobject{constvalACTION_PLAY="play"constvalACTION_PAUSE="pause"constvalACTION_STOP="stop"constvalNOTIFICATION_ID=1001constvalCHANNEL_ID="playback"}}
Manifest declaration (required for every Service):
A Bound Service acts as a server. Clients bind to it, call methods via the returned IBinder, and unbind when done. The service is destroyed automatically when all clients unbind.
For cross-process bound services (IPC to another app), use AIDL — covered in Chapter 16 (IPC Mechanisms).
BroadcastReceiver
A BroadcastReceiver responds to system-wide or app-internal broadcast Intents. Typical system broadcasts: device boot, network connectivity changes, battery level, incoming calls.
Static vs Dynamic registration
Registration
How
Survives app not running?
Use for
Static (manifest)
<receiver> in AndroidManifest.xml
Yes — with restrictions
BOOT_COMPLETED, install-triggered events
Dynamic (code)
registerReceiver / unregisterReceiver
Only while registered
UI-tied events, connectivity while app is active
Android 8+ implicit broadcast restriction: most implicit broadcasts (e.g. CONNECTIVITY_ACTION) cannot be received by manifest-declared receivers — you register for them dynamically instead. Exceptions: BOOT_COMPLETED, locale and timezone changes.
onReceive() runs on the main thread with a 10-second timeout — never do I/O or network calls here. Hand off immediately to WorkManager or launch a coroutine via goAsync().
ContentProvider
A ContentProvider exposes structured data to other apps (or within the same app) through a URI-based API backed by Binder IPC. It is Android’s standard mechanism for controlled, cross-process data sharing.
FileProvider — the most common ContentProvider use
FileProvider is a pre-built ContentProvider for sharing files with other apps securely. It replaces insecure file:// URIs with content:// URIs that carry temporary, permission-scoped access:
Structured data shared across app boundaries; extending system APIs
FileProvider
File sharing via Intent with temporary, scoped permission
AIDL / Binder
Complex IPC with method calls between apps
Broadcast
One-way event notifications to multiple receivers
Platform ContentProviders you already use every day: ContactsContract (contacts), MediaStore (photos, video, audio), CalendarContract (calendar events).
Real Scenario: Offline-First with Clean Architecture
Problem: Show locally-cached data immediately while refreshing from the network in the background. No loading spinners. No stale data left on screen.
This is the “offline-first” / “single source of truth” pattern — the standard recommendation from the Android Developers documentation for production apps.
// --- DATA LAYER ---// Room DAO — returns a Flow that emits whenever the table changes@DaointerfaceUserDao{@Query("SELECT * FROM users ORDER BY name")funobserveAll():Flow<List<UserEntity>>@UpsertsuspendfunupsertAll(users:List<UserEntity>)}// Repository — Room is the source of truth; remote refreshes the local storeclassUserRepositoryImpl(privatevaldao:UserDao,privatevalapi:UserApi,privatevalioDispatcher:CoroutineDispatcher=Dispatchers.IO):UserRepository{overridefungetUsers():Flow<List<User>>=dao.observeAll().map{entities->entities.map{it.toDomain()}}overridesuspendfunsyncUsers()=withContext(ioDispatcher){valremote=api.getUsers()// network calldao.upsertAll(remote.map{it.toEntity()})// write to Room → triggers Flow}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// --- PRESENTATION LAYER ---classUserListViewModel(privatevalgetUsers:GetUsersUseCase,privatevalrepository:UserRepository):ViewModel(){// StateFlow starts empty; Room emits as soon as the query runsvalusers:StateFlow<List<User>>=getUsers().stateIn(scope=viewModelScope,started=SharingStarted.WhileSubscribed(5_000),initialValue=emptyList())init{viewModelScope.launch{repository.syncUsers()}// refresh on open}}
1
2
3
4
5
6
7
8
9
10
11
12
// --- UI (Compose) ---@ComposablefunUserListScreen(viewModel:UserListViewModel=viewModel()){valusersbyviewModel.users.collectAsStateWithLifecycle()if(users.isEmpty()){CircularProgressIndicator()// only on truly first launch with empty DB}else{LazyColumn{items(users){UserRow(it)}}}}
WorkManager for periodic background sync
For sync that must survive app death and run on a schedule, WorkManager is the right tool. It wraps JobScheduler under the hood and respects battery optimisation, network constraints, and device restarts.
// Worker lives in the data layer — calls only the Repository interface@HiltWorkerclassSyncUsersWorker@AssistedInjectconstructor(@Assistedcontext:Context,@Assistedparams:WorkerParameters,privatevalrepository:UserRepository// injected by HiltWorkerFactory):CoroutineWorker(context,params){overridesuspendfundoWork():Result{returntry{repository.syncUsers()Result.success()}catch(e:Exception){if(runAttemptCount<3)Result.retry()elseResult.failure()}}}// Schedule once at app startup or from BootReceiverfunschedulePeriodicSync(context:Context){valrequest=PeriodicWorkRequestBuilder<SyncUsersWorker>(1,TimeUnit.HOURS).setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()).setBackoffCriteria(BackoffPolicy.EXPONENTIAL,15,TimeUnit.MINUTES).build()WorkManager.getInstance(context).enqueueUniquePeriodicWork("user-sync",ExistingPeriodicWorkPolicy.KEEP,// don't replace if already scheduledrequest)}
How BroadcastReceiver + WorkManager + Repository compose
// BroadcastReceiver detects the signal, delegates immediately to WorkManagerclassConnectivityReceiver:BroadcastReceiver(){overridefunonReceive(context:Context,intent:Intent){// Never do I/O here — enqueue a WorkerWorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<SyncUsersWorker>().build())}}
The critical property of this architecture: no layer knows about the layer above it.
BroadcastReceiver knows only WorkManager — nothing about the ViewModel or UI
Worker knows only the UserRepository interface — nothing about the scheduler or the UI
UserRepository knows Room and Retrofit — nothing about the Worker or ViewModel
ViewModel knows only use cases — nothing about Room, Retrofit, or WorkManager
Compose knows only the StateFlow — nothing about any of the above
Each component is independently replaceable: swap the scheduler from WorkManager to AlarmManager, swap Room for another local store, swap Retrofit for Ktor — the rest of the app does not change.