跳到正文
DEV Community· ReClaim·· 2 小時前AI 評分50

使用 Room 為 Android 啟動器實作備份與還原:防抖快照與空資料庫閘門

Backup and restore for an Android launcher with Room: debounced snapshots and an empty-database gate

AI 導讀

ReClaim 以 Kotlin、Room 等技術為 Android 啟動器實作備份與還原,將完整設定保存為本機 JSON 快照,並可匯出成 ZIP。系統監聽 Room 資料變更,經 3,000 毫秒防抖後自動寫入;還原則在單一 Room 交易中完成。若資料庫為空但快照仍有資料,系統會暫停自動備份並先詢問用戶是否還原,以免空資料覆蓋原有快照。

正文

We're building ReClaim, a minimalist Android launcher that's now on Google Play. A launcher stores a lot of small, personal things: your home screen order, folders, hidden apps, blocks, schedules, timers and tasks. Nobody notices that setup until it's gone.

So we built our own backup and restore. It isn't Android's BackupAgent. It's a plain class, also called BackupManager, that does three things:

  1. It keeps a local snapshot of the whole setup, updated automatically.
  2. It can export that snapshot as a single zip the user moves to another phone.
  3. It can restore from either, all or nothing.

Here's how it works, traps included. Stack: Kotlin, Hilt, Room, DataStore, coroutines, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts.

1. One JSON file, kept outside databases/

The snapshot is one JSON document that holds every user-data table, the settings, and a flag for the home background image. It lives in filesDir/backups/, not next to the database. The class comment explains why: the local copy "survives anything that resets the database itself."

private val backupDir: File get() = File(context.filesDir, "backups")
private val backupFile: File get() = File(backupDir, "reclaim_backup.json")
private val writeMutex = Mutex()

private suspend fun writeBackupLocked(): BackupMeta {
    val favorites = favoritesDao.observeAll().first()
    val hidden = hiddenAppsDao.observeAll().first()
    val tasks = taskDao.observeAll().first()
    // ... every other DAO, plus settings
    val root = JSONObject().apply {
        put("formatVersion", 2)
        put("createdAt", System.currentTimeMillis())
        put("favorites", JSONArray(favorites.map {
            it.toJson().put("label", displayLabel(it.packageName, renameMap))
        }))
        put("tasks", JSONArray(tasks.map { it.toJson() }))
        // ...
        put("settings", settings.toBackupJson(tutorialCompleted, hasBackground, renameMap))
    }

    backupDir.mkdirs()
    val tmp = File(backupDir, "${backupFile.name}.tmp")
    tmp.writeText(root.toString())
    tmp.renameTo(backupFile)
    // ... return counts for the UI
}

A few choices are worth calling out:

  • Write to a temp file, then rename. If the process dies halfway through a write, the half-written file is the .tmp and the previous snapshot is still there.
  • A Mutex around every write. Auto-backup, manual export and import all go through writeMutex.withLock { ... }, so two snapshots never write the same file at once.
  • formatVersion from day one. Import checks for it (or for the older favorites key) before trusting a file.
  • Labels next to package names. A package name like com.example.notes means nothing to a person. We save the display name with it so a later restore can say which apps are missing (section 7).

The entity-to-JSON mappers are written by hand. The comment above them explains the trade-off: "these tables rarely change shape, and a direct field-by-field mapping is far easier to audit for correctness than a reflection-based one."

2. Auto-backup: merge every table, debounce, write

We didn't want a "Back up now" button that people forget to press. Every Room DAO already exposes a Flow, so the auto-backup listens to all of them at once:

private fun startAutoBackup() {
    val changed = merge(
        favoritesDao.observeAll().map {},
        hiddenAppsDao.observeAll().map {},
        blockDao.observeAll().map {},
        // ... one line per table
        settingsRepository.settings.map {},
    )
    appScope.launch {
        changed.debounce(3_000).collect { writeBackup() }
    }
}

.map {} turns every emission into Unit. We don't care what changed, only that something did, because the write always re-reads everything. debounce does the batching. The kotlinx.coroutines docs describe it as filtering out "values that are followed by the newer values within the given timeout," and add that "the latest value is always emitted." Dragging five apps around the home screen produces one write, three seconds after the last drop, instead of five.

3. The trap: an empty database will happily back itself up

This is the part that would have hurt users. Room flows emit their current value as soon as you collect them. If the database ever comes up empty for any reason, the auto-backup loop above would see "a change", wait three seconds, and replace the last good snapshot with an empty one. The one copy that could fix the problem would be gone.

