Deep Blue Links
Documentation menu

.NET

Open your .NET MAUI, .NET for Android or .NET for iOS app from its links, resolve each link with the API, and find the link that led to an install.

This guide follows .NET MAUI's own universal links and app links setup, and uses the Xamarin.Google.Android.InstallReferrer package for the Android install referrer. 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. For a .NET for Android or .NET for iOS app without MAUI, follow the same steps, then the last section.

1. Set up iOS

Add the associated domain to Platforms/iOS/Entitlements.plist, plus one entry per custom domain:

Platforms/iOS/Entitlements.plist
<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:demo.dev.thedbl.ink</string>
</array>

Enable the Associated Domains capability on your App ID in your Apple developer account and download the updated provisioning profile. The team ID and bundle ID in your app's settings must match the app.

2. Set up Android

Add a verified intent filter to MainActivity:

Platforms/Android/MainActivity.cs
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, Exported = true, LaunchMode = LaunchMode.SingleTop)]
[IntentFilter(
    new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "https",
    DataHost = "demo.dev.thedbl.ink",
    AutoVerify = true)]
public class MainActivity : MauiAppCompatActivity
{
}

Keep your existing ConfigurationChanges on the Activity attribute, and the template's LaunchMode.SingleTop: it hands a link that opens while the app runs to the running activity, through OnNewIntent, instead of starting a second one. Your app's settings need the package name and the SHA-256 fingerprint of every key that signs the app, including the Play app signing key.

Forward links from both platforms to Application.SendOnAppLinkRequestReceived in MauiProgram.cs:

MauiProgram.cs
builder.ConfigureLifecycleEvents(lifecycle =>
{
#if IOS || MACCATALYST
    lifecycle.AddiOS(ios =>
    {
        ios.FinishedLaunching((app, data) => HandleUserActivity(app.UserActivity));
        ios.ContinueUserActivity((app, userActivity, handler) => HandleUserActivity(userActivity));
        ios.OpenUrl((app, url, options) => HandleUrl(url));
        if (OperatingSystem.IsIOSVersionAtLeast(13))
        {
            ios.SceneWillConnect((scene, session, options) => HandleUserActivity(options.UserActivities.ToArray()
                .FirstOrDefault(a => a.ActivityType == Foundation.NSUserActivityType.BrowsingWeb)));
            ios.SceneContinueUserActivity((scene, userActivity) => HandleUserActivity(userActivity));
            ios.SceneWillConnect((scene, session, options) => HandleUrl(options.UrlContexts?.ToArray().FirstOrDefault()?.Url));
            ios.SceneOpenUrl((scene, urlContexts) => HandleUrl(urlContexts?.ToArray().FirstOrDefault()?.Url));
        }
    });
#endif
#if ANDROID
    lifecycle.AddAndroid(android =>
    {
        android.OnCreate((activity, bundle) => HandleIntent(activity.Intent));
        android.OnNewIntent((activity, intent) => HandleIntent(intent));
    });
#endif
});
MauiProgram.cs
#if IOS || MACCATALYST
static bool HandleUserActivity(Foundation.NSUserActivity? activity)
{
    if (activity?.ActivityType != Foundation.NSUserActivityType.BrowsingWeb || activity.WebPageUrl is null)
        return false;
    SendAppLink(activity.WebPageUrl.ToString()!);
    return true;
}

static bool HandleUrl(Foundation.NSUrl? url)
{
    if (url?.AbsoluteString is null)
        return false;
    SendAppLink(url.AbsoluteString);
    return true;
}
#endif

#if ANDROID
static void HandleIntent(Android.Content.Intent? intent)
{
    if (intent?.Action == Android.Content.Intent.ActionView && intent.Data is not null)
        SendAppLink(intent.Data.ToString()!);
}
#endif

static void SendAppLink(string url)
{
    if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
        Application.Current?.SendOnAppLinkRequestReceived(uri);
}

iOS delivers universal links as user activities and URL scheme links as URLs, so both kinds are forwarded. With scenes, a link that launches the app arrives in SceneWillConnect, whose UrlContexts is null when the app opens without one.

DeepBlueLinks.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

public record ResolvedLink(int Id, string LongUrl, string ShortUrl, bool? IsSandbox);

