【Flutter】Android App Links(Deep Link)でWebURLからアプリを起動してみる（ローカル編）

# DeepLink機能作成に挑戦
モバイルアプリで、以下の流れを経験したこと、ありませんか？

アプリをインストール
↓
アプリを起動して、フォームからメールアドレスを入力して送信
↓
メールが届く
↓
**メール本文のリンクをタップしたら、アプリが起動**
↓
登録情報の入力画面が表示される

太字で記載した部分がDeepLinkに関係するものです。

今回はこの機能を実現してみたいと思います。
また私自身の端末がAndroid、ということもあり、iOS関連に関する作業は行っていません。

## ローカル編とリリース編
ボリュームが少し大きめのため、2本立てで進めます。
**まずはローカル編です。**
ローカルで起動したアプリケーションを使って、機能するように作業を進めます。

リリース編では、デプロイしたアプリケーション(apkファイル)を端末にインストールして起動したアプリで機能させる作業になります。

（誤解を招くかもしれないので記載しますが、Google Play Storeへのリリースは実施していません。ローカルに対してどういう単語を用いるか考えた結果、リリース、になりました）

## 作業の前提
以下ご経験のある方が進めやすい内容だと思います。
- Flutterのアプリ作成とFirebaseの利用（個人開発レベルでOK！）
- `AndroidManifest.xml`ファイルを修正

## 主な作業内容
ローカル環境でDeepLinkを実現するために主な作業は以下の通りです。
- Firebase Hostingのセットアップ(作業量の調整の関係でこちらで実施)
- AndroidManifest.xmlの修正
- アプリケーションのコードで、WebURLを受け取れる状態を作成

### Firebase Hostingのセットアップ
ローカルで動かすだけの場合、ここは必要ありません。
後編（リリース編）の内容とのバランスの関係で、プロジェクト作成をこのタイミングで実施します。

詳細は後編で実施しますが、アプリケーションが`https`のWebURLを受け付けられるよう、Firebase Hostingにデプロイします。

また、アプリケーションはAndroidデバイス上で動かすものなので、資材をビルドしてそれをデプロイするというよりは、
**WebURLとAndroidアプリケーションのMappingで必要な情報をデプロイする上で必要になります。**

では進めていきます。
Firebaseでプロジェクトを作成の上、Hostingの画面を開きます。
「始める」とあるので、クリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/7d902cde-f3d7-46a0-ac50-c6f363392459.png)

#### Firebase CLI のインストール
コマンドをコピーして、インストールします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/c8cab287-3990-4d43-b4f6-595313346112.png)

#### プロジェクトの初期化
こちらもコマンドを実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/9559459b-7fed-4045-a57b-ac91a4206a5b.png)

`firebase init`を実行すると、何点か聞かれるので回答します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/7d0395a5-bdd6-439e-9695-5932fce62f86.png)

どのプロジェクトを利用するか選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/251d1c29-f271-4c09-9598-fdfde83947b2.png)

Hostingの設定を行います。
最初のpublicですが、何も入力せずに、Enterを押せば、勝手に`public`が設定されます。
残り2項目は`N`を入力して進みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/8aa4627f-9c62-441c-84e2-b992fef210b2.png)

ここまで進めると`public`フォルダ生成されています。
この`public`フォルダはリリース編で動作確認する際に必要です。

### AndroidManifest.xmlの修正

https://firebase.google.com/support/guides/app-links-universal-links?hl=ja#update_your_android_app_and_application_code_to_receive_deep_links

上記記事の[ディープリンクを受け取るために Android アプリとアプリコードを更新する](https://firebase.google.com/support/guides/app-links-universal-links?hl=ja#update_your_android_app_and_application_code_to_receive_deep_links)を参考に記述を追加します。

以下内容の追記が必要です。リンクに記載されたコードとほぼ同じです。
```xml
<activity
    android:name=".MainActivity"
    android:exported="true">
    <!-- 略 -->
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </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" />
        <!-- local  -->
        <data
            android:scheme="http"
            android:host="local.com"
            android:pathPrefix="/register"
        />
    </intent-filter>
```
`intent-filter`が2箇所あります。
- 1つ目
DeepLink特有の話ではありませんが、端末上でHome画面（アプリ一覧）にアプリを表示する上で必要な記述です。
この記述がない場合、Home画面からアプリをタップして起動する、という当たり前のようにやっている方法でアプリ起動が出来なくなってしまいます。
- 2つ目
**こちらがDeepLinkに関係する内容です。**
    - `android:autoVerify="true"`：リンク先の文章にもある通りです。
        > また、android:autoVerify="true" 属性も指定する必要があります。これにより、アプリをこの特定の種類のリンクのデフォルト ハンドラに指定できます。
    - `android.intent.category.BROWSABLE`
        - 英単語から推測できますが、ブラウザから起動可能な状態にします。
    - `data`タグ
        - ここもリンク先の文章の通りです。私は値を少々修正しているので、その内容で一部上書きすると、以下の通りです。
            > このステップでは、local ドメインからのディープリンクの処理先として /register パス プレフィックスを含む MainActivity を指定します。
            
### アプリケーション側で、WebURLを受け取れる状態へ
アプリケーションがWeb URLを受け取るとき、
デバイス上のアプリケーション状態として、大きく2パターンに分かれます。

- Cold Start：アプリを終了した状態
- Hot Start / Warm Start：Cold Start以外の状態（※）

詳細は以下に記載されています。

https://developer.android.com/topic/performance/vitals/launch-time?hl=ja#startup-state

**ローカル環境は、Cold Startの動作確認はできません。**
（アプリが終了した時点で、接続が切れてしまう（`Lost Connection`状態になる）ため）
ローカルの動作確認では、Hot Start / Warm Startについて、想定する挙動になることを確認します。

Flutterの`lib/main.dart`でrunAppに設定しているWidgetに対して、WebURLのハンドリングを追加します。（※）

※以下の例でいえば、`TestingApp`ウィジェットのことです。

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello, world!'),
        ),
      ),
    );
  }
}
```

実装する前に、以下パッケージを追加します。

https://pub.dev/packages/app_links

アプリケーション起動時に以下の処理を行っています。（一部略）
先ほどの例で記載した`MyApp`が、私の場合は`TestingApp`になっています。

全体実装は、[こちら(app_local.dart)](https://gist.github.com/eno-conan/ee359e0fa7d67e1831e83b88549fcd6d)で公開しています。

```dart
/// アプリケーションのエントリーポイント
class TestingApp extends ConsumerStatefulWidget {
  const TestingApp({super.key});

