Deferred Deep Linking from App Store using Universal Links for iOS with Flutter

In Doubble we recently had to create a customized landing page to have more fine-graned control over our invite links.

Issues with Firebase Dynamic Links

We have until this point been using Firebase Dynamic Links which was great and easy to setup, but had a few issues.

Everytime you set out to create something yourself, instead of picking something pre-built off from the shelves, it's important to ask whether or not this is a good idea. Usually it's better to offload your resources to the open source community and leverage the power behind it, instead of creating everything yourself. At least for the most part, anyway.

Other times, the energy you would spend customizing what's already out there would simply be more time consuming than building it yourself, or perhaps what's out there, does not provide the exact use-case you want to deliver.

Customized landing page

The main reason for switching away from Firebase was due to the lack of customization of the landing page. We could not customize the landingpage - so you would be stuck with the "default" one:

The default firebase dynamic links landing page

Instead, we wanted to built something a bit more personalized for the user, like this:

 Doubble invite landing page

And also, although it's a minor thing, but being able to decide exactly how the link should end up looking, is nice-to-have, but becomes a natural effect of handling the generation of URLs yourself.

The bonus was that this would also give us an overview of all the generated links in our database, something Firebase did not provide us with.

How to

Here's a little explainer of what happens for each use case.

Generating the link

Once a user clicks a specific button in the app to copy their invite link, the following happens:

  1. A request is sent to the backend, requesting the users personal invite link
  2. If the link is not yet generated, we create it, and store it in a invite_code table.
  3. The personal link is returned from the backend and ready to be sent out

Generating the actual random code

We generate a 5 alphanumeric digit random string (A-Z, a-z, 0-9), which should give 916132832 unique referral codes (and that should be enough for the next weeks, months, years, depending on how ambitious you are regarding your growth).

Thanks to the power of Stackoverflow, this function seemed to do the trick for us:

export const randomString = (length: number): string => {
  let result = '';
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  const charactersLength = characters.length;
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
};

Our schema for the invite_code looks something like this:

CREATE TABLE invite_code
(
    code       varchar NULL,
    user_id    UUID    NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (user_id, code),
    FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE
);

There are several approaches you could take to finding the next available code, but we choose out to start out by this:

  1. We generates 50 random codes (without actually creating them in the database)
  2. We query these 50 random codes against the invite_code table
  3. We return the first one that is not yet taken.
  4. Should the unlikely (or happy) scenario be, that none of the 50 codes is available, we repeat the process above. After a certain threshold we increase the length of the invite code, just to make it extra future proof.

An alternative way could also be to pre-generate X amount of codes.

The service to generate the code, looks something like this:

private async generateCode(userId: string, retries = 0): Promise<string> {
  if (retries > 10) {
    throw new Error('Could not generate invite code');
  }

  // generate a set of random codes, and take first one that is not in use
  // should we have tried more than 3 times to generate a code, we should
  // increase the length of the code
  const randomCodes = new Array(50).fill({}).map(() => randomString(5 + (retries > 3 ? retries - 3 : 0)));

  // check if any of the codes are in use
  const takenCodes = await this.repository.find({
    where: {
      code: In(randomCodes),
    },
  });

  // find first non-taken code
  const nonTakenCode = randomCodes.find((code) => !takenCodes.find((taken) => taken.code === code));

  // just as a precaution, if we can't find a non-taken code, we'll try again
  if (!nonTakenCode) {
    // perhaps log this somewhere, so you're aware of it
    return this.generateCode(userId, retries + 1);
  }

  // return the available-code
  return nonTakenCode;
}

The returned generated code can then be saved in the database.

First part is done. We can now generate a unique link that will look something like this: https://get.dbbl.app/21vw5, super great! 🎉

Signing up using the link

Now to what was a bit more opaque to me, and that is how to handle the following cases:

1. Redirecting to App Store if app is not installed (Deferred Deep Linking)

User does not have the app installed yet, so upon clicking the button in the personalized landingpage, it should take the user to the app store, while still maintaining the original link.

So when the user then opens the app for the first time, after having had it installed from App Store, the app should know which link was used.

2. Open the app if the app is already installed Should the app already be installed on the iPhone, the link should instead simply take the user to the approiate place. That means, if anybody with the app, clicks https://get.dbbl.app/XXX it should open the app, without going to the landing page.

I researched quite a bit, and most answers you would come across would suggest complicated methods such as IP/User Agent tracking, or simply state that it wasn't possible. But I knew this wouldn't need to be the case, because I knew Firebase Dynamic Links did it.

Point 2 is handled automatically by Universal Links due to the nature of how it works, but point 1 needed a little workaround to work properly.

The solution - copy-pasting the link

So the solution ended up being quite simple: before the user clicks the button to go to App Store, copy the link.

We used Cloudflare Workers to host the actual landing page. So somewhere on the landing page we would show the "accept friend request" button that will take the user to the App Store.

<a href="https://apps.apple.com/us/app/doubble/id1572234875" onClick="return openAppStore(event);">
  accept friend request
</a>

And the actual javascript:

const inviteLink = 'https://get.dbbl.app/XXXX';

