DataStoreのInvalidProtocolBufferExceptionへの対処方法

Preference DataStoreを使っているアプリで、レアケースながら、`InvalidProtocolBufferException`によるクラッシュがCrashlyticsに記録されていました。どうやらファイルが破損してしまい、読み出せなくなっているようです。
再現はできないので推測になりますが、ファイルが破損しているため、再起動しても復帰することができず、繰り返しクラッシュして起動不可に陥っているようです。

スタックトレースは以下のようになっていて、アプリのコードを経由していないのでcatchすることはできません。

```
CodedInputStream$StreamDecoder.readTag
androidx.datastore.preferences.protobuf.InvalidProtocolBufferException
- Protocol message contained an invalid tag (zero).

Fatal Exception: androidx.datastore.core.CorruptionException
Unable to parse preferences proto.
androidx.datastore.core.CorruptionException.<init> (CorruptionException.java:25)
androidx.datastore.preferences.PreferencesMapCompat$Companion.readFrom (PreferencesMapCompat.java:34)
androidx.datastore.preferences.core.PreferencesSerializer.readFrom (PreferencesSerializer.jvm.kt:46)
androidx.datastore.core.okio.OkioReadScope.readData$suspendImpl (OkioStorage.kt:180)
androidx.datastore.core.okio.OkioReadScope.readData (OkioReadScope.java:40)
androidx.datastore.core.StorageConnectionKt$readData$2.invokeSuspend (StorageConnection.kt:74)
androidx.datastore.core.StorageConnectionKt$readData$2.invoke (StorageConnection.kt:23)
androidx.datastore.core.StorageConnectionKt$readData$2.invoke (StorageConnection.kt:23)
androidx.datastore.core.okio.OkioStorageConnection.readScope (OkioStorage.kt:113)
androidx.datastore.core.StorageConnectionKt.readData (StorageConnection.kt:74)
androidx.datastore.core.DataStoreImpl.readDataFromFileOrDefault (DataStoreImpl.kt:331)
androidx.datastore.core.DataStoreImpl.readDataOrHandleCorruption (DataStoreImpl.kt:373)
androidx.datastore.core.DataStoreImpl.access$readDataOrHandleCorruption (DataStoreImpl.kt:53)
androidx.datastore.core.DataStoreImpl$InitDataStore$doRun$initData$1.invokeSuspend (DataStoreImpl.kt:445)
androidx.datastore.core.DataStoreImpl$InitDataStore$doRun$initData$1.invoke (DataStoreImpl.kt:13)
androidx.datastore.core.DataStoreImpl$InitDataStore$doRun$initData$1.invoke (DataStoreImpl.kt:13)
androidx.datastore.core.SingleProcessCoordinator.lock (SingleProcessCoordinator.kt:41)
androidx.datastore.core.DataStoreImpl$InitDataStore.doRun (DataStoreImpl.kt:442)
androidx.datastore.core.RunOnce.runIfNeeded (DataStoreImpl.kt:505)
androidx.datastore.core.DataStoreImpl.readAndInitOrPropagateAndThrowFailure (DataStoreImpl.kt:274)
androidx.datastore.core.DataStoreImpl.handleUpdate (DataStoreImpl.kt:251)
androidx.datastore.core.DataStoreImpl.access$handleUpdate (DataStoreImpl.kt:53)
androidx.datastore.core.DataStoreImpl$writeActor$3.invokeSuspend (DataStoreImpl.kt:215)
androidx.datastore.core.DataStoreImpl$writeActor$3.invoke (DataStoreImpl.kt:12)
androidx.datastore.core.DataStoreImpl$writeActor$3.invoke (DataStoreImpl.kt:12)
androidx.datastore.core.SimpleActor$offer$2.invokeSuspend (SimpleActor.kt:121)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:104)
kotlinx.coroutines.internal.LimitedDispatcher$Worker.run (LimitedDispatcher.java:111)
kotlinx.coroutines.scheduling.TaskImpl.run (Tasks.kt:99)
kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely (CoroutineScheduler.java:585)
kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask (CoroutineScheduler.kt:802)
kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker (CoroutineScheduler.kt:706)
kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run (CoroutineScheduler.kt:693)
```

# 対処方法：corruptionHandlerを設定する

`preferenceDataStore`の定義は以下のようになっており、第二引数に`corruptionHandler`があります。省略可能で、チュートリアルでは説明されていないので省略してしまっているアプリも多いのではないでしょうか？

```kotlin
public fun preferencesDataStore(
    name: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    produceMigrations: (Context) -> List<DataMigration<Preferences>> = { listOf() },
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
)
```

`corruptionHandler`は、ファイルの読み出しでExceptionが発生した場合に、そのExceptionを引数に呼び出されます。
省略した場合、`NoOpCorruptionHandler`が使われます。
この実装は以下のようになっており、単に引数のExceptionをthrowするだけです。

```kotlin
internal actual class NoOpCorruptionHandler<T> : CorruptionHandler<T> {
    @Throws(CorruptionException::class)
    actual override suspend fun handleCorruption(ex: CorruptionException): T {
        throw ex
    }
}
```

つまり、省略した状態で、ファイルの破損などで読み出しに失敗した場合、そのままExceptionがthrowされ、アプリがクラッシュしてしまいます。

クラッシュさせたくない場合は、適切な`corruptionHandler`を設定します。
実装方法は、`ReplaceFileCorruptionHandler`のコンストラクタにファイルが読み出せない場合のデータを返すラムダを渡すだけです。

ファイルが読み出せない以上、その内容は諦めるしかありません。
空のpreferencesを返すことで、起動初回と同様に初期値を書き込む動作を動かすのが簡単な方法の一つでしょう。空のpreferencesは`emtpyPreferences()`というメソッドで作ることができるので、以下のように呼び出せばOKです。

```kotlin
preferencesDataStore(
    name = ...,
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
    produceMigrations = ...,
)
```

また、削除されたことを記録しておきたい場合もあるでしょう。その場合は`preferencesOf()`メソッドで削除されたことを示す値を持ったpreferencesを返すことで対応できますね。

Proto DataStoreでも同様に、`corruptionHandler`で読み出せない場合の値を提供することで対処できるようです。

何が何でもクラッシュさせないことが正しいわけではなく、ここでクラッシュさせ、次回起動時に正常に読み出されることを期待するのが正しい場合もあると思います。保存しているデータの性質や発生したExceptionの内容を元に適切な処理を実装してください。

---
以上です。

# corruptionHandlerがどう使われているか

蛇足ですが、`corruptionHandler`がどう使われてるかはDataStoreImplを見れば分かります。

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt

デフォルトで`NoOpCorruptionHandler`が使われていることも分かりますね。

```kotlin
internal class DataStoreImpl<T>(
    private val storage: Storage<T>,
    initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(),
    /**
     * The handler of [CorruptionException]s when they are thrown during reads or writes. It
     * produces the new data to replace the corrupted data on disk. By default it is a no-op which
     * simply throws the exception and does not produce new data.
     */
    private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler(),
    private val scope: CoroutineScope = CoroutineScope(ioDispatcher() + SupervisorJob())
) : DataStore<T> {
```

`corruptionHandler`が使われているのは、`readDataOrHandleCorruption`というメソッドです。

```kotlin
private suspend fun readDataOrHandleCorruption(hasWriteFileLock: Boolean): Data<T> {
    try {
        return if (hasWriteFileLock) {
            val data = readDataFromFileOrDefault()
            Data(data, data.hashCode(), version = coordinator.getVersion())
        } else {
            val preLockVersion = coordinator.getVersion()
            coordinator.tryLock { locked ->
                val data = readDataFromFileOrDefault()
                val version = if (locked) coordinator.getVersion() else preLockVersion
                Data(
                    data,
                    data.hashCode(),
                    version
                )
            }
        }
    } catch (ex: CorruptionException) {
        var newData: T = corruptionHandler.handleCorruption(ex)
        var version: Int // initialized inside the try block
        try {
            doWithWriteFileLock(hasWriteFileLock) {
                // Confirms the file is still corrupted before overriding
                try {
                    newData = readDataFromFileOrDefault()
                    version = coordinator.getVersion()
                } catch (ignoredEx: CorruptionException) {
                    version = writeData(newData, updateCache = true)
                }
            }
        } catch (writeEx: Throwable) {
            // If we fail to write the handled data, add the new exception as a suppressed
            // exception.
            ex.addSuppressed(writeEx)
            throw ex
        }
        // If we reach this point, we've successfully replaced the data on disk with newData.
        return Data(newData, newData.hashCode(), version)
    }
}
```

Exceptionが発生するとcatchされ、`corruptionHandler`が呼び出されます。`NoOpCorruptionHandler`だとここでExceptionが再度throwされてアプリがクラッシュします。

`corruptionHandler`が値を返した場合も、そのまま使われる訳ではなく、もう一度ファイル読み出しを試みて、それでもダメなら`corruptionHandler`の結果を書き込んでいるようです。
単純にemptyPreferencesを返すだけの実装でも、有意義に動作しそうですね。