[DataStore] Provide BackupRestoreFileStorage

Bug: 325144964
Test: Manual tests
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:50784fe9c08b868ec04562f7ae3df751515c9ee3)
Merged-In: I9ad13305321d0c9f5f6dccc46c266443fd45f73e
Change-Id: I9ad13305321d0c9f5f6dccc46c266443fd45f73e
diff --git a/packages/SettingsLib/DataStore/Android.bp b/packages/SettingsLib/DataStore/Android.bp
index 868a4a5..1806544 100644
--- a/packages/SettingsLib/DataStore/Android.bp
+++ b/packages/SettingsLib/DataStore/Android.bp
@@ -11,6 +11,7 @@
     static_libs: [
         "androidx.annotation_annotation",
         "androidx.collection_collection-ktx",
+        "androidx.core_core-ktx",
         "guava",
     ],
 }
diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt
new file mode 100644
index 0000000..cb1b072
--- /dev/null
+++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.datastore
+
+import android.app.backup.BackupDataInputStream
+import android.content.Context
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+
+/**
+ * File archiver to handle backup and restore for all the [BackupRestoreFileStorage] subclasses.
+ *
+ * Compared with [android.app.backup.FileBackupHelper], this class supports forward-compatibility
+ * like the [com.google.android.libraries.backup.PersistentBackupAgentHelper]: the app does not need
+ * to know the list of files in advance at restore time.
+ */
+internal class BackupRestoreFileArchiver(
+    private val context: Context,
+    private val fileStorages: List<BackupRestoreFileStorage>,
+) : BackupRestoreStorage() {
+    override val name: String
+        get() = "file_archiver"
+
+    override fun createBackupRestoreEntities(): List<BackupRestoreEntity> =
+        fileStorages.map { it.toBackupRestoreEntity() }
+
+    override fun restoreEntity(data: BackupDataInputStream) {
+        val key = data.key
+        val fileStorage = fileStorages.firstOrNull { it.storageFilePath == key }
+        val file =
+            if (fileStorage != null) {
+                if (!fileStorage.enableRestore()) {
+                    Log.i(LOG_TAG, "[$name] $key restore disabled")
+                    return
+                }
+                fileStorage.restoreFile
+            } else { // forward-compatibility
+                Log.i(LOG_TAG, "Restore unknown file $key")
+                File(context.dataDirCompat, key)
+            }
+        Log.i(LOG_TAG, "[$name] Restore ${data.size()} bytes for $key to $file")
+        try {
+            file.parentFile?.mkdirs() // ensure parent folders are created
+            val wrappedInputStream = wrapRestoreInputStream(data)
+            file.outputStream().use { wrappedInputStream.copyTo(it) }
+            Log.i(LOG_TAG, "[$name] $key restored")
+            fileStorage?.onRestoreFinished(file)
+        } catch (e: Exception) {
+            Log.e(LOG_TAG, "[$name] Fail to restore $key", e)
+        }
+    }
+
+    override fun writeNewStateDescription(newState: ParcelFileDescriptor) =
+        fileStorages.forEach { it.writeNewStateDescription(newState) }
+}
+
+private fun BackupRestoreFileStorage.toBackupRestoreEntity() =
+    object : BackupRestoreEntity {
+        override val key: String
+            get() = storageFilePath
+
+        override fun backup(
+            backupContext: BackupContext,
+            outputStream: OutputStream
+        ): EntityBackupResult {
+            if (!enableBackup(backupContext)) {
+                Log.i(LOG_TAG, "[$name] $key backup disabled")
+                return EntityBackupResult.INTACT
+            }
+            val file = backupFile
+            prepareBackup(file)
+            if (!file.exists()) {
+                Log.i(LOG_TAG, "[$name] $key not exist")
+                return EntityBackupResult.DELETE
+            }
+            val wrappedOutputStream = wrapBackupOutputStream(outputStream)
+            file.inputStream().use { it.copyTo(wrappedOutputStream) }
+            onBackupFinished(file)
+            return EntityBackupResult.UPDATE
+        }
+
+        override fun restore(restoreContext: RestoreContext, inputStream: InputStream) {
+            // no-op, BackupRestoreFileArchiver#restoreEntity will restore files
+        }
+    }
diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileStorage.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileStorage.kt
new file mode 100644
index 0000000..b531bd1
--- /dev/null
+++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileStorage.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.datastore
+
+import android.content.Context
+import androidx.core.content.ContextCompat
+import java.io.File
+
+/**
+ * A file-based storage with backup and restore support.
+ *
+ * [BackupRestoreFileArchiver] will handle the backup and restore based on file path for all
+ * subclasses.
+ *
+ * @param context Context to retrieve data dir
+ * @param storageFilePath Storage file path, which MUST be relative to the [Context.getDataDir]
+ *   folder. This is used as the entity name for backup and restore.
+ */
+abstract class BackupRestoreFileStorage(
+    val context: Context,
+    val storageFilePath: String,
+) : BackupRestoreStorage() {
+
+    /** The absolute path of the file to backup. */
+    open val backupFile: File
+        get() = File(context.dataDirCompat, storageFilePath)
+
+    /** The absolute path of the file to restore. */
+    open val restoreFile: File
+        get() = backupFile
+
+    fun checkFilePaths() {
+        if (storageFilePath.isEmpty() || storageFilePath[0] == File.separatorChar) {
+            throw IllegalArgumentException("$storageFilePath is not valid path")
+        }
+        if (!backupFile.isAbsolute) {
+            throw IllegalArgumentException("backupFile is not absolute")
+        }
+        if (!restoreFile.isAbsolute) {
+            throw IllegalArgumentException("restoreFile is not absolute")
+        }
+    }
+
+    /**
+     * Callback before [backupFile] is backed up.
+     *
+     * @param file equals to [backupFile]
+     */
+    open fun prepareBackup(file: File) {}
+
+    /**
+     * Callback when [backupFile] is restored.
+     *
+     * @param file equals to [backupFile]
+     */
+    open fun onBackupFinished(file: File) {}
+
+    /**
+     * Callback when [restoreFile] is restored.
+     *
+     * @param file equals to [restoreFile]
+     */
+    open fun onRestoreFinished(file: File) {}
+
+    final override fun createBackupRestoreEntities(): List<BackupRestoreEntity> = listOf()
+}
+
+internal val Context.dataDirCompat: File
+    get() = ContextCompat.getDataDir(this)!!
diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorageManager.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorageManager.kt
index 221e2e8..0e39493 100644
--- a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorageManager.kt
+++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorageManager.kt
@@ -46,12 +46,22 @@
     /**
      * Adds all the registered [BackupRestoreStorage] as the helpers of given [BackupAgentHelper].
      *
+     * All [BackupRestoreFileStorage]s will be wrapped as a single [BackupRestoreFileArchiver].
+     *
      * @see BackupAgentHelper.addHelper
      */
     fun addBackupAgentHelpers(backupAgentHelper: BackupAgentHelper) {
+        val fileStorages = mutableListOf<BackupRestoreFileStorage>()
         for ((keyPrefix, storage) in storages) {
-            backupAgentHelper.addHelper(keyPrefix, storage)
+            if (storage is BackupRestoreFileStorage) {
+                fileStorages.add(storage)
+            } else {
+                backupAgentHelper.addHelper(keyPrefix, storage)
+            }
         }
+        // Always add file archiver even fileStorages is empty to handle forward compatibility
+        val fileArchiver = BackupRestoreFileArchiver(application, fileStorages)
+        backupAgentHelper.addHelper(fileArchiver.name, fileArchiver)
     }
 
     /**
@@ -87,6 +97,7 @@
      * The storage MUST implement [KeyedObservable] or [Observable].
      */
     fun add(storage: BackupRestoreStorage) {
+        if (storage is BackupRestoreFileStorage) storage.checkFilePaths()
         val name = storage.name
         val oldStorage = storages.put(name, storage)
         if (oldStorage != null) {