iOS14でUniversal Links対応することになった人が見る記事

## Universal Linksとは
urlからアプリをシームレスに起動できる機能のことです。

https://developer.apple.com/ios/universal-links/

ディープリンクとの大きな違いは確認のダイアログなしでアプリに遷移できることです。
UX上の利点や特徴などは以下の記事で詳しく解説されています。

https://qiita.com/mono0926/items/2bf651246714f20df626#universal-links%E3%81%A8%E3%81%AF

## モチベーション
今回Universal links対応をiOS側の担当者として調査から実装までやりました。
しかしこれが曲者で、

* OSごとに対応方法が異なっていたり、
* 事前に対策を打たないと特定の環境で動作確認できなかったり
* 挙動にOS差分があって中々原因に気づけなかったはまりどころがあったり、

などなど結構ハマってしまいました。。。

そこで、同じようにiOS14からUniversal Linsに対応する方が苦しまなくてよいようにするため、以下の情報を記事にしました。

* iOS14時点のUniversal Links対応手順
* Universal Linksの実装に役立つテクニック

## iOS14からのUniversal Links対応手順
### 1.`apple-app-site-association`の作成

`apple-app-site-association`とは、iOSアプリへの遷移の資格情報を記載するファイルです。

https://developer.apple.com/documentation/xcode/supporting-associated-domains

#### 注意点

