Saltar al contenido
Todas las entradas

App shortcuts y Quick Settings Tiles en Android: registrar agua sin abrir la app

Una guía práctica de la API dinámica ShortcutManager y de TileService en Android — cómo hacer que una acción de un solo toque se salte la app por completo, con los errores en los que caen la mayoría de las implementaciones.

MFKAPPS 6 min de lectura

La versión más rápida de cualquier acción de una app es la que nunca llega a abrir la app. El bucle central de Hydrame — registrar un vaso de agua — ocurre decenas de veces al día, y pedirle a alguien que desbloquee el teléfono, encuentre el icono, espere un arranque en frío y luego toque un botón son tres pasos de más para algo que debería ser uno solo. Android ofrece dos formas de reducir eso a un solo toque: los app shortcuts de la pantalla de inicio y un Quick Settings Tile. Resuelven el mismo problema desde puntos de entrada distintos, y ambos son más sencillos de implementar de lo que parecen. Así es como funciona cada uno en realidad.

Dos puntos de entrada, dos trabajos distintos

Los app shortcuts viven bajo el icono del launcher — mantenlo pulsado y aparece un menú con las acciones que has definido. Sirven para un pequeño conjunto de acciones de alta frecuencia ligadas a esta app en concreto, descubribles por cualquiera que ya tenga el icono a la vista.

Un Quick Settings Tile vive en el panel de notificaciones, junto al Wi-Fi y el Bluetooth. Sirve para una acción que alguien quiere tener disponible desde cualquier sitio, sin tener que buscar ningún icono — más cerca de un botón físico que de una función de la app.

Hydrame incluye ambos: un shortcut para «Log 250ml» y «Log 500ml» bajo el icono del launcher, y un Quick Settings Tile que registra una cantidad por defecto con un solo toque desde el panel de la pantalla de bloqueo. Son API separadas, con rutas de registro separadas, y merece la pena construirlas por separado en lugar de intentar unificarlas.

Shortcuts dinámicos: ShortcutManagerCompat

Los shortcuts vienen en dos variantes — estáticos (declarados en XML, fijos en tiempo de compilación) y dinámicos (enviados en tiempo de ejecución, pueden cambiar según el estado de la app). Un «Log 250ml» fijo no necesita cambiar, así que un shortcut estático funcionaría, pero los shortcuts dinámicos permiten que las cantidades reflejen lo que alguien realmente bebe — los dos tamaños que más usa, no dos que la app se inventó.

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))

El intent tiene que apuntar a una Activity, no a un BroadcastReceiver — esa es la única restricción estricta que impone la API de shortcuts, a diferencia del patrón de acciones de notificación, donde un receiver es la opción correcta. La propia guía de Google recomienda hacer que esa Activity sea lo más invisible posible: sin layout, con finish() inmediatamente después de la escritura.

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()
        }
    }
}

Dale un tema transparente (Theme.Material3.DayNight.NoActionBar sobrescrito con windowIsTranslucent) y la transición se percibe como una confirmación del sistema, no como el lanzamiento de una app — todo el proceso se resuelve en menos de medio segundo en un dispositivo de gama media.

Orden y límite: los shortcuts no son un menú

ShortcutManagerCompat.setDynamicShortcuts() reemplaza todo el conjunto en cada llamada, y el sistema limita la cantidad — getMaxShortcutCountPerActivity() — normalmente alrededor de cuatro o cinco, según el launcher. Si envías más que eso, los sobrantes se descartan silenciosamente, no se encolan. El orden también importa: los shortcuts se muestran en el orden en que los pasas, así que la cantidad que alguien registra más debe ir primero, no al final.

La otra trampa es llamar a setDynamicShortcuts() con demasiada frecuencia. Cada llamada está pensada para cambios de estado reales — los hábitos de un usuario cambiando a lo largo de semanas, no cada arranque de la app. Reescribir los mismos dos shortcuts en cada arranque en frío no hace nada salvo costarte una llamada al sistema; comprueba si el conjunto realmente cambió antes de enviarlo.

El Quick Settings Tile: TileService

Un tile es un service, no una Activity — Android controla su ciclo de vida, y solo tienes un punto de entrada que realmente importa: 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() aparece aquí por la misma razón que en un BroadcastReceiver: se espera que onClick() devuelva el control rápido, y una escritura de Room en el hilo principal entorpecería la animación de cierre del panel. El tile tiene que declararse en el manifiesto con el permiso BIND_QUICK_SETTINGS_TILE, y — esto es lo que sorprende a la gente — no aparece en ningún sitio automáticamente. El usuario tiene que arrastrarlo a sus tiles activos desde la pantalla de edición del panel. TileService.requestListeningState() puede hacer que Android 13+ sugiera añadirlo, pero no hay forma de forzar su colocación, ni motivo para esperar que la mayoría de los usuarios se molesten en hacerlo. Trata el tile como una función para usuarios avanzados, no como una vía de interacción principal.

Mantener honesto el estado del tile

Un tile que siempre muestra el mismo icono sin importar el estado de la app parece roto la primera vez que no lo refleja. Si el objetivo diario de Hydrame ya se alcanzó, onStartListening() es el lugar para reflejarlo:

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() se dispara cada vez que el panel con tu tile se vuelve visible, no según un temporizador, así que esta lectura es barata y está actualizada por construcción — sin polling, sin ningún job en segundo plano para mantenerla sincronizada.

Por qué merece la pena construir esto

Ninguna de estas dos API es difícil una vez que conoces las dos reglas que no son obvias en la documentación: los shortcuts necesitan una Activity incluso para una escritura en segundo plano, y un tile necesita goAsync() por la misma razón que un receiver de acción de notificación. Lo que ganas es desproporcionado respecto al código — una pulsación larga o un toque en el panel sustituyendo el lanzamiento completo de una app para el puñado de acciones que alguien repite cada día. Es el tipo de fricción que los usuarios no notan conscientemente que se ha eliminado, solo su ausencia si se la quitas. Para cualquier app con una o dos acciones dominantes — registrar algo, iniciar algo, activar algo — merece la pena dedicarle una tarde.