本文へスキップ
すべての記事

2026年、Androidでローカルファーストなデータをバックアップする: SAFエクスポート、Auto Backup、そして信頼できる復元フロー

サーバーを持たないローカルファーストのAndroidアプリが、どのようにユーザーデータをバックアップするか — SAFによるエクスポート/インポート、Auto Backup for App Data、そして上書き前に検証を行う復元処理について。

MFKAPPS 1 分で読めます

「サーバーを持たない」ことこそが、ローカルファーストアプリの核心的な売りだ。しかしそれは同時に、工場出荷状態へのリセットや端末の紛失、機種変更が、Granynに蓄積された数ヶ月分の家計簿の履歴を、復元する手立てもないまま消し去ってしまう理由でもある。クラウドがないのなら、バックアップは後回しにできるものではない — それが保護しているデータモデルと同じだけの注意を払って作り込むべき、一つの機能そのものでなければならない。

ここで重要になるAndroidの仕組みは二つあり、それぞれ異なる問題を解決する。どちらかがもう一方の代わりになるわけではない。

Auto Backup for App Data: 自動だが、中身が見えない

オプトインしておけば、Androidはアプリのファイルをユーザーの Google ドライブへ自動的に、無料でバックアップしてくれる。

<!-- AndroidManifest.xml -->
<application
    android:allowBackup="true"
    android:fullBackupContent="@xml/backup_rules"
    ...>
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
    <include domain="database" path="granyn.db" />
    <exclude domain="database" path="granyn.db-wal" />
    <exclude domain="sharedpref" path="device_keys.xml" />
</full-backup-content>

これは正真正銘役に立つ仕組みだ — 設定画面を一度も開かないような人を救うセーフティネットになる。ただし、実際的な限界があり、それに逆らうのではなく前提として設計している。

  • 合計25MBまでで、超えると静かに切り詰められる。 数年分の取引履歴とスクリーンショットを抱えたRoomデータベースは、思っているより早くこの上限に達しうる。
  • 自動復元が行われるのは、同じGoogleアカウントでサインインした新しい端末の初期セットアップ時だけだ。 同じ端末にアプリを再インストールしたユーザーは、この方法ではデータを取り戻せない。
  • 中身が見えない。 何がいつバックアップされたかをアプリ内で確認する手段がない。「最後のバックアップは3日前」とユーザーに表示することもできない — OSがその情報を教えてくれないからだ。

良いデフォルトではあるが、主戦略としては物足りない。バックアップについて考えたこともない人たちのためのバックアップであって、不安を抱えたユーザーに勧めるべきものではない。

明示的なエクスポート: ユーザー自身が実際に制御できる方法

実際に信頼しているのは、ユーザー自身がトリガーする素朴なエクスポート/インポートの仕組みで、Storage Access Framework経由でユーザーが選んだ好きな場所にファイルを書き出すというものだ。

@Serializable
data class GranynExport(
    val schemaVersion: Int = 2,
    val exportedAt: Long,
    val accounts: List<AccountDto>,
    val transactions: List<TransactionDto>,
)

suspend fun exportTo(context: Context, uri: Uri) {
    val export = GranynExport(
        exportedAt = clock.nowMillis(),
        accounts = accountDao.getAll().map { it.toDto() },
        transactions = transactionDao.getAll().map { it.toDto() },
    )
    context.contentResolver.openOutputStream(uri)?.use { out ->
        out.write(Json.encodeToString(export).toByteArray())
    }
}

ACTION_CREATE_DOCUMENTから起動されるので、保存先はユーザーが選ぶ — 端末のストレージでも、USBドライブでも、すでに同期しているクラウドフォルダでも構わない。一度限りのピッカー以上の権限は不要で、書き出されるファイルはただのJSON、中身を確認できる。この最後の点は見た目以上に重要だ — テキストエディタでバックアップを開き、自分自身の口座名が並んでいるのを目にできるユーザーは、不透明な.bakのかたまりには決して得られない種類の信頼を、その仕組みに寄せてくれる。

schemaVersionは、未来の自分を救うフィールドだ。Granynのデータモデルはローンチ以来すでに一度変わっている — 新しいフィールドが増えるたびにバージョンを上げれば、インポーター側は現在あるものから形を推測するのではなく、期待すべき形をあらかじめ知ることができる。

復元は、何かを上書きする権利を自ら勝ち取らなければならない

バックアップ機能が実際に試されるのはインポートの場面であり、そこは偏執的なくらい慎重になる価値がある部分だ。起こりうる失敗は「ファイルが壊れている」ではなく、「ファイル自体は正しいのに、書き込みが途中で終わってしまい、ユーザーの手元に元より少ないデータしか残らない」というものだ。復元処理に課しているルールは次の通り。

  1. 稼働中のデータベースに触れる前に、パースと検証を行う。 schemaVersionを確認し、ファイルが空でないことを確認し、参照されているIDが内部的に整合していることを確認する。
  2. 確定する前にサマリーを表示する。 「口座12件、取引340件、7月24日にバックアップ」— 何かが上書きされる前に、ユーザーが自分の記憶と照らし合わせて確認できる数字を示す。
  3. 書き込みを単一のRoomトランザクションでくるむ。 インポートは全体が反映されるか、まったく反映されないかのどちらかであり、インポート途中のクラッシュがデータベースを中途半端な状態のまま残すことはない。
suspend fun restoreFrom(export: GranynExport) = db.withTransaction {
    require(export.schemaVersion <= CURRENT_SCHEMA_VERSION) {
        "Backup was made with a newer app version"
    }
    accountDao.deleteAll()
    transactionDao.deleteAll()
    accountDao.insertAll(export.accounts.map { it.toEntity() })
    transactionDao.insertAll(export.transactions.map { it.toEntity() })
}

実際に安全を担保しているのはdb.withTransactionだ — insertAllが途中で例外を投げても、Roomはブロック全体をロールバックし、ユーザーの既存データには一切手をつけない。これがなければ、途中で失敗する復元は、復元機能そのものが存在しないより悪い結果を招く。

設定画面の一行ではなく、コア機能として扱う

エクスポート/インポートを設定画面の奥に埋め込んだボタン一つとして出荷し、それで終わりにしたくなる誘惑はある。だがローカルファーストなアプリにとって、そのボタンこそが災害復旧計画そのものだ — そこにバグがあっても、静かに肩代わりしてくれるサーバー側のバックアップは存在しない。復元パスは、オンボーディングと同じ真剣さでテストしている — 実際にエクスポートしたファイル、ロックして再起動した端末、まっさらな新規インストール。エクスポートボタンが壊れていれば、それに誰も気づかないまま、まさに一番必要とされる瞬間までそのまま放置される — それは気づくには最悪のタイミングだ。

ここに書いたことは、どれも複雑なエンジニアリングではない。地味な種類の作業であり、ユーザーが実際にそれを必要とするたった一日のためにしか報われない — そしてローカルファーストなアプリにとっては、その一日こそが存在理由のすべてだ。