Dynalinks Flutter SDK

The official Flutter SDK for Dynalinks - deferred deep linking and attribution for iOS and Android apps.

pub package License: MIT

Features

  • Deferred Deep Linking: Track users who click links before installing your app
  • Universal Links / App Links: Handle incoming deep links automatically
  • Cross-Platform: Single API for both iOS and Android
  • Type-Safe: Full Dart type safety with comprehensive error handling

Requirements

  • Flutter 3.44.0 or later (Dart 3.12 or later)
  • iOS 16.0 or later
  • Android API 21 or later

On iOS the plugin builds with either Swift Package Manager (the default from Flutter 3.44) or CocoaPods. Either way you must set your app's iOS deployment target to 16.0, because Flutter's default is lower and the build fails until you raise it - see iOS Setup. The plugin ships a privacy manifest declaring its data use, so it is picked up automatically by your app's privacy report.

Upgrading from 1.x? See Upgrading from 1.x.

Installation

Add dynalinks to your pubspec.yaml:

dependencies:
  dynalinks: ^2.0.0

Then run:

flutter pub get

iOS Setup

  1. Register your iOS app in the Dynalinks Console:

    • Bundle Identifier (from Xcode project settings)
    • Team ID (from Apple Developer account)
    • App Store ID (from your app's App Store URL)
  2. Configure Associated Domains in Xcode:

    • Open your iOS project > Signing & Capabilities
    • Add the "Associated Domains" capability
    • Add your domain: applinks:yourproject.dynalinks.app
  3. Set the iOS deployment target to 16.0:

    • Open your iOS project > Runner target > General > Minimum Deployments
    • Set iOS to 16.0 or later

    Flutter's default is lower, so a project created from the Flutter template fails to build until you raise it. Under Swift Package Manager the error reads:

    The package product 'dynalinks' requires minimum platform version 16.0 for the iOS platform,
    but this target supports 13.0
    

See the iOS integration guide for detailed instructions.

UIScene life cycle: incoming links are delivered through both the app delegate and the scene delegate, so the plugin works whichever life cycle your app uses. If your app has not adopted the UIScene life cycle yet, flutter run migrates it for you - Apple requires the adoption for apps built against the latest SDK.

One detail of that migration matters for deep linking. Under UIScene, plugins must be registered from didInitializeImplicitFlutterEngine, not from didFinishLaunchingWithOptions, because a link that launches the app is delivered while the scene connects. Registering later means the plugin is not listening yet when the launch link arrives. The automatic migration does this for you; if you maintain AppDelegate.swift by hand, it should look like this:

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
  }
}

Android Setup

  1. Register your Android app in the Dynalinks Console:

    • Package identifier (from build.gradle applicationId)
    • SHA-256 certificate fingerprint (run ./gradlew signingReport)
  2. Add the JitPack repository to android/build.gradle.kts, where the Flutter template declares its repositories:

allprojects {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

If your project declares repositories in a dependencyResolutionManagement block in android/settings.gradle.kts instead, add it there.

  1. Add intent filter to your AndroidManifest.xml:

Add the intent filter to your existing MainActivity - leave the rest of the activity as Flutter generated it:

<activity android:name=".MainActivity">

    <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="yourproject.dynalinks.app" />
    </intent-filter>
</activity>

See the Android integration guide for detailed instructions.

Gradle: the plugin uses Flutter's built-in Kotlin support and no longer applies the Kotlin Gradle Plugin itself, so it builds under AGP 9. It compiles against Java 17; if your app pins an older JVM target you may need to raise it.

Usage

Initialize the SDK

Configure the SDK as early as possible in your app's lifecycle:

import 'package:flutter/material.dart';
import 'package:dynalinks/dynalinks.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Dynalinks.configure(
    clientAPIKey: 'your-client-api-key',
    logLevel: DynalinksLogLevel.debug, // Use .error in production
  );

  runApp(const MyApp());
}

Check if the user came from a Dynalinks link before installing:

Future<void> checkDeferredDeepLink() async {
  try {
    final result = await Dynalinks.checkForDeferredDeepLink();

    if (result.matched && result.link != null) {
      // User came from a deep link - navigate accordingly
      final deepLinkValue = result.link!.deepLinkValue;
      if (deepLinkValue != null) {
        navigateTo(deepLinkValue);
      }
    }
  } on SimulatorException {
    // Running on simulator - deferred deep linking not available
  } on DynalinksException catch (e) {
    print('Error: ${e.message}');
  }
}

Incoming links reach your app through two separate channels, and you need both:

  • getInitialLink() - the link that launched the app from a terminated state (cold start)
  • onDeepLinkReceived - links that arrive while the app is already running (warm start)

A launch link is delivered only through getInitialLink(), even if a stream listener is already attached. That is what keeps a single tap from being delivered twice - but it also means an app that only subscribes to the stream will never see cold-start links.

A link that arrives while no listener is attached is held and delivered to the next one, so a tap during a route change is not lost. The SDK retains the 32 most recent queued links; if that limit is reached before a listener attaches, it discards the oldest link.

There is one exception to the rule above, and it exists so that no link is lost. getInitialLink() answers once: if it has already told your app there is no link, a launch link that only resolves afterwards - a very slow first request, or a configure() that failed and was retried - has nowhere left to go, so it arrives on the stream instead. Handling both channels, as the example below does, means you never have to think about it.

class _MyAppState extends State<MyApp> {
  StreamSubscription<DeepLinkResult>? _subscription;

