🧨

Firebase App Distribution の落とし穴 4 選(3/3 実践編)

に公開

Flutter + Firebase App Distribution の配信パイプラインを構築した後に直面しやすい 4 つの落とし穴と、その解決策を実体験ベースで解説します。フィードバック SDK のスクリーンショット黒塗り問題から CI での Bash 変数衝突まで、公式ドキュメントだけでは辿り着けない実践知をまとめました。

https://zenn.dev/motowo/articles/firebase-app-distribution-github-actions

テーマ 内容
第1回 設計編 配信フローの全体設計と技術選定
第2回 実装編 配信スクリプト・Gradle 署名設定・GitHub Actions
第3回(本記事) 実践編 ハマりポイント4事例と ADB 検証の自動化

フィードバック SDK のスクリーンショットが黒塗りになるのはなぜ?

配信パイプラインを構築し、テスターにフィードバック SDK を使ってもらうと、送信されたスクリーンショットが真っ黒、という報告が上がります。ギャラリーからの画像添付は正常なのにスクリーンショット撮影だけ黒くなるため、原因の特定に時間がかかりがちです。

原因: FlutterSurfaceView の GPU 直接描画

Flutter はデフォルトで FlutterSurfaceView を使い、GPU バッファに直接描画します。一方、Firebase フィードバック SDK は Android ネイティブの View.draw() 系 API でスクリーンショットを取得します。GPU バッファの内容はこの API では読み取れないため、Flutter が描画した領域が黒塗りになります。

描画方式 レンダリング先 View.draw() でのキャプチャ パフォーマンス
FlutterSurfaceView(デフォルト) GPU バッファ 不可(黒塗り) 最高
FlutterImageViewRenderMode.image ImageReaderCanvas 可能 やや劣る

出典: FlutterSurfaceView APIFlutterImageView API

解決策: RenderMode.image への切り替え

MainActivity.ktgetRenderMode() をオーバーライドし、RenderMode.image を返すようにします。

android/app/src/main/kotlin/.../MainActivity.kt
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.RenderMode

class MainActivity : FlutterActivity() {
    override fun getRenderMode(): RenderMode {
        return RenderMode.image
    }
}

RenderMode.imageImageReader 経由で Canvas に描画するモードです。Canvas ベースの描画になるため、Android 標準の View.draw() API でスクリーンショットを正常にキャプチャできます。

関連 Issue: firebase/firebase-android-sdk#5386firebase/flutterfire#11687

Android 13 以降でテスターに通知が届かないのはなぜ?

テスターから「新しいビルドの通知が来ない」と報告されたら、まず Android のバージョンを確認してください。Android 13(API 33)以降では、通知の送信にランタイム権限 POST_NOTIFICATIONS の明示的な許可が必要です。

原因: POST_NOTIFICATIONS 権限の未取得

Android 13 以降では、アプリが通知を送信するには POST_NOTIFICATIONS 権限をユーザーから取得する必要があります。この権限がないと、Firebase App Distribution の新規ビルド通知を含むすべての通知がブロックされます。

出典: Notification runtime permission(Android Developers)

解決策: 動的権限要求の実装

まず AndroidManifest.xml に権限を宣言します。

android/app/src/main/AndroidManifest.xml
<manifest ...>
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <application ...>
        ...
    </application>
</manifest>

次に、Flutter 側で permission_handler(v12.0.1)を使って動的に権限を要求します。

まず依存パッケージを追加します。

flutter pub add permission_handler

次に権限要求ロジックを実装します。

lib/utils/notification_permission.dart
import 'package:permission_handler/permission_handler.dart';

Future<void> requestNotificationPermission() async {
  var status = await Permission.notification.status;

  if (status.isDenied) {
    status = await Permission.notification.request(); // 戻り値を再代入して最新の状態を取得
  }

  // ユーザーが「今後表示しない」を選択した場合、設定画面へ誘導
  if (status.isPermanentlyDenied) {
    openAppSettings();
  }
}

ADB を使ったテスト方法も押さえておくと便利です。

# 権限を取り消し(新規インストール状態をシミュレート)
adb shell pm revoke com.example.app android.permission.POST_NOTIFICATIONS

# 権限を付与(アップグレード状態をシミュレート)
adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS

CI の配信スクリプトで変数が空になるのはなぜ?

ローカルでは問題なく動いていた配信スクリプトが、GitHub Actions では --groups に空文字が渡されて配信に失敗します。原因は Bash のビルトイン変数 GROUPS との名前衝突です。

原因: GROUPS は Bash の予約済みビルトイン変数

