Android で Firebase In-App Messaging が表示されない問題の workaround

# はじめに

■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
本件は [In-App Messaging version 19.0.7](https://firebase.google.com/support/release-notes/android?hl=ja#inappmessaging_v19-0-7) で改修されました :tada: 
■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

Android で Firebase In-App Messaging を表示させてようとしても、アプリの作り次第では、 **表示されない or すぐに消えてしまう問題** が発生することが確認されました。
全てのケースで解決しないかも知れませんが、意図通りに表示できる workaround を見つけたので記しておきます。

Firebase In-App Messaging 導入時の参考になれば幸いです。

※調査した技術内容が多めです
※キャンペーン情報の取得完了のタイミングや、メッセージを表示したい Activity の lifecycle の状態次第では上手く行かないケースが存在するかも知れません
→　技術的背景を理解した上で、最適な workaround を使うことをオススメします。

## 本記事での用語

| 用語 | 意味 |
| --- | --- |
| LaunchActivity | `AndroidManifest.xml` で `android.intent.category.LAUNCHER` が指定されている Activity |
| MainActivity | LaunchActivity から起動される Activity |
| In-App Message | Firebase In-App Messaging が表示するメッセージ<br>（com.google.firebase.inappmessaging.model.InAppMessage というクラスがあります） |

# 再現条件
In-App Message を表示した後、何らかの Activity（最前面になくても良い）が destroy されると、表示されている In-App Message が消える。
（タイミングによっては、表示されたことに気付けない）

## ありがちな例
`LaunchActivity` が、 onCreate で他の Activity を呼び出して、 `LaunchActivity` は即閉じるような場合には再現します。

## 実装例
```Kotlin
class LaunchActivity : AppCompatActivity() {

    // Firebase In-App Messaging SDK はこの Activity にメッセージを表示させようとするが、
    // すぐに画面遷移してしまうため、メッセージが表示されない（or すぐ消える）
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_launch)

        if (isLogin) {
            openMainActivity()
        } else {
            openLoginActivity()
        }
        // ...
    }

    private fun openMainActivity() {
        val intent = Intent(this, MainActivity::class.java)
        //finish() で閉じなくても、↓でも再現
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
        startActivity(intent)
    }
}
```

# なぜ消えるのか？
Firebase In-App Messaging の内部では、 `Application.ActivityLifecycleCallbacks` を実装したクラス（ `com.google.firebase.inappmessaging.display.FirebaseInAppMessagingDisplay` ）が、アプリケーション内の Activity の Lifecycle を監視して、In-App Message の制御を行っています。

`onActivityDestroyed` の override 実装を見ると、
https://github.com/firebase/firebase-android-sdk/blob/master/firebase-inappmessaging-display/src/main/java/com/google/firebase/inappmessaging/display/FirebaseInAppMessagingDisplay.java#L222-L228

```java
@Override
public void onActivityDestroyed(Activity activity) {
  // clear all state scoped to activity and dismiss fiam
  headlessInAppMessaging.clearDisplayListener();
  imageLoader.cancelTag(activity.getClass());
  removeDisplayedFiam(activity);
  super.onActivityDestroyed(activity);
}
```

のようになっており、 Activity が destroy されたら、 `removeDisplayedFiam(...)` が呼び出されています。
※ `Fiam` は **F**irebase **I**n-**A**pp **M**essaging のことです

特筆すべきは、最前面にある Activity かどうかは考慮していないということです。
（つまり、最前面にない Activity が destroy されることを SDK 側が考慮できていません）

# workaround

`MainActivity` にて、 `LaunchActivity` が onDestroy されるまで In-App Messaging の表示を遅らせる。

と、端的に言っても、いくつかステップがあります。

1. In-App Message の表示を抑制しておく
2. `MainActivity` にて、 `LaunchActivity` が onDestroy されたことを検出する
3. In-App Message の表示を抑制を解除して、In-App Message の表示処理を呼び出す

です。

## 1. 表示の抑制方法
`FirebaseInAppMessaging.getInstance().setMessagesSuppressed(true)` を呼び出せば、In-App Message の表示を抑制することができます。

```FirebaseInAppMessaging.java
/**
 * Enable or disable suppression of Firebase In App Messaging messages
 *
 * <p>When enabled, no in app messages will be rendered until either you either disable
 * suppression, or the app restarts, as this state is not preserved over app restarts.
 *
 * <p>By default, messages are not suppressed.
 *
 * @param areMessagesSuppressed Whether messages should be suppressed
 */
@Keep
public void setMessagesSuppressed(@NonNull Boolean areMessagesSuppressed) {
  this.areMessagesSuppressed = areMessagesSuppressed;
}
```

実装を見てもわかるとおり、単に suppress するか否かのフラグの書き換えだけです。
suppress を false にしたからといって、そのタイミングで In-App Message の表示処理が実行されるわけではありません。
（JavaDoc コメントにもそのあたりの言及はありませんでした…）

## 2. `LaunchActivity#onDestroy` の検出
`Application.ActivityLifecycleCallbacks` を使えば検出が可能です。
後述しますが、In-App Messaging は最前面の Activity が resumed になったら表示処理が実行されるので、

* `LaunchActivity#onDestroy` になった
* `MainActivity#onResume` になった

の2つの条件を満たしたときに、 `MainActivity` で何らかの callback を受け取れるようにすれば OK です。

もうちょっとやりようはあるかも知れませんが…

```InAppMessagingDelayHelper.kt
object InAppMessagingDelayHelper : Application.ActivityLifecycleCallbacks {

    private val targetActivityName: String = MainActivity::class.simpleName.orEmpty()
    private val backgroundActivityName: String = LaunchActivity::class.simpleName.orEmpty()

    private val destroyed = MutableLiveData<Boolean>().apply { value = false }
    private val resumed = MutableLiveData<Boolean>().apply { value = false }

    init {
        FirebaseInAppMessaging.getInstance().setMessagesSuppressed(true)
    }

    val canShow: LiveData<Boolean> = Transformations.distinctUntilChanged(
        MediatorLiveData<Boolean>().apply {
            value = false
            listOf(destroyed, resumed).forEach { liveData ->
                addSource(liveData) {
                    val isDestroyed = destroyed.value ?: false
                    val isResumed = resumed.value ?: false
                    value = isDestroyed && isResumed
                }
            }
        }
    )

    override fun onActivityDestroyed(activity: Activity?) {
        destroyed.value = (backgroundActivityName == activity?.localClassName)
    }

    override fun onActivityResumed(activity: Activity?) {
        resumed.value = (targetActivityName == activity?.localClassName)
    }

    override fun onActivityPaused(activity: Activity?) = Unit

    override fun onActivityStarted(activity: Activity?) = Unit

    override fun onActivitySaveInstanceState(activity: Activity?, outState: Bundle?) = Unit

    override fun onActivityStopped(activity: Activity?) = Unit

    override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) = Unit

}
```
を作って、 Application で `registerActivityLifecycleCallbacks` しておけば

```kotlin
override fun onCreate() {
    super.onCreate()
    registerActivityLifecycleCallbacks(InAppMessagingDelayHelper)
    ...
}
```
`MainActivity` で *LaunchActivity is destroyed && MainActivity is resumed* の状態になったコールバックを受け取ることができます。

```MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    ....

    InAppMessagingDelayHelper.canShow.observe(this, Observer { canShow ->
        if (canShow) {
            // LaunchActivity is destroyed && MainActivity is resumed
            // TODO : In-App Message の表示処理の呼び出し
        }
    })
    ...
}

```
## 3. In-App Message の表示処理を呼び出す
In-App Message の表示処理は、 `FirebaseInAppMessagingDisplay#onActivityResumed` から呼び出されています。

```FirebaseInAppMessagingDisplay.java
@Override
public void onActivityResumed(Activity activity) {
  super.onActivityResumed(activity);
  if (inAppMessage != null) {
    showActiveFiam(activity);
  }
}
...
private void showActiveFiam(@NonNull final Activity activity) {
  if (inAppMessage == null || headlessInAppMessaging.areMessagesSuppressed()) {
    Logging.loge("No active message found to render");
    return;
  }
  // 表示処理
}
```
`showActiveFiam(...)` は private ですが、呼び出し元の `onActivityResumed(...)` は public なので、
`FirebaseInAppMessagingDisplay.getInstance()` でインスタンスを取得すれば呼び出すことができます。

つまり、

```diff
InAppMessagingDelayHelper.canShow.observe(this, Observer { canShow ->
    if (canShow) {
-       // LauncherActivity is destroyed && MainActivity is resumed
-       // TODO : In-App Message の表示処理の呼び出し
+       FirebaseInAppMessaging.getInstance().setMessagesSuppressed(false) // 抑制解除
+       FirebaseInAppMessagingDisplay.getInstance().onActivityResumed(this) // 表示処理の呼び出し
    }
})

```
のようにすれば、In-App Message がちゃんと表示されるようになります。

アプリケーションによって、Landing する Activity までに、どんな Activity が表示されては消えるのかがまちまちだと思うので、それぞれのアプリケーションに応じた回避方法を採る必要があると考えています。
あくまでも参考程度にして下さい。

本件は Firebase Android SDK の GitHub Issue で報告しているので、あわよくば、将来的にはこの workaround は不要になるかも知れません。
（不要になることを祈ってます。）
https://github.com/firebase/firebase-android-sdk/issues/1324

　
# それ以外に調べた事
調査したときにわかったことを、ついでなので記しておきます。

## メッセージレイアウトのトップバナーは Push 通知ではない
これは、単に勘違いしていただけですが、一応記載。
![スクリーンショット 2020-03-22 1.16.17（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9695/e61cc081-4657-9423-fdac-ea25c5b8f0b5.png)
は
![スクリーンショット 2020-03-22 1.16.21（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9695/334b5ca8-8500-1491-571a-fd0533ee059d.png)
こんな感じで Push 通知っぽく表示されますが、Push 通知ではありません。
**つまり、アプリを開かないと表示されません。**

アプリの起動を促進させるためには、[Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) を使う必要があります。
※In-App Messaging と Cloud Messaging を併用すれば、Push 通知経由でアプリを開いた場合のみに、特定の In-App Message を表示させることも可能です。


## In-App Message を表示している状態で別 Activity を表示させたときの挙動
新たに開いた Activity の上に、In-App Message が新たに表示される。
（既に表示している In-App Message が、新たに開いた Activity に隠れる…と思ってましたが、大丈夫でした！）

## In-App Message 表示させる方法
### 1. キャンペーンを作成して、テストデバイスに送る
https://firebase.google.com/docs/in-app-messaging/get-started?authuser=0&platform=android
によると、
> 電力を節約するため、Firebase アプリ内メッセージングはサーバーからのメッセージの取得を 1 日に 1 回だけ行います。この設定の場合、テストが困難になることがあるため、メッセージをオンデマンドで表示するテストデバイスを Firebase コンソールで指定できます。

と書かれています。
この手ももちろん使えます。
（でも、若干めんどくさい…）

### 2. コードでダミーのメッセージを生成して、メッセージの表示処理を呼び出す

[Firebase Android SDK のソースコード](https://github.com/firebase/firebase-android-sdk/blob/master/fiamui-app/src/main/java/com/example/firebase/fiamui/MainActivity.java#L289-L299) を見てみると、どうやら、コードで In-App Messaging の表示処理を実行することができそうです。

リンク先が変わるかも知れないので、コードを引用しておきます。

```com.example.firebase.fiamui.MainActivity.java
ModalMessage message =
    builder
        .setBackgroundHexColor(bodyBackgroundColorString)
        .setTitle(title)
        .setBody(body)
        .setImageData(imageData)
        .setAction(modalAction)
        .build(campaignMetadata, data);

FirebaseInAppMessagingDisplay.getInstance()
    .testMessage(this, message, new NoOpDisplayCallbacks());
```

これを `LaunchActivity#onCreate(...)` などで実行します。
この手を使うと、色んなタイミングで In-App Messaging の表示処理の呼び出しを再現することが可能です。
表示処理の呼び出しのタイミングを変えてみたり、表示時や表示中の Activity の状態を変えてみるには、この方法が手軽でオススメです。

具体的に、どんな場合に In-App Messaging の表示処理が呼び出されるのかは後述します。

## In-App Message の表示処理の発動
```InAppMessageStreamManager.java
public Flowable<TriggeredInAppMessage> createFirebaseInAppMessageStream() {
  return Flowable.merge(
          appForegroundEventFlowable,
          analyticsEventsManager.getAnalyticsEventsFlowable(),
          programmaticTriggerEventFlowable)
      .doOnNext(e -> Logging.logd("Event Triggered: " + e))
      .observeOn(schedulers.io())
      ...色んな処理... // キャンペーン取得の通信が絡めば時間が掛かる可能性がある
      .observeOn(schedulers.mainThread())
```

これを、 `FirebaseInAppMessaging` の constructor で subscribe しています。
（内部では RxJava を使ってるんですね！）

```FirebaseInAppMessaging.java
public class FirebaseInAppMessaging {
  FirebaseInAppMessaging(...) {
    ...
    Disposable unused =
        inAppMessageStreamManager
            .createFirebaseInAppMessageStream()
            .subscribe(FirebaseInAppMessaging.this::triggerInAppMessage);
  }
  ...
  private void triggerInAppMessage(TriggeredInAppMessage inAppMessage) {
    if (this.fiamDisplay != null) {
      fiamDisplay.displayMessage(
          inAppMessage.getInAppMessage(),
          displayCallbacksFactory.generateDisplayCallback(
              inAppMessage.getInAppMessage(), inAppMessage.getTriggeringEvent()));
    }
  }
```
* アプリが最前面に来たとき
* Analytics の Event が発行されたとき
* programmaticTriggerEvent が発行されたとき

に In-App Message の表示処理が開始されます。
ただし、バックグラウンド処理が完了してから、UI への表示処理の実行になるので、その間に Activity が遷移・終了している可能性も十分あり得ます。

しかしながら、UI への表示処理を行ったタイミングで最前面の Activity に表示されるようになっていました。
（逆に言えば、 `MainActivity#onCreate` でイベントを送信し、それをトリガーに In-App Messaging を表示するように設定しても、画面遷移をしてしまえば、別の Activity で表示されることもある）