DEV Community

Cover image for Deep Links no Android: Implementação Nativa com Kotlin + Flutter (Parte 2)
Cristian Dornelles
Cristian Dornelles

Posted on

Deep Links no Android: Implementação Nativa com Kotlin + Flutter (Parte 2)

Header

No post anterior, preparamos a base — constantes, enum de tipos e modelo de dados. Agora é hora de sujar as mãos com código nativo. Vamos configurar o Android para capturar deep links e entregá-los ao Flutter — sem nenhum pacote externo no meio do caminho.

Spoiler: é mais simples do que parece — mas alguns detalhes fazem toda a diferença na prática.

Dando continuidade à série sobre Deep Links no Flutter, com novos artigos saindo semanalmente, ou quase. Se você ainda não viu a base do projeto em Flutter, recomendo começar pelo primeiro artigo da série: Post 1 — Guia para Iniciantes.


Neste artigo, você vai aprender:

  • Como configurar o AndroidManifest.xml para deep links.
  • A diferença entre MethodChannel e EventChannel.
  • Como testar com adb sem precisar de um dispositivo físico.

Configurando o AndroidManifest.xml

O Android descobre que seu app pode tratar um link por meio de intent-filters declarados no AndroidManifest.xml. Podemos declarar dois intent-filters separados (um para custom scheme e outro para HTTPS) ou um único filtro com múltiplos <data>. Neste exemplo, vamos manter separados para maior clareza didática.

<!-- android/app/src/main/AndroidManifest.xml -->
<activity
    android:name=".MainActivity"
    android:launchMode="singleTop"
    ...>

    <!-- Intent filter padrão (launcher) -->
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>

    <!-- Deep Link: Custom Scheme -->
    <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="fitconnect" />
        <data android:host="fitconnect.app" />
    </intent-filter>

    <!-- Deep Link: HTTPS (App Links) -->
    <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" />
        <data android:host="fitconnect.app" />
        <data android:pathPrefix="/signup" />
    </intent-filter>
</activity>
Enter fullscreen mode Exit fullscreen mode

Três atributos merecem atenção:

  • android:launchMode="singleTop": evita criar uma nova instância da Activity quando ela já está no topo da stack, direcionando o novo intent para onNewIntent. Sem isso, o usuário poderia acabar com múltiplas instâncias da mesma Activity na stack.
  • BROWSABLE: autoriza que links externos — de um e-mail, mensagem ou navegador — possam abrir o app. Sem essa categoria, o intent-filter é invisível para o sistema.
  • android:autoVerify="true": ativa a verificação bidirecional do App Links. O Android vai checar se o servidor confirma que este app tem permissão para tratar o domínio. Montamos esse arquivo de verificação no Post 5.

Implementando o MainActivity.kt

Com o manifest configurado, o Android vai entregar o link como um Intent para a MainActivity. Agora precisamos capturá-lo e repassar ao Flutter via dois canais:

  • MethodChannel: Flutter chama quando o app abre e precisa saber se havia um link pendente.
  • EventChannel: stream contínuo para links recebidos com o app já em execução.
package com.fitconnect.app

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterFragmentActivity() {
    private val TAG = "FitConnect"
    private val CHANNEL = "com.fitconnect.app/deeplink"
    private val EVENT_CHANNEL = "com.fitconnect.app/deeplink_stream"

    private var methodChannel: MethodChannel? = null
    private var eventChannel: EventChannel? = null
    private var eventSink: EventChannel.EventSink? = null

    private var initialLink: String? = null
    private var lastProcessedLink: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, "onCreate - checking for deep link")
        handleIntent(intent, isInitial = true)
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // MethodChannel: Flutter pede deep link inicial
        methodChannel = MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            CHANNEL
        )
        methodChannel?.setMethodCallHandler { call, result ->
            when (call.method) {
                "getInitialLink" -> result.success(initialLink)
                else -> result.notImplemented()
            }
        }

        // EventChannel: Stream de deep links em tempo real
        eventChannel = EventChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            EVENT_CHANNEL
        )
        eventChannel?.setStreamHandler(object : EventChannel.StreamHandler {
            override fun onListen(args: Any?, events: EventChannel.EventSink?) {
                eventSink = events
            }
            override fun onCancel(args: Any?) {
                eventSink = null
            }
        })
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        Log.d(TAG, "onNewIntent - app was already open")
        handleIntent(intent, isInitial = false)
    }

    private fun handleIntent(intent: Intent?, isInitial: Boolean) {
        val data: Uri? = intent?.data

        if (intent?.action == Intent.ACTION_VIEW && data != null) {
            val deepLinkUrl = data.toString()

            // Evita processar o mesmo link duas vezes
            if (deepLinkUrl == lastProcessedLink) return

            lastProcessedLink = deepLinkUrl
            Log.i(TAG, "Deep link: $deepLinkUrl")

            if (isInitial) {
                // App estava fechado
                initialLink = deepLinkUrl
            } else {
                // App estava aberto — envia via stream
                eventSink?.success(deepLinkUrl)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

O que acontece em cada cenário

O fluxo de entrega do link depende de como o app foi aberto:

Diagrama de fluxo no Android

O lastProcessedLink existe para um caso específico: em algumas versões do Android, onCreate e onNewIntent podem ser chamados em sequência para o mesmo link. Sem essa proteção, o Flutter processaria o mesmo deep link duas vezes.


Testando com adb

Antes de integrar com o Flutter, vale confirmar que o Android está capturando os links corretamente. O adb permite simular cliques em links diretamente pelo terminal.

# Testa o custom scheme (fitconnect://)
# Deve abrir o app sem precisar de verificação de domínio
adb shell am start -a android.intent.action.VIEW \
  -d "fitconnect://fitconnect.app/signup?referralCode=TRAINER1234567890123"
Enter fullscreen mode Exit fullscreen mode
# Testa o App Link (HTTPS)
# Pode abrir um seletor de aplicativos se a verificação do domínio ainda não estiver concluída
adb shell am start -a android.intent.action.VIEW \
  -d "https://fitconnect.app/signup?referralCode=TRAINER1234567890123"
Enter fullscreen mode Exit fullscreen mode

Se o custom scheme abrir o app, o manifest está correto. Se o HTTPS mostrar um seletor de aplicativos em vez de abrir direto — é sinal de que a verificação bidirecional ainda não está configurada. Isso é esperado neste ponto da série e será resolvido no Post 5.


O que construímos até aqui

Ao final desta etapa, você já tem:

  • O AndroidManifest configurado com os dois intent-filters: custom scheme e App Links.
  • O MainActivity.kt com MethodChannel (link inicial) e EventChannel (stream em tempo real).
  • Clareza sobre a diferença entre onCreate e onNewIntent e por que ambos precisam ser tratados.
  • Comandos adb prontos para testar sem precisar de dispositivo físico.

Este é o segundo de 9 posts da série. Se você já passou por alguma situação estranha com intent-filters no Android, conta nos comentários — esse tipo de detalhe vai direto para os próximos artigos.

Na próxima etapa, vamos replicar esse fluxo no iOS usando Swift — e você vai perceber como os conceitos são os mesmos, mesmo com APIs diferentes.


Código completo disponível no repositório: FitConnect no GitHub


Se esse conteúdo te ajudou, deixa um clap 👏 e salva o post — isso me ajuda a continuar a série.

E se você já implementou deep links no Android, comenta aqui qual foi o maior desafio — vou usar isso nos próximos artigos.


Tags: Flutter, Android, Kotlin, Deep Links, Mobile Development

Top comments (0)