Xamarin.AndroidのFCM通知でAndroid 12「FLAG_IMMUTABLE/FLAG_MUTABLE必須」例外が出る原因と解決策(データメッセージ化と.NET MAUI/.NET 9移行ガイド)

Android 12以降の端末で、Xamarin.Android製アプリがバックグラウンド受信のFCM通知をきっかけに落ちる——現場では珍しくない事故です。本稿は「なぜ落ちるのか」「今すぐ止血する方法」「根治のための移行計画」までを、具体的なコードと運用の観点を交えて整理します。再発を防ぎつつ、.NET MAUI/.NET 9 for Androidへの安全な道筋も提示します。

目次

症状の全体像:Android 12以降での FLAG_IMMUTABLE / FLAG_MUTABLE 必須化

バックグラウンドにあるアプリがFCM通知を受信した瞬間、以下の例外でクラッシュします。

IllegalArgumentException: requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified

エラーメッセージどおり、Android 12(API 31)以降では、PendingIntent を生成するすべての箇所で FLAG_IMMUTABLE もしくは FLAG_MUTABLE のいずれかが必須になりました。ところが「自分のコードでは PendingIntentFlags.Immutable を付けているのに落ちる」ことがあります。この場合、アプリ側ではなく、OS/ライブラリ側が内部的に生成する PendingIntent(通知タップで起動する既定のインテントなど)が無印フラグで作られ、Android 12+で拒否されるのが原因です。

原因を短くまとめる

  • 通知ペイロードに notification ブロックが含まれると、Android(正確にはGoogle Play開発者サービス)が自動で通知を表示し、内部で PendingIntent を作成します。
  • 一部の組み合わせ(古いXamarin.Android+古いFirebaseバインディング等)では、この内部 PendingIntent のフラグが不足してクラッシュが発生します。
  • アプリ側でどれだけ Immutable を徹底しても、OS自動通知の経路に乗る限り事故を完全には防げません。

最短の止血策:「データメッセージ」一本化でOS自動通知を封じる

まずサービス継続を最優先にするなら、FCMの送信ペイロードから notification ブロックを削除し、データメッセージ(data のみ)に切り替えてください。これにより必ずアプリの FirebaseMessagingService.OnMessageReceived() が呼ばれ、通知生成はアプリ側の責務になります。こうして初めて、PendingIntent に Immutable(または必要な場面では Mutable)を付けることができます。

HTTP v1 のデータメッセージ送信例

{
  "message": {
    "token": "<Device FCM Token>",
    "data": {
      "title": "新しいお知らせ",
      "body": "タップして詳細を確認",
      "deepLink": "myapp://notice/12345",
      "type": "notice",
      "priority": "high"
    }
  }
}

レガシーHTTPの例

{
  "to": "<Device FCM Token>",
  "data": {
    "title": "新しいお知らせ",
    "body": "タップして詳細を確認",
    "deepLink": "myapp://notice/12345",
    "type": "notice",
    "priority": "high"
  }
}

ポイント:notification を送らないことでOSは自動通知を表示せず、アプリ側で安全な PendingIntent を構築できます。

アプリ側での安全な通知生成(Xamarin.Android)

以下は FirebaseMessagingService でデータメッセージを受信し、安全な PendingIntent(Immutable)を使って通知を表示する最小実装例です。

using Android.App;
using Android.Content;
using Android.OS;
using AndroidX.Core.App;
using Firebase.Messaging;
using System.Linq;

namespace MyApp.Droid
{
  [Service(Exported = false)]
  [IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
  public class MyFirebaseMessagingService : FirebaseMessagingService
  {
    const string ChannelId = "myapp.default";

    public override void OnMessageReceived(RemoteMessage message)
    {
      var title = message.Data.TryGetValue("title", out var t) ? t : "お知らせ";
      var body  = message.Data.TryGetValue("body",  out var b) ? b : "タップして詳細を確認";
      var deep  = message.Data.TryGetValue("deepLink", out var d) ? d : null;

      CreateChannelIfNeeded();

      // 画面遷移用のIntentを組み立て(必要に応じてDeep Linkなどに対応)
      var intent = new Intent(this, typeof(MainActivity));
      intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
      intent.PutExtra("from_fcm", true);
      if (!string.IsNullOrEmpty(deep)) intent.PutExtra("deepLink", deep);
      foreach (var kv in message.Data)
        intent.PutExtra(kv.Key, kv.Value);

      // TaskStackBuilderで戻る遷移も成立させておく
      var stack = AndroidX.Core.App.TaskStackBuilder.Create(this);
      stack.AddNextIntentWithParentStack(intent);

      // Android 12+ 要件:Immutableを必ず付与。上書き更新のためUpdateCurrentも併用
      var pending = stack.GetPendingIntent(
        requestCode: 0,
        flags: (int)(PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent)
      );

      var builder = new NotificationCompat.Builder(this, ChannelId)
        .SetContentTitle(title)
        .SetContentText(body)
        .SetSmallIcon(Resource.Drawable.ic_stat_notification)
        .SetContentIntent(pending)
        .SetAutoCancel(true)
        .SetPriority((int)NotificationPriority.High);

      // アクションやRemoteInputで可変にする必要がない限りMutableは使わない
      var notification = builder.Build();
      NotificationManagerCompat.From(this).Notify(NotifyId(), notification);
    }

    static int NotifyId() => (int)(Java.Lang.JavaSystem.CurrentTimeMillis() & 0xFFFFFF);

    void CreateChannelIfNeeded()
    {
      if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
      {
        var mgr = (NotificationManager)GetSystemService(NotificationService);
        var ch = mgr.GetNotificationChannel(ChannelId);
        if (ch == null)
        {
          ch = new NotificationChannel(ChannelId, "標準通知", NotificationImportance.High);
          mgr.CreateNotificationChannel(ch);
        }
      }
    }
  }
}

Manifestの要点:

<application ...>
  <service
      android:name=".MyFirebaseMessagingService"
      android:exported="false">
      <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
      </intent-filter>
  </service>







 

注意: Android 12以降は exported の明示が必須です(インテントフィルタを持つコンポーネント)。

あなたのコード側で確認すべき「PendingIntent」リスト

  • 通知の SetContentIntent:Immutable | UpdateCurrent を基本形に。
  • 通知アクション(Actionボタン):Mutableが必要な場合あり(後述表を参照)。不要な限りImmutable。
  • Broadcast/Service/Activity 起動用PendingIntent:すべてにフラグを付与。
  • Deep Link(NavDeepLinkBuilder 等):内部的にPendingIntentを作るAPIは生成フラグに注意。
  • Widget/AlarmManager/WorkManager:アラームや遅延処理でPendingIntentを組む箇所も総点検。

Immutable と Mutable の使い分け早見表

用途推奨フラグ理由/補足
通知タップでActivityを開くImmutable | UpdateCurrent動的変更不要。安全性重視。UpdateCurrentで既存IntentのExtra更新。
通知アクション+RemoteInput(返信)Mutableユーザー入力等でPendingIntent内容が変更され得るため。
Broadcastで軽量処理Immutable受け側でIntentを書き換えない。
AlarmManagerでのスケジュールImmutable(基本)必要なら UpdateCurrent を併用。

一時しのぎとしての「ライブラリ更新」

「Xamarinプロジェクトのまま」進める場合、Xamarin.Firebase.Messaging を比較的新しい系列(例:123.0.8以上)に更新することで改善する報告があります。ただし、古いXamarin.Androidと新しいAndroidX/Firebaseの依存関係衝突(ビルド時の解決失敗、Duplicate class、Java.Lang.NoSuchMethodError 等)が起きやすく、現実にはビルドを通すための調停作業が発生しがちです。

衝突のよくあるパターンと調停のコツ

  • 旧 Android.Support.* が混在:全面的に AndroidX へ置き換え。
  • GooglePlayServices 系のバージョンずれ:同系列にそろえる(Messagingだけを上げない)。
  • CompileSdkVersion が古い:最新(少なくともAPI 34相当)へ。targetSdkVersion も追随。
  • マルチDexやリンカー設定:リリースビルドでシンボル欠落が起きたら「Link SDK Assemblies only」+AndroidLinkSkip で最小回避。

根治策:.NET MAUI / .NET 9 for Androidへの移行

2024年5月1日をもってXamarinのサポートは終了しました。セキュリティ/依存関係/ストア要件の観点から、.NET for Android(.NET 9)あるいは .NET MAUI への移行を推奨します。

推奨NuGet構成(例)

用途推奨パッケージ(例)備考
基盤Microsoft.Maui.Essentials 9.0.xMAUI利用時。非MAUIなら Mono.Android 標準でOK。
AndroidXXamarin.AndroidX.Work.Runtime 2.10.x ほかSupport系は使用しない。
FCMXamarin.Firebase.Messaging 124.1.xGoogle Play Services一式と整合の取れた系列を揃える。

ターゲットフレームワーク例(MAUIなしの .NET for Android)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0-android</TargetFramework>
    <SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
    <ApplicationTitle>MyApp</ApplicationTitle>
    <ApplicationId>com.example.myapp</ApplicationId>
    <Nullable>enable</Nullable>
  </PropertyGroup>






 

.NET MAUIでの受信サービス(Androidプラットフォーム層)

MAUIでもFCM受信はAndroid固有サービスとして実装します(Platforms/Android)。

using Android.App;
using Android.Content;
using Android.OS;
using AndroidX.Core.App;
using Firebase.Messaging;

namespace MyApp;

[Service(Exported = false)]
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
public class MauiFirebaseMessagingService : FirebaseMessagingService
{
const string ChannelId = "myapp.default";

public override void OnMessageReceived(RemoteMessage message)
{
var title = message.Data.TryGetValue("title", out var t) ? t : "お知らせ";
var body  = message.Data.TryGetValue("body", out var b) ? b : "タップして詳細を確認";

```
CreateChannel();

var intent = new Intent(this, typeof(MainActivity));
intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
foreach (var kv in message.Data) intent.PutExtra(kv.Key, kv.Value);

var stack = AndroidX.Core.App.TaskStackBuilder.Create(this);
stack.AddNextIntentWithParentStack(intent);

var pending = stack.GetPendingIntent(0,
  (int)(PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent));

var n = new NotificationCompat.Builder(this, ChannelId)
  .SetContentTitle(title)
  .SetContentText(body)
  .SetSmallIcon(Resource.Drawable.ic_stat_notification)
  .SetAutoCancel(true)
  .SetContentIntent(pending)
  .Build();

NotificationManagerCompat.From(this).Notify(1001, n);
```

}

void CreateChannel()
{
if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
{
var mgr = (NotificationManager)GetSystemService(NotificationService);
var ch = mgr.GetNotificationChannel(ChannelId)
?? new NotificationChannel(ChannelId, "標準通知", NotificationImportance.High);
mgr.CreateNotificationChannel(ch);
}
}
} 

送信側(バックエンド)の設計ポイント

  • 必ずデータメッセージを送る:notification ブロックは使わない。
  • 通知文言はdataに格納:title / body / deepLink / type など。
  • 優先度:即時性が必要な場合は priority=high(HTTP v1では AndroidConfig.Priority )。
  • 多言語:文言はサーバーで組み立てるか、クライアントでキーを受けて辞書化。

サンプル:深いリンクで画面を直接開く

アプリ側で deepLink を解釈し、目的の画面へ遷移します。PendingIntentはImmutableで十分です。

// MainActivity.OnCreate など
if (Intent?.GetBooleanExtra("from_fcm", false) == true)
{
  var deep = Intent.GetStringExtra("deepLink");
  if (!string.IsNullOrEmpty(deep))
  {
    // myapp://notice/12345 -&gt; 画面を開くロジックへ
    NavigateToDeepLink(deep);
  }
}

テスト戦略:事故を未然に防ぐ

  • デバイスバリエーション:API 30、31、34あたりを最低ラインとする(Android 11/12/14)。
  • バックグラウンド実機検証:ホームボタンで退避/スワイプキル/画面ロック時それぞれで通知受信。
  • 通知タップ遷移の再現性:バックスタックの期待どおりに戻れるか(TaskStackBuilder を利用)。
  • 大量配信時:同一通知IDを意図的に上書きするか、ユニークIDで積み上げるかを事前合意。

よくある落とし穴と回避策

  • アクション付き通知の安易なMutable化:Mutableは本当に必要な場面に限定。基本はImmutable。
  • PendingIntentの重複:requestCode が同じだと別通知なのに意図せず上書き。意味のある整数を採番。
  • Firebaseコンソール手打ち通知:多くの場合 notification を含むため、今回の事故条件を満たしてしまう。デバッグでも避ける。
  • 古いSupportライブラリ:AndroidXへ置換。名称だけでなくパッケージ階層も見直す。
  • Manifestの exported 未設定:Android 12以降でビルド/インストール失敗。必ず明示。

移行プロジェクトの進め方(実務フロー)

  1. 止血(本日):サーバー側をデータメッセージ化。アプリ側で通知生成。
  2. 棚卸(1〜2週間):依存NuGet、Android.Support.* の残骸、ビルド設定、CI/CDを洗い出し。
  3. Pilot(2〜4週間):小規模なMAUI/.NET for AndroidテストアプリでFCM/通知/Deep Link/バックグラウンドを検証。
  4. 段階移行(4週間〜):機能単位で移植。移植困難箇所(カメラ、ストレージ、バックグラウンドジョブ)を優先的に潰す。
  5. 計測と最適化:ANR/クラッシュレポート、通知開封率、配信遅延をダッシュボード化。

移行時にありがちなビルドエラー対処表

症状原因対処
Duplicate classSupportとAndroidXの混在全面的にAndroidXへ、Support依存を除去
NoSuchMethodErrorランタイムでのバージョン不一致GooglePlayServices/Firebase系列のバージョンを統一
Manifest merge failedexported 未指定/重複定義Android 12要件を満たすよう明示、重複はツール要素で上書き

通知要件のセキュリティ観点

  • Immutable優先:第三者アプリによる意図しない変更を防止。
  • 深いリンクの検証:myapp:// スキームの受け口で許容パスをホワイトリスト化。
  • エクスポート範囲の最小化:受信サービスは Exported=false、必要な Activity/Receiver のみ公開。
  • 通知内容に機微情報を置かない:ロック画面表示設定に留意。

デバッグ:スタックトレースと再現ログの採取

再現時のログに「どのコードがフラグ無しの PendingIntent を作ったか」は直接は出ません。以下の手順で当たりを付けます。

  1. 通知の送信経路を洗う:コンソール通知は使っていないか。使っていれば即停止。
  2. 受信時の RemoteMessage をダンプ:message.Notification が null であること(=データメッセージにできていること)を確認。
  3. 自前通知コードを総点検:GetActivity/GetService/GetBroadcast の全呼び出しにフラグ。
  4. サードパーティSDKが通知を出さないか確認:A/Bテストや解析SDKが裏で通知することがある。

R8/ProGuard・リンカーの注意

  • メッセージング関連クラスが最適化で消されないよう、必要に応じてKeepルールを追加。
  • リリースビルド専用のクラッシュに遭遇したら、まずリンカー設定を「SDK assemblies only」に落として原因を切り分け。

チェックリスト(導入から運用まで)

  • FCM送信は dataのみ に統一(notification は禁止)。
  • すべての PendingIntent に Immutable または Mutable を必ず付与。
  • 通知の戻る遷移は TaskStackBuilder で保証。
  • Android 12以降の exported 要件を満たす。
  • 新系列のAndroidX/Firebaseに統一。Xamarinからの移行計画を走らせる。
  • 配信と開封のメトリクスを継続監視。

まとめ:今すぐ動かし、将来に備える

今すぐ動かすには、FCMのペイロードから notification を取り除き、データメッセージ経由でアプリ側が通知を作る——これでOS内部の未フラグ PendingIntent 経路を通らず、クラッシュは止まります。将来の安心のためには、.NET for Android / .NET MAUIへ段階的に移行し、最新のAndroidX/Firebase系列で堅牢な実装に更新するのが最適解です。

付録:トラブル対策テーブル(A〜Dの要約)

対策内容補足
A. 速攻での暫定回避FCMをデータメッセージのみに。notification を送らない。必ず OnMessageReceived() が呼ばれ、アプリ側で Immutable を付与可能。
B. コード側の基本確認全PendingIntentに Immutable(または必要時は Mutable)。Android 12以降の必須要件。UpdateCurrent 併用が実用的。
C. ライブラリ更新で凌ぐXamarin.Firebase.Messaging を新系列(例:123.0.8+)へ。依存衝突の調停が必要になりがち。恒久解ではない。
D. 中長期的解決(推奨).NET MAUI / .NET 9 for Androidへ移行。推奨パッケージに統一。保守性と安全性を長期に担保。Xamarinは既にサポート終了。

付録:Mutableが必要になる具体例

  • 返信アクション付き通知(RemoteInput):ユーザー入力がIntentに流入。
  • MediaStyle通知:再生/一時停止など、状態に応じて変わる操作を同じPendingIntentで差し替える場合。
  • 複数アクションをダイナミックに差し替える:生成後にExtras等を書き換える要件があるとき。

上記以外は原則Immutableで問題ありません。

付録:運用Tips

  • 通知チャンネルの設計:重要度ごとに分け、ユーザーが自分でコントロールできるように。
  • バッジ数の整合:アプリ内既読と通知キャンセルの整合を保つ(タップ時にサーバーへ既読API等)。
  • トークンローテーション:OnNewToken でサーバーと同期する再登録ロジックを実装。

結論:本件クラッシュの主因は「OS自動通知による未フラグのPendingIntent」。サーバー側でデータメッセージに切り替え、アプリ側で安全な Immutable を付けて通知を構築することが即効の解決です。並行して .NET MAUI/.NET for Android へ移行し、最新のAndroidX/Firebase系に整流化しましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次