  @override
  void initState() {
    super.initState();
    _checkInitialLink();
    _listenForLinks();
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  Future<void> _checkInitialLink() async {
    // Check for cold start link
    final initialLink = await Dynalinks.getInitialLink();
    if (initialLink != null && initialLink.matched) {
      _handleResult(initialLink);
    }
  }

  void _listenForLinks() {
    _subscription = Dynalinks.onDeepLinkReceived.listen(_handleResult);
  }

  void _handleResult(DeepLinkResult result) {
    if (result.matched && result.link?.deepLinkValue != null) {
      // Navigate to the deep link destination
      Navigator.pushNamed(context, result.link!.deepLinkValue!);
    }
  }
}

Manually resolve a URI if needed:

final result = await Dynalinks.handleDeepLink(
  Uri.parse('https://yourproject.dynalinks.app/promo'),
);

if (result.matched) {
  // Handle the resolved link
}

Upgrading from 1.x

  1. Raise your minimum SDK versions. 2.0.0 requires Flutter 3.44 / Dart 3.12, needed for both Swift Package Manager and the UIScene life cycle APIs.

  2. Call getInitialLink() on startup. A link that launched the app is now delivered only there, never on the onDeepLinkReceived stream. If your 1.x app relied on the stream alone, it will silently stop receiving cold-start links. See Handle Incoming Deep Links for the pattern.

  3. iOS: run the app once to migrate. flutter run adopts the UIScene life cycle and switches the project to Swift Package Manager for you. If you maintain AppDelegate.swift by hand, apply the plugin-registration change shown in iOS Setup.

  4. iOS: update the native SDK. 2.0.0 requires Dynalinks iOS SDK 1.0.4 or later. On CocoaPods, run pod update DynalinksSDK; Swift Package Manager resolves it automatically.

Nothing changed in the Dart API surface - no method signatures, parameters, or model fields were renamed or removed.

API Reference

Method Description
configure() Initialize the SDK with your API key
checkForDeferredDeepLink() Check for deferred deep link (first launch)
handleDeepLink(Uri) Manually resolve a deep link URI
getInitialLink() Get the link that launched the app (cold start)
onDeepLinkReceived Stream of incoming links while app is running
reset() Reset SDK state. Annotated @visibleForTesting - calling it from app code triggers an analyzer warning
version SDK version string

DeepLinkResult

Property Type Description
matched bool Whether a link was matched
confidence Confidence? Match confidence (high/medium/low)
matchScore int? Match score (0-100)
link LinkData? The matched link data
isDeferred bool Whether from deferred deep link

LinkData

Property Type Description
id String Unique link identifier
name String? Link name (for display)
path String? Link path
shortenedPath String? Shortened path
url Uri? Original URL the link points to
fullUrl Uri? Full Dynalinks URL
deepLinkValue String? Value for in-app navigation
iosDeferredDeepLinkingEnabled bool? Whether iOS deferred deep linking is enabled
iosFallbackUrl Uri? iOS fallback URL (when app not installed)
androidFallbackUrl Uri? Android fallback URL (when app not installed)
enableForcedRedirect bool? Whether forced redirect is enabled
socialTitle String? Social sharing title
socialDescription String? Social sharing description
socialImageUrl Uri? Social sharing image
clicks int? Number of clicks on this link
referrer String? Referrer tracking parameter for attribution
providerToken String? Apple Search Ads attribution token (pt)
campaignToken String? Campaign identifier for attribution (ct)

Exceptions

Exception Description
NotConfiguredException SDK not configured
InvalidApiKeyException Invalid API key
SimulatorException Running on simulator/emulator
NetworkException Network request failed
InvalidResponseException Server returned invalid response
ServerException Server returned an error
NoMatchException No matching link found
InvalidIntentException Invalid intent data (Android)
InstallReferrerUnavailableException Install Referrer API unavailable (Android)
InstallReferrerTimeoutException Install Referrer connection timed out (Android)
UnknownException Unknown error occurred

Configuration Options

await Dynalinks.configure(
  clientAPIKey: 'your-api-key',           // Required
  baseURL: 'https://custom.api.url',      // Optional, custom API URL
  logLevel: DynalinksLogLevel.debug,      // Optional, default: .error
  allowSimulatorOrEmulator: false,        // Optional, default: false
);

Log Levels

  • DynalinksLogLevel.none - No logging
  • DynalinksLogLevel.error - Errors only (default)
  • DynalinksLogLevel.warning - Warnings and errors
  • DynalinksLogLevel.info - Info, warnings, and errors
  • DynalinksLogLevel.debug - All logs

Attribution Tracking

The SDK provides attribution data for campaign tracking and analytics:

final result = await Dynalinks.checkForDeferredDeepLink();
if (result.matched && result.link != null) {
  final link = result.link!;

  // Track attribution data for analytics
  if (link.referrer != null) {
    print('Referrer: ${link.referrer}'); // e.g., "utm_source=facebook&utm_campaign=summer"
  }

  if (link.providerToken != null) {
    print('Apple Search Ads token: ${link.providerToken}'); // pt parameter
  }

  if (link.campaignToken != null) {
    print('Campaign: ${link.campaignToken}'); // ct parameter
  }

  // Send to your analytics platform
  analytics.track('deep_link_opened', properties: {
    'referrer': link.referrer,
    'provider_token': link.providerToken,
    'campaign': link.campaignToken,
    'deep_link': link.deepLinkValue,
  });
}

Example App

See the example directory for a complete sample app demonstrating all SDK features.

Support

License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries