iOS, Androidアプリの強制アップデート（サーバーレス）

# はじめに
[文字列→塩基配列の相互変換ツールをつくってみた(アプリ版)](https://qiita.com/am10/items/3ecac20451c82133e900)でつくったアプリ

* [Mac](https://apps.apple.com/jp/app/dna%E5%A4%89%E6%8F%9B/id1494127578?mt=12)
* [iOS](https://apps.apple.com/jp/app/id1493994947)
* [Android](https://play.google.com/store/apps/details?id=am10.dnaconverter)

これに強制アップデート機能（半強制で抜け道あり）をつけてみました。

特に必要なわけではないですがこの記事([[iOS]アプリに強制アップデート機能を導入すべき理由と、簡単に実装する方法](https://qiita.com/kazu0620/items/f2f6c711c8800d127eb2))をみてやりたいと思い実装してみました。

が！！サーバーを用意するのはめんどくさいと思いサーバーなしで強制アップデート機能のようなものをつけてみました。

# iOS&Mac
## 方法
[iTunes Search API](https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup)というのがあるらしくこれを使うとアプリの情報が取れるそうです。

下記のURLのアプリIDに指定のアプリを設定するとそのアプリ情報が取得できます。

```
https://itunes.apple.com/lookup?id=[アプリID]
```

取得した情報からバージョンをみてBundleのバージョンと比較してアプリストアに遷移させるようにすれば強制アップデートのようなことができます。

## ソース
[iOS&Macアプリソース](https://github.com/adventam10/DNAConverter-ios)

```swift:AppStoreModel.swift
struct AppStoreModel {
    private let version = Version(version: Bundle.main.version!)
    private var appId: String {
        #if targetEnvironment(macCatalyst)
        return "1494127578"
        #else
        return "1493994947"
        #endif
    }
    private var url: URL {
        return URL(string: "https://itunes.apple.com/lookup?id=\(appId)")!
    }
    var appStoreURL: URL {
        return URL(string: "itms-apps://itunes.apple.com/app/id\(appId)")!
    }

    func checkVersion(completion: @escaping ((Result<AppVersionState, AppVersionError>) -> ())) {
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let _ = error {
                completion(.failure(.network))
                return
            }
            guard let data = data else {
                completion(.failure(.invalidData))
                return
            }
            do {
                let appVersion = try JSONDecoder().decode(AppVersion.self, from: data)
                if let version = appVersion.version,
                    Version(version: version) > self.version {
                    completion(.success(.shouldUpdate))
                } else {
                    completion(.success(.noUpdate))
                }
                
            } catch {
                completion(.failure(.invalidJSON))
            }
        }
        task.resume()
    }
}

struct AppVersion: Decodable {

    struct Result: Codable {
        let version: String
        let trackName: String
    }

    let name: String?
    let version: String?
    private enum CodingKeys: String, CodingKey {
        case results = "results"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let results = try container.decodeIfPresent([Result].self, forKey: .results)
        name = results?.first?.trackName
        version = results?.first?.version
    }
}

struct Version {
    let major: Int
    let minor: Int
    let revision: Int
}

extension Version {
    init(version: String) {
        let versions = version.components(separatedBy: ".")
        self.major = versions[safe: 0].flatMap { Int($0) } ?? 0
        self.minor = versions[safe: 1].flatMap { Int($0) } ?? 0
        self.revision = versions[safe: 2].flatMap { Int($0) } ?? 0
    }

    static func > (lhs: Version, rhs: Version) -> Bool {
        if lhs.major > rhs.major {
            return true
        }
        if lhs.major < rhs.major {
            return false
        }
        // lhs.major == rhs.major
        if lhs.minor > rhs.minor {
            return true
        }
        if lhs.minor < rhs.minor {
            return false
        }
        // lhs.major == rhs.major && lhs.minor == rhs.minor
        if lhs.revision > rhs.revision {
            return true
        }
        return false
    }
}

enum AppVersionError: Error {
    case network
    case invalidData
    case invalidJSON
}

enum AppVersionState {
    case shouldUpdate
    case noUpdate
}

extension Bundle {
    var version: String? {
        return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
    }
}
```

`SceneDelegate` の `func scene(_ scene: UIScene, willConnectTo...` に下記を記載

```swift:SceneDelegate.swift
guard let _ = (scene as? UIWindowScene) else { return }
let appStoreModel = AppStoreModel()
        appStoreModel.checkVersion { [weak self] result in
            DispatchQueue.main.async {
                if case .success(.shouldUpdate) = result {
                    let alertController = UIAlertController(title: "アップデート", message: "", preferredStyle: .alert)
                    let action = UIAlertAction(title: "OK", style: .default,
                                               handler:
                        { _ in
                            UIApplication.shared.open(appStoreModel.appStoreURL)
                    })
                    alertController.addAction(action)
                    self?.window?.rootViewController?.present(alertController, animated: true)
                }
            }
        }
```

なんかめっちゃ長くなった...

最初は `Version(version: version) > self.version` このバージョン比較を `version != self.version` にしてたのですがこれだと審査のときに常にアラートが表示されリジェクトされました:sob:

この方法はストアに遷移したあとにもう一回アプリを表示したら普通に使えるので強制アップデートとまではいえませんが、アップデートを促すことはできるのでまあいいかな。

# Android
## 方法
[in-app Updates API](https://developer.android.com/guide/app-bundle/in-app-updates)というのがあるのでこれを使えばいい感じにやってくれるみたいです。

使えるのはAndroid 5.0 (API level 21) 以上です。

下記の参考サイトに丁寧に書いてくれています:tada:

* [Android in-app Updates API 解説と雑感](https://qiita.com/consomme72/items/bcda09cf36e4fc6d31b7) 
* [[Android] アプリ内アップデート AppUpdateManager / FakeUpdateManager ( in-app updates API ) のまとめ](https://qiita.com/kurun_pan/items/0e4d916d106f64942195)

## ソース
[Androidアプリソース](https://github.com/adventam10/DNAConverter-android)

ソースも参考サイトにあるのですが少しつまずきました...

```kotlin
manager.appUpdateInfo.addOnCompleteListener { task ->
            val info = task.result
            when (info.updateAvailability()) {
                UpdateAvailability.UPDATE_AVAILABLE -> {
                    manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                }
                else -> {
                }
            }
        }
```

上記のような実装をしているとエミュレータで実行すると下記のようなエラーが発生しました。

```
com.google.android.play.core.tasks.RuntimeExecutionException: com.google.android.play.core.internal.aa: Failed to bind to the service.
        at com.google.android.play.core.tasks.l.getResult(Unknown Source:18)
        at am10.dnaconverter.models.AppUpdateModel$checkAppVersion$1.onComplete(AppUpdateModel.kt:19)
        at com.google.android.play.core.tasks.a.run(Unknown Source:23)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7356)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
     Caused by: com.google.android.play.core.internal.aa: Failed to bind to the service.
        at com.google.android.play.core.internal.t.b(Unknown Source:82)
        at com.google.android.play.core.internal.t.a(Unknown Source:0)
        at com.google.android.play.core.internal.v.a(Unknown Source:4)
        at com.google.android.play.core.internal.r.run(Unknown Source:0)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.os.HandlerThread.run(HandlerThread.java:67)
```

これは一部の実機でも起こるようで `addOnCompleteListener` を使う場合は `task` が成功したかを下記のようにしっかりチェックしないといけないようです:see_no_evil:

```kotlin
manager.appUpdateInfo.addOnCompleteListener { task ->
            if (task.isSuccessful) {
                val info = task.result
                when (info.updateAvailability()) {
                    UpdateAvailability.UPDATE_AVAILABLE -> {
                        manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                    }
                    else -> {
                    }
                }
            } else {
                task.exception.printStackTrace()
            }
        }
```

参考サイトは `addOnSuccessListener` を使ってました:hear_no_evil:

全体としては下記のような実装になりました。

gradleに下記を追加

```
implementation 'com.google.android.play:core:1.6.4'
implementation 'com.google.android.material:material:1.0.0' // Snackbar用
```

```kotlin:AppUpdateModel.kt
class AppUpdateModel(context: Context) {
    val manager = AppUpdateManagerFactory.create(context)
    var listener: InstallStateUpdatedListener? = null
    val REQUEST_CODE = 100
    fun checkAppVersion(activity: Activity, callback: (() -> (Unit))?) {
        listener = makeListener(callback)
        manager.registerListener(listener)
        manager.appUpdateInfo.addOnSuccessListener { info ->
            when (info.updateAvailability()) {
                UpdateAvailability.UPDATE_AVAILABLE -> {
                    manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                }
                else -> {
                }
            }
        }
    }

    // ミス（20200126修正ここもCompleteじゃなくてSuccessじゃないと落ちる）
    fun addOnSuccessListener(callback: (() -> (Unit))?) {
        manager.appUpdateInfo.addOnSuccessListener { info ->
            if (info.installStatus() == InstallStatus.DOWNLOADED) {
                callback?.invoke()
            }
        }
    }

    fun completeUpdate() {
        manager.completeUpdate()
    }

    private fun makeListener(callback: (() -> (Unit))?) : InstallStateUpdatedListener {
        return InstallStateUpdatedListener {
            if (it.installStatus() == InstallStatus.DOWNLOADED) {
                callback?.invoke()
                manager.unregisterListener(listener)
            }
        }
    }
}
```

```kotlin:MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        appUpdateModel = AppUpdateModel(this)
        appUpdateModel.checkAppVersion(this) {
            popupSnackbarForCompleteUpdate()
        }
     }

    override fun onResume() {
        super.onResume()
        appUpdateModel.addOnSuccessListener {
            popupSnackbarForCompleteUpdate()
        }
    }
    
    private fun popupSnackbarForCompleteUpdate() {
        Snackbar.make(findViewById(R.id.root_layout),
            "ダウンロード完了", Snackbar.LENGTH_INDEFINITE)
            .setAction("更新") {
                appUpdateModel.completeUpdate()
            }
            .show()
    }
```

Google Playストアのアプリが更新判定を行うらしいのでどの時点で更新情報が受け取れるのかはわかりません。

これも戻るボタンとかで回避できるらしいので強制アップデートとまではいえないかもしれません。（そもそもGoogle Playストアのアプリがないと取れない？）

### 20200127追記
やっぱりストアに公開されてから即時反応というわけではなく、２端末(Android 6.0.1, 9.0)で確認したところ、公開されてから2~３時間後は特に反応はなく、１０時間後に再度アプリを起動したところ無事下記の画面が表示されました:confetti_ball:

<img width="300" alt="update" src="https://user-images.githubusercontent.com/34936885/73165783-ce65b480-4137-11ea-9de7-80408f61dd3c.png">

# おまけ
サーバー用意してバージョン情報のJSONファイル置くのめんどくさいと思って今回の方法で実装しましたが、GitHubにJSONファイル置けばいいんじゃね？ふと思いました。（iOSアプリをリリースしてればもしかしたらプライバシーポリシー用にGitHub使ってるかもしれないですし）

試しにJSONファイル置いてるリポジトリでやってみたらJSON取れました:tada:

[https://raw.githubusercontent.com/adventam10/TestApplicationArchitecture/master/TestWeatherApplication/TestWeatherApplication/Resource/CityData.json](https://raw.githubusercontent.com/adventam10/TestApplicationArchitecture/master/TestWeatherApplication/TestWeatherApplication/Resource/CityData.json)

`github.com` のところを `raw.githubusercontent.com` するといけそうです！！

# さいごに
特にこのアプリに強制アップデート必要ないですが、試しに実装してみましたmm

関係ないですがAndroidのアプリを最適化しようと思って下記をgradleに追加したらサイズがめっちゃ小さくなりました。

```
release {
    minifyEnabled true
    shrinkResources true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
```

| before | after |
| --- | --- |
|<img width="724" alt="before" src="https://user-images.githubusercontent.com/34936885/73123231-3c439c00-3fd1-11ea-832f-58eb5ed700f5.png">|<img width="722" alt="after" src="https://user-images.githubusercontent.com/34936885/73123246-5d0bf180-3fd1-11ea-8b8d-c167df48b35b.png">|

アプリサイズ 50%OFF ！！！