LinkTrail Flutter SDK

Mobile attribution and deferred deep linking for Flutter. A thin plugin over the native LinkTrail SDKs — package linktrail_flutter, entry point LinkTrail. Wraps the LinkTrail Android and iOS SDKs, exposing one Dart API across both platforms.

  • Package: linktrail_flutter (pub.flutter-io.cn) · Android: minSdk 26 · iOS: 15+
  • Native SDKs wrapped: io.linktrail:sdk (Maven Central) · LinkTrailSDK (CocoaPods)

Install

flutter pub add linktrail_flutter

or add it to your app's pubspec.yaml:

dependencies:
  linktrail_flutter: ^0.0.3

Then flutter pub get. The native SDKs are pulled in automatically — no manual Gradle or CocoaPods edits needed. On iOS run pod install in ios/ (or let flutter run do it).

Platform minimums the SDK requires — set them if your app targets lower:

// android/app/build.gradle.kts
android { defaultConfig { minSdk = 26 } }
# ios/Podfile
platform :ios, '15.0'

Quick start

import 'package:linktrail_flutter/linktrail_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // One hook handles both first-launch (deferred) AND re-engagement links.
  LinkTrail.onLink.listen((event) {
    router.route(event.link.path, event.link.customData); // e.g. "/products/aj1" + {voucher: SUMMER25}
  });

  // Observe failures if you want (e.g. LinkTrailInvalidApiKeyException).
  LinkTrail.onError.listen((error) => debugPrint('LinkTrail: $error'));

  // The API key is required. The install is tracked automatically by configure().
  await LinkTrail.configure(apiKey: 'lt_live_…');

  runApp(const MyApp());
}

Incoming links are captured automatically — you do not need to override MainActivity (Android) or AppDelegate/SceneDelegate (iOS). The plugin forwards App Links, Universal Links and custom-scheme opens to the SDK for you, on both cold start and while running. Every callback is a broadcast Stream, so you can listen from multiple places (e.g. a StreamBuilder).

More

// Configuration
await LinkTrail.configure(apiKey: 'lt_live_…', options: LinkTrailOptions(...));

// Streams (native callbacks, surfaced as Dart streams)
LinkTrail.onLink;         // Stream<LinkTrailLinkEvent>   — (link, source: deferred | reengagement)
LinkTrail.onAttribution;  // Stream<LinkTrailAttribution>
LinkTrail.onError;        // Stream<LinkTrailException>

// Actions
await LinkTrail.handleDeepLink(uri);                       // resolve a link manually (also auto-captured)
await LinkTrail.trackInstall(force: false);                // called automatically by configure()
await LinkTrail.trackEvent(name: 'purchase', value: 9.99, currency: 'USD');

// Last known state
await LinkTrail.lastAttribution;
await LinkTrail.lastDeepLink;

// iOS-only (no-ops on Android)
await LinkTrail.requestTrackingAuthorization();            // App Tracking Transparency
await LinkTrail.registerForSKAdAttribution();
await LinkTrail.updateConversionValue(3, coarseValue: LinkTrailCoarseConversionValue.medium);

Errors from the native SDKs arrive as typed LinkTrailException subtypes on onError, and are thrown from the Future-returning calls — so you can try/catch a specific case:

try {
  await LinkTrail.trackEvent(name: 'purchase');
} on LinkTrailInvalidApiKeyException {
  // the key was rejected by the server
}

LinkTrailOptions.requireConsent defaults to truedeny-by-default. Until you call LinkTrail.setConsent(true), the SDK holds the install and drops events. Deep links still route without consent (onLink fires and the user reaches the destination); only the attribution/tracking is gated. setConsent(false) stops sending and clears the queue.

There is no consent getter — the app is the source of truth. Persist the choice yourself and replay it on every launch right after configure, so a previously-granted user resumes automatically:

await LinkTrail.configure(apiKey: 'lt_live_…', options: const LinkTrailOptions(requireConsent: true));

// Replay the persisted decision (e.g. from SharedPreferences). Do nothing while undecided.
final consent = await loadConsent();          // 'granted' | 'denied' | 'undecided'
if (consent == 'granted') await LinkTrail.setConsent(true);
if (consent == 'denied') await LinkTrail.setConsent(false);

Set requireConsent: false to opt out of gating and track automatically (the pre-consent behavior).

linkDomains — re-engagement host gating

When linkDomains is non-empty, the SDK routes re-engagement opens (app already installed) only for those hosts — a link on an unlisted host opens the app but never navigates. Deferred (install-time) links skip this check and route regardless. Net effect: a missing host looks fine on a fresh install yet silently fails once installed. Leave linkDomains empty to handle every parseable link.

Deferred attribution & the paste button (iOS)

On iOS, deferred attribution recovers a click token the tapped link leaves on the clipboard. LinkTrailOptions.clickTokenSource chooses how it's read:

  • pasteButton (default) — the token is read only when the user taps a LinkTrailPasteButton (Apple's UIPasteControl), with no system "Allow Paste" alert. Render the button and set autoTrackInstall: false so the install waits for the tap.
  • automatic — the SDK reads the clipboard itself at install (no UI, but iOS shows the "Allow Paste" alert on first launch).
LinkTrailPasteButton(
  width: 240,
  onToken: (token) async {
    await LinkTrail.trackInstallWithClickToken(token);
  },
)

The widget renders the native control on iOS 16+ and nothing on Android (Play Install Referrer handles deferred attribution there, so clickTokenSource and the paste button are ignored). Apple restricts customization — no custom label text, font or border.

Implementation notes

  • Subscribe to onLink before any await after configure. A cold-start deep link (deferred first-launch, or a Universal Link that launched the app) is delivered right after configure; if you await something first (e.g. reading consent from storage), the delivery lands with no listener and is lost. Wire onLink first, then do async setup.
  • Consent has no getter — replay it from your own storage on every launch (see above).

The plugin captures links automatically, but the OS still needs to route the link to your app.

Android

Declare your App Links host (and optionally a custom scheme) in android/app/src/main/AndroidManifest.xml:

<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="kick.linktrail.io" />
</intent-filter>

Then host a Digital Asset Links file at https://<host>/.well-known/assetlinks.json listing your package + signing-cert SHA-256 (LinkTrail infra hosts this for your links). If links open the browser or Play Store instead of your installed app, that's almost always App Links verification — see the Android SDK's TROUBLESHOOTING.md.

iOS

Add the Associated Domains capability with applinks:kick.linktrail.io, and host an apple-app-site-association file on that domain. For a custom scheme, add it under CFBundleURLTypes in ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>kickflip</string></array>
  </dict>
</array>

On iOS 13+ the plugin registers on the UIScene lifecycle, so links are delivered correctly on iOS 26 (FlutterSceneDelegate) as well as the classic app-delegate lifecycle.

When linkDomains is non-empty, the SDK routes re-engagement opens (app already installed) only for those hosts — a link on an unlisted host opens the app but never navigates. Deferred (install-time) links skip this check and route regardless, so a missing host can look fine on a fresh install yet fail once the app is installed. Leave linkDomains empty (the default) to handle every parseable link.

Example app

example/ is KickFlip, a small storefront that demonstrates deferred deep linking end to end — the same demo shipped with the native Android and iOS SDKs, rebuilt in Flutter. A link button fires the four scenarios (home · category · product · product + voucher):

cd example && flutter run --dart-define=LINKTRAIL_API_KEY=lt_live_…

Supply your key at build time so it never lands in source control. See example/README.md.

License

MIT — see LICENSE. Note the plugin is open source, but the LinkTrail service it talks to requires a workspace API key.