// a legacy fallback for browsers that don't support navigator.clipboard
// most likely not needed, but it's cheap!
function fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement('textarea');
  textArea.value = text;

  // Avoid scrolling to bottom
  textArea.style.top = '0';
  textArea.style.left = '0';
  textArea.style.position = 'fixed';

  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();

  try {
    document.execCommand('copy');
  } catch (err) {}

  document.body.removeChild(textArea);
}

async function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    fallbackCopyTextToClipboard(text);
    return new Promise((r) => setTimeout(r, 250));
  }
  return navigator.clipboard.writeText(text);
}

function openAppStore(e) {
  e.preventDefault();
  copyTextToClipboard(inviteLink).then(() => {
    window.location.href = e.target.href;
  });
}

So what happens is esentially:

  • The user clicks the link
  • The invite link is copied to clipboard
  • The user is redirected to app store

Now, next step is actually reading the invite link from the clipboard inside the Flutter app, once the app has been installed and opened for the first time.

Reading the copied link from clipboard in Flutter

So when the app is run for the first time, we want to read the contents of the clipboard, and try to see if we can parse it as a link. This is exactly what Firebase Dynamic Links is doing.

For figuring out if the app is being run for the first time, I found a simple little package called is_first_run that would check this for me, but it would also be easy to implement something on your own, e.g. using SharedPreferences.

For the handling of the Universal Links I found uni_links a great package from a developer called avioli.

I won't go deep into how you actually need to setup the associated domains as required by universal links, as the uni_links documentation already contains a lot of great information on this. But at least the API is very close to the one provided by firebase_dynamic_links.

The actual Flutter code to read the copy pasted link, as well as handling other use-cases such as retrieving the link when app is already opened, looks like this:

import 'package:flutter/services.dart';
import 'package:is_first_run/is_first_run.dart';
import 'package:uni_links/uni_links.dart';

// this is a class that is called using Riverpod early in my main.dart file.
// but it doesn't have to be Riverpod. I'm sure you find a way to handle this
// some appropiate place in your app. Preferably as early as possible.
class DynamicLink extends _$DynamicLink {
  late StreamSubscription _sub;
  @override
  build() {
    // listen on the universal links
    this.init();

    ref.onDispose(() {
      // cancel the stream should the class ever be disposed
      _sub.cancel();
    });
  }


  Future<void> init() async {
    // getInitialLink is triggered if a link triggered the app to open
    // without it being running in the background
    try {
      getReferralLink(await getInitialLink());
    } on PlatformException {
    }

    // Attach a listener to the stream.
    // this is triggered if a link is clicked, while the app is running
    _sub = linkStream.listen((String? link) {
      getReferralLink(link);
    }, onError: (err) {
      // print('got error');
    });

    // get from clipboard - if redirected from app store
    // TODO: should use hasUrls instead https://github.com/flutter/flutter/issues/68014
    bool firstRun = await IsFirstRun.isFirstRun();
    if (firstRun && await Clipboard.hasStrings()) {
      ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);

      if (data != null && data.text != '') {
        getReferralLink(data.text);
      }
    }
  }

  Future<void> getReferralLink(String? link) async {
    if(link == null) {
      return;
    }

    // wrap it in a try/catch, since it could be an invalid link
    try {
      // par the (potential) link
      final uri = Uri.parse(link);

      // we now have the inviteCode as well as the link
      final inviteCode = uri.pathSegments.first;

      // do whatever needs to be done here
    } catch (e) {}
  }
}

And voila! You have now successfully implemented Deferred Deep Linking from App Store without using Firebase Dynamic Links.

We now have a personalized invite link, as well as a dynamically generated open graph image preview of the link, that will change, depending on who receives the link (but that's outside the scope of this post).

How our personalized preview image look in iMessage

Bonus: Serving your apple-app-site-association through Cloudflare Workers

I mentioned above that we used Cloudflare Workers to host our custom landing page as well as universal link.

I can highly recommend Cloudflare Workers, first of all because it's free, second of all because it's extremly powerful.

We use Worktop by Luke Edwards (former Cloudflare engineer) together with his cfw tool for easy deployment.

That will also allow you to customize the link, to show the Google Play Store should your user be on an Android.

It also makes it extremly easy to host your apple-app-site-association file

Our Worker script looks something like this:

import { listen, Router } from 'worktop';

const API = new Router();

API.add('GET', '/.well-known/apple-app-site-association', async (req, res) => {
  return new Response(
    JSON.stringify({
      applinks: {
        apps: [],
        details: [
          {
            appID: 'G6X9L9768W.dk.doubble.dating',
            paths: ['*'],
          },
        ],
      },
    }),
    {
      headers: {
        'Content-Type': 'application/json',
      },
    },
  );
});

API.add('GET', '/:inviteCode', async (req, res) => {
  const inviteCode = req.params.inviteCode as string;
  const inviteLink = `https://get.dbbl.app/${inviteCode}`;
  const userAgent = req.headers.get('user-agent') || '';

  return new Response(
    /*html*/ `
    <html>
      [....]
    </html>
  `,
    {
      headers: {
        'content-type': 'text/html;charset=UTF-8',
      },
    },
  );
});

listen(API.run);

And your site can be built and deployed as easy as: npm run build && npm run deploy

Comments