Deep links parecem detalhe de produto até o primeiro incidente real: uma campanha abre a tela errada, um link de recuperação de conta cai na home, uma notificação perde o contexto, ou uma URL pública aceita parâmetros sem validação. Em apps Android com Kotlin, App Links e deep links conectam marketing, onboarding, notificações, login, checkout, suporte e retenção. Por isso, eles precisam ser tratados como parte da arquitetura do app, não como uma regra improvisada dentro da Activity.

Um deep link é qualquer link que leva a uma área específica do app. Um App Link é um caso mais confiável: uma URL HTTPS associada ao domínio do produto, verificada pelo Android por meio do arquivo assetlinks.json. Na prática, App Links reduzem a fricção porque o sistema pode abrir o app diretamente sem mostrar seletor, enquanto deep links customizados como meuapp://pedido/123 continuam úteis para integrações internas, ambientes de teste ou fluxos onde HTTPS não é possível.

Este guia mostra como desenhar App Links e deep links em um app Android moderno com Kotlin, Compose e Navigation. A ideia é cobrir produção: contrato de URLs, manifesto, navegação tipada, validação de parâmetros, testes, analytics e segurança. Se você ainda está montando a base Android, combine este conteúdo com Kotlin para Android, Navigation 3 com Compose, testes Android com Compose e Maestro e Firebase Crashlytics com Kotlin.

Use App Links quando a URL representa uma rota real do seu produto e pode ser compartilhada fora do app. Alguns exemplos comuns:

  • abrir um produto, artigo, pedido, conversa ou perfil específico;
  • retomar onboarding depois de confirmação por e-mail;
  • levar uma pessoa de uma notificação para a tela exata do evento;
  • abrir uma promoção sem perder o identificador da campanha;
  • direcionar suporte para uma área já autenticada do app;
  • permitir que o site e o app compartilhem a mesma URL pública.

O ganho principal é consistência. Se https://exemplo.com/pedidos/123 funciona no navegador, o app pode tratar essa mesma URL como entrada para a tela de detalhe do pedido. Isso melhora SEO, campanhas, compartilhamento e manutenção, porque o time não precisa inventar contratos paralelos para web, mobile e notificações.

Desenhe o contrato de URLs antes do código

O erro mais comum é começar pelo AndroidManifest.xml e decidir as rotas depois. Faça o contrário. Liste os caminhos que o app realmente aceita, quem os gera e quais parâmetros são obrigatórios.

Um contrato simples pode ficar assim:

URLTelaParâmetrosObservação
/artigos/{slug}detalhe de artigoslugpúblico
/vagas/{id}detalhe de vagaidpúblico
/conta/confirmarconfirmaçãotokensensível
/checkout/{cartId}checkoutcartId, utm_sourceexige login

Evite URLs que dependem de estado escondido, como /abrir?id=123&tipo=pedido&acao=detalhe. Esse formato parece flexível, mas vira uma API sem contrato. Prefira caminhos explícitos e versionáveis.

No Android, você declara os links aceitos dentro da Activity que recebe a entrada. Para App Links HTTPS, use android:autoVerify="true":

<activity
    android:name=".MainActivity"
    android:exported="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="exemplo.com"
            android:pathPrefix="/artigos" />
    </intent-filter>

    <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="exemplo.com"
            android:pathPrefix="/vagas" />
    </intent-filter>
</activity>

Separe filtros quando os caminhos têm comportamentos diferentes. Isso facilita auditoria, testes e remoção futura de rotas antigas. Também evite declarar um pathPrefix="/" amplo demais se o app só sabe tratar algumas URLs. Quanto mais aberto o filtro, maior o risco de abrir telas inesperadas.

Arquivo assetlinks.json

Para o Android verificar que o domínio pertence ao app, publique um arquivo em:

https://exemplo.com/.well-known/assetlinks.json

O conteúdo aponta para o pacote Android e o SHA-256 do certificado usado na assinatura:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "br.dev.exemplo",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]

Em times com vários ambientes, documente quais domínios usam certificado de debug, staging e produção. Um App Link que funciona localmente pode falhar em produção se o SHA-256 publicado não corresponder à chave final da Play Store.

Convertendo URL em destino de navegação

Não deixe parsing de URL espalhado em Composables. Crie uma pequena fronteira que transforma Uri em uma intenção de navegação tipada:

sealed interface DeepLinkDestination {
    data class Artigo(val slug: String) : DeepLinkDestination
    data class Vaga(val id: String) : DeepLinkDestination
    data class ConfirmarConta(val token: String) : DeepLinkDestination
    data object Home : DeepLinkDestination
}

fun parseDeepLink(uri: Uri): DeepLinkDestination {
    val segments = uri.pathSegments

    return when {
        segments.size == 2 && segments[0] == "artigos" -> {
            DeepLinkDestination.Artigo(slug = segments[1])
        }

        segments.size == 2 && segments[0] == "vagas" -> {
            DeepLinkDestination.Vaga(id = segments[1])
        }

        segments == listOf("conta", "confirmar") -> {
            val token = uri.getQueryParameter("token").orEmpty()
            DeepLinkDestination.ConfirmarConta(token = token)
        }

        else -> DeepLinkDestination.Home
    }
}

