effidevFlutter · Cloudflare edge · Cloud cost optimization
English

Flutter App Links & Universal Links: Automated Domain Verification and CI Deep-Link Testing

Flutter App Links Universal Links Automated Verification CI Pipeline

Deep linking is critical for user onboarding, marketing attribution, push notification navigation, and payment redirects in mobile apps. Legacy custom URL schemes (myapp://) carry significant hijacking vulnerabilities where malicious third-party apps register identical schemes to intercept sensitive payloads.

To counter this threat, Google and Apple enforce domain-bound verification standards: Android App Links and iOS Universal Links.

However, minor hosting mistakes on .well-known endpoints—such as incorrect HTTP Content-Type, SSL validation errors, or mismatched SHA-256 fingerprints—frequently cause deep links to fail silently in production, defaulting to mobile web browsers.

This guide provides an architectural breakdown of App Links and Universal Links, Flutter client handling with GoRouter and app_links, and an automated GitHub Actions CI pipeline to verify domain association before deployment.

Key Takeaways

  • Android App Links Requirements: The file https://<domain>/.well-known/assetlinks.json must serve Content-Type: application/json and include both local keystore and Google Play App Signing SHA-256 fingerprints.
  • iOS Universal Links Requirements: The file https://<domain>/.well-known/apple-app-site-association (AASA) must list valid appID values formatted as <TEAM_ID>.<BUNDLE_ID>.
  • Flutter Router Integration: Combining app_links with GoRouter ensures parameter retention across cold launches and background app states.
  • CI Test Automation: Utilizing GitHub Actions with Google Statement List APIs and CLI triggers (adb / xcrun simctl) catches broken links before reaching production.

1. Domain Association File Configurations

1) Android assetlinks.json (/.well-known/assetlinks.json)

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "dev.effidev.flutterapp",
      "sha256_cert_fingerprints": [
        "14:6D:E9:7D:59:06:50:57:3E:99:A5:1D:64:1D:64:D4:6C:C9:86:EC:65:4E:91:D9:D2:ED:EB:98:C4:4B:9B:C6"
      ]
    }
  }
]

2) iOS apple-app-site-association (/.well-known/apple-app-site-association)

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCDE12345.dev.effidev.flutterapp",
        "paths": [ "NOT /api/*", "/product/*", "/share/*" ]
      }
    ]
  }
}

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:app_links/app_links.dart';

final _appLinks = AppLinks();

final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) {
        final id = state.pathParameters['id'] ?? '';
        return ProductDetailScreen(productId: id);
      },
    ),
  ],
);

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    _initDeepLinks();
  }

  Future<void> _initDeepLinks() async {
    // 1. Cold Start Initial Link
    final initialUri = await _appLinks.getInitialLink();
    if (initialUri != null) {
      router.go(initialUri.path);
    }

    // 2. Background State Stream
    _appLinks.uriLinkStream.listen((uri) {
      router.go(uri.path);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

3. GitHub Actions CI Automated Verification

name: Verify Deep Links Domain Association

on:
  push:
    branches: [ main ]

jobs:
  verify-domain:
    runs-on: ubuntu-latest
    steps:
      - name: Check Android Assetlinks.json HTTP & SSL
        run: |
          HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://effidev.dev/.well-known/assetlinks.json)
          if [ "$HTTP_STATUS" -ne 200 ]; then
            echo "Error: assetlinks.json HTTP status is $HTTP_STATUS"
            exit 1
          fi
          
      - name: Check iOS Apple App Site Association (AASA)
        run: |
          AASA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://effidev.dev/.well-known/apple-app-site-association)
          if [ "$AASA_STATUS" -ne 200 ]; then
            echo "Error: AASA file HTTP status is $AASA_STATUS"
            exit 1
          fi

Conclusion

Automating domain verification in CI pipelines eliminates production failures for App Links and Universal Links, providing smooth, reliable user navigation.