Saltar al contenido
K
Deep links y compartirPaso a paso45 min Avanzado

Deep links en Android

Al terminar: Abrir el Pokémon correcto desde un enlace, con la app cerrada y con la app abierta.

Al terminar, un enlace como pokedex://pokemon/25 abre la ficha de Pikachu en tu app. Con la app cerrada y con la app ya abierta: son dos casos distintos por dentro, y el segundo es el que casi nadie prueba.

También verás por qué el enlace https no abre la app por sí solo en este proyecto, aunque el manifiesto lo pida. Es un límite real de la web en la que vive la página del curso, se comprueba con una orden de adb, y es la razón de que el esquema propio (pokedex://) sea el camino principal. Y verás cómo aprobar ese dominio a mano para ver el https abriendo la app.

El concepto · Un enlace que abre una pantalla

Un enlace profundo (en inglés, deep link) es una dirección que, en vez de abrir una página web, abre tu app en una pantalla concreta. La dirección tiene tres partes: el esquema (lo que va antes de ://, como https o pokedex), el dominio (el nombre de la web, como ejemplo.org) y la ruta (lo que va detrás, como /pokemon/25). Es como una dirección postal: el esquema es el tipo de envío (carta o paquete), el dominio es la ciudad y la calle, y la ruta es la puerta concreta.

Un enlace a un Pokémon se puede escribir de dos maneras, y ninguna sirve para todo:

  • pokedex://pokemon/25 (esquema propio) funciona siempre, sin dominio y sin verificación. A cambio, la mayoría de apps de mensajería no lo convierten en enlace pulsable: se queda en texto.
  • https://sebastiannarvaez11.github.io/pokedex-kmp/pokemon/25 sí es pulsable en cualquier sitio. A cambio, el sistema exige comprobar que ese dominio es tuyo. Un enlace https cuyo dominio Android ha verificado así, y que por eso abre la app directamente, se llama App Link (enlace de aplicación).

Para que la app entienda las dos, hay que leer sus partes y quedarse con lo que importa. Este ejemplo, ajeno al proyecto, hace justo eso con una biblioteca imaginaria que publica su web en una subcarpeta (/catalogo):

ejemplo mínimo: leer las partes de un enlace (con comentarios)
// Un enlace tiene tres partes: esquema://dominio/ruta
// Una biblioteca imaginaria publica su web en una subcarpeta, /catalogo.
const val ESQUEMA = "biblio"
const val DOMINIO = "ejemplo.org"
const val PREFIJO = "/catalogo"
 
// Devuelve el número del libro, o null si el enlace no es de un libro.
fun libroDe(url: String): Int? {
    val resto = url
        .substringAfter("://")                                       // quita «biblio://» o «https://»
        .let { if (it.startsWith(DOMINIO)) it.removePrefix(DOMINIO) else it }  // quita el dominio, si está (`let` recibe el texto como `it` y devuelve lo que calcules)
        .let { if (it.startsWith(PREFIJO)) it.removePrefix(PREFIJO) else it }  // y la subcarpeta, si está
    val ruta = "/" + resto.trim('/')                                 // «/libro/42»
    // `toIntOrNull()` da el número, o `null` si el texto no es un número.
    return if (ruta.startsWith("/libro/")) ruta.removePrefix("/libro/").toIntOrNull() else null
}
 
fun main() {
    println(libroDe("biblio://libro/42"))                         // esquema propio
    println(libroDe("https://ejemplo.org/catalogo/libro/42"))     // https con subcarpeta
    println(libroDe("https://ejemplo.org/catalogo/libro/quijote")) // no es un número
    println(libroDe("biblio://socios/7"))                         // otra pantalla que no existe
}
42
42
null
null

Pégalo en el Playground de Kotlin (play.kotlinlang.org) y ejecútalo. Es lo que hará Destination.parse en el paso 1, con el dominio y la subcarpeta de la web del curso. Fíjate en los dos null: un enlace que no entendemos no rompe nada, simplemente no lleva a ninguna parte.

Paso 1 · Destination entiende el dominio del curso

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.ktse modifica
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.ktse modifica
androidApp
src/main/AndroidManifest.xmllaunchMode y dos intent-filter
src/main/kotlin/com/sebastiannarvaez/pokedex/android
MainActivity.ktse modifica
navigation/NavKeys.ktse modifica
navigation/PokedexApp.ktse modifica
Qué cambia en esta lección. Las rutas del módulo compartido, desde `shared`; las de Android, desde `androidApp`.

El archivo. Destination.kt (shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt, el del mapa de pantallas) ya sabe leer pokedex://pokemon/25 y un https con el dominio de ejemplo pokedex-kmp.example. Lo que falta es apuntarlo a una página que exista. Usamos la web que el curso tiene publicada, sebastiannarvaez11.github.io/pokedex-kmp: una página gratuita que vive en una subcarpeta (/pokedex-kmp) de un dominio que no es del curso. No es tuya, y no hace falta que lo sea para esta lección; en una app propia pondrías aquí tu dominio.

La página del curso vive bajo esa subcarpeta y no en la raíz del dominio. Por eso el enlace lleva un prefijo (/pokedex-kmp) que parse tiene que saltarse, igual que PREFIJO en el ejemplo. Sustituye el companion object entero (es lo que está dentro de la interfaz Destination, tras toPath) por este:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt: el companion object completo
    companion object {
 
        /** El esquema propio, que funciona sin dominio ni verificación. */
        const val ESQUEMA = "pokedex"
 
        /** El dominio del enlace `https`, que sí se puede compartir por chat. */
        const val HOST = "sebastiannarvaez11.github.io"   // antes: pokedex-kmp.example
 
        /** El prefijo de la ruta: la subcarpeta donde vive la web del curso. */
        const val BASE_PATH = "/pokedex-kmp"              // nuevo
 
        /**
         * Traduce una URL entrante a una pantalla.
         *
         * Devuelve null en vez de lanzar: una URL que no entendemos no es un
         * error de programación, es alguien pegando cualquier cosa.
         */
        fun parse(url: String): Destination? {
            val sinEsquema = url
                // Quita «pokedex://» o «https://». Si el texto no trae «://»,
                // `missingDelimiterValue = url` lo deja entero, tal cual.
                .substringAfter("://", missingDelimiterValue = url)
                // Si empieza por el dominio, lo quita.
                .let { if (it.startsWith(HOST)) it.removePrefix(HOST) else it }
                // Nuevo: y si después viene el prefijo del proyecto, también.
                .let { if (it.startsWith(BASE_PATH)) it.removePrefix(BASE_PATH) else it }
            // `substringBefore('?')` descarta lo que va detrás de un «?»: los parámetros
            // que algunos enlaces añaden (`…/pokemon/25?origen=chat`) y que aquí no usamos.
            val ruta = "/" + sinEsquema.trim('/').substringBefore('?')
 
            return when {
                ruta == "/" -> Lista
                ruta.startsWith("/pokemon/") ->
                    // `toIntOrNull()` da el número, o `null` si el texto no lo es.
                    // `?.let(::Detalle)` solo actúa si hay número: `::Detalle` es el
                    // constructor de `Detalle` pasado como función, igual que
                    // `{ n -> Detalle(n) }`.
                    ruta.removePrefix("/pokemon/").toIntOrNull()?.let(::Detalle)
                else -> null
            }
        }
    }

Y al final del fichero, toShareUrl() (la que construye el enlace https) tiene que escribir el prefijo; toDeepLink() no cambia:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt: toShareUrl, ahora con el prefijo
/** El enlace que se comparte: `https`, para que sea pulsable en cualquier app. */
fun Destination.toShareUrl(): String =
    "https://${Destination.HOST}${Destination.BASE_PATH}${toPath()}"

Las dos direcciones salen del mismo sitio para que no se separen nunca. Un test de El mapa de pantallas compartido escribe el dominio de ejemplo a mano en dos sitios, así que hay que actualizarlo. Abre shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.kt y sustituye estas dos funciones:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.kt: las dos funciones que cambian
    @Test
    fun elEnlaceHttpsAbreLaMismaPantalla() {
        // Las dos formas llevan al mismo sitio: una sola traducción, no dos.
        assertEquals(
            Destination.parse("pokedex://pokemon/25"),
            Destination.parse("https://sebastiannarvaez11.github.io/pokedex-kmp/pokemon/25"),
        )
    }
 
    @Test
    fun laRaizEsLaLista() {
        assertEquals(Destination.Lista, Destination.parse("pokedex://"))
        assertEquals(Destination.Lista, Destination.parse("https://sebastiannarvaez11.github.io/pokedex-kmp/"))
    }

En la Terminal de Android Studio, en la carpeta de tu proyecto:

./gradlew :shared:jvmTest

Debe terminar con BUILD SUCCESSFUL y ningún test en rojo. Son los mismos 76 tests de la JVM que antes (si quieres ver la cifra, abre el informe de tests como ya hiciste antes; el triángulo verde junto a la clase solo ejecuta los de esa clase, los cinco de DestinationTest): esta lección cambia dos tests, no añade ninguno. Si falla elEnlaceHttpsAbreLaMismaPantalla, te has dejado el dominio de ejemplo en algún sitio.

Paso 2 · Dos intent-filter en el manifiesto

El archivo. AndroidManifest.xml ya lo has tocado (HTTP y Ktor: el cliente de PokeAPI). Ábrelo en androidApp/src/main/AndroidManifest.xml. La MainActivity ya tiene el filtro de lanzador, el que hace que el icono abra la app. En su elemento <activity> hay dos cambios, y conservas todos los atributos que ya tiene:

  1. Un atributo nuevo, junto a los demás (por ejemplo, debajo de android:label):
androidApp/src/main/AndroidManifest.xml: atributo nuevo del elemento activity
            android:launchMode="singleTop"

launchMode="singleTop" dice «si la pantalla que se pide ya está arriba, reutilízala en vez de crear otra MainActivity». Ojo, que hay dos pilas distintas: esta es la pila de Activities de Android (las pantallas del sistema, una por MainActivity abierta); la lista de claves de Navigation 3 que verás en el paso 4 es otra, dentro de la misma Activity. singleTop es lo que hace que el paso 3 tenga sentido, y lo verás actuar en el paso 6: el segundo am start dirá intent has been delivered to currently running top-most instance.

  1. Dos filtros nuevos, debajo del <intent-filter> del lanzador y dentro de la misma <activity>. Un detalle del segundo: android:pathPrefix dice «acepta las rutas que empiecen por esto». Aquí es /pokedex-kmp/pokemon/, así que el filtro solo captura enlaces a fichas; la raíz de la web (la lista) no abre la app. Por eso parse entiende la lista, pero este filtro no la deja llegar:
androidApp/src/main/AndroidManifest.xml: los dos filtros nuevos
            <!-- Filtro 1: el esquema propio, pokedex://. Funciona siempre, sin
                 dominio ni verificación. -->
            <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="pokedex" />
            </intent-filter>
 
            <!-- Filtro 2: el enlace https. `autoVerify` pide a Android que
                 compruebe que el dominio es de la app (paso 5). -->
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data
                    android:scheme="https"
                    android:host="sebastiannarvaez11.github.io"
                    android:pathPrefix="/pokedex-kmp/pokemon/" />
            </intent-filter>

Paso 3 · Recibirlo: dos sitios, no uno

El archivo. MainActivity.kt es la puerta de la app, y el enlace puede llegar por dos puertas distintas: con la app cerrada, dentro del Intent que la arranca (onCreate); con la app ya abierta, en onNewIntent. Hay que atender las dos. Y el destino que llega tiene que ser un estado (mutableStateOf), no un valor leído una vez: así Compose reacciona cada vez que cambia. Sustituye el fichero androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt entero por este (PokedexApp aún no acepta esos dos parámetros: Android Studio los marcará en rojo hasta el paso 4). Cuatro cosas nuevas antes de leerlo:

  • Hasta ahora mutableStateOf iba siempre dentro de remember, en un composable. Aquí es una propiedad de la Activity: vive lo que vive ella, y Compose redibuja igual cuando cambia, porque setContent la lee.
  • intent es una propiedad que tiene toda Activity: el mensaje que la arrancó (en Java se ve como getIntent(), el mismo nombre con otro aspecto). intent?.dataString es la dirección que trae ese mensaje, como texto (o null si no trae ninguna, que es lo que pasa al abrir la app con el icono). Destination::parse pasa la función parse como valor, igual que ::Detalle en el paso 1.
  • Al girar el móvil, intent no cambia: sigue siendo el mensaje que arrancó la app, con el enlace dentro. Si lo leyéramos en cada onCreate, después de volver a la lista y girar el móvil la ficha del enlace volvería a abrirse sola. Por eso el enlace se lee solo la primera vez, cuando savedInstanceState es null.
  • El resto (settingsVm, el tema) ya lo tenías de Los ajustes mandan en toda la app, en Android.
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt: el fichero entero
package com.sebastiannarvaez.pokedex.android
 
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.android.navigation.PokedexApp
import com.sebastiannarvaez.pokedex.android.ui.PokedexTheme
import com.sebastiannarvaez.pokedex.feature.settings.SettingsViewModel
import com.sebastiannarvaez.pokedex.navigation.Destination
import org.koin.compose.viewmodel.koinViewModel
 
class MainActivity : ComponentActivity() {
 
    // El destino que trajo el enlace, o null si no hubo. Es un estado
    // observable porque puede cambiar con la app ya abierta. Sin `remember`:
    // `remember` es solo para composables; aquí es una propiedad de la Activity
    // y se crea una vez por cada Activity.
    private val destinoEntrante = mutableStateOf<Destination?>(null)
 
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
 
        // App cerrada: el enlace viene en el Intent que la arrancó.
        // `dataString` es la URL; `parse` la traduce o devuelve null.
        // Solo si la Activity es nueva: al girar el móvil Android la recrea,
        // `savedInstanceState` ya no es null y `intent` sigue siendo el
        // original, con el enlace. Sin el `if`, la ficha reaparecería sola.
        if (savedInstanceState == null) {
            destinoEntrante.value = intent?.dataString?.let(Destination::parse)
        }
 
        setContent {
            // El tema se lee en la raíz, no dentro de la pantalla de ajustes:
            // si no, cambiarlo solo repintaría esa pantalla.
            val settingsVm: SettingsViewModel = koinViewModel()
            val preferencias by settingsVm.settings.collectAsStateWithLifecycle()
 
            PokedexTheme(tema = preferencias.tema) {
                PokedexApp(
                    destinoEntrante = destinoEntrante.value,
                    // Cuando la app usa el destino, lo apaga para no repetirlo.
                    alConsumirDestino = { destinoEntrante.value = null },
                )
            }
        }
    }
 
    // App abierta: no hay onCreate, llega un Intent nuevo.
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // Se lee el parámetro `intent`, no `getIntent()`: ese sigue
        // devolviendo el intent original que arrancó la Activity.
        destinoEntrante.value = intent.dataString?.let(Destination::parse)
    }
}

