0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】Android App Links(旧Deep Link)でWebURLからアプリを起動してみる(リリース編)

Last updated at Posted at 2025-11-08

App Links機能作成に挑戦

モバイルアプリで、以下の流れ、経験したことありませんか?

アプリをインストール

アプリを起動して、フォームからメールアドレスを入力して送信

メールが届く

メール本文のリンクをタップしたら、アプリが起動

登録情報の入力画面が表示される

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

あるサイトの画像をお借りしますが、以下の通りです。

URLをタップ時、
対応アプリがインストール済であれば、アプリが起動し、
未インストールであれば、以下の通りアプリインストールに進むか、ブラウザが開くか、そのどちらかになるかと思います。

image.png
App Linksより

ローカル編とリリース編

ボリュームが少し大きめのため、2本立てで進めていました。
既にローカル編のは公開しています。アプリをローカルで動かしながら(USBケーブルでPCと端末を接続して確認)、App Linksの機能構築をする話でした。

今回はリリース編です。
ビルドを行ってapkファイルを作成します。そのapkファイルを端末にインストールする形で動作するか確認する方法です。Play Storeからのインストールとフローは違いますが、本質的には同じで、配布可能な状態となった形のものに対して、動作確認をします。

リリースの定義
Google Play Storeへのリリースは実施していません。AppDistributionにデプロイしたapkファイルを、各端末でダウンロードして利用可能な状態にするところまで実施しています。
「ローカル」に対してどういう単語を用いるか考えた結果「リリース」になりました

こんな方向けの記事です

以下の経験がある方を想定しています。

  • Flutterでアプリ開発の経験がある
  • AndroidManifest.xmlの編集経験がある

また、以下のようなトピックに興味がある方はぜひ読んでみてください。参考になれば嬉しいです。

  • メールなどのURLからアプリを直接起動させたい
  • Firebase Hostingと連携したApp Links実装
  • apkファイルでのリリース前動作確認方法

主な作業内容

App Linksを実現するために主な作業は以下の通りです。

  • AndroidManifest.xmlの修正
  • assetlink.jsonのデプロイ(WebURLとAndroidアプリケーションのMapping)
  • アプリケーションコードで、WebURLを受け取れる状態へ
  • アプリ(apkファイル)デプロイと端末へのインストール

アプリケーションの構成

構成としては、大きく2段階です。
この構成実現のために、supabaseとresendを用いています。

1. アプリで会員登録するメールアドレスを入力すると、そのアドレスに仮会員登録完了のメールが届く

image.png

2. 届いたメールのURLをタップすると、アプリが起動し、本会員登録情報の入力画面を表示

赤字で記載したURL処理の部分が、App Linksが関係する部分です。

image.png

ここから本題に進んでいきます。

AndroidManifest.xmlの修正

ローカル編で実施したことと同じような作業です。

上記記事のディープリンクを受け取るために Android アプリとアプリコードを更新するを参考に記述を追加します。

肝心な部分が2か所あります。

  • android:autoVerify="true"を設定

アプリのインテント フィルタの少なくとも 1 つに android:autoVerify="true" が存在する場合、Android 6.0(API レベル 23)以上を搭載しているデバイスにアプリをインストールすると、システムはアプリのインテント フィルタ内の URL に関連付けられているホストを自動的に検証します。

  • <data>タグ部分

上記2か所の内容について、以下の通り反映します。

<intent-filter>
   <action android:name="android.intent.action.MAIN" />
   <category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- App Links (本番環境用 - 自動検証あり) -->
<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="${FIREBASE_HOSTING_DOMAIN}" 
        android:path="/register"
    />
</intent-filter>

<!-- App Links (開発環境用 - カスタムスキーム、検証なし) -->

ホストの部分にhttpsの情報を追加しました。

私はとりあえず、Firebase Hostingのホストを設定しています。各自値が決まっていると思いますので、その値を設定します。

またドメイン名は、AndroidManifest.xmlには直接記載せず、変数を用いて読み込んでいます。android/secrets.propertiesに、以下のように記載します。

FIREBASE_HOSTING_DOMAIN=xxxxx.web.app

assetlink.jsonのデプロイ(WebURLとAndroidアプリケーションのMapping)

