【Flutter】Firebase Dynamic LinksからApp Links/Universal Linksへの置き換え攻略ガイド

:::note info
**Firebase Dynamic Linksの廃止が予定**されているため、Flutterアプリでのディープリンク機能を **App Links（Android）**/**Universal Links（iOS)** に変更する方法を解説します。
※ここではFlutterの`app_links`パッケージを使用します。
:::

※この記事は2024年9月24日時点での情報をもとに作成されています。

speakerDeckでも似たようなの出してるのでぜひ参考にしてください。
https://speakerdeck.com/asa012/universal-linksnizhi-kihuan-etemita

## なぜFlutterでDynamicLinkを置き換えるのか？

**Firebase Dynamic Linksが2025年8月25日に廃止される予定**であるため、代替手段としてApp Links/Universal Links、カスタムURLスキームなどへの移行が必要となります。

> カスタム URL スキームはディープ リンクの許容可能な形式ですが、ユニバーサル リンクを強くお勧めします。ユニバーサル リンクの詳細については、「アプリと Web サイトがコンテンツにリンクできるようにする」を参照してください。

カスタムURLスキームについてはAppleのドキュメントにある通り推奨されていないので、App Links/Universal Linksを用いたやり方を説明します。

https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app

また、従来使用されていた`uni_links`パッケージですが、`pub.dev`では「**discontinued（中止）**」と記載されており、代替として`app_links`パッケージを推奨しているのでこれを使います。

> **参考**: Firebase Dynamic Linksの廃止に関する詳細は、[Firebase公式アナウンス](https://firebase.google.com/docs/dynamic-links/deprecation)

### 独自ドメインを使用する場合
<details><summary>独自ドメインを使用する場合について</summary>

**独自のドメインをお持ちで、そのドメイン上に必要なファイルを配置できる場合、Firebase Hostingを使用せずにApp LinksやUniversal Linksを設定することが可能**です。その場合、以下の手順は不要になります。

- **Firebase CLIのインストールとプロジェクトの初期化**: 不要
- **GitHub ActionsでFirebase Hostingへのデプロイを設定**: 不要
- **Firebase Hostingの設定ファイル更新**: 不要

</details>

本記事では、**Firebase Hostingを使用する方法**を中心に、**使用しない方法**でも解説しています。

### DynamicLinkからApp Links（Android）/Universal Links（iOS)への移行例
<details><summary>移行例はこちら</summary>

| 項目                 | 値                                                                                                                                     |
|:--------------------|:---------------------------------------------------------------------------------------------------------------------------------------|
| **ディープリンク**   | [https://example.web.app/welcome](https://example.web.app/welcome)                                                                     |
| **Android アプリ**  | `com.example.android`                                                                                                                  |
| **Apple アプリ**    | `com.example.ios`                                                                                                                      |
| **長いダイナミックリンク** | [https://example.page.link/?link=https://example.web.app/welcome&apn=com.example.android&isi=123456789&ibi=com.example.ios](https://example.page.link/?link=https://example.web.app/welcome&apn=com.example.android&isi=123456789&ibi=com.example.ios) |
| **短いダイナミックリンク** | [https://example.page.link/m9Mm](https://example.page.link/m9Mm)                                                                     |

↓

| 項目                     | 値                                                                                      |
|:------------------------|:----------------------------------------------------------------------------------------|
| **新しいディープリンク** | [https://your-project-domain.web.app/welcome](https://your-project-domain.web.app/welcome) |
| **Android アプリ**      | `com.example.android`                                                                    |
| **Apple アプリ**        | `com.example.ios`                                                                       |

</details>


## 実現したいこと

App LinksとUniversal Linksを使用して、
**アプリがインストールされている場合に特定のURLを踏むとアプリ内の特定の画面に遷移**するようにします。

Firebase Dynamic Linksのように、アプリがインストールされていない場合にストアに遷移する機能はありません。そのため、その場合は別途対応が必要です。

### アプリ未インストール時の対応方法の例
<details><summary>アプリ未インストール時の対応方法の例はこちら</summary>

アプリがインストールされていない場合にユーザーを適切に誘導するための方法として、以下のような対応が考えられます。

- **カスタムウェブページの作成**: リンク先をウェブページに設定し、そのページでユーザーの環境を判定して適切なストアへのリンクを表示します。
- **Deferred Deep Linkingの利用**: 一部のサードパーティサービスでは、アプリ未インストール時にストアへの遷移とインストール後のディープリンクを実現する機能を提供しています。
- **Firebase Dynamic Linksの代替サービスを利用**: Firebase Dynamic Linksが廃止されるため、他のディープリンクサービス（例: Branch.io、Adjustなど）を利用することも検討できます。
</details>

## 前提条件

- **npmのインストール**: Firebase CLIのインストールに必要です。
- **Flutter開発環境の構築**: Flutter SDK、Dart SDK、各プラットフォームのセットアップ。
- **Firebaseプロジェクトの設定**: Firebaseコンソールでプロジェクトを作成済みであること。
- **GitHubリポジトリの用意**: コードはGitHubで管理し、Pull Request（プルリクエスト）でプレビューが自動デプロイされる環境。
- **デプロイフローの策定**: メインブランチへのマージ後、**GitHub Actionsを使用して自動的に本番環境へデプロイ**するフロー、または手動でデプロイするフローを採用。

## 動作確認方法
<details><summary>動作確認方法はこちら</summary>

### iOS

シミュレータで以下のコマンドを実行します。

```shell
/usr/bin/xcrun simctl openurl booted "https://your.domain.com/yourpath?param=value"
```

- **ポイント**:

  - Universal Linksをテストするために、シミュレータにリンクを送信します。

  - **クエリパラメータ付きのURL**もテストし、アプリが正しく遷移するか確認します。

  - アプリがフォアグラウンド、バックグラウンド、未起動の各状態でテストします。

### Android

以下のコマンドを実行します。

```shell
adb shell am start -a android.intent.action.VIEW -d "https://your.domain.com/yourpath?param=value"
```

- **ポイント**:

  - ADBコマンドでリンクをシミュレートします。

  - **クエリパラメータ付きのURL**もテストし、アプリが正しく遷移するか確認します。

  - アプリの状態に応じて挙動が異なるため、各状態でテストします。

### メモ帳アプリを使用したデバッグ方法（Android）

Android端末でApp Linksをテストする際、メモ帳アプリを使用してリンクをタップする方法があります。

#### 手順

1. **テストしたいリンクをコピー**

   - テストしたいURL（例: `https://your.domain.com/yourpath?param=value`）をコピーします。

2. **メモ帳アプリを開く**

   - Android端末にデフォルトでインストールされているメモ帳アプリ（または任意のテキストエディタ）を開きます。

3. **リンクを貼り付け**

   - 新しいメモを作成し、コピーしたリンクを貼り付けます。

4. **リンクをタップ**

   - 貼り付けたリンクをタップします。

5. **アプリの挙動を確認**

   - 正しく設定されていれば、アプリが起動し、指定された画面に遷移します。

#### ポイント

- **ブラウザを経由しないため、App Linksの動作を正確にテストできる**
- **ユーザーが他のアプリからリンクをタップした際の挙動を再現可能**

</details>

## 手順
### 1. Firebase Hostingを使用する場合の設定手順
<details><summary>Firebase Hostingを使用する場合の設定手順はこちら</summary>

#### Firebase CLIのインストール

まず、Firebase CLIをローカルのPCにインストールします。

```shell
npm install -g firebase-tools
```

#### プロジェクトの初期化

Firebase Hostingを利用するために、プロジェクトディレクトリを作成し、そこでFirebaseの初期化を行います。

```shell
mkdir your_project_name
cd your_project_name
firebase init hosting
```

初めて`firebase init hosting`を実行すると、ターミナルにいくつかの英語のプロンプトが表示されます。以下、具体的なプロンプトとその対応方法を説明します。

1. **Firebase CLIへようこそ**

   ```plaintext
   === Hosting Setup
   ```

2. **既存のプロジェクトを使用する**

   ```plaintext
   Please select an option:
   > Use an existing project
     Create a new project
     Add Firebase to an existing Google Cloud Platform project
   ```

   **対応**: 矢印キーで「Use an existing project」を選択し、Enterキーを押します。

3. **Firebaseプロジェクトを選択**

   ```plaintext
   Select a default Firebase project for this directory:
   > your-firebase-project-id (Your Firebase Project)
   ```

   **対応**: 使用したいFirebaseプロジェクトを選択してEnterキーを押します。

   > **トラブルシューティング**: プロジェクトIDが表示されない場合は、後述の「トラブルシューティング」を参照してください。


4. **公開ディレクトリの設定**

   ```plaintext
   What do you want to use as your public directory? (public)
   ```

   **対応**: デフォルトの`public`を使用する場合はEnterキーを押します。カスタムのディレクトリを使用したい場合はディレクトリ名を入力します。


5. **シングルページアプリケーションかどうか**

   ```plaintext
   Configure as a single-page app (rewrite all urls to /index.html)? (y/N)
   ```

   **対応**:

   - **判断基準**:
     - **シングルページアプリケーション（SPA）の場合**: React、Vue.js、Angular、Flutter Webなどのフロントエンドフレームワークを使用しており、ルーティングをクライアントサイドで処理している場合。
     - **マルチページアプリケーション（MPA）の場合**: 各ページが独立しており、サーバーサイドでルーティングを処理している場合。

   - **対応方法**:
     - SPAであれば`y`を入力してEnterキーを押します。
     - MPAであれば`N`を入力するか、そのままEnterキーを押します（デフォルトで`N`）。

6. **自動的にファイルを上書きするか**

   ```plaintext
   File public/index.html already exists. Overwrite? (y/N)
   ```

   **対応**:

   - **上書きしたい場合**: `y`を入力してEnterキーを押します。
   - **上書きしたくない場合**: `N`を入力するか、そのままEnterキーを押します。

   ※ 初期状態では`public`ディレクトリが存在しないため、このプロンプトは表示されない場合があります。

7. **完了メッセージ**

   ```plaintext
   ✔  Firebase initialization complete!
   ```

#### GitHubへのコードのアップロード

1. **GitHubリポジトリの作成**: GitHub上で新しいリポジトリを作成します。

2. **ローカルリポジトリの初期化**:

   ```shell
   git init
   git remote add origin https://github.com/your_username/your_repository.git
   ```

3. **ファイルのコミットとプッシュ**:

   ```shell
   git add .
   git commit -m "Initial commit"
   git push -u origin main
   ```

#### Firebase認証情報の設定（FIREBASE_TOKEN）

Firebase CLIがFirebaseプロジェクトにアクセスするために**`FIREBASE_TOKEN`**を設定する必要があります。

##### FIREBASE_TOKENの取得と設定

1. **Firebase CLIでトークンを取得**

   ```shell
   firebase login:ci
   ```

   このコマンドを実行すると、ブラウザが開き、Firebaseへのログインを求められます。ログイン後、ターミナルにトークンが表示されます。

   ```plaintext
   ✔  Success! Use this token to login on a CI server:

   1//0d-ExampleFirebaseToken
   ```

2. **GitHubリポジトリのシークレットに追加**

   GitHubリポジトリの「Settings」→「Secrets and variables」→「Actions」で、新しいシークレットを追加します。名前は`FIREBASE_TOKEN`とします。

   - シークレット名: `FIREBASE_TOKEN`
   - 値: 先ほど取得したトークン（例: `1//0d-ExampleFirebaseToken`）
</details>

### 2. 必要なファイルの配置
**※アプリ側ではなくFirebase Hostingの設定です。**

<details><summary>必要なファイルの配置はこちら</summary>

Firebase Hosting、独自のウェブサーバーやホスティングサービスを使用して、 `assetlinks.json` （Android用）や `apple-app-site-association` （iOS用）ファイルを適切な場所に配置します。

#### Androidの場合

- `assetlinks.json`を以下のパスに配置します。

  ```
  https://your.domain.com/.well-known/assetlinks.json
  ```

#### iOSの場合

- `apple-app-site-association`を以下のパスに配置します。

  ```
  https://your.domain.com/.well-known/apple-app-site-association
  ```
 

- **注意**: `apple-app-site-association`ファイルには拡張子がありません。
- また、`Content-Type`を`application/json`に設定する必要があります。

</details>

### 3. ウェブサーバーの設定（Firebase Hostingを使用しない場合）
<details><summary>ウェブサーバーの設定はこちら</summary>

- **正しいContent-Typeの設定**: サーバーが`assetlinks.json`や`apple-app-site-association`を提供する際に、適切な`Content-Type`ヘッダーを設定する必要があります。

  - `assetlinks.json`: `application/json`
  - `apple-app-site-association`: `application/json`（ただし、ファイル名に拡張子がないため、サーバーの設定が必要な場合があります）

- **HTTPSでの配信**: App LinksとUniversal Linksは、HTTPSプロトコルでの通信が必要です。SSL証明書を設定し、ドメインでHTTPSが有効になっていることを確認してください。

</details>

### 4. GitHub ActionsでFirebase Hostingへのデプロイを設定（Firebase Hostingを使用する場合）
**※アプリ側ではなくFirebase Hostingの設定です。**
<details><summary>Firebase Hostingへのデプロイ方法はこちら</summary>

**Firebase Hostingを使用しない場合、このセクションは不要です。**
※ただしデプロイする必要はあるので忘れないようにしましょう。

#### デプロイ方法の選択

Firebase Hostingへのデプロイには、以下の2つの方法があります。プロジェクトの運用フローに合わせて選択してください。

1. **メインブランチへのマージ時に自動デプロイ**: 開発フローで`develop`ブランチなどを使用し、`main`ブランチが常に本番環境と同期している場合に適しています。

2. **手動でデプロイ**: 本番環境へのデプロイをより慎重に行いたい場合や、特定のタイミングでのみデプロイしたい場合に適しています。

#### 方法1: メインブランチへのマージ時に自動デプロイ

メインブランチにコードがマージされたとき、自動的にFirebase HostingへデプロイするGitHub Actionsのワークフローを設定します。

1. **`firebase-hosting-merge.yml`ファイルの作成**

   `.github/workflows/firebase-hosting-merge.yml`を作成し、以下の内容を記述します。

   ```yaml
   name: Deploy to Firebase Hosting on merge

   on:
     push:
       branches:
         - main

   jobs:
     build_and_deploy:
       runs-on: ubuntu-latest

       steps:
       - name: Checkout the repository
         uses: actions/checkout@v2

       - name: Install Node.js
         uses: actions/setup-node@v2
         with:
           node-version: '20'

       - name: Install Firebase CLI
         run: npm install -g firebase-tools

       - name: Deploy to Firebase Hosting
         run: firebase deploy --only hosting --project your-firebase-project-id
         env:
           FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
   ```

   - **ポイント**:

     - **`on`セクションで`main`ブランチへのプッシュをトリガー**しています。
     - **`your-firebase-project-id`**はFirebaseプロジェクトのIDに置き換えてください。
     - **`FIREBASE_TOKEN`**はGitHubシークレットに設定したFirebaseのトークンです。

2. **ワークフローのコミットとプッシュ**

   ```shell
   git add .github/workflows/firebase-hosting-merge.yml
   git commit -m "Add deployment workflow for main branch"
   git push
   ```

#### 方法2: 手動でデプロイ

必要なタイミングで手動でデプロイを実行するGitHub Actionsのワークフローを設定します。

1. **`firebase-hosting-manual.yml`ファイルの作成**

   `.github/workflows/firebase-hosting-manual.yml`を作成し、以下の内容を記述します。

   ```yaml
   name: Manual Deploy to Production

   on:
     workflow_dispatch:

   jobs:
     build_and_deploy:
       runs-on: ubuntu-latest

       steps:
       - name: Checkout the repository
         uses: actions/checkout@v2

       - name: Install Node.js
         uses: actions/setup-node@v2
         with:
           node-version: '20'

       - name: Install Firebase CLI
         run: npm install -g firebase-tools

       - name: Deploy to Firebase Hosting
         run: firebase deploy --only hosting --project your-firebase-project-id
         env:
           FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
   ```

   - **ポイント**:

     - **`on`セクションで`workflow_dispatch`を指定**し、手動でワークフローをトリガーできるようにしています。
     - **GitHubリポジトリのActionsタブ**から、このワークフローを選択し、手動で実行できます。

2. **ワークフローのコミットとプッシュ**

   ```shell
   git add .github/workflows/firebase-hosting-manual.yml
   git commit -m "Add manual deployment workflow"
   git push
   ```
</details>

### 5. Androidの設定
**※アプリ側ではなくFirebase Hostingの設定です。**
<details><summary>Androidの設定はこちら</summary>

#### `assetlinks.json`の作成

`assetlinks.json`を以下の内容で作成します。

```json
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "your.package.name",
      "sha256_cert_fingerprints": ["YOUR_SHA256_CERT_FINGERPRINT"]
    }
  }
]
```

- **注意点**:

  - **`namespace`は必ず`"android_app"`と記述**します。**アプリ名ではありません**。

- **`package_name`**: アプリのパッケージ名（例: `com.example.app`）

- **`sha256_cert_fingerprints`**: アプリのSHA256証明書フィンガープリント

##### SHA256フィンガープリントの取得方法

1. **デバッグ用キーストアの場所**

   デフォルトのデバッグ用キーストアは以下の場所にあります。

   - macOS/Linux: `~/.android/debug.keystore`
   - Windows: `C:\Users\your_username\.android\debug.keystore`

2. **SHA256フィンガープリントの取得コマンド**

   ```shell
   keytool -list -v -alias androiddebugkey -keystore ~/.android/debug.keystore -storepass android -keypass android
   ```

   - **`-alias`**: デバッグキーのエイリアスは通常`androiddebugkey`
   - **`-storepass`と`-keypass`**: デフォルトのパスワードは`android`

3. **本番環境でのSHA256フィンガープリント**

   - **リリース用キーストア**を使用している場合は、リリース用キーストアファイルのパスとパスワードを指定します。

     ```shell
     keytool -list -v -keystore your_release_keystore.jks -alias your_alias -storepass your_storepass
     ```

   - **Google Play App Signing**を使用している場合:

     - **Google Play Console**でSHA256証明書フィンガープリントを取得できます。

     - 手順:

       1. Google Play Consoleにログイン

       2. 対象のアプリを選択

       3. 左側のメニューから「リリース」→「セットアップ」→「アプリの署名」を選択

       4. 「アプリの署名証明書」または「アップロード証明書」のSHA256フィンガープリントを確認

     - **注意**: 本番環境では、デバッグ用のSHA256ではなく、**リリース用のSHA256**を使用してください。

4. **出力からSHA256フィンガープリントを確認**

   ```plaintext
   Certificate fingerprints:
            SHA1:  ...
            SHA256:  01:23:45:67:89:AB:CD:EF:...
   ```

   **SHA256**の値を`assetlinks.json`に使用します。

#### 設定ファイル更新

`firebase.json`に以下を追加し、`assetlinks.json`が正しいContent-Typeで提供されるようにします。

```json
{
  "hosting": {
    // 既存の設定
    "headers": [
      {
        "source": "/.well-known/assetlinks.json",
        "headers": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ]
      }
    ]
  }
}
```

#### デプロイ

GitHubにコードをプッシュし、プルリクエストを作成します。マージ後、**GitHub Actionsが自動的にデプロイ**を行います。

</details>

### 6. iOSの設定
**※アプリ側ではなくFirebase Hostingの設定です。**

<details><summary>iOSの設定はこちら</summary>

#### `apple-app-site-association`の作成

`apple-app-site-association`を以下の内容で作成します。
※公式には違う形式のものが書かれていましたが、それではうまくいかなかったのでこれでやってみてください。無理なら公式のものでいけるかもしれません。

```json
{
  "applinks": {
      "details": [
           {
             "appIDs": [ "{TeamID}.{package名}" ],
             "components": [
               {
                  "#": "no_universal_links",
                  "exclude": true,
                  "comment": "このフラグメントが 'no_universal_links' のURLは、Universal Linkとして開かれません。"
               },
               {
                  "/": "/abcd/*",
                  "comment": "このパターンは、'/abcd/'で始まるURLにマッチします。"
               }
             ]
           }
       ]
   },
   "webcredentials": {
      "apps": [ "{TeamID}.{package名}" ]
   },
   "appclips": {
        "apps": ["{TeamID}.{package名}"]
    }
}
```

- **`TEAMID`**: Apple DeveloperのチームID

- **`your.bundle.id`**: アプリのバンドルID（例: `com.example.app`）

[公式のやり方はこちら](https://firebase.google.com/support/guides/app-links-universal-links?hl=ja)

##### TEAMID（チームID）の確認方法

1. **Apple Developerのウェブサイトにログイン**

   - URL: [https://developer.apple.com/account/](https://developer.apple.com/account/)

2. **上部メニューの「Membership」をクリック**

   - 「Membership Details」ページが表示されます。

3. **「Team ID」を確認**

   - 「Membership Information」セクションに「Team ID」が記載されています。

   - 例: `1A2B3C4D5E`

#### 設定ファイル更新

`firebase.json`に以下を追加し、`apple-app-site-association`が正しいContent-Typeで提供されるようにします。

```json
{
  "hosting": {
    // 既存の設定
    "headers": [
      {
        "source": "/.well-known/apple-app-site-association",
        "headers": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ]
      }
    ]
  }
}
```

#### デプロイ

GitHubにコードをプッシュし、プルリクエストを作成します。マージ後、**GitHub Actionsが自動的にデプロイ**を行います。
</details>

### 7. Flutterアプリ側での受け取り処理の実装（`app_links`パッケージ）
<details><summary>Flutterアプリ側での受け取り処理の実装（app_linksパッケージ）はこちら</summary>

#### Flutterアプリでの設定
<details><summary>Flutter側の設定はこちら</summary>

##### 1.パッケージのインストール

`pubspec.yaml`に以下を追加し、`app_links`パッケージ（バージョン **6.3.2**）をインストールします。

```yaml
dependencies:
  app_links: ^6.3.2
```

```shell
fvm flutter pub get
```

##### 2. **ディープリンクの受け取り設定**
`app_links`パッケージを使用して、ディープリンクを受け取るための設定を行います。

```dart
import 'package:flutter/material.dart';
import 'package:app_links/app_links.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late final AppLinks _appLinks;
  Uri? _latestUri;

  @override
  void initState() {
    super.initState();
    _appLinks = AppLinks();

    // 初期リンクの取得
    _initDeepLink();

    // リンクストリームのリスナー
    _appLinks.uriLinkStream.listen((Uri? uri) {
      if (!mounted) return;
      setState(() {
        _latestUri = uri;
      });
      // ここでNavigatorを使用して画面遷移などを行う
    });
  }

  Future<void> _initDeepLink() async {
    try {
      final Uri? initialUri = await _appLinks.getInitialAppLink();
      if (initialUri != null) {
        setState(() {
          _latestUri = initialUri;
        });
        // ここでNavigatorを使用して画面遷移などを行う
      }
    } on Exception catch (e) {
      print('Failed to get initial link: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    // _latestUriを使用して適切な画面を表示
    return MaterialApp(
      onGenerateRoute: (settings) {
        final uri = Uri.parse(settings.name ?? '');
        switch (uri.path) {
          case '/yourpath':
            final param = uri.queryParameters['param'];
            return MaterialPageRoute(
              builder: (_) => YourScreen(param: param),
            );
          // 他のパスの処理
          default:
            return MaterialPageRoute(
              builder: (_) => NotFoundScreen(),
            );
        }
      },
      home: Scaffold(
        body: Center(
          child: Text('Deep Link URI: $_latestUri'),
        ),
      ),
    );
  }
}
```

- **ポイント**:

  - `AppLinks`を使用して、アプリが起動されたときやバックグラウンドから復帰したときにリンクを受け取ります。
  - 受け取ったリンクに応じて、`Navigator`やルーティングパッケージを使用して画面遷移を行います。
</details>

#### iOSの設定
<details><summary>iOSの設定はこちら</summary>

##### Flutterのデフォルトのディープリンクを無効化
`ios/Runner/Info.plist`に以下を追加します。

```xml
<key>FlutterDeepLinkingEnabled</key>
<false/>
```

##### (必要あれば)Deployment TargetをiOS13以上にする

iOS 13以降では、**SceneDelegate**が導入され、アプリのライフサイクル管理が変更されました。
最低対応バージョン（Deployment Target）をiOS 13以上であれば分岐を機にする必要がないのでできるだけ新しいDeployment Targetに設定することをおすすめします。

※ユーザーがユニバーサル リンクをクリックした時点ですでにアプリが開いている場合、SceneDelegate 内のユニバーサル リンク コールバックが呼び出されます。

  **最低対応バージョンの設定方法**:

  1. **Xcodeでプロジェクトを開く**: `ios/Runner.xcworkspace`を開きます。

  2. **プロジェクト設定の編集**: 左側のプロジェクトナビゲーターで`Runner`を選択し、`TARGETS`の`Runner`を選択します。

  3. **「General」タブの「Deployment Info」セクション**で、「Deployment Target」を`iOS 13.0`以上に設定します。

##### **AppDelegate.swiftとSceneDelegate.swiftの編集**

  **AppDelegate.swift**:

  ```swift
  import UIKit
  import Flutter

  @UIApplicationMain
  @objc class AppDelegate: FlutterAppDelegate {
    override func application(
      _ application: UIApplication,
      didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
      GeneratedPluginRegistrant.register(with: self)
      return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
  }
  ```

  **SceneDelegate.swift**:

  ```swift
  import UIKit
  import Flutter
  import app_links

  class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
      guard let windowScene = scene as? UIWindowScene else { return }

      let flutterEngine = (UIApplication.shared.delegate as! FlutterAppDelegate).flutterEngine
      let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)

      window = UIWindow(windowScene: windowScene)
      window?.rootViewController = flutterViewController
      window?.makeKeyAndVisible()

      // 起動時のディープリンクを処理
      if let userActivity = connectionOptions.userActivities.first {
        handleUserActivity(userActivity)
      }
    }

    func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
      // バックグラウンドからのディープリンクを処理
      handleUserActivity(userActivity)
    }

    private func handleUserActivity(_ userActivity: NSUserActivity) {
      if let url = userActivity.webpageURL {
        AppLinks.shared.handleLink(url: url)
      }
    }
  }
  ```

- **ポイント**:

  - `SceneDelegate`を使用することで、iOS 13以降のライフサイクルイベントに対応します。
  - `AppLinks.shared.handleLink(url:)`を使用して、受け取ったURLをFlutter側に渡します。

##### **Associated DomainsのCapabilitiesを追加**

CapabilitesにAssociated Domainsを追加してください。

iOS 13までは、ルートドメイン（例: `example.com`）のみを`Associated Domains`に指定していれば、サブドメイン（例: `www.example.com`）でもUniversal Linksが機能していました。しかし、**iOS 14以降では、サブドメインごとに正確に指定する必要**があります。

**設定手順**

1. **Associated DomainsのCapabilitiesを追加**

   Xcodeの`Capabilities`タブで、`Associated Domains`を有効にしてください。

2. **Associated Domainsにサブドメインを追加**

   iOS 14以降では、サブドメイン（例: `www.example.com`）を個別に指定する必要があります。以下のように、各ドメインを正確に追加します。

   ```plaintext
   applinks:example.com
   applinks:www.example.com
   ```

   特に、`www`などのサブドメインを含む場合、iOS 14以降ではこれを忘れるとUniversal Linksが機能しなくなります。

3. **webcredentialsの設定**

   Webのログイン認証に`webcredentials`を使用する場合も、同様に正確なドメインを指定します。

   ```plaintext
   webcredentials:example.com
   webcredentials:www.example.com
   ```

---

参考: [Apple公式ドキュメント](https://developer.apple.com/documentation/xcode/supporting-associated-domains)

###### iOS 14以降のUniversal Linksにおける`apple-app-site-association`取得の注意点

iOS 13までは、アプリは直接ドメインの`.well-known`パスから`apple-app-site-association`（AASA）ファイルを取得していました。しかし、**iOS 14からは、AppleのCDNを経由して取得するように変更**されました。この変更により、特定の条件下でUniversal Linksが機能しない問題が発生することがあります。

**例**
- **IP制限があるStaging環境**：AppleのCDNがアクセスできないと、AASAファイルを取得できず、Universal Linksが動作しません

この問題を回避するために、`Associated Domains`設定で**`mode=developer`**を指定することで、開発端末ではAppleのCDNを経由せずに直接ドメインからAASAファイルを取得することが可能です。

```plaintext
applinks:example.com?mode=developer
```

`mode=developer`を使う場合の注意点は以下の通りです。

- このモードは、**開発者の端末でのみ有効**です。
- ドキュメントには、**使用するデバイスで開発者モード上でNetworkingのAssociated Domains Developmentの設定をONにする**必要があると記載されています。

これにより、Staging環境でIP制限があり、AppleのCDN経由ではUniversal Linksがテストできない場合、この設定を使うことで、**Staging環境でも直接ドメインからAASAファイルを取得してテストが可能**になります。
</details>

#### Androidの設定
<details><summary>Androidの設定はこちら</summary>

- **Flutterのデフォルトのディープリンクを無効化**: `android/app/src/main/AndroidManifest.xml`で以下を追加します。

```xml
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
```

- **`MainActivity.java`または`MainActivity.kt`の編集**:

```kotlin
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import com.llfbandit.app_links.AppLinksPlugin

class MainActivity: FlutterActivity() {
  override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    // Intentからリンクを取得してハンドリング
    AppLinksPlugin.Companion.getInstance().handleIntent(intent)
  }
}
```

- **インテントフィルタの追加**: `AndroidManifest.xml`内の`<activity>`タグに以下を追加します。

```xml
<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>
    <data android:scheme="https"
          android:host="your.domain.com"
          android:pathPrefix="/yourpath"/>
</intent-filter>
```

- **ポイント**:

  - **`android:autoVerify="true"`**を設定することで、リンクの自動検証が有効化します。

  - **`android:host`**と**`android:pathPrefix`**で受け取るリンクのパターンを指定します。

  - **`onNewIntent`**メソッドでリンクをハンドリングします。

- **`android:autoVerify="true"`がない場合の影響**

  - **問題点**:

    - **自動的なリンクの検証が行われない**: アプリのインストール時にリンクの所有権が検証されず、信頼性が低下します。

    - **ユーザーにアプリ選択ダイアログが表示される**: リンクをタップした際に、どのアプリで開くかをユーザーに選択させるダイアログが表示されます。

    - **アプリがデフォルトで起動しない**: 他のアプリ（ブラウザなど）が優先され、期待した動作にならないことがあります。

  - **結論**: `android:autoVerify="true"`を設定することで、アプリリンクの信頼性とユーザーエクスペリエンスを向上させることができます。

- **`pathPrefix`の指定について**

  - **問題点**: `android:pathPrefix`を指定しないと、App Linksが正しく動作しない場合があります。

  - **複数のパスに対応する方法**:

    - **同じ`intent-filter`内で複数の`<data>`タグを使用**:

      ```xml
      <intent-filter android:autoVerify="true">
          <action android:name="android.intent.action.VIEW"/>
          <category android:name="android.intent.category.DEFAULT"/>
          <category android:name="android.intent.category.BROWSABLE"/>
          <data android:scheme="https"
                android:host="your.domain.com"
                android:pathPrefix="/path1"/>
          <data android:scheme="https"
                android:host="your.domain.com"
                android:pathPrefix="/path2"/>
          <data android:scheme="https"
                android:host="your.domain.com"
                android:pathPrefix="/path3"/>
      </intent-filter>
      ```

    - **複数の`intent-filter`を使用**:

      ```xml
      <intent-filter android:autoVerify="true">
          <action android:name="android.intent.action.VIEW"/>
          <category android:name="android.intent.category.DEFAULT"/>
          <category android:name="android.intent.category.BROWSABLE"/>
          <data android:scheme="https"
                android:host="your.domain.com"
                android:pathPrefix="/path1"/>
      </intent-filter>
      <intent-filter android:autoVerify="true">
          <action android:name="android.intent.action.VIEW"/>
          <category android:name="android.intent.category.DEFAULT"/>
          <category android:name="android.intent.category.BROWSABLE"/>
          <data android:scheme="https"
                android:host="your.domain.com"
                android:pathPrefix="/path2"/>
      </intent-filter>
      ```

  - **ポイント**:

    - **同じ`intent-filter`内で複数のパスを指定**することで、簡潔に複数のパスに対応できます。

    - **異なる`intent-filter`を使用**すると、各パスごとに異なる設定や処理を行うことが可能です。

  - **どちらを使うべきか**:

    - **簡単に複数のパスをサポートしたい場合**: 同じ`intent-filter`内で複数の`<data>`タグを使用する方法が便利です。

    - **詳細な制御が必要な場合**: 複数の`intent-filter`を使用して、各パスごとに個別の設定を行います。

- **minSdkVersionの設定**

  一部のディープリンク機能は特定のAPIレベル以上でサポートされています。必要に応じて、`android/app/build.gradle`で`minSdkVersion`を変更します。

  ```gradle
  defaultConfig {
      // 他の設定
      minSdkVersion 21 // 必要に応じて変更
  }
  ```

#### ProGuardの設定

- **ProGuardを使用している場合**、`proguard-rules.pro`に以下を追加します。

```
-keep class com.llfbandit.app_links.** { *; }
```

- **ポイント**:

  - `app_links`パッケージのクラスが削除されないようにします。

</details>
</details>

### 8. 【任意】 環境ごとの設定（本番環境と開発環境）
<details><summary>環境ごとの設定（本番環境と開発環境）はこちら</summary>

**本番環境と開発環境を分けて設定する**ことが一般的です。ここでは、環境ごとに設定を分ける方法を説明します。

#### 8-1. **Firebaseプロジェクトを分ける**

- **本番環境用**と**開発環境用**でFirebaseプロジェクトを別に作成します。これにより、それぞれの環境でデータや設定を分離して管理できます。

#### 8-2. **異なるドメインを使用する**

- 本番環境には`your.domain.com`、開発環境には`dev.your.domain.com`といった具合にドメインを分けます。これにより、各環境でURLが異なるため、誤って本番環境にアクセスすることを防げます。

#### 8-3. **`assetlinks.json`や`apple-app-site-association`の内容を環境ごとに変更**

- 環境ごとに異なるアプリの**パッケージ名**や**バンドルID**を使用している場合、それに応じてこれらのファイルも変更する必要があるので忘れずに行いましょう。

#### 8-4. **GitHub Actionsで環境ごとにデプロイ先を分ける**

- ブランチ名やタグ名を使用して、どの環境にデプロイするかを制御します。例えば、`main`ブランチは本番環境に、`develop`ブランチは開発環境にデプロイするよう設定します。

</details>

### 9. 【実践】メアドログインで確認メールを送って特定の完了ページに遷移
<details><summary>ここまでで移行自体は完了していますが必要あればご覧ください</summary>

メアドでログインした時に確認メールを送信するFlutterのコードの1例は以下になります。
  ```dart
  // 認証メールを再送する
  Future<void> sendVerificationEmail() async {
    User? user = service.auth.currentUser;
    if(user!=null && !user.emailVerified){
      await user.sendEmailVerification();
    }
  }
  ```
  
この確認メールではメールに送る文章を変えることができませんが、URLはFirebase管理画面のFirebase Authenticationのページにて変更することができます。

![スクリーンショット 2024-09-23 23.03.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670224/11912aa7-3979-a75f-0404-071d44e09571.png)

それを今回設定したDeepLinkのURLに設定してください。
これにより、メールにそのリンクが送られます。

もしこのコードをタップして、アプリの完了しましたページに遷移できなかった場合は、URLが正しく受け取れていない可能性がありますので、デバッグして確認してみてください。

私の場合、例えばonGenerateRouteのsetting.nameの中にパスだけでなくクエリが入っているURLが渡されていて一致せずにハンドリングできてなかったです。
その辺も見てみるといいかもしれません。

※startWithにすることで対応しました

</details>

## トラブルシューティング

### 1. クエリパラメータ付きのURLで正しく遷移しない
<details><summary>クエリパラメータ付きのURLで正しく遷移しない場合の解決策</summary>

**問題点**:

- パスにクエリパラメータが付いている場合、ディープリンクが正しく処理されず、画面遷移が行われないことがあります。

**原因**:

- Flutterの`onGenerateRoute`で`settings.name`を使用してルーティングを行う際、`settings.name`にはパスだけでなくクエリパラメータも含まれるため、期待したルートとマッチしない可能性があります。

**解決策**:

1. **Uriを解析してパスとクエリを分離する**

   `onGenerateRoute`内で`settings.name`を`Uri.parse`を使って解析し、パスとクエリパラメータを取得します。

   ```dart
   Route<dynamic>? onGenerateRoute(RouteSettings settings) {
     final uri = Uri.parse(settings.name ?? '');

     switch (uri.path) {
       case '/yourpath':
         // クエリパラメータを取得
         final param = uri.queryParameters['param'];
         // 画面遷移を実行
         return MaterialPageRoute(
           builder: (_) => YourScreen(param: param),
         );
       // 他のパスの処理
       default:
         return MaterialPageRoute(
           builder: (_) => NotFoundScreen(),
         );
     }
   }
   ```

2. **正規表現を使用してパスをマッチング**

   クエリパラメータを無視してパスだけでルーティングを行う場合、正規表現を使用します。

   ```dart
   Route<dynamic>? onGenerateRoute(RouteSettings settings) {
     final uri = Uri.parse(settings.name ?? '');
     final path = uri.path;

     if (RegExp(r'^/yourpath$').hasMatch(path)) {
       // クエリパラメータを取得
       final param = uri.queryParameters['param'];
       // 画面遷移を実行
       return MaterialPageRoute(
         builder: (_) => YourScreen(param: param),
       );
     }

     // 他のパスの処理

     return MaterialPageRoute(
       builder: (_) => NotFoundScreen(),
     );
   }
   ```

**ポイント**:

- **`Uri.parse`を使用**することで、パスとクエリパラメータを簡単に取得できます。

- **`uri.queryParameters`**でクエリパラメータをマップとして取得できます。

- **パスのみでルーティング**を行い、クエリパラメータは別途処理することで、期待した画面遷移が可能になります。
</details>

### 2. ユーザーにアプリ選択ダイアログが表示されたり、デフォルトアプリになってくれない

`android:autoVerify="true"`がない可能性があります。

<details><summary>詳細</summary>

**問題点**:

- `android:autoVerify="true"`を指定しない場合、以下の問題が発生する可能性があります。

  - **自動的なリンクの検証が行われない**: アプリのインストール時にリンクの所有権が検証されず、信頼性が低下します。

  - **ユーザーにアプリ選択ダイアログが表示される**: リンクをタップした際に、どのアプリで開くかをユーザーに選択させるダイアログが表示されます。

  - **アプリがデフォルトで起動しない**: 他のアプリ（ブラウザなど）が優先され、期待した動作にならないことがあります。

**解決策**:

- **`android:autoVerify="true"`を設定する**

  ```xml
  <intent-filter android:autoVerify="true">
      <!-- インテントフィルタの内容 -->
  </intent-filter>
  ```

**ポイント**:

- **信頼性とユーザーエクスペリエンスの向上**: `android:autoVerify="true"`を設定することで、アプリリンクの検証が自動的に行われ、ユーザーはシームレスにアプリへ遷移できます。

</details>

### 3. URLを叩いても`application/json`が返ってこず正しく遷移しない
<details><summary>URLを叩いても`application/json`が返ってこず正しく遷移しない場合の解決策</summary>

`assetlinks.json`や`apple-app-site-association`にアクセスしたときに、`Content-Type`が`application/json`になっていない場合があります。これは、ファイルの拡張子がないためにサーバーが適切なMIMEタイプを設定できないことが原因です。

#### 解決策

Firebase Hostingでは、`firebase.json`の設定で`headers`を追加して対応できます。

```json
{
  "hosting": {
    // 既存の設定
    "headers": [
      {
        "source": "/.well-known/assetlinks.json",
        "headers": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ]
      },
      {
        "source": "/.well-known/apple-app-site-association",
        "headers": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ]
      }
    ]
  }
}
```

</details>

### 4. `firebase init`時にプロジェクトIDが表示されない
<details><summary>`firebase init`時にプロジェクトIDが表示されない場合の解決策</summary>

#### 原因と解決策

1. **Firebaseプロジェクトに`firebase:enabled`ラベルが付いていない**

   - **対策**:

     - Google Cloudコンソールのプロジェクトの**ラベル**ページで、プロジェクトに`firebase:enabled`というラベルが付いていることを確認します。

     - ラベルがない場合は、キーに`firebase`、値に`enabled`を設定してラベルを追加します。

2. **間違ったアカウントで`firebase login`している**

   - **対策**:

     - `firebase logout`を実行してログアウトします。

     - 正しいアカウントで`firebase login`を実行し、再度ログインします。

</details>

### 5. アプリを起動すると黒い画面になる
<details><summary>アプリを起動すると黒い画面になる場合の解決策</summary>

**原因**:

- `AppDelegate`や`SceneDelegate`のコードに問題がある場合、アプリが正しく起動せず、黒い画面のままになることがあります。

**対策**:

- **コードの確認**:

  - `AppDelegate.swift`と`SceneDelegate.swift`のコードが正しく実装されているか確認します。

  - 特に、`window`や`rootViewController`の設定が正しいかをチェックします。

- **iOSの最低対応バージョンをiOS 13以上に設定**

  - iOS 13未満のバージョンをサポートしない場合、`SceneDelegate`で分岐する必要がないので、コードの簡素化が可能です。

  - 前述の「iOSの設定」のセクションで、最低対応バージョンの設定方法を参照してください。

 - apple-app-site-associationをそれぞれ1つ1つ個別に設定すると直るかもしれない.

当初、公式に載ってるものをそのまま使っていましたが、以下の形式にしたら直ったので試してみてください。

```json
{
  "applinks": {
      "details": [
           {
             "appIDs": [ "{TeamID}.{package名}" ],
             "components": [
               {
                  "#": "no_universal_links",
                  "exclude": true,
                  "comment": "このフラグメントが 'no_universal_links' のURLは、Universal Linkとして開かれません。"
               },
               {
                  "/": "/abcd/*",
                  "comment": "このパターンは、'/abcd/'で始まるURLにマッチします。"
               }
             ]
           }
       ]
   },
   "webcredentials": {
      "apps": [ "{TeamID}.{package名}" ]
   },
   "appclips": {
        "apps": ["{TeamID}.{package名}"]
    }
}
```

## 注意点

- **バックグラウンドと未起動状態での挙動の違い**: アプリがバックグラウンドにある場合と、完全に終了している場合でリンクの受け取り方が異なります。両方のケースでテストを行い、適切にハンドリングできているか確認してください。

- **Flutterのデフォルトのディープリンクを無効化する理由**: `app_links`パッケージを使用する際、Flutterのデフォルトのディープリンクハンドラが干渉するため、無効化が必要です。

- **アプリがインストールされていない場合の対応**: App LinksとUniversal Linksでは、アプリがインストールされていない場合にストアへの遷移は行われません。そのため、前述の「アプリ未インストール時の対応方法」を参考に、適切な対応を検討してください。

- **プレビューURLへの対応**: プレビュー用のURLでもApp LinksとUniversal Linksが機能するように、アプリとウェブサーバーの設定を調整する必要があります。
</details>

## まとめ

カスタムURLスキーマの問題点を踏まえ、より安全で信頼性の高い**App Links**（Android）と**Universal Links**（iOS）を使用して、Firebase Dynamic Linksからの移行手順を解説しました。

**Firebase Hostingを使用する方法**と**使用しない方法**の両方を紹介し、独自ドメインをお持ちの場合はFirebase Hostingを使用せずに設定できることを説明しました。

また、`app_links`パッケージを使用することで、Flutterアプリでのディープリンク処理が可能になります。

Dynamic Linksの廃止に伴い、早めの移行を検討することをおすすめします。

## 参考資料

- [Firebase Dynamic Linksの廃止について](https://firebase.google.com/docs/dynamic-links/deprecation)
- [app_links パッケージ](https://pub.dev/packages/app_links)
- [Flutter公式: Universal Linksの設定方法](https://docs.flutter.dev/cookbook/navigation/set-up-universal-links)
- [App Links Assistant](https://developer.android.com/studio/write/app-link-indexing)
- [Supporting Associated Domains](https://developer.apple.com/documentation/safariservices/supporting_associated_domains)
- [Firebase公式ガイド](https://firebase.google.com/support/guides/app-links-universal-links?hl=ja)
- [Firebase HostingとGitHub Actionsの連携](https://firebase.google.com/docs/hosting/github-integration)
- [Android Developers: App Links](https://developer.android.com/training/app-links)
- [Apple Developer Documentation: Supporting Universal Links](https://developer.apple.com/documentation/xcode/allowing_apps_and_websites_to_link_to_your_content)
- [Let's Encrypt](https://letsencrypt.org/)
- [iOS 14 の Universal Links の変更点
](https://en-jp.wantedly.com/companies/wantedly/post_articles/305303)
---

**この記事は2024年9月24日時点の情報をもとに作成されています。**

**間違えや補足など、ご指摘ありましたらお気軽にコメントください。**