public static class DeepBlueLinks
{
    const string ApiKey = "YOUR_API_KEY";
    static readonly HttpClient Http = new() { BaseAddress = new Uri("https://dev.thedbl.ink/") };
    static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    public static Task<ResolvedLink?> ResolveAsync(string url) => PostAsync("api/links/resolve", new { url });

    public static async Task<ResolvedLink?> PostAsync(string path, object body)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body, options: Json) };
        request.Headers.Add("Api-Key", ApiKey);
        using var response = await Http.SendAsync(request);
        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;
        response.EnsureSuccessStatusCode();
        var link = await response.Content.ReadFromJsonAsync<ResolvedLink>(Json);
        return link;
    }
}

Route each link in your App class:

App.xaml.cs
protected override async void OnAppLinkRequestReceived(Uri uri)
{
    base.OnAppLinkRequestReceived(uri);
    if (uri.Scheme == "myapp")
    {
        await Router.OpenAsync("/" + uri.Host + uri.AbsolutePath, uri.Query);
        return;
    }
    ResolvedLink? link = null;
    try
    {
        link = await DeepBlueLinks.ResolveAsync(uri.ToString());
    }
    catch (HttpRequestException ex)
    {
        System.Diagnostics.Debug.WriteLine($"Couldn't resolve {uri}: {ex.Message}");
    }
    var destination = link is null ? uri : new Uri(link.LongUrl);
    await Router.OpenAsync(destination.AbsolutePath, destination.Query);
}

OnAppLinkRequestReceived is async void, so an exception escaping it would crash the app: a failed call, such as no network or a wrong key, is logged and the URL routed as it is. Router stands for your app's navigation, such as Shell's GoToAsync. 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. The myapp branch handles your URL scheme, whose host is the link's first path segment; register the scheme in Info.plist and with a second intent filter if your app has one. 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. Add the package to the Android target:

YourApp.csproj
<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
  <PackageReference Include="Xamarin.Google.Android.InstallReferrer" Version="2.2.0.9" />
</ItemGroup>
Platforms/Android/InstallReferrer.cs
using Xamarin.Android.InstallReferrer.Api;

public static class InstallReferrer
{
    const int ResponseOk = 0;

    public static Task<string?> ReadLinkAsync(Android.Content.Context context)
    {
        var result = new TaskCompletionSource<string?>();
        var client = InstallReferrerClient.NewBuilder(context)!.Build()!;
        client.StartConnection(new Listener(client, result));
        return result.Task;
    }

    static string? LinkFrom(string? referrer) =>
        referrer?.Split('&')
            .Select(pair => pair.Split('=', 2))
            .Where(parts => parts.Length == 2 && parts[0] == "link")
            .Select(parts => Uri.UnescapeDataString(parts[1]))
            .FirstOrDefault();

    sealed class Listener(InstallReferrerClient client, TaskCompletionSource<string?> result)
        : Java.Lang.Object, IInstallReferrerStateListener
    {
        public void OnInstallReferrerSetupFinished(int responseCode)
        {
            try
            {
                var referrer = responseCode == ResponseOk ? client.InstallReferrer?.InstallReferrer : null;
                result.TrySetResult(LinkFrom(referrer));
            }
            catch (Exception)
            {
                result.TrySetResult(null);
            }
            finally
            {
                client.EndConnection();
            }
        }

        public void OnInstallReferrerServiceDisconnected() => result.TrySetResult(null);
    }
}

ResponseOk is the Play library's InstallReferrerResponse.OK, and a failure to read the referrer counts as no link. Elsewhere, call check-deferred with the timezone and the screen in physical pixels, which DeviceDisplay reports:

DeepBlueLinks.cs
public static async Task<ResolvedLink?> FindInstallLinkAsync()
{
#if ANDROID
    var link = await InstallReferrer.ReadLinkAsync(Platform.AppContext);
    return link is null ? null : await ResolveAsync(link);
#else
    var display = DeviceDisplay.Current.MainDisplayInfo;
    return await PostAsync("api/links/check-deferred", new
    {
        timezone = TimeZoneInfo.Local.Id,
        device = new
        {
            screenWidth = display.Width,
            screenHeight = display.Height,
            language = System.Globalization.CultureInfo.CurrentUICulture.Name,
        },
    });
#endif
}

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

