diff options
16 files changed, 1551 insertions, 126 deletions
diff --git a/packages/SettingsLib/DataStore/Android.bp b/packages/SettingsLib/DataStore/Android.bp index 9fafcabad81b..86c8f0da83cb 100644 --- a/packages/SettingsLib/DataStore/Android.bp +++ b/packages/SettingsLib/DataStore/Android.bp @@ -2,12 +2,17 @@ package { default_applicable_licenses: ["frameworks_base_license"], } +filegroup { + name: "SettingsLibDataStore-srcs", + srcs: ["src/**/*"], +} + android_library { name: "SettingsLibDataStore", defaults: [ "SettingsLintDefaults", ], - srcs: ["src/**/*"], + srcs: [":SettingsLibDataStore-srcs"], static_libs: [ "androidx.annotation_annotation", "androidx.collection_collection-ktx", 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 9d3fb66c7ce0..7644bc967281 100644 --- a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt +++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreFileArchiver.kt @@ -19,6 +19,7 @@ package com.android.settingslib.datastore import android.app.backup.BackupDataInputStream import android.content.Context import android.util.Log +import androidx.annotation.VisibleForTesting import java.io.File import java.io.InputStream import java.io.OutputStream @@ -33,11 +34,9 @@ import java.util.zip.CheckedInputStream */ internal class BackupRestoreFileArchiver( private val context: Context, - private val fileStorages: List<BackupRestoreFileStorage>, + @get:VisibleForTesting internal val fileStorages: List<BackupRestoreFileStorage>, + override val name: String, ) : BackupRestoreStorage() { - override val name: String - get() = "file_archiver" - override fun createBackupRestoreEntities(): List<BackupRestoreEntity> = fileStorages.map { it.toBackupRestoreEntity() } @@ -88,7 +87,8 @@ internal class BackupRestoreFileArchiver( } } -private fun BackupRestoreFileStorage.toBackupRestoreEntity() = +@VisibleForTesting +internal fun BackupRestoreFileStorage.toBackupRestoreEntity() = object : BackupRestoreEntity { override val key: String get() = storageFilePath @@ -107,7 +107,7 @@ private fun BackupRestoreFileStorage.toBackupRestoreEntity() = Log.i(LOG_TAG, "[$name] $key not exist") return EntityBackupResult.DELETE } - val codec = codec() ?: defaultCodec() + val codec = defaultCodec() // MUST close to flush the data wrapBackupOutputStream(codec, outputStream).use { stream -> val bytesCopied = file.inputStream().use { it.copyTo(stream) } diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorage.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorage.kt index c4c00cbb8191..935f9ccf6ed9 100644 --- a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorage.kt +++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorage.kt @@ -22,6 +22,7 @@ import android.app.backup.BackupDataOutput import android.app.backup.BackupHelper import android.os.ParcelFileDescriptor import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.collection.MutableScatterMap import com.google.common.io.ByteStreams import java.io.ByteArrayOutputStream @@ -60,10 +61,11 @@ abstract class BackupRestoreStorage : BackupHelper { * * Map key is the entity key, map value is the checksum of backup data. */ - protected val entityStates = MutableScatterMap<String, Long>() + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + val entityStates = MutableScatterMap<String, Long>() /** Entities created by [createBackupRestoreEntities]. This field is for restore only. */ - private var entities: List<BackupRestoreEntity>? = null + @VisibleForTesting internal var entities: List<BackupRestoreEntity>? = null /** Entities to back up and restore. */ abstract fun createBackupRestoreEntities(): List<BackupRestoreEntity> @@ -76,7 +78,7 @@ abstract class BackupRestoreStorage : BackupHelper { data: BackupDataOutput, newState: ParcelFileDescriptor, ) { - oldState.readEntityStates(entityStates) + readEntityStates(oldState, entityStates) val backupContext = BackupContext(data) if (!enableBackup(backupContext)) { Log.i(LOG_TAG, "[$name] Backup disabled") @@ -94,7 +96,10 @@ abstract class BackupRestoreStorage : BackupHelper { val codec = entity.codec() ?: defaultCodec() val result = try { - entity.backup(backupContext, wrapBackupOutputStream(codec, checkedOutputStream)) + // MUST close to flush all data + wrapBackupOutputStream(codec, checkedOutputStream).use { + entity.backup(backupContext, it) + } } catch (exception: Exception) { Log.e(LOG_TAG, "[$name] Fail to backup entity $key", exception) continue @@ -191,9 +196,13 @@ abstract class BackupRestoreStorage : BackupHelper { /** Callbacks when restore finished. */ open fun onRestoreFinished() {} - private fun ParcelFileDescriptor?.readEntityStates(state: MutableScatterMap<String, Long>) { + @VisibleForTesting + internal fun readEntityStates( + parcelFileDescriptor: ParcelFileDescriptor?, + state: MutableScatterMap<String, Long>, + ) { state.clear() - if (this == null) return + val fileDescriptor = parcelFileDescriptor?.fileDescriptor ?: return // do not close the streams val fileInputStream = FileInputStream(fileDescriptor) val dataInputStream = DataInputStream(fileInputStream) @@ -233,6 +242,7 @@ abstract class BackupRestoreStorage : BackupHelper { dataOutputStream.writeUTF(key) dataOutputStream.writeLong(value) } + dataOutputStream.flush() } catch (exception: Exception) { Log.e(LOG_TAG, "[$name] Fail to write state file", exception) } @@ -241,7 +251,7 @@ abstract class BackupRestoreStorage : BackupHelper { } companion object { - private const val STATE_VERSION: Byte = 0 + internal const val STATE_VERSION: Byte = 0 /** Checksum for entity backup data. */ fun createChecksum(): Checksum = CRC32() 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 cfdcaff4d34c..82423473e682 100644 --- a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorageManager.kt +++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/BackupRestoreStorageManager.kt @@ -21,23 +21,32 @@ import android.app.backup.BackupAgentHelper import android.app.backup.BackupManager import android.content.Context import android.util.Log +import androidx.annotation.VisibleForTesting import com.google.common.util.concurrent.MoreExecutors import java.util.concurrent.ConcurrentHashMap /** Manager of [BackupRestoreStorage]. */ class BackupRestoreStorageManager private constructor(private val application: Application) { - private val storageWrappers = ConcurrentHashMap<String, StorageWrapper>() + @VisibleForTesting internal val storageWrappers = ConcurrentHashMap<String, StorageWrapper>() private val executor = MoreExecutors.directExecutor() /** * Adds all the registered [BackupRestoreStorage] as the helpers of given [BackupAgentHelper]. * - * All [BackupRestoreFileStorage]s will be wrapped as a single [BackupRestoreFileArchiver]. + * All [BackupRestoreFileStorage]s will be wrapped as a single [BackupRestoreFileArchiver], + * specify [fileArchiverName] to avoid key prefix conflict if needed. * + * @param backupAgentHelper backup agent helper to add helpers + * @param fileArchiverName key prefix of the [BackupRestoreFileArchiver], the value must not be + * changed in future * @see BackupAgentHelper.addHelper */ - fun addBackupAgentHelpers(backupAgentHelper: BackupAgentHelper) { + @JvmOverloads + fun addBackupAgentHelpers( + backupAgentHelper: BackupAgentHelper, + fileArchiverName: String = "file_archiver", + ) { val fileStorages = mutableListOf<BackupRestoreFileStorage>() for ((keyPrefix, storageWrapper) in storageWrappers) { val storage = storageWrapper.storage @@ -48,7 +57,7 @@ class BackupRestoreStorageManager private constructor(private val application: A } } // Always add file archiver even fileStorages is empty to handle forward compatibility - val fileArchiver = BackupRestoreFileArchiver(application, fileStorages) + val fileArchiver = BackupRestoreFileArchiver(application, fileStorages, fileArchiverName) backupAgentHelper.addHelper(fileArchiver.name, fileArchiver) } @@ -106,7 +115,8 @@ class BackupRestoreStorageManager private constructor(private val application: A /** Returns storage with given name, exception is raised if not found. */ fun getOrThrow(name: String): BackupRestoreStorage = storageWrappers[name]!!.storage - private inner class StorageWrapper(val storage: BackupRestoreStorage) : + @VisibleForTesting + internal inner class StorageWrapper(val storage: BackupRestoreStorage) : Observer, KeyedObserver<Any?> { init { when (storage) { @@ -139,7 +149,7 @@ class BackupRestoreStorageManager private constructor(private val application: A LOG_TAG, "Notify BackupManager dataChanged: storage=$name key=$key reason=$reason" ) - BackupManager.dataChanged(application.packageName) + BackupManager(application).dataChanged() } fun removeObserver() { diff --git a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt index 0c1b41799f09..9f9c0d839744 100644 --- a/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt +++ b/packages/SettingsLib/DataStore/src/com/android/settingslib/datastore/SharedPreferencesStorage.kt @@ -20,9 +20,11 @@ import android.content.Context import android.content.SharedPreferences import android.os.Build import android.util.Log -import androidx.core.content.ContextCompat +import androidx.annotation.VisibleForTesting import java.io.File +private fun defaultVerbose() = Build.TYPE == "eng" + /** * [SharedPreferences] based storage. * @@ -43,24 +45,35 @@ import java.io.File * @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 +open class SharedPreferencesStorage @JvmOverloads constructor( context: Context, override val name: String, - mode: Int, - private val verbose: Boolean = (Build.TYPE == "eng"), + @get:VisibleForTesting internal val sharedPreferences: SharedPreferences, + private val codec: BackupCodec? = null, + private val verbose: Boolean = defaultVerbose(), private val filter: (String, Any?) -> Boolean = { _, _ -> true }, ) : BackupRestoreFileStorage(context, context.getSharedPreferencesFilePath(name)), KeyedObservable<String> by KeyedDataObservable() { - private val sharedPreferences = context.getSharedPreferences(name, mode) + @JvmOverloads + constructor( + context: Context, + name: String, + mode: Int, + codec: BackupCodec? = null, + verbose: Boolean = defaultVerbose(), + filter: (String, Any?) -> Boolean = { _, _ -> true }, + ) : this(context, name, context.getSharedPreferences(name, mode), codec, verbose, filter) /** Name of the intermediate SharedPreferences. */ - private val intermediateName: String + @VisibleForTesting + internal val intermediateName: String get() = "_br_$name" + @Suppress("DEPRECATION") private val intermediateSharedPreferences: SharedPreferences get() { // use MODE_MULTI_PROCESS to ensure a reload @@ -82,12 +95,15 @@ constructor( sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferencesListener) } + override fun defaultCodec() = codec ?: super.defaultCodec() + 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") + val editor = + mergeSharedPreferences(intermediateSharedPreferences, sharedPreferences.all, "Backup") // commit to ensure data is write to disk synchronously if (!editor.commit()) { Log.w(LOG_TAG, "[$name] fail to commit") @@ -104,8 +120,8 @@ constructor( // observers consistently once restore finished. sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener) val restored = intermediateSharedPreferences - val editor = sharedPreferences.merge(restored.all, "Restore") - editor.apply() // apply to avoid blocking + val editor = mergeSharedPreferences(sharedPreferences, restored.all, "Restore") + editor.commit() // commit to avoid race condition sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferencesListener) // clear the intermediate SharedPreferences restored.delete(intermediateName) @@ -115,7 +131,7 @@ constructor( if (deleteSharedPreferences(name)) { Log.i(LOG_TAG, "SharedPreferences $name deleted") } else { - edit().clear().apply() + edit().clear().commit() // commit to avoid potential race condition } } @@ -126,11 +142,13 @@ constructor( false } - private fun SharedPreferences.merge( + @VisibleForTesting + internal open fun mergeSharedPreferences( + sharedPreferences: SharedPreferences, entries: Map<String, Any?>, - operation: String + operation: String, ): SharedPreferences.Editor { - val editor = edit() + val editor = sharedPreferences.edit() for ((key, value) in entries) { if (!filter.invoke(key, value)) { if (verbose) Log.v(LOG_TAG, "[$name] $operation skips $key=$value") @@ -184,7 +202,7 @@ constructor( companion object { private fun Context.getSharedPreferencesFilePath(name: String): String { val file = getSharedPreferencesFile(name) - return file.relativeTo(ContextCompat.getDataDir(this)!!).toString() + return file.relativeTo(dataDirCompat).toString() } /** Returns the absolute path of shared preferences file. */ diff --git a/packages/SettingsLib/DataStore/tests/Android.bp b/packages/SettingsLib/DataStore/tests/Android.bp index 8770dfa013d0..5d000ebe9417 100644 --- a/packages/SettingsLib/DataStore/tests/Android.bp +++ b/packages/SettingsLib/DataStore/tests/Android.bp @@ -9,11 +9,16 @@ android_app { android_robolectric_test { name: "SettingsLibDataStoreTest", - srcs: ["src/**/*"], + srcs: [ + ":SettingsLibDataStore-srcs", // b/240432457 + "src/**/*", + ], static_libs: [ - "SettingsLibDataStore", + "androidx.collection_collection-ktx", + "androidx.core_core-ktx", "androidx.test.ext.junit", "guava", + "kotlin-test", "mockito-robolectric-prebuilt", // mockito deps order matters! "mockito-kotlin2", ], diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupCodecTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupCodecTest.kt new file mode 100644 index 000000000000..867831b7f2bd --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupCodecTest.kt @@ -0,0 +1,75 @@ +/* + * 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.random.Random +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests of [BackupCodec]. */ +@RunWith(AndroidJUnit4::class) +class BackupCodecTest { + @Test + fun name() { + val names = mutableSetOf<String>() + for (codec in allCodecs()) { + assertThat(names).doesNotContain(codec.name) + names.add(codec.name) + } + } + + @Test + fun fromId() { + for (codec in allCodecs()) { + assertThat(BackupCodec.fromId(codec.id)).isInstanceOf(codec::class.java) + } + } + + @Test + fun fromId_unknownId() { + assertFailsWith(IllegalArgumentException::class) { BackupCodec.fromId(-1) } + } + + @Test + fun encode_decode() { + val random = Random.Default + fun test(codec: BackupCodec, size: Int) { + val data = random.nextBytes(size) + + // encode + val outputStream = ByteArrayOutputStream() + codec.encode(outputStream).use { it.write(data) } + + // decode + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + val result = codec.decode(inputStream).use { it.readBytes() } + + assertWithMessage("$size bytes: $data").that(result).isEqualTo(data) + } + + for (codec in allCodecs()) { + test(codec, 0) + repeat(10) { test(codec, random.nextInt(1, 1024)) } + } + } +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreContextTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreContextTest.kt new file mode 100644 index 000000000000..911665a28809 --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreContextTest.kt @@ -0,0 +1,44 @@ +/* + * 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.BackupDataOutput +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +/** Tests of [BackupContext] and [RestoreContext]. */ +@RunWith(AndroidJUnit4::class) +class BackupRestoreContextTest { + @Test + fun backupContext_quota() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val data = mock<BackupDataOutput> { on { quota } doReturn 10L } + assertThat(BackupContext(data).quota).isEqualTo(10) + } + + @Test + fun backupContext_transportFlags() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return + val data = mock<BackupDataOutput> { on { transportFlags } doReturn 5 } + assertThat(BackupContext(data).transportFlags).isEqualTo(5) + } +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreFileArchiverTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreFileArchiverTest.kt new file mode 100644 index 000000000000..6cce45320a04 --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreFileArchiverTest.kt @@ -0,0 +1,275 @@ +/* + * 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.Application +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlin.random.Random +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +/** Tests of [BackupRestoreFileArchiver]. */ +@RunWith(AndroidJUnit4::class) +class BackupRestoreFileArchiverTest { + private val random = Random.Default + private val application: Application = getApplicationContext() + @get:Rule val temporaryFolder = TemporaryFolder(application.dataDirCompat) + + @Test + fun createBackupRestoreEntities() { + val fileStorages = mutableListOf<BackupRestoreFileStorage>() + for (count in 0 until 3) { + val fileArchiver = BackupRestoreFileArchiver(application, fileStorages, "") + fileArchiver.createBackupRestoreEntities().apply { + assertThat(this).hasSize(fileStorages.size) + for (index in 0 until count) { + assertThat(get(index).key).isEqualTo(fileStorages[index].storageFilePath) + } + } + fileStorages.add(FileStorage("storage", "path$count")) + } + } + + @Test + fun wrapBackupOutputStream() { + val fileArchiver = BackupRestoreFileArchiver(application, listOf(), "") + val outputStream = ByteArrayOutputStream() + assertThat(fileArchiver.wrapBackupOutputStream(BackupZipCodec.BEST_SPEED, outputStream)) + .isSameInstanceAs(outputStream) + } + + @Test + fun wrapRestoreInputStream() { + val fileArchiver = BackupRestoreFileArchiver(application, listOf(), "") + val inputStream = ByteArrayInputStream(byteArrayOf()) + assertThat(fileArchiver.wrapRestoreInputStream(BackupZipCodec.BEST_SPEED, inputStream)) + .isSameInstanceAs(inputStream) + } + + @Test + fun restoreEntity_disabled() { + val file = temporaryFolder.newFile() + val key = file.name + val fileStorage = FileStorage("fs", key, restoreEnabled = false) + + BackupRestoreFileArchiver(application, listOf(fileStorage), "archiver").apply { + restoreEntity(newBackupDataInputStream(key, byteArrayOf())) + assertThat(entityStates.asMap()).isEmpty() + } + } + + @Test + fun restoreEntity_raiseIOException() { + val key = "key" + val fileStorage = FileStorage("fs", key) + BackupRestoreFileArchiver(application, listOf(fileStorage), "archiver").apply { + restoreEntity(newBackupDataInputStream(key, byteArrayOf(), IOException())) + assertThat(entityStates.asMap()).isEmpty() + } + } + + @Test + fun restoreEntity_onRestoreFinished_raiseException() { + val key = "key" + val fileStorage = FileStorage("fs", key, restoreException = IllegalStateException()) + BackupRestoreFileArchiver(application, listOf(fileStorage), "archiver").apply { + val data = random.nextBytes(random.nextInt(10)) + val outputStream = ByteArrayOutputStream() + fileStorage.wrapBackupOutputStream(fileStorage.defaultCodec(), outputStream).use { + it.write(data) + } + val payload = outputStream.toByteArray() + restoreEntity(newBackupDataInputStream(key, payload)) + assertThat(entityStates.asMap()).isEmpty() + } + } + + @Test + fun restoreEntity_forwardCompatibility() { + val key = "key" + val fileStorage = FileStorage("fs", key) + for (codec in allCodecs()) { + BackupRestoreFileArchiver(application, listOf(), "archiver").apply { + val data = random.nextBytes(random.nextInt(MAX_DATA_SIZE)) + val outputStream = ByteArrayOutputStream() + fileStorage.wrapBackupOutputStream(codec, outputStream).use { it.write(data) } + val payload = outputStream.toByteArray() + + restoreEntity(newBackupDataInputStream(key, payload)) + + assertThat(entityStates.asMap()).apply { + hasSize(1) + containsKey(key) + } + assertThat(fileStorage.restoreFile.readBytes()).isEqualTo(data) + } + } + } + + @Test + fun restoreEntity() { + val folder = File(application.dataDirCompat, "backup") + val file = File(folder, "file") + val key = "${folder.name}${File.separator}${file.name}" + fun test(codec: BackupCodec, size: Int) { + val fileStorage = FileStorage("fs", key, if (size % 2 == 0) codec else null) + val data = random.nextBytes(size) + val outputStream = ByteArrayOutputStream() + fileStorage.wrapBackupOutputStream(codec, outputStream).use { it.write(data) } + val payload = outputStream.toByteArray() + + val fileArchiver = + BackupRestoreFileArchiver(application, listOf(fileStorage), "archiver") + fileArchiver.restoreEntity(newBackupDataInputStream(key, payload)) + + assertThat(fileArchiver.entityStates.asMap()).apply { + hasSize(1) + containsKey(key) + } + assertThat(file.readBytes()).isEqualTo(data) + } + + for (codec in allCodecs()) { + for (size in 0 until 100) test(codec, size) + repeat(10) { test(codec, random.nextInt(100, MAX_DATA_SIZE)) } + } + } + + @Test + fun onRestoreFinished() { + val fileStorage = mock<BackupRestoreFileStorage>() + val fileArchiver = BackupRestoreFileArchiver(application, listOf(fileStorage), "") + + fileArchiver.onRestoreFinished() + + verify(fileStorage).onRestoreFinished() + } + + @Test + fun toBackupRestoreEntity_backup_disabled() { + val context = BackupContext(mock()) + val fileStorage = + mock<BackupRestoreFileStorage> { on { enableBackup(context) } doReturn false } + + assertThat(fileStorage.toBackupRestoreEntity().backup(context, ByteArrayOutputStream())) + .isEqualTo(EntityBackupResult.INTACT) + + verify(fileStorage, never()).prepareBackup(any()) + } + + @Test + fun toBackupRestoreEntity_backup_fileNotExist() { + val context = BackupContext(mock()) + val file = File("NotExist") + val fileStorage = + mock<BackupRestoreFileStorage> { + on { enableBackup(context) } doReturn true + on { backupFile } doReturn file + } + + assertThat(fileStorage.toBackupRestoreEntity().backup(context, ByteArrayOutputStream())) + .isEqualTo(EntityBackupResult.DELETE) + + verify(fileStorage).prepareBackup(file) + verify(fileStorage, never()).defaultCodec() + } + + @Test + fun toBackupRestoreEntity_backup() { + val context = BackupContext(mock()) + val file = temporaryFolder.newFile() + + fun test(codec: BackupCodec, size: Int) { + val data = random.nextBytes(size) + file.outputStream().use { it.write(data) } + + val outputStream = ByteArrayOutputStream() + val fileStorage = + mock<BackupRestoreFileStorage> { + on { enableBackup(context) } doReturn true + on { backupFile } doReturn file + on { defaultCodec() } doReturn codec + on { wrapBackupOutputStream(any(), any()) }.thenCallRealMethod() + on { wrapRestoreInputStream(any(), any()) }.thenCallRealMethod() + on { prepareBackup(any()) }.thenCallRealMethod() + on { onBackupFinished(any()) }.thenCallRealMethod() + } + + assertThat(fileStorage.toBackupRestoreEntity().backup(context, outputStream)) + .isEqualTo(EntityBackupResult.UPDATE) + + verify(fileStorage).prepareBackup(file) + verify(fileStorage).onBackupFinished(file) + + val decodedData = + fileStorage + .wrapRestoreInputStream(codec, ByteArrayInputStream(outputStream.toByteArray())) + .readBytes() + assertThat(decodedData).isEqualTo(data) + } + + for (codec in allCodecs()) { + // test small data to ensure correctness + for (size in 0 until 100) test(codec, size) + repeat(10) { test(codec, random.nextInt(100, MAX_DATA_SIZE)) } + } + } + + @Test + fun toBackupRestoreEntity_restore() { + val restoreContext = RestoreContext("storage") + val inputStream = + object : InputStream() { + override fun read() = throw IllegalStateException() + + override fun read(b: ByteArray, off: Int, len: Int) = throw IllegalStateException() + } + FileStorage("storage", "path").toBackupRestoreEntity().restore(restoreContext, inputStream) + } + + private open class FileStorage( + override val name: String, + filePath: String, + private val codec: BackupCodec? = null, + private val restoreEnabled: Boolean? = null, + private val restoreException: Exception? = null, + ) : BackupRestoreFileStorage(getApplicationContext(), filePath) { + + override fun defaultCodec() = codec ?: super.defaultCodec() + + override fun enableRestore() = restoreEnabled ?: super.enableRestore() + + override fun onRestoreFinished(file: File) { + super.onRestoreFinished(file) + if (restoreException != null) throw restoreException + } + } +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreFileStorageTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreFileStorageTest.kt new file mode 100644 index 000000000000..422273d9ce14 --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreFileStorageTest.kt @@ -0,0 +1,105 @@ +/* + * 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.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests of [BackupRestoreFileStorage]. */ +@RunWith(AndroidJUnit4::class) +class BackupRestoreFileStorageTest { + private val application: Application = getApplicationContext() + + @Test + fun dataDirCompat() { + val expected = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + application.dataDir + } else { + File(application.applicationInfo.dataDir) + } + assertThat(application.dataDirCompat).isEqualTo(expected) + } + + @Test + fun backupFile() { + assertThat(FileStorage("path").backupFile.toString()) + .startsWith(application.dataDirCompat.toString()) + } + + @Test + fun restoreFile() { + FileStorage("path").apply { assertThat(restoreFile).isEqualTo(backupFile) } + } + + @Test + fun checkFilePaths() { + FileStorage("path").checkFilePaths() + } + + @Test + fun checkFilePaths_emptyFilePath() { + assertFailsWith(IllegalArgumentException::class) { FileStorage("").checkFilePaths() } + } + + @Test + fun checkFilePaths_absoluteFilePath() { + assertFailsWith(IllegalArgumentException::class) { + FileStorage("${File.separatorChar}file").checkFilePaths() + } + } + + @Test + fun checkFilePaths_backupFile() { + assertFailsWith(IllegalArgumentException::class) { + FileStorage("path", fileForBackup = File("path")).checkFilePaths() + } + } + + @Test + fun checkFilePaths_restoreFile() { + assertFailsWith(IllegalArgumentException::class) { + FileStorage("path", fileForRestore = File("path")).checkFilePaths() + } + } + + @Test + fun createBackupRestoreEntities() { + assertThat(FileStorage("path").createBackupRestoreEntities()).isEmpty() + } + + private class FileStorage( + filePath: String, + val fileForBackup: File? = null, + val fileForRestore: File? = null, + ) : BackupRestoreFileStorage(getApplicationContext(), filePath) { + override val name = "storage" + + override val backupFile: File + get() = fileForBackup ?: super.backupFile + + override val restoreFile: File + get() = fileForRestore ?: super.restoreFile + } +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreStorageManagerTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreStorageManagerTest.kt new file mode 100644 index 000000000000..d8f502854402 --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreStorageManagerTest.kt @@ -0,0 +1,237 @@ +/* + * 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.Application +import android.app.backup.BackupAgentHelper +import android.app.backup.BackupHelper +import android.app.backup.BackupManager +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.MoreExecutors.directExecutor +import kotlin.test.assertFailsWith +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowBackupManager + +/** Tests of [BackupRestoreStorageManager]. */ +@RunWith(AndroidJUnit4::class) +class BackupRestoreStorageManagerTest { + private val application: Application = getApplicationContext() + private val manager = BackupRestoreStorageManager.getInstance(application) + private val fileStorage = FileStorage("fileStorage") + private val keyedStorage = KeyedStorage("keyedStorage") + + private val storage1 = mock<ObservableBackupRestoreStorage> { on { name } doReturn "1" } + private val storage2 = mock<ObservableBackupRestoreStorage> { on { name } doReturn "1" } + + @After + fun tearDown() { + manager.removeAll() + ShadowBackupManager.reset() + } + + @Test + fun getInstance() { + assertThat(BackupRestoreStorageManager.getInstance(application)).isSameInstanceAs(manager) + } + + @Test + fun addBackupAgentHelpers() { + val fs = FileStorage("fs") + manager.add(keyedStorage, fileStorage, storage1, fs) + val backupAgentHelper = DummyBackupAgentHelper() + manager.addBackupAgentHelpers(backupAgentHelper) + backupAgentHelper.backupHelpers.apply { + assertThat(size).isEqualTo(3) + assertThat(remove(keyedStorage.name)).isSameInstanceAs(keyedStorage) + assertThat(remove(storage1.name)).isSameInstanceAs(storage1) + val fileArchiver = entries.first().value as BackupRestoreFileArchiver + assertThat(fileArchiver.fileStorages.toSet()).containsExactly(fs, fileStorage) + } + } + + @Test + fun addBackupAgentHelpers_withoutFileStorage() { + manager.add(keyedStorage, storage1) + val backupAgentHelper = DummyBackupAgentHelper() + manager.addBackupAgentHelpers(backupAgentHelper) + backupAgentHelper.backupHelpers.apply { + assertThat(size).isEqualTo(3) + assertThat(remove(keyedStorage.name)).isSameInstanceAs(keyedStorage) + assertThat(remove(storage1.name)).isSameInstanceAs(storage1) + val fileArchiver = entries.first().value as BackupRestoreFileArchiver + assertThat(fileArchiver.fileStorages).isEmpty() + } + } + + @Test + fun add() { + manager.add(keyedStorage, fileStorage, storage1) + assertThat(manager.storageWrappers).apply { + hasSize(3) + containsKey(keyedStorage.name) + containsKey(fileStorage.name) + containsKey(storage1.name) + } + } + + @Test + fun add_identicalName() { + manager.add(storage1) + assertFailsWith(IllegalStateException::class) { manager.add(storage1) } + assertFailsWith(IllegalStateException::class) { manager.add(storage2) } + } + + @Test + fun add_nonObservable() { + assertFailsWith(IllegalArgumentException::class) { + manager.add(mock<BackupRestoreStorage>()) + } + } + + @Test + fun removeAll() { + add() + manager.removeAll() + assertThat(manager.storageWrappers).isEmpty() + } + + @Test + fun remove() { + manager.add(keyedStorage, fileStorage) + assertThat(manager.remove(storage1.name)).isNull() + assertThat(manager.remove(keyedStorage.name)).isSameInstanceAs(keyedStorage) + assertThat(manager.remove(fileStorage.name)).isSameInstanceAs(fileStorage) + } + + @Test + fun get() { + manager.add(keyedStorage, fileStorage) + assertThat(manager.get(storage1.name)).isNull() + assertThat(manager.get(keyedStorage.name)).isSameInstanceAs(keyedStorage) + assertThat(manager.get(fileStorage.name)).isSameInstanceAs(fileStorage) + } + + @Test + fun getOrThrow() { + manager.add(keyedStorage, fileStorage) + assertFailsWith(NullPointerException::class) { manager.getOrThrow(storage1.name) } + assertThat(manager.getOrThrow(keyedStorage.name)).isSameInstanceAs(keyedStorage) + assertThat(manager.getOrThrow(fileStorage.name)).isSameInstanceAs(fileStorage) + } + + @Test + fun notifyRestoreFinished() { + manager.add(keyedStorage, fileStorage) + val keyedObserver = mock<KeyedObserver<String>>() + val anyKeyObserver = mock<KeyedObserver<String?>>() + val observer = mock<Observer>() + val executor = directExecutor() + keyedStorage.addObserver("key", keyedObserver, executor) + keyedStorage.addObserver(anyKeyObserver, executor) + fileStorage.addObserver(observer, executor) + + manager.onRestoreFinished() + + verify(keyedObserver).onKeyChanged("key", ChangeReason.RESTORE) + verify(anyKeyObserver).onKeyChanged(null, ChangeReason.RESTORE) + verify(observer).onChanged(ChangeReason.RESTORE) + if (isRobolectric()) { + Shadows.shadowOf(BackupManager(application)).apply { + assertThat(isDataChanged).isFalse() + assertThat(dataChangedCount).isEqualTo(0) + } + } + } + + @Test + fun notifyBackupManager() { + manager.add(keyedStorage, fileStorage) + val keyedObserver = mock<KeyedObserver<String>>() + val anyKeyObserver = mock<KeyedObserver<String?>>() + val observer = mock<Observer>() + val executor = directExecutor() + keyedStorage.addObserver("key", keyedObserver, executor) + keyedStorage.addObserver(anyKeyObserver, executor) + fileStorage.addObserver(observer, executor) + + val backupManager = + if (isRobolectric()) Shadows.shadowOf(BackupManager(application)) else null + backupManager?.apply { + assertThat(isDataChanged).isFalse() + assertThat(dataChangedCount).isEqualTo(0) + } + + fileStorage.notifyChange(ChangeReason.UPDATE) + verify(observer).onChanged(ChangeReason.UPDATE) + verify(keyedObserver, never()).onKeyChanged(any(), any()) + verify(anyKeyObserver, never()).onKeyChanged(any(), any()) + reset(observer) + backupManager?.apply { + assertThat(isDataChanged).isTrue() + assertThat(dataChangedCount).isEqualTo(1) + } + + keyedStorage.notifyChange("key", ChangeReason.DELETE) + verify(observer, never()).onChanged(any()) + verify(keyedObserver).onKeyChanged("key", ChangeReason.DELETE) + verify(anyKeyObserver).onKeyChanged("key", ChangeReason.DELETE) + backupManager?.apply { + assertThat(isDataChanged).isTrue() + assertThat(dataChangedCount).isEqualTo(2) + } + reset(keyedObserver) + + // backup manager is not notified for restore event + fileStorage.notifyChange(ChangeReason.RESTORE) + keyedStorage.notifyChange("key", ChangeReason.RESTORE) + verify(observer).onChanged(ChangeReason.RESTORE) + verify(keyedObserver).onKeyChanged("key", ChangeReason.RESTORE) + verify(anyKeyObserver).onKeyChanged("key", ChangeReason.RESTORE) + backupManager?.apply { + assertThat(isDataChanged).isTrue() + assertThat(dataChangedCount).isEqualTo(2) + } + } + + private class KeyedStorage(override val name: String) : + BackupRestoreStorage(), KeyedObservable<String> by KeyedDataObservable() { + + override fun createBackupRestoreEntities(): List<BackupRestoreEntity> = listOf() + } + + private class FileStorage(override val name: String) : + BackupRestoreFileStorage(getApplicationContext(), "file"), Observable by DataObservable() + + private class DummyBackupAgentHelper : BackupAgentHelper() { + val backupHelpers = mutableMapOf<String, BackupHelper>() + + override fun addHelper(keyPrefix: String, helper: BackupHelper) { + backupHelpers[keyPrefix] = helper + } + } +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreStorageTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreStorageTest.kt new file mode 100644 index 000000000000..99998ffc13ec --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/BackupRestoreStorageTest.kt @@ -0,0 +1,414 @@ +/* + * 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.BackupAgentHelper +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataInputStream +import android.app.backup.BackupDataOutput +import android.os.ParcelFileDescriptor +import android.os.ParcelFileDescriptor.MODE_APPEND +import android.os.ParcelFileDescriptor.MODE_READ_ONLY +import android.os.ParcelFileDescriptor.MODE_WRITE_ONLY +import androidx.collection.MutableScatterMap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.io.DataOutputStream +import java.io.File +import java.io.FileDescriptor +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import kotlin.random.Random +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify + +/** Tests of [BackupRestoreStorage]. */ +@RunWith(AndroidJUnit4::class) +class BackupRestoreStorageTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + private val entity1 = Entity("key1", "value1".toByteArray()) + private val entity1NoOpCodec = Entity("key1", "value1".toByteArray(), BackupNoOpCodec()) + private val entity2 = Entity("key2", "value2".toByteArray(), BackupZipCodec.BEST_SPEED) + + @Test + fun performBackup_disabled() { + val storage = spy(TestStorage().apply { enabled = false }) + val unused = performBackup { data, newState -> storage.performBackup(null, data, newState) } + verify(storage, never()).createBackupRestoreEntities() + assertThat(storage.entities).isNull() + assertThat(storage.entityStates.size).isEqualTo(0) + } + + @Test + fun performBackup_enabled() { + val storage = spy(TestStorage()) + val unused = performBackup { data, newState -> storage.performBackup(null, data, newState) } + verify(storage).createBackupRestoreEntities() + assertThat(storage.entities).isNull() + assertThat(storage.entityStates.size).isEqualTo(0) + } + + @Test + fun performBackup_entityBackupWithException() { + val entity = + mock<BackupRestoreEntity> { + on { key } doReturn "" + on { backup(any(), any()) } doThrow IllegalStateException() + } + val storage = TestStorage(entity, entity1) + + val (_, stateFile) = + performBackup { data, newState -> storage.performBackup(null, data, newState) } + + assertThat(storage.readEntityStates(stateFile)).apply { + hasSize(1) + containsKey(entity1.key) + } + } + + @Test + fun performBackup_update_unchanged() { + performBackupTest({}) { entityStates, newEntityStates -> + assertThat(entityStates).isEqualTo(newEntityStates) + } + } + + @Test + fun performBackup_intact() { + performBackupTest({ entity1.backupResult = EntityBackupResult.INTACT }) { + entityStates, + newEntityStates -> + assertThat(entityStates).isEqualTo(newEntityStates) + } + } + + @Test + fun performBackup_delete() { + performBackupTest({ entity1.backupResult = EntityBackupResult.DELETE }) { _, newEntityStates + -> + assertThat(newEntityStates.size).isEqualTo(1) + assertThat(newEntityStates).containsKey(entity2.key) + } + } + + private fun performBackupTest( + update: () -> Unit, + verification: (Map<String, Long>, Map<String, Long>) -> Unit, + ) { + val storage = TestStorage(entity1, entity2) + val (_, stateFile) = + performBackup { data, newState -> storage.performBackup(null, data, newState) } + + val entityStates = storage.readEntityStates(stateFile) + assertThat(entityStates).apply { + hasSize(2) + containsKey(entity1.key) + containsKey(entity2.key) + } + + update.invoke() + val (_, newStateFile) = + performBackup { data, newState -> + stateFile.toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.performBackup(it, data, newState) + } + } + verification.invoke(entityStates, storage.readEntityStates(newStateFile)) + } + + @Test + fun restoreEntity_disabled() { + val storage = spy(TestStorage().apply { enabled = false }) + temporaryFolder.newFile().toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.restoreEntity(it.toBackupDataInputStream()) + } + verify(storage, never()).createBackupRestoreEntities() + assertThat(storage.entities).isNull() + assertThat(storage.entityStates.size).isEqualTo(0) + } + + @Test + fun restoreEntity_entityNotFound() { + val storage = TestStorage() + temporaryFolder.newFile().toParcelFileDescriptor(MODE_READ_ONLY).use { + val backupDataInputStream = it.toBackupDataInputStream() + backupDataInputStream.setKey("") + storage.restoreEntity(backupDataInputStream) + } + } + + @Test + fun restoreEntity_exception() { + val storage = TestStorage(entity1) + temporaryFolder.newFile().toParcelFileDescriptor(MODE_READ_ONLY).use { + val backupDataInputStream = it.toBackupDataInputStream() + backupDataInputStream.setKey(entity1.key) + storage.restoreEntity(backupDataInputStream) + } + } + + @Test + fun restoreEntity_codecChanged() { + assertThat(entity1.codec()).isNotEqualTo(entity1NoOpCodec.codec()) + backupAndRestore(entity1) { _, data -> + TestStorage(entity1NoOpCodec).apply { restoreEntity(data) } + } + assertThat(entity1.data).isEqualTo(entity1NoOpCodec.restoredData) + } + + @Test + fun restoreEntity() { + val random = Random.Default + fun test(codec: BackupCodec, size: Int) { + val entity = Entity("key", random.nextBytes(size), codec) + backupAndRestore(entity) + entity.verifyRestoredData() + } + for (codec in allCodecs()) { + // test small data to ensure correctness + for (size in 0 until 100) test(codec, size) + repeat(10) { test(codec, random.nextInt(100, MAX_DATA_SIZE)) } + } + } + + @Test + fun readEntityStates_eof_exception() { + val storage = TestStorage() + val entityStates = MutableScatterMap<String, Long>() + entityStates.put("", 0) // add an item to verify that exiting elements are clear + temporaryFolder.newFile().toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.readEntityStates(it, entityStates) + } + assertThat(entityStates.size).isEqualTo(0) + } + + @Test + fun readEntityStates_other_exception() { + val storage = TestStorage() + val entityStates = MutableScatterMap<String, Long>() + entityStates.put("", 0) // add an item to verify that exiting elements are clear + temporaryFolder.newFile().toParcelFileDescriptor(MODE_READ_ONLY).apply { + close() // cause exception when read state file + storage.readEntityStates(this, entityStates) + } + assertThat(entityStates.size).isEqualTo(0) + } + + @Test + fun readEntityStates_unknownVersion() { + val storage = TestStorage() + val stateFile = temporaryFolder.newFile() + stateFile.toParcelFileDescriptor(MODE_WRITE_ONLY or MODE_APPEND).use { + DataOutputStream(FileOutputStream(it.fileDescriptor)) + .writeByte(BackupRestoreStorage.STATE_VERSION + 1) + } + val entityStates = MutableScatterMap<String, Long>() + entityStates.put("", 0) // add an item to verify that exiting elements are clear + stateFile.toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.readEntityStates(it, entityStates) + } + assertThat(entityStates.size).isEqualTo(0) + } + + @Test + fun writeNewStateDescription() { + val storage = spy(TestStorage()) + // use read only mode to trigger exception when write state file + temporaryFolder.newFile().toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.writeNewStateDescription(it) + } + verify(storage).onRestoreFinished() + } + + @Test + fun backupAndRestore() { + val storage = spy(TestStorage(entity1, entity2)) + val backupAgentHelper = BackupAgentHelper() + backupAgentHelper.addHelper(storage.name, storage) + + // backup + val (dataFile, stateFile) = + performBackup { data, newState -> backupAgentHelper.onBackup(null, data, newState) } + storage.verifyFieldsArePurged() + + // verify state + val entityStates = MutableScatterMap<String, Long>() + entityStates[""] = 1 + storage.readEntityStates(null, entityStates) + assertThat(entityStates.size).isEqualTo(0) + stateFile.toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.readEntityStates(it, entityStates) + } + assertThat(entityStates.asMap()).apply { + hasSize(2) + containsKey(entity1.key) + containsKey(entity2.key) + } + reset(storage) + + // restore + val newStateFile = temporaryFolder.newFile() + dataFile.toParcelFileDescriptor(MODE_READ_ONLY).use { dataPfd -> + newStateFile.toParcelFileDescriptor(MODE_WRITE_ONLY or MODE_APPEND).use { + backupAgentHelper.onRestore(dataPfd.toBackupDataInput(), 0, it) + } + } + verify(storage).onRestoreFinished() + storage.verifyFieldsArePurged() + + // ShadowBackupDataOutput does not write data to file, so restore is bypassed + if (!isRobolectric()) { + entity1.verifyRestoredData() + entity2.verifyRestoredData() + assertThat(entityStates.asMap()).isEqualTo(storage.readEntityStates(newStateFile)) + } + } + + private fun backupAndRestore( + entity: BackupRestoreEntity, + restoreEntity: (TestStorage, BackupDataInputStream) -> TestStorage = { storage, data -> + storage.restoreEntity(data) + storage + }, + ) { + val storage = TestStorage(entity) + val entityKey = argumentCaptor<String>() + val entitySize = argumentCaptor<Int>() + val entityData = argumentCaptor<ByteArray>() + val data = mock<BackupDataOutput>() + + val stateFile = temporaryFolder.newFile() + stateFile.toParcelFileDescriptor(MODE_WRITE_ONLY or MODE_APPEND).use { + storage.performBackup(null, data, it) + } + val entityStates = MutableScatterMap<String, Long>() + stateFile.toParcelFileDescriptor(MODE_READ_ONLY).use { + storage.readEntityStates(it, entityStates) + } + assertThat(entityStates.size).isEqualTo(1) + + verify(data).writeEntityHeader(entityKey.capture(), entitySize.capture()) + verify(data).writeEntityData(entityData.capture(), entitySize.capture()) + assertThat(entityKey.allValues).isEqualTo(listOf(entity.key)) + assertThat(entityData.allValues).hasSize(1) + val payload = entityData.firstValue + assertThat(entitySize.allValues).isEqualTo(listOf(payload.size, payload.size)) + + val dataFile = temporaryFolder.newFile() + dataFile.toParcelFileDescriptor(MODE_WRITE_ONLY or MODE_APPEND).use { + FileOutputStream(it.fileDescriptor).write(payload) + } + + newBackupDataInputStream(entity.key, payload).apply { + restoreEntity.invoke(storage, this).also { + assertThat(it.entityStates).isEqualTo(entityStates) + } + } + } + + fun performBackup(backup: (BackupDataOutput, ParcelFileDescriptor) -> Unit): Pair<File, File> { + val dataFile = temporaryFolder.newFile() + val stateFile = temporaryFolder.newFile() + dataFile.toParcelFileDescriptor(MODE_WRITE_ONLY or MODE_APPEND).use { dataPfd -> + stateFile.toParcelFileDescriptor(MODE_WRITE_ONLY or MODE_APPEND).use { + backup.invoke(dataPfd.toBackupDataOutput(), it) + } + } + return dataFile to stateFile + } + + private fun BackupRestoreStorage.verifyFieldsArePurged() { + assertThat(entities).isNull() + assertThat(entityStates.size).isEqualTo(0) + assertThat(entityStates.capacity).isEqualTo(0) + } + + private fun BackupRestoreStorage.readEntityStates(stateFile: File): Map<String, Long> { + val entityStates = MutableScatterMap<String, Long>() + stateFile.toParcelFileDescriptor(MODE_READ_ONLY).use { readEntityStates(it, entityStates) } + return entityStates.asMap() + } + + private fun File.toParcelFileDescriptor(mode: Int) = ParcelFileDescriptor.open(this, mode) + + private fun ParcelFileDescriptor.toBackupDataOutput() = fileDescriptor.toBackupDataOutput() + + private fun ParcelFileDescriptor.toBackupDataInputStream(): BackupDataInputStream = + BackupDataInputStream::class.java.newInstance(toBackupDataInput()) + + private fun ParcelFileDescriptor.toBackupDataInput() = fileDescriptor.toBackupDataInput() + + private fun FileDescriptor.toBackupDataOutput(): BackupDataOutput = + BackupDataOutput::class.java.newInstance(this) + + private fun FileDescriptor.toBackupDataInput(): BackupDataInput = + BackupDataInput::class.java.newInstance(this) +} + +private open class TestStorage(vararg val backupRestoreEntities: BackupRestoreEntity) : + ObservableBackupRestoreStorage() { + var enabled: Boolean? = null + + override val name + get() = "TestBackup" + + override fun createBackupRestoreEntities() = backupRestoreEntities.toList() + + override fun enableBackup(backupContext: BackupContext) = + enabled ?: super.enableBackup(backupContext) + + override fun enableRestore() = enabled ?: super.enableRestore() +} + +private class Entity( + override val key: String, + val data: ByteArray, + private val codec: BackupCodec? = null, +) : BackupRestoreEntity { + var restoredData: ByteArray? = null + var backupResult = EntityBackupResult.UPDATE + + override fun codec() = codec ?: super.codec() + + override fun backup( + backupContext: BackupContext, + outputStream: OutputStream, + ): EntityBackupResult { + outputStream.write(data) + return backupResult + } + + override fun restore(restoreContext: RestoreContext, inputStream: InputStream) { + restoredData = inputStream.readBytes() + inputStream.close() + } + + fun verifyRestoredData() = assertThat(restoredData).isEqualTo(data) +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt index b52586c2d8d9..8638b2f20b52 100644 --- a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/KeyedObserverTest.kt @@ -16,76 +16,58 @@ package com.android.settingslib.datastore +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.google.common.util.concurrent.MoreExecutors.directExecutor +import com.google.common.util.concurrent.MoreExecutors import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicInteger import org.junit.Assert -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.junit.MockitoJUnit -import org.mockito.junit.MockitoRule -import org.mockito.kotlin.any +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.reset import org.mockito.kotlin.verify -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class KeyedObserverTest { - @get:Rule - val mockitoRule: MockitoRule = MockitoJUnit.rule() + private val observer1 = mock<KeyedObserver<Any?>>() + private val observer2 = mock<KeyedObserver<Any?>>() + private val keyedObserver1 = mock<KeyedObserver<Any>>() + private val keyedObserver2 = mock<KeyedObserver<Any>>() - @Mock - private lateinit var observer1: KeyedObserver<Any?> - - @Mock - private lateinit var observer2: KeyedObserver<Any?> - - @Mock - private lateinit var keyedObserver1: KeyedObserver<Any> - - @Mock - private lateinit var keyedObserver2: KeyedObserver<Any> - - @Mock - private lateinit var key1: Any - - @Mock - private lateinit var key2: Any - - @Mock - private lateinit var executor: Executor + private val key1 = Object() + private val key2 = Object() + private val executor1: Executor = MoreExecutors.directExecutor() + private val executor2: Executor = MoreExecutors.newDirectExecutorService() private val keyedObservable = KeyedDataObservable<Any>() @Test fun addObserver_sameExecutor() { - keyedObservable.addObserver(observer1, executor) - keyedObservable.addObserver(observer1, executor) + keyedObservable.addObserver(observer1, executor1) + keyedObservable.addObserver(observer1, executor1) } @Test fun addObserver_keyedObserver_sameExecutor() { - keyedObservable.addObserver(key1, keyedObserver1, executor) - keyedObservable.addObserver(key1, keyedObserver1, executor) + keyedObservable.addObserver(key1, keyedObserver1, executor1) + keyedObservable.addObserver(key1, keyedObserver1, executor1) } @Test fun addObserver_differentExecutor() { - keyedObservable.addObserver(observer1, executor) + keyedObservable.addObserver(observer1, executor1) Assert.assertThrows(IllegalStateException::class.java) { - keyedObservable.addObserver(observer1, directExecutor()) + keyedObservable.addObserver(observer1, executor2) } } @Test fun addObserver_keyedObserver_differentExecutor() { - keyedObservable.addObserver(key1, keyedObserver1, executor) + keyedObservable.addObserver(key1, keyedObserver1, executor1) Assert.assertThrows(IllegalStateException::class.java) { - keyedObservable.addObserver(key1, keyedObserver1, directExecutor()) + keyedObservable.addObserver(key1, keyedObserver1, executor2) } } @@ -93,7 +75,7 @@ class KeyedObserverTest { fun addObserver_weaklyReferenced() { val counter = AtomicInteger() var observer: KeyedObserver<Any?>? = KeyedObserver { _, _ -> counter.incrementAndGet() } - keyedObservable.addObserver(observer!!, directExecutor()) + keyedObservable.addObserver(observer!!, executor1) keyedObservable.notifyChange(ChangeReason.UPDATE) assertThat(counter.get()).isEqualTo(1) @@ -111,7 +93,7 @@ class KeyedObserverTest { fun addObserver_keyedObserver_weaklyReferenced() { val counter = AtomicInteger() var keyObserver: KeyedObserver<Any>? = KeyedObserver { _, _ -> counter.incrementAndGet() } - keyedObservable.addObserver(key1, keyObserver!!, directExecutor()) + keyedObservable.addObserver(key1, keyObserver!!, executor1) keyedObservable.notifyChange(key1, ChangeReason.UPDATE) assertThat(counter.get()).isEqualTo(1) @@ -127,45 +109,43 @@ class KeyedObserverTest { @Test fun addObserver_notifyObservers_removeObserver() { - keyedObservable.addObserver(observer1, directExecutor()) - keyedObservable.addObserver(observer2, executor) + keyedObservable.addObserver(observer1, executor1) + keyedObservable.addObserver(observer2, executor2) keyedObservable.notifyChange(ChangeReason.UPDATE) verify(observer1).onKeyChanged(null, ChangeReason.UPDATE) - verify(observer2, never()).onKeyChanged(any(), any()) - verify(executor).execute(any()) + verify(observer2).onKeyChanged(null, ChangeReason.UPDATE) - reset(observer1, executor) + reset(observer1, observer2) keyedObservable.removeObserver(observer2) keyedObservable.notifyChange(ChangeReason.DELETE) verify(observer1).onKeyChanged(null, ChangeReason.DELETE) - verify(executor, never()).execute(any()) + verify(observer2, never()).onKeyChanged(null, ChangeReason.DELETE) } @Test fun addObserver_keyedObserver_notifyObservers_removeObserver() { - keyedObservable.addObserver(key1, keyedObserver1, directExecutor()) - keyedObservable.addObserver(key2, keyedObserver2, executor) + keyedObservable.addObserver(key1, keyedObserver1, executor1) + keyedObservable.addObserver(key2, keyedObserver2, executor2) keyedObservable.notifyChange(key1, ChangeReason.UPDATE) verify(keyedObserver1).onKeyChanged(key1, ChangeReason.UPDATE) - verify(keyedObserver2, never()).onKeyChanged(any(), any()) - verify(executor, never()).execute(any()) + verify(keyedObserver2, never()).onKeyChanged(key2, ChangeReason.UPDATE) - reset(keyedObserver1, executor) - keyedObservable.removeObserver(key2, keyedObserver2) + reset(keyedObserver1, keyedObserver2) + keyedObservable.removeObserver(key1, keyedObserver1) keyedObservable.notifyChange(key1, ChangeReason.DELETE) - verify(keyedObserver1).onKeyChanged(key1, ChangeReason.DELETE) - verify(executor, never()).execute(any()) + verify(keyedObserver1, never()).onKeyChanged(key1, ChangeReason.DELETE) + verify(keyedObserver2, never()).onKeyChanged(key2, ChangeReason.DELETE) } @Test fun notifyChange_addMoreTypeObservers_checkOnKeyChanged() { - keyedObservable.addObserver(observer1, directExecutor()) - keyedObservable.addObserver(key1, keyedObserver1, directExecutor()) - keyedObservable.addObserver(key2, keyedObserver2, directExecutor()) + keyedObservable.addObserver(observer1, executor1) + keyedObservable.addObserver(key1, keyedObserver1, executor1) + keyedObservable.addObserver(key2, keyedObserver2, executor1) keyedObservable.notifyChange(ChangeReason.UPDATE) verify(observer1).onKeyChanged(null, ChangeReason.UPDATE) @@ -191,10 +171,10 @@ class KeyedObserverTest { fun notifyChange_addObserverWithinCallback() { // ConcurrentModificationException is raised if it is not implemented correctly val observer: KeyedObserver<Any?> = KeyedObserver { _, _ -> - keyedObservable.addObserver(observer1, executor) + keyedObservable.addObserver(observer1, executor1) } - keyedObservable.addObserver(observer, directExecutor()) + keyedObservable.addObserver(observer, executor1) keyedObservable.notifyChange(ChangeReason.UPDATE) keyedObservable.removeObserver(observer) @@ -204,12 +184,12 @@ class KeyedObserverTest { fun notifyChange_KeyedObserver_addObserverWithinCallback() { // ConcurrentModificationException is raised if it is not implemented correctly val keyObserver: KeyedObserver<Any?> = KeyedObserver { _, _ -> - keyedObservable.addObserver(key1, keyedObserver1, executor) + keyedObservable.addObserver(key1, keyedObserver1, executor1) } - keyedObservable.addObserver(key1, keyObserver, directExecutor()) + keyedObservable.addObserver(key1, keyObserver, executor1) keyedObservable.notifyChange(key1, ChangeReason.UPDATE) keyedObservable.removeObserver(key1, keyObserver) } -}
\ No newline at end of file +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt index f0658290beb0..173c2b1d4b81 100644 --- a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/ObserverTest.kt @@ -22,40 +22,33 @@ import com.google.common.util.concurrent.MoreExecutors import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicInteger import org.junit.Assert.assertThrows -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.junit.MockitoJUnit -import org.mockito.junit.MockitoRule -import org.mockito.kotlin.any +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.reset import org.mockito.kotlin.verify @RunWith(AndroidJUnit4::class) class ObserverTest { - @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() - - @Mock private lateinit var observer1: Observer - - @Mock private lateinit var observer2: Observer - - @Mock private lateinit var executor: Executor + private val observer1 = mock<Observer>() + private val observer2 = mock<Observer>() + private val executor1: Executor = MoreExecutors.directExecutor() + private val executor2: Executor = MoreExecutors.newDirectExecutorService() private val observable = DataObservable() @Test fun addObserver_sameExecutor() { - observable.addObserver(observer1, executor) - observable.addObserver(observer1, executor) + observable.addObserver(observer1, executor1) + observable.addObserver(observer1, executor1) } @Test fun addObserver_differentExecutor() { - observable.addObserver(observer1, executor) + observable.addObserver(observer1, executor1) assertThrows(IllegalStateException::class.java) { - observable.addObserver(observer1, MoreExecutors.directExecutor()) + observable.addObserver(observer1, executor2) } } @@ -63,7 +56,7 @@ class ObserverTest { fun addObserver_weaklyReferenced() { val counter = AtomicInteger() var observer: Observer? = Observer { counter.incrementAndGet() } - observable.addObserver(observer!!, MoreExecutors.directExecutor()) + observable.addObserver(observer!!, executor1) observable.notifyChange(ChangeReason.UPDATE) assertThat(counter.get()).isEqualTo(1) @@ -79,31 +72,27 @@ class ObserverTest { @Test fun addObserver_notifyObservers_removeObserver() { - observable.addObserver(observer1, MoreExecutors.directExecutor()) - observable.addObserver(observer2, executor) + observable.addObserver(observer1, executor1) + observable.addObserver(observer2, executor2) observable.notifyChange(ChangeReason.DELETE) verify(observer1).onChanged(ChangeReason.DELETE) - verify(observer2, never()).onChanged(any()) - verify(executor).execute(any()) + verify(observer2).onChanged(ChangeReason.DELETE) - reset(observer1, executor) + reset(observer1, observer2) observable.removeObserver(observer2) observable.notifyChange(ChangeReason.UPDATE) verify(observer1).onChanged(ChangeReason.UPDATE) - verify(executor, never()).execute(any()) + verify(observer2, never()).onChanged(ChangeReason.UPDATE) } @Test fun notifyChange_addObserverWithinCallback() { // ConcurrentModificationException is raised if it is not implemented correctly - val observer = Observer { observable.addObserver(observer1, executor) } - observable.addObserver( - observer, - MoreExecutors.directExecutor() - ) + val observer = Observer { observable.addObserver(observer1, executor1) } + observable.addObserver(observer, executor1) observable.notifyChange(ChangeReason.UPDATE) observable.removeObserver(observer) } diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/SharedPreferencesStorageTest.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/SharedPreferencesStorageTest.kt new file mode 100644 index 000000000000..fec7d758b893 --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/SharedPreferencesStorageTest.kt @@ -0,0 +1,175 @@ +/* + * 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.Application +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.MoreExecutors +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.Executor +import kotlin.random.Random +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify + +/** Tests of [SharedPreferencesStorage]. */ +@RunWith(AndroidJUnit4::class) +class SharedPreferencesStorageTest { + private val random = Random.Default + private val application: Application = ApplicationProvider.getApplicationContext() + private val map = + mapOf( + "boolean" to true, + "float" to random.nextFloat(), + "int" to random.nextInt(), + "long" to random.nextLong(), + "string" to "string", + "set" to setOf("string"), + ) + + @After + fun tearDown() { + application.getSharedPreferences(NAME, MODE).edit().clear().applySync() + } + + @Test + fun constructors() { + val storage1 = SharedPreferencesStorage(application, NAME, MODE) + val storage2 = + SharedPreferencesStorage( + application, + NAME, + application.getSharedPreferences(NAME, MODE), + ) + assertThat(storage1.sharedPreferences).isSameInstanceAs(storage2.sharedPreferences) + } + + @Test + fun observer() { + val observer = mock<KeyedObserver<Any?>>() + val keyedObserver = mock<KeyedObserver<Any>>() + val storage = SharedPreferencesStorage(application, NAME, MODE) + val executor: Executor = MoreExecutors.directExecutor() + storage.addObserver(observer, executor) + storage.addObserver("key", keyedObserver, executor) + + storage.sharedPreferences.edit().putString("key", "string").applySync() + verify(observer).onKeyChanged("key", ChangeReason.UPDATE) + verify(keyedObserver).onKeyChanged("key", ChangeReason.UPDATE) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + storage.sharedPreferences.edit().clear().applySync() + verify(observer).onKeyChanged(null, ChangeReason.DELETE) + verify(keyedObserver).onKeyChanged("key", ChangeReason.DELETE) + } + } + + @Test + fun prepareBackup_commitFailed() { + val editor = mock<SharedPreferences.Editor> { on { commit() } doReturn false } + val storage = + spy(SharedPreferencesStorage(application, NAME, MODE)) { + onGeneric { mergeSharedPreferences(any(), any(), any()) } doReturn editor + } + storage.prepareBackup(File("")) + } + + @Test + fun backupAndRestore() { + fun test(codec: BackupCodec) { + val storage = SharedPreferencesStorage(application, NAME, MODE, codec) + storage.mergeSharedPreferences(storage.sharedPreferences, map, "op").commit() + assertThat(storage.sharedPreferences.all).isEqualTo(map) + + val outputStream = ByteArrayOutputStream() + assertThat(storage.toBackupRestoreEntity().backup(BackupContext(mock()), outputStream)) + .isEqualTo(EntityBackupResult.UPDATE) + val payload = outputStream.toByteArray() + + storage.sharedPreferences.edit().clear().commit() + assertThat(storage.sharedPreferences.all).isEmpty() + + BackupRestoreFileArchiver(application, listOf(storage), "archiver") + .restoreEntity(newBackupDataInputStream(storage.storageFilePath, payload)) + assertThat(storage.sharedPreferences.all).isEqualTo(map) + } + + for (codec in allCodecs()) test(codec) + } + + @Test + fun mergeSharedPreferences_filter() { + val storage = + SharedPreferencesStorage(application, NAME, MODE) { key, value -> + key == "float" || value is String + } + storage.mergeSharedPreferences(storage.sharedPreferences, map, "op").apply() + assertThat(storage.sharedPreferences.all) + .containsExactly("float", map["float"], "string", map["string"]) + } + + @Test + fun mergeSharedPreferences_invalidSet() { + val storage = SharedPreferencesStorage(application, NAME, MODE, verbose = true) + storage + .mergeSharedPreferences( + storage.sharedPreferences, + mapOf<String, Any>("set" to setOf(Any())), + "op" + ) + .apply() + assertThat(storage.sharedPreferences.all).isEmpty() + } + + @Test + fun mergeSharedPreferences_unknownType() { + val storage = SharedPreferencesStorage(application, NAME, MODE) + storage + .mergeSharedPreferences(storage.sharedPreferences, map + ("key" to Any()), "op") + .apply() + assertThat(storage.sharedPreferences.all).isEqualTo(map) + } + + @Test + fun mergeSharedPreferences() { + val storage = SharedPreferencesStorage(application, NAME, MODE, verbose = true) + storage.mergeSharedPreferences(storage.sharedPreferences, map, "op").apply() + assertThat(storage.sharedPreferences.all).isEqualTo(map) + } + + private fun SharedPreferences.Editor.applySync() { + apply() + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } + + companion object { + private const val NAME = "pref" + private const val MODE = Context.MODE_PRIVATE + } +} diff --git a/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/TestUtils.kt b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/TestUtils.kt new file mode 100644 index 000000000000..823d222b2305 --- /dev/null +++ b/packages/SettingsLib/DataStore/tests/src/com/android/settingslib/datastore/TestUtils.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.app.backup.BackupDataInput +import android.app.backup.BackupDataInputStream +import android.os.Build +import java.io.ByteArrayInputStream +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock + +internal const val MAX_DATA_SIZE = 1 shl 12 + +internal fun allCodecs() = + arrayOf<BackupCodec>( + BackupNoOpCodec(), + ) + zipCodecs() + +internal fun zipCodecs() = + arrayOf<BackupCodec>( + BackupZipCodec.DEFAULT_COMPRESSION, + BackupZipCodec.BEST_COMPRESSION, + BackupZipCodec.BEST_SPEED, + ) + +internal fun <T : Any> Class<T>.newInstance(arg: Any, type: Class<*> = arg.javaClass): T = + getDeclaredConstructor(type).apply { isAccessible = true }.newInstance(arg) + +internal fun newBackupDataInputStream( + key: String, + data: ByteArray, + e: Exception? = null, +): BackupDataInputStream { + // ShadowBackupDataOutput does not write data to file, so mock for reading data + val inputStream = ByteArrayInputStream(data) + val backupDataInput = + mock<BackupDataInput> { + on { readEntityData(any(), any(), any()) } doAnswer + { + if (e != null) throw e + val buf = it.arguments[0] as ByteArray + val offset = it.arguments[1] as Int + val size = it.arguments[2] as Int + inputStream.read(buf, offset, size) + } + } + return BackupDataInputStream::class + .java + .newInstance(backupDataInput, BackupDataInput::class.java) + .apply { + setKey(key) + setDataSize(data.size) + } +} + +internal fun BackupDataInputStream.setKey(value: Any) { + val field = javaClass.getDeclaredField("key") + field.isAccessible = true + field.set(this, value) +} + +internal fun BackupDataInputStream.setDataSize(dataSize: Int) { + val field = javaClass.getDeclaredField("dataSize") + field.isAccessible = true + field.setInt(this, dataSize) +} + +internal fun isRobolectric() = Build.FINGERPRINT.contains("robolectric") |