Flutterプロジェクトルートディレクトリのpublicフォルダに資材を追加します。
1. .well-knownフォルダを作成
2..well-knownフォルダにassetlinks.jsonを作成
3. assetlinks.json ファイルを作成してホストする手順を参考に、ファイルの中身を作成

  • delegate_permission/common.handle_all_urls:おまじない的な部分もありますが、大きく以下の役割を担っている
    • Webサイト(この場合はFirebase Hosting上のサイト)が、指定されたAndroidアプリにURL処理の権限を委譲することを宣言
    • 指定されたSHA-256フィンガープリントを持つアプリのみにURLハンドリングを許可
    • このサイトのURLがモバイルデバイスで開かれた時、ブラウザではなく直接アプリで開くことを許可
  • namespace:私はAndroidアプリのため、android_appを設定
  • package_nameandroid/app/build.gradlenamespaceの値
  • sha256_cert_fingerprintsこちらの記事で実施の通り、keytoolコマンドを用いて、SHA-256の値を取得して設定
     [
         {
             "relation": [
                 "delegate_permission/common.handle_all_urls",
             ],
             "target": {
                 "namespace": "android_app",
                 "package_name": "com.enoconan.testingappv2",
                 "sha256_cert_fingerprints": [
                     "43:F2:CF:97:EF:1E:30:3D:86:35:44:69:F8:CF:BD:F9:ED:15:2C:3E:BD:45:F3:34:FA:E0:58:E0:9B:76:FF:D1"
                 ]
             },
         }
     ]
    

4. assetlinks.jsonの設定内容検証
Statement List Generator and Testerを用いて検証し、以下画像の通り必要事項を入力し、Test Statementを押下して、Success!となれば正しく設定が完了

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/d194f7a5-c900-483f-81f3-638a33b827a5.png)

5.firebase.jsonの設定
headersキーとrewritesキーの設定値を修正します。
全体実装は、こちらfirebase.jsonで公開しています。

```json
"headers": [
  {
    "source": "/.well-known/assetlinks.json",
    "headers": [
      {
        "key": "Content-Type",
        "value": "application/json"
      }
    ]
  }
],
"rewrites": [
  {
    "source": "**",
    "destination": "/index.html"
  }
]
```

6. Firebase Hostingへデプロイ
これまでの手順で設定した内容をリモートに反映します。
firebase deploy --only hosting

7.反映内容確認

assetlinks.jsonはブラウザから確認可能

Chromeなどのブラウザで、URLにhttps://{domain}/.well-known/assetlinks.jsonと入力すると、作成したassetlinks.jsonの内容を確認できます。これで正しく反映できているか確認します。

誤って機密情報などを貼り付けたりしないようにしてください。

AndroidManifest.xmlassetlinks.json

ここまでで、以下2ファイルに関する話をしました。

  • AndroidManifest.xml
  • assetlinks.json
    開発時からアプリインストール後までの流れは、アプリリンクの仕組みで、以下の通り記載されています。1は本記事でも記載した通りの話です。
  1. アプリのマニフェストで、android:autoVerify="true を使用してインテント フィルタ内の URL を宣言し、ウェブサイトのホストを指定します。
  2. アプリがインストールされると、Android システムはウェブサーバー上の既知の場所から assetlinks.json ファイルを取得します。
  3. システムは、assetlinks.json ファイルが有効で、sha256_cert_fingerprints がアプリの署名証明書と一致することを確認します。
  4. ユーザーが一致するリンクをクリックすると、システムは確認ダイアログを表示せずに、ユーザーをアプリに直接誘導します。

アプリケーションコードで、WebURLを受け取れる状態へ

アプリケーションがWeb URLを受け取る場合、デバイス上のアプリケーション状態として、以下の2パターンに分かれます。今回はローカルではないので、どちらも確認が可能です。

  • Hot Start(アプリを表示状態、非表示であるが未終了)
  • Cold Start(アプリ終了状態)

以下のスニペットでは核となる箇所のみ記載しています。ご了承ください。

