/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.sync; import java.net.URI; import java.net.URISyntaxException; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import org.mozilla.gecko.background.common.log.Logger; import org.mozilla.gecko.sync.crypto.KeyBundle; import org.mozilla.gecko.sync.crypto.PersistedCrypto5Keys; import org.mozilla.gecko.sync.net.AuthHeaderProvider; import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; public class SyncConfiguration { public class EditorBranch implements Editor { private final String prefix; private Editor editor; public EditorBranch(SyncConfiguration config, String prefix) { if (!prefix.endsWith(".")) { throw new IllegalArgumentException("No trailing period in prefix."); } this.prefix = prefix; this.editor = config.getEditor(); } public void apply() { // Android <=r8 SharedPreferences.Editor does not contain apply() for overriding. this.editor.commit(); } @Override public Editor clear() { this.editor = this.editor.clear(); return this; } @Override public boolean commit() { return this.editor.commit(); } @Override public Editor putBoolean(String key, boolean value) { this.editor = this.editor.putBoolean(prefix + key, value); return this; } @Override public Editor putFloat(String key, float value) { this.editor = this.editor.putFloat(prefix + key, value); return this; } @Override public Editor putInt(String key, int value) { this.editor = this.editor.putInt(prefix + key, value); return this; } @Override public Editor putLong(String key, long value) { this.editor = this.editor.putLong(prefix + key, value); return this; } @Override public Editor putString(String key, String value) { this.editor = this.editor.putString(prefix + key, value); return this; } // Not marking as Override, because Android <= 10 doesn't have // putStringSet. Neither can we implement it. public Editor putStringSet(String key, Set value) { throw new RuntimeException("putStringSet not available."); } @Override public Editor remove(String key) { this.editor = this.editor.remove(prefix + key); return this; } } /** * A wrapper around a portion of the SharedPreferences space. * * @author rnewman * */ public class ConfigurationBranch implements SharedPreferences { private final SyncConfiguration config; private final String prefix; // Including trailing period. public ConfigurationBranch(SyncConfiguration syncConfiguration, String prefix) { if (!prefix.endsWith(".")) { throw new IllegalArgumentException("No trailing period in prefix."); } this.config = syncConfiguration; this.prefix = prefix; } @Override public boolean contains(String key) { return config.getPrefs().contains(prefix + key); } @Override public Editor edit() { return new EditorBranch(config, prefix); } @Override public Map getAll() { // Not implemented. TODO return null; } @Override public boolean getBoolean(String key, boolean defValue) { return config.getPrefs().getBoolean(prefix + key, defValue); } @Override public float getFloat(String key, float defValue) { return config.getPrefs().getFloat(prefix + key, defValue); } @Override public int getInt(String key, int defValue) { return config.getPrefs().getInt(prefix + key, defValue); } @Override public long getLong(String key, long defValue) { return config.getPrefs().getLong(prefix + key, defValue); } @Override public String getString(String key, String defValue) { return config.getPrefs().getString(prefix + key, defValue); } // Not marking as Override, because Android <= 10 doesn't have // getStringSet. Neither can we implement it. public Set getStringSet(String key, Set defValue) { throw new RuntimeException("getStringSet not available."); } @Override public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { config.getPrefs().registerOnSharedPreferenceChangeListener(listener); } @Override public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { config.getPrefs().unregisterOnSharedPreferenceChangeListener(listener); } } private static final String LOG_TAG = "SyncConfiguration"; // These must be set in GlobalSession's constructor. public URI clusterURL; public KeyBundle syncKeyBundle; public CollectionKeys collectionKeys; public InfoCollections infoCollections; public MetaGlobal metaGlobal; public String syncID; protected final String username; /** * Persisted collection of enabledEngineNames. *

* Can contain engines Android Sync is not currently aware of, such as "prefs" * or "addons". *

* Copied from latest downloaded meta/global record and used to generate a * fresh meta/global record for upload. */ public Set enabledEngineNames; public Set declinedEngineNames = new HashSet(); /** * Names of stages to sync this sync, or null to sync * all known stages. *

* Generated each sync from extras bundle passed to * SyncAdapter.onPerformSync and not persisted. *

* Not synchronized! Set this exactly once per global session and don't modify * it -- especially not from multiple threads. */ public Collection stagesToSync; /** * Engines whose sync state has been modified by the user through * SelectEnginesActivity, where each key-value pair is an engine name and * its sync state. * * This differs from enabledEngineNames in that * enabledEngineNames reflects the downloaded meta/global, * whereas userSelectedEngines stores the differences in engines to * sync that the user has selected. * * Each engine stage will check for engine changes at the beginning of the * stage. * * If no engine sync state changes have been made by the user, userSelectedEngines * will be null, and Sync will proceed normally. * * If the user has made changes to engine syncing state, each engine will sync * according to the sync state specified in userSelectedEngines and propagate that * state to meta/global, to be uploaded. */ public Map userSelectedEngines; public long userSelectedEnginesTimestamp; public SharedPreferences prefs; protected final AuthHeaderProvider authHeaderProvider; public static final String PREF_PREFS_VERSION = "prefs.version"; public static final long CURRENT_PREFS_VERSION = 1; public static final String CLIENTS_COLLECTION_TIMESTAMP = "serverClientsTimestamp"; // When the collection was touched. public static final String CLIENT_RECORD_TIMESTAMP = "serverClientRecordTimestamp"; // When our record was touched. public static final String PREF_CLUSTER_URL = "clusterURL"; public static final String PREF_SYNC_ID = "syncID"; public static final String PREF_ENABLED_ENGINE_NAMES = "enabledEngineNames"; public static final String PREF_DECLINED_ENGINE_NAMES = "declinedEngineNames"; public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC = "userSelectedEngines"; public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP = "userSelectedEnginesTimestamp"; public static final String PREF_CLUSTER_URL_IS_STALE = "clusterurlisstale"; public static final String PREF_ACCOUNT_GUID = "account.guid"; public static final String PREF_CLIENT_NAME = "account.clientName"; public static final String PREF_NUM_CLIENTS = "account.numClients"; public static final String PREF_CLIENT_DATA_TIMESTAMP = "account.clientDataTimestamp"; private static final String API_VERSION = "1.5"; public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs) { this.username = username; this.authHeaderProvider = authHeaderProvider; this.prefs = prefs; this.loadFromPrefs(prefs); } public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs, KeyBundle syncKeyBundle) { this(username, authHeaderProvider, prefs); this.syncKeyBundle = syncKeyBundle; } public String getAPIVersion() { return API_VERSION; } public SharedPreferences getPrefs() { return this.prefs; } /** * Valid engines supported by Android Sync. * * @return Set of valid engine names that Android Sync implements. */ public static Set validEngineNames() { Set engineNames = new HashSet(); for (Stage stage : Stage.getNamedStages()) { engineNames.add(stage.getRepositoryName()); } return engineNames; } /** * Return a convenient accessor for part of prefs. * @return * A ConfigurationBranch object representing this * section of the preferences space. */ public ConfigurationBranch getBranch(String prefix) { return new ConfigurationBranch(this, prefix); } /** * Gets the engine names that are enabled, declined, or other (depending on pref) in meta/global. * * @param prefs * SharedPreferences that the engines are associated with. * @param pref * The preference name to use. E.g, PREF_ENABLED_ENGINE_NAMES. * @return Set of the enabled engine names if they have been stored, * or null otherwise. */ protected static Set getEngineNamesFromPref(SharedPreferences prefs, String pref) { final String json = prefs.getString(pref, null); if (json == null) { return null; } try { final ExtendedJSONObject o = ExtendedJSONObject.parseJSONObject(json); return new HashSet(o.keySet()); } catch (Exception e) { return null; } } /** * Returns the set of engine names that the user has enabled. If none * have been stored in prefs, null is returned. */ public static Set getEnabledEngineNames(SharedPreferences prefs) { return getEngineNamesFromPref(prefs, PREF_ENABLED_ENGINE_NAMES); } /** * Returns the set of engine names that the user has declined. */ public static Set getDeclinedEngineNames(SharedPreferences prefs) { final Set names = getEngineNamesFromPref(prefs, PREF_DECLINED_ENGINE_NAMES); if (names == null) { return new HashSet(); } return names; } /** * Gets the engines whose sync states have been changed by the user through the * SelectEnginesActivity. * * @param prefs * SharedPreferences of account that the engines are associated with. * @return Map of changed engines. Key is the lower-cased * engine name, Value is the new sync state. */ public static Map getUserSelectedEngines(SharedPreferences prefs) { String json = prefs.getString(PREF_USER_SELECTED_ENGINES_TO_SYNC, null); if (json == null) { return null; } try { ExtendedJSONObject o = ExtendedJSONObject.parseJSONObject(json); Map map = new HashMap(); for (Entry e : o.entrySet()) { String key = e.getKey(); Boolean value = (Boolean) e.getValue(); map.put(key, value); // Forms depends on history. Add forms if history is selected. if ("history".equals(key)) { map.put("forms", value); } } // Sanity check: remove forms if history does not exist. if (!map.containsKey("history")) { map.remove("forms"); } return map; } catch (Exception e) { return null; } } /** * Store a Map of engines and their sync states to prefs. * * Any engine that's disabled in the input is also recorded * as a declined engine, overwriting the stored values. * * @param prefs * SharedPreferences that the engines are associated with. * @param selectedEngines * Map of engine name to sync state */ public static void storeSelectedEnginesToPrefs(SharedPreferences prefs, Map selectedEngines) { ExtendedJSONObject jObj = new ExtendedJSONObject(); HashSet declined = new HashSet(); for (Entry e : selectedEngines.entrySet()) { final Boolean enabled = e.getValue(); final String engine = e.getKey(); jObj.put(engine, enabled); if (!enabled) { declined.add(engine); } } // Our history checkbox drives form history, too. // We don't need to do this for enablement: that's done at retrieval time. if (selectedEngines.containsKey("history") && !selectedEngines.get("history")) { declined.add("forms"); } String json = jObj.toJSONString(); long currentTime = System.currentTimeMillis(); Editor edit = prefs.edit(); edit.putString(PREF_USER_SELECTED_ENGINES_TO_SYNC, json); edit.putString(PREF_DECLINED_ENGINE_NAMES, setToJSONObjectString(declined)); edit.putLong(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP, currentTime); Logger.error(LOG_TAG, "Storing user-selected engines at [" + currentTime + "]."); edit.commit(); } public void loadFromPrefs(SharedPreferences prefs) { if (prefs.contains(PREF_CLUSTER_URL)) { String u = prefs.getString(PREF_CLUSTER_URL, null); try { clusterURL = new URI(u); Logger.trace(LOG_TAG, "Set clusterURL from bundle: " + u); } catch (URISyntaxException e) { Logger.warn(LOG_TAG, "Ignoring bundle clusterURL (" + u + "): invalid URI.", e); } } if (prefs.contains(PREF_SYNC_ID)) { syncID = prefs.getString(PREF_SYNC_ID, null); Logger.trace(LOG_TAG, "Set syncID from bundle: " + syncID); } enabledEngineNames = getEnabledEngineNames(prefs); declinedEngineNames = getDeclinedEngineNames(prefs); userSelectedEngines = getUserSelectedEngines(prefs); userSelectedEnginesTimestamp = prefs.getLong(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP, 0); // We don't set crypto/keys here because we need the syncKeyBundle to decrypt the JSON // and we won't have it on construction. // TODO: MetaGlobal, password, infoCollections. } public void persistToPrefs() { this.persistToPrefs(this.getPrefs()); } private static String setToJSONObjectString(Set set) { ExtendedJSONObject o = new ExtendedJSONObject(); for (String name : set) { o.put(name, 0); } return o.toJSONString(); } public void persistToPrefs(SharedPreferences prefs) { Editor edit = prefs.edit(); if (clusterURL == null) { edit.remove(PREF_CLUSTER_URL); } else { edit.putString(PREF_CLUSTER_URL, clusterURL.toASCIIString()); } if (syncID != null) { edit.putString(PREF_SYNC_ID, syncID); } if (enabledEngineNames == null) { edit.remove(PREF_ENABLED_ENGINE_NAMES); } else { edit.putString(PREF_ENABLED_ENGINE_NAMES, setToJSONObjectString(enabledEngineNames)); } if (declinedEngineNames == null || declinedEngineNames.isEmpty()) { edit.remove(PREF_DECLINED_ENGINE_NAMES); } else { edit.putString(PREF_DECLINED_ENGINE_NAMES, setToJSONObjectString(declinedEngineNames)); } if (userSelectedEngines == null) { edit.remove(PREF_USER_SELECTED_ENGINES_TO_SYNC); edit.remove(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP); } // Don't bother saving userSelectedEngines - these should only be changed by // SelectEnginesActivity. edit.commit(); // TODO: keys. } public AuthHeaderProvider getAuthHeaderProvider() { return authHeaderProvider; } public CollectionKeys getCollectionKeys() { return collectionKeys; } public void setCollectionKeys(CollectionKeys k) { collectionKeys = k; } /** * Return path to storage endpoint without trailing slash. * * @return storage endpoint without trailing slash. */ public String storageURL() { return clusterURL + "/storage"; } protected String infoBaseURL() { return clusterURL + "/info/"; } public String infoCollectionsURL() { return infoBaseURL() + "collections"; } public String infoCollectionCountsURL() { return infoBaseURL() + "collection_counts"; } public String metaURL() { return storageURL() + "/meta/global"; } public URI collectionURI(String collection) throws URISyntaxException { return new URI(storageURL() + "/" + collection); } public URI collectionURI(String collection, boolean full) throws URISyntaxException { // Do it this way to make it easier to add more params later. // It's pretty ugly, I'll grant. boolean anyParams = full; String uriParams = ""; if (anyParams) { StringBuilder params = new StringBuilder("?"); if (full) { params.append("full=1"); } uriParams = params.toString(); } String uri = storageURL() + "/" + collection + uriParams; return new URI(uri); } public URI wboURI(String collection, String id) throws URISyntaxException { return new URI(storageURL() + "/" + collection + "/" + id); } public URI keysURI() throws URISyntaxException { return wboURI("crypto", "keys"); } public URI getClusterURL() { return clusterURL; } public String getClusterURLString() { if (clusterURL == null) { return null; } return clusterURL.toASCIIString(); } public void setClusterURL(URI u) { this.clusterURL = u; } /** * Used for direct management of related prefs. */ public Editor getEditor() { return this.getPrefs().edit(); } /** * We persist two different clients timestamps: our own record's, * and the timestamp for the collection. */ public void persistServerClientRecordTimestamp(long timestamp) { getEditor().putLong(SyncConfiguration.CLIENT_RECORD_TIMESTAMP, timestamp).commit(); } public long getPersistedServerClientRecordTimestamp() { return getPrefs().getLong(SyncConfiguration.CLIENT_RECORD_TIMESTAMP, 0); } public void persistServerClientsTimestamp(long timestamp) { getEditor().putLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, timestamp).commit(); } public long getPersistedServerClientsTimestamp() { return getPrefs().getLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, 0); } public void purgeCryptoKeys() { if (collectionKeys != null) { collectionKeys.clear(); } persistedCryptoKeys().purge(); } public void purgeMetaGlobal() { metaGlobal = null; persistedMetaGlobal().purge(); } public PersistedCrypto5Keys persistedCryptoKeys() { return new PersistedCrypto5Keys(getPrefs(), syncKeyBundle); } public PersistedMetaGlobal persistedMetaGlobal() { return new PersistedMetaGlobal(getPrefs()); } }