  @override
  ConsumerState<TestingApp> createState() => _TestingAppState();
}

class _TestingAppState extends ConsumerState<TestingApp> {
  late AppLinks _appLinks;
  StreamSubscription<Uri>? _linkSubscription;

  final _router = GoRouter(
    initialLocation: MenuPage.routeName,
    routes: Routes.routeList,
    redirect: (context, state) {
      return null;
    },
  );

  @override
  void initState() {
    super.initState();
    initDeepLinks();
  }

  @override
  void dispose() {
    _linkSubscription?.cancel();
    super.dispose();
  }

  void openAppLink(Uri uri) {
    final path = uri.path.isEmpty ? '/' : uri.path;
    if (kDebugMode) {
      print('Opening deep link path: $path');
    }

    // 会員登録画面へ(トークン情報を渡す)
    if (path == '/register') {
      final token = uri.queryParameters['token'] ?? '';
      if (token.isEmpty) {
        if (kDebugMode) {
          print('Register Token is empty');
        }
        _router.go(SignUpFailed.routeName);
      } else {
        _router.go(SignUp.routeName, extra: {'token': token});
      }
    } else {
      _router.go('/');
    }
  }

  // ディープリンクの初期化と処理
  Future<void> initDeepLinks() async {
    _appLinks = AppLinks();

    // アプリ起動後のディープリンク処理
    _linkSubscription = _appLinks.uriLinkStream.listen((uri) {
      if (kDebugMode) {
        print('Got deep link while app running: $uri');
      }
      openAppLink(uri);
    });
  }
}
```
`_appLinks.uriLinkStream.listen`を呼び出し、WebURLを受け付け状態を整えます。
`openAppLink`メソッドには、受け取ったURLに応じた遷移先を決める処理を記述しています。

#### `context.go('/')`はエラーになった
実装に関する気付きみたいな内容なので、気になる方は読んでください。
私が作成しているアプリケーションはGo Routerによる画面遷移を管理しています。

https://pub.dev/packages/go_router

どの画面に遷移するか、最初は以下の実装をしていました。
```dart
context.go('/');
```
GoRouterを使った画面遷移は、`context.go`や`context.push`を用いることが多いです。
- `context.go`：これまでのスタック（※）を削除して、新しいスタックを作成し、そこにウィジェットを載せる
- `context.push`：これまでのスタック（※）に遷移先のウィジェットを重ねる
※スタック：スタック/キューのスタックのことですが、それぞれ以下の違いがあります。

アプリ側でリンクを受け取るのは成功しましたが、
実際に画面遷移するための処理で以下エラーが発生しました。（アプリケーションの構造によっては、発生しないエラーかもしれません）

> I/flutter (21437): /register
E/flutter (21437): [ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: 'package:go_router/src/router.dart': Failed assertion: line 521 pos 12: 'inherited != null': No GoRouter found in context

**まだ`MaterialApp.router`にルーティングに関する定義をしていない状態で、GoRouterを用いた遷移処理を実行しようとしたからです。**
ざっくり記載すると、UI制御でGoRouterを使うための初期設定が完了していないのに、遷移する処理を呼び出しているよ、といったエラーです。

エラー回避するため、
`GoRouter`インスタンスを定義した変数を直接利用して、遷移処理を実装しました。
```dart
_router.go('/');
```

### 動作確認
`adb`コマンドを使って、挙動を確認します。

https://developer.android.com/tools/adb?hl=ja

指定されたAndroidアプリ（com.sample.package）を起動し、特定のURL（`http://local.com/register?token=ABCDE`）をそのアプリで開くよう指示しています。

```
adb shell am start -W -a android.intent.action.VIEW -d "http://local.com/register?token=ABCDE" com.sample.package
```

アプリが起動して、
実装したハンドリング通りの画面が表示できていれば成功です。

## まとめと今後
Android App Links(Deep Link)の機能実装について、まずはローカルで起動したアプリに適用する方法について説明してきました。
この後、リリース編でデプロイしたapkファイルでも機能するように作業を進めていきます。

予定作業は以下の通りです。
- WebURLとAndroidアプリケーションをMapping
- AndroidManifest.xmlの修正
- App Distributionを用いたアプリケーションのデプロイ

## 参考記事

https://developer.android.com/topic/performance/vitals/launch-time?hl=ja

https://stackoverflow.com/questions/25219551/what-is-the-meaning-of-android-intent-action-main

https://fintan-contents.github.io/mobile-app-crib-notes/reference/deep-link/android-app-links/

https://fintan-contents.github.io/mobile-app-crib-notes/react-native/santoku/application-architecture/deep-link/android-app-links

https://zenn.dev/kobalab/articles/60c610c9ee2185

https://zenn.dev/sugitlab/articles/606b658825792e