Bash には GROUPS というビルトイン変数が存在し、現在のユーザーが所属するグループ ID の配列を保持しています。GNU Bash 公式マニュアルには「Assignments to GROUPS have no effect」と明記されており、この変数への代入は無視されます。

出典: Bash Variables(GNU Bash Manual)

さらに厄介なのは、macOS と GitHub Actions で挙動が異なる点です。

環境 Bash バージョン GROUPS="qa-team" の挙動
macOS 3.2.x 代入が黙って無視される(エラーなし)
GitHub Actions(Ubuntu) 5.x 代入が無視される。環境によっては readonly variable エラー
sh(dash) N/A 正常に動作(GROUPS は予約されていない)

ローカルの macOS では代入が黙って無視されるため、デフォルト値 ${GROUPS:-qa-team}qa-team が使われて問題が表面化しません。しかし GitHub Actions の Ubuntu 環境(Bash 5.x)では、GROUPS にビルトインの配列値が入っているため、意図しない値が --groups に渡されます。

解決策: 変数名を TESTER_GROUPS にリネーム

# NG: GROUPS は Bash ビルトイン変数
GROUPS="qa-team"
echo "$GROUPS"  # macOS Bash 3.2: プライマリグループ ID の数値(例: 20)、Ubuntu Bash 5.x: グループ ID の配列

# OK: プレフィックスをつけて衝突を回避
TESTER_GROUPS="qa-team"
echo "$TESTER_GROUPS"  # どの環境でも "qa-team"

第2回で紹介した配信スクリプトの該当箇所を修正する場合は、以下のように変更します。

scripts/distribute_android.sh
# 修正前
GROUPS="${GROUPS:-qa-team}"
firebase appdistribution:distribute "$APK_PATH" \
  --groups "$GROUPS"

# 修正後
TESTER_GROUPS="${TESTER_GROUPS:-qa-team}"
firebase appdistribution:distribute "$APK_PATH" \
  --groups "$TESTER_GROUPS"

サービスアカウントで 403 エラーが出るのはなぜ?

GitHub Actions から firebase appdistribution:distribute を実行すると PERMISSION_DENIED: The caller does not have permission が返る。サービスアカウントを作成して Secrets に登録したのに、なぜ権限が足りないのか。

原因: 必要な IAM ロールの不足

サービスアカウントの作成だけでは不十分です。Firebase App Distribution への操作に必要な IAM ロールを明示的に付与する必要があります。

ロール 役割 付与しないと
roles/firebaseappdistro.admin APK アップロード・テスター管理 PERMISSION_DENIED で配信失敗
roles/serviceusage.serviceUsageConsumer Firebase API の呼び出し権限 API 呼び出し自体が拒否される

出典: Firebase App Distribution roles(Cloud IAM)

