From 1e64246154401f7798c9070f74e45de6891c9d62 Mon Sep 17 00:00:00 2001 From: Transformer Team Date: Tue, 7 Feb 2017 16:14:05 +0000 Subject: Import from google3 See google3/third_party/java_src/android_libs/backup/copy.bara.sky. Project import generated by Copybara (go/copybara). Bug: 34489332 Bug: 35093989 Bug: 35129081 PiperOrigin-RevId: 146787977 Change-Id: I904e1ba370a33212aca8b7840ee78d6aee2e0924 --- LICENSE | 202 ++++++++++++++++++++ .../google/android/libraries/backup/Backup.java | 13 ++ .../libraries/backup/BackupKeyPredicate.java | 8 + .../libraries/backup/BackupKeyPredicates.java | 169 ++++++++++++++++ .../backup/PersistentBackupAgentHelper.java | 212 +++++++++++++++++++++ .../libraries/backup/PreferenceBackupUtil.java | 99 ++++++++++ .../backup/shadow/BackupAgentHelperShadow.java | 181 ++++++++++++++++++ .../backup/shadow/BackupHelperSimulator.java | 28 +++ .../backup/shadow/FileBackupHelperSimulator.java | 157 +++++++++++++++ .../SharedPreferencesBackupHelperSimulator.java | 147 ++++++++++++++ 10 files changed, 1216 insertions(+) create mode 100644 LICENSE create mode 100644 src/com/google/android/libraries/backup/Backup.java create mode 100644 src/com/google/android/libraries/backup/BackupKeyPredicate.java create mode 100644 src/com/google/android/libraries/backup/BackupKeyPredicates.java create mode 100644 src/com/google/android/libraries/backup/PersistentBackupAgentHelper.java create mode 100644 src/com/google/android/libraries/backup/PreferenceBackupUtil.java create mode 100644 src/com/google/android/libraries/backup/shadow/BackupAgentHelperShadow.java create mode 100644 src/com/google/android/libraries/backup/shadow/BackupHelperSimulator.java create mode 100644 src/com/google/android/libraries/backup/shadow/FileBackupHelperSimulator.java create mode 100644 src/com/google/android/libraries/backup/shadow/SharedPreferencesBackupHelperSimulator.java diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/src/com/google/android/libraries/backup/Backup.java b/src/com/google/android/libraries/backup/Backup.java new file mode 100644 index 0000000..372f5a7 --- /dev/null +++ b/src/com/google/android/libraries/backup/Backup.java @@ -0,0 +1,13 @@ +package com.google.android.libraries.backup; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that a static field should be backed up. This should ONLY be used on static fields. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Backup {} \ No newline at end of file diff --git a/src/com/google/android/libraries/backup/BackupKeyPredicate.java b/src/com/google/android/libraries/backup/BackupKeyPredicate.java new file mode 100644 index 0000000..6c85dbd --- /dev/null +++ b/src/com/google/android/libraries/backup/BackupKeyPredicate.java @@ -0,0 +1,8 @@ +package com.google.android.libraries.backup; + +/** A predicate that determines whether a given key should be backed up. */ +public interface BackupKeyPredicate { + + /** Returns whether a given key should be backed up. */ + boolean shouldBeBackedUp(String key); +} diff --git a/src/com/google/android/libraries/backup/BackupKeyPredicates.java b/src/com/google/android/libraries/backup/BackupKeyPredicates.java new file mode 100644 index 0000000..56570ad --- /dev/null +++ b/src/com/google/android/libraries/backup/BackupKeyPredicates.java @@ -0,0 +1,169 @@ +package com.google.android.libraries.backup; + +import android.content.Context; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Static utility methods returning {@link BackupKeyPredicate} instances. */ +public class BackupKeyPredicates { + + /** + * Returns a predicate that determines whether a key was defined as a field with the given + * annotation in one of the given classes. Assumes that the given annotation and classes are + * valid. You must ensure that proguard does not remove your annotation or any fields annotated + * with it. + * + * @see Backup + */ + public static BackupKeyPredicate buildPredicateFromAnnotatedFieldsIn( + Class annotation, Class... klasses) { + return in(getAnnotatedFieldValues(annotation, klasses)); + } + + /** + * Returns a predicate that determines whether a key matches a regex that was defined as a field + * with the given annotation in one of the given classes. The test used is equivalent to + * {@link #containsPattern(String)} for each annotated field value. Assumes that the given + * annotation and classes are valid. You must ensure that proguard does not remove your annotation + * or any fields annotated with it. + * + * @see Backup + */ + public static BackupKeyPredicate buildPredicateFromAnnotatedRegexFieldsIn( + Class annotation, Class... klasses) { + Set patterns = getAnnotatedFieldValues(annotation, klasses); + Set patternPredicates = new HashSet<>(); + for (String pattern : patterns) { + patternPredicates.add(containsPattern(pattern)); + } + return or(patternPredicates); + } + + private static Set getAnnotatedFieldValues( + Class annotation, Class... klasses) { + Set values = new HashSet<>(); + for (Class klass : klasses) { + addAnnotatedFieldValues(annotation, klass, values); + } + return values; + } + + private static void addAnnotatedFieldValues( + Class annotation, Class klass, Set values) { + for (Field field : klass.getDeclaredFields()) { + addFieldValueIfAnnotated(annotation, field, values); + } + } + + private static void addFieldValueIfAnnotated( + Class annotation, Field field, Set values) { + if (field.isAnnotationPresent(annotation) && field.getType().equals(String.class)) { + try { + values.add((String) field.get(null)); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException(e); + } + } + } + + /** + * Returns a predicate that determines whether a key is a member of the given collection. Changes + * to the given collection will change the returned predicate. + */ + public static BackupKeyPredicate in(final Collection collection) { + if (collection == null) { + throw new NullPointerException("Null collection given."); + } + return new BackupKeyPredicate() { + @Override + public boolean shouldBeBackedUp(String key) { + return collection.contains(key); + } + }; + } + + /** + * Returns a predicate that determines whether a key contains any match for the given regular + * expression pattern. The test used is equivalent to {@link Matcher#find()}. + */ + public static BackupKeyPredicate containsPattern(String pattern) { + final Pattern compiledPattern = Pattern.compile(pattern); + return new BackupKeyPredicate() { + @Override + public boolean shouldBeBackedUp(String key) { + return compiledPattern.matcher(key).find(); + } + }; + } + + /** + * Returns a predicate that determines whether a key passes any of the given predicates. Each + * predicate is evaluated in the order given, and the evaluation process stops as soon as an + * accepting predicate is found. Changes to the given iterable will not change the returned + * predicate. The returned predicate returns {@code false} for any key if the given iterable is + * empty. + */ + public static BackupKeyPredicate or(Iterable predicates) { + final List copiedPredicates = new ArrayList<>(); + for (BackupKeyPredicate predicate : predicates) { + copiedPredicates.add(predicate); + } + return orDefensivelyCopied(new ArrayList<>(copiedPredicates)); + } + + /** + * Returns a predicate that determines whether a key passes any of the given predicates. Each + * predicate is evaluated in the order given, and the evaluation process stops as soon as an + * accepting predicate is found. The returned predicate returns {@code false} for any key if no + * there are no given predicates. + */ + public static BackupKeyPredicate or(BackupKeyPredicate... predicates) { + return orDefensivelyCopied(Arrays.asList(predicates)); + } + + private static BackupKeyPredicate orDefensivelyCopied( + final Iterable predicates) { + return new BackupKeyPredicate() { + @Override + public boolean shouldBeBackedUp(String key) { + for (BackupKeyPredicate predicate : predicates) { + if (predicate.shouldBeBackedUp(key)) { + return true; + } + } + return false; + } + }; + } + + /** + * Returns a predicate that determines whether a key is one of the resources from the provided + * resource IDs. Assumes that all of the given resource IDs are valid. + */ + public static BackupKeyPredicate buildPredicateFromResourceIds( + Context context, Collection ids) { + Set keys = new HashSet<>(); + for (Integer id : ids) { + keys.add(context.getString(id)); + } + return in(keys); + } + + /** Returns a predicate that returns true for any key. */ + public static BackupKeyPredicate alwaysTrue() { + return new BackupKeyPredicate() { + @Override + public boolean shouldBeBackedUp(String key) { + return true; + } + }; + } +} diff --git a/src/com/google/android/libraries/backup/PersistentBackupAgentHelper.java b/src/com/google/android/libraries/backup/PersistentBackupAgentHelper.java new file mode 100644 index 0000000..a107281 --- /dev/null +++ b/src/com/google/android/libraries/backup/PersistentBackupAgentHelper.java @@ -0,0 +1,212 @@ +package com.google.android.libraries.backup; + +import android.app.backup.BackupAgentHelper; +import android.app.backup.BackupDataInput; +import android.app.backup.BackupDataOutput; +import android.app.backup.SharedPreferencesBackupHelper; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.os.ParcelFileDescriptor; +import android.support.annotation.VisibleForTesting; +import android.util.Log; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * A {@link BackupAgentHelper} that contains the following improvements: + * + *