Without MAUI: .NET for Android and .NET for iOS

A .NET for Android or .NET for iOS app uses the setup of steps 1 and 2, DeepBlueLinks.cs from step 4 and, on Android, InstallReferrer.cs from step 5, with Android's Application.Context in place of MAUI's Platform.AppContext. Only the way links arrive differs: there is no OnAppLinkRequestReceived, so the activity or the scene delegate passes each link to a method of your own, OnLinkReceived below, with the body of step 4's handler.

.NET for Android: handle the intent in MainActivity. LaunchMode.SingleTask hands a link that opens while the app runs to OnNewIntent, and setting Intent keeps the latest link across a rotation:

MainActivity.cs
[Activity(MainLauncher = true, Exported = true, LaunchMode = LaunchMode.SingleTask)]
[IntentFilter(
    new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "https",
    DataHost = "demo.dev.thedbl.ink",
    AutoVerify = true)]
public class MainActivity : Activity
{
    protected override void OnCreate(Bundle? savedInstanceState)
    {
        base.OnCreate(savedInstanceState);
        HandleIntent(Intent);
    }

    protected override void OnNewIntent(Intent? intent)
    {
        base.OnNewIntent(intent);
        Intent = intent;
        HandleIntent(intent);
    }

    void HandleIntent(Intent? intent)
    {
        if (intent?.Action == Intent.ActionView && Uri.TryCreate(intent.Data?.ToString(), UriKind.Absolute, out var uri))
            OnLinkReceived(uri);
    }
}

Store the first-launch flag with GetSharedPreferences instead of Preferences.Default, and keep it out of Auto Backup, as the Android guide shows.

.NET for iOS: an app with a scene manifest, as the template has, receives its links only in the scene delegate. A link that launches the app arrives in WillConnect, and later ones in ContinueUserActivity (universal links) and OpenUrlContexts (your URL scheme):

SceneDelegate.cs
[Export("scene:willConnectToSession:options:")]
public void WillConnect(UIScene scene, UISceneSession session, UISceneConnectionOptions connectionOptions)
{
    // Create and show the window here, then:
    HandleUserActivity(connectionOptions.UserActivities.ToArray().FirstOrDefault(a => a.ActivityType == NSUserActivityType.BrowsingWeb));
    HandleUrl(connectionOptions.UrlContexts?.ToArray().FirstOrDefault()?.Url);
}

[Export("scene:continueUserActivity:")]
public void ContinueUserActivity(UIScene scene, NSUserActivity userActivity) => HandleUserActivity(userActivity);

[Export("scene:openURLContexts:")]
public void OpenUrlContexts(UIScene scene, NSSet<UIOpenUrlContext> urlContexts) =>
    HandleUrl(urlContexts.ToArray().FirstOrDefault()?.Url);

void HandleUserActivity(NSUserActivity? activity)
{
    if (activity?.ActivityType == NSUserActivityType.BrowsingWeb)
        HandleUrl(activity.WebPageUrl);
}

void HandleUrl(NSUrl? url)
{
    if (Uri.TryCreate(url?.AbsoluteString, UriKind.Absolute, out var uri))
        OnLinkReceived(uri);
}

DeviceDisplay is part of MAUI, so FindInstallLinkAsync takes the screen from the window scene, in pixels, and the flag goes in NSUserDefaults:

DeepBlueLinks.cs
public static async Task<ResolvedLink?> FindInstallLinkAsync()
{
    var screen = UIApplication.SharedApplication.ConnectedScenes.ToArray().OfType<UIWindowScene>().FirstOrDefault()?.Screen;
    return await PostAsync("api/links/check-deferred", new
    {
        timezone = TimeZoneInfo.Local.Id,
        device = screen is null ? null : new
        {
            screenWidth = (double)(screen.Bounds.Width * screen.Scale),
            screenHeight = (double)(screen.Bounds.Height * screen.Scale),
            language = System.Globalization.CultureInfo.CurrentUICulture.Name,
        },
    });
}

The casts to double matter: System.Text.Json writes an nfloat as an object, not a number.

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.