A boot receiver should be almost boring: Android announces a lifecycle event, the receiver validates it, records or schedules the smallest necessary follow-up, and returns. The dangerous version tries to initialize an entire application or start an unrestricted service while the system and every other app are waking up.
What BOOT_COMPLETED means
Intent.ACTION_BOOT_COMPLETEDis sent after Android finishes booting and the user is unlocked for the ordinary credential-encrypted environment.The broadcast is protected: third-party apps receive it but do not become the trusted system sender by manufacturing an equivalent intent.
Apps need the normal
RECEIVE_BOOT_COMPLETEDpermission and a registered receiver.The platform can withhold broadcasts from an app in the stopped state until the user launches/interacts with it again.
Receiving boot does not waive background-execution, foreground-service, battery, privacy, or user-control rules.
1. Declare permission and receiver
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application>
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>Why exported is explicit here
Android 12+ requires an explicit
android:exportedvalue for components with intent filters.A manifest receiver for a system broadcast must be reachable by the system; verify this setting with the selected Android behavior and threat model.
Exporting a receiver also increases its externally reachable surface, so
onReceive()must allow only the intended actions and perform no privileged caller-controlled operation.RECEIVE_BOOT_COMPLETEDis a normal permission granted at install time; no runtime prompt is shown.Do not add unrelated actions to this receiver merely to reuse a class.
2. Keep onReceive() short
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
BootWork.enqueue(context.applicationContext)
}
}The receiver performs dispatch, not the workload
The action comparison rejects unexpected explicit broadcasts that target the exported component.
Use the application context for work that outlives the receiver callback.
onReceive()runs on the main thread and has a short execution window.Network calls, database migrations, media scanning, and long initialization do not belong inline.
If asynchronous receiver work is unavoidable,
goAsync()still has a finite deadline and must callfinish(); durable scheduling is usually clearer.
3. Enqueue idempotent WorkManager work
object BootWork {
private const val UNIQUE_NAME = "post-boot-maintenance"
fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<PostBootWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
UNIQUE_NAME,
ExistingWorkPolicy.KEEP,
request,
)
}
}Unique work prevents boot storms
WorkManager persists eligible work and applies platform scheduling constraints.
A unique name plus
KEEPavoids parallel duplicates when boot, app startup, or another recovery path requests the same maintenance.Use
REPLACEorAPPENDonly when their cancellation/chaining semantics match the domain.A network constraint delays the job rather than blocking the receiver while connectivity returns.
The Worker itself must be idempotent because processes, retries, updates, and partial completion can repeat execution.
4. Implement bounded work with explicit results
class PostBootWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = try {
maintenanceRepository.reconcileAfterBoot()
Result.success()
} catch (error: IOException) {
Result.retry()
} catch (error: InvalidLocalState) {
Result.failure()
}
}Retry only failures that may recover
CoroutineWorkersupports suspending APIs without blocking a receiver thread.Transient I/O may justify retry and WorkManager backoff.
Corrupt or invalid local state should not loop forever; fail and surface a repair/telemetry path.
Do not log credentials, user content, or server payloads from boot-time failures.
Inject dependencies through the project’s supported Worker factory rather than relying on uninitialized global state.
Design the worker for repetition
Read current durable state before applying a change.
Use database transactions or compare-and-set semantics for multi-step local updates.
Give remote mutations idempotency keys or reconcile rather than blindly append.
Persist checkpoints only after the corresponding side effect succeeds.
Test process death between every meaningful step and on every retry attempt.
Do not start arbitrary background services at boot
Modern Android restricts background service creation and places additional rules on starting foreground services from the background. A receiver that immediately calls startService() using a decade-old sample is not a durable architecture. Use WorkManager for deferrable guaranteed work, AlarmManager only for justified alarm semantics, and media/device-policy/location foreground services only under the applicable documented exemptions and user-visible requirements.
Direct Boot is a different phase
After a reboot, credential-encrypted storage remains unavailable until the user unlocks. Android can send LOCKED_BOOT_COMPLETED to a Direct-Boot-aware receiver that uses only device-protected storage. Most applications should wait for normal BOOT_COMPLETED; opting into Direct Boot expands complexity and sensitive pre-unlock behavior.
<receiver
android:name=".LockedBootReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>Pre-unlock data must be intentionally limited
A Direct-Boot-aware component can run before user unlock.
It must not touch credential-protected files, databases, preferences, or keys that are unavailable.
Use
createDeviceProtectedStorageContext()only for the minimum data genuinely needed before unlock.Do not copy sensitive user data into device-protected storage for convenience.
Test cold reboot without unlocking, then unlock and test migration/reconciliation exactly once.
Keep locked and unlocked state machines separate
Define the tiny operation permitted before unlock.
Store only device-protected flags/data required for that operation.
On user unlock, re-read credential-protected truth instead of trusting stale pre-unlock assumptions.
Make the transition idempotent if both boot actions or app launch request reconciliation.
Remove Direct Boot support entirely when no pre-unlock requirement remains.
Synthetic test: target the exact component
adb shell am broadcast \
-a android.intent.action.BOOT_COMPLETED \
-n com.example.app/.BootReceiverBroadcasting: Intent { act=android.intent.action.BOOT_COMPLETED cmp=com.example.app/.BootReceiver }
Broadcast completed: result=0Risk level: caution. Review the command before running it.
This tests routing, not system authenticity
Replace the application ID and receiver class with the installed test build’s exact component.
The short
.BootReceiverform expands relative to the application package.An explicit component avoids waking every installed boot receiver during development.
Shell permissions and Android version/OEM policy may reject or alter protected-broadcast testing.
A successful command result does not prove the Worker ran; inspect WorkManager/app diagnostics.
Do not run broadcast tests against production user devices without authorization.
Inspect component registration
adb shell dumpsys package com.example.app | \
rg -n "BootReceiver|BOOT_COMPLETED|RECEIVE_BOOT_COMPLETED|stopped="Inspect receiver resolution, requested/granted permission, enabled state, and stopped state.Manifest source is not the installed truth
Build variants, manifest merging, package names, and component aliases can change the installed declaration.
Confirm the permission, receiver, action filter, enabled/exported state, and final package.
A force-stopped/stopped app can behave differently from an ordinarily idle app.
OEM settings can add user-facing autostart controls not represented by portable Android APIs.
Treat dumpsys field names as diagnostic output that can vary by Android release.
Observe receiver and work state
adb logcat -c
adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -n com.example.app/.BootReceiver
adb logcat -d | rg "BootReceiver|PostBootWorker|WorkManager"Verify one receiver invocation and one unique work transition.Use privacy-safe structured diagnostics
Clearing logs deletes prior evidence; do it only on a dedicated test device when acceptable.
Log action, app version, work ID/name, attempt count, and coarse outcome rather than user data.
Correlate receiver time with enqueue and Worker execution.
Constraints may leave work enqueued until network/battery/storage conditions are satisfied.
Remove noisy debug logs or protect them through the release logging policy.
Real reboot test
adb reboot
# Wait for the device to boot, unlock it when testing BOOT_COMPLETED, then reconnect.
adb wait-for-device
adb shell getprop sys.boot_completed1Risk level: caution. Review the command before running it.
A real reboot adds the missing system conditions
Reboot interrupts device use and active sessions; perform it only on an authorized test target.
sys.boot_completed=1is a diagnostic property, not proof your receiver ran.For normal BOOT_COMPLETED, unlock the user and verify the receiver/work evidence.
For Direct Boot, inspect behavior before unlock without accessing credential-protected data.
Test after fresh install and first app launch because stopped-state behavior matters.
Repeat with offline boot, delayed network, low battery, app update, and OEM battery/autostart settings relevant to users.
Recovery when boot delivery is absent
Run the same idempotent reconciliation from ordinary application startup when needed.
Inspect persisted work before enqueueing a duplicate.
Respect user-disabled features and explicit sign-out/deletion state.
Do not show a blocking “boot permission” prompt; the normal permission has no runtime dialog.
Expose actionable status only when delayed maintenance affects a user-visible feature.
Test without abusing the protected action
For unit and instrumentation tests, move boot decision logic into a normal class and invoke it directly with fake dependencies. Test the BroadcastReceiver’s action gate separately. This gives deterministic coverage without pretending a custom intent reproduces framework boot delivery.
@Test
fun bootActionEnqueuesUniqueMaintenance() {
receiver.onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
assertThat(workGateway.enqueuedNames)
.containsExactly("post-boot-maintenance")
}
@Test
fun unrelatedActionIsIgnored() {
receiver.onReceive(context, Intent("com.example.UNRELATED"))
assertThat(workGateway.enqueuedNames).isEmpty()
}Deterministic tests cover your code contract
Inject a small work gateway/scheduler rather than asserting global WorkManager internals in a unit test.
Verify the intended action and unrelated explicit actions.
Add idempotency, retry, constraint, and corrupted-state tests for the Worker/domain layer.
Use WorkManager’s testing support for integration-level scheduling assertions.
Keep at least one real-device reboot test because unit tests cannot reproduce platform delivery.
When boot work is unnecessary
WorkManager already persists scheduled work across reboot when configured through its supported scheduler.
Periodic synchronization can often be scheduled once rather than re-enqueued on every boot.
Application initialization can occur lazily on the next user launch.
Server push, exact alarms, and media sessions have different semantics and should not be shoehorned into boot.
If no user-visible requirement depends on reboot timing, removing the receiver saves wake-up contention and maintenance.
Common failures decoded
Receiver never runs: verify installed manifest, permission, enabled state, exact package/component, user unlock, and stopped/force-stopped status.
Explicit ADB broadcast works but reboot does not: test first launch, unlock phase, OEM controls, receiver action, and real logs.
Reboot works but Worker waits: its constraints are unsatisfied or the scheduler is delayed by platform policy.
Crash accessing preferences/database: code ran during Direct Boot against credential-protected storage.
Background service exception: replace obsolete service startup with compliant WorkManager/foreground-service architecture.
Duplicate jobs after reboot: use unique/idempotent work and reconcile current state.
Only debug works: inspect release manifest merging, shrinking/initialization, package name, logging, and dependency injection.
OEM device misses delivery: check documented vendor user controls while retaining portable recovery on app launch.
Security and reliability checklist
Receiver accepts only documented system actions and no caller-controlled commands.
Exported surface is minimal and protected by validation/permissions where applicable.
No secrets or personal payloads appear in logs, intents, or device-protected storage.
Boot work is idempotent, unique, constrained, bounded, and retry-aware.
The receiver never blocks the main thread or performs network/database migrations inline.
Foreground execution is used only under current documented rules with required user visibility.
Normal app launch can repair state when boot delivery is delayed or absent.
Synthetic explicit broadcast, genuine locked/unlocked reboot, force-stop, offline boot, and update cases pass.
Release observation signals
Count receiver dispatches, unique-work requests, starts, retries, successes, and permanent failures without user identifiers.
Record app/OS version and coarse constraint state for failures.
Alert on retry loops, duplicate side effects, or boot-time crash spikes.
Compare reconciliation triggered by boot versus ordinary app startup.
Retire temporary diagnostics after the rollout proves stable.
Official Android references
Broadcasts overview covers manifest/context receivers, export behavior, lifecycle, and security.
Intent ACTION_BOOT_COMPLETED defines permission and boot-delivery behavior.
WorkManager persistent work documents requests, constraints, results, and unique work.
Background execution limits explains service and broadcast restrictions introduced in modern Android.
Direct Boot explains locked boot, device-protected storage, and user-unlock boundaries.
App launch restrictions after force-stop provides current background-work guidance and platform constraints.
Comments and corrections