1) All backed-up shared preference files will automatically be restored; the app does not need + * to know the list of files in advance at restore time. This is important for apps that generate + * files dynamically, and it's also important for all apps that use restoreAnyVersion because + * additional files could have been added. + * + *

2) Only the requested keys will be backed up from each shared preference file. All keys that + * were backed up will be restored. + * + *

These benefits apply only to shared preference files. Other file helpers can be added in the + * normal way for a {@link BackupAgentHelper}. + * + *

This class works by creating a separate shared preference file named + * {@link #RESERVED_SHARED_PREFERENCES} that it backs up and restores. Before backing up, this file + * is populated based on the requested shared preference files and keys. After restoring, the data + * is copied back into the original files. + */ +public abstract class PersistentBackupAgentHelper extends BackupAgentHelper { + + /** + * The name of the shared preferences file reserved for use by the + * {@link PersistentBackupAgentHelper}. Files with this name cannot be backed up by this helper. + */ + protected static final String RESERVED_SHARED_PREFERENCES = "persistent_backup_agent_helper"; + + private static final String TAG = "PersistentBackupAgentHe"; // The max tag length is 23. + private static final String BACKUP_KEY = RESERVED_SHARED_PREFERENCES + "_prefs"; + private static final String BACKUP_DELIMITER = "/"; + + @Override + public void onCreate() { + addHelper(BACKUP_KEY, new SharedPreferencesBackupHelper(this, RESERVED_SHARED_PREFERENCES)); + } + + @Override + public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, + ParcelFileDescriptor newState) throws IOException { + writeFromPreferenceFilesToBackupFile(); + super.onBackup(oldState, data, newState); + clearBackupFile(); + } + + @VisibleForTesting + void writeFromPreferenceFilesToBackupFile() { + Map fileBackupKeyPredicates = getBackupSpecification(); + Editor backupEditor = getSharedPreferences(RESERVED_SHARED_PREFERENCES, MODE_PRIVATE).edit(); + backupEditor.clear(); + for (Map.Entry entry : fileBackupKeyPredicates.entrySet()) { + writeToBackupFile(entry.getKey(), backupEditor, entry.getValue()); + } + backupEditor.apply(); + } + + /** + * Returns the predicate that decides which keys should be backed up for each shared preference + * file name. There must be no files with the same name as {@link #RESERVED_SHARED_PREFERENCES}. + * Assumes all shared preference file names are valid. + * + *

This method will only be called at backup time. At restore time, everything that was backed + * up is restored. + * + * @see BackupKeyPredicates + */ + protected abstract Map getBackupSpecification(); + + /** + * Adds data from the given file name for keys that pass the given predicate. + * {@link Editor#apply()} is not called. + */ + private void writeToBackupFile( + String srcFileName, Editor editor, BackupKeyPredicate backupKeyPredicate) { + if (srcFileName.equals(RESERVED_SHARED_PREFERENCES)) { + throw new IllegalStateException("Backup file name \"" + RESERVED_SHARED_PREFERENCES + "\" is " + + "reserved by PersistentBackupAgentHelper and cannot be used."); + } + if (srcFileName.contains(BACKUP_DELIMITER)) { + throw new IllegalStateException("Backup file name \"" + srcFileName + "\" cannot contain " + + "delimiter \"" + BACKUP_DELIMITER + "\"."); + } + SharedPreferences srcSharedPreferences = getSharedPreferences(srcFileName, MODE_PRIVATE); + Map srcMap = srcSharedPreferences.getAll(); + for (Map.Entry entry : srcMap.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (backupKeyPredicate.shouldBeBackedUp(key)) { + putSharedPreference(editor, buildBackupKey(srcFileName, key), value); + } + } + } + + private static String buildBackupKey(String fileName, String key) { + return fileName + BACKUP_DELIMITER + key; + } + + /** + * Puts the given value into the given editor for the given key. {@link Editor#apply()} is not + * called. + */ + @SuppressWarnings("unchecked") // There are no unchecked casts - the Set cast IS checked. + public static void putSharedPreference(Editor editor, String key, Object value) { + if (value instanceof Boolean) { + editor.putBoolean(key, (Boolean) value); + } else if (value instanceof Float) { + editor.putFloat(key, (Float) value); + } else if (value instanceof Integer) { + editor.putInt(key, (Integer) value); + } else if (value instanceof Long) { + editor.putLong(key, (Long) value); + } else if (value instanceof String) { + editor.putString(key, (String) value); + } else if (value instanceof Set) { + for (Object object : (Set) value) { + if (!(object instanceof String)) { + // If a new type of shared preference set is added in the future, it can't be correctly + // restored on this version. + Log.w(TAG, "Skipping restore of key " + key + " because its value is a set containing" + + " an object of type " + (value == null ? null : value.getClass()) + "."); + return; + } + } + editor.putStringSet(key, (Set) value); + } else { + // If a new type of shared preference is added in the future, it can't be correctly restored + // on this version. + Log.w(TAG, "Skipping restore of key " + key + " because its value is the unrecognized type " + + (value == null ? null : value.getClass()) + "."); + return; + } + } + + private void clearBackupFile() { + // We don't currently delete the file because of a lack of a supported way to do it and because + // of the concerns of synchronously doing so. + getSharedPreferences(RESERVED_SHARED_PREFERENCES, MODE_PRIVATE).edit().clear().apply(); + } + + @Override + public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor stateFile) + throws IOException { + super.onRestore(data, appVersionCode, stateFile); + writeFromBackupFileToPreferenceFiles(appVersionCode); + clearBackupFile(); + } + + @VisibleForTesting + void writeFromBackupFileToPreferenceFiles(int appVersionCode) { + SharedPreferences backupSharedPreferences = + getSharedPreferences(RESERVED_SHARED_PREFERENCES, MODE_PRIVATE); + Map editors = new HashMap<>(); + for (Map.Entry entry : backupSharedPreferences.getAll().entrySet()) { + // We restore all files and keys, including those that this version doesn't know about or + // wouldn't have backed up. This ensures forward-compatibility. + String backupKey = entry.getKey(); + Object value = entry.getValue(); + int backupDelimiterIndex = backupKey.indexOf(BACKUP_DELIMITER); + if (backupDelimiterIndex < 1 || backupDelimiterIndex >= backupKey.length() - 1) { + Log.w(TAG, "Format of key \"" + backupKey + "\" not understood, so skipping its restore."); + continue; + } + String fileName = backupKey.substring(0, backupDelimiterIndex); + String preferenceKey = backupKey.substring(backupDelimiterIndex + 1); + Editor editor = editors.get(fileName); + if (editor == null) { + // #apply is called once for each editor later. + editor = getSharedPreferences(fileName, MODE_PRIVATE).edit(); + editors.put(fileName, editor); + } + putSharedPreference(editor, preferenceKey, value); + } + for (Editor editor : editors.values()) { + editor.apply(); + } + onPreferencesRestored(editors.keySet(), appVersionCode); + } + + /** + * This method is called when the preferences have been restored. It can be overridden to apply + * processing to the restored preferences. However, this is not recommended to be used in + * conjunction with restoreAnyVersion unless the following problems are considered: + * + *

1) Once the processing is live, it could be applied to any data that ever gets backed up by + * the app, not just the types of data that were available when the processing was originally + * added. + * + *

