android.database.sqlite.SQLiteCantOpenDatabaseException への対処方法

roomを使っているアプリでCrashlyticsに`SQLiteCantOpenDatabaseException`によるクラッシュが記録されていました。
原因はいろいろあるようで、ストレージがいっぱいになってしまっていたり、ファイルの破損のようなものだったり、ストレージそのものが破損しているのではと思われるものだったり。
いずれも繰り返し発生し、アプリの起動すらままならない状況になっていそうですが、モバイル端末で動いている以上、アプリとして完全に回避するのは難しそうな問題です。しかし、ファイルの破損であれば、DBファイルを作り直させれば復帰できる可能性があります。DBに重要なデータが格納されている場合は安易に削除できないですが、キャッシュとして使っているなど、データが消失しても大きな問題にならないなら、復帰を優先しても良さそうです。

前置きが長くなりましたが、データが消えてもいいからクラッシュを回避したい。ということで調査してみました。

スタックトレースは以下のようになっています。

```
android.database.sqlite.SQLiteConnection.open (SQLiteConnection.java:257)
android.database.sqlite.SQLiteConnection.open (SQLiteConnection.java:210)
android.database.sqlite.SQLiteConnectionPool.openConnectionLocked (SQLiteConnectionPool.java:516)
android.database.sqlite.SQLiteConnectionPool.open (SQLiteConnectionPool.java:206)
android.database.sqlite.SQLiteConnectionPool.open (SQLiteConnectionPool.java:198)
android.database.sqlite.SQLiteDatabase.openInner (SQLiteDatabase.java:922)
android.database.sqlite.SQLiteDatabase.open (SQLiteDatabase.java:902)
android.database.sqlite.SQLiteDatabase.openDatabase (SQLiteDatabase.java:766)
android.database.sqlite.SQLiteDatabase.openDatabase (SQLiteDatabase.java:755)
android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked (SQLiteOpenHelper.java:378)
android.database.sqlite.SQLiteOpenHelper.getWritableDatabase (SQLiteOpenHelper.java:321)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getWritableOrReadableDatabase (FrameworkSQLiteOpenHelper.kt:232)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.innerGetDatabase (FrameworkSQLiteOpenHelper.kt:190)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getSupportDatabase (FrameworkSQLiteOpenHelper.kt:151)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper.getWritableDatabase (FrameworkSQLiteOpenHelper.kt:104)
androidx.room.RoomDatabase.inTransaction (RoomDatabase.kt:632)
androidx.room.RoomDatabase.assertNotSuspendingTransaction (RoomDatabase.kt:451)
androidx.room.RoomDatabase.query (RoomDatabase.kt:480)
```

# 対処方法：allowDataLossOnRecoveryを指定する

`SupportSQLiteOpenHelper.Configuration`で`allowDataLossOnRecovery`がtrueになっている場合、データの消失を許容して復帰させる動作になります。
これをRoomのどこで指定するかというと、`BatabaseBuilder`に、`openHelperFactory`というメソッドがあり、ここでSupportSQLiteOpenHelperの作成処理を変更することができます。

```kotlin
Room.databaseBuilder(context, HogeDatabase::class.java, DB_NAME)
    .openHelperFactory {
         // ここで実装する
    }
    .build()
```

引数に`SupportSQLiteOpenHelper.Configuration`が渡されるため、以下のように実装すると概ねデフォルトの動作と同等の処理になります。

```kotlin
Room.databaseBuilder(context, HogeDatabase::class.java, DB_NAME)
    .openHelperFactory {
         FrameworkSQLiteOpenHelperFactory().create(it)
    }
    .build()
```

しかし、`SupportSQLiteOpenHelper.Configuration`は`Immutable`でdata classのcopyメソッドがある訳でもないので、builderを使ってパラメータをコピーする必要があります。
`allowDataLossOnRecovery`をtrueに書き換えるだけなら、以下のような拡張関数を作成しても良いでしょう

```kotlin
private fun Configuration.allowDataLossOnRecovery(context: Context): Configuration =
    Configuration.builder(context)
        .name(name)
        .callback(callback)
        .noBackupDirectory(useNoBackupDirectory)
        .allowDataLossOnRecovery(true)
        .build()
```

これを使って以下のようにすることで、`allowDataLossOnRecovery`がtrueになった`SupportSQLiteOpenHelper`が使われるようになります。

```kotlin
Room.databaseBuilder(context, HogeDatabase::class.java, DB_NAME)
    .openHelperFactory {
         FrameworkSQLiteOpenHelperFactory()
             .create(it.allowDataLossOnRecovery(context))
    }
    .build()
```

注意点として、` Configuration.Builder`のコンストラクタは`internal`であるため、通常は使えません。代わりに` Configuration.builder(Context)`というメソッドを使います。`builder`で、頭文字が小文字です。（これに気づかずしばらくはまりました）

--

DBデータが重要で再取得不可能なものの場合、安易に消去を許容できないでしょうが、データの性質によってはこういう対処方法もあるよという紹介でした。

# allowDataLossOnRecoveryを設定した場合の挙動

蛇足ですが、allowDataLossOnRecoveryを設定したらどうなるかを調べて見ましょう
（今回の件はスタックトレースから追っていき、allowDataLossOnRecoveryを設定すればいけそうだ→設定の仕方はと調べました）

