Deep link e App Links in Flutter: configurazione avanzata con go_router

Foto di Team Nocoloco su Unsplash

GuideAvanzato55 min Flutter 3.x

Deep link e App Links in Flutter: configurazione avanzata con go_router

I deep link trasformano un URL in un punto di ingresso diretto dentro la tua app: https://app.example.com/prodotti/42 non apre più il browser, ma la schermata di dettaglio del prodotto 42, con lo stack di navigazione corretto e il tasto indietro che funziona come l'utente si aspetta.

In questo tutorial avanzato affronteremo l'intera catena:

  • configurazione di go_router con rotte parametriche e gestione degli URL sconosciuti;
  • Android App Links verificati tramite assetlinks.json e firma dell'app;
  • iOS Universal Links con Associated Domains e apple-app-site-association;
  • gestione del cold start e dei link ricevuti ad app già aperta con il pacchetto app_links;
  • guardie di rotta: cosa succede se il link punta a un'area protetta e l'utente non è autenticato (pattern del pending deep link);
  • test reali da terminale con adb e xcrun simctl, più il debug della verifica dei domini.

Prerequisiti: conoscenza di Flutter e go_router, accesso a un dominio HTTPS su cui poter pubblicare file statici, Android Studio e (per iOS) Xcode con un Apple Developer Account.

  1. 1

    Progettare lo schema degli URL e configurare go_router

    Prima di toccare i file nativi bisogna decidere la mappa degli URL. Un buon schema di deep link è stabile nel tempo, leggibile e speculare alla struttura del sito web: se il sito espone /prodotti/:id, l'app deve rispondere allo stesso path.

    Punti chiave della configurazione:

    • initialLocation è usata solo quando non arriva un deep link: go_router legge automaticamente la route iniziale dalla piattaforma.
    • errorBuilder gestisce gli URL sconosciuti (link vecchi, typo, campagne mal configurate): mai lasciare una schermata bianca.
    • Le rotte annidate con routes: generano automaticamente lo stack corretto, quindi da /prodotti/42 il back porta a /prodotti e non fuori dall'app.

    Aggiungi le dipendenze in pubspec.yaml:

    dependencies:
      go_router: ^14.0.0
      app_links: ^6.0.0
    
    import 'package:flutter/material.dart';
    import 'package:go_router/go_router.dart';
    
    final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
    
    final GoRouter appRouter = GoRouter(
      navigatorKey: rootNavigatorKey,
      initialLocation: '/',
      debugLogDiagnostics: true,
      routes: <RouteBase>[
        GoRoute(
          path: '/',
          name: 'home',
          builder: (context, state) => const HomePage(),
          routes: <RouteBase>[
            GoRoute(
              path: 'prodotti',
              name: 'prodotti',
              builder: (context, state) => const ProdottiPage(),
              routes: <RouteBase>[
                GoRoute(
                  path: ':id',
                  name: 'prodottoDettaglio',
                  builder: (context, state) {
                    final id = state.pathParameters['id']!;
                    // Query string: /prodotti/42?ref=newsletter
                    final ref = state.uri.queryParameters['ref'];
                    return ProdottoDettaglioPage(id: id, campagna: ref);
                  },
                ),
              ],
            ),
            GoRoute(
              path: 'profilo',
              name: 'profilo',
              builder: (context, state) => const ProfiloPage(),
            ),
          ],
        ),
      ],
      errorBuilder: (context, state) => LinkNonValidoPage(uri: state.uri),
    );
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Deep Link Demo',
          routerConfig: appRouter,
        );
      }
    }

    Risultato atteso

    L'app si avvia sulla home; navigando manualmente a `/prodotti/42` (ad esempio con `context.go`) viene mostrato il dettaglio e il back riporta alla lista prodotti.

  2. 2

    Configurare Android App Links con verifica del dominio

    Su Android un link https:// apre l'app senza chiedere nulla all'utente solo se il dominio è verificato tramite Digital Asset Links. Servono due pezzi: l'intent filter con android:autoVerify="true" e il file assetlinks.json pubblicato sul dominio.

    1. Intent filter in android/app/src/main/AndroidManifest.xml, dentro l'<activity> principale (.MainActivity). Aggiungi anche <meta-data android:name="flutter_deeplinking_enabled" android:value="true" /> per delegare il routing a Flutter/go_router.

    2. Ottieni l'impronta SHA-256 della chiave con cui firmi l'app (una per il debug keystore, una per la release, più quella di Play App Signing che trovi nella Play Console → Configurazione → Integrità dell'app):

    keytool -list -v -keystore ~/.android/debug.keystore \
      -alias androiddebugkey -storepass android -keypass android
    

    3. Pubblica il file su https://app.example.com/.well-known/assetlinks.json, servito con Content-Type: application/json, senza redirect e raggiungibile pubblicamente (attenzione a Cloudflare/WAF che bloccano user-agent sconosciuti).

    <!-- AndroidManifest.xml -->
    <activity
        android:name=".MainActivity"
        android:exported="true"
        android:launchMode="singleTop"
        android:theme="@style/LaunchTheme">
    
        <meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
    
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    
        <!-- App Link verificato -->
        <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="app.example.com" />
        </intent-filter>
    
        <!-- Fallback custom scheme: myapp://prodotti/42 -->
        <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="myapp" android:host="prodotti" />
        </intent-filter>
    </activity>
    
    <!-- File da pubblicare su https://app.example.com/.well-known/assetlinks.json -->
    [{
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.example.deeplinkdemo",
        "sha256_cert_fingerprints": [
          "AA:BB:CC:...:FF",
          "11:22:33:...:99"
        ]
      }
    }]

    Risultato atteso

    Dopo l'installazione, `adb shell pm get-app-links com.example.deeplinkdemo` mostra il dominio con stato `verified`, e aprendo il link da Gmail o dalle Note si apre direttamente l'app.

  3. 3

    Configurare iOS Universal Links con Associated Domains

    Su iOS il meccanismo equivalente sono gli Universal Links. Servono tre passaggi:

    1. Capability Associated Domains: in Xcode, target Runner → Signing & Capabilities+ CapabilityAssociated Domains, e aggiungi applinks:app.example.com. Xcode crea/aggiorna ios/Runner/Runner.entitlements. Il tuo App ID deve avere la capability abilitata nel Developer Portal (rigenera i profili di provisioning).

    2. File apple-app-site-association (senza estensione!) pubblicato su https://app.example.com/.well-known/apple-app-site-association, servito come application/json, senza redirect e senza firma. appID è TEAMID.bundleIdentifier.

    3. Custom scheme di fallback in ios/Runner/Info.plist per i casi in cui l'Universal Link non è disponibile (es. link incollato nella barra indirizzi di Safari).

    ⚠️ iOS scarica l'AASA tramite la CDN di Apple e la mette in cache. In sviluppo aggiungi applinks:app.example.com?mode=developer (iOS 14+ con la modalità sviluppatore attiva) per bypassare la cache, oppure disinstalla e reinstalla l'app.

    <!-- ios/Runner/Runner.entitlements -->
    <key>com.apple.developer.associated-domains</key>
    <array>
      <string>applinks:app.example.com</string>
    </array>
    
    <!-- https://app.example.com/.well-known/apple-app-site-association -->
    {
      "applinks": {
        "details": [
          {
            "appIDs": ["ABCDE12345.com.example.deeplinkdemo"],
            "components": [
              { "/": "/prodotti/*", "comment": "Dettaglio prodotto" },
              { "/": "/profilo", "comment": "Area personale" },
              { "/": "/admin/*", "exclude": true, "comment": "Resta sul web" }
            ]
          }
        ]
      }
    }
    
    <!-- ios/Runner/Info.plist : fallback custom scheme -->
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLName</key>
        <string>com.example.deeplinkdemo</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>myapp</string>
        </array>
      </dict>
    </array>

    Risultato atteso

    Su device fisico, aprendo `https://app.example.com/prodotti/42` dalle Note o da Messaggi l'app si apre sulla schermata di dettaglio; da Safari compare il banner "Apri nell'app".

  4. 4

    Intercettare i link a runtime con app_links (cold start e warm start)

    Con flutter_deeplinking_enabled e gli intent filter corretti, go_router riceve già gli URL. Ma nelle app reali serve controllo esplicito per: normalizzare gli URL (host multipli, alias legacy, parametri UTM), fare logging/analytics, o gestire schemi custom che non mappano 1:1 sui path.

    Il pacchetto app_links espone due API:

    • getInitialLink() → il link che ha avviato l'app (cold start), null se l'app è stata aperta normalmente;
    • uriLinkStream → lo stream dei link ricevuti mentre l'app è già in esecuzione (warm start).

    Il servizio qui sotto centralizza il parsing e usa il router per navigare. Nota l'uso di un flag _coldStartHandled per non processare due volte lo stesso link: su alcune piattaforme il link iniziale viene emesso anche dallo stream.

    Se gestisci tutto manualmente, imposta flutter_deeplinking_enabled a false su Android e implementa application(_:continue:restorationHandler:) solo se necessario: evita il doppio routing.

    import 'dart:async';
    import 'package:app_links/app_links.dart';
    import 'package:go_router/go_router.dart';
    
    class DeepLinkService {
      DeepLinkService(this._router);
    
      final GoRouter _router;
      final AppLinks _appLinks = AppLinks();
      StreamSubscription<Uri>? _sub;
      bool _coldStartHandled = false;
    
      /// Domini/host accettati: difesa contro link contraffatti.
      static const Set<String> _hostConsentiti = {'app.example.com', 'example.com'};
    
      Future<void> init() async {
        final Uri? initial = await _appLinks.getInitialLink();
        if (initial != null) {
          _coldStartHandled = true;
          _handle(initial, coldStart: true);
        }
    
        _sub = _appLinks.uriLinkStream.listen(
          (uri) {
            if (_coldStartHandled) {
              _coldStartHandled = false; // ignora il primo replay
              return;
            }
            _handle(uri, coldStart: false);
          },
          onError: (Object e, StackTrace s) => print('Deep link error: $e'),
        );
      }
    
      void _handle(Uri uri, {required bool coldStart}) {
        final location = _normalizza(uri);
        if (location == null) return;
        // Analytics: traccia la sorgente della campagna
        final utm = uri.queryParameters['utm_source'];
        print('Deep link ($coldStart ? cold : warm) -> $location (utm: $utm)');
        _router.go(location);
      }
    
      /// Converte http(s) e custom scheme in una location di go_router.
      String? _normalizza(Uri uri) {
        if (uri.scheme == 'myapp') {
          // myapp://prodotti/42  -> host = prodotti, path = /42
          final segments = [uri.host, ...uri.pathSegments].where((s) => s.isNotEmpty);
          return '/${segments.join('/')}${_query(uri)}';
        }
        if (uri.scheme == 'https' && _hostConsentiti.contains(uri.host)) {
          // Alias legacy: /p/42 -> /prodotti/42
          if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'p') {
            return '/prodotti/${uri.pathSegments[1]}${_query(uri)}';
          }
          return '${uri.path}${_query(uri)}';
        }
        return null; // schema o host non riconosciuto: ignora
      }
    
      String _query(Uri uri) =>
          uri.query.isEmpty ? '' : '?${uri.query}';
    
      void dispose() => _sub?.cancel();
    }

    Risultato atteso

    Con l'app chiusa o in background, aprire un link porta alla schermata corretta; nei log compare la riga `Deep link ... -> /prodotti/42` una sola volta per link.

  5. 5

    Guardie di rotta e pending deep link per le aree protette

    Il caso più delicato: il link punta a /profilo ma l'utente non ha ancora effettuato il login. Il comportamento professionale è:

    1. il redirect di go_router intercetta la rotta protetta;
    2. l'URL richiesto viene memorizzato (pending deep link), passandolo come query param from o salvandolo in uno store;
    3. l'utente viene mandato al login;
    4. al termine dell'autenticazione il router riesegue il redirect e lo porta esattamente dove voleva andare.

    Il parametro refreshListenable fa sì che, al cambio di stato di autenticazione, go_router rivaluti il redirect senza che tu debba chiamare go a mano. Ricorda di codificare l'URL con Uri.encodeComponent e di validare il valore di from prima di reindirizzarci: un from arbitrario proveniente da un link esterno è un vettore di open redirect.

    import 'package:flutter/foundation.dart';
    import 'package:go_router/go_router.dart';
    
    class AuthState extends ChangeNotifier {
      bool _logged = false;
      bool get isLogged => _logged;
    
      void login() { _logged = true; notifyListeners(); }
      void logout() { _logged = false; notifyListeners(); }
    }
    
    final auth = AuthState();
    
    const Set<String> _rottaProtetta = {'/profilo', '/ordini'};
    
    final router = GoRouter(
      refreshListenable: auth,
      routes: [
        GoRoute(path: '/', builder: (c, s) => const HomePage()),
        GoRoute(path: '/profilo', builder: (c, s) => const ProfiloPage()),
        GoRoute(
          path: '/login',
          builder: (c, s) => LoginPage(from: s.uri.queryParameters['from']),
        ),
      ],
      redirect: (context, state) {
        final loc = state.matchedLocation;
        final protetta = _rottaProtetta.any(loc.startsWith);
    
        if (protetta && !auth.isLogged) {
          final target = Uri.encodeComponent(state.uri.toString());
          return '/login?from=$target';
        }
    
        if (loc == '/login' && auth.isLogged) {
          final from = state.uri.queryParameters['from'];
          return _isSafe(from) ? from! : '/';
        }
        return null; // nessun redirect
      },
    );
    
    /// Accetta solo path relativi interni: niente open redirect.
    bool _isSafe(String? value) {
      if (value == null || value.isEmpty) return false;
      final uri = Uri.tryParse(value);
      return uri != null && !uri.hasScheme && !uri.hasAuthority && value.startsWith('/');
    }

    Risultato atteso

    Aprendo `https://app.example.com/profilo` da sloggati si arriva al login; dopo `auth.login()` l'app naviga automaticamente su `/profilo` senza tap aggiuntivi.

  6. 6

    Testare i deep link da terminale su Android e iOS

    Non affidarti solo ai tap manuali: i test da terminale isolano il problema (routing dell'app vs verifica del dominio).

    Android — simula un intent VIEW:

    # App Link https
    adb shell am start -a android.intent.action.VIEW \
      -c android.intent.category.BROWSABLE \
      -d "https://app.example.com/prodotti/42?utm_source=test"
    
    # Custom scheme
    adb shell am start -a android.intent.action.VIEW -d "myapp://prodotti/42"
    

    Verifica lo stato della verifica dei domini (Android 12+):

    adb shell pm get-app-links com.example.deeplinkdemo
    # Forza una nuova verifica
    adb shell pm verify-app-links --re-verify com.example.deeplinkdemo
    

    Se leggi legacy_failure o 1024 (nessuna risposta), il problema è quasi sempre lato server: redirect, Content-Type errato, SHA-256 sbagliata.

    iOS — sul simulatore:

    xcrun simctl openurl booted "https://app.example.com/prodotti/42"
    xcrun simctl openurl booted "myapp://prodotti/42"
    

    Su device fisico, gli Universal Links non si attivano digitando l'URL in Safari: usa Note, Messaggi o Mail.

    Test automatizzato: con integration_test puoi verificare il routing simulando il messaggio di piattaforma, senza dipendere dal sistema operativo.

    // test/deep_link_router_test.dart
    import 'package:flutter/widgets.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:go_router/go_router.dart';
    
    void main() {
      testWidgets('Il deep link /prodotti/42 apre il dettaglio', (tester) async {
        final router = GoRouter(
          initialLocation: '/prodotti/42',
          routes: [
            GoRoute(path: '/', builder: (c, s) => const Text('home')),
            GoRoute(
              path: '/prodotti/:id',
              builder: (c, s) => Text('dettaglio ${s.pathParameters['id']}'),
            ),
          ],
        );
    
        await tester.pumpWidget(MaterialApp.router(routerConfig: router));
        await tester.pumpAndSettle();
    
        expect(find.text('dettaglio 42'), findsOneWidget);
      });
    
      testWidgets('Un URL sconosciuto mostra la pagina di errore', (tester) async {
        final router = GoRouter(
          initialLocation: '/rotta/inesistente',
          routes: [GoRoute(path: '/', builder: (c, s) => const Text('home'))],
          errorBuilder: (c, s) => const Text('link non valido'),
        );
    
        await tester.pumpWidget(MaterialApp.router(routerConfig: router));
        await tester.pumpAndSettle();
    
        expect(find.text('link non valido'), findsOneWidget);
      });
    }

    Risultato atteso

    I comandi da terminale aprono l'app sulla schermata attesa e i due test passano con `flutter test`.

  7. 7

    Rifiniture: link condivisibili, fallback web e checklist di produzione

    Ultimo miglio: rendere i link generabili dall'app e resilienti quando l'app non è installata.

    Generare link di condivisione: centralizza la costruzione degli URL in una sola classe, così web e app restano allineati.

    Fallback web: la pagina https://app.example.com/prodotti/42 deve esistere davvero e mostrare il contenuto. Se l'app non è installata, l'utente atterra sul sito (con eventuale smart banner iOS via <meta name="apple-itunes-app">). Evita le pagine "scarica l'app" cieche: perdi utenti e ranking SEO.

    Checklist prima della release

    • [ ] assetlinks.json contiene tutte le SHA-256: debug, release upload key e Play App Signing.
    • [ ] Entrambi i file .well-known rispondono 200 senza redirect e con Content-Type: application/json.
    • [ ] apple-app-site-association usa il TEAM ID di produzione e nessuna estensione .json nel nome file.
    • [ ] Ogni path pubblico dell'app ha un equivalente web funzionante.
    • [ ] Gli URL sconosciuti finiscono in errorBuilder, mai in schermata bianca.
    • [ ] Host e scheme vengono validati prima di navigare (nessun open redirect, nessun path da fonti non fidate).
    • [ ] I parametri utm_* vengono tracciati e poi rimossi dall'URL visibile.
    • [ ] Testato: app chiusa, app in background, app in foreground, utente sloggato.
    • [ ] Testato su Android 12+ (verifica automatica) e iOS con cache AASA ripulita.
    class AppLinkBuilder {
      static const String _host = 'app.example.com';
    
      static Uri prodotto(String id, {String? campagna}) => Uri(
            scheme: 'https',
            host: _host,
            pathSegments: ['prodotti', id],
            queryParameters: campagna == null ? null : {'utm_source': campagna},
          );
    
      static Uri profilo() =>
          Uri(scheme: 'https', host: _host, path: '/profilo');
    }
    
    // Uso con share_plus:
    // await Share.share(AppLinkBuilder.prodotto('42', campagna: 'app').toString());
    
    // Pulizia dei parametri di tracking dopo averli registrati
    extension UriTracking on Uri {
      Uri senzaUtm() {
        final params = Map<String, String>.from(queryParameters)
          ..removeWhere((k, _) => k.startsWith('utm_'));
        return replace(queryParameters: params.isEmpty ? null : params);
      }
    }

    Risultato atteso

    L'app genera link condivisibili coerenti con il sito, che si aprono nell'app se installata e sul web in caso contrario, con i parametri di campagna tracciati e poi ripuliti.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!