roles/serviceusage.serviceUsageConsumer は見落としやすいロールです。このロールが必要になった背景には、Firebase-admin SDK 側の仕様変更があります(参考: firebase-admin-node Discussion #2624)。

解決策: GCP Console で IAM ロールを付与

  1. GCP IAM コンソール を開く
  2. 対象のサービスアカウントを選択
  3. 「ロールを追加」から以下を付与する
    • Firebase App Distribution 管理者roles/firebaseappdistro.admin
    • Service Usage ユーザーroles/serviceusage.serviceUsageConsumer
  4. 保存して数分待つ

ADB でフィードバック送信を自動検証する方法は?

ハマりポイント 1 で紹介した RenderMode.image の設定後、フィードバック SDK が正しく動作するか手動で毎回確認するのは非効率です。ADB と uiautomator dump を組み合わせることで、フィードバック送信の動作確認を自動化できます。

検証スクリプトの全体像

以下のコードブロックを順番に scripts/send_feedback_adb.sh という 1 つのファイルとして保存し、実行権限を付与してください。

chmod +x scripts/send_feedback_adb.sh

スクリプトは、フィードバックフォームの起動からボタンタップ、送信完了の確認までを自動で行います。

scripts/send_feedback_adb.sh
#!/bin/bash
set -euo pipefail

PACKAGE_NAME="${1:-com.example.app}"
DEVICE_ID="${2:-}"

ADB="adb"
if [ -n "$DEVICE_ID" ]; then
  ADB="adb -s $DEVICE_ID"
fi

echo "[INFO] フィードバックフォームを起動します..."
$ADB shell input keyevent 82  # メニューキー(Android KeyEvent.KEYCODE_MENU)でフォームを起動

sleep 2

冒頭部分では、パッケージ名とデバイス ID を引数で受け取ります。複数デバイスが接続されている場合は $DEVICE_ID を指定してターゲットを絞ります。

uiautomator dump で UI 構造を取得する

uiautomator dump は、現在表示されている画面の UI 要素を XML 形式で出力するコマンドです。各要素のテキスト、座標(bounds 属性)、クリック可否などの情報が含まれます。

scripts/send_feedback_adb.sh
echo "[INFO] UI 構造を取得します..."
$ADB shell uiautomator dump /sdcard/window_dump.xml
$ADB pull /sdcard/window_dump.xml /tmp/window_dump.xml

取得した XML は以下のような構造です。

<node text="送信"
      class="android.widget.Button"
      clickable="true"
      bounds="[63,1626][1017,1752]">
</node>

bounds="[63,1626][1017,1752]" は要素の左上座標 (63, 1626) と右下座標 (1017, 1752) を示しています。

Python で送信ボタンの座標を算出する

XML から「送信」ボタンを検索し、bounds 属性から中心座標を計算します。

scripts/send_feedback_adb.sh
COORDS=$(python3 - <<'EOF'
import xml.etree.ElementTree as ET
import re

tree = ET.parse("/tmp/window_dump.xml")
for elem in tree.iter("node"):
    text = elem.get("text", "")
    if text in ["送信", "Submit", "送信する"]:
        bounds = elem.get("bounds", "")
        nums = re.findall(r'\d+', bounds)
        if len(nums) == 4:
            x = (int(nums[0]) + int(nums[2])) // 2
            y = (int(nums[1]) + int(nums[3])) // 2
            print(f"{x} {y}")
            break
EOF
)

if [ -z "$COORDS" ]; then
  echo "[ERROR] 送信ボタンが見つかりません"
  exit 1
fi

X=$(echo "$COORDS" | cut -d' ' -f1)
Y=$(echo "$COORDS" | cut -d' ' -f2)

echo "[INFO] 送信ボタンをタップします: ($X, $Y)"
$ADB shell input tap "$X" "$Y"

中心座標は (x1 + x2) / 2(y1 + y2) / 2 で算出します。bounds="[63,1626][1017,1752]" の場合、中心は (540, 1689) です。adb shell input tap でこの座標をタップすることで、送信ボタンの押下をシミュレートします。

送信完了の確認

送信後に再度 uiautomator dump を実行し、送信ボタンが画面から消えたか(フォームが閉じたか)を確認します。

scripts/send_feedback_adb.sh
sleep 1

echo "[INFO] フィードバック送信を確認します..."
$ADB shell uiautomator dump /sdcard/window_dump_after.xml
$ADB pull /sdcard/window_dump_after.xml /tmp/window_dump_after.xml

python3 - <<'EOF2'
import xml.etree.ElementTree as ET

tree = ET.parse("/tmp/window_dump_after.xml")
for elem in tree.iter("node"):
    text = elem.get("text", "")
    if text in ["送信", "Submit", "送信する"]:
        print("[WARN] 送信ボタンがまだ表示されています。送信に失敗した可能性があります。")
        exit(1)  # CI で失敗として検知させる
print("[INFO] フィードバック送信が完了しました。")
EOF2

実行例を示します。

# デフォルトのパッケージ名で実行
./scripts/send_feedback_adb.sh

# パッケージ名とデバイス ID を指定
./scripts/send_feedback_adb.sh com.example.myapp emulator-5554

まとめ

全3回の連載を通じて、Firebase App Distribution による Flutter アプリの配信自動化を設計から実践まで解説しました。

本記事で取り上げた 4 つの落とし穴を振り返ります。

落とし穴 原因 解決策
スクリーンショット黒塗り FlutterSurfaceView の GPU 直接描画 RenderMode.image に切り替え
テスター通知が届かない Android 13+ の POST_NOTIFICATIONS 権限 permission_handler で動的要求
CI で変数が空になる Bash ビルトイン変数 GROUPS との衝突 TESTER_GROUPS にリネーム
サービスアカウントで 403 IAM ロール(firebaseappdistro.admin + serviceUsageConsumer)の不足 GCP Console で両ロールを付与

連載全体のロードマップも整理します。

  • 第1回(設計編): 手動配布の課題を整理し、Firebase App Distribution を中心としたアーキテクチャを設計しました
  • 第2回(実装編): 配信シェルスクリプト・Gradle ハイブリッド署名・GitHub Actions ワークフローを実装しました
  • 第3回(本記事): 実運用で遭遇する落とし穴 4 選と ADB による自動検証を紹介しました

これらを組み合わせることで、「Slack に APK を貼る」運用から「main へのマージで自動配信、テスターからのフィードバックは Firebase Console に集約」という仕組みへ移行できます。

連載全3回を読んでいただきありがとうございました。実際に導入してみて「ここでハマった」「こう改善した」といった経験があれば、ぜひコメントで共有してください。

Discussion