FrameworkSQLiteOpenHelperが実装になっています。

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite-framework/src/androidMain/kotlin/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelper.android.kt

`innerGetDatabase` を見ると以下のようになっていて、Exceptionのハンドリングも行われています。

```kotlin
private fun innerGetDatabase(writable: Boolean): SQLiteDatabase {
    val name = databaseName
    val isOpen = opened
    if (name != null && !isOpen) {
        val databaseFile = context.getDatabasePath(name)
        val parentFile = databaseFile.parentFile
        if (parentFile != null) {
            parentFile.mkdirs()
            if (!parentFile.isDirectory) {
                Log.w(TAG, "Invalid database parent file, not a directory: $parentFile")
            }
        }
    }
    try {
        return getWritableOrReadableDatabase(writable)
    } catch (t: Throwable) {
        // No good, just try again...
    }
    try {
        // Wait before trying to open the DB, ideally enough to account for some slow I/O.
        // Similar to android_database_SQLiteConnection's BUSY_TIMEOUT_MS but not as much.
        Thread.sleep(500)
    } catch (e: InterruptedException) {
        // Ignore, and continue
    }
    val openRetryError: Throwable =
        try {
            return getWritableOrReadableDatabase(writable)
        } catch (t: Throwable) {
            t
        }
    if (openRetryError is CallbackException) {
        // Callback error (onCreate, onUpgrade, onOpen, etc), possibly user error.
        val cause = openRetryError.cause
        when (openRetryError.callbackName) {
            CallbackName.ON_CONFIGURE,
            CallbackName.ON_CREATE,
            CallbackName.ON_UPGRADE,
            CallbackName.ON_DOWNGRADE -> throw cause
            CallbackName.ON_OPEN -> {}
        }
        // If callback exception is not an SQLiteException, then more certainly it is not
        // recoverable.
        if (cause !is SQLiteException) {
            throw cause
        }
    } else if (openRetryError is SQLiteException) {
        // Ideally we are looking for SQLiteCantOpenDatabaseException and similar, but
        // corruption can manifest in others forms.
        if (name == null || !allowDataLossOnRecovery) {
            throw openRetryError
        }
    } else {
        throw openRetryError
    }
    // Delete the database and try one last time. (mAllowDataLossOnRecovery == true)
    context.deleteDatabase(name)
    try {
        return getWritableOrReadableDatabase(writable)
    } catch (ex: CallbackException) {
        // Unwrap our exception to avoid disruption with other try-catch in the call stack.
        throw ex.cause
    }
}
```

最初のディレクトリ作成は置いておいて、`getWritableOrReadableDatabase`を呼び出しているのは以下ですね。

```kotlin
    try {
        return getWritableOrReadableDatabase(writable)
    } catch (t: Throwable) {
        // No good, just try again...
    }
    try {
        // Wait before trying to open the DB, ideally enough to account for some slow I/O.
        // Similar to android_database_SQLiteConnection's BUSY_TIMEOUT_MS but not as much.
        Thread.sleep(500)
    } catch (e: InterruptedException) {
        // Ignore, and continue
    }
    val openRetryError: Throwable =
        try {
            return getWritableOrReadableDatabase(writable)
        } catch (t: Throwable) {
            t
        }
```

一回呼び出して、Exceptionが発生したら一回握りつぶして、500ms待って、リトライしてますね。

そして、2回目の呼び出しでもやはりExceptionが発生した場合、以下のような処理が行われています。

```kotlin
    if (openRetryError is CallbackException) {
        // Callback error (onCreate, onUpgrade, onOpen, etc), possibly user error.
        val cause = openRetryError.cause
        when (openRetryError.callbackName) {
            CallbackName.ON_CONFIGURE,
            CallbackName.ON_CREATE,
            CallbackName.ON_UPGRADE,
            CallbackName.ON_DOWNGRADE -> throw cause
            CallbackName.ON_OPEN -> {}
        }
        // If callback exception is not an SQLiteException, then more certainly it is not
        // recoverable.
        if (cause !is SQLiteException) {
            throw cause
        }
    } else if (openRetryError is SQLiteException) {
        // Ideally we are looking for SQLiteCantOpenDatabaseException and similar, but
        // corruption can manifest in others forms.
        if (name == null || !allowDataLossOnRecovery) {
            throw openRetryError
        }
    } else {
        throw openRetryError
    }
    // Delete the database and try one last time. (mAllowDataLossOnRecovery == true)
    context.deleteDatabase(name)
    try {
        return getWritableOrReadableDatabase(writable)
    } catch (ex: CallbackException) {
        // Unwrap our exception to avoid disruption with other try-catch in the call stack.
        throw ex.cause
    }
```

`SQLiteCantOpenDatabaseException` は `SQLiteException` のサブクラスなのでelse if節の中に入ります。`allowDataLossOnRecovery` のフラグが立っている場合、throwには進まず、DBファイルを削除して、再度オープンを試みる、という動作になっていますね。

ということでまとめると
DBファイルを読み出し
失敗したら500msまって
再度度オープンを試みる
それでも失敗したら
allowDataLossOnRecoveryがfalseならクラッシュ
allowDataLossOnRecoveryがtrueなら
DBファイルを削除して再度オープンを試みる

という動作になっていました。