Most Android apps only exist while their Activity is on screen. That’s a choice, not a constraint.
The platform ships surfaces that let your app do work while the launcher icon sits untouched: the notification shade, the autofill overlay, the system’s speech stack. Almost nobody wires them up. I did, in Burrow, my offline-first expense and password manager, and the features that came out of it are the ones I actually use every day.
The surfaces you already paid for
Every one of these is in the SDK. No library, no third-party dependency, no server.
+---------------------------+
| SYSTEM SURFACES |
+---------------------------+
| | |
+-------------+ | +-------------+
v v v
+----------+ +---------------+ +-----------------+
| QS Tile | | Notification | | Autofill / |
| shade | | Listener | | Speech stack |
+----+-----+ +-------+-------+ +--------+---------+
| | |
| 2 taps | zero taps | 1 tap
v v v
+----------------------------------------------------------+
| Burrow's domain layer |
| use cases . repositories . local database |
+----------------------------------------------------------+
The interesting column is the middle one. A NotificationListenerService costs the user zero taps. That is a category of feature an Activity can never deliver.
Quick Settings tiles: two taps to your hottest action
A TileService puts a button in the notification shade next to Wi-Fi and Bluetooth. Users can drag it into the visible row. From lock screen to your action is two taps.
Burrow uses two tiles: add a transaction, and scan a receipt. Both are the actions I take while standing at a cashier, one-handed, in a hurry.
The implementation is ~40 lines. The only part worth knowing is the API 34 break:
override fun onClick() {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("navigate_to", "transaction_list")
putExtra("show_scanner", true)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
startActivityAndCollapse(pendingIntent)
} else {
startActivityAndCollapse(intent)
}
}
Android 14 deprecated the Intent overload of startActivityAndCollapse and throws if you call it. Ship the branch or crash on modern devices.
The trap is picking the wrong action. A tile that just launches your app is worthless — the launcher icon already does that. A tile earns its slot only when it lands on a specific screen in a specific state. Note the show_scanner extra: the camera is already open by the time the app draws.
Autofill: your app becomes infrastructure
AutofillService is the most underrated API on Android. Implement it and your app can fill username and password fields in every other app on the device. Chrome, banking apps, anything.
Burrow’s password vault registers as an autofill provider. The core of it:
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback
) {
val structure = request.fillContexts.last().structure
val fields = findAutofillFields(structure)
if (fields.usernameId == null || fields.passwordId == null) {
callback.onSuccess(null); return
}
serviceScope.launch {
val credentials = credentialRepository.getCredentials().first()
val response = FillResponse.Builder().apply {
credentials.forEach { c ->
addDataset(Dataset.Builder()
.setValue(fields.usernameId,
AutofillValue.forText(c.username), remoteViews(c))
.setValue(fields.passwordId,
AutofillValue.forText(c.password), remoteViews(c))
.build())
}
}.build()
callback.onSuccess(response)
}
}
Two things I got wrong. First, my findAutofillFields only walks the direct children of each window’s root node, so it silently misses fields nested inside a Column or ScrollView — which is most real login screens. A recursive walk over ViewNode.getChildAt is the correct implementation, and it’s on my list to fix.
Second, don’t fill from a plaintext store. Burrow keeps credentials encrypted with a key in Android Keystore and gates the vault behind BiometricPrompt. Autofill hands your database to the entire OS surface area. Earn that.
Worth saying plainly: for first-party sign-in, Credential Manager is the modern path — passkeys, password, and federated sign-in through one API. AutofillService is the right tool when you are the password manager.
Notification listeners: features with zero taps
This is the one that changed how I use my own app.
Indonesian banking and wallet apps push a notification for every transaction. That notification contains the amount and the merchant. A NotificationListenerService can read it. So Burrow records your spending without you touching the app.
GoPay pushes "Rp 45.000 at Kopi Kenangan"
|
v
+----------------------------------+
| TransactionWatcherService |
| onNotificationPosted(sbn) |
+----------------+-----------------+
|
+-------------+-------------+
| 1. watched package? | user opts in per app
| 2. seen in last 5s? | dedup, apps repost
| 3. parse amount+merchant | regex, or Gemini
| 4. infer category | merchant history
+-------------+-------------+
v
AddTransactionUseCase
|
v
"Transaction recorded" <- confirmation notif
Three lessons from building it.
Dedup is not optional. Apps repost the same notification on update. Burrow keys on "$packageName:$text" and drops anything seen inside a 5-second window. Without that, one payment became three transactions.
The service must never crash. onNotificationPosted runs on a system-bound service. Every listener body is wrapped in a try/catch that swallows, and onListenerDisconnected writes a flag to settings so the UI can tell the user the watcher went down. Users revoke the permission by accident constantly.
Package visibility is a real wall. On Android 11+ you cannot see other apps without declaring them. Burrow’s manifest lists every wallet and bank it supports:
<queries>
<package android:name="com.gojek.app" />
<package android:name="ovo.id" />
<package android:name="id.dana" />
<package android:name="com.bca" />
<!-- ... -->
</queries>
Which means adding a bank is a release, not a settings toggle. That’s the honest cost of this feature.
And the obvious point: BIND_NOTIFICATION_LISTENER_SERVICE is the most invasive permission on the platform. It reads every notification. Burrow’s watcher is off by default, opt-in per app, and parses locally unless you turn on AI parsing. If you can’t defend the permission in one sentence to a user, don’t ship it.
The one nobody thinks of: the system speech stack
Burrow’s bill-splitting flow needs names typed in fast. Typing six names on a phone keyboard is miserable. SpeechRecognizer with Locale("id", "ID") turns it into six seconds of talking.
if (SpeechRecognizer.isRecognitionAvailable(context)) {
val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE,
Locale("id", "ID").toLanguageTag())
}
}
Guard on that availability check, handle ERROR_SPEECH_TIMEOUT and ERROR_NO_MATCH as silence rather than failure, and declare the android.speech.RecognitionService query in your manifest. Free on-device speech-to-text in the user’s language, no API key, no per-request cost.
What this actually costs
None of it is free. Every surface is real surface area:
- All four live in Burrow’s
androidMain— none of it crosses to iOS if the app ever goes Kotlin Multiplatform. - Autofill and notification access are permissions that scare users, and reasonably so.
- Package visibility means the bank list is hardcoded, so coverage is a release cycle, not a config change.
I’d still take the trade, because each of these surfaces removes taps from something I do daily. That’s the filter I’d apply. Not “can I build a tile” — but “what does my user do every single day, and how many taps stand between them and it?”
The best feature I shipped this year isn’t in my app. It’s in the notification shade.