2) Older versions of the app (that use restoreAnyVersion) will restore data without applying + * the processing. For first-party apps pre-installed on the device, this could be the case for + * every new user. + * + * @param names The list of files restored. + * @param appVersionCode The app version code from {@link #onRestore}. + */ + @SuppressWarnings({"unused"}) + protected void onPreferencesRestored(Set names, int appVersionCode) {} +} diff --git a/src/com/google/android/libraries/backup/PreferenceBackupUtil.java b/src/com/google/android/libraries/backup/PreferenceBackupUtil.java new file mode 100644 index 0000000..6cc4012 --- /dev/null +++ b/src/com/google/android/libraries/backup/PreferenceBackupUtil.java @@ -0,0 +1,99 @@ +package com.google.android.libraries.backup; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +/** + * Utility class of static methods to help process shared preferences for backup and restore. + */ +public class PreferenceBackupUtil { + + @VisibleForTesting + @Nullable + static String getRingtoneTitleFromUri(Context context, @Nullable String uri) { + if (uri == null) { + return null; + } + + Ringtone sound = RingtoneManager.getRingtone(context, Uri.parse(uri)); + if (sound == null) { + return null; + } + return sound.getTitle(context); + } + + /** + * Get ringtone uri from a preference key in a shared preferences file, retrieve the associated + * ringtone's title and, if possible, save the title to the target preference key. + * + * @param srcRingtoneUriPrefKey preference key of the ringtone uri. + * @param dstRingtoneTitlePrefKey preference key where the ringtone title should be put. + * @return whether the ringtoneTitleKey was set. + */ + public static boolean encodeRingtonePreference(Context context, String prefsName, + String srcRingtoneUriPrefKey, String dstRingtoneTitlePrefKey) { + SharedPreferences preferences = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); + + String uri = preferences.getString(srcRingtoneUriPrefKey, null); + String title = getRingtoneTitleFromUri(context, uri); + if (title == null) { + return false; + } + + preferences.edit().putString(dstRingtoneTitlePrefKey, title).apply(); + return true; + } + + @VisibleForTesting + @Nullable + static String getRingtoneUriFromTitle(Context context, @Nullable String title, int ringtoneType) { + // Check whether the ringtoneType is a valid combination of the 3 ringtone types. + if ((ringtoneType == 0) + || ((RingtoneManager.TYPE_ALL & ringtoneType) != ringtoneType)) { + throw new IllegalStateException(); + } + if (title == null) { + return null; + } + + RingtoneManager manager = new RingtoneManager(context); + manager.setType(ringtoneType); + Cursor cur = manager.getCursor(); + for (int i = 0; i < cur.getCount(); i++) { + Ringtone ringtone = manager.getRingtone(i); + if (ringtone.getTitle(context).equals(title)) { + return manager.getRingtoneUri(i).toString(); + } + } + + return null; + } + + /** + * Get ringtone title from a preference key of a shared preferences file, find a ringtone with the + * same title and, if possible, save its uri to the target preference key. + * + * @param dstRingtoneUriPrefKey preference key where the ringtone uri should be put. + * @param srcRingtoneTitlePrefKey preference key of the ringtone title. + * @return whether the ringtoneUriKey was set. + */ + public static boolean decodeRingtonePreference(Context context, String prefsName, + String dstRingtoneUriPrefKey, String srcRingtoneTitlePrefKey, int ringtoneType) { + SharedPreferences preferences = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); + + String title = preferences.getString(srcRingtoneTitlePrefKey, null); + String uri = getRingtoneUriFromTitle(context, title, ringtoneType); + if (uri == null) { + return false; + } + + preferences.edit().putString(dstRingtoneUriPrefKey, uri).apply(); + return true; + } +} \ No newline at end of file diff --git a/src/com/google/android/libraries/backup/shadow/BackupAgentHelperShadow.java b/src/com/google/android/libraries/backup/shadow/BackupAgentHelperShadow.java new file mode 100644 index 0000000..18deabf --- /dev/null +++ b/src/com/google/android/libraries/backup/shadow/BackupAgentHelperShadow.java @@ -0,0 +1,181 @@ +package com.google.android.libraries.backup.shadow; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.app.backup.BackupAgent; +import android.app.backup.BackupAgentHelper; +import android.app.backup.BackupDataInput; +import android.app.backup.BackupDataOutput; +import android.app.backup.BackupHelper; +import android.app.backup.FileBackupHelper; +import android.app.backup.SharedPreferencesBackupHelper; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.ParcelFileDescriptor; +import android.util.Log; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.RealObject; +import org.robolectric.fakes.RoboSharedPreferences; + +/** + * Shadow class for end-to-end testing of {@link BackupAgentHelper} subclasses in unit tests. + * + *

This class currently supports key-value backups only. In other words, it does + * not support Dolly. In addition, the testing framework has the following two limitations + * with regards to backup/restore of {@link SharedPreferences}: + * + *

    + *
  1. Preferences are normally backed by xml files in the app's shared_prefs directory, but + * Robolectric replaces them with {@link RoboSharedPreferences}, which are backed by an in-memory + * {@link Map}. Therefore, modifying the relevant xml files will have no effect on the preferences + * (and vice versa). + *
  2. For the same reason, the testing framework cannot easily determine whether the underlying + * xml file for given shared preferences would have been empty or missing upon backup. The latter + * is assumed to ensure that apps don't rely on restore to implicitly clear data (potentially + * PII). + *
+ */ +@Implements(BackupAgentHelper.class) +public class BackupAgentHelperShadow { + private static final String TAG = "BackupAgentHelperShadow"; + + /** + * Temporarily stores the backup data generated in {@link #onBackup} so that it could be returned + * by {@link #simulateBackup}. + */ + private static final AtomicReference> backupDataMapToBackup = + new AtomicReference<>(); + + /** + * Temporarily stores the backed up data passed to {@link #simulateRestore} so that it could be + * used in {@link #onRestore}. + */ + private static final AtomicReference> backupDataMapToRestore = + new AtomicReference<>(); + + /** + * Simulates key-value backup for the provided agent all the way from {@link + * BackupAgentHelper#onCreate} to {@link BackupAgentHelper#onDestroy} (both inclusive). + */ + public static Map simulateBackup(BackupAgentHelper agent) { + Map backupDataMap; + attachBaseContextToAgentIfNecessary(agent); + agent.onCreate(); + try { + agent.onBackup(null, null, null); + backupDataMap = backupDataMapToBackup.getAndSet(null); + } catch (IOException e) { + backupDataMapToBackup.set(null); + throw new IllegalStateException(e); + } + agent.onDestroy(); + return backupDataMap; + } + + /** + * Simulates key-value restore for the provided agent all the way from {@link + * BackupAgentHelper#onCreate} to {@link BackupAgentHelper#onDestroy} (both inclusive). + * + *

Note: To make end-to-end tests more realistic, different {@link BackupAgentHelper} + * instances should be used in {@link #simulateBackup} and {@link #simulateRestore}. + */ + public static void simulateRestore( + BackupAgentHelper agent, Map backupDataMap, int appVersionCode) { + attachBaseContextToAgentIfNecessary(agent); + agent.onCreate(); + assertTrue(backupDataMapToRestore.compareAndSet(null, backupDataMap)); + try { + agent.onRestore(null, appVersionCode, null); + } catch (IOException e) { + throw new IllegalStateException(e); + } finally { + backupDataMapToRestore.set(null); + } + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + agent.onRestoreFinished(); + } + agent.onDestroy(); + } + + private static void attachBaseContextToAgentIfNecessary(BackupAgentHelper agent) { + if (agent.getBaseContext() != null) { + return; + } + try { + // {@link BackupAgent#attach} is a hidden method, so we need to call it via reflection. + Method method = BackupAgent.class.getMethod("attach", Context.class); + method.invoke(agent, RuntimeEnvironment.application); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private final Map helperSimulators; + + public BackupAgentHelperShadow() { + // Use a {@link TreeMap} to mirror the internal implementation of {@link BackupHelperDispatcher} + // as closely as possible. + helperSimulators = new TreeMap<>(); + } + + @RealObject private BackupAgentHelper realHelper; + + @Implementation + public void addHelper(String keyPrefix, BackupHelper helper) { + Class helperClass = helper.getClass(); + final BackupHelperSimulator simulator; + if (helperClass == SharedPreferencesBackupHelper.class) { + simulator = SharedPreferencesBackupHelperSimulator.fromHelper( + keyPrefix, (SharedPreferencesBackupHelper) helper); + } else if (helperClass == FileBackupHelper.class) { + simulator = FileBackupHelperSimulator.fromHelper(keyPrefix, (FileBackupHelper) helper); + } else { + throw new UnsupportedOperationException( + "Unknown backup helper class for key prefix \"" + keyPrefix + "\": " + helperClass); + } + helperSimulators.put(keyPrefix, simulator); + } + + @Implementation + public void onBackup( + ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) + throws IOException { + ImmutableMap.Builder backupDataMapBuilder = ImmutableMap.builder(); + for (Map.Entry simulatorEntry : helperSimulators.entrySet()) { + String keyPrefix = simulatorEntry.getKey(); + BackupHelperSimulator simulator = simulatorEntry.getValue(); + backupDataMapBuilder.put(keyPrefix, simulator.backup(realHelper)); + } + + assertTrue(backupDataMapToBackup.compareAndSet(null, backupDataMapBuilder.build())); + } + + @Implementation + public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) + throws IOException { + Map backupDataMap = backupDataMapToRestore.getAndSet(null); + assertNotNull(backupDataMap); + + for (Map.Entry simulatorEntry : helperSimulators.entrySet()) { + String keyPrefix = simulatorEntry.getKey(); + Object dataToRestore = backupDataMap.get(keyPrefix); + if (dataToRestore == null) { + Log.w(TAG, "No data to restore for key prefix: \"" + keyPrefix + "\"."); + continue; + } + BackupHelperSimulator simulator = simulatorEntry.getValue(); + simulator.restore(realHelper, dataToRestore); + } + } +} diff --git a/src/com/google/android/libraries/backup/shadow/BackupHelperSimulator.java b/src/com/google/android/libraries/backup/shadow/BackupHelperSimulator.java new file mode 100644 index 0000000..01236f6 --- /dev/null +++ b/src/com/google/android/libraries/backup/shadow/BackupHelperSimulator.java @@ -0,0 +1,28 @@ +package com.google.android.libraries.backup.shadow; + +import android.app.backup.BackupHelper; +import android.app.backup.FileBackupHelper; +import android.content.Context; +import com.google.common.base.Preconditions; + +/** + * Class which simulates backup & restore functionality of a {@link BackupHelper}. + */ +public abstract class BackupHelperSimulator { + + /** Prefix key of the corresponding {@link FileBackupHelper}. */ + protected final String keyPrefix; + + public BackupHelperSimulator(String keyPrefix) { + this.keyPrefix = Preconditions.checkNotNull(keyPrefix); + } + + /** Perform backup into an {@link Object}, which is then returned by the method. */ + public abstract Object backup(Context context); + + /** + * Perform restore from the provided {@link Object}, which must have the same type as the one + * returned by {@link #backup}. + */ + public abstract void restore(Context context, Object data); +} diff --git a/src/com/google/android/libraries/backup/shadow/FileBackupHelperSimulator.java b/src/com/google/android/libraries/backup/shadow/FileBackupHelperSimulator.java new file mode 100644 index 0000000..77ed6a3 --- /dev/null +++ b/src/com/google/android/libraries/backup/shadow/FileBackupHelperSimulator.java @@ -0,0 +1,157 @@ +package com.google.android.libraries.backup.shadow; + +import android.app.backup.FileBackupHelper; +import android.content.Context; +import android.util.Log; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.io.Files; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +/** + * Representation of {@link FileBackupHelper} configuration used for testing. This class simulates + * backing up and restoring files by storing their contents in memory. + * + *

{@see BackupAgentHelperShadow} + */ +public class FileBackupHelperSimulator extends BackupHelperSimulator { + private static final String TAG = "FileBackupHelperSimulat"; + + /** Filenames which should be backed up/restored. */ + private final Set fileNames; + + private FileBackupHelperSimulator(String keyPrefix, Set fileNames) { + super(keyPrefix); + this.fileNames = Preconditions.checkNotNull(fileNames); + } + + public static FileBackupHelperSimulator fromFileNames(String keyPrefix, Set fileNames) { + return new FileBackupHelperSimulator(keyPrefix, fileNames); + } + + public static FileBackupHelperSimulator fromHelper(String keyPrefix, FileBackupHelper helper) { + return new FileBackupHelperSimulator(keyPrefix, extractFileNamesFromHelper(helper)); + } + + @VisibleForTesting + static Set extractFileNamesFromHelper(FileBackupHelper helper) { + try { + Field filesField = FileBackupHelper.class.getDeclaredField("mFiles"); + filesField.setAccessible(true); + return ImmutableSet.copyOf((String[]) filesField.get(helper)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + /** Collection of backed up files. */ + public static class FilesBackupData { + /** Map from file names to their backed up contents. */ + private final Map files; + + public FilesBackupData(Map files) { + this.files = Preconditions.checkNotNull(files); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof FilesBackupData && files.equals(((FilesBackupData) obj).files); + } + + @Override + public int hashCode() { + return files.hashCode(); + } + + public Map getFiles() { + return files; + } + } + + /** Single backed up file. */ + public static class FileBackupContents { + private final byte[] bytes; + + public FileBackupContents(byte[] bytes) { + this.bytes = Preconditions.checkNotNull(bytes); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof FileBackupContents + && Arrays.equals(bytes, ((FileBackupContents) obj).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + public static FileBackupContents readFromFile(File file) { + return new FileBackupContents(readBytesFromFile(file)); + } + + public void writeToFile(File file) { + writeBytesToFile(file, bytes); + } + + public static byte[] readBytesFromFile(File file) { + try { + return Files.toByteArray(file); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + public static void writeBytesToFile(File file, byte[] bytes) { + try { + Files.write(bytes, file); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + + @Override + public Object backup(Context context) { + File base = context.getFilesDir(); + ImmutableMap.Builder dataToBackupBuilder = ImmutableMap.builder(); + for (String fileName : fileNames) { + File file = new File(base, fileName); + if (!file.exists()) { + Log.w(TAG, "File \"" + fileName + "\" not found by helper \"" + keyPrefix + "\"."); + continue; + } + dataToBackupBuilder.put(fileName, FileBackupContents.readFromFile(file)); + } + return new FilesBackupData(dataToBackupBuilder.build()); + } + + @Override + public void restore(Context context, Object data) { + if (!(data instanceof FilesBackupData)) { + throw new IllegalArgumentException("Invalid type of files to restore in helper \"" + + keyPrefix + "\": " + data.getClass()); + } + + File base = context.getFilesDir(); + Map dataToRestore = ((FilesBackupData) data).getFiles(); + for (Map.Entry restoreEntry : dataToRestore.entrySet()) { + String fileName = restoreEntry.getKey(); + if (!fileNames.contains(fileName)) { + Log.w(TAG, "File \"" + fileName + "\" ignored by helper \"" + keyPrefix + "\"."); + continue; + } + FileBackupContents contents = restoreEntry.getValue(); + File file = new File(base, fileName); + contents.writeToFile(file); + } + } +} diff --git a/src/com/google/android/libraries/backup/shadow/SharedPreferencesBackupHelperSimulator.java b/src/com/google/android/libraries/backup/shadow/SharedPreferencesBackupHelperSimulator.java new file mode 100644 index 0000000..407f3f0 --- /dev/null +++ b/src/com/google/android/libraries/backup/shadow/SharedPreferencesBackupHelperSimulator.java @@ -0,0 +1,147 @@ +package com.google.android.libraries.backup.shadow; + +import static android.content.Context.MODE_PRIVATE; + +import android.app.backup.SharedPreferencesBackupHelper; +import android.content.Context; +import android.content.SharedPreferences.Editor; +import android.util.Log; +import com.google.android.libraries.backup.PersistentBackupAgentHelper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.Set; + +/** + * Representation of {@link SharedPreferencesBackupHelper} configuration used for testing. This + * class simulates backing up and restoring shared preferences by storing them in memory. + * + *

{@see BackupAgentHelperShadow} + */ +public class SharedPreferencesBackupHelperSimulator extends BackupHelperSimulator { + private static final String TAG = "SharedPreferencesBackup"; + + /** Shared preferences file names which should be backed up/restored. */ + private final Set prefGroups; + + private SharedPreferencesBackupHelperSimulator(String keyPrefix, Set prefGroups) { + super(keyPrefix); + this.prefGroups = Preconditions.checkNotNull(prefGroups); + } + + public static SharedPreferencesBackupHelperSimulator fromPreferenceGroups( + String keyPrefix, Set prefGroups) { + return new SharedPreferencesBackupHelperSimulator(keyPrefix, prefGroups); + } + + public static SharedPreferencesBackupHelperSimulator fromHelper( + String keyPrefix, SharedPreferencesBackupHelper helper) { + return new SharedPreferencesBackupHelperSimulator( + keyPrefix, extractPreferenceGroupsFromHelper(helper)); + } + + @VisibleForTesting + static Set extractPreferenceGroupsFromHelper(SharedPreferencesBackupHelper helper) { + try { + Field prefGroupsField = SharedPreferencesBackupHelper.class.getDeclaredField("mPrefGroups"); + prefGroupsField.setAccessible(true); + return ImmutableSet.copyOf((String[]) prefGroupsField.get(helper)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to construct SharedPreferencesBackupHelperSimulator", e); + } + } + + /** Collection of backed up shared preferences. */ + public static class SharedPreferencesBackupData { + /** Map from shared preferences file names to key-value preference maps. */ + private final Map> preferences; + + public SharedPreferencesBackupData(Map> data) { + this.preferences = Preconditions.checkNotNull(data); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof SharedPreferencesBackupData + && preferences.equals(((SharedPreferencesBackupData) obj).preferences); + } + + @Override + public int hashCode() { + return preferences.hashCode(); + } + + public Map> getPreferences() { + return preferences; + } + } + + @Override + public Object backup(Context context) { + ImmutableMap.Builder> dataToBackupBuilder = ImmutableMap.builder(); + for (String prefGroup : prefGroups) { + Map prefs = context.getSharedPreferences(prefGroup, MODE_PRIVATE).getAll(); + if (prefs.isEmpty()) { + Log.w(TAG, "Shared prefs \"" + prefGroup + "\" are empty. The helper \"" + keyPrefix + + "\" assumes this is due to a missing (rather than empty) shared preferences file."); + continue; + } + ImmutableMap.Builder prefsData = ImmutableMap.builder(); + for (Map.Entry prefEntry : prefs.entrySet()) { + String key = prefEntry.getKey(); + Object value = prefEntry.getValue(); + if (value instanceof Set) { + value = ImmutableSet.copyOf((Set) value); + } + prefsData.put(key, value); + } + dataToBackupBuilder.put(prefGroup, prefsData.build()); + } + return new SharedPreferencesBackupData(dataToBackupBuilder.build()); + } + + @Override + public void restore(Context context, Object data) { + if (!(data instanceof SharedPreferencesBackupData)) { + throw new IllegalArgumentException("Invalid type of files to restore in helper \"" + + keyPrefix + "\": " + data.getClass()); + } + + Map> prefsToRestore = + ((SharedPreferencesBackupData) data).getPreferences(); + + // Display a warning when missing/empty preferences are restored onto non-empty preferences. + for (String prefGroup : prefGroups) { + if (context.getSharedPreferences(prefGroup, MODE_PRIVATE).getAll().isEmpty()) { + continue; + } + Map prefsData = prefsToRestore.get(prefGroup); + if (prefsData == null) { + Log.w(TAG, "Non-empty shared prefs \"" + prefGroup + "\" will NOT be cleared by helper \"" + + keyPrefix + "\" because the corresponding file is missing in the restored data."); + } else if (prefsData.isEmpty()) { + Log.w(TAG, "Non-empty shared prefs \"" + prefGroup + "\" will be cleared by helper \"" + + keyPrefix + "\" because the corresponding file is empty in the restored data."); + } + } + + for (Map.Entry> restoreEntry : prefsToRestore.entrySet()) { + String prefGroup = restoreEntry.getKey(); + if (!prefGroups.contains(prefGroup)) { + Log.w(TAG, "Shared prefs \"" + prefGroup + "\" ignored by helper \"" + keyPrefix + "\"."); + continue; + } + Map prefsData = restoreEntry.getValue(); + Editor editor = context.getSharedPreferences(prefGroup, MODE_PRIVATE).edit().clear(); + for (Map.Entry prefEntry : prefsData.entrySet()) { + PersistentBackupAgentHelper.putSharedPreference( + editor, prefEntry.getKey(), prefEntry.getValue()); + } + editor.apply(); + } + } +} -- cgit v1.2.3