[DataStore] Provide SharedPreferencesStorage Bug: 325144964 Test: Manual tests (cherry picked from https://googleplex-android-review.googlesource.com/q/commit:e753701dd90a0bbf4348a1e94493f3dbaea67aec) Merged-In: I3f6bbf82855aab2b376d96b38747f295cf96085e Change-Id: I3f6bbf82855aab2b376d96b38747f295cf96085e
diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt index cb1b072..a447ace 100644 --- a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt +++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt
@@ -78,7 +78,7 @@ override fun backup( backupContext: BackupContext, - outputStream: OutputStream + outputStream: OutputStream, ): EntityBackupResult { if (!enableBackup(backupContext)) { Log.i(LOG_TAG, "[$name] $key backup disabled")
diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt new file mode 100644 index 0000000..0c1b417 --- /dev/null +++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt
@@ -0,0 +1,199 @@ +/* + * 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 android.content.SharedPreferences +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat +import java.io.File + +/** + * [SharedPreferences] based storage. + * + * The backup and restore is handled by [BackupRestoreFileArchiver] to achieve forward-compatibility + * just like `PersistentBackupAgentHelper`. + * + * Simple file based backup and restore is not safe, which incurs multi-thread file writes in + * SharedPreferences file. Additionally, SharedPreferences has in-memory state, so reload is needed. + * However, there is no public reload API on SharedPreferences and listeners are not notified in + * current private implementation. As such, an intermediate SharedPreferences file is introduced for + * backup and restore. + * + * Note that existing entries in the SharedPreferences will NOT be deleted before restore. + * + * @param context Context to get SharedPreferences + * @param name Name of the SharedPreferences + * @param mode Operating mode, see [Context.getSharedPreferences] + * @param verbose Verbose logging on key/value pairs during backup/restore. Enable for dev only! + * @param filter Filter of key/value pairs for backup and restore. + */ +class SharedPreferencesStorage +@JvmOverloads +constructor( + context: Context, + override val name: String, + mode: Int, + private val verbose: Boolean = (Build.TYPE == "eng"), + private val filter: (String, Any?) -> Boolean = { _, _ -> true }, +) : + BackupRestoreFileStorage(context, context.getSharedPreferencesFilePath(name)), + KeyedObservable<String> by KeyedDataObservable() { + + private val sharedPreferences = context.getSharedPreferences(name, mode) + + /** Name of the intermediate SharedPreferences. */ + private val intermediateName: String + get() = "_br_$name" + + private val intermediateSharedPreferences: SharedPreferences + get() { + // use MODE_MULTI_PROCESS to ensure a reload + return context.getSharedPreferences(intermediateName, Context.MODE_MULTI_PROCESS) + } + + private val sharedPreferencesListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key != null) { + notifyChange(key, ChangeReason.UPDATE) + } else { + // On Android >= R, SharedPreferences.Editor.clear() will trigger this case + notifyChange(ChangeReason.DELETE) + } + } + + init { + // listener is weakly referenced, so unregister is optional + sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferencesListener) + } + + override val backupFile: File + // use a different file to avoid multi-thread file write + get() = context.getSharedPreferencesFile(intermediateName) + + override fun prepareBackup(file: File) { + val editor = intermediateSharedPreferences.merge(sharedPreferences.all, "Backup") + // commit to ensure data is write to disk synchronously + if (!editor.commit()) { + Log.w(LOG_TAG, "[$name] fail to commit") + } + } + + override fun onBackupFinished(file: File) { + intermediateSharedPreferences.delete(intermediateName) + } + + override fun onRestoreFinished(file: File) { + // Unregister listener to avoid notify observer during restore because there might be + // dependency between keys. BackupRestoreStorageManager.onRestoreFinished will notify + // observers consistently once restore finished. + sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener) + val restored = intermediateSharedPreferences + val editor = sharedPreferences.merge(restored.all, "Restore") + editor.apply() // apply to avoid blocking + sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferencesListener) + // clear the intermediate SharedPreferences + restored.delete(intermediateName) + } + + private fun SharedPreferences.delete(name: String) { + if (deleteSharedPreferences(name)) { + Log.i(LOG_TAG, "SharedPreferences $name deleted") + } else { + edit().clear().apply() + } + } + + private fun deleteSharedPreferences(name: String): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + context.deleteSharedPreferences(name) + } else { + false + } + + private fun SharedPreferences.merge( + entries: Map<String, Any?>, + operation: String + ): SharedPreferences.Editor { + val editor = edit() + for ((key, value) in entries) { + if (!filter.invoke(key, value)) { + if (verbose) Log.v(LOG_TAG, "[$name] $operation skips $key=$value") + continue + } + when (value) { + is Boolean -> { + editor.putBoolean(key, value) + if (verbose) Log.v(LOG_TAG, "[$name] $operation Boolean $key=$value") + } + is Float -> { + editor.putFloat(key, value) + if (verbose) Log.v(LOG_TAG, "[$name] $operation Float $key=$value") + } + is Int -> { + editor.putInt(key, value) + if (verbose) Log.v(LOG_TAG, "[$name] $operation Int $key=$value") + } + is Long -> { + editor.putLong(key, value) + if (verbose) Log.v(LOG_TAG, "[$name] $operation Long $key=$value") + } + is String -> { + editor.putString(key, value) + if (verbose) Log.v(LOG_TAG, "[$name] $operation String $key=$value") + } + is Set<*> -> { + val nonString = value.firstOrNull { it !is String } + if (nonString != null) { + Log.e( + LOG_TAG, + "[$name] $operation StringSet $key=$value" + + " but non string found: $nonString (${nonString.javaClass})", + ) + } else { + @Suppress("UNCHECKED_CAST") editor.putStringSet(key, value as Set<String>) + if (verbose) Log.v(LOG_TAG, "[$name] $operation StringSet $key=$value") + } + } + else -> { + Log.e( + LOG_TAG, + "[$name] $operation $key=$value, unknown type: ${value?.javaClass}" + ) + } + } + } + return editor + } + + companion object { + private fun Context.getSharedPreferencesFilePath(name: String): String { + val file = getSharedPreferencesFile(name) + return file.relativeTo(ContextCompat.getDataDir(this)!!).toString() + } + + /** Returns the absolute path of shared preferences file. */ + @JvmStatic + fun Context.getSharedPreferencesFile(name: String): File { + // ContextImpl.getSharedPreferencesPath is private + return File(getSharedPreferencesDir(), "$name.xml") + } + + private fun Context.getSharedPreferencesDir() = File(dataDirCompat, "shared_prefs") + } +}