全体実装は、こちら(app.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;
  bool _isProcessingDeepLink = false;

  final _router = GoRouter(
    initialLocation: MenuPage.routeName,
    routes: AppRouter.routes,
    redirect: (context, state) {
      // リダイレクト処理の詳細ログを追加
      if (kDebugMode) {
        debugPrint('GoRouter redirect - location: ${state.uri}, path: ${state.uri.path}');
      }
      return null;
    }
  );

  void openAppLink(Uri uri) {
    setState(() {
      _isProcessingDeepLink = true;
    });

    if (!_isValidUri(uri)) {
      if (kDebugMode) {
        debugPrint('Invalid URI: $uri');
      }
      _navigateToMenu();
      setState(() {
        _isProcessingDeepLink = false;
      });
      return;
    }

    final path = uri.path.isEmpty ? '/' : uri.path;

    if (path == '/register') {
      final token = uri.queryParameters['token'] ?? '';
      if (token.isEmpty) {
        _router.go(SignUpError.routeName);
      } else if (_isValidToken(token)) {
        _router.go(SignUp.routeName, extra: {'token': token});
      } else {
        _router.go(SignUpError.routeName);
      }
    } else {
      _navigateToMenu();
    }

    // 遷移完了後、即座にローディングを解除
    setState(() {
      _isProcessingDeepLink = false;
    });
  }

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

    // コールドスタート時のディープリンク処理
    try {
      final uri = await _appLinks.getInitialLink();
      if (uri != null) {
        openAppLink(uri);
      }
    } catch (e) {
      if (kDebugMode) {
        LoggerService.warn('Error getting initial deep link: $e');
      }
    }

    // アプリ起動後のディープリンク処理
    _linkSubscription = _appLinks.uriLinkStream.listen((uri) {
      openAppLink(uri);
    });
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(responsiveDimensionsProvider.notifier).updateDimensions(size);
    });

    // ディープリンク処理中はローディング画面を表示
    if (_isProcessingDeepLink) {
      return const MaterialApp(
        home: Scaffold(
          body: Center(child: CircularProgressIndicator(color: MyColors.greenButton)),
        ),
        debugShowCheckedModeBanner: false,
      );
    }

    return MaterialApp.router(
      routerConfig: _router,
      localizationsDelegates: const [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: const [Locale('ja', 'JP')],
      debugShowCheckedModeBanner: false,
      title: 'Testing Sample',
      theme: ThemeData(fontFamily: 'NotoSansJP', appBarTheme: const AppBarTheme())
    );
  }
}

アプリ(apkファイル)デプロイと端末へのインストール

App Distributionを用いて、デプロイ作業を行います。
ここでは具体的なセットアップの話は省いています。

以下記事にて手順は紹介していますので、必要に応じてご覧ください。

また開発時に都度、App Distributionにapkファイルをアップロードするのが面倒になる場合もあるかと思います。

その場合は、実端末とPCをUSBケーブルで接続するUSBデバッグの形をとり、adbコマンドでアプリをインストールすることもできます。

# ビルド
flutter build apk --release
# アンインストール
adb uninstall com.enoconan.testingappv2
# アプリインストール
adb install build/app/outputs/flutter-apk/release.apk

動作確認

以下のように、メールに添付したURLをタップする形で確認します。
image.png

大きく3種類の挙動になるパターンで確認してみました。

パターン 起動方法 表示内容
1.正規パス アプリ起動 会員情報入力画面を表示
2.パラメーター誤り アプリ起動 トークンの有効期限切れ画面表示
3.パス誤りまたはドメイン名誤り ブラウザ起動 404ページを表示

それぞれのパターンに関するスクリーンショットです。想定した通りに制御できていることが確認できました。

1.正規パス 2.パラメーター誤り 3.パス誤りまたはドメイン名誤り
Screenshot_20251107_095252.jpg Screenshot_20251107_095301.jpg image.png

まとめと今後

前回のローカル編と合わせて、App Linksを実現する作業に取り組んできました。
動作確認の部分で記載の通りですが、きちんと制御できていることが確認できると嬉しいものです。

それぞれ、ざっくりはありますが、以下の内容を理解することができました。

  • ローカル編
    • App Linksの基本的な内容理解
    • adb(Android Debug Bridge)コマンドを用いた動作確認方法
  • リリース編:
    • コールドスタートでも機能するための実装方法理解
    • ドメインに対する検証やパスに関する制御方法

今後ですが、Dynamic App linksに関して触れたいと思っています。

Android 15 以降では、動的アプリリンクが導入され、アプリリンクがさらに強力になります。動的アプリリンクを使用すると、アプリの新しいバージョンを公開することなく、サーバーサイドの assetlinks.json ファイルでディープリンク ルールを更新できます。

URL パス、フラグメント、クエリ パラメータのマッチングなど、アプリを開く URL をきめ細かく制御することもできます。

AndroidManifest.xmlの修正のdataタブで、URLのschema,host,pathに関するルールを記述していました。
App Linksの挙動を変えたい場合、以下のどちらかになります。

  • AndroidManifest.xml
  • アプリケーションのコード修正

どちらにせよ再度apkファイルをビルドするので再リリースが必要です。公開済のアプリであれば、再インストールをお願いすることになります。

これを避けるために、assetlinks.json側でパスやクエリに関する条件を定義しよう!となっています。

動的アプリリンクで記載された以下内容が該当箇所です。

システムは assetlinks.json ファイルを定期的に再取得して最新のルールを取得するため、アプリをアップデートしなくてもリンクを更新できます。定期的な再取得は、Google サービスがインストールされている Android 15(API レベル 35)以降を搭載したデバイスでサポートされています。

Android 15(API レベル 35)に関する話は、以下が参考になります。

参考記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?