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
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"
]
}
}