Paso 4 · Dónde cae el enlace dentro de la pila

Primero, la traducción. Navigation 3 navega con claves (ListaKey, DetalleKey), no con Destination. Navegación en Android con Navigation 3 dejó la traducción pendiente para esta lección. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt, añade el import en su sitio, entre los demás, y la función al final del fichero:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt: el import nuevo
import com.sebastiannarvaez.pokedex.navigation.Destination
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt: la función nueva, al final del fichero
/** Traduce el mapa compartido a la clave que entiende Navigation 3. */
fun Destination.aNavKey(): NavKey = when (this) {
    Destination.Lista -> ListaKey
    is Destination.Detalle -> DetalleKey(pokemonId)
}

Aquí el when es exhaustivo sobre una interfaz sellada: si mañana existe una pantalla nueva en Destination, esto deja de compilar hasta que decidas su clave.

Después, la pila de Navigation 3 (la lista de claves de dentro de la Activity, no la pila de Activities del paso 2). Abrir una ficha desde fuera no es lo mismo que abrirla desde la lista: si el enlace deja la ficha sola en la pila, el botón de volver cierra la app. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt. Solo cambia el principio de la función. Dos import nuevos, junto a los demás (Option+Enter sobre los nombres en rojo también los añade):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: imports nuevos
import androidx.compose.runtime.LaunchedEffect
import com.sebastiannarvaez.pokedex.navigation.Destination

