Universal links not working in iOS

For anyone looking for a solution, prepare to read. The AASA configuration to place on the server in order to support the various iOS versions is not complicated per-se, but the documentation is definitely not easy to find.

For the expo team: Since the expo documentation lacks any specifics on how to configure the apple-app-site-association file, please, feel free to take this post, clean it, and use it for your documentation. It may be very useful to other developers who, like me, spent days figuring out what is what. You’re welcome :slight_smile:

NOTE: The following information may not be complete, accurate, correct, or any combination of these. But it’s the best I could collect from various help requests on Apple’s developer forums, Apple’s documentation, github, StackOverflow, or other websites. I will cite the sources which to me looked like the most reliable information, but those may not be the only places that the information comes from.

iOS 10 and and lower

Source: Adopting Handoff

I could only find limited information on this iOS version, and I also think that there are not enough users of iOS 10 (or lower) to justify the effort. But anyway, listing here for completeness.

iOS 10 requires the apple-app-site-association configuration to be placed either on the website root, or in the .well-known folder, with the latter being the preferred one to be checked:
https://<some-website.com>/.well-known/apple-app-site-association
https://<some-website.com>/apple-app-site-association

The file must be up to 128KB when uncompressed. The configuration file requires the following format:

{
	"activitycontinuation": {
		"apps": [
			"TEAMID.bundleidentifier",
			"OTHTEAMID.otherbundleidentifier"
		]
	}
}

iOS 11 and 12

Source: Setting up iOS Universal Links · GitHub

The apple-app-site-association will only be read from the .well-known directory, dropping support for the same file in the website root directory. If you still have the iOS 10 configuration in the website root, just move it to the /.well-known/ directory, it will still work on iOS 10, and will support iOS 11+.

In iOS 11+, the configuration file added support for various new services (applinks, webcredentials, appclips, maybe others). For universal links we are interested in the applinks service, which requires the apps and details keys.
I could not find information on what is the purpose of the apps key, why it needs to be an empty array, and if it’s actually required (most links I found said that it is required to be empty…). I can only suppose that it’s a legacy remnant from the iOS 10 configuration, which is not used in the iOS 11+ configuration, but must be present for legacy support reasons.
Anyway, the details key is what iOS 11+ will check for. It must be an array of per-app configurations. The difference from iOS 10 config is that you can now specify which paths you want to handle with your universal links (or match all with the "*" (any) wildcard, more on that later).

The configuration file requires the following format:

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appID": "TEAMID.bundleidentifier",
				"paths": [ "/buy/*", "/help/website/*",  "/help/*" ]
			},
			{
				"appID": "OTHTEAMID.otherbundleidentifier",
				"paths": [ "/blog", "/blog/post/*" ]
			},
			{
				"appID": "YAOTHTEAMID.yetanotherbundleidentifier",
				"paths": [ "*" ]
			}
		]
	}
}

iOS 13 and 14

Source: Supporting associated domains | Apple Developer Documentation

iOS 13+ improves on the previous iOS 11/12 configuration by splitting the paths into components, with a greater level of configuration. You can now specify paths with fragments (/path#fragment), whether to actually exclude the specified path with the exclude key, and also add a comment key, in case you want to document what the path is used for.
Also, the configuration now is not per-app, but it’s per-app-groups. This aims to make it easier to associate multiple apps with the same config (consider the case of the full Facebook app, and the Facebook lite app: both handle the same links, with the same configurations).

The configuration file requires the following format:
(copied from the above source link)

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appIDs": ["ABCDE12345.com.example.app", "ABCDE12345.com.example.app2"],
				"components": [
					{
						"#": "no_universal_links",
						"exclude": true,
						"comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link"
					},
					{
						"/": "/buy/*",
						"comment": "Matches any URL whose path starts with /buy/"
					},
					{
						"/": "/help/website/*",
						"exclude": true,
						"comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link"
					},
					{
						"/": "/help/*",
						"?": { "articleNumber": "????" },
						"comment": "Matches any URL whose path starts with /help/ and which has a query item with name 'articleNumber' and a value of exactly 4 characters"
					}
				]
			}
		]
	}
}

A note on the * (any) wildcard

You may be familiar with the * (any) wildcard, and you may expect for

  • https://*.mywebsite.com to match all links that begin with anything, and have .mywebsite.com in the domain name
  • https://mywebsite.com/* to match any link that follows into a subpath of the domain name.

Then you would be surprised to discover that:

  • https://test.dev.mywebsite.com does not match your configured *.<domain>
  • https://mywebsite.com/product/details does not match your app paths <domain>/*

Why?
The reason is that the * (any) wildcard matches any string of characters, except:

  • level domain separators (the dot)
  • path separators (the forward slash)
  • (possibly others?)

In order to match multiple domain levels or subpaths you have to explictly configure it so:
https://*.*.mywebsite.com/*/*

Also, please consider the order of precedence: if you specify the path /blog/* before /blog/post, then the all links to /blog/post will match against the first path, not the second. That is because the /blog/* will be evaluated first, and will match, before /blog/post gets a chance to be evaluated. The solution is to place these catch-all paths after any specific sub-paths that you may want to handle differently.

Merging configurations

Even if you followed along the above, it may not be obvious to you how to merge these different configurations in order to support all iOS versions (to a certain degree) with a single configuration file. But the solution is actually simple, it’s just a trick merging of only the root keys, and adding their values to the existing ones in the arrays.
Also, for some reason, newer iOS version configs should be listed before older ones (iOS 13-14 > iOS 11-12 > iOS 10 and lower). It seems that, failing to do so, fails the checks during app installation for some developers. Although, I suspect this is not true and the real issue was somewhere else, it doesn’t hurt to follow this rule to avoid possible issues if it actually is true.

EDIT
The AASA validator will validate the configuration in whichever order you write it. The issue seems to only be an iOS-level of preference of the order of configurations, or things of sort.

The following example merges all of the 3 above configurations into a single JSON config.
Notice how configs in the details key are just copy-pasted configs from the iOS 13+ and 11-12 configs, while the root simply contains the activitycontinuation service config required for iOS 10.

{
	"applinks": {
		"apps": [],
		"details": [
			{
				"appIDs": [ "TEAMID.bundleidentifier", "ABCDE12345.com.example.app2" ],
				"components": [
					{
						"#": "no_universal_links",
						"exclude": true,
						"comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link"
					},
					{
						"/": "/buy/*",
						"comment": "Matches any URL whose path starts with /buy/"
					},
					{
						"/": "/help/website/*",
						"exclude": true,
						"comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link"
					},
					{
						"/": "/help/*",
						"?": { "articleNumber": "????" },
						"comment": "Matches any URL whose path starts with /help/ and which has a query item with name 'articleNumber' and a value of exactly 4 characters"
					}
				]
			},
			{
				"appID": "TEAMID.bundleidentifier",
				"paths": [ "/buy/*", "/help/website/*",  "/help/*" ]
			},
			{
				"appID": "OTHTEAMID.otherbundleidentifier",
				"paths": [ "/blog", "/blog/post/*" ]
			},
			{
				"appID": "YAOTHTEAMID.yetanotherbundleidentifier",
				"paths": [ "*" ]
			}
		]
	},
	"activitycontinuation": {
		"apps": [
			"TEAMID.bundleidentifier",
			"OTHTEAMID.otherbundleidentifier"
		]
	}
}
3 Likes