Deep Blue Links
Documentation menu

Flutter

Open your Flutter app from its links on iOS and Android, resolve each link with the API, and find the link that led to an install.

This guide uses app_links for incoming links, play_install_referrer and flutter_timezone for deferred links, and http. Replace demo with your app's name and YOUR_API_KEY with its key: the sandbox key in debug builds and the live key in release builds.

pubspec.yaml
dependencies:
  app_links: ^7.2.2
  flutter_timezone: ^5.1.1
  http: ^1.6.0
  play_install_referrer: ^0.6.0

1. Set up iOS

In Xcode, open ios/Runner.xcworkspace, select the Runner target, open Signing & Capabilities, add Associated Domains and enter applinks:demo.dev.thedbl.ink, plus one entry per custom domain. The team ID and bundle ID in your app's settings must match the app.

app_links handles the links itself, so turn off Flutter's own deep linking in ios/Runner/Info.plist. If your app has a URL scheme, register it there too:

ios/Runner/Info.plist
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
    </dict>
</array>

2. Set up Android

In android/app/src/main/AndroidManifest.xml, allow the app to reach the API: Flutter's template declares the permission only for debug builds, so a release build can't call the API without it. Inside the .MainActivity activity, turn off Flutter's deep linking and add a verified intent filter for your link host, plus a filter for your URL scheme if your app has one:

android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />

<!-- inside <activity android:name=".MainActivity"> -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />

<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="demo.dev.thedbl.ink" />
</intent-filter>

<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" />
</intent-filter>

Your app's settings need the package name and the SHA-256 fingerprint of every key that signs it, including the Play app signing key.

lib/deep_blue_links.dart
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:ui' show PlatformDispatcher;

import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:http/http.dart' as http;
import 'package:play_install_referrer/play_install_referrer.dart';

class ResolvedLink {
  ResolvedLink.fromJson(Map<String, dynamic> json)
      : id = json['id'] as int,
        longUrl = json['longUrl'] as String,
        shortUrl = json['shortUrl'] as String,
        isSandbox = json['isSandbox'] == true;

  final int id;
  final String longUrl;
  final String shortUrl;
  final bool isSandbox;
}

class DeepBlueLinks {
  static const _apiKey = 'YOUR_API_KEY';

  static Future<ResolvedLink?> resolve(Uri url) =>
      _post('/api/links/resolve', {'url': url.toString()});

  static Future<ResolvedLink?> _post(String path, Map<String, Object?> body) async {
    final response = await http.post(
      Uri.parse('https://dev.thedbl.ink$path'),
      headers: {'Api-Key': _apiKey, 'Content-Type': 'application/json'},
      body: jsonEncode(body),
    );
    if (response.statusCode == 404) return null;
    if (response.statusCode != 200) {
      throw http.ClientException('Deep Blue Links answered ${response.statusCode}');
    }
    return ResolvedLink.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  }
}

Create AppLinks early, such as in your root widget's initState, so it catches the link that launched the app; its stream delivers that link and every later one:

final _appLinks = AppLinks();

void listenForLinks() {
  _appLinks.uriLinkStream.listen((uri) async {
    if (uri.scheme == 'myapp') {
      router.open('/${uri.host}${uri.path}', uri.query);
      return;
    }
    ResolvedLink? link;
    try {
      link = await DeepBlueLinks.resolve(uri);
    } catch (error) {
      debugPrint("Couldn't resolve $uri: $error");
    }
    final destination = link == null ? uri : Uri.parse(link.longUrl);
    router.open(destination.path, destination.query);
  });
}

router stands for your app's own navigation. A 404 from resolve means the URL isn't one of your short links, such as a link's full URL, so it's routed as it is, and so is a URL whose resolve fails, such as with no network. The myapp branch handles your URL scheme, if your app has one: its host is the first path segment. When link.isSandbox is true, load the destination's content from your sandbox backend.

On Android, read the short link from the Play install referrer. Elsewhere, call check-deferred with the timezone and the screen in physical pixels:

Add this method to the DeepBlueLinks class:

lib/deep_blue_links.dart
static Future<ResolvedLink?> findInstallLink() async {
  if (Platform.isAndroid) {
    final String? link;
    try {
      final details = await PlayInstallReferrer.installReferrer;
      link = Uri.splitQueryString(details.installReferrer ?? '')['link'];
    } catch (_) {
      return null;
    }
    return link == null ? null : resolve(Uri.parse(link));
  }

  final timezone = await FlutterTimezone.getLocalTimezone();
  final screen = PlatformDispatcher.instance.views.first.physicalSize;
  return _post('/api/links/check-deferred', {
    'timezone': timezone.identifier,
    'device': {
      'screenWidth': screen.width,
      'screenHeight': screen.height,
      'language': PlatformDispatcher.instance.locale.toLanguageTag(),
    },
  });
}

A failure to read the referrer, such as an install from another store, counts as no link, while a failed resolve throws like any other call. physicalSize is the app's view in pixels, its points times the screen's scale, which is how the link's page measures the screen; the display's own size is the panel's native resolution, which differs on some iPhones.

Call DeepBlueLinks.findInstallLink() once, on the first launch (store a flag, for example with shared_preferences), and route like a resolved link when it returns one. A null result is normal: most launches don't follow a link.

Test it

  • iOS universal links work on a device: paste a short link into Notes and tap it.
  • On Android, check verification with adb shell pm get-app-links com.example.demo, then open a link with adb shell am start -a android.intent.action.VIEW -d "https://demo.dev.thedbl.ink/Xy3kP9aQ".
  • The install referrer is only present for installs from Google Play, such as an internal testing track opened from a link's preview page.