Skip to content
All posts

Android app shortcuts and Quick Settings Tiles: logging water without opening the app

A practical guide to Android's dynamic ShortcutManager API and TileService — how to let a one-tap action skip the app entirely, with the pitfalls that trip most implementations.

MFKAPPS 5 min read

The fastest version of any app action is the one that never opens the app. Hydrame’s core loop — log a glass of water — happens dozens of times a day, and asking someone to unlock their phone, find the icon, wait for a cold start, then tap a button is three steps too many for something that should be one. Android gives you two ways to collapse that down to a single tap: home screen app shortcuts and a Quick Settings Tile. They solve the same problem from different entry points, and both are smaller to implement than they look. Here’s how each one actually works.

Two entry points, two different jobs

App shortcuts live under the launcher icon — long-press it and a menu pops up with actions you’ve defined. They’re for a small set of high-frequency actions tied to this specific app, discoverable by anyone who already has the icon in view.

A Quick Settings Tile lives in the notification shade, next to Wi-Fi and Bluetooth. It’s for one action someone wants available from anywhere, without hunting for an icon at all — closer to a hardware button than an app feature.

Hydrame ships both: a shortcut for “Log 250ml” and “Log 500ml” under the launcher icon, and a Quick Settings Tile that logs a default amount with a single tap from the lock screen shade. They’re separate APIs, separate registration paths, and worth building separately rather than trying to unify them.

Dynamic shortcuts: ShortcutManagerCompat

Shortcuts come in two flavors — static (declared in XML, fixed at build time) and dynamic (pushed at runtime, can change based on app state). A fixed “Log 250ml” doesn’t need to change, so static would work, but dynamic shortcuts let the amounts reflect what someone actually drinks — the two sizes they use most, not two the app guessed at.

val shortcut = ShortcutInfoCompat.Builder(context, "log_250ml")
    .setShortLabel("250ml")
    .setLongLabel("Log 250ml of water")
    .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_glass))
    .setIntent(
        Intent(context, LogIntakeReceiverActivity::class.java).apply {
            action = ACTION_LOG_INTAKE
            putExtra(EXTRA_AMOUNT_ML, 250)
        }
    )
    .build()

ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcut250, shortcut500))

The intent has to target an Activity, not a BroadcastReceiver — that’s the one hard constraint the shortcut API enforces, unlike the notification-action pattern where a receiver is the right call. Google’s own guidance is to make that Activity as close to invisible as possible: no layout, finish() immediately after the write.

class LogIntakeReceiverActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val amount = intent.getIntExtra(EXTRA_AMOUNT_ML, 0)

        lifecycleScope.launch {
            intakeDao.logIntake(amount, timestamp = System.currentTimeMillis())
            Toast.makeText(this@LogIntakeReceiverActivity, "Logged ${amount}ml", Toast.LENGTH_SHORT).show()
            finish()
        }
    }
}

Give it a transparent theme (Theme.Material3.DayNight.NoActionBar overridden with windowIsTranslucent) and the transition reads as a system confirmation, not an app launch — the whole thing resolves in under half a second on a mid-range device.

Rank and cap: shortcuts are not a menu

ShortcutManagerCompat.setDynamicShortcuts() replaces the entire set on every call, and the system caps the count — getMaxShortcutCountPerActivity() — usually around four or five depending on the launcher. Push more than that and the extras are silently dropped, not queued. Order matters too: shortcuts render in the order you pass them, so the amount someone logs most goes first, not last.

The other trap is calling setDynamicShortcuts() too often. Each call is meant for real state changes — a user’s habits shifting over weeks, not every app launch. Rewriting the same two shortcuts on every cold start does nothing except cost you a system call; check whether the set actually changed before pushing it.

The Quick Settings Tile: TileService

A tile is a service, not an Activity — Android controls its lifecycle, and you get exactly one entry point that matters: onClick().

class LogWaterTileService : TileService() {
    override fun onStartListening() {
        super.onStartListening()
        qsTile?.apply {
            label = "Log water"
            icon = Icon.createWithResource(this@LogWaterTileService, R.drawable.ic_tile_glass)
            state = Tile.STATE_ACTIVE
            updateTile()
        }
    }

    override fun onClick() {
        super.onClick()
        val pendingResult = goAsync()
        lifecycleScope.launch {
            try {
                intakeDao.logIntake(defaultAmountMl(), timestamp = System.currentTimeMillis())
            } finally {
                pendingResult.finish()
            }
        }
    }
}

goAsync() shows up here for the same reason it does in a BroadcastReceiver: onClick() is expected to return fast, and a Room write on the main thread would jank the shade’s collapse animation. The tile has to be declared in the manifest with the BIND_QUICK_SETTINGS_TILE permission, and — this is the part that catches people — it won’t appear anywhere automatically. The user has to drag it into their active tiles from the shade’s edit screen. TileService.requestListeningState() can prompt Android 13+ to suggest adding it, but there’s no way to force placement, and no reason to expect most users will bother. Treat the tile as a power-user feature, not a primary interaction path.

Keeping the tile’s state honest

A tile that always shows the same icon regardless of app state looks broken the first time it isn’t. If Hydrame’s daily goal is already hit, onStartListening() is the place to reflect that:

override fun onStartListening() {
    super.onStartListening()
    lifecycleScope.launch {
        val goalMet = intakeDao.todayTotal() >= goalDao.currentGoal()
        qsTile?.apply {
            state = if (goalMet) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE
            subtitle = if (goalMet) "Goal reached" else null
            updateTile()
        }
    }
}

onStartListening() fires each time the shade with your tile becomes visible, not on a timer, so this read is cheap and current by construction — no polling, no background job to keep it in sync.

What this is worth building for

Neither of these APIs is difficult once you know the two rules that aren’t obvious from the docs: shortcuts need an Activity even for a background write, and a tile needs goAsync() for the same reason a notification action receiver does. What they buy is disproportionate to the code — a long-press or a shade tap replacing a full app launch for the handful of actions someone repeats every day. That’s the kind of friction users don’t consciously notice removed, only the absence of it if you take it away. For any app with one or two dominant actions — logging something, starting something, toggling something — it’s worth the afternoon.