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

  • 6
    Like
  • 0
    Comment

やりたいこと

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

レギュレーション

  • Android Studio 2.2
  • 最小APIバージョンは9 (FCM最低値)
  • ターゲットAPIバージョンは25
  • ふつうにJava
  • Rxってなんですか?
  • MainActivityクラスで実装されている唯一のアクティビティー
  • そのlaunchModeはSingleTask
  • 画面の切り替えはMainActivity上で複数のフラグメントが入れ替わることで実現

はじめに謝辞

iOSでは正直あまりまとめて参考にできる資料がなかったのでかなり自力での実装になりましたが、Androidに関してはこちらの記事が大変参考になりました。ただ、今回実装したかったことに関しては少々追加知見も必要でしたので、それも含めてのこの記事となっています。

実装の要点

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

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

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

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

        // other initializing...
    }

デバイストークンの取得

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

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

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」の章)に挙げた、「Androidで/標準的な通知を/バックグラウンド時のみに受信する場合、コードを書く必要がない」というスッキリ仕様は変更されませんでした。
ただし今回は、フォアグラウンド時にも通知を処理しなければなりません。
そのためには、FirebaseMessagingService#onMessageReceivedをオーバーライドすればよいので、そうします。その中で、バックグラウンド時に表示される通知と同じ内容を自ら生成して、ペンディングインテントとして登録すればよいわけです。

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

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());
    }
}

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

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

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

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

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と同一の文字列でなければなりません

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" マニフェスト
メインのアクティビティー
インテントのアクション名 FCM通知ペイロードのclick_actionの値
onMessageReceivedで作成するインテント
onNewIntentに渡されてくるインテント
onCreate内でgetIntentを呼ぶと得られるインテント
"fragment" FirebaseMessagingService
メインのアクティビティー
ユーザーデータのキー名 FCMデータペイロード内のキー名
onMessageReceivedで取得できるリモートデータ内のキー名
"info" FirebaseMessagingService
メインのアクティビティー
ユーザーデータの値 FCMデータペイロードの"fragment"の値
onMessageReceivedで取得できるリモートデータ内の値

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

FCMペイロードについては公式ドキュメントにひととおり出ていますが、これだけをうのみにしてもだめで、全フォーマットをちゃんと読むことは必須ではないかと思われます。

FCMペイロード

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

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

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

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

なお、今回は国際化しないので、titlebodyにはそのまま日本語をベタ書くような感じですが、もし国際化が必要であれば前職で書いたGCMの記事がそのまま参考になるはずです。

通知アイコン

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

{
  "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化がたいへん)…歴史があるものはどうしてもそうなってしまうものなのか。