So auto-backup doesn't start right away. On app start, initialize() checks first:

fun initialize() {
    appScope.launch {
        val meta = readMeta()
        val liveEmpty = isLiveDataEmpty()
        if (liveEmpty && meta != null && meta.totalItems > 0) {
            _restoreOffer.value = meta   // UI asks: restore, or start fresh?
        } else {
            startAutoBackup()
        }
    }
}

fun restoreAndResume() {
    appScope.launch {
        val outcome = restoreBackup()
        _restoreOffer.value = null
        if (outcome.missingApps.isNotEmpty()) _missingAppsNotice.value = outcome.missingApps
        startAutoBackup()
    }
}

The UI shows a "Restore your data?" dialog with the snapshot's date and counts, and auto-backup stays off while that question is open. The general lesson: any automatic job that overwrites the "last known good" copy needs a gate for the case where the current state is the thing that's broken.

4. Restore in one Room transaction

A restore means "make the tables look exactly like this file." A half-applied restore is worse than none. So everything runs inside withTransaction:

private suspend fun applyRoot(root: JSONObject, imageFile: File?): BackupApplyResult {
    val missing = root.missingAppLabels()
    db.withTransaction {
        favoritesDao.clearAll()
        hiddenAppsDao.clearAll()
        folderDao.clearAllFolderApps()
        folderDao.clearAllFolders()
        // ... clear every table

        root.jsonArray("favorites").forEachObject { favoritesDao.upsert(it.toFavoriteEntity()) }
        root.jsonArray("folders").forEachObject { folderDao.upsertFolder(it.toFolderEntity()) }
        root.jsonArray("folderApps").forEachObject { folderDao.assign(it.toFolderAppEntity()) }
        // Categories first: a restored task's categoryId has to point at a row that exists.
        root.jsonArray("taskCategories").forEachObject { taskCategoryDao.upsert(it.toTaskCategoryEntity()) }
        root.jsonArray("tasks").forEachObject { taskDao.upsert(it.toTaskEntity()) }
        // ...
    }
    applySettings(root, imageFile)
    return BackupApplyResult(applied = true, missingApps = missing)
}

The Room reference says withTransaction "will be marked as successful unless an exception is thrown in the suspending block or the coroutine is cancelled." A malformed row in the middle of the file rolls back every clear and every insert before it. The same page notes that Room "will only perform at most one transaction at a time", and that's why the clear-then-insert block doesn't interleave with other writes.

Settings live in Preferences DataStore, not Room, so they are applied after the transaction in a single store.edit { ... } call (SettingsRepository.replaceAll). One thing to be clear about: the all-or-nothing guarantee covers the Room tables. Settings and the background image are a separate step.

5. Export: one zip through the Storage Access Framework

To move a setup to another phone, the user needs a file they can see. We use the Storage Access Framework through Compose's activity-result contracts. The screen launches CreateDocument("application/zip") with a suggested name of ReClaim-backup.zip, and the manager writes into the URI it gets back:

private fun zipTo(uri: Uri): Boolean = runCatching {
    val temp = File(context.cacheDir, "reclaim-export.zip")
    ZipOutputStream(temp.outputStream()).use { zip ->
        zip.putNextEntry(ZipEntry("backup.json"))
        backupFile.inputStream().use { it.copyTo(zip) }
        zip.closeEntry()
        if (backgroundFile.isFile && backgroundFile.length() > 0L) {
            zip.putNextEntry(ZipEntry("home_background.jpg"))
            backgroundFile.inputStream().use { it.copyTo(zip) }
            zip.closeEntry()
        }
    }
    val wrote = context.contentResolver.openOutputStream(uri)?.use { out ->
        temp.inputStream().use { it.copyTo(out) }
        true
    } ?: false
    temp.delete()
    wrote
}.getOrDefault(false)

Two points from the SAF guide shaped this. First, because the user picks the location, the mechanism "doesn't require any system permissions." Second, ACTION_CREATE_DOCUMENT "cannot overwrite an existing file"; the system appends a number instead. So exporting twice gives you ReClaim-backup(1).zip rather than silently replacing the first file.

6. Import: treat the file as hostile

An imported file can come from anywhere. The import path assumes nothing about it:

private fun unzipBackup(zipFile: File, dest: File): Boolean {
    var found = false
    ZipInputStream(zipFile.inputStream()).use { zip ->
        var entry = zip.nextEntry
        while (entry != null) {
            val name = File(entry.name).name
            val limit = if (name == "home_background.jpg") IMAGE_LIMIT else JSON_LIMIT
            if (!entry.isDirectory && (name == "backup.json" || name == "home_background.jpg")) {
                val out = File(dest, name)
                val ok = out.outputStream().use { zip.copyLimited(it, limit) }
                if (!ok) { out.delete(); return false }
                if (name == "backup.json") found = true
            }
            zip.closeEntry()
            entry = zip.nextEntry
        }
    }
    return found
}

The layers, in order:

  • Size caps while copying. copyLimited counts bytes and gives up past a limit: 32 MB for the incoming file, 8 MB for the JSON, 20 MB for the image. We never trust a size the file reports about itself.
  • Magic bytes, not the extension. isZip() checks for the PK signature. Anything else goes down the legacy path for raw JSON, which still has to parse and contain favorites or formatVersion.
  • No path traversal. Android's Zip Path Traversal guidance warns that zip entry names can contain ../ and recommends checking the canonical path. We go stricter: File(entry.name).name throws away every directory part, and only two exact names are ever written. An entry called ../../shared_prefs/x.xml turns into x.xml and is skipped.
  • A throwaway directory. Everything is unpacked into a fresh folder under cacheDir, which is deleted in finally whether the import worked or not.

Only after all that does the file reach applyRoot, and if it applied, the manager immediately writes a new local snapshot so the two copies agree.

7. Apps that aren't installed yet

On a new phone, many of your apps may not be installed yet. We don't drop their rows during restore. Folders, favorites and blocks keep their entries, so the layout comes back intact as you reinstall apps. What we do is tell you which ones are missing:

private fun JSONObject.missingAppLabels(): List<String> {
    // ...
    fun add(packageName: String?, fallback: String?) {
        val pkg = packageName?.takeIf { it.isNotBlank() } ?: return
        if (isPackageInstalled(pkg)) return
        val chosen = renames[pkg]?.takeIf { it.isNotBlank() }
            ?: fallback?.takeIf { it.isNotBlank() }
            ?: pkg
        // ...
    }
    listOf("favorites", "folderApps", "hiddenApps", "blocks", "reminders", "budgets", "scheduleApps")
        .forEach { key ->
            jsonArray(key).forEachObject { add(it.optStringOrNull("packageName"), it.optStringOrNull("label")) }
        }
    // ...
}

This is where saving labels in section 1 pays off. The new phone can't look up a name for a package it doesn't have, but the backup already carries one. Your own rename comes first, then the saved label, and the raw package name only as a last resort.

8. The background image, and what the platform allows

If you picked a custom photo for Home, the snapshot copies it. When Home just shows the system wallpaper, the code tries to capture that too, and avoids re-encoding it on every snapshot by caching WallpaperManager.getWallpaperId(FLAG_SYSTEM). The WallpaperManager reference says a new ID is assigned "every time the wallpaper image is set" so an unchanged ID means the cached JPEG is still current. Reordering apps doesn't re-encode a wallpaper.

We need to be honest about the limits, because the same reference spells them out. Reading the actual wallpaper file has been locked down: on Android 13 apps get the default wallpaper instead, and from Android 14 getWallpaperFile throws unless the app holds permissions a launcher like ours doesn't request. The capture is wrapped in runCatching, so on those versions the snapshot just goes on without it. In practice, the background that reliably travels in a ReClaim backup is a photo you picked inside ReClaim.

9. Why not just rely on Auto Backup?

Android's Auto Backup already exists, and our manifest leaves android:allowBackup="true". It does a different job. Backups run when "at least 24 hours have elapsed since the last backup", the device is idle and on Wi-Fi, and data is restored "whenever the app is installed." That's a good safety net, but the user can't hold that copy or restore it on demand into an app that's already running. Ours is a file they can see and choose when to restore. The two exist side by side.

What we'd tell another app team

  • Snapshot from your Flows with merge and debounce; don't add a "Back up now" button.
  • Write to a temp file, then rename, and serialize writers with a Mutex.
  • Gate the auto-backup when the live data is empty, or it will overwrite your only good copy.
  • Clear and insert in one transaction, and order inserts by reference.
  • On import, cap sizes, check magic bytes, strip entry paths, and whitelist file names.
  • Store human-readable labels next to IDs that won't exist on the next device.

This post was drafted with AI assistance and reviewed by the ReClaim team.

來源:DEV Community · dev.to