[Android] アプリ内アップデート AppUpdateManager / FakeUpdateManager ( in-app updates API ) のまとめ

[Google I/O 2019](https://android-developers.googleblog.com/2019/05/whats-new-in-play.html)で発表され正式公開されたin-app updates APIであるAppUpdateManagerを利用した、アプリ内でのアップデートチェックとアップデート対応方法について解説します。

また、初心者がハマるであろうポイントについてもお伝えしますが、in-app updates APIはGoogle Play ストア アプリと連携するためのAPIと考えた方が良いです。アップデート有無の判定自体はGoogle Play ストア アプリが行っています。そのため、<font color="Red">Google Playストアアプリがアプリの更新を認識してくれないと</font>、in-app updates apiは更新ありと判定してくれません。

2020/8/2追記
最新の`com.google.android.play:core:1.8.0`では少し仕様変更になっているようで、追って更新します。

## はじめに
#### API Level
AppUpdateManagerは Android 5.0 (API level 21) 以上でのみサポートされています。

#### 公式ドキュメント
公式なドキュメントのリンクを記載しておきます。

- [Support in-app updates](https://developer.android.com/guide/app-bundle/in-app-updates)
- [AppUpdateManager API](https://developer.android.com/reference/com/google/android/play/core/appupdate/AppUpdateManager.html)
- [FakeAppUpdateManager API](https://developer.android.com/reference/com/google/android/play/core/appupdate/testing/FakeAppUpdateManager)

## アップデートの種類
FlexibleとImmediateの2種類のフローが用意されています。デフォルトはどちらも利用可能で、アプリ開発者が好きな方でアップデート通知するように実装出来ます。

#### Flexibleフロー
Flexibleフローは更新を検出したらダイアログ形式で通知し、ユーザが更新ボタンを押したらバックグラウンドでダウンロードするフローです。ダウンロード中は通常通り、アプリを利用できます。

ダウンロード完了後は、ユーザに何らかの方法で通知して（公式サイトのサンプルだとSnackbar）、インストール作業に移行します。インストール時は全画面表示になり、インストール完了後に自動でアプリが再起動します。
![flexible_flow.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259963/56d5ebb0-229a-4052-4789-174e3b3dfc99.jpeg)

#### Immediateフロー
Immediateフローは更新検出後、全画面表示でアップデートを促します。ただし、右上の×ボタンや戻るボタンで簡単に消せるため、強制アップデートとまでは言えません。

ユーザが更新ボタンを押すと、そのままダウンロードおよびインストール作業に移行し、通常のアプリはバックグラウンドに移行します。インストール作業移行のフローはFlexibleと同じです。
![immediate_flow.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259963/137fd3bc-ea13-e0c3-bc51-c768e2671cfc.jpeg)

## 実装
githubに[サンプルコード](https://github.com/Kurun-pan/android-in-app-updates-sample/blob/master/app/src/main/java/com/example/in_app_updates_sample/MainActivity.kt)を用意していますので、併せて参照して下さい。

#### ライブラリ
PlayCoreのライブラリをapp/build.gradleに追加します。2019/10/27時点の最新バージョンは1.6.4です。各バージョンの詳細は[リリースノート](https://developer.android.com/reference/com/google/android/play/core/release-notes)を参照して下さい。

```xml:build.gradle
implementation 'com.google.android.play:core:1.6.4'
```

#### UpdateFlowステータスリスナーの登録と処理
InstallStateUpdatedListenerは、startUpdateFlowForResult後のイベントのコールバックイベントリスナーです。Flexibleフローの場合に、アップデートイメージのダウンロード完了のイベントをトリガーにして、ユーザに通知してインストール作業に移行させるために利用します。一方、インストール完了イベントトリガーでリスナーを解除します。

```Kotlin
installStateUpdatedListener = InstallStateUpdatedListener { installState ->
    when (installState.installStatus()) {
        InstallStatus.DOWNLOADED -> {
            Log.d(TAG, "Downloaded")
            updaterDownloadCompleted()
        }
        InstallStatus.INSTALLED -> {
            Log.d(TAG, "Installed")
            appUpdateManager.unregisterListener(installStateUpdatedListener)
        }
        else -> {
            Log.d(TAG, "installStatus = " + installState.installStatus())
        }
    }
}
appUpdateManager.registerListener(installStateUpdatedListener)
```

#### アップデート検知方法
肝心のアップデート検出については、appUpdateInfoTask.addOnSuccessListenerの部分が該当します。updateAvailabilityでUpdateAvailability.UPDATE_AVAILABLEが帰ってきた時にのみ、in-app apiでアップデート可能です。また、isUpdateTypeAllowedの部分で、許可されているアップデートタイプを確認します。ここはデフォルトでAppUpdateType.FLEXIBLE, AppUpdateType.IMMEDIATEの両方がtrueになるはずですので、自分が利用したいフローの方で先に進むように実装すると良いと思います。

そして、startUpdateFlowForResultの部分でアップデート作業に移行します。ここから先、FlexibleフローかImmediateフローかでUI表示等含めてフローが変わってきます。

```Kotlin
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnSuccessListener(playServiceExecutor, OnSuccessListener { appUpdateInfo ->
    when (appUpdateInfo.updateAvailability()) {
        UpdateAvailability.UPDATE_AVAILABLE -> {
            val updateTypes = arrayOf(AppUpdateType.FLEXIBLE, AppUpdateType.IMMEDIATE)
            run loop@{
                updateTypes.forEach { type ->
                    if (appUpdateInfo.isUpdateTypeAllowed(type)) {
                        appUpdateManager.startUpdateFlowForResult(appUpdateInfo, type, this, REQUEST_UPDATE_CODE)
                        return@loop
                    }
                }
            }
        }
        else -> {
            Log.d(TAG, "updateAvailability = " + appUpdateInfo.updateAvailability())
        }
    }
})
```

#### エラー発生時の処理
startUpdateFlowForResultに対するonActivityResultの処理の部分はあまり大した事がなく、エラーが起きた時に再度startUpdateFlowForResultを発行するためのもの程度で捉えて頂いて大丈夫です。

```Kotlin
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, intent)
    if (requestCode == REQUEST_UPDATE_CODE) {
        if (resultCode != RESULT_OK) {
            // If the update is cancelled or fails, you can request to start the update again.
            Log.e(TAG, "Update flow failed! Result code: $resultCode")
        }
    }
}
```

#### ダウンロート完了とインストール開始通知（Flexibleフローのみ）
Flexibleフローの場合、ダウンロード完了のイベントをトリガーにユーザにインストール作業に移行を促します。サンプルコードはSnackbarで通知した例ですが、appUpdateManager.completeUpdate()の部分を実行することで、勝手にインストール作業に移行して、アプリが再起動します。

```Kotlin
private fun updaterDownloadCompleted() {
    Snackbar.make(
        findViewById(R.id.activity_main_layout),
        "An update has just been downloaded.",
        Snackbar.LENGTH_INDEFINITE
    ).apply {
        setAction("RESTART") { appUpdateManager.completeUpdate() }
        show()
    }
}
```

#### アプリ再開時のケア
アプリがバックグラウンドに移り、再度アプリがフォアグランドに来たときのケアが必要にあります。Flexibleフローの場合は、ダウンロード完了状態であれば再度インストール促し表示に移ります。Immediateフローの場合には、全画面表示をさせます。

```Kotlin
override fun onResume() {
    super.onResume()
    appUpdateManager.appUpdateInfo.addOnSuccessListener(playServiceExecutor, OnSuccessListener { appUpdateInfo ->
        if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
            // If the update is downloaded but not installed,
            // notify the user to complete the update.
            if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED)
                updaterDownloadCompleted()
        } else {
            // for AppUpdateType.IMMEDIATE only
            // already executing updater
            if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
                appUpdateManager.startUpdateFlowForResult(
                    appUpdateInfo,
                    AppUpdateType.IMMEDIATE,
                    this,
                    REQUEST_UPDATE_CODE
                )
            }
        }
    })
}
```

## ハマるポイント

#### updateAvailabilityがUpdateAvailability.UPDATE_NOT_AVILABLE(1)しか来ない

ガイドライン通りに実装後のデバッグ時に想定外にはまりました。Google Playストアにアップしている最新版のバージョンコードを取得して、現在プロジェクトのバージョンコードと比較して現在のコードが古ければアップデート検出してくれるよね？って思っていましたが、実はそうではありません。

そのため、いくら動かしてもupdateAvailabilityがUpdateAvailability.UPDATE_NOT_AVILABLE(1)しか返して来ず、先に進めないのです。理由は簡単で、appUpdateInfo.availableVersionCodeが常に0でストア側が古いと認識しているためです。

同じような事象で悩んだ人が複数居る様子です。

- [Android In App Updates - Not able to detect the update in AppUpdateInfo](
https://stackoverflow.com/questions/56118563/android-in-app-update-api-showing-update-not-available-while-testing-on-debuggin)
- [Android In-App update API return 0 as availableVersionCode & updateAvailability as 1 (UPDATE_NOT_AVAILABLE)](https://stackoverflow.com/questions/56253593/android-in-app-update-api-return-0-as-availableversioncode-updateavailability)

<font color="Red">実はこれは正しい動き</font>で、前述の通りアップデート検知処理自体はGoogle Playストアアプリが行っているためです。そのため、リリースする前にデバッグするには後述のテスト専用のFakeAppUpdateManagerを利用する必要があります。

#### Google Playストアアプリが更新を認識しているけどUPDATE_NOT_AVILABLEが来る

アプリ説明ページやベータ版のタブで更新と認識しているだけでは駄目な様子です。以下の様に、アップデートタブにリストアップされる必要があります。Playストアアプリのキャッシュ更新アルゴリズムは不明です。

|<img width="216" height="444" alt="Screenshot_20191022-084741.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259963/e4e7355b-7ce5-4c5d-bcf0-74a0e2afab9c.png"/>|
|:-:|

#### どうしても駄目な場合の最終手段

Google Playストアアプリのキャッシュ更新（アップデート検出サイクルなど）が不明のため、手っ取り早く更新を認識してもらうには以下の方法があります。Playストアアプリを完全に終了させる方法で私の場合はほぼ更新認識されるようになりました。

- Google Playストアアプリを完全に終了させて（Kill）、Playストアアプリを再起動
- Google Playストアアプリのデータ＆キャシュ削除

|<img width="216" height="444" alt="Screenshot_20191022-141254.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259963/736cae2d-a270-5278-35e8-2db9cff91a27.png"/>|
|:-:|

## テストコード
テスト用の[サンプルコード](https://github.com/Kurun-pan/android-in-app-updates-sample/blob/master/app/src/androidTest/java/com/example/in_app_updates_sample/MainActivityTest.kt)を用意しておきました。

ポイントは以下の2点です。

- FakeAppUpdateManagerはUnitTest用のためUI表示が一切されない
    - そのため、自分でAPIを叩いて先に進める必要がある
- アップデートタイプやバージョンコードは自由に設定出来る

こんな感じでテスト出来ます。

```Kotlin
@Test
fun test_FlexibleUpdateSuccess() {
    fakeAppUpdateManager.partiallyAllowedUpdateType = AppUpdateType.FLEXIBLE
    fakeAppUpdateManager.setUpdateAvailable(10)

    ActivityScenario.launch(MainActivity::class.java)

    assertTrue(fakeAppUpdateManager.isConfirmationDialogVisible)

    fakeAppUpdateManager.userAcceptsUpdate()
    fakeAppUpdateManager.downloadStarts()
    fakeAppUpdateManager.downloadCompletes()

    Espresso.onView(
        allOf(
            isDescendantOfA(instanceOf(Snackbar.SnackbarLayout::class.java)),
            instanceOf(AppCompatButton::class.java)
        )
    ).perform(ViewActions.click())

    assertTrue(fakeAppUpdateManager.isInstallSplashScreenVisible)
    fakeAppUpdateManager.installCompletes()
}
```

#### Google Playストアアプリのデータ削除
FakeAppUpdateManagerを使った場合の注意点ですが、setUpdateAvailableのAPIで設定したバージョンコードはしばらく残り続けます。そのため、前述のGoogle Playストアアプリのデータ削除（ストレージを消去）により、設定したバージョンコードを削除（0になる）しておいた方が無難です。

## 最後に
公式ドキュメントの情報不足でかなりはまりましたが、FirebaseのRemote Configを利用したアップデート検出＆促しに比べて、一度実装するとメンテナンスフリーで対応出来るため楽で良いと思います。

**Google Playストアアプリがインストールされていない特に中国とかの環境だとどうなるの？？**

間違い等あればご指摘頂けますと助かります。