Antes, el principio de la función era:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: el principio, antes
@Composable
fun PokedexApp(inicio: NavKey = ListaKey) {
    val pila = rememberNavBackStack(inicio)
    val actual = pila.lastOrNull()
    // El número de la pestaña: otro lector del mismo repositorio de favoritos.
    val favoritos: FavoritesViewModel = koinViewModel()
    val insignia by favoritos.insignia.collectAsStateWithLifecycle()

Ya no hay parámetro inicio: nadie lo usaba (si alguna vista previa se lo pasaba, quítaselo). Después, con el destino entrante. Tres cosas se quedan como estaban:

  • Scaffold( y todo lo de debajo, exactamente igual.
  • entryDecorators, con sus dos decoradores.
  • Las tres líneas de la insignia de Favoritos, debajo de val actual: ya las tenías de la lección de ajustes, no las borres.
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: el principio, después
@Composable
fun PokedexApp(
    destinoEntrante: Destination? = null,   // nuevo: el enlace que llegó, si llegó
    alConsumirDestino: () -> Unit = {},     // nuevo: avisa de que ya se usó
) {
    val pila = rememberNavBackStack(ListaKey)   // antes recibía `inicio`; ya no lo usa nadie
    val actual = pila.lastOrNull()
    // El número de la pestaña: otro lector del mismo repositorio de favoritos.
    val favoritos: FavoritesViewModel = koinViewModel()
    val insignia by favoritos.insignia.collectAsStateWithLifecycle()
 
    // Se ejecuta al empezar y cada vez que llega un destino distinto.
    LaunchedEffect(destinoEntrante) {
        val destino = destinoEntrante ?: return@LaunchedEffect   // sin enlace, sale del efecto
        val clave = destino.aNavKey()
        pila.clear()                                 // se parte de cero…
        if (clave != ListaKey) pila.add(ListaKey)    // …con la lista debajo…
        pila.add(clave)                              // …y el destino encima
        alConsumirDestino()                          // lo gastado se apaga
    }

return@LaunchedEffect sale solo del bloque del efecto (un return a secas intentaría salir de la función PokedexApp entera). Y ?: dice «si lo de la izquierda es null, haz lo de la derecha».

alConsumirDestino() apaga el estado en cuanto se usa. Para ver por qué, un ejemplo ajeno al proyecto: un buzón que solo avisa cuando su contenido cambia, igual que un LaunchedEffect con clave solo se repite si la clave cambia.

ejemplo mínimo: un aviso que solo salta si el valor cambia (con comentarios)
// Un buzón que solo avisa cuando su contenido CAMBIA,
// igual que un LaunchedEffect con clave solo se repite si la clave cambia.
class Buzon(private val consumir: Boolean) {
    private var carta: String? = null
 
    fun llega(nueva: String) {
        if (nueva == carta) {                 // mismo valor: no hay cambio, nadie se entera
            println("llega $nueva -> (nada)")
            return
        }
        carta = nueva
        println("llega $nueva -> abro $nueva")
        if (consumir) carta = null            // la leo y vacío el buzón
    }
}
 
fun main() {
    println("Sin vaciar el buzón:")
    val a = Buzon(consumir = false)
    a.llega("ficha 25")
    a.llega("ficha 25")
    println("Vaciándolo al leer:")
    val b = Buzon(consumir = true)
    b.llega("ficha 25")
    b.llega("ficha 25")
}
Sin vaciar el buzón:
llega ficha 25 -> abro ficha 25
llega ficha 25 -> (nada)
Vaciándolo al leer:
llega ficha 25 -> abro ficha 25
llega ficha 25 -> abro ficha 25

Eso es lo que pasa aquí: se apaga para que el mismo enlace pueda llegar dos veces. Si el estado se quedara en Detalle(25), un segundo enlace al 25 (tras volver a la lista, por ejemplo) pondría el mismo valor, la clave no cambiaría y el efecto no se ejecutaría. Apagado a null, cada enlace es un cambio.

autoVerify="true" le pide a Android que compruebe que la app y el dominio son del mismo dueño. La comprobación descarga un fichero, assetlinks.json, que el dueño del dominio publica con la huella SHA-256 del certificado de firma de la app. Toda app de Android va firmada: lleva un sello digital que demuestra quién la hizo. Las de prueba las firma Android Studio solo, con un certificado de depuración que creó en tu ordenador la primera vez. La huella es un resumen corto de ese sello, calculado con un método llamado SHA-256: 32 pares de cifras y letras (del 0 al 9 y de la A a la F, lo que se llama hexadecimal) separados por dos puntos, como 90:1F:FE:…. En el paso 6 verás la de tu app, y será distinta de la del curso porque tu certificado es otro. Así sería el fichero (con com.sebastiannarvaez.pokedex, o el applicationId de tu proyecto, en package_name):

assetlinks.json: lo que publicaría el dueño del dominio
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.sebastiannarvaez.pokedex",
      "sha256_cert_fingerprints": ["LA:HUELLA:DE:TU:CERTIFICADO"]
    }
  }
]

Tú no puedes publicarlo en sebastiannarvaez11.github.io: el dominio no es tuyo. Pero aunque lo fuera, aquí hay una segunda trampa. Antes, una palabra: .well-known es una carpeta de nombre fijo que Android y iOS buscan siempre en la raíz de una web, es decir, justo detrás del dominio (https://dominio/.well-known/…) y nunca dentro de una subcarpeta.

Para verificar un App Link hace falta un dominio cuya raíz controles, y que el fichero lleve la huella de tu certificado. Ninguna de las dos cosas está a tu alcance en este curso sin comprar un dominio, y por eso el esquema propio es el camino principal. El https se queda como el enlace que se comparte: sin verificar, abre el navegador en la página del curso que invita a abrir la app.

Paso 6 · Probarlo sin mandarte mensajes

Con el emulador arrancado y la app instalada (botón Run, como en Ejecutar en Android), lo más cómodo es disparar los enlaces desde la Terminal de Android Studio. adb shell ejecuta una orden dentro del emulador; am start (activity manager, el gestor de actividades) lanza una pantalla con el intent que le describas, y pm (package manager, el gestor de paquetes) pregunta por las apps instaladas. am lo usaste en Intents implícitos y filtros y pm en Gradle: ficheros, catálogo y dependencias; los dos están en la Chuleta de adb.

Con la app cerrada. Ciérrala del todo (en el emulador, desliza hacia arriba desde la barra de gestos para ver las apps recientes y desliza la tarjeta de la Pokédex hacia arriba para descartarla) y lanza:

adb shell am start -a android.intent.action.VIEW -d 'pokedex://pokemon/25'

Se abre la ficha de Pikachu. Pulsa volver: debe llevarte a la lista, no cerrar la app.

Con la app abierta. Con la Pokédex delante, lanza otro enlace, esta vez al 6:

adb shell am start -a android.intent.action.VIEW -d 'pokedex://pokemon/6'

La pantalla cambia a Charizard. Es el caso de onNewIntent, y la orden responde Warning: Activity not started, intent has been delivered to currently running top-most instance.: es singleTop reutilizando la Activity. Pulsa volver: te lleva a la lista, y no a un Pikachu anterior. Después, con la ficha abierta, vuelve a la lista y gira el móvil (en el emulador, con los botones de rotar de su barra lateral; si no gira, activa la rotación automática en los ajustes rápidos): te quedas en la lista, gracias al if (savedInstanceState == null).

Prueba también pokedex://pokemon/pikachu con la misma orden: la app pasa al frente y no cambia de pantalla, porque parse devuelve null. Un enlace mal escrito no rompe nada, y eso es lo que fija el test loQueNoEntendemosNoRevienta, el que escribiste en El mapa de pantallas compartido.

Con la verificación. Estas dos órdenes abren el enlace https y preguntan por el estado de la verificación:

adb shell am start -a android.intent.action.VIEW -d 'https://sebastiannarvaez11.github.io/pokedex-kmp/pokemon/25'
adb shell pm get-app-links com.sebastiannarvaez.pokedex

En estas órdenes (y en la de más abajo), com.sebastiannarvaez.pokedex es el identificador de la app: si en Crear el proyecto Pokédex pusiste otro applicationId, usa el tuyo.

La primera abre el navegador, no la app, y cae en la página del curso que invita a abrir la app. Si es la primera vez que abres Chrome en el emulador, enseña antes una pantalla de bienvenida: acéptala o pulsa «No thanks». La segunda enseña la huella de firma de tu app (Signatures, la que iría en assetlinks.json) y el estado de la verificación del dominio:

Signatures: [90:1F:FE:…:D6:84]
Domain verification state:
  sebastiannarvaez11.github.io: 1024

Tu huella será distinta de esta: es la de tu certificado de depuración. El estado puede ser un número de 1024 o más (un código de error del verificador del dispositivo; en Android 15 sale 1024) o una palabra, como legacy_failure, según la versión. Lo importante es que sea cualquier cosa que no sea verified.

Aprobar el dominio a mano (opcional)

Este apartado es opcional: sirve para ver el https abriendo la app, pero no hace falta para seguir el curso. Lo que la verificación hace sola, el usuario puede hacerlo a mano: permitir que la app abra los enlaces de ese dominio, en los ajustes de la app. Desde la Terminal se hace con pm set-app-links-user-selection (--user cur significa «para el usuario actual del emulador»), y después se vuelve a lanzar el enlace https:

adb shell pm set-app-links-user-selection --user cur --package com.sebastiannarvaez.pokedex true sebastiannarvaez11.github.io
adb shell am start -a android.intent.action.VIEW -d 'https://sebastiannarvaez11.github.io/pokedex-kmp/pokemon/25'

Ahora el https abre la ficha de Pikachu en tu app, sin haber verificado nada. La verificación existe para que no haga falta pedírselo a nadie. Se deshace con la misma orden cambiando true por false.

Punto de control

  • ./gradlew :shared:jvmTest termina en BUILD SUCCESSFUL, con los cinco tests de DestinationTest y ninguno en rojo. Esta lección no añade tests: siguen los 76 de la JVM.
  • Con la app cerrada, el adb shell am start del esquema propio abre la ficha de Pikachu.
  • Con la app abierta, el mismo comando con otro número cambia la pantalla a ese Pokémon.
  • Desde la ficha abierta por enlace, volver lleva a la lista y no cierra la app.
  • adb shell pm get-app-links responde, para el dominio, un estado distinto de verified (por ejemplo 1024).
  • El enlace https abre el navegador en la página del curso que invita a abrir la app.
  • (Opcional) Tras aprobar el dominio a mano, el mismo enlace https abre la ficha de Pikachu en la app.

6 preguntas · necesitas 5 para completar la lección

  1. 1.Lanzas un enlace con la app ya abierta: la pantalla no cambia y, al volver atrás, reaparece la de antes. ¿Qué falta?

  2. 2.adb shell pm get-app-links no muestra verified para el dominio, sino un número como 1024 (o una palabra como legacy_failure). ¿Qué significa?

  3. 3.¿Por qué el esquema propio pokedex:// es el camino principal y no el enlace https?

  4. 4.Al abrir una ficha por enlace, la pila se vacía y se apilan la lista y el detalle. ¿Por qué no solo el detalle?

  5. 5.¿Por qué Destination.parse devuelve null en vez de lanzar una excepción?

  6. 6.Abres la app con un enlace a Pikachu, vuelves a la lista y giras el móvil. ¿Por qué no reaparece la ficha?

Faltan 6 de 6