Essa função é simples de testar na JVM. Ela também força o time a decidir o que acontece com URL inválida. Para conteúdo público, cair na home pode ser aceitável. Para confirmação de conta ou checkout, é melhor mostrar uma tela de erro controlada do que tentar seguir com dados incompletos.

Integração com Compose e Navigation

Na MainActivity, leia o Intent inicial e também trate novos intents quando a Activity já estiver aberta:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val initialUri = intent?.data

        setContent {
            AppRoot(initialDeepLink = initialUri)
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        // Encaminhe intent.data para o estado de navegação do app.
    }
}

Em apps pequenos, você pode chamar navController.navigate(...) depois de parsear a URL. Em apps maiores, prefira converter o link em evento de aplicação e deixar a camada de navegação decidir o destino. Isso combina melhor com StateFlow, ViewModel e navegação orientada a estado.

Um exemplo direto com Navigation Compose tradicional:

fun NavController.openDeepLink(destination: DeepLinkDestination) {
    when (destination) {
        is DeepLinkDestination.Artigo -> navigate("artigos/${destination.slug}")
        is DeepLinkDestination.Vaga -> navigate("vagas/${destination.id}")
        is DeepLinkDestination.ConfirmarConta -> navigate("conta/confirmar")
        DeepLinkDestination.Home -> navigate("home")
    }
}

Se você estiver usando Navigation 3, a mesma ideia vale, mas o destino pode ser uma chave serializável em vez de uma string. Isso reduz erro de rota e facilita restauração de estado.

Segurança e validação

Deep link é entrada externa. Trate como input não confiável.

Valide host, caminho e parâmetros antes de navegar. Nunca execute ação destrutiva apenas porque uma URL chegou ao app. Uma rota como /conta/excluir?confirm=true seria perigosa se abrisse uma ação irreversível sem tela de confirmação. Também evite colocar dados sensíveis em query string, porque URLs podem aparecer em logs, analytics, screenshots e histórico do navegador.

Para fluxos autenticados, o link deve levar até uma tela que verifica sessão. Se o usuário não estiver logado, salve o destino pendente de forma segura, faça login e só depois continue. Para tokens de confirmação, use validade curta, uso único e tratamento claro para token expirado.

Analytics sem vazar dados

Instrumente eventos de deep link para saber se campanhas e notificações funcionam, mas não envie o token nem identificadores sensíveis. Um evento útil pode conter:

  • rota normalizada, como /vagas/{id};
  • origem, como push, e-mail, web ou campanha;
  • resultado, como aberto, inválido, exige login ou expirado;
  • versão do app e plataforma.

Evite registrar a URL completa quando ela contém token, email, cpf, session, cartId ou qualquer dado pessoal. Para estabilidade, conecte falhas de parsing e URLs inesperadas ao seu processo de observabilidade, como explicado no guia de Crashlytics com Kotlin.

Testes que realmente pegam regressão

Comece com testes unitários para parseDeepLink. Eles são baratos e pegam a maioria dos erros de contrato:

@Test
fun `abre artigo por slug`() {
    val uri = "https://exemplo.com/artigos/navigation-3".toUri()

    val destination = parseDeepLink(uri)

    assertEquals(
        DeepLinkDestination.Artigo("navigation-3"),
        destination,
    )
}

Depois, adicione um teste instrumentado ou E2E para uma jornada crítica. Com ADB, é possível disparar uma intent real:

adb shell am start \
  -a android.intent.action.VIEW \
  -d "https://exemplo.com/vagas/123" \
  br.dev.exemplo

Em Maestro, o fluxo pode abrir o link e verificar a tela esperada. Não automatize todas as rotas. Escolha login, recuperação de conta, checkout, detalhe público e uma rota inválida. Essa seleção cobre risco real sem criar uma suíte lenta.

Checklist de produção

Antes de considerar App Links prontos para produção, revise:

  • contrato de URL documentado e aprovado por web, mobile e produto;
  • AndroidManifest.xml sem filtros amplos demais;
  • assetlinks.json publicado no domínio correto com SHA-256 de produção;
  • parsing de URL coberto por testes unitários;
  • rotas sensíveis exigem autenticação e confirmação explícita;
  • analytics usa rota normalizada, não URL completa com dados sensíveis;
  • links de campanha, push e e-mail testados em dispositivo real;
  • comportamento definido para app instalado, app não instalado e usuário deslogado.

App Links bem feitos não são apenas conveniência. Eles conectam aquisição, retenção, suporte e arquitetura. Para quem trabalha com Android Kotlin em produto real, dominar esse fluxo demonstra maturidade: você entende navegação, segurança, analytics, testes e experiência do usuário como partes do mesmo sistema.