FirebaseのRemoteConfigを用いてAndoridアプリのアップデート訴求通知を出す方法を考えてみた

#はじめに
先日「[のぞきみ](https://play.google.com/store/apps/details?id=com.masaibar.nozokimi&referrer=qiita_updatechecker)」という、メッセージアプリに既読をつけずに読むためのアプリをリリースしました。
今後アプリのユーザを増やしていくために機能をアップデートさせていく予定なのですが、GooglePlayアプリの設定次第ではアプリの更新が自動でかからないユーザーもおり、ストアに最新版があることを既存ユーザに認知してもらうための仕組みが欲しいと考えました。

実際、Facebookアプリなんかでは「ストアに最新版があります」という通知を表示していたりしますが、恐らく更新を知らせるためのAPIを定期的に叩いていたりするのではないかと思います。
しかし正攻法で、サーバを立てて、APIを作って、更にはその管理…というのは個人開発としては作業量が膨れ上がりそうなので出来れば避けたいところです。
また、次に考えつくのがGooglePlayのスクレイピングですが、DOMの変更により打撃を受けやすかったり、アップデート通知を出す出さないも開発者側でコントロールしにくいので選択肢から外しました。
（ちなみにiPhoneアプリではiTunes APIなるものがあり、スクレイピングせずともバージョンを取得できそうでした、羨ましい。）

そこで考えたのが、FirebaseのRemoteConfigを用いたアップデート訴求通知の実装です。


#概要
Firebaseのコンソールから入稿し、端末側でアップデート訴求通知を表示させるための仕組みを説明します。
RemoteConfigの組み込み等は本稿では触れませんので、[公式ドキュメント](https://firebase.google.com/docs/remote-config/)等でご確認下さい。

#実装
デフォルト定義のためのxmlファイル、アップデート検知のためのクラス、そして呼び出し元の実装の例を示します。

##デフォルト定義ファイル
まずはresの下にxmlのディレクトリを作成し、その中にxmlファイルを追加します。
取得した値が不正だったときや、入稿していないときは端末内でこの値が参照されます。
中身の要素としては下記の5つを定義しておきます。

* 最新バージョンコード
* 通知表示のためのフラグ
* 通知のTicker文言
* 通知のTitle文言
* 通知のContent文言
 
```xml:app/src/main/res/xml/remote_config_defaults.xml
<?xml version="1.0" encoding="utf-8"?>
<defaultMap>
    <entry>
        <key>latestVersionCode</key>
        <value>0</value>
    </entry>
    <entry>
        <key>showNotificationFlag</key>
        <value>false</value>
    </entry>
    <entry>
        <key>notificationTicker</key>
        <value>ストアに最新のバージョンがあります。アップデートしてください。</value>
    </entry>
    <entry>
        <key>notificationTitle</key>
        <value>最新バージョンがあります</value>
    </entry>
    <entry>
        <key>notificationContent</key>
        <value>ストアで最新版にアップデートする</value>
    </entry>
</defaultMap>
```

##アップデート検知クラス

RemoteConfigをフェッチしてきて、アップデートの有無と通知を表示する必要の有無を検知するクラスです。
通知を表示する条件(通知フラグON,自分より新しいバージョンがストアに有り)に合致した際にはshouldNotify()が呼ばれ、最新バージョン、通知の文言を渡してくれます。

```java:UpdateChecker.java
public class UpdateChecker {
    private static final String KEY_LATEST_VERSION_CODE = "latestVersionCode";
    private static final String KEY_SHOW_NOTIFICATION_FLAG = "showNotificationFlag";
    private static final String KEY_NOTIFICATION_TICKER = "notificationTicker";
    private static final String KEY_NOTIFICATION_TITLE = "notificationTitle";
    private static final String KEY_NOTIFICATION_CONTENT = "notificationContent";

    private int mConfigId;

    public UpdateChecker(int configId) {
        mConfigId = configId;
    }

    public interface UpdateCheckerListener {
        void shouldNotify(int latestVersionCode, String ticker, String title, String content);
    }

    /**
     * ticker, title, contentのどれかが（空の文字列）の場合は通知を表示しない。
     * ※デフォルト値を適用したい場合にはコンソールで（値なし）を選択する。
     */
    public void check(
            Activity activity, UpdateCheckerListener listener) {
        Context context = activity.getApplicationContext();

        FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.getInstance();

        initRemoteConfig(remoteConfig);
        fetch(activity, remoteConfig);
        String ticker = getStringValue(remoteConfig, KEY_NOTIFICATION_TICKER);
        String title = getStringValue(remoteConfig, KEY_NOTIFICATION_TITLE);
        String content = getStringValue(remoteConfig, KEY_NOTIFICATION_CONTENT);

        if (TextUtils.isEmpty(ticker) || TextUtils.isEmpty(title) || TextUtils.isEmpty(content)) {
            return;
        }

        if (!shouldNotify(context, remoteConfig)) {
            return;
        }

        if (listener == null) {
            return;
        }

        listener.shouldNotify(getLatestVersionCode(remoteConfig), ticker, title, content);
        }
    }

    private void initRemoteConfig(FirebaseRemoteConfig remoteConfig) {
        FirebaseRemoteConfigSettings.Builder builder = new FirebaseRemoteConfigSettings.Builder();
        if (BuildConfig.BUILD_TYPE.equals("debug")) {
            builder.setDeveloperModeEnabled(BuildConfig.DEBUG);
        }

        remoteConfig.setConfigSettings(builder.build());
        remoteConfig.setDefaults(mConfigId);
    }

    private void fetch(Activity activity, final FirebaseRemoteConfig remoteConfig) {
        long catchExpiration = 3600;
        if (remoteConfig.getInfo().getConfigSettings().isDeveloperModeEnabled()) {
            catchExpiration = 0;
        }

        remoteConfig.fetch(catchExpiration)
                .addOnCompleteListener(activity, new OnCompleteListener<Void>() {
                    @Override
                    public void onComplete(@NonNull Task<Void> task) {
                        if (!task.isSuccessful()) {
                            return;
                        }

                        remoteConfig.activateFetched();
                    }
                });
    }

    /**
     * 通知表示フラグがtrueかつ、現在のバージョンコードよりも新しいアプリがあったらtrueを返す
     */
    private boolean shouldNotify(
            Context context, FirebaseRemoteConfig remoteConfig) {

        //通知表示フラグを確認
        if (!remoteConfig.getBoolean(KEY_SHOW_NOTIFICATION_FLAG)) {
            return false;
        }

        int currentVersionCode = getVersionCode(context, context.getPackageName());

        return currentVersionCode < getLatestVersionCode(remoteConfig);
    }

    private int getLatestVersionCode(FirebaseRemoteConfig remoteConfig) {
        int latestVersionCode = 0;
        try {
            latestVersionCode = Integer.parseInt(remoteConfig.getString(KEY_LATEST_VERSION_CODE));
        } catch (NumberFormatException ignored) {
        }
        return latestVersionCode;
    }

    private String getStringValue(FirebaseRemoteConfig remoteConfig, String key) {
        return remoteConfig.getString(key);
    }

    public int getVersionCode(Context context, String packageName) {
        PackageInfo info = null;

        try {
            info = context.getPackageManager().getPackageInfo(packageName, 0);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }

        return info == null ? -1 : info.versionCode;
    }
}
```

##呼び出し元
どこかのActivityで呼ばれることを想定しています、ココではonCreate()で呼んでいます。
上述のUpdateCheckerクラスのコンストラクタにデフォルト値の入ったxmlのidを渡し、checkメソッドを呼びます。
通知を表示するべき条件に合致するとshouldNotify()が呼ばれるので、実際に通知を表示する処理はココから呼んでください。
また、onCreateのたびに通知が表示されると鬱陶しいので、実際には通知送信処理の前後に今回のlatesetVersionCodeでは既に送信していたら送信しない、送信したlatestVersionCodeを保存しておくみたいな処理を入れておいたほうが良いと思います。

```java:任意のActivityなど

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    new UpdateChecker(R.xml.remote_config_defaults)
            .check(this, new UpdateChecker.UpdateCheckerListener() {
                @Override
                public void shouldNotify(int latestVersionCode, String ticker, String title, String content) {
                    //todo latestVersionを比較して、既に出してたら出さない的な処理
                    
                    //todo 通知を送信する
                        
                    //todo 通知を送信したlatestVersionを保存
                }
           });
}
```

#入稿管理
入稿はFirebaseのRemoteConfigコンソールから行います。
今回例として「端末側アプリのバージョンコードが10として、ストアにバージョン11がアップロードされた際に通知を出す」想定で入稿してみます。
また、アプリ側はMainActivityのonCreateでアップデート検知処理が走るものとします。

|key                 |value             |
|:------------------:|:----------------:|
|latestVersionCode   |11                |
|showNotificationFlag|true              |
|notificationTicker  |(値なし)           |
|notificationTitle   |新機能追加されました！|
|notificationContent |(値なし)           |


![image](https://qiita-image-store.s3.amazonaws.com/0/68671/464e7453-f921-9f45-82d6-89d1f7ad732a.png)

全部入力し終わったら右上にある**変更を公開**を押します。
公開すると、変更内容がアプリとユーザに**即座に**適用されます、と脅し文句が出てきますが厳密には端末側でfetchを呼ばない限り反映されることはない模様です。

![image](https://qiita-image-store.s3.amazonaws.com/0/68671/f7622c60-d11b-bb35-37f1-e0927fce2aea.png)

この状態でアプリを起動してみるとタイトルのみ入稿された値が反映され、それ以外はデフォルト値が設定された通知が無事に表示されました。
![image](https://qiita-image-store.s3.amazonaws.com/0/68671/eaa2a7e9-66fc-4471-c78c-b4b425af29a8.png)

一点注意しなければならないのは、コンソールの**(値なし)**と**(空の文字列)**は似て非なるということです。
(値なし)で設定するとxmlで定義されたデフォルト値が反映されますが、空の文字列で設定してしまうと取得されるテキストが空になり、そのまま通知してしまうとタイトルや本文のない事故みたいな通知になることがあります。

※上述のUpdateChecker.javaでは安全のためにticker, title, contentのいずれかが空のときは早期リターンをするように作ってあります。

#終わりに
本来RemoteConfigはA/Bテストなどに用いるようですが思いついたので作ってみたところ自分の欲しい要件は満たしてくれました。
他にもっと楽でいい方法をご存じの方は教えてください。