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.x | MAUI利用時。非MAUIなら Mono.Android 標準でOK。 |
| AndroidX | Xamarin.AndroidX.Work.Runtime 2.10.x ほか | Support系は使用しない。 |
| FCM | Xamarin.Firebase.Messaging 124.1.x | Google 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 -> 画面を開くロジックへ
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週間):依存NuGet、
Android.Support.*の残骸、ビルド設定、CI/CDを洗い出し。 - Pilot(2〜4週間):小規模なMAUI/.NET for AndroidテストアプリでFCM/通知/Deep Link/バックグラウンドを検証。
- 段階移行(4週間〜):機能単位で移植。移植困難箇所(カメラ、ストレージ、バックグラウンドジョブ)を優先的に潰す。
- 計測と最適化:ANR/クラッシュレポート、通知開封率、配信遅延をダッシュボード化。
移行時にありがちなビルドエラー対処表
| 症状 | 原因 | 対処 |
|---|---|---|
| Duplicate class | SupportとAndroidXの混在 | 全面的にAndroidXへ、Support依存を除去 |
| NoSuchMethodError | ランタイムでのバージョン不一致 | GooglePlayServices/Firebase系列のバージョンを統一 |
| Manifest merge failed | exported 未指定/重複定義 | Android 12要件を満たすよう明示、重複はツール要素で上書き |
通知要件のセキュリティ観点
- Immutable優先:第三者アプリによる意図しない変更を防止。
- 深いリンクの検証:
myapp://スキームの受け口で許容パスをホワイトリスト化。 - エクスポート範囲の最小化:受信サービスは
Exported=false、必要な Activity/Receiver のみ公開。 - 通知内容に機微情報を置かない:ロック画面表示設定に留意。
デバッグ:スタックトレースと再現ログの採取
再現時のログに「どのコードがフラグ無しの PendingIntent を作ったか」は直接は出ません。以下の手順で当たりを付けます。
- 通知の送信経路を洗う:コンソール通知は使っていないか。使っていれば即停止。
- 受信時の
RemoteMessageをダンプ:message.Notificationが null であること(=データメッセージにできていること)を確認。 - 自前通知コードを総点検:
GetActivity/GetService/GetBroadcastの全呼び出しにフラグ。 - サードパーティ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系に整流化しましょう。

コメント