/* * Copyright 2017 Google Inc. * * 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.google.firebase.internal; import static com.google.common.base.Preconditions.checkState; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Arrays.asList; import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.io.BaseEncoding; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.ImplFirebaseTrampolines; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.prefs.BackingStoreException; import java.util.prefs.Preferences; /** Responsible for the persistence of FirebaseApps. */ // TODO: reenable persistence. See b/28158809. // TODO: Make this an independent implementation using Preferences // once the Shared Preferences version is stable and re-enabled. public class SharedPrefsFirebaseAppStore extends FirebaseAppStore { // Increment this number if you make a backwards incompatible change to the storage format. // As currently implemented an increase of the data format version results in an ISE getting // thrown. // TODO: Change this behavior when this value is changed. private static final int DATA_FORMAT_VERSION = 1; private static final String KEY_DATA_FORMAT_VERSION_VERSION = "version"; private static final String KEY_FIREBASE_APP_NAMES = "firebase-app-names"; private static final String KEY_PREFIX_API_KEY = "apiKey-"; private static final String KEY_PREFIX_APP_ID = "appId-"; private static final String KEY_PREFIX_DATABASE_URL = "dbUrl-"; private static final String KEY_PREFIX_GA_TRACKING_ID = "gaTrackingId-"; private static final String KEY_PREFIX_GCM_SENDER_ID = "gcmSenderId-"; private static final String KEY_PREFIX_STORAGE_BUCKET = "storageBucket-"; private static final String VALUE_SEPARATOR = ","; /** * Used to make multiple SharedPreferences reads/writes an atomic operation. Not necessary around * single reads. */ private final Object lock = new Object(); private Preferences preferences; SharedPrefsFirebaseAppStore() {} private static String encodeValue(String value) { if (value == null) { value = ""; } return BaseEncoding.base64Url().omitPadding().encode(value.getBytes(UTF_8)); } /** * @throws IllegalArgumentException if value is not valid websafe, no padding base64. */ private static String decodeValue(String encodedValue) { String decodedValue = new String(BaseEncoding.base64Url().omitPadding().decode(encodedValue), UTF_8); if (Strings.isNullOrEmpty(decodedValue)) { // FirebaseOptions values are null by default. Restoring values to empty string would // break equality check between original and restored instance. return null; } return decodedValue; } private static void writeValue(Preferences prefs, String key, String value) { if (value != null) { prefs.put(key, encodeValue(value)); } } private static String readValue(Preferences prefs, String key) { String encodedValue = prefs.get(key, null); if (encodedValue != null) { return decodeValue(encodedValue); } return null; } /** The returned set is mutable. */ @Override public Set<String> getAllPersistedAppNames() { ensurePrefsInitialized(); List<String> encodedAppNames = getEncodedAppNames(); Set<String> persistedAppNames = new HashSet<>(); for (String encodedAppName : encodedAppNames) { persistedAppNames.add(decodeValue(encodedAppName)); } return persistedAppNames; } @Override public void persistApp(@NonNull FirebaseApp app) { synchronized (lock) { Preferences prefs = ensurePrefsInitialized(); String encodedAppName = encodeValue(app.getName()); String encodedAppNamesValue = prefs.get(KEY_FIREBASE_APP_NAMES, ""); List<String> encodedAppNames = asList(encodedAppNamesValue.split(VALUE_SEPARATOR)); if (!ImplFirebaseTrampolines.isDefaultApp(app) && encodedAppNames.contains(encodedAppName)) { checkPersistedAppCompatible(app); return; } FirebaseOptions options = app.getOptions(); prefs.put(KEY_FIREBASE_APP_NAMES, encodedAppNamesValue + VALUE_SEPARATOR + encodedAppName); // TODO: Make sure this has all of the options -- not just the DB URL. writeValue(prefs, KEY_PREFIX_DATABASE_URL + encodedAppName, options.getDatabaseUrl()); } } @Override public void removeApp(@NonNull String name) { synchronized (lock) { Preferences prefs = ensurePrefsInitialized(); String encodedAppName = encodeValue(name); String encodedAppNamesValue = prefs.get(KEY_FIREBASE_APP_NAMES, ""); List<String> encodedAppNames = asList(encodedAppNamesValue.split(VALUE_SEPARATOR)); List<String> updatedEncodedAppNames = new ArrayList<>(encodedAppNames); updatedEncodedAppNames.remove(encodedAppName); prefs.put(KEY_FIREBASE_APP_NAMES, Joiner.on(VALUE_SEPARATOR).join(updatedEncodedAppNames)); prefs.remove(KEY_PREFIX_API_KEY + encodedAppName); prefs.remove(KEY_PREFIX_APP_ID + encodedAppName); prefs.remove(KEY_PREFIX_DATABASE_URL + encodedAppName); prefs.remove(KEY_PREFIX_GA_TRACKING_ID + encodedAppName); prefs.remove(KEY_PREFIX_GCM_SENDER_ID + encodedAppName); prefs.remove(KEY_PREFIX_STORAGE_BUCKET + encodedAppName); } } /** * @return The restored {@link FirebaseOptions}, or null if it doesn't exist. */ @Override public FirebaseOptions restoreAppOptions(@NonNull String name) { synchronized (lock) { Preferences prefs = ensurePrefsInitialized(); String encodedName = encodeValue(name); String applicationId = prefs.get(KEY_PREFIX_APP_ID + encodedName, null); if (applicationId == null) { return null; } return new FirebaseOptions.Builder() .setDatabaseUrl(readValue(prefs, KEY_PREFIX_DATABASE_URL + encodedName)) .build(); // TODO: Ensure all of the options are included, not just DB URL. } } private void checkPersistedAppCompatible(FirebaseApp app) { String name = app.getName(); FirebaseOptions options = restoreAppOptions(name); // This check is probably too restrictive. However it is easier to move from a more // restrictive check to a more lenient one than doing the reverse. // TODO: can we be less restrictive here? checkState( options.equals(app.getOptions()), "FirebaseApp %s incompatible with persisted version! Persisted options: %s, " + "Newly initialized app options: %s", app.getName(), options, app.getOptions()); } private Preferences ensurePrefsInitialized() { synchronized (lock) { if (preferences == null) { preferences = Preferences.userNodeForPackage(FirebaseApp.class); int readDataVersion = preferences.getInt(KEY_DATA_FORMAT_VERSION_VERSION, -1); if (readDataVersion == -1) { resetStore(); } else if (readDataVersion != DATA_FORMAT_VERSION) { // Data in Preferences is an older format. // TODO: come up with something better before an SDK with an // incremented version is released. throw new IllegalStateException( String.format( "Unexpected data format version. Was %d, but expected %d.", readDataVersion, DATA_FORMAT_VERSION)); } } } return preferences; } private List<String> getEncodedAppNames() { String encodedAppNamesValue = preferences.get(KEY_FIREBASE_APP_NAMES, ""); List<String> encodedAppNames = new ArrayList<>(); for (String encodedAppName : encodedAppNamesValue.split(VALUE_SEPARATOR)) { // Filter empty values. Split returns a non-empty array for an empty string. if (encodedAppName != null && !encodedAppName.equals("")) { encodedAppNames.add(encodedAppName); } } return encodedAppNames; } /** * Clears all data in {@link Preferences} */ @Override protected void resetStore() { try { preferences.clear(); } catch (BackingStoreException e) { throw new IllegalStateException("Could not clear Preferences", e); } preferences.putInt(KEY_DATA_FORMAT_VERSION_VERSION, DATA_FORMAT_VERSION); } }