Firebaseプッシュ通知をJavaで実装してみた

## やりたいこと

+ [ニッチな自作Androidアプリ](https://github.com/yonezawaizumi/OritsubushiRoid2/tree/fcm)にプッシュ通知を実装したい
+ [同機能のiOSアプリも作ってる](http://qiita.com/yonezawaizumi/items/fc9c37b08ad889304ff1)ので、プッシュ通知のフレームワークとしてFirebase FCMを使いたい
+ プッシュ通知を常に通知一覧に表示させ、そのタップでアプリ内の特定のフラグメント表示へと遷移させたい
+ 通知一覧に表示されるアイコンをアプリのものにしたい
+ 日本どローカルなアプリなので、各種表示の国際化はとりあえず考えない

## レギュレーション

+ Android Studio 2.2
+ 最小APIバージョンは9 （FCM最低値）
+ ターゲットAPIバージョンは25
+ ふつうにJava
+ Rxってなんですか?
+ `MainActivity`クラスで実装されている唯一のアクティビティー
+ その[launchMode](http://y-anz-m.blogspot.jp/2011/02/androidlauchmode.html)はSingleTask
+ 画面の切り替えは`MainActivity`上で複数のフラグメントが入れ替わることで実現

## はじめに謝辞

iOSでは正直あまりまとめて参考にできる資料がなかったので[かなり自力での実装になりました](http://qiita.com/yonezawaizumi/items/fc9c37b08ad889304ff1)が、Androidに関しては[こちらの記事](http://qiita.com/kirimin/items/f16b22625a365f5d64f3)が大変参考になりました。ただ、今回実装したかったことに関しては少々追加知見も必要でしたので、それも含めてのこの記事となっています。

## 実装の要点

### アクティビティーでの初期化

まずメインのアクティビティー。
これはFCMの初期化に限れば**たった1行、トピック足すだけ**です。
**それすら不要なのであれば「何もいらない」**んですが、そうなるとこちらとiOS版アプリとで通知を出し分けられなくなってしまいます。

```Java:MainActivity.java
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        FirebaseMessaging.getInstance().subscribeToTopic("TOPICNAME");

        // other initializing...
    }
```

### デバイストークンの取得

次に生成/更新されたデバイストークンの取得。
これは、デバイス別に異なる通知を行う場合には必須です[^1]が、まさにそれを行えるタイミングが`FirebaseInstanceIdService#onTokenRefresh`です。これをオーバーライドして、そのタイミングでデバイストークンを自前サーバーに送信すればよいわけです。

しかし今回は、あくまでもすべてのユーザーに通知をいっせいに送れればよく、それでも`FirebaseInstanceIdService`サービスが必要なのかどうか、正直わかりません。
もし必要なければ、**これまた「何もしなくてよい」**となります。が、ちょっと心もとなかったので、一応サービスだけは実行させるようにしておこう、と、サブクラスは作らず、直接スーパークラスをサービスとして起動するように設定しました。いいのかこれで。

```XML:AndroidManifest.xml
    <service android:name="com.google.firebase.iid.FirebaseInstanceIdService">
      <intent-filter>
        <action android:name="com.google.firebase.INSTANCE_ID_EVENT"/>
      </intent-filter>
    </service>
```

### フォアグラウンド時の受信の対応

FCMでも、[前職で書いたGCMの記事（の「自動でAndroid通知の上書き更新を行えるtag」の章）](https://www.mate-en.com/techblog/google-cloud-messaging-2.html)に挙げた、**「Androidで/標準的な通知を/バックグラウンド時のみに受信する場合、コードを書く必要がない」**というスッキリ仕様は変更されませんでした。
ただし今回は、フォアグラウンド時にも通知を処理しなければなりません。
そのためには、`FirebaseMessagingService#onMessageReceived`をオーバーライドすればよいので、そうします。その中で、バックグラウンド時に表示される通知と同じ内容を自ら生成して、ペンディングインテントとして登録すればよいわけです。

っていうかこれ、[@kiriminさんの記事](http://qiita.com/kirimin/items/f16b22625a365f5d64f3)ほぼそのまんまです。感謝感激雨霰（死語）でしかありません。ただ、このコードで表示させた通知がタップされたときのインテントの生成だけちょっと変えてます。
基本、このインテントはインプロセス、あ、[COM脳](https://code.msdn.microsoft.com/windowsdesktop/VisualC-dbc36ad9)で手がすべりました（そしてなぜかこれをタイプするときにさらに指がすべってインプロレスと打ってしまうプロレス脳）、ではなくて自アプリ内でだけ使うものなので、文字列定数は`MainActivity`で定義しといて使えばいいのかなと。それが`MainActivity.ARG_FRAGMENT`と`MainActivity.PUSH_NOTIFICATION_ACTION`です。

```Java:FirMessagingService.java
public class FirMessagingService extends FirebaseMessagingService {
    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {
        Map<String, String> data = remoteMessage.getData();
        if (data == null) {
            return;
        }
        String fragment = data.get(MainActivity.ARG_FRAGMENT);
        if (fragment == null) {
            return;
        }

        NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext());
        builder.setSmallIcon(R.drawable.notification);
        builder.setContentTitle(remoteMessage.getNotification().getTitle());
        builder.setContentText(remoteMessage.getNotification().getBody());
        builder.setDefaults(Notification.DEFAULT_SOUND
                | Notification.DEFAULT_VIBRATE
                | Notification.DEFAULT_LIGHTS);
        builder.setAutoCancel(true);

        // Pending Intent作成
        Intent intent = new Intent(this, MainActivity.class);
        intent.setAction(MainActivity.PUSH_NOTIFICATION_ACTION);
        intent.putExtra(MainActivity.ARG_FRAGMENT, fragment);
        PendingIntent contentIntent = PendingIntent.getActivity(
                getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        builder.setContentIntent(contentIntent);

        // Notification表示
        NotificationManagerCompat manager = NotificationManagerCompat.from(getApplicationContext());
        manager.notify(0, builder.build());
    }
}
```

もちろんこのサブクラスをサービスとして登録するのも忘れずに。

```XML:AndroidManifest.xml
    <service android:name=".FirMessagingService">
      <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT"/>
      </intent-filter>
    </service>
```

### 通知タップ時の処理

これについては「launchModeはSingleTask」というレギュレーションがものすごく有効に作用していまして、通知タップ時に起こり得るパターンは次の2つしかありません。
（なお、このレギュレーションはFCMとは関係なく、そもそもAndroid上で自アプリを必ず1つしか起動させないための常套手段であり、ごく一般的なものです[^2]）

|アクティビティー|タップ時に呼び出される<br>`AppCompatActivity`のメソッド|インテントの取得方法|
|:----:|:----:|:----:|
|起動中|`onNewIntent`|`onNewIntent`の引数|
|未起動|`onCreate`|`getIntent`メソッド|

そしていずれの場合も、まったく同じ情報が詰め込まれたインテントを受信できるので、それを共通のメソッドで処理すればよいわけです。

```Java:MainActivity.java
    public static final String PUSH_NOTIFICATION_ACTION = "FCM";
    public static final String ARG_FRAGMENT = "fragment";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        FirebaseMessaging.getInstance().subscribeToTopic("TOPICNAME");
        int fragmentId = onIntent(getIntent());
        if (fragmentId == 0) {
            // 直近で表示していたFragmentのID値を得る
            fragmentId = PreferenceManager.getDefaultSharedPreferences(this).getInt(PreferenceKey.RECENT_FRAGMENT_POSITION, 0);
        }

        // other initializing

        changeFragment(fragmentId, true);
    }

    @Override
    public void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        changeFragment(onIntent(intent), false);
    }

    // 通知のIntentから表示させたいFragmentのIDを得る
    // この実装では、通知に{"fragment":"info"}というKVが含まれていたら、info画面を表示させようとしている
    // リフレクション使えば条件分岐は不要ですが(^^ゞ
    private int onIntent(Intent intent) {
        if (intent != null && "info".equals(intent.getStringExtra(ARG_FRAGMENT))) {
            return R.id.info;
        } else {
            return 0;
        }
    }

    // fragmentIdで指定されたFragmentがあればそれに切り替える独自メソッド
    // isFirstがtrueなら、fragmentIdが0の場合はデフォルトのFragmentを表示
    // falseなら何もしない
    private void changeFragment(int fragmentId, boolean isFirst) {
        // 各自がんばりましょう
    }
```

そして、通知のインテントを`MainActivity`が受信できるように、インテントフィルターをちゃんと設定します。理由は後述しますが、アクション名の`"FCM"`は、**`MainActivity.PUSH_NOTIFICATION_ACTION`と同一の文字列でなければなりません**。

```XML:AndroidManifest.xml
    <activity android:label="@string/app_name" android:name=".MainActivity"
        android:taskAffinity="com.wsf_lp.oritsubushi.map" android:launchMode="singleTask">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
      <intent-filter>
        <action android:name="FCM" />
        <category android:name="android.intent.category.DEFAULT" />
      </intent-filter>
    </activity>
```

### もっとも肝心な話: 各種文字列リテラルの意味を把握する

実はこれを、アプリ側だけでなく、自前サーバー側でもきちんとさせておかないと、「通知を発信して受信して表示を切り替える」という一連の動きのどこかでつまずきます。

というわけで、ここまでの一連のコードで出てきた文字列リテラルの一覧と、その意味をまとめてみました。

|文字列|登場箇所|意味|適用される箇所|
|:----:|:----:|:----:|:----:|
|"FCM"|マニフェスト<br>メインのアクティビティー|インテントのアクション名|**FCM通知ペイロードの`click_action`の値**<br>`onMessageReceived`で作成するインテント<br>`onNewIntent`に渡されてくるインテント<br>`onCreate`内で`getIntent`を呼ぶと得られるインテント|
|"fragment"|`FirebaseMessagingService`<br>メインのアクティビティー|ユーザーデータのキー名|**FCMデータペイロード内のキー名**<br>`onMessageReceived`で取得できるリモートデータ内のキー名|
|"info"|`FirebaseMessagingService`<br>メインのアクティビティー|ユーザーデータの値|**FCMデータペイロードの"fragment"の値**<br>`onMessageReceived`で取得できるリモートデータ内の値|

とまとめてしまうとシンプルなのですが、表の全項目に「FCMペイロード」が登場しています。実は、**FCMに送信するデータを渡す自前サーバー側のペイロードをきちんと理解する**ことこそが、もっとも肝要です。
すなわちこれは、**アプリの動作とプッシュ通知を連動させる場合、「Firebaseコンソールの通知画面からお手軽に通知内容を入力してポチッ（死語）」という手抜きが許されない**、ということでもあります（もちろん、後述のアイコン問題を考慮すれば、はじめから現状のコンソールは使い物にならないんですが）。

FCMペイロードについては[公式ドキュメントにひととおり出ています](https://firebase.google.com/docs/cloud-messaging/concept-options)が、これだけをうのみにしてもだめで、[全フォーマット](https://firebase.google.com/docs/cloud-messaging/http-server-ref)をちゃんと読むことは必須ではないかと思われます。

### FCMペイロード

それをお踏まえいただいた上で、私が自前サーバーからFCMサーバーへと送出しているJSONを挙げます。

```JSON
{
  "to": "アプリ内で登録したトピック名",
  "notification": {
    "title": "通知のタイトル",
    "body": "通知の本文",
    "click_action": "FCM",
    "icon": "notification"
  },
  "data": {
    "fragment": "info"
  }
}
```

FCMで最重要なのは`notification`、すなわち通知ペイロードです。
この中では`title`や`body`はともかく、`click_action`、これがひとつのキモとなります。これがまさにAndroidのプッシュ通知のインテントに含まれるアクションの値になるわけです[^3]。そしてこのアクションの値で、アプリのアクティビティーがよけいなインテントを受信せずに済むようにしてくれているのが、マニフェストのインテントフィルターの設定なのです。
なので、*FCMサーバーに送出する通知ペイロードの`click_action`とマニフェストに設定するインテントフィルターのアクション名は一致していなければならない*し、*フォアグラウンド時にも通知を表示するために自ら生成するインテントのアクション名も同じでなければなりません*。

一方、FCMで任意とされている`data`、すなわちデータペイロードですが、こちらはふつうの辞書になっています。そしてこれは、`FirebaseMessagingService#onMessageReceived`の引数で渡されてくる`RemoteMessage`にそのまま渡されているものですし、またアクティビティーの`onCreate`内部で呼び出した`getIntent`や`onNewIntent`の引数で取得できるインテントのエクストラとして設定されている値です。
なので、*それらのキー名は一致していなければならない*し、また*フォアグラウンド時にも通知を表示するために自ら生成するインテントに同じキー名のエクストラとして同じ値をコピーしなければなりません*。

なお、今回は国際化しないので、`title`や`body`にはそのまま日本語をベタ書くような感じですが、もし国際化が必要であれば[前職で書いたGCMの記事](https://www.mate-en.com/techblog/google-cloud-messaging-2.html)がそのまま参考になるはずです。

### 通知アイコン

そしてこれまたコードには直接には関係しませんが。
Androidでは通知の表示にアイコンが表示可能です。そして、FCMの前のGCMの、さらに古いバージョンにおいては、`GCMListenerService#onMessageReceived`はアプリがフォアグラウンドかバックグラウンドかにかかわらずに*必ず呼び出されて*（そのために`GCMListerService`はサービスでなければならなかった）、そしてその中で必ず自力で通知を作成していました（ちょうど、今のFCMでのフォアグラウンド時の`onMessageReceived`の中の処理そのもので）。当然そこで、通知のアイコンを設定することが自由に行えました。
しかしその後の新しいGCM、これはほぼ現在のFCMになりますが、ここから*バックグラウンド時には`onMessageReceived`が呼ばれない*という仕様が導入されました。
そしてその結果、バックグラウンド時にはアイコンを設定することができなくなります。それを回避するため、GCMの通知ペイロードに`icon`というキー名が追加され、それが現在のFCMにも引き継がれているわけです。

```JSON
{
  "notification": {
    "icon": "notification"
  }
}
```

そしてこれがまたド変態なのですが、上記のように`notification`という値を、`icon`に設定したとします。この`notification`という値は、アプリ側では`R.drawable.notification`にマップされるのでした。
もちろんこのシンボルはID値であり、アイコンが画像ファイルであれば、具体的には`res/drawable/notification.png`というファイル（当然ながら、その実体は解像度/SDKバージョン/ロカールなどで分けられたフォルダーに分散されています）をアイコンとして使う、ということになるわけです。
要は、**アイコン用のPNG画像のファイル名（拡張子除く）が、通知ペイロードの`icon`キーの値になる**ということです。つまり、通知アイコンを表示させるには、普通にアイコン画像を（たくさん！）作成してres/drawable/以下に適切に配置し、そのファイル名をFCMサーバーに渡してやればよい、ということです。
もちろん、フォアグラウンド時にも通知を表示するために、`onMessageReceived`の内部でも同じアイコンを設定しなければなりません。

この、「ファイル名の文字列がJavaコード上ではシンボル名になって参照すれば整数値になり、しかしそれをサーバーから呼び出すときは文字列で指定する」という、一周回って元に戻った感。さすがAndroidであります（胸焼け）[^4]。

## 結論

+ さすが自社プロダクツのセット、iOSよりもスッキリだしハマりどころもやや少ない…
+ でももういろいろとコードがぐちゃぐちゃ、ScalaやKotlinの採用も考えたい、それ以上に早くRxで光のオーロラを身にまといたい…

## 注釈

[^1]: SNSやチャットのアプリであれば、通知すべき内容はユーザーによって異なります。そこで自前のサーバー側でユーザーと端末のデバイストークン（もちろん同じユーザーが複数のデバイスを使うことがあるため、RDBならn:mテーブルで管理）をひも付けておいて、特定のユーザーへの通知をそのユーザーの全デバイスのみに送信する、という処理が必要となります。そしてその特定のユーザーのデバイスのみへの通知にはデバイストークンが必要であり、Androidではそのデバイストークンが変更されることがあり得る、ということで、その変更のたびに自前サーバーへとそれを知らせなければなりません。
[^2]: あぁWin32時代はこれ、名前付きMutexでやってたよなぁ…初期のWindows版Eclipseがまさにアイコンクリックしすぎて多重起動してしまってワークスペースが壊れるのを避けるために専用ランチャーを`CreateMutex`で書いたよなぁ…（老眼の結果遠い目）
[^3]: ちなみに`click_action`、これはiOSでは通知をスワイプすると出てくるボタン（アプリが対応していれば、ですが）＝カスタムアクションにひも付けられています。GCMから引き継がれてたキーですが、これで両OSに対応できているのはすごい！ とすなおに称賛です。
[^4]: もちろん、シンボルを整数値に変換してビルドする、というのは、めちゃくちゃリソースが貧弱だった古のAndroidでは必要だったことなのでしょう。そしてそうでなくなった現代でも、IDEのコード補完が気軽に働くというメリットはあります（古でも、VC++のバッドノウハウとして何でもかんでも謎のクラスに詰め込む形で悪用されてました）。いやしかし、Win32 APIの理不尽さのかなりの部分は、VBのために企業が内製したOLEコントロールのコードを互換させるためにWin16に引きずられ過ぎたことによる気もしますし（っていうか諸悪の根源はWin16よりも古いVBなのかも？？）、Androidでもメソッド64k制限とかいろいろなものに引きずられてますよね（結果、Scala化がたいへん）…歴史があるものはどうしてもそうなってしまうものなのか。