Los tres conceptos — por qué tanta confusión
La confusión viene de que Android usa el término "deep link" para referirse a tres cosas distintas según el contexto. Aclaremos cada una de una vez:
- Deep link con esquema custom: una URI con un esquema propio de la app, como
miapp://productos/42. Solo tu app responde a este esquema. Sin verificación de dominio. El más simple. - Web link: una URI
http://ohttps://normal. Cuando el usuario la toca, Android pregunta con qué app abrirla (el "disambiguator"). Tu app puede ser una de las opciones. - App Link: una URI
https://cuyo dominio está verificado como perteneciente a tu app. Android abre tu app directamente, sin preguntar. Requiere configuración extra en el servidor y en el Manifest.
# Resumen rápido:
# miapp://productos/42 → Deep link custom — abre directamente tu app
# https://miapp.com/... → Web link — muestra el disambiguator
# https://miapp.com/... + → App Link verificado — abre directamente tu app
# assetlinks.json en el servidor
Por qué el disambiguator es un problemaCuando un usuario toca un link https:// desde una notificación o un email, Android le pregunta si quiere abrirlo con Chrome o con tu app. La mayoría elige Chrome. Con App Links verificados, tu app se abre directamente sin esa pregunta — mejora significativa en conversión.
Deep links con esquema custom
El más simple. Definís un esquema propio (miapp://) y registrás las URIs que tu app maneja. Nadie más puede registrar el mismo esquema en el mismo dispositivo salvo que tenga el mismo applicationId.
<!-- AndroidManifest.xml -->
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link: miapp://productos/{id} -->
<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="miapp" android:host="productos" />
</intent-filter>
<!-- Múltiples hosts en el mismo esquema: -->
<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="miapp" android:host="checkout" />
<data android:scheme="miapp" android:host="perfil" />
</intent-filter>
</activity>
Con esto, cualquier URI miapp://productos/42 abre directamente tu app. La desventaja: estos links no funcionan en el browser. Si ponés miapp://productos/42 en un email o en una web, Chrome no lo va a abrir — solo funciona desde apps que explícitamente construyen el Intent.
Web links (http/https) — el comportamiento que sorprende
Podés registrar tu app para manejar URLs https:// normales. El problema es el comportamiento cuando hay múltiples apps que manejan el mismo dominio:
<!-- Registrar para URLs https://miapp.com/* -->
<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="https" android:host="miapp.com" />
</intent-filter>
# Lo que pasa cuando alguien toca https://miapp.com/productos/42:
#
# Android busca apps que manejen esta URL
# Encuentra: Chrome, tu app (y tal vez otras)
# Muestra el disambiguator: "¿Con qué querés abrir este link?"
# El usuario elige — y puede elegir Chrome para siempre ("Siempre")
#
# Si el usuario eligió Chrome "siempre", tu app nunca más recibe esa URL
# hasta que el usuario cambie los defaults en Configuración
Este comportamiento es el motivo por el que el simple registro en el Manifest no es suficiente. Necesitás App Links verificados para evitar el disambiguator.
App Links — la solución completa
Los App Links verificados le dicen a Android: "este dominio le pertenece a esta app". Cuando está verificado, Android abre tu app directamente sin preguntar. La verificación usa un archivo JSON que ponés en tu servidor.
Requiere dos cosas:
- El intent-filter en el Manifest con
android:autoVerify="true" - Un archivo
assetlinks.jsonaccesible enhttps://tudominio.com/.well-known/assetlinks.json
<!-- Manifest — agregar autoVerify="true" -->
<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="miapp.com" />
<data android:scheme="https" android:host="www.miapp.com" />
</intent-filter>
Verificación del dominio — el assetlinks.json
Este archivo JSON va en tu servidor web, en la ruta exacta /.well-known/assetlinks.json. Le dice a Android qué app puede reclamar ese dominio, identificada por su applicationId y el SHA-256 de su certificado de firma.
# 1. Obtener el SHA-256 del certificado de firma:
keytool -list -v -keystore mi-app-release.keystore -alias mi-app
# Buscá en el output:
# Certificate fingerprints:
# SHA1: ...
# SHA256: AB:CD:EF:12:... ← este es el que necesitás (sin los dos puntos)
// https://miapp.com/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "ar.pensa.miapp",
"sha256_cert_fingerprints": [
"AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78"
]
}
}]
Requerimientos del servidor para que Android acepte el archivo:
# ✓ Accesible por HTTPS (no HTTP)
# ✓ Content-Type: application/json
# ✓ Sin redirecciones (el archivo debe estar en la URL exacta)
# ✓ El servidor no debe requerir autenticación para acceder al archivo
# ✓ Accesible desde la IP de los servidores de Google (para la verificación automática)
# Verificar que el archivo es accesible:
curl -I https://miapp.com/.well-known/assetlinks.json
# Debe retornar 200 con Content-Type: application/json
# Si usás múltiples dominios (www y sin www), necesitás el mismo archivo en ambos:
# https://miapp.com/.well-known/assetlinks.json
# https://www.miapp.com/.well-known/assetlinks.json
La verificación falla silenciosamenteSi el assetlinks.json tiene un error o no es accesible, Android simplemente no verifica el dominio y el comportamiento vuelve a ser el del web link (con disambiguator). No hay ningún error visible. La única forma de saber si la verificación funcionó es usar las herramientas de debugging que se mencionan al final del artículo.
Configuración completa en el Manifest
El caso típico: una app que quiere manejar tanto un esquema custom como URLs verificadas de su dominio:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<!-- Launcher normal -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links con esquema custom -->
<!-- miapp://productos/42, miapp://checkout, etc. -->
<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="miapp" />
</intent-filter>
<!-- App Links verificados -->
<!-- https://miapp.com/productos/42, etc. -->
<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="miapp.com" />
<data android:scheme="https" android:host="www.miapp.com" />
</intent-filter>
</activity>
singleTask evita instancias duplicadasCon android:launchMode="singleTask", si la app ya está abierta y llega un deep link, Android llama a onNewIntent() en lugar de crear una nueva instancia. Sin esto, podés terminar con múltiples instancias de MainActivity apiladas.
Navigation Component — la forma moderna
Si usás Navigation Component, podés declarar los deep links directamente en el NavGraph en lugar del Manifest. Navigation Component genera automáticamente el intent-filter correspondiente.
<!-- res/navigation/nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/productoDetalleFragment"
android:name=".feature.productos.DetalleFragment">
<argument
android:name="productoId"
app:argType="integer" />
<!-- Deep link con esquema custom -->
<deepLink app:uri="miapp://productos/{productoId}" />
<!-- App Link (con autoVerify en el intent-filter generado) -->
<deepLink
app:uri="https://miapp.com/productos/{productoId}"
app:action="android.intent.action.VIEW"
app:mimeType="*/*" />
</fragment>
<fragment android:id="@+id/checkoutFragment"
android:name=".feature.checkout.CheckoutFragment">
<deepLink app:uri="miapp://checkout" />
<deepLink app:uri="https://miapp.com/checkout" />
</fragment>
</navigation>
// Para que Navigation Component genere los intent-filters automáticamente,
// agregar el NavHostFragment en el Manifest con el NavGraph:
// (AndroidManifest.xml ya debe tener la Activity con exported="true")
// En MainActivity — Navigation Component maneja automáticamente el Intent
// si la Activity fue abierta desde un deep link:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navController = findNavController(R.id.nav_host_fragment)
// Navigation Component analiza el Intent automáticamente
// y navega al destino correcto si hay un deep link
// No necesitás código extra para esto
}
}
Navigation Component extrae los argumentos automáticamenteSi el deep link es miapp://productos/{productoId} y la URI que llega es miapp://productos/42, Navigation Component extrae el productoId = 42 y lo pone en el Bundle de argumentos del Fragment. El Fragment lo recibe como si hubiera navegado normalmente con Safe Args.
Recibir el Intent manualmente — sin Navigation Component
Si no usás Navigation Component, tenés que procesar el Intent a mano en la Activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Procesar el Intent que abrió la Activity
manejarDeepLink(intent)
}
// onNewIntent se llama cuando la Activity ya existe (launchMode = singleTask)
// y llega un nuevo Intent (ej: otro deep link mientras la app está abierta)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
manejarDeepLink(intent)
}
private fun manejarDeepLink(intent: Intent) {
// Verificar que el Intent es de tipo VIEW (no MAIN)
if (intent.action != Intent.ACTION_VIEW) return
val uri = intent.data ?: return
when {
// Esquema custom: miapp://productos/42
uri.scheme == "miapp" && uri.host == "productos" -> {
val productoId = uri.lastPathSegment?.toIntOrNull() ?: return
navegarADetalle(productoId)
}
// App Link: https://miapp.com/productos/42
uri.host == "miapp.com" && uri.path?.startsWith("/productos/") == true -> {
val productoId = uri.lastPathSegment?.toIntOrNull() ?: return
navegarADetalle(productoId)
}
uri.scheme == "miapp" && uri.host == "checkout" -> {
navegarACheckout()
}
else -> {
// URI no reconocida — ir al home
navegarAHome()
}
}
}
}
Deep links desde notificaciones push
El caso de uso más frecuente: una notificación push que lleva al usuario a una pantalla específica. El patrón con Navigation Component:
// En FirebaseMessagingService — construir el PendingIntent con el deep link
class MiMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val productoId = message.data["producto_id"] ?: return
val deepLinkUri = Uri.parse("miapp://productos/$productoId")
// Opción 1: PendingIntent directo con la URI
val intent = Intent(Intent.ACTION_VIEW, deepLinkUri).apply {
setPackage(packageName) // forzar que abra solo nuestra app
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, productoId.toInt(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Opción 2 (con Navigation Component): NavDeepLinkBuilder — más limpio
val pendingIntentNav = NavDeepLinkBuilder(this)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.productoDetalleFragment)
.setArguments(bundleOf("productoId" to productoId.toInt()))
.createPendingIntent()
// Armar y mostrar la notificación con el pendingIntent...
val notificacion = NotificationCompat.Builder(this, "ofertas")
.setContentTitle("Oferta especial")
.setContentText("Mirá este producto antes de que se acabe")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntentNav)
.setAutoCancel(true)
.build()
getSystemService(NotificationManager::class.java)
.notify(productoId.hashCode(), notificacion)
}
}
Cómo testear deep links y App Links
# Testear deep links con adb — el método más directo:
# Abrir la app con un deep link custom:
adb shell am start \
-W -a android.intent.action.VIEW \
-d "miapp://productos/42" \
ar.pensa.miapp
# Testear un App Link (https):
adb shell am start \
-W -a android.intent.action.VIEW \
-d "https://miapp.com/productos/42" \
ar.pensa.miapp
# Verificar si los App Links están verificados en el dispositivo:
adb shell pm get-app-links ar.pensa.miapp
# Output que querés ver:
# ar.pensa.miapp:
# ID: ...
# Signatures: [...]
# Domain verification state:
# miapp.com: verified ← "verified" es el estado correcto
# www.miapp.com: verified
# Si dice "none" o "ask" en lugar de "verified":
# → El assetlinks.json no está accesible, tiene un error, o el SHA no coincide
# Forzar la re-verificación (útil durante el desarrollo):
adb shell pm verify-app-links --re-verify ar.pensa.miapp
# Herramienta online de Google para validar el assetlinks.json:
# developers.google.com/digital-asset-links/tools/generator
// Test unitario del manejo del Intent:
class MainActivityTest {
@Test
fun `deep link de producto navega al detalle`() {
val scenario = ActivityScenario.launch<MainActivity>(
Intent(Intent.ACTION_VIEW, Uri.parse("miapp://productos/42"))
)
scenario.onActivity { activity ->
val navController = activity.findNavController(R.id.nav_host_fragment)
assertThat(navController.currentDestination?.id)
.isEqualTo(R.id.productoDetalleFragment)
}
}
@Test
fun `deep link invalido va al home`() {
val scenario = ActivityScenario.launch<MainActivity>(
Intent(Intent.ACTION_VIEW, Uri.parse("miapp://desconocido/"))
)
scenario.onActivity { activity ->
val navController = activity.findNavController(R.id.nav_host_fragment)
assertThat(navController.currentDestination?.id)
.isEqualTo(R.id.homeFragment)
}
}
}
Cuándo usar cada tipo — la decisión
# ── ESQUEMA CUSTOM (miapp://) ─────────────────────────────────
# ✓ Links internos entre apps propias del mismo desarrollador
# ✓ Deep links en notificaciones push (solo se ejecutan en el dispositivo)
# ✓ Links en QR codes que solo se escanean con apps conocidas
# ✓ Comunicación entre tu app y otras apps que tenés bajo control
# ✗ No funciona en browsers
# ✗ No funciona en emails que se leen en el browser
# ✗ Otro desarrollador podría registrar el mismo esquema
# ── APP LINKS VERIFICADOS (https://) ─────────────────────────
# ✓ Links compartidos en redes sociales, emails, WhatsApp, SMS
# ✓ Links en el sitio web que deben abrir la app si está instalada
# ✓ Cualquier lugar donde el usuario pueda tocar un link desde el browser
# ✓ La experiencia más fluida para el usuario (sin disambiguator)
# ✗ Requiere configuración del servidor (assetlinks.json)
# ✗ Requiere control del dominio
# ✗ Si la app no está instalada → abre el browser normalmente (que es lo correcto)
# La combinación más robusta: los dos
# Notificaciones → esquema custom (miapp://)
# Links en web/emails → App Links verificados (https://)
# En Navigation Component podés declarar ambos para el mismo destino