* .jsonなどのファイル形式を指定しないこと。(ただの`apple-app-site-association` として作成する。)
* iOS13以前と以降で書き方が異なるが、13以前のOSでUniversal Linksを使用したい場合は旧形式で記載すること。
    * iOS13以前の書き方→[apple-app-site-associationのpaths(遷移対象パス)の作り方（UniversalLinks対応）
](https://qiita.com/keroppi0_0/items/1a7fab4a8cc78412d30c#apple-app-site-association%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%A3%E3%81%A6)
    * iOS13以前のOSを気にする必要がない場合は、後述する記載形式で作成することをお勧めします。パラメータを柔軟に指定できるためです。

#### 記載方法
それでは中身を見ていきます。ざっくり以下の要素を記載する必要があります。

key | valueに記載するもの | 例
--- | --- | ---
appIDs | [teamID] + "." + [BundleID]を配列で記載 | ABCDE12345.com.example.app
components | 遷移を許可するパスの情報を記載 | 後述
webcredentials | [teamID] + "." + [BundleID]を配列で記載 | ABCDE12345.com.example.app

```json
{
  "applinks": {
      "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"
               },
               ...

   },
   "webcredentials": {
      "apps": [ "ABCDE12345.com.example.app" ]
   },

   ...

}


```

teamIDとbundleIDがBuildConfigurationごとに異なっている場合は、それら別に記載する必要があるので注意です。
**僕はRelaseとDebugのteamIDを変える対応が漏れていて爆死しました。**
`apple-app-site-association`の記載ミスはファイルを修正してまた配置し直す作業が発生するため、**修正時のリードタイムが大きくなります**。
入念にレビューしてもらってから配置しましょう。

**components内の項目**

key | valueに記載するもの | 例
--- | --- | ---
/ | 遷移を許可するパス | /foo
`#` | URLのフラグメント | no_universal_links
? | パラメータ | { "articleNumber": "????" }
comment | コメント(遷移には影響しない) | "fooへ遷移を許可します。"

```json
"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"
    },
    ...
]
```

覚えておきたいのは以下のポイントです。

* "/"で記載する遷移を許可するパスはドメイン以降のパスを指定する。
    * 例えば`https://example.com/foo`の場合は`/foo`のみ記載する。
* 全体で正規表現が使用できる。
    * `?`はランダムな1文字を許可
    * `*`はランダムな文字列を許可


パラメータの詳しい指定方法は2019年のWWDCの動画が参考になります。

https://developer.apple.com/videos/play/wwdc2019/717

### 2.`apple-app-site-association`をサーバーに配置する。

1で作成した`apple-app-site-association`をUniversalLinksを起動させるURLのドメインのサイトに配置します。

配置するパスは以下のように`.wellknown`配下を指定する必要があります。

```
https://<fully qualified domain>/.well-known/apple-app-site-association
```

こうして配置された`apple-app-site-association`はアプリのインストール時にアプリ側から取得され、UniversalLinksに遷移の可否判定に使用されます。

#### iOS14以降の`apple-app-site-association`取得上の注意

iOS13までは.well-knownのパスにアプリ方直接`apple-app-site-association`を取得しに行っていました。
しかしiOS14から取得経路が変わり、AppleのCDNを経由して`apple-app-site-association`が取得されるようになりました。
図にすると以下にようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/48bfd7ce-d39e-4278-dd84-b0a2f9ea63e7.jpeg" width="600">

この変更による問題点は、**AppleのCDNが`apple-app-site-association`を取得できない場合、UniversalLinksが機能しない** ということです。

例えばStaging環境でIP制限を設けている場合だと、AppleのCDNからのアクセスを弾いてしまい、アプリがCDNから`apple-app-site-association`を取得できず、UniversalLinksが機能しなくなってしまう事態が発生します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/f654fe63-e0b3-bf87-1955-f4c5bf7d9514.jpeg" width="600">

これは後述するAssociated Domainsの設定でドメインにmodeを指定することで回避できます。
`mode=developer`を指定することで開発者の端末であればCDNを経由せずに直接ドメインの.well-knownにJSONを取得しに行くことができます。

Staging環境でもUniversalLinksのテストをしたい場合は、上記の設定によってテストが可能です。

---
※ ドキュメントにはmode=developerを指定する場合、「使用するデバイスでオプトイン」する必要があると記載されています。
なんのことかというと設計アプリのデベロッパ画面の以下の項目です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/8c9ca9e2-7d77-8293-52f2-2d08991ca4e5.jpeg" width="250">　<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/d6ac69fe-c476-0509-c701-048796c3eeec.jpeg" width="250">

(Appleのドキュメントはこういう細かいとこの記載が分かりにくいと思う。。。)

---

iOS14でのUniversalLinksの変更点については以下を参考にさせていただきました。

https://en-jp.wantedly.com/companies/wantedly/post_articles/305303

公式によるiOS14でのUniversalLinksの変更点の説明は2020年のWWDCの動画で確認できます。

https://developer.apple.com/videos/play/wwdc2020/10098/


### 3.`Associated Domains`のCapabilitiesを追加

Capabilitesに`Associated Domains`を追加します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/badfca00-2845-077b-6013-a7da122f0ec2.jpeg" width="600">

https://developer.apple.com/documentation/xcode/supporting-associated-domains

ここには2でサーバーに配置した`apple-app-site-association`を取得しにいくドメインを記載します。

以下の形式で追加します。

```
<service>:<fully qualified domain>
```

2で記載したCDNを経由せずにドメインに`apple-app-site-association`を取得しに行く設定にするためには、ここでmodeを指定します。

Releaseビルドではmode=developer付きのstagingドメインを記載する必要はないので、BuildConfigurationごとにentitlementsを分ける対応になりそうです。

#### ドメイン記載時の注意

**`apple-app-site-association`を配置したドメインとAssociatedDomainsの記載は一致している必要があります。**

例えば`https://www.example.com/.well-known/`に`apple-app-site-association`を配置したとします。

この時**`www`なしの`applinks:example.com`を記載していた場合、iOS14ではUniversalLinksが機能しません**。

iOS13までは`example.com`のようなルートのドメインのみapplinksに指定すれば、`apple-app-site-association`の配置先が`www.example.com`のようなサブドメインでも取得されました。
しかしiOS14では`apple-app-site-association`の配置先のドメインと一致しているAssocaitedDomainsがないとUniversalLinksは機能しなくなっていました。
ドキュメントの通り、ルートのドメインと遷移を許可したいサブドメインを指定していれば基本発生しない事象ですが、仮にiOS13まではルートのドメインのみAssociatedDomainsに指定してサブドメインから`apple-app-site-association`を取得していた場合、iOS14以降は機能しなくなる恐れがあリますので注意が必要です。

### 4.UniversalLinks起動時のハンドリングを実装

UniversalLinksの起動を許可するパスのURLを開いた時、AppDelegateの以下のDelegateメソッドが呼ばれます。

```swift

func application(
    _ application: NSApplication,
    continue userActivity: NSUserActivity,
    restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void
) -> Bool {

    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, // ブラウザからの遷移か確認
        let incomingURL = userActivity.webpageURL, // URLを取得
        let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true) else {
        return false
    }
　　
    return true
}
```

ここでtrueを返せばアプリが開きます。
受け取ったパスが遷移を許可したくないパスかどうか確認するにはuserActivityからURLを取得してそのURLを確認します。
URLにしてしまえばあとはComponentからパスを取得するなりパラメータを取得するなりなんなりできます。

https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app

ここまで対応すればUniversalLinksによるアプリの起動が可能になっていると思います。


## UniversalLinksの実装に役に立ったテクニック
### SimulatorでUniversalLinkの動作を確認する。
アプリをsimulatorにインストールした状態で以下のコマンドを実行します。
ブラウザで直接URLを書き込むことで確認もできますが、コマンド一つでPC上から確認できるので、こちらの方が確認が簡単でした。

```
xcrun simctl openurl booted {URL}
```

### AppleのCDNから取得できるapple-app-site-associationを確認する。
これは以下のエンドポイントに対してGETのリクエストを送ることで確認できます。

```
GET https://app-site-association.cdn-apple.com/a/v1/{確認したいdomain}
```

こちらを参考にさせていただき、実際にapple-app-site-associationが取得できることを確認しました。

https://medium.com/zrealm-ios-dev/universal-links-%E6%96%B0%E9%AE%AE%E4%BA%8B-12c5026da33d

※ヘッダーに`HOST=app-site-association.cdn-apple.com`の指定が必要とのことですが、なしでも取得できました。

## CharlesやProxyManでapple-app-site-associationの取得をキャプチャする際の注意

**AppleのCDNに対するリクエストはCharlesなどの通信デバッグツールを噛ませるとapple-app-site-associationの取得に失敗します。**

simulatorやデバイスに対して、CharlesのRoot証明書を信頼させていてもこれは不可能でした。。。
僕はAppleのCDN経由からapple-app-site-associationを確認したく、何度もCharlesを噛ませながらリクエストを送っていたんですが、この時間は徒労に終わってしまいました。
(もし同様に通信デバッグツールを使ってもCDNからのapple-app-site-associationをキャプチャできた方がいましたら環境や設定など教えていただけると嬉しいです。)

## まとめ
Universal Links対応は開発の作業自体は少ないものの、サーバーチームとの連携、概念の理解、OSごとの対応手順差分・挙動差分などなど、ハマるポイントが結構あると思いました。
今後UniversalLinksに対応する方が僕と同じようなポイントでハマってしまわないようにこの記事がお役に立てば幸いです。