Intents implícitos y filtros
Al terminar: Pedirle al sistema que abra un enlace o comparta un texto con un intent implícito, y recibir enlaces con un filtro.
Al terminar esta lección, Marcador podrá compartir el resultado del partido con las apps del móvil, abrir una web y recibir un enlace: abrirás el detalle desde el ordenador con marcador://partido/2, lanzado con adb.
Importa porque un intent implícito es la forma de pedir cosas a otras apps sin saber cuáles hay instaladas, y un filtro es la forma de que otras apps te las pidan a ti. Tema: intents implícitos y intent filters.
Qué hace un intent implícito
Un intent implícito no nombra una clase: declara una acción (ACTION_SEND, «enviar»; ACTION_VIEW, «ver») y, a veces, una URI (una dirección con forma de enlace, como https://kotlinlang.org o marcador://partido/2). El sistema busca entre las apps instaladas cuáles saben hacerlo; si hay varias, decide el usuario.
val implicito = Intent(
Intent.ACTION_VIEW, // la acción: «ver algo»
Uri.parse("https://kotlinlang.org") // convierte el texto en una dirección (Uri)
)
startActivity(implicito) // el sistema busca quién sabe abrir un enlace webEl intent filter: cómo se declara «yo sé hacer eso»
Para que una Activity reciba intents implícitos, en el Manifest lleva un <intent-filter>: una plantilla que dice qué acciones, categorías y datos acepta. Android compara cada intent implícito con los filtros de todas las apps instaladas.
| Parte del filtro | Qué significa |
|---|---|
<action android:name="android.intent.action.VIEW" /> | «Sé ver algo» |
<category android:name="android.intent.category.DEFAULT" /> | Una categoría es una etiqueta más del intent. Android añade DEFAULT por su cuenta a todo intent implícito que lanzas con startActivity, así que tu filtro tiene que aceptarla: sin ella, no te encuentra |
<category android:name="android.intent.category.BROWSABLE" /> | Puede abrirse desde un enlace del navegador |
<data android:scheme="marcador" android:host="partido" /> | Acepta enlaces que empiezan por marcador://partido: el esquema es lo que va antes de :// (marcador, https) y el host, lo que va justo después (partido) |
Además, android:exported dice si otras apps pueden lanzar ese componente. Con un intent-filter hay que declararlo a mano, y en "true": si no, el navegador o adb no podrían abrirla.
Las piezas, en una app de recetas
Las piezas que vas a usar:
Intent.ACTION_SEND,type = "text/plain"(el tipo MIME: una etiqueta estándar que dice qué clase de contenido viaja;text/plaines texto sin formato),Intent.EXTRA_TEXT(la clave estándar del texto a enviar) yIntent.createChooser(intent, título): compartir texto, con el selector de apps siempre visible..apply { … }: ejecuta el bloque sobre el objeto y lo devuelve; dentro,type = …yputExtra(…)se refieren alIntentque estás creando (lo viste en Funciones de alcance).- Funciones de extensión (
fun Context.compartir(...), en Extensiones): añaden una función aContext. Como unaActivityes unContext, puedes llamar acompartir(texto)desdeDetalleActivitysin nada delante. Uri.parse(texto): convierte un texto en unaUri;uri.lastPathSegmentes su último trozo (enmarcador://partido/2,"2");intent.dataes la URI del intent, onull.texto.removePrefix(prefijo): devuelve el texto sin ese principio, si lo tenía."marcador://partido/2".removePrefix("marcador://partido/")da"2". Ytexto.toIntOrNull()convierte aInto devuelvenullsi no es un número; con?.(de Nulabilidad) encadenas sin fallar.try { … } catch (e: ActivityNotFoundException) { … }: «intenta esto y, si falla con este error, haz esto otro» (lo viste en Errores).ActivityNotFoundException: lo lanzastartActivitysi nadie sabe hacer lo pedido.Toast.makeText(...).show()muestra un mensaje breve (lo viste en Context y Application).Log.w(tag, mensaje, error)escribe en Logcat comoLog.d, pero con nivelW(warning, aviso: algo no fue como se esperaba, sin ser grave) y adjunta el error.
Todas esas piezas, en una app de recetas inventada. No es Marcador: los nombres y los datos son otros, pero la forma es la que vas a escribir.
// Intent implícito como función de extensión de Context:
fun Context.enviarLista(texto: String) {
val envio = Intent(Intent.ACTION_SEND).apply { // qué quieres hacer: enviar
type = "text/plain" // tipo MIME: texto sin formato
putExtra(Intent.EXTRA_TEXT, texto) // el texto, en la clave estándar
}
startActivity(Intent.createChooser(envio, "Enviar lista")) // con selector siempre visible
}
fun Context.abrirMapa(url: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) // Uri.parse: de texto a dirección
} catch (e: ActivityNotFoundException) { // nadie sabe hacerlo
Log.w("Recetas", "Nadie abre $url", e) // nivel W (aviso), con el error adjunto
Toast.makeText(this, "Sin app para el mapa", Toast.LENGTH_SHORT).show() // mensaje breve en pantalla
}
}
// Dentro de RecetaActivity.onCreate: leer el número de un enlace "recetas://plato/7".
val numero = intent.data?.lastPathSegment?.toIntOrNull() // 7; null si no hay enlace o no es númeroY el filtro del manifiesto para que esa RecetaActivity acepte enlaces recetas://plato/…. Los comentarios van fuera de las etiquetas, porque XML no los admite dentro:
<!-- Va dentro de <application>, junto a las demás <activity>. -->
<!-- exported="true": otras apps (el navegador, adb) pueden abrirla. Obligatorio con intent-filter. -->
<activity
android:name=".RecetaActivity"
android:exported="true">
<intent-filter>
<!-- VIEW: «sé ver esto» -->
<action android:name="android.intent.action.VIEW" />
<!-- DEFAULT: Android la añade a todo startActivity implícito; sin ella no te encuentra -->
<category android:name="android.intent.category.DEFAULT" />
<!-- BROWSABLE: se puede abrir desde un enlace del navegador -->
<category android:name="android.intent.category.BROWSABLE" />
<!-- acepta direcciones que empiezan por recetas://plato -->
<data android:scheme="recetas" android:host="plato" />
</intent-filter>
</activity>Ejercicio · validar el enlace
Reto · Ejercicio: validar el enlace
En el Kotlin Playground, fuera de tu proyecto. Recrea la validación de DetalleActivity sin Android. Recuerda: removePrefix quita el principio de un texto y startsWith comprueba si lo tiene.
- Escribe una función que reciba un enlace como texto y devuelva el número de partido de un enlace como
marcador://partido/2, onullsi el enlace no empieza pormarcador://partido/o si lo que sigue no es un número. - Llama a la función con estos tres enlaces, imprime lo que devuelve en este orden:
marcador://partido/2,marcador://partido/abcyhttps://kotlinlang.org. Lo que debe salir:
2
null
null- Piensa: ¿por qué esta comprobación es necesaria aunque el enlace lo escribas tú?
Ver solución
fun numeroDePartido(enlace: String): Int? {
val prefijo = "marcador://partido/"
if (!enlace.startsWith(prefijo)) return null
// removePrefix quita el inicio que ya comprobamos; toIntOrNull da null si no es un número.
return enlace.removePrefix(prefijo).toIntOrNull()
}
fun main() {
println(numeroDePartido("marcador://partido/2")) // 2
println(numeroDePartido("marcador://partido/abc")) // null
println(numeroDePartido("https://kotlinlang.org")) // null
}Porque la Activity está exportada: cualquier app, o adb, puede lanzarla con lo que quiera. Los datos de un intent son entrada externa, como un formulario, y se validan antes de usarlos.
Paso · Compartir, abrir una web y el enlace marcador://partido/2
Objetivo. Añadir Compartir.kt, dos botones más en MainActivity, un botón «Compartir» en el detalle, y el filtro del Manifest con el que el detalle lee enlaces. Al terminar: «Compartir» abre el selector del sistema; y adb shell am start con el enlace abre el detalle de Lobos 1-2 Águilas.
Compartir.kt va en una carpeta nueva, core.ui, hermana de core.domain: es una acción de pantalla que usarán varias pantallas y una notificación. Se crea como core.domain en Intents explícitos y una segunda pantalla: en el panel, app → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador (el que no lleva (test) ni (androidTest)) → New → Package, escribe .core.ui al final (con el punto delante) y pulsa Intro. Al tener core dos carpetas dentro, la vista Android deja de juntarlas: verás core con domain y ui dentro. Después, clic derecho sobre ui, dentro de core → New → Kotlin Class/File, elige File, escribe Compartir y pulsa Intro.
El proyecto no usa nada que no hayas instalado ya: Intent, Uri y Toast vienen con el propio Android. No hay que tocar Gradle.
Qué hay que hacer en cada fichero (obligatorio; el código completo está más abajo):
Compartir.kt: dos funciones de extensión deContext,compartiryabrirEnlace.MainActivity.kt: dos botones más en laColumn, tras el de «Ver detalle». TienesIntentimportado desde antes; añadeimport com.sebastiannarvaez.marcador.core.ui.compartireimport com.sebastiannarvaez.marcador.core.ui.abrirEnlace(Option+Enter los añade).DetalleActivity.kt: tresimportdecore(compartir,partidosDeEjemployresumen; Option+Enter los añade), un botón «Compartir» y, en lugar de leer solo el extra, una funcióntextoRecibidoque mira primero si llegó un enlace.Resumen.kt: el mapapartidosDeEjemplo(número → partido).AndroidManifest.xml: la<activity>deDetalleActivity(con su nombre.laboratorio.DetalleActivity) pasa a tener el<intent-filter>yexported="true".
Ahora, el paso en tu proyecto. Los títulos de los bloques de abajo son las rutas de los ficheros en tu proyecto; en la vista Android están en app → kotlin+java (o app → manifests para el manifiesto).
Intenta escribirlo tú, en este orden:
Compartir.kt: dos funciones que cualquierContextpueda usar,compartir, que ofrece enviar un texto a otra app con el selector siempre visible, yabrirEnlace, que abre una dirección web y, si no hay app que sepa, lo anota en Logcat y avisa con un mensaje breve. EnMainActivity, un botón para cada una: «Compartir resultado (ACTION_SEND)» y «Abrir web (ACTION_VIEW)», que abre kotlinlang.org. Resultado esperado: el primero abre el selector del sistema conRayo FC 2-1 Toros; el segundo deja el navegador en primer plano.- En
DetalleActivity, un botón «Compartir» que comparte el texto que muestra. Resultado esperado: el mismo selector, desde el detalle. - El filtro del manifiesto, para que el detalle acepte los enlaces que empiezan por
marcador://partido. EnResumen.kt, los partidos por número (el 1 es Rayo contra Toros y el 2, Lobos contra Águilas), y en el detalle, si llega un enlace, el número del final elige el partido; si no lo conoce, dice «No conozco el partido N». Resultado esperado:marcador://partido/2abreLobos 1-2 Águilas.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.core.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
// Intent IMPLICITO: no dices QUE app, dices QUE quieres hacer (ACTION_SEND con un
// texto) y el sistema ofrece las apps que saben hacerlo (mensajes, correo...).
// `createChooser` fuerza que salga siempre el selector, aunque haya una por defecto.
fun Context.compartir(texto: String) {
val envio = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, texto)
}
startActivity(Intent.createChooser(envio, "Compartir resultado"))
}
// Otro implicito: ACTION_VIEW con una URL abre el navegador. Si no hubiera ninguna
// app capaz (un emulador sin navegador, por ejemplo) startActivity lanza
// ActivityNotFoundException: hay que atraparla.
fun Context.abrirEnlace(url: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (e: ActivityNotFoundException) {
Log.w("Intents", "Nadie sabe abrir $url", e)
Toast.makeText(this, "No hay app para abrir el enlace", Toast.LENGTH_SHORT).show()
}
}En MainActivity.kt, los dos botones van dentro de la Column, tras el de «Ver detalle».
Button(onClick = { compartir(Ejemplo.rayoContraToros.resumen()) }) {
Text("Compartir resultado (ACTION_SEND)")
}
Button(onClick = { abrirEnlace("https://kotlinlang.org") }) {
Text("Abrir web (ACTION_VIEW)")
}En DetalleActivity.kt, el botón «Compartir» va justo antes del de «Volver», dentro de la Column. El resto no cambia.
Button(onClick = { compartir(texto) }) { Text("Compartir") }En Resumen.kt, el mapa va debajo de los import y hay que añadir un tercero, Ejemplo. El resto no cambia.
import com.sebastiannarvaez.marcador.torneo.Ejemplo
// Los partidos que la app conoce, por numero. Un enlace `marcador://partido/2`
// llega con ese 2 como texto.
val partidosDeEjemplo: Map<Int, Partido> = mapOf(
1 to Ejemplo.rayoContraToros,
2 to Ejemplo.lobosContraAguilas,
)En DetalleActivity.kt, la pantalla se abre ahora de DOS formas: con el intent explícito de antes y con un intent implícito que casa con el filtro (un enlace marcador://partido/2). Hay que añadir import android.content.Intent y los tres import com.sebastiannarvaez.marcador.core.ui.compartir, import com.sebastiannarvaez.marcador.core.domain.partidosDeEjemplo e import com.sebastiannarvaez.marcador.core.domain.resumen (los símbolos ahora viven en core), cambiar la línea que calcula texto en onCreate y añadir la función textoRecibido dentro de la clase, justo antes del companion object.
val texto = textoRecibido(intent) // Un Intent trae DOS tipos de informacion: lo que se quiere hacer (action,
// data) y datos sueltos («extras»). Aqui se lee de donde toque. El parametro
// se llama igual que la propiedad `intent` de la Activity: dentro de la
// funcion manda el parametro, que recibe justo esa propiedad.
private fun textoRecibido(intent: Intent): String {
// Si viene de un enlace, `intent.data` es la URI marcador://partido/2.
val numero = intent.data?.lastPathSegment?.toIntOrNull()
if (numero != null) {
return partidosDeEjemplo[numero]?.resumen() ?: "No conozco el partido $numero"
}
// Si viene del intent explícito, el resumen va en un extra.
return intent.getStringExtra(EXTRA_RESUMEN) ?: "Sin partido"
}Por último, el manifiesto: la <activity> de DetalleActivity que escribiste en la lección anterior se sustituye por esta, que sí se deja abrir desde fuera. El resto del fichero no cambia.
<!-- Segunda pantalla. exported="true" porque tiene intent-filter y otras apps
(el navegador, adb) deben poder lanzarla. Casa con marcador://partido/<n>:
VIEW = «ver esto»; DEFAULT = imprescindible para los intents implicitos
de startActivity; BROWSABLE = permite abrirla desde un enlace del navegador. -->
<activity
android:name=".laboratorio.DetalleActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="marcador" android:host="partido" />
</intent-filter>
</activity>Probar los dos caminos
Los comandos adb se escriben en la Terminal de Android Studio y están explicados en la Chuleta de adb. Hoy usas am start, que lanza una Activity con el intent que le describas: sus opciones -a (action) y -d (data) rellenan la acción y la URI del Intent. (Si adb no se reconoce, mira Instalar Android Studio y el SDK; el emulador se arranca como en La Activity.) Ejecuta la app con Run, con app elegido arriba. Antes de cada prueba limpia Logcat con adb logcat -c.
1. Intent implícito, desde el ordenador. Con la app instalada, un enlace que casa con el filtro:
adb logcat -c
adb shell am start -a android.intent.action.VIEW -d "marcador://partido/2"am start imprime en la terminal:
Starting: Intent { act=android.intent.action.VIEW dat=marcador://partido/... }Y Logcat, con adb logcat -d -s Intents:
D Intents : DetalleActivity abierta con action=android.intent.action.VIEW data=marcador://partido/2 -> Lobos 1-2 ÁguilasAndroid oculta el final de la URI al imprimir el intent (partido/...), pero la Activity recibe la completa. Aquí el texto viene de intent.data, no de un extra: es el mismo mecanismo que usaría un enlace en un navegador (BROWSABLE). Prueba también marcador://partido/9 (No conozco el partido 9) y marcador://partido/abc (toIntOrNull() da null y sale Sin partido).
2. Intent implícito, a otras apps. Compartir resultado (ACTION_SEND) abre el selector del sistema con el texto Rayo FC 2-1 Toros. Qué apps salen, y con qué nombre, depende del dispositivo y de su idioma: en un emulador verás algo como Quick Share, Chrome, Messages, Drive y Copy to clipboard. Abrir web (ACTION_VIEW) deja el navegador en primer plano. Y el botón Compartir del detalle abre el mismo selector.
Opcional: una Activity que ya existe y onNewIntent
Hasta aquí cada intent abre una DetalleActivity nueva. Pero si ya hay una abierta arriba de la pila y le llega otro intent, Android puede reutilizarla en lugar de crear una segunda: entonces no llama a onCreate, sino a onNewIntent(intent), el callback que viste en la tabla de El ciclo de vida de la Activity. Para ello la Activity se declara con android:launchMode="singleTop" en el Manifest (modo de arranque: «si ya soy la pantalla de arriba, reutilízame»).
// En el Manifest: <activity android:name=".laboratorio.DetalleActivity" android:launchMode="singleTop" …>
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // sin esto, la propiedad `intent` seguiría siendo el mensaje antiguo
// … aquí se vuelve a leer intent.data y se actualiza la pantalla …
}Marcador no lo necesita todavía, así que no hay nada que escribir ni probar hoy. Lo aplicarás de verdad en Deep links en Android, donde un enlace puede llegar con la app ya abierta.
Punto de control
- Ver detalle abre la segunda pantalla con
Rayo FC 2-1 Toros, y Volver regresa a la principal. adb shell am start -a android.intent.action.VIEW -d "marcador://partido/2"abre el detalle conLobos 1-2 Águilas; con el número 9,No conozco el partido 9.- Compartir resultado abre el selector del sistema.
- En la Terminal de Android Studio,
./gradlew :app:assembleDebugtermina enBUILD SUCCESSFUL.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué pasa si
DetalleActivitydeclara un<intent-filter>pero no poneandroid:exported?2.Alguien lanza
marcador://partido/abcconadb. ¿Por quéDetalleActivityno falla?3.¿Para qué sirve la categoría
DEFAULTen el filtro de una Activity?4.¿Qué aporta
Intent.createChooser(envio, "Compartir resultado")al compartir un texto?5.En un emulador sin navegador,
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))lanza una excepción. ¿Cuál y qué haces?