diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /mobile/android/services/src | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/services/src')
350 files changed, 38643 insertions, 0 deletions
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java new file mode 100644 index 000000000..df603a58e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java @@ -0,0 +1,23 @@ +/* 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.background; + +import org.mozilla.gecko.AppConstants; + +/** + * This is in 'background' not 'reading' so that it's still usable even when the + * Reading List feature is build-time disabled. + */ +public class ReadingListConstants { + public static final String GLOBAL_LOG_TAG = "FxReadingList"; + public static final String USER_AGENT = "Firefox-Android-FxReader/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_UA_NAME + ")"; + public static final String DEFAULT_DEV_ENDPOINT = "https://readinglist.dev.mozaws.net/v1/"; + public static final String DEFAULT_PROD_ENDPOINT = "https://readinglist.services.mozilla.com/v1/"; + + public static final String OAUTH_SCOPE_READINGLIST = "readinglist"; + public static final String AUTH_TOKEN_TYPE = "oauth::" + OAUTH_SCOPE_READINGLIST; + + public static boolean DEBUG = false; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java new file mode 100644 index 000000000..1ead09afa --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java @@ -0,0 +1,82 @@ +/* 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.background.common; + +import java.util.Set; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +public class EditorBranch implements Editor { + + private final String prefix; + private Editor editor; + + public EditorBranch(final SharedPreferences prefs, final String prefix) { + if (!prefix.endsWith(".")) { + throw new IllegalArgumentException("No trailing period in prefix."); + } + this.prefix = prefix; + this.editor = prefs.edit(); + } + + @Override + public void apply() { + this.editor.apply(); + } + + @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<String> value) { + throw new RuntimeException("putStringSet not available."); + } + + @Override + public Editor remove(String key) { + this.editor = this.editor.remove(prefix + key); + return this; + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java new file mode 100644 index 000000000..d661e62dc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java @@ -0,0 +1,90 @@ +/* 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.background.common; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.AppConstants.Versions; + +/** + * Constant values common to all Android services. + */ +public class GlobalConstants { + public static final String BROWSER_INTENT_PACKAGE = AppConstants.ANDROID_PACKAGE_NAME; + public static final String BROWSER_INTENT_CLASS = AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS; + + public static final int SHARED_PREFERENCES_MODE = 0; + + // Common time values. + public static final long MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + public static final long MILLISECONDS_PER_SIX_MONTHS = 180 * MILLISECONDS_PER_DAY; + + // Acceptable cipher suites. + /** + * We support only a very limited range of strong cipher suites and protocols: + * no SSLv3 or TLSv1.0 (if we can), no DHE ciphers that might be vulnerable to Logjam + * (https://weakdh.org/), no RC4. + * + * Backstory: Bug 717691 (we no longer support Android 2.2, so the name + * workaround is unnecessary), Bug 1081953, Bug 1061273, Bug 1166839. + * + * See <http://developer.android.com/reference/javax/net/ssl/SSLSocket.html> for + * supported Android versions for each set of protocols and cipher suites. + * + * Note that currently we need to support connections to Sync 1.1 on Mozilla-hosted infra, + * as well as connections to FxA and Sync 1.5 on AWS. + * + * ELB cipher suites: + * <http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-security-policy-table.html> + */ + public static final String[] DEFAULT_CIPHER_SUITES; + public static final String[] DEFAULT_PROTOCOLS; + + static { + // Prioritize 128 over 256 as a tradeoff between device CPU/battery and the minor + // increase in strength. + if (Versions.feature20Plus) { + DEFAULT_CIPHER_SUITES = new String[] + { + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", // 20+ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", // 20+ + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", // 20+ + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", // 11+ + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", // 20+ + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", // 20+ + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", // 11+ + + // For Sync 1.1. + "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", // 9+ + "TLS_RSA_WITH_AES_128_CBC_SHA", // 9+ + }; + } else { + DEFAULT_CIPHER_SUITES = new String[] + { + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", // 11+ + "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", // 11+ + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", // 11+ + + // For Sync 1.1. + "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", // 9+ + "TLS_RSA_WITH_AES_128_CBC_SHA", // 9+ + }; + } + + if (Versions.feature16Plus) { + DEFAULT_PROTOCOLS = new String[] + { + "TLSv1.2", + "TLSv1.1", + "TLSv1", // We would like to remove this, and will do so when we can. + }; + } else { + // Fall back to TLSv1 if there's nothing better. + DEFAULT_PROTOCOLS = new String[] + { + "TLSv1", + }; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java new file mode 100644 index 000000000..78d5f61a1 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java @@ -0,0 +1,83 @@ +/* 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.background.common; + +import java.util.Map; +import java.util.Set; + +import android.content.SharedPreferences; + +/** + * A wrapper around a portion of the SharedPreferences space. + */ +public class PrefsBranch implements SharedPreferences { + private final SharedPreferences prefs; + private final String prefix; // Including trailing period. + + public PrefsBranch(SharedPreferences prefs, String prefix) { + if (!prefix.endsWith(".")) { + throw new IllegalArgumentException("No trailing period in prefix."); + } + this.prefs = prefs; + this.prefix = prefix; + } + + @Override + public boolean contains(String key) { + return prefs.contains(prefix + key); + } + + @Override + public Editor edit() { + return new EditorBranch(prefs, prefix); + } + + @Override + public Map<String, ?> getAll() { + // Not implemented. TODO + return null; + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + return prefs.getBoolean(prefix + key, defValue); + } + + @Override + public float getFloat(String key, float defValue) { + return prefs.getFloat(prefix + key, defValue); + } + + @Override + public int getInt(String key, int defValue) { + return prefs.getInt(prefix + key, defValue); + } + + @Override + public long getLong(String key, long defValue) { + return prefs.getLong(prefix + key, defValue); + } + + @Override + public String getString(String key, String defValue) { + return prefs.getString(prefix + key, defValue); + } + + // Not marking as Override, because Android <= 10 doesn't have + // getStringSet. Neither can we implement it. + public Set<String> getStringSet(String key, Set<String> defValue) { + throw new RuntimeException("getStringSet not available."); + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { + prefs.registerOnSharedPreferenceChangeListener(listener); + } + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { + prefs.unregisterOnSharedPreferenceChangeListener(listener); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java new file mode 100644 index 000000000..2575717eb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java @@ -0,0 +1,232 @@ +/* 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.background.common.log; + +import java.io.PrintWriter; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.background.common.log.writers.AndroidLevelCachingLogWriter; +import org.mozilla.gecko.background.common.log.writers.AndroidLogWriter; +import org.mozilla.gecko.background.common.log.writers.LogWriter; +import org.mozilla.gecko.background.common.log.writers.PrintLogWriter; +import org.mozilla.gecko.background.common.log.writers.SimpleTagLogWriter; +import org.mozilla.gecko.background.common.log.writers.ThreadLocalTagLogWriter; + +import android.util.Log; + +/** + * Logging helper class. Serializes all log operations (by synchronizing). + */ +public class Logger { + public static final String LOGGER_TAG = "Logger"; + public static final String DEFAULT_LOG_TAG = "GeckoLogger"; + + // For extra debugging. + public static boolean LOG_PERSONAL_INFORMATION = false; + + /** + * Allow each thread to use its own global log tag. This allows + * independent services to log as different sources. + * + * When your thread sets up logging, it should do something like the following: + * + * Logger.setThreadLogTag("MyTag"); + * + * The value is inheritable, so worker threads and such do not need to + * set the same log tag as their parent. + */ + private static final InheritableThreadLocal<String> logTag = new InheritableThreadLocal<String>() { + @Override + protected String initialValue() { + return DEFAULT_LOG_TAG; + } + }; + + public static void setThreadLogTag(final String logTag) { + Logger.logTag.set(logTag); + } + public static String getThreadLogTag() { + return Logger.logTag.get(); + } + + /** + * Current set of writers to which we will log. + * <p> + * We want logging to be available while running tests, so we initialize + * this set statically. + */ + protected final static Set<LogWriter> logWriters; + static { + final Set<LogWriter> defaultWriters = Logger.defaultLogWriters(); + logWriters = new LinkedHashSet<LogWriter>(defaultWriters); + } + + /** + * Default set of log writers to log to. + */ + public final static Set<LogWriter> defaultLogWriters() { + final String processedPackage = GlobalConstants.BROWSER_INTENT_PACKAGE.replace("org.mozilla.", ""); + + final Set<LogWriter> defaultLogWriters = new LinkedHashSet<LogWriter>(); + + final LogWriter log = new AndroidLogWriter(); + final LogWriter cache = new AndroidLevelCachingLogWriter(log); + + final LogWriter single = new SimpleTagLogWriter(processedPackage, new ThreadLocalTagLogWriter(Logger.logTag, cache)); + + defaultLogWriters.add(single); + return defaultLogWriters; + } + + public static synchronized void startLoggingTo(LogWriter logWriter) { + logWriters.add(logWriter); + } + + public static synchronized void startLoggingToWriters(Set<LogWriter> writers) { + logWriters.addAll(writers); + } + + public static synchronized void stopLoggingTo(LogWriter logWriter) { + try { + logWriter.close(); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception closing and removing LogWriter " + logWriter + ".", e); + } + logWriters.remove(logWriter); + } + + public static synchronized void stopLoggingToAll() { + for (LogWriter logWriter : logWriters) { + try { + logWriter.close(); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception closing and removing LogWriter " + logWriter + ".", e); + } + } + logWriters.clear(); + } + + /** + * Write to only the default log writers. + */ + public static synchronized void resetLogging() { + stopLoggingToAll(); + logWriters.addAll(Logger.defaultLogWriters()); + } + + /** + * Start writing log output to stdout. + * <p> + * Use <code>resetLogging</code> to stop logging to stdout. + */ + public static synchronized void startLoggingToConsole() { + setThreadLogTag("Test"); + startLoggingTo(new PrintLogWriter(new PrintWriter(System.out, true))); + } + + // Synchronized version for other classes to use. + public static synchronized boolean shouldLogVerbose(String logTag) { + for (LogWriter logWriter : logWriters) { + if (logWriter.shouldLogVerbose(logTag)) { + return true; + } + } + return false; + } + + public static void error(String tag, String message) { + Logger.error(tag, message, null); + } + + public static void warn(String tag, String message) { + Logger.warn(tag, message, null); + } + + public static void info(String tag, String message) { + Logger.info(tag, message, null); + } + + public static void debug(String tag, String message) { + Logger.debug(tag, message, null); + } + + public static void trace(String tag, String message) { + Logger.trace(tag, message, null); + } + + public static void pii(String tag, String message) { + if (LOG_PERSONAL_INFORMATION) { + Logger.debug(tag, "$$PII$$: " + message); + } + } + + public static synchronized void error(String tag, String message, Throwable error) { + Iterator<LogWriter> it = logWriters.iterator(); + while (it.hasNext()) { + LogWriter writer = it.next(); + try { + writer.error(tag, message, error); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e); + it.remove(); + } + } + } + + public static synchronized void warn(String tag, String message, Throwable error) { + Iterator<LogWriter> it = logWriters.iterator(); + while (it.hasNext()) { + LogWriter writer = it.next(); + try { + writer.warn(tag, message, error); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e); + it.remove(); + } + } + } + + public static synchronized void info(String tag, String message, Throwable error) { + Iterator<LogWriter> it = logWriters.iterator(); + while (it.hasNext()) { + LogWriter writer = it.next(); + try { + writer.info(tag, message, error); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e); + it.remove(); + } + } + } + + public static synchronized void debug(String tag, String message, Throwable error) { + Iterator<LogWriter> it = logWriters.iterator(); + while (it.hasNext()) { + LogWriter writer = it.next(); + try { + writer.debug(tag, message, error); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e); + it.remove(); + } + } + } + + public static synchronized void trace(String tag, String message, Throwable error) { + Iterator<LogWriter> it = logWriters.iterator(); + while (it.hasNext()) { + LogWriter writer = it.next(); + try { + writer.trace(tag, message, error); + } catch (Exception e) { + Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e); + it.remove(); + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java new file mode 100644 index 000000000..ac4250a03 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java @@ -0,0 +1,132 @@ +/* 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.background.common.log.writers; + +import java.util.IdentityHashMap; +import java.util.Map; + +import android.util.Log; + +/** + * Make a <code>LogWriter</code> only log when the Android log system says to. + */ +public class AndroidLevelCachingLogWriter extends LogWriter { + protected final LogWriter inner; + + public AndroidLevelCachingLogWriter(LogWriter inner) { + this.inner = inner; + } + + // I can't believe we have to implement this ourselves. + // These aren't synchronized (and neither are the setters) because + // the logging calls themselves are synchronized. + private Map<String, Boolean> isErrorLoggable = new IdentityHashMap<String, Boolean>(); + private Map<String, Boolean> isWarnLoggable = new IdentityHashMap<String, Boolean>(); + private Map<String, Boolean> isInfoLoggable = new IdentityHashMap<String, Boolean>(); + private Map<String, Boolean> isDebugLoggable = new IdentityHashMap<String, Boolean>(); + private Map<String, Boolean> isVerboseLoggable = new IdentityHashMap<String, Boolean>(); + + /** + * Empty the caches of log levels. + */ + public void refreshLogLevels() { + isErrorLoggable = new IdentityHashMap<String, Boolean>(); + isWarnLoggable = new IdentityHashMap<String, Boolean>(); + isInfoLoggable = new IdentityHashMap<String, Boolean>(); + isDebugLoggable = new IdentityHashMap<String, Boolean>(); + isVerboseLoggable = new IdentityHashMap<String, Boolean>(); + } + + private boolean shouldLogError(String logTag) { + Boolean out = isErrorLoggable.get(logTag); + if (out != null) { + return out; + } + out = Log.isLoggable(logTag, Log.ERROR); + isErrorLoggable.put(logTag, out); + return out; + } + + private boolean shouldLogWarn(String logTag) { + Boolean out = isWarnLoggable.get(logTag); + if (out != null) { + return out; + } + out = Log.isLoggable(logTag, Log.WARN); + isWarnLoggable.put(logTag, out); + return out; + } + + private boolean shouldLogInfo(String logTag) { + Boolean out = isInfoLoggable.get(logTag); + if (out != null) { + return out; + } + out = Log.isLoggable(logTag, Log.INFO); + isInfoLoggable.put(logTag, out); + return out; + } + + private boolean shouldLogDebug(String logTag) { + Boolean out = isDebugLoggable.get(logTag); + if (out != null) { + return out; + } + out = Log.isLoggable(logTag, Log.DEBUG); + isDebugLoggable.put(logTag, out); + return out; + } + + @Override + public boolean shouldLogVerbose(String logTag) { + Boolean out = isVerboseLoggable.get(logTag); + if (out != null) { + return out; + } + out = Log.isLoggable(logTag, Log.VERBOSE); + isVerboseLoggable.put(logTag, out); + return out; + } + + @Override + public void error(String tag, String message, Throwable error) { + if (shouldLogError(tag)) { + inner.error(tag, message, error); + } + } + + @Override + public void warn(String tag, String message, Throwable error) { + if (shouldLogWarn(tag)) { + inner.warn(tag, message, error); + } + } + + @Override + public void info(String tag, String message, Throwable error) { + if (shouldLogInfo(tag)) { + inner.info(tag, message, error); + } + } + + @Override + public void debug(String tag, String message, Throwable error) { + if (shouldLogDebug(tag)) { + inner.debug(tag, message, error); + } + } + + @Override + public void trace(String tag, String message, Throwable error) { + if (shouldLogVerbose(tag)) { + inner.trace(tag, message, error); + } + } + + @Override + public void close() { + inner.close(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java new file mode 100644 index 000000000..9d309844d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java @@ -0,0 +1,46 @@ +/* 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.background.common.log.writers; + +import android.util.Log; + +/** + * Log to the Android log. + */ +public class AndroidLogWriter extends LogWriter { + @Override + public boolean shouldLogVerbose(String logTag) { + return true; + } + + @Override + public void error(String tag, String message, Throwable error) { + Log.e(tag, message, error); + } + + @Override + public void warn(String tag, String message, Throwable error) { + Log.w(tag, message, error); + } + + @Override + public void info(String tag, String message, Throwable error) { + Log.i(tag, message, error); + } + + @Override + public void debug(String tag, String message, Throwable error) { + Log.d(tag, message, error); + } + + @Override + public void trace(String tag, String message, Throwable error) { + Log.v(tag, message, error); + } + + @Override + public void close() { + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java new file mode 100644 index 000000000..74c3608c4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java @@ -0,0 +1,67 @@ +/* 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.background.common.log.writers; + +import android.util.Log; + +/** + * A LogWriter that logs only if the message is as important as the specified + * level. For example, if the specified level is <code>Log.WARN</code>, only + * <code>warn</code> and <code>error</code> will log. + */ +public class LevelFilteringLogWriter extends LogWriter { + protected final LogWriter inner; + protected final int logLevel; + + public LevelFilteringLogWriter(int logLevel, LogWriter inner) { + this.inner = inner; + this.logLevel = logLevel; + } + + @Override + public void close() { + inner.close(); + } + + @Override + public void error(String tag, String message, Throwable error) { + if (logLevel <= Log.ERROR) { + inner.error(tag, message, error); + } + } + + @Override + public void warn(String tag, String message, Throwable error) { + if (logLevel <= Log.WARN) { + inner.warn(tag, message, error); + } + } + + @Override + public void info(String tag, String message, Throwable error) { + if (logLevel <= Log.INFO) { + inner.info(tag, message, error); + } + } + + @Override + public void debug(String tag, String message, Throwable error) { + if (logLevel <= Log.DEBUG) { + inner.debug(tag, message, error); + } + } + + @Override + public void trace(String tag, String message, Throwable error) { + if (logLevel <= Log.VERBOSE) { + inner.trace(tag, message, error); + } + } + + @Override + public boolean shouldLogVerbose(String tag) { + return logLevel <= Log.VERBOSE; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java new file mode 100644 index 000000000..acfb09969 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java @@ -0,0 +1,29 @@ +/* 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.background.common.log.writers; + +/** + * An abstract object that logs information in some way. + * <p> + * Intended to be composed with other log writers, for example a log + * writer could make all log entries have the same single log tag, or + * could ignore certain log levels, before delegating to an inner log + * writer. + */ +public abstract class LogWriter { + public abstract void error(String tag, String message, Throwable error); + public abstract void warn(String tag, String message, Throwable error); + public abstract void info(String tag, String message, Throwable error); + public abstract void debug(String tag, String message, Throwable error); + public abstract void trace(String tag, String message, Throwable error); + + /** + * We expect <code>close</code> to be called only by static + * synchronized methods in class <code>Logger</code>. + */ + public abstract void close(); + + public abstract boolean shouldLogVerbose(String tag); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java new file mode 100644 index 000000000..6e1f63de3 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java @@ -0,0 +1,77 @@ +/* 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.background.common.log.writers; + +import java.io.PrintWriter; + +/** + * Log to a <code>PrintWriter</code>. + */ +public class PrintLogWriter extends LogWriter { + protected final PrintWriter pw; + protected boolean closed = false; + + public static final String ERROR = " :: E :: "; + public static final String WARN = " :: W :: "; + public static final String INFO = " :: I :: "; + public static final String DEBUG = " :: D :: "; + public static final String VERBOSE = " :: V :: "; + + public PrintLogWriter(PrintWriter pw) { + this.pw = pw; + } + + protected void log(String tag, String message, Throwable error) { + if (closed) { + return; + } + + pw.println(tag + message); + if (error != null) { + error.printStackTrace(pw); + } + } + + @Override + public void error(String tag, String message, Throwable error) { + log(tag, ERROR + message, error); + } + + @Override + public void warn(String tag, String message, Throwable error) { + log(tag, WARN + message, error); + } + + @Override + public void info(String tag, String message, Throwable error) { + log(tag, INFO + message, error); + } + + @Override + public void debug(String tag, String message, Throwable error) { + log(tag, DEBUG + message, error); + } + + @Override + public void trace(String tag, String message, Throwable error) { + log(tag, VERBOSE + message, error); + } + + @Override + public boolean shouldLogVerbose(String tag) { + return true; + } + + @Override + public void close() { + if (closed) { + return; + } + if (pw != null) { + pw.close(); + } + closed = true; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java new file mode 100644 index 000000000..a17654371 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java @@ -0,0 +1,21 @@ +/* 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.background.common.log.writers; + +/** + * Make a <code>LogWriter</code> only log with a single string tag. + */ +public class SimpleTagLogWriter extends TagLogWriter { + final String tag; + public SimpleTagLogWriter(String tag, LogWriter inner) { + super(inner); + this.tag = tag; + } + + @Override + protected String getMainTag() { + return tag; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java new file mode 100644 index 000000000..d6a9f5eb8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java @@ -0,0 +1,57 @@ +/* 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.background.common.log.writers; + +import java.io.PrintWriter; +import java.io.StringWriter; + +public class StringLogWriter extends LogWriter { + protected final StringWriter sw; + protected final PrintLogWriter inner; + + public StringLogWriter() { + sw = new StringWriter(); + inner = new PrintLogWriter(new PrintWriter(sw)); + } + + public String toString() { + return sw.toString(); + } + + @Override + public boolean shouldLogVerbose(String tag) { + return true; + } + + @Override + public void error(String tag, String message, Throwable error) { + inner.error(tag, message, error); + } + + @Override + public void warn(String tag, String message, Throwable error) { + inner.warn(tag, message, error); + } + + @Override + public void info(String tag, String message, Throwable error) { + inner.info(tag, message, error); + } + + @Override + public void debug(String tag, String message, Throwable error) { + inner.debug(tag, message, error); + } + + @Override + public void trace(String tag, String message, Throwable error) { + inner.trace(tag, message, error); + } + + @Override + public void close() { + inner.close(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java new file mode 100644 index 000000000..fbcd94a91 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java @@ -0,0 +1,55 @@ +/* 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.background.common.log.writers; + +/** + * A @link{LogWriter} that logs each message under a parent tag. + */ +public abstract class TagLogWriter extends LogWriter { + + protected final LogWriter inner; + + public TagLogWriter(final LogWriter inner) { + super(); + this.inner = inner; + } + + protected abstract String getMainTag(); + + @Override + public void error(String tag, String message, Throwable error) { + inner.error(this.getMainTag(), tag + " :: " + message, error); + } + + @Override + public void warn(String tag, String message, Throwable error) { + inner.warn(this.getMainTag(), tag + " :: " + message, error); + } + + @Override + public void info(String tag, String message, Throwable error) { + inner.info(this.getMainTag(), tag + " :: " + message, error); + } + + @Override + public void debug(String tag, String message, Throwable error) { + inner.debug(this.getMainTag(), tag + " :: " + message, error); + } + + @Override + public void trace(String tag, String message, Throwable error) { + inner.trace(this.getMainTag(), tag + " :: " + message, error); + } + + @Override + public boolean shouldLogVerbose(String tag) { + return inner.shouldLogVerbose(this.getMainTag()); + } + + @Override + public void close() { + inner.close(); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java new file mode 100644 index 000000000..0c83504a0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java @@ -0,0 +1,25 @@ +/* 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.background.common.log.writers; + +/** + * Log with a single global tag… but that tag can be different for each thread. + * + * Takes a @link{ThreadLocal} as a constructor parameter. + */ +public class ThreadLocalTagLogWriter extends TagLogWriter { + + private final ThreadLocal<String> tag; + + public ThreadLocalTagLogWriter(ThreadLocal<String> tag, LogWriter inner) { + super(inner); + this.tag = tag; + } + + @Override + protected String getMainTag() { + return this.tag.get(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java new file mode 100644 index 000000000..6639b817d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java @@ -0,0 +1,56 @@ +/* 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.background.common.telemetry; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import org.mozilla.gecko.background.common.log.Logger; + +/** + * Android Background Services are normally built into Fennec, but can also be + * built as a stand-alone APK for rapid local development. The current Telemetry + * implementation is coupled to Gecko, and Background Services should not + * interact with Gecko directly. To maintain this independence, Background + * Services lazily introspects the relevant Telemetry class from the enclosing + * package, warning but otherwise ignoring failures during introspection or + * invocation. + * <p> + * It is possible that Background Services will introspect and invoke the + * Telemetry implementation while Gecko is not running. In this case, the Fennec + * process itself buffers Telemetry events until such time as they can be + * flushed to disk and uploaded. <b>There is no guarantee that all Telemetry + * events will be uploaded!</b> Depending on the volume of data and the + * application lifecycle, Telemetry events may be dropped. + */ +public class TelemetryWrapper { + private static final String LOG_TAG = TelemetryWrapper.class.getSimpleName(); + + // Marking this volatile maintains thread safety cheaply. + private static volatile Method mAddToHistogram; + + public static void addToHistogram(String key, int value) { + if (mAddToHistogram == null) { + try { + final Class<?> telemetry = Class.forName("org.mozilla.gecko.Telemetry"); + mAddToHistogram = telemetry.getMethod("addToHistogram", String.class, int.class); + } catch (ClassNotFoundException e) { + Logger.warn(LOG_TAG, "org.mozilla.gecko.Telemetry class found!"); + return; + } catch (NoSuchMethodException e) { + Logger.warn(LOG_TAG, "org.mozilla.gecko.Telemetry.addToHistogram(String, int) method not found!"); + return; + } + } + + if (mAddToHistogram != null) { + try { + mAddToHistogram.invoke(null, key, value); + } catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException e) { + Logger.warn(LOG_TAG, "Got exception invoking telemetry!"); + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java new file mode 100644 index 000000000..bce968b00 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java @@ -0,0 +1,99 @@ +/* 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.background.db; + +import android.database.Cursor; + +/** + * A utility for dumping a cursor the debug log. + * <p> + * <b>For debugging only!</p> + */ +public class CursorDumper { + protected static String fixedWidth(int width, String s) { + if (s == null) { + return spaces(width); + } + int length = s.length(); + if (width == length) { + return s; + } + if (width > length) { + return s + spaces(width - length); + } + return s.substring(0, width); + } + + protected static String spaces(int i) { + return " ".substring(0, i); + } + + protected static String dashes(int i) { + return "-------------------------------------".substring(0, i); + } + + /** + * Dump a cursor to the debug log, ignoring any log level settings. + * <p> + * The position in the cursor is maintained. Caller is responsible for opening + * and closing cursor. + * + * @param cursor + * to dump. + */ + public static void dumpCursor(Cursor cursor) { + dumpCursor(cursor, 18, "records"); + } + + /** + * Dump a cursor to the debug log, ignoring any log level settings. + * <p> + * The position in the cursor is maintained. Caller is responsible for opening + * and closing cursor. + * + * @param cursor + * to dump. + * @param columnWidth + * how many characters per cursor column. + * @param tags + * a descriptor, printed like "(10 tags)", in the header row. + */ + protected static void dumpCursor(Cursor cursor, int columnWidth, String tags) { + int originalPosition = cursor.getPosition(); + try { + String[] columnNames = cursor.getColumnNames(); + int columnCount = cursor.getColumnCount(); + + for (int i = 0; i < columnCount; ++i) { + System.out.print(fixedWidth(columnWidth, columnNames[i]) + " | "); + } + System.out.println("(" + cursor.getCount() + " " + tags + ")"); + for (int i = 0; i < columnCount; ++i) { + System.out.print(dashes(columnWidth) + " | "); + } + System.out.println(""); + if (!cursor.moveToFirst()) { + System.out.println("EMPTY"); + return; + } + + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + for (int i = 0; i < columnCount; ++i) { + System.out.print(fixedWidth(columnWidth, cursor.getString(i)) + " | "); + } + System.out.println(""); + cursor.moveToNext(); + } + for (int i = 0; i < columnCount-1; ++i) { + System.out.print(dashes(columnWidth + 3)); + } + System.out.print(dashes(columnWidth + 3 - 1)); + System.out.println(""); + } finally { + cursor.moveToPosition(originalPosition); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java new file mode 100644 index 000000000..f38cfdf0e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java @@ -0,0 +1,86 @@ +/* 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.background.db; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.Tabs; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +import android.content.ContentValues; +import android.database.Cursor; + +// Immutable. +public class Tab { + public final String title; + public final String icon; + public final JSONArray history; + public final long lastUsed; + + public Tab(String title, String icon, JSONArray history, long lastUsed) { + this.title = title; + this.icon = icon; + this.history = history; + this.lastUsed = lastUsed; + } + + public ContentValues toContentValues(String clientGUID, int position) { + ContentValues out = new ContentValues(); + out.put(BrowserContract.Tabs.POSITION, position); + out.put(BrowserContract.Tabs.CLIENT_GUID, clientGUID); + + out.put(BrowserContract.Tabs.FAVICON, this.icon); + out.put(BrowserContract.Tabs.LAST_USED, this.lastUsed); + out.put(BrowserContract.Tabs.TITLE, this.title); + out.put(BrowserContract.Tabs.URL, (String) this.history.get(0)); + out.put(BrowserContract.Tabs.HISTORY, this.history.toJSONString()); + return out; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Tab)) { + return false; + } + final Tab other = (Tab) o; + + if (!RepoUtils.stringsEqual(this.title, other.title)) { + return false; + } + if (!RepoUtils.stringsEqual(this.icon, other.icon)) { + return false; + } + + if (!(this.lastUsed == other.lastUsed)) { + return false; + } + + return Utils.sameArrays(this.history, other.history); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + /** + * Extract a <code>Tab</code> from a cursor row. + * <p> + * Caller is responsible for creating, positioning, and closing the cursor. + * + * @param cursor + * to inspect. + * @return <code>Tab</code> instance. + */ + public static Tab fromCursor(final Cursor cursor) { + final String title = RepoUtils.getStringFromCursor(cursor, Tabs.TITLE); + final String icon = RepoUtils.getStringFromCursor(cursor, Tabs.FAVICON); + final JSONArray history = RepoUtils.getJSONArrayFromCursor(cursor, Tabs.HISTORY); + final long lastUsed = RepoUtils.getLongFromCursor(cursor, Tabs.LAST_USED); + + return new Tab(title, icon, history, lastUsed); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java new file mode 100644 index 000000000..98809137f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java @@ -0,0 +1,52 @@ +/* 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.background.fxa; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; + +public class FxAccount20CreateDelegate { + protected final byte[] emailUTF8; + protected final byte[] authPW; + protected final boolean preVerified; + + /** + * Make a new "create account" delegate. + * + * @param emailUTF8 + * email as UTF-8 bytes. + * @param quickStretchedPW + * quick stretched password as bytes. + * @param preVerified + * true if account should be marked already verified; only effective + * for non-production auth servers. + * @throws UnsupportedEncodingException + * @throws GeneralSecurityException + */ + public FxAccount20CreateDelegate(byte[] emailUTF8, byte[] quickStretchedPW, boolean preVerified) throws UnsupportedEncodingException, GeneralSecurityException { + this.emailUTF8 = emailUTF8; + this.authPW = FxAccountUtils.generateAuthPW(quickStretchedPW); + this.preVerified = preVerified; + } + + public ExtendedJSONObject getCreateBody() throws FxAccountClientException { + final ExtendedJSONObject body = new ExtendedJSONObject(); + try { + body.put("email", new String(emailUTF8, "UTF-8")); + body.put("authPW", Utils.byte2Hex(authPW)); + if (preVerified) { + // Production endpoints do not allow preVerified; this assumes we only + // set it when it's okay to send it. + body.put("preVerified", preVerified); + } + return body; + } catch (UnsupportedEncodingException e) { + throw new FxAccountClientException(e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java new file mode 100644 index 000000000..0266a6eab --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java @@ -0,0 +1,36 @@ +/* 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.background.fxa; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; + +/** + * An abstraction around providing an email and authorization token to the auth + * server. + */ +public class FxAccount20LoginDelegate { + protected final byte[] emailUTF8; + protected final byte[] authPW; + + public FxAccount20LoginDelegate(byte[] emailUTF8, byte[] quickStretchedPW) throws UnsupportedEncodingException, GeneralSecurityException { + this.emailUTF8 = emailUTF8; + this.authPW = FxAccountUtils.generateAuthPW(quickStretchedPW); + } + + public ExtendedJSONObject getCreateBody() throws FxAccountClientException { + final ExtendedJSONObject body = new ExtendedJSONObject(); + try { + body.put("email", new String(emailUTF8, "UTF-8")); + body.put("authPW", Utils.byte2Hex(authPW)); + return body; + } catch (UnsupportedEncodingException e) { + throw new FxAccountClientException(e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java new file mode 100644 index 000000000..ed959ff0e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java @@ -0,0 +1,24 @@ +/* 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.background.fxa; + +import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse; +import org.mozilla.gecko.background.fxa.FxAccountClient20.RecoveryEmailStatusResponse; +import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate; +import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys; +import org.mozilla.gecko.fxa.FxAccountDevice; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +import java.util.List; + +public interface FxAccountClient { + public void accountStatus(String uid, RequestDelegate<AccountStatusResponse> requestDelegate); + public void recoveryEmailStatus(byte[] sessionToken, RequestDelegate<RecoveryEmailStatusResponse> requestDelegate); + public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate); + public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate); + public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate<FxAccountDevice> requestDelegate); + public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> requestDelegate); + public void notifyDevices(byte[] sessionToken, List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> requestDelegate); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java new file mode 100644 index 000000000..596f4525e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java @@ -0,0 +1,914 @@ +/* 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.background.fxa; + +import android.support.annotation.NonNull; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientMalformedResponseException; +import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.fxa.FxAccountDevice; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.crypto.HKDF; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider; +import org.mozilla.gecko.sync.net.Resource; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.Executor; + +import javax.crypto.Mac; + +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +/** + * An HTTP client for talking to an FxAccount server. + * <p> + * <p> + * The delegate structure used is a little different from the rest of the code + * base. We add a <code>RequestDelegate</code> layer that processes a typed + * value extracted from the body of a successful response. + */ +public class FxAccountClient20 implements FxAccountClient { + protected static final String LOG_TAG = FxAccountClient20.class.getSimpleName(); + + protected static final String ACCEPT_HEADER = "application/json;charset=utf-8"; + + public static final String JSON_KEY_EMAIL = "email"; + public static final String JSON_KEY_KEYFETCHTOKEN = "keyFetchToken"; + public static final String JSON_KEY_SESSIONTOKEN = "sessionToken"; + public static final String JSON_KEY_UID = "uid"; + public static final String JSON_KEY_VERIFIED = "verified"; + public static final String JSON_KEY_ERROR = "error"; + public static final String JSON_KEY_MESSAGE = "message"; + public static final String JSON_KEY_INFO = "info"; + public static final String JSON_KEY_CODE = "code"; + public static final String JSON_KEY_ERRNO = "errno"; + public static final String JSON_KEY_EXISTS = "exists"; + + protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE, JSON_KEY_INFO }; + protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO }; + + /** + * The server's URI. + * <p> + * We assume throughout that this ends with a trailing slash (and guarantee as + * much in the constructor). + */ + protected final String serverURI; + + protected final Executor executor; + + public FxAccountClient20(String serverURI, Executor executor) { + if (serverURI == null) { + throw new IllegalArgumentException("Must provide a server URI."); + } + if (executor == null) { + throw new IllegalArgumentException("Must provide a non-null executor."); + } + this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/"; + if (!this.serverURI.endsWith("/")) { + throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI); + } + this.executor = executor; + } + + protected BaseResource getBaseResource(String path, Map<String, String> queryParameters) throws UnsupportedEncodingException, URISyntaxException { + if (queryParameters == null || queryParameters.isEmpty()) { + return getBaseResource(path); + } + final String[] array = new String[2 * queryParameters.size()]; + int i = 0; + for (Entry<String, String> entry : queryParameters.entrySet()) { + array[i++] = entry.getKey(); + array[i++] = entry.getValue(); + } + return getBaseResource(path, array); + } + + /** + * Create <code>BaseResource</code>, encoding query parameters carefully. + * <p> + * This is equivalent to <code>android.net.Uri.Builder</code>, which is not + * present in our JUnit 4 tests. + * + * @param path fragment. + * @param queryParameters list of key/value query parameter pairs. Must be even length! + * @return <code>BaseResource<instance> + * @throws URISyntaxException + * @throws UnsupportedEncodingException + */ + protected BaseResource getBaseResource(String path, String... queryParameters) throws URISyntaxException, UnsupportedEncodingException { + final StringBuilder sb = new StringBuilder(serverURI); + sb.append(path); + if (queryParameters != null) { + int i = 0; + while (i < queryParameters.length) { + sb.append(i > 0 ? "&" : "?"); + final String key = queryParameters[i++]; + final String val = queryParameters[i++]; + sb.append(URLEncoder.encode(key, "UTF-8")); + sb.append("="); + sb.append(URLEncoder.encode(val, "UTF-8")); + } + } + return new BaseResource(new URI(sb.toString())); + } + + /** + * Process a typed value extracted from a successful response (in an + * endpoint-dependent way). + */ + public interface RequestDelegate<T> { + public void handleError(Exception e); + public void handleFailure(FxAccountClientRemoteException e); + public void handleSuccess(T result); + } + + /** + * Thin container for two cryptographic keys. + */ + public static class TwoKeys { + public final byte[] kA; + public final byte[] wrapkB; + public TwoKeys(byte[] kA, byte[] wrapkB) { + this.kA = kA; + this.wrapkB = wrapkB; + } + } + + protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleError(e); + } + }); + } + + enum ResponseType { + JSON_ARRAY, + JSON_OBJECT + } + + /** + * Translate resource callbacks into request callbacks invoked on the provided + * executor. + * <p> + * Override <code>handleSuccess</code> to parse the body of the resource + * request and call the request callback. <code>handleSuccess</code> is + * invoked via the executor, so you don't need to delegate further. + */ + protected abstract class ResourceDelegate<T> extends BaseResourceDelegate { + + protected void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body) throws Exception { + throw new UnsupportedOperationException(); + } + + protected void handleSuccess(final int status, HttpResponse response, final JSONArray body) throws Exception { + throw new UnsupportedOperationException(); + } + + protected final RequestDelegate<T> delegate; + + protected final byte[] tokenId; + protected final byte[] reqHMACKey; + protected final SkewHandler skewHandler; + protected final ResponseType responseType; + + /** + * Create a delegate for an un-authenticated resource. + */ + public ResourceDelegate(final Resource resource, final RequestDelegate<T> delegate, ResponseType responseType) { + this(resource, delegate, responseType, null, null); + } + + /** + * Create a delegate for a Hawk-authenticated resource. + * <p> + * Every Hawk request that encloses an entity (PATCH, POST, and PUT) will + * include the payload verification hash. + */ + public ResourceDelegate(final Resource resource, final RequestDelegate<T> delegate, ResponseType responseType, final byte[] tokenId, final byte[] reqHMACKey) { + super(resource); + this.delegate = delegate; + this.reqHMACKey = reqHMACKey; + this.tokenId = tokenId; + this.skewHandler = SkewHandler.getSkewHandlerForResource(resource); + this.responseType = responseType; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + if (tokenId != null && reqHMACKey != null) { + // We always include the payload verification hash for FxA Hawk-authenticated requests. + final boolean includePayloadVerificationHash = true; + return new HawkAuthHeaderProvider(Utils.byte2Hex(tokenId), reqHMACKey, includePayloadVerificationHash, skewHandler.getSkewInSeconds()); + } + return super.getAuthHeaderProvider(); + } + + @Override + public String getUserAgent() { + return FxAccountConstants.USER_AGENT; + } + + @Override + public void handleHttpResponse(HttpResponse response) { + try { + final int status = validateResponse(response); + skewHandler.updateSkew(response, now()); + invokeHandleSuccess(status, response); + } catch (FxAccountClientRemoteException e) { + if (!skewHandler.updateSkew(response, now())) { + // If we couldn't update skew, but we got a failure, let's try clearing the skew. + skewHandler.resetSkew(); + } + invokeHandleFailure(e); + } + } + + protected void invokeHandleFailure(final FxAccountClientRemoteException e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleFailure(e); + } + }); + } + + protected void invokeHandleSuccess(final int status, final HttpResponse response) { + executor.execute(new Runnable() { + @Override + public void run() { + try { + SyncResponse syncResponse = new SyncResponse(response); + if (responseType == ResponseType.JSON_ARRAY) { + JSONArray body = syncResponse.jsonArrayBody(); + ResourceDelegate.this.handleSuccess(status, response, body); + } else { + ExtendedJSONObject body = syncResponse.jsonObjectBody(); + ResourceDelegate.this.handleSuccess(status, response, body); + } + } catch (Exception e) { + delegate.handleError(e); + } + } + }); + } + + @Override + public void handleHttpProtocolException(final ClientProtocolException e) { + invokeHandleError(delegate, e); + } + + @Override + public void handleHttpIOException(IOException e) { + invokeHandleError(delegate, e); + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + invokeHandleError(delegate, e); + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + super.addHeaders(request, client); + + // The basics. + final Locale locale = Locale.getDefault(); + request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale)); + request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER); + } + } + + protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody) { + if (requestBody == null) { + resource.post((HttpEntity) null); + } else { + resource.post(requestBody); + } + } + + @SuppressWarnings("static-method") + public long now() { + return System.currentTimeMillis(); + } + + /** + * Intepret a response from the auth server. + * <p> + * Throw an appropriate exception on errors; otherwise, return the response's + * status code. + * + * @return response's HTTP status code. + * @throws FxAccountClientException + */ + public static int validateResponse(HttpResponse response) throws FxAccountClientRemoteException { + final int status = response.getStatusLine().getStatusCode(); + if (status == 200) { + return status; + } + int code; + int errno; + String error; + String message; + String info; + ExtendedJSONObject body; + try { + body = new SyncStorageResponse(response).jsonObjectBody(); + body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class); + body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class); + code = body.getLong(JSON_KEY_CODE).intValue(); + errno = body.getLong(JSON_KEY_ERRNO).intValue(); + error = body.getString(JSON_KEY_ERROR); + message = body.getString(JSON_KEY_MESSAGE); + info = body.getString(JSON_KEY_INFO); + } catch (Exception e) { + throw new FxAccountClientMalformedResponseException(response); + } + throw new FxAccountClientRemoteException(response, code, errno, error, message, info, body); + } + + /** + * Don't call this directly. Use <code>unbundleBody</code> instead. + */ + protected void unbundleBytes(byte[] bundleBytes, byte[] respHMACKey, byte[] respXORKey, byte[]... rest) + throws InvalidKeyException, NoSuchAlgorithmException, FxAccountClientException { + if (bundleBytes.length < 32) { + throw new IllegalArgumentException("input bundle must include HMAC"); + } + int len = respXORKey.length; + if (bundleBytes.length != len + 32) { + throw new IllegalArgumentException("input bundle and XOR key with HMAC have different lengths"); + } + int left = len; + for (byte[] array : rest) { + left -= array.length; + } + if (left != 0) { + throw new IllegalArgumentException("XOR key and total output arrays have different lengths"); + } + + byte[] ciphertext = new byte[len]; + byte[] HMAC = new byte[32]; + System.arraycopy(bundleBytes, 0, ciphertext, 0, len); + System.arraycopy(bundleBytes, len, HMAC, 0, 32); + + Mac hmacHasher = HKDF.makeHMACHasher(respHMACKey); + byte[] computedHMAC = hmacHasher.doFinal(ciphertext); + if (!Arrays.equals(computedHMAC, HMAC)) { + throw new FxAccountClientException("Bad message HMAC"); + } + + int offset = 0; + for (byte[] array : rest) { + for (int i = 0; i < array.length; i++) { + array[i] = (byte) (respXORKey[offset + i] ^ ciphertext[offset + i]); + } + offset += array.length; + } + } + + protected void unbundleBody(ExtendedJSONObject body, byte[] requestKey, byte[] ctxInfo, byte[]... rest) throws Exception { + int length = 0; + for (byte[] array : rest) { + length += array.length; + } + + if (body == null) { + throw new FxAccountClientException("body must be non-null"); + } + String bundle = body.getString("bundle"); + if (bundle == null) { + throw new FxAccountClientException("bundle must be a non-null string"); + } + byte[] bundleBytes = Utils.hex2Byte(bundle); + + final byte[] respHMACKey = new byte[32]; + final byte[] respXORKey = new byte[length]; + HKDF.deriveMany(requestKey, new byte[0], ctxInfo, respHMACKey, respXORKey); + unbundleBytes(bundleBytes, respHMACKey, respXORKey, rest); + } + + public void keys(byte[] keyFetchToken, final RequestDelegate<TwoKeys> delegate) { + final byte[] tokenId = new byte[32]; + final byte[] reqHMACKey = new byte[32]; + final byte[] requestKey = new byte[32]; + try { + HKDF.deriveMany(keyFetchToken, new byte[0], FxAccountUtils.KW("keyFetchToken"), tokenId, reqHMACKey, requestKey); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + BaseResource resource; + try { + resource = getBaseResource("account/keys"); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<TwoKeys>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception { + byte[] kA = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES]; + byte[] wrapkB = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES]; + unbundleBody(body, requestKey, FxAccountUtils.KW("account/keys"), kA, wrapkB); + delegate.handleSuccess(new TwoKeys(kA, wrapkB)); + } + }; + resource.get(); + } + + /** + * Thin container for account status response. + */ + public static class AccountStatusResponse { + public final boolean exists; + public AccountStatusResponse(boolean exists) { + this.exists = exists; + } + } + + /** + * Query the account status of an account given a uid. + * + * @param uid to query. + * @param delegate to invoke callbacks. + */ + public void accountStatus(String uid, final RequestDelegate<AccountStatusResponse> delegate) { + final BaseResource resource; + try { + final Map<String, String> params = new HashMap<>(1); + params.put("uid", uid); + resource = getBaseResource("account/status", params); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<AccountStatusResponse>(resource, delegate, ResponseType.JSON_OBJECT) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception { + boolean exists = body.getBoolean(JSON_KEY_EXISTS); + delegate.handleSuccess(new AccountStatusResponse(exists)); + } + }; + resource.get(); + } + + /** + * Thin container for recovery email status response. + */ + public static class RecoveryEmailStatusResponse { + public final String email; + public final boolean verified; + public RecoveryEmailStatusResponse(String email, boolean verified) { + this.email = email; + this.verified = verified; + } + } + + /** + * Query the recovery email status of an account given a valid session token. + * <p> + * This API is a little odd: the auth server returns the email and + * verification state of the account that corresponds to the (opaque) session + * token. It might fail if the session token is unknown (or invalid, or + * revoked). + * + * @param sessionToken + * to query. + * @param delegate + * to invoke callbacks. + */ + public void recoveryEmailStatus(byte[] sessionToken, final RequestDelegate<RecoveryEmailStatusResponse> delegate) { + final byte[] tokenId = new byte[32]; + final byte[] reqHMACKey = new byte[32]; + final byte[] requestKey = new byte[32]; + try { + HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + BaseResource resource; + try { + resource = getBaseResource("recovery_email/status"); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<RecoveryEmailStatusResponse>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception { + String[] requiredStringFields = new String[] { JSON_KEY_EMAIL }; + body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class); + String email = body.getString(JSON_KEY_EMAIL); + Boolean verified = body.getBoolean(JSON_KEY_VERIFIED); + delegate.handleSuccess(new RecoveryEmailStatusResponse(email, verified)); + } + }; + resource.get(); + } + + @SuppressWarnings("unchecked") + public void sign(final byte[] sessionToken, final ExtendedJSONObject publicKey, long durationInMilliseconds, final RequestDelegate<String> delegate) { + final ExtendedJSONObject body = new ExtendedJSONObject(); + body.put("publicKey", publicKey); + body.put("duration", durationInMilliseconds); + + final byte[] tokenId = new byte[32]; + final byte[] reqHMACKey = new byte[32]; + try { + HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + BaseResource resource; + try { + resource = getBaseResource("certificate/sign"); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<String>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception { + String cert = body.getString("cert"); + if (cert == null) { + delegate.handleError(new FxAccountClientException("cert must be a non-null string")); + return; + } + delegate.handleSuccess(cert); + } + }; + post(resource, body); + } + + protected static final String[] LOGIN_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_UID, JSON_KEY_SESSIONTOKEN }; + protected static final String[] LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS = new String[] { JSON_KEY_UID, JSON_KEY_SESSIONTOKEN, JSON_KEY_KEYFETCHTOKEN, }; + protected static final String[] LOGIN_RESPONSE_REQUIRED_BOOLEAN_FIELDS = new String[] { JSON_KEY_VERIFIED }; + + /** + * Thin container for login response. + * <p> + * The <code>remoteEmail</code> field is the email address as normalized by the + * server, and is <b>not necessarily</b> the email address delivered to the + * <code>login</code> or <code>create</code> call. + */ + public static class LoginResponse { + public final String remoteEmail; + public final String uid; + public final byte[] sessionToken; + public final boolean verified; + public final byte[] keyFetchToken; + + public LoginResponse(String remoteEmail, String uid, boolean verified, byte[] sessionToken, byte[] keyFetchToken) { + this.remoteEmail = remoteEmail; + this.uid = uid; + this.verified = verified; + this.sessionToken = sessionToken; + this.keyFetchToken = keyFetchToken; + } + } + + // Public for testing only; prefer login and loginAndGetKeys (without boolean parameter). + public void login(final byte[] emailUTF8, final byte[] quickStretchedPW, final boolean getKeys, + final Map<String, String> queryParameters, + final RequestDelegate<LoginResponse> delegate) { + final BaseResource resource; + final ExtendedJSONObject body; + try { + final String path = "account/login"; + final Map<String, String> modifiedParameters = new HashMap<>(); + if (queryParameters != null) { + modifiedParameters.putAll(queryParameters); + } + if (getKeys) { + modifiedParameters.put("keys", "true"); + } + resource = getBaseResource(path, modifiedParameters); + body = new FxAccount20LoginDelegate(emailUTF8, quickStretchedPW).getCreateBody(); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<LoginResponse>(resource, delegate, ResponseType.JSON_OBJECT) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception { + final String[] requiredStringFields = getKeys ? LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS : LOGIN_RESPONSE_REQUIRED_STRING_FIELDS; + body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class); + + final String[] requiredBooleanFields = LOGIN_RESPONSE_REQUIRED_BOOLEAN_FIELDS; + body.throwIfFieldsMissingOrMisTyped(requiredBooleanFields, Boolean.class); + + String uid = body.getString(JSON_KEY_UID); + boolean verified = body.getBoolean(JSON_KEY_VERIFIED); + byte[] sessionToken = Utils.hex2Byte(body.getString(JSON_KEY_SESSIONTOKEN)); + byte[] keyFetchToken = null; + if (getKeys) { + keyFetchToken = Utils.hex2Byte(body.getString(JSON_KEY_KEYFETCHTOKEN)); + } + LoginResponse loginResponse = new LoginResponse(new String(emailUTF8, "UTF-8"), uid, verified, sessionToken, keyFetchToken); + + delegate.handleSuccess(loginResponse); + } + }; + + post(resource, body); + } + + public void createAccount(final byte[] emailUTF8, final byte[] quickStretchedPW, + final boolean getKeys, + final boolean preVerified, + final Map<String, String> queryParameters, + final RequestDelegate<LoginResponse> delegate) { + final BaseResource resource; + final ExtendedJSONObject body; + try { + final String path = "account/create"; + final Map<String, String> modifiedParameters = new HashMap<>(); + if (queryParameters != null) { + modifiedParameters.putAll(queryParameters); + } + if (getKeys) { + modifiedParameters.put("keys", "true"); + } + resource = getBaseResource(path, modifiedParameters); + body = new FxAccount20CreateDelegate(emailUTF8, quickStretchedPW, preVerified).getCreateBody(); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + // This is very similar to login, except verified is not required. + resource.delegate = new ResourceDelegate<LoginResponse>(resource, delegate, ResponseType.JSON_OBJECT) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception { + final String[] requiredStringFields = getKeys ? LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS : LOGIN_RESPONSE_REQUIRED_STRING_FIELDS; + body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class); + + String uid = body.getString(JSON_KEY_UID); + boolean verified = false; // In production, we're definitely not verified immediately upon creation. + Boolean tempVerified = body.getBoolean(JSON_KEY_VERIFIED); + if (tempVerified != null) { + verified = tempVerified; + } + byte[] sessionToken = Utils.hex2Byte(body.getString(JSON_KEY_SESSIONTOKEN)); + byte[] keyFetchToken = null; + if (getKeys) { + keyFetchToken = Utils.hex2Byte(body.getString(JSON_KEY_KEYFETCHTOKEN)); + } + LoginResponse loginResponse = new LoginResponse(new String(emailUTF8, "UTF-8"), uid, verified, sessionToken, keyFetchToken); + + delegate.handleSuccess(loginResponse); + } + }; + + post(resource, body); + } + + /** + * We want users to be able to enter their email address case-insensitively. + * We stretch the password locally using the email address as a salt, to make + * dictionary attacks more expensive. This means that a client with a + * case-differing email address is unable to produce the correct + * authorization, even though it knows the password. In this case, the server + * returns the email that the account was created with, so that the client can + * re-stretch the password locally with the correct email salt. This version + * of <code>login</code> retries at most one time with a server provided email + * address. + * <p> + * Be aware that consumers will not see the initial error response from the + * server providing an alternate email (if there is one). + * + * @param emailUTF8 + * user entered email address. + * @param stretcher + * delegate to stretch and re-stretch password. + * @param getKeys + * true if a <code>keyFetchToken</code> should be returned (in + * addition to the standard <code>sessionToken</code>). + * @param queryParameters + * @param delegate + * to invoke callbacks. + */ + public void login(final byte[] emailUTF8, final PasswordStretcher stretcher, final boolean getKeys, + final Map<String, String> queryParameters, + final RequestDelegate<LoginResponse> delegate) { + byte[] quickStretchedPW; + try { + FxAccountUtils.pii(LOG_TAG, "Trying user provided email: '" + new String(emailUTF8, "UTF-8") + "'" ); + quickStretchedPW = stretcher.getQuickStretchedPW(emailUTF8); + } catch (Exception e) { + delegate.handleError(e); + return; + } + + this.login(emailUTF8, quickStretchedPW, getKeys, queryParameters, new RequestDelegate<LoginResponse>() { + @Override + public void handleSuccess(LoginResponse result) { + delegate.handleSuccess(result); + } + + @Override + public void handleError(Exception e) { + delegate.handleError(e); + } + + @Override + public void handleFailure(FxAccountClientRemoteException e) { + String alternateEmail = e.body.getString(JSON_KEY_EMAIL); + if (!e.isBadEmailCase() || alternateEmail == null) { + delegate.handleFailure(e); + return; + }; + + Logger.info(LOG_TAG, "Server returned alternate email; retrying login with provided email."); + FxAccountUtils.pii(LOG_TAG, "Trying server provided email: '" + alternateEmail + "'" ); + + try { + // Nota bene: this is not recursive, since we call the fixed password + // signature here, which invokes a non-retrying version. + byte[] alternateEmailUTF8 = alternateEmail.getBytes("UTF-8"); + byte[] alternateQuickStretchedPW = stretcher.getQuickStretchedPW(alternateEmailUTF8); + login(alternateEmailUTF8, alternateQuickStretchedPW, getKeys, queryParameters, delegate); + } catch (Exception innerException) { + delegate.handleError(innerException); + return; + } + } + }); + } + + /** + * Registers a device given a valid session token. + * + * @param sessionToken to query. + * @param delegate to invoke callbacks. + */ + @Override + public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate<FxAccountDevice> delegate) { + final byte[] tokenId = new byte[32]; + final byte[] reqHMACKey = new byte[32]; + final byte[] requestKey = new byte[32]; + try { + HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + final BaseResource resource; + final ExtendedJSONObject body; + try { + resource = getBaseResource("account/device"); + body = device.toJson(); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<FxAccountDevice>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + delegate.handleSuccess(FxAccountDevice.fromJson(body)); + } catch (Exception e) { + delegate.handleError(e); + } + } + }; + + post(resource, body); + } + + @Override + public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> delegate) { + final byte[] tokenId = new byte[32]; + final byte[] reqHMACKey = new byte[32]; + final byte[] requestKey = new byte[32]; + try { + HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + final BaseResource resource; + try { + resource = getBaseResource("account/devices"); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<FxAccountDevice[]>(resource, delegate, ResponseType.JSON_ARRAY, tokenId, reqHMACKey) { + @Override + public void handleSuccess(int status, HttpResponse response, JSONArray devicesJson) { + try { + FxAccountDevice[] devices = new FxAccountDevice[devicesJson.size()]; + for (int i = 0; i < devices.length; i++) { + ExtendedJSONObject deviceJson = new ExtendedJSONObject((JSONObject) devicesJson.get(i)); + devices[i] = FxAccountDevice.fromJson(deviceJson); + } + delegate.handleSuccess(devices); + } catch (Exception e) { + delegate.handleError(e); + } + } + }; + + resource.get(); + } + + @Override + public void notifyDevices(@NonNull byte[] sessionToken, @NonNull List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> delegate) { + final byte[] tokenId = new byte[32]; + final byte[] reqHMACKey = new byte[32]; + final byte[] requestKey = new byte[32]; + try { + HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + final BaseResource resource; + final ExtendedJSONObject body = createNotifyDevicesBody(deviceIds, payload, TTL); + try { + resource = getBaseResource("account/devices/notify"); + } catch (URISyntaxException | UnsupportedEncodingException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<ExtendedJSONObject>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + delegate.handleSuccess(body); + } catch (Exception e) { + delegate.handleError(e); + } + } + }; + + post(resource, body); + } + + @NonNull + @SuppressWarnings("unchecked") + private ExtendedJSONObject createNotifyDevicesBody(@NonNull List<String> deviceIds, ExtendedJSONObject payload, Long TTL) { + final ExtendedJSONObject body = new ExtendedJSONObject(); + final JSONArray to = new JSONArray(); + to.addAll(deviceIds); + body.put("to", to); + if (payload != null) { + body.put("payload", payload); + } + if (TTL != null) { + body.put("TTL", TTL); + } + return body; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java new file mode 100644 index 000000000..28ee5630e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java @@ -0,0 +1,133 @@ +/* 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.background.fxa; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.HttpStatus; + +/** + * From <a href="https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md">https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md</a>. + */ +public class FxAccountClientException extends Exception { + private static final long serialVersionUID = 7953459541558266597L; + + public FxAccountClientException(String detailMessage) { + super(detailMessage); + } + + public FxAccountClientException(Exception e) { + super(e); + } + + public static class FxAccountClientRemoteException extends FxAccountClientException { + private static final long serialVersionUID = 2209313149952001097L; + + public final HttpResponse response; + public final long httpStatusCode; + public final long apiErrorNumber; + public final String error; + public final String message; + public final String info; + public final ExtendedJSONObject body; + + public FxAccountClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, String info, ExtendedJSONObject body) { + super(new HTTPFailureException(new SyncStorageResponse(response))); + if (body == null) { + throw new IllegalArgumentException("body must not be null"); + } + this.response = response; + this.httpStatusCode = httpStatusCode; + this.apiErrorNumber = apiErrorNumber; + this.error = error; + this.message = message; + this.info = info; + this.body = body; + } + + @Override + public String toString() { + return "<FxAccountClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">"; + } + + public boolean isInvalidAuthentication() { + return httpStatusCode == HttpStatus.SC_UNAUTHORIZED; + } + + public boolean isAccountAlreadyExists() { + return apiErrorNumber == FxAccountRemoteError.ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS; + } + + public boolean isAccountDoesNotExist() { + return apiErrorNumber == FxAccountRemoteError.ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST; + } + + public boolean isBadPassword() { + return apiErrorNumber == FxAccountRemoteError.INCORRECT_PASSWORD; + } + + public boolean isUnverified() { + return apiErrorNumber == FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT; + } + + public boolean isUpgradeRequired() { + return + apiErrorNumber == FxAccountRemoteError.ENDPOINT_IS_NO_LONGER_SUPPORTED || + apiErrorNumber == FxAccountRemoteError.INCORRECT_LOGIN_METHOD_FOR_THIS_ACCOUNT || + apiErrorNumber == FxAccountRemoteError.INCORRECT_KEY_RETRIEVAL_METHOD_FOR_THIS_ACCOUNT || + apiErrorNumber == FxAccountRemoteError.INCORRECT_API_VERSION_FOR_THIS_ACCOUNT; + } + + public boolean isTooManyRequests() { + return apiErrorNumber == FxAccountRemoteError.CLIENT_HAS_SENT_TOO_MANY_REQUESTS; + } + + public boolean isServerUnavailable() { + return apiErrorNumber == FxAccountRemoteError.SERVICE_TEMPORARILY_UNAVAILABLE_DUE_TO_HIGH_LOAD; + } + + public boolean isBadEmailCase() { + return apiErrorNumber == FxAccountRemoteError.INCORRECT_EMAIL_CASE; + } + + public boolean isAccountLocked() { + return apiErrorNumber == FxAccountRemoteError.ACCOUNT_LOCKED; + } + + public int getErrorMessageStringResource() { + if (isUpgradeRequired()) { + return R.string.fxaccount_remote_error_UPGRADE_REQUIRED; + } else if (isAccountAlreadyExists()) { + return R.string.fxaccount_remote_error_ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS; + } else if (isAccountDoesNotExist()) { + return R.string.fxaccount_remote_error_ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST; + } else if (isBadPassword()) { + return R.string.fxaccount_remote_error_INCORRECT_PASSWORD; + } else if (isUnverified()) { + return R.string.fxaccount_remote_error_ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT; + } else if (isTooManyRequests()) { + return R.string.fxaccount_remote_error_CLIENT_HAS_SENT_TOO_MANY_REQUESTS; + } else if (isServerUnavailable()) { + return R.string.fxaccount_remote_error_SERVICE_TEMPORARILY_UNAVAILABLE_TO_DUE_HIGH_LOAD; + } else if (isAccountLocked()) { + return R.string.fxaccount_remote_error_ACCOUNT_LOCKED; + } else { + return R.string.fxaccount_remote_error_UNKNOWN_ERROR; + } + } + } + + public static class FxAccountClientMalformedResponseException extends FxAccountClientRemoteException { + private static final long serialVersionUID = 2209313149952001098L; + + public FxAccountClientMalformedResponseException(HttpResponse response) { + super(response, 0, FxAccountRemoteError.UNKNOWN_ERROR, "Response malformed", "Response malformed", "Response malformed", new ExtendedJSONObject()); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java new file mode 100644 index 000000000..5a89561cb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java @@ -0,0 +1,33 @@ +/* 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.background.fxa; + +public interface FxAccountRemoteError { + public static final int ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS = 101; + public static final int ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST = 102; + public static final int INCORRECT_PASSWORD = 103; + public static final int ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT = 104; + public static final int INVALID_VERIFICATION_CODE = 105; + public static final int REQUEST_BODY_WAS_NOT_VALID_JSON = 106; + public static final int REQUEST_BODY_CONTAINS_INVALID_PARAMETERS = 107; + public static final int REQUEST_BODY_MISSING_REQUIRED_PARAMETERS = 108; + public static final int INVALID_REQUEST_SIGNATURE = 109; + public static final int INVALID_AUTHENTICATION_TOKEN = 110; + public static final int INVALID_AUTHENTICATION_TIMESTAMP = 111; + public static final int CONTENT_LENGTH_HEADER_WAS_NOT_PROVIDED = 112; + public static final int REQUEST_BODY_TOO_LARGE = 113; + public static final int CLIENT_HAS_SENT_TOO_MANY_REQUESTS = 114; + public static final int INVALID_NONCE_IN_REQUEST_SIGNATURE = 115; + public static final int ENDPOINT_IS_NO_LONGER_SUPPORTED = 116; + public static final int INCORRECT_LOGIN_METHOD_FOR_THIS_ACCOUNT = 117; + public static final int INCORRECT_KEY_RETRIEVAL_METHOD_FOR_THIS_ACCOUNT = 118; + public static final int INCORRECT_API_VERSION_FOR_THIS_ACCOUNT = 119; + public static final int INCORRECT_EMAIL_CASE = 120; + public static final int ACCOUNT_LOCKED = 121; + public static final int UNKNOWN_DEVICE = 123; + public static final int DEVICE_SESSION_CONFLICT = 124; + public static final int SERVICE_TEMPORARILY_UNAVAILABLE_DUE_TO_HIGH_LOAD = 201; + public static final int UNKNOWN_ERROR = 999; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java new file mode 100644 index 000000000..2d29725a0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java @@ -0,0 +1,217 @@ +/* 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.background.fxa; + +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.nativecode.NativeCrypto; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.crypto.HKDF; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.crypto.PBKDF2; + +import android.content.Context; + +public class FxAccountUtils { + private static final String LOG_TAG = FxAccountUtils.class.getSimpleName(); + + public static final int SALT_LENGTH_BYTES = 32; + public static final int SALT_LENGTH_HEX = 2 * SALT_LENGTH_BYTES; + + public static final int HASH_LENGTH_BYTES = 16; + public static final int HASH_LENGTH_HEX = 2 * HASH_LENGTH_BYTES; + + public static final int CRYPTO_KEY_LENGTH_BYTES = 32; + public static final int CRYPTO_KEY_LENGTH_HEX = 2 * CRYPTO_KEY_LENGTH_BYTES; + + public static final String KW_VERSION_STRING = "identity.mozilla.com/picl/v1/"; + + public static final int NUMBER_OF_QUICK_STRETCH_ROUNDS = 1000; + + // For extra debugging. Not final so it can be changed from Fennec, or from + // an add-on. + public static boolean LOG_PERSONAL_INFORMATION = false; + + public static void pii(String tag, String message) { + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + Logger.info(tag, "$$FxA PII$$: " + message); + } + } + + public static String bytes(String string) throws UnsupportedEncodingException { + return Utils.byte2Hex(string.getBytes("UTF-8")); + } + + public static byte[] KW(String name) throws UnsupportedEncodingException { + return Utils.concatAll( + KW_VERSION_STRING.getBytes("UTF-8"), + name.getBytes("UTF-8")); + } + + public static byte[] KWE(String name, byte[] emailUTF8) throws UnsupportedEncodingException { + return Utils.concatAll( + KW_VERSION_STRING.getBytes("UTF-8"), + name.getBytes("UTF-8"), + ":".getBytes("UTF-8"), + emailUTF8); + } + + /** + * Calculate the SRP verifier <tt>x</tt> value. + */ + public static BigInteger srpVerifierLowercaseX(byte[] emailUTF8, byte[] srpPWBytes, byte[] srpSaltBytes) + throws NoSuchAlgorithmException, UnsupportedEncodingException { + byte[] inner = Utils.sha256(Utils.concatAll(emailUTF8, ":".getBytes("UTF-8"), srpPWBytes)); + byte[] outer = Utils.sha256(Utils.concatAll(srpSaltBytes, inner)); + return new BigInteger(1, outer); + } + + /** + * Calculate the SRP verifier <tt>v</tt> value. + */ + public static BigInteger srpVerifierLowercaseV(byte[] emailUTF8, byte[] srpPWBytes, byte[] srpSaltBytes, BigInteger g, BigInteger N) + throws NoSuchAlgorithmException, UnsupportedEncodingException { + BigInteger x = srpVerifierLowercaseX(emailUTF8, srpPWBytes, srpSaltBytes); + BigInteger v = g.modPow(x, N); + return v; + } + + /** + * Format x modulo N in hexadecimal, using as many characters as N takes (in hexadecimal). + * @param x to format. + * @param N modulus. + * @return x modulo N in hexadecimal. + */ + public static String hexModN(BigInteger x, BigInteger N) { + int byteLength = (N.bitLength() + 7) / 8; + int hexLength = 2 * byteLength; + return Utils.byte2Hex(Utils.hex2Byte((x.mod(N)).toString(16), byteLength), hexLength); + } + + /** + * The first engineering milestone of PICL (Profile-in-the-Cloud) was + * comprised of Sync 1.1 fronted by a Firefox Account. The sync key was + * generated from the Firefox Account password-derived kB value using this + * method. + */ + public static KeyBundle generateSyncKeyBundle(final byte[] kB) throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + byte[] encryptionKey = new byte[32]; + byte[] hmacKey = new byte[32]; + byte[] derived = HKDF.derive(kB, new byte[0], FxAccountUtils.KW("oldsync"), 2*32); + System.arraycopy(derived, 0*32, encryptionKey, 0, 1*32); + System.arraycopy(derived, 1*32, hmacKey, 0, 1*32); + return new KeyBundle(encryptionKey, hmacKey); + } + + /** + * Firefox Accounts are password authenticated, but clients should not store + * the plain-text password for any amount of time. Equivalent, but slightly + * more secure, is the quickly client-side stretched password. + * <p> + * We separate this since multiple login-time operations want it, and the + * PBKDF2 operation is computationally expensive. + */ + public static byte[] generateQuickStretchedPW(byte[] emailUTF8, byte[] passwordUTF8) throws GeneralSecurityException, UnsupportedEncodingException { + byte[] S = FxAccountUtils.KWE("quickStretch", emailUTF8); + try { + return NativeCrypto.pbkdf2SHA256(passwordUTF8, S, NUMBER_OF_QUICK_STRETCH_ROUNDS, 32); + } catch (final LinkageError e) { + // This will throw UnsatisfiedLinkError (missing mozglue) the first time it is called, and + // ClassNotDefFoundError, for the uninitialized NativeCrypto class, each subsequent time this + // is called; LinkageError is their common ancestor. + Logger.warn(LOG_TAG, "Got throwable stretching password using native pbkdf2SHA256 " + + "implementation; ignoring and using Java implementation.", e); + return PBKDF2.pbkdf2SHA256(passwordUTF8, S, NUMBER_OF_QUICK_STRETCH_ROUNDS, 32); + } + } + + /** + * The password-derived credential used to authenticate to the Firefox Account + * auth server. + */ + public static byte[] generateAuthPW(byte[] quickStretchedPW) throws GeneralSecurityException, UnsupportedEncodingException { + return HKDF.derive(quickStretchedPW, new byte[0], FxAccountUtils.KW("authPW"), 32); + } + + /** + * The password-derived credential used to unwrap keys managed by the Firefox + * Account auth server. + */ + public static byte[] generateUnwrapBKey(byte[] quickStretchedPW) throws GeneralSecurityException, UnsupportedEncodingException { + return HKDF.derive(quickStretchedPW, new byte[0], FxAccountUtils.KW("unwrapBkey"), 32); + } + + public static byte[] unwrapkB(byte[] unwrapkB, byte[] wrapkB) { + if (unwrapkB == null) { + throw new IllegalArgumentException("unwrapkB must not be null"); + } + if (wrapkB == null) { + throw new IllegalArgumentException("wrapkB must not be null"); + } + if (unwrapkB.length != CRYPTO_KEY_LENGTH_BYTES || wrapkB.length != CRYPTO_KEY_LENGTH_BYTES) { + throw new IllegalArgumentException("unwrapkB and wrapkB must be " + CRYPTO_KEY_LENGTH_BYTES + " bytes long"); + } + byte[] kB = new byte[CRYPTO_KEY_LENGTH_BYTES]; + for (int i = 0; i < wrapkB.length; i++) { + kB[i] = (byte) (wrapkB[i] ^ unwrapkB[i]); + } + return kB; + } + + /** + * The token server accepts an X-Client-State header, which is the + * lowercase-hex-encoded first 16 bytes of the SHA-256 hash of the + * bytes of kB. + * @param kB a byte array, expected to be 32 bytes long. + * @return a 32-character string. + * @throws NoSuchAlgorithmException + */ + public static String computeClientState(byte[] kB) throws NoSuchAlgorithmException { + if (kB == null || + kB.length != 32) { + throw new IllegalArgumentException("Unexpected kB."); + } + byte[] sha256 = Utils.sha256(kB); + byte[] truncated = new byte[16]; + System.arraycopy(sha256, 0, truncated, 0, 16); + return Utils.byte2Hex(truncated); // This is automatically lowercase. + } + + /** + * Given an endpoint, calculate the corresponding BrowserID audience. + * <p> + * This is the domain, in web parlance. + * + * @param serverURI endpoint. + * @return BrowserID audience. + * @throws URISyntaxException + */ + public static String getAudienceForURL(String serverURI) throws URISyntaxException { + URI uri = new URI(serverURI); + return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), null, null, null).toString(); + } + + public static String defaultClientName(Context context) { + String name = AppConstants.MOZ_APP_DISPLAYNAME; // The display name is never translated. + // Change "Firefox Aurora" or similar into "Aurora". + if (name.contains("Aurora")) { + name = "Aurora"; + } else if (name.contains("Beta")) { + name = "Beta"; + } else if (name.contains("Nightly")) { + name = "Nightly"; + } + return context.getResources().getString(R.string.sync_default_client_name, name, android.os.Build.MODEL); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java new file mode 100644 index 000000000..2debf3c77 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java @@ -0,0 +1,12 @@ +/* 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.background.fxa; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; + +public interface PasswordStretcher { + public byte[] getQuickStretchedPW(byte[] emailUTF8) throws UnsupportedEncodingException, GeneralSecurityException; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java new file mode 100644 index 000000000..bf4b1bc97 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java @@ -0,0 +1,35 @@ +/* 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.background.fxa; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Map; + +import org.mozilla.gecko.sync.Utils; + +public class QuickPasswordStretcher implements PasswordStretcher { + protected final String password; + protected final Map<String, String> cache = new HashMap<String, String>(); + + public QuickPasswordStretcher(String password) { + this.password = password; + } + + @Override + public synchronized byte[] getQuickStretchedPW(byte[] emailUTF8) throws UnsupportedEncodingException, GeneralSecurityException { + if (emailUTF8 == null) { + throw new IllegalArgumentException("emailUTF8 must not be null"); + } + String key = Utils.byte2Hex(emailUTF8); + if (!cache.containsKey(key)) { + byte[] value = FxAccountUtils.generateQuickStretchedPW(emailUTF8, password.getBytes("UTF-8")); + cache.put(key, Utils.byte2Hex(value)); + return value; + } + return Utils.hex2Byte(cache.get(key)); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java new file mode 100644 index 000000000..9d0ad5e03 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java @@ -0,0 +1,111 @@ +/* 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.background.fxa; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.net.Resource; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.impl.cookie.DateParseException; +import ch.boye.httpclientandroidlib.impl.cookie.DateUtils; + +public class SkewHandler { + private static final String LOG_TAG = "SkewHandler"; + protected volatile long skewMillis = 0L; + protected final String hostname; + + private static final HashMap<String, SkewHandler> skewHandlers = new HashMap<String, SkewHandler>(); + + public static SkewHandler getSkewHandlerForResource(final Resource resource) { + return getSkewHandlerForHostname(resource.getHostname()); + } + + public static SkewHandler getSkewHandlerFromEndpointString(final String url) throws URISyntaxException { + if (url == null) { + throw new IllegalArgumentException("url must not be null."); + } + URI u = new URI(url); + return getSkewHandlerForHostname(u.getHost()); + } + + public static synchronized SkewHandler getSkewHandlerForHostname(final String hostname) { + SkewHandler handler = skewHandlers.get(hostname); + if (handler == null) { + handler = new SkewHandler(hostname); + skewHandlers.put(hostname, handler); + } + return handler; + } + + public static synchronized void clearSkewHandlers() { + skewHandlers.clear(); + } + + public SkewHandler(final String hostname) { + this.hostname = hostname; + } + + public boolean updateSkewFromServerMillis(long millis, long now) { + skewMillis = millis - now; + Logger.debug(LOG_TAG, "Updated skew: " + skewMillis + "ms for hostname " + this.hostname); + return true; + } + + public boolean updateSkewFromHTTPDateString(String date, long now) { + try { + final long millis = DateUtils.parseDate(date).getTime(); + return updateSkewFromServerMillis(millis, now); + } catch (DateParseException e) { + Logger.warn(LOG_TAG, "Unexpected: invalid Date header from " + this.hostname); + return false; + } + } + + public boolean updateSkewFromDateHeader(Header header, long now) { + String date = header.getValue(); + if (null == date) { + Logger.warn(LOG_TAG, "Unexpected: null Date header from " + this.hostname); + return false; + } + return updateSkewFromHTTPDateString(date, now); + } + + /** + * Update our tracked skew value to account for the local clock differing from + * the server's. + * + * @param response + * the received HTTP response. + * @param now + * the current time in milliseconds. + * @return true if the skew value was updated, false otherwise. + */ + public boolean updateSkew(HttpResponse response, long now) { + Header header = response.getFirstHeader(HttpHeaders.DATE); + if (null == header) { + Logger.warn(LOG_TAG, "Unexpected: missing Date header from " + this.hostname); + return false; + } + return updateSkewFromDateHeader(header, now); + } + + public long getSkewInMillis() { + return skewMillis; + } + + public long getSkewInSeconds() { + return skewMillis / 1000; + } + + public void resetSkew() { + skewMillis = 0L; + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java new file mode 100644 index 000000000..4bdaa6690 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java @@ -0,0 +1,224 @@ +/* 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.background.fxa.oauth; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Locale; +import java.util.concurrent.Executor; + +import org.mozilla.gecko.background.fxa.FxAccountClientException; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientMalformedResponseException; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.Resource; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +public abstract class FxAccountAbstractClient { + protected static final String LOG_TAG = FxAccountAbstractClient.class.getSimpleName(); + + protected static final String ACCEPT_HEADER = "application/json;charset=utf-8"; + protected static final String AUTHORIZATION_RESPONSE_TYPE = "token"; + + public static final String JSON_KEY_ERROR = "error"; + public static final String JSON_KEY_MESSAGE = "message"; + public static final String JSON_KEY_CODE = "code"; + public static final String JSON_KEY_ERRNO = "errno"; + + protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE }; + protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO }; + + /** + * The server's URI. + * <p> + * We assume throughout that this ends with a trailing slash (and guarantee as + * much in the constructor). + */ + protected final String serverURI; + + protected final Executor executor; + + public FxAccountAbstractClient(String serverURI, Executor executor) { + if (serverURI == null) { + throw new IllegalArgumentException("Must provide a server URI."); + } + if (executor == null) { + throw new IllegalArgumentException("Must provide a non-null executor."); + } + this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/"; + if (!this.serverURI.endsWith("/")) { + throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI); + } + this.executor = executor; + } + + /** + * Process a typed value extracted from a successful response (in an + * endpoint-dependent way). + */ + public interface RequestDelegate<T> { + public void handleError(Exception e); + public void handleFailure(FxAccountAbstractClientRemoteException e); + public void handleSuccess(T result); + } + + /** + * Intepret a response from the auth server. + * <p> + * Throw an appropriate exception on errors; otherwise, return the response's + * status code. + * + * @return response's HTTP status code. + * @throws FxAccountClientException + */ + public static int validateResponse(HttpResponse response) throws FxAccountAbstractClientRemoteException { + final int status = response.getStatusLine().getStatusCode(); + if (status == 200) { + return status; + } + int code; + int errno; + String error; + String message; + ExtendedJSONObject body; + try { + body = new SyncStorageResponse(response).jsonObjectBody(); + body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class); + body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class); + code = body.getLong(JSON_KEY_CODE).intValue(); + errno = body.getLong(JSON_KEY_ERRNO).intValue(); + error = body.getString(JSON_KEY_ERROR); + message = body.getString(JSON_KEY_MESSAGE); + } catch (Exception e) { + throw new FxAccountAbstractClientMalformedResponseException(response); + } + throw new FxAccountAbstractClientRemoteException(response, code, errno, error, message, body); + } + + protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleError(e); + } + }); + } + + protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody, final RequestDelegate<T> delegate) { + try { + if (requestBody == null) { + resource.post((HttpEntity) null); + } else { + resource.post(requestBody); + } + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + } + + /** + * Translate resource callbacks into request callbacks invoked on the provided + * executor. + * <p> + * Override <code>handleSuccess</code> to parse the body of the resource + * request and call the request callback. <code>handleSuccess</code> is + * invoked via the executor, so you don't need to delegate further. + */ + protected abstract class ResourceDelegate<T> extends BaseResourceDelegate { + protected abstract void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body); + + protected final RequestDelegate<T> delegate; + + /** + * Create a delegate for an un-authenticated resource. + */ + public ResourceDelegate(final Resource resource, final RequestDelegate<T> delegate) { + super(resource); + this.delegate = delegate; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return super.getAuthHeaderProvider(); + } + + @Override + public String getUserAgent() { + return FxAccountConstants.USER_AGENT; + } + + @Override + public void handleHttpResponse(HttpResponse response) { + try { + final int status = validateResponse(response); + invokeHandleSuccess(status, response); + } catch (FxAccountAbstractClientRemoteException e) { + invokeHandleFailure(e); + } + } + + protected void invokeHandleFailure(final FxAccountAbstractClientRemoteException e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleFailure(e); + } + }); + } + + protected void invokeHandleSuccess(final int status, final HttpResponse response) { + executor.execute(new Runnable() { + @Override + public void run() { + try { + ExtendedJSONObject body = new SyncResponse(response).jsonObjectBody(); + ResourceDelegate.this.handleSuccess(status, response, body); + } catch (Exception e) { + delegate.handleError(e); + } + } + }); + } + + @Override + public void handleHttpProtocolException(final ClientProtocolException e) { + invokeHandleError(delegate, e); + } + + @Override + public void handleHttpIOException(IOException e) { + invokeHandleError(delegate, e); + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + invokeHandleError(delegate, e); + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + super.addHeaders(request, client); + + // The basics. + final Locale locale = Locale.getDefault(); + request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale)); + request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java new file mode 100644 index 000000000..21025af0a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java @@ -0,0 +1,68 @@ +/* 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.background.fxa.oauth; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.HttpStatus; + +/** + * From <a href="https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md">https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md</a>. + */ +public class FxAccountAbstractClientException extends Exception { + private static final long serialVersionUID = 1953459541558266597L; + + public FxAccountAbstractClientException(String detailMessage) { + super(detailMessage); + } + + public FxAccountAbstractClientException(Exception e) { + super(e); + } + + public static class FxAccountAbstractClientRemoteException extends FxAccountAbstractClientException { + private static final long serialVersionUID = 1209313149952001097L; + + public final HttpResponse response; + public final long httpStatusCode; + public final long apiErrorNumber; + public final String error; + public final String message; + public final ExtendedJSONObject body; + + public FxAccountAbstractClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, ExtendedJSONObject body) { + super(new HTTPFailureException(new SyncStorageResponse(response))); + if (body == null) { + throw new IllegalArgumentException("body must not be null"); + } + this.response = response; + this.httpStatusCode = httpStatusCode; + this.apiErrorNumber = apiErrorNumber; + this.error = error; + this.message = message; + this.body = body; + } + + @Override + public String toString() { + return "<FxAccountAbstractClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">"; + } + + public boolean isInvalidAuthentication() { + return this.httpStatusCode == HttpStatus.SC_UNAUTHORIZED; + } + } + + public static class FxAccountAbstractClientMalformedResponseException extends FxAccountAbstractClientRemoteException { + private static final long serialVersionUID = 1209313149952001098L; + + public FxAccountAbstractClientMalformedResponseException(HttpResponse response) { + super(response, 0, FxAccountOAuthRemoteError.UNKNOWN_ERROR, "Response malformed", "Response malformed", new ExtendedJSONObject()); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java new file mode 100644 index 000000000..4f233695b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java @@ -0,0 +1,129 @@ +/* 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.background.fxa.oauth; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.Executor; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.BaseResource; + +import ch.boye.httpclientandroidlib.HttpResponse; + +/** + * Talk to an fxa-oauth-server to get "implicitly granted" OAuth tokens. + * <p> + * To use this client, you will need a pre-allocated fxa-oauth-server + * "client_id" with special "implicit grant" permissions. + * <p> + * This client was written against the API documented at <a href="https://github.com/mozilla/fxa-oauth-server/blob/41538990df9e91158558ae5a8115194383ac3b05/docs/api.md">https://github.com/mozilla/fxa-oauth-server/blob/41538990df9e91158558ae5a8115194383ac3b05/docs/api.md</a>. + */ +public class FxAccountOAuthClient10 extends FxAccountAbstractClient { + protected static final String LOG_TAG = FxAccountOAuthClient10.class.getSimpleName(); + + protected static final String AUTHORIZATION_RESPONSE_TYPE = "token"; + + protected static final String JSON_KEY_ACCESS_TOKEN = "access_token"; + protected static final String JSON_KEY_ASSERTION = "assertion"; + protected static final String JSON_KEY_CLIENT_ID = "client_id"; + protected static final String JSON_KEY_RESPONSE_TYPE = "response_type"; + protected static final String JSON_KEY_SCOPE = "scope"; + protected static final String JSON_KEY_STATE = "state"; + protected static final String JSON_KEY_TOKEN = "token"; + protected static final String JSON_KEY_TOKEN_TYPE = "token_type"; + + // access_token: A string that can be used for authorized requests to service providers. + // scope: A string of space-separated permissions that this token has. May differ from requested scopes, since user can deny permissions. + // token_type: A string representing the token type. Currently will always be "bearer". + protected static final String[] AUTHORIZATION_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_ACCESS_TOKEN, JSON_KEY_SCOPE, JSON_KEY_TOKEN_TYPE }; + + public FxAccountOAuthClient10(String serverURI, Executor executor) { + super(serverURI, executor); + } + + /** + * Thin container for an authorization response. + */ + public static class AuthorizationResponse { + public final String access_token; + public final String token_type; + public final String scope; + + public AuthorizationResponse(String access_token, String token_type, String scope) { + this.access_token = access_token; + this.token_type = token_type; + this.scope = scope; + } + } + + public void authorization(String client_id, String assertion, String state, String scope, + RequestDelegate<AuthorizationResponse> delegate) { + final BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "authorization")); + } catch (URISyntaxException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<AuthorizationResponse>(resource, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + body.throwIfFieldsMissingOrMisTyped(AUTHORIZATION_RESPONSE_REQUIRED_STRING_FIELDS, String.class); + String access_token = body.getString(JSON_KEY_ACCESS_TOKEN); + String token_type = body.getString(JSON_KEY_TOKEN_TYPE); + String scope = body.getString(JSON_KEY_SCOPE); + delegate.handleSuccess(new AuthorizationResponse(access_token, token_type, scope)); + return; + } catch (Exception e) { + delegate.handleError(e); + return; + } + } + }; + + final ExtendedJSONObject requestBody = new ExtendedJSONObject(); + requestBody.put(JSON_KEY_RESPONSE_TYPE, AUTHORIZATION_RESPONSE_TYPE); + requestBody.put(JSON_KEY_CLIENT_ID, client_id); + requestBody.put(JSON_KEY_ASSERTION, assertion); + if (scope != null) { + requestBody.put(JSON_KEY_SCOPE, scope); + } + if (state != null) { + requestBody.put(JSON_KEY_STATE, state); + } + + post(resource, requestBody, delegate); + } + + public void deleteToken(final String token, final RequestDelegate<Void> delegate) { + final BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "destroy")); + } catch (URISyntaxException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<Void>(resource, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + delegate.handleSuccess(null); + return; + } catch (Exception e) { + delegate.handleError(e); + return; + } + } + }; + + final ExtendedJSONObject requestBody = new ExtendedJSONObject(); + requestBody.put(JSON_KEY_TOKEN, token); + post(resource, requestBody, delegate); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java new file mode 100644 index 000000000..d949d316b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java @@ -0,0 +1,19 @@ +/* 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.background.fxa.oauth; + +public interface FxAccountOAuthRemoteError { + public static final int ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS = 101; + public static final int UNKNOWN_CLIENT_ID = 101; + public static final int INCORRECT_CLIENT_SECRET = 102; + public static final int REDIRECT_URI_DOES_NOT_MATCH_REGISTERED_VALUE = 103; + public static final int INVALID_FXA_ASSERTION = 104; + public static final int UNKNOWN_CODE = 105; + public static final int INCORRECT_CODE = 106; + public static final int EXPIRED_CODE = 107; + public static final int INVALID_TOKEN = 108; + public static final int INVALID_REQUEST_PARAMETER = 109; + public static final int UNKNOWN_ERROR = 999; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java new file mode 100644 index 000000000..cb851a8db --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java @@ -0,0 +1,59 @@ +/* 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.background.fxa.profile; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.Executor; + +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider; + +import ch.boye.httpclientandroidlib.HttpResponse; + + +/** + * Talk to an fxa-profile-server to get profile information like name, age, gender, and avatar image. + * <p> + * This client was written against the API documented at <a href="https://github.com/mozilla/fxa-profile-server/blob/0c065619f5a2e867f813a343b4c67da3fe2c82a4/docs/API.md">https://github.com/mozilla/fxa-profile-server/blob/0c065619f5a2e867f813a343b4c67da3fe2c82a4/docs/API.md</a>. + */ +public class FxAccountProfileClient10 extends FxAccountAbstractClient { + public FxAccountProfileClient10(String serverURI, Executor executor) { + super(serverURI, executor); + } + + public void profile(final String token, RequestDelegate<ExtendedJSONObject> delegate) { + BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "profile")); + } catch (URISyntaxException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<ExtendedJSONObject>(resource, delegate) { + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return new BearerAuthHeaderProvider(token); + } + + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + delegate.handleSuccess(body); + return; + } catch (Exception e) { + delegate.handleError(e); + return; + } + } + }; + + resource.get(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java new file mode 100644 index 000000000..25f0f84d9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java @@ -0,0 +1,60 @@ +/* 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.background.nativecode; + +import java.security.GeneralSecurityException; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants; + +import android.util.Log; + +@RobocopTarget +public class NativeCrypto { + static { + try { + System.loadLibrary("mozglue"); + } catch (UnsatisfiedLinkError e) { + Log.wtf("NativeCrypto", "Couldn't load mozglue. Trying /data/app-lib path."); + try { + System.load("/data/app-lib/" + AppConstants.ANDROID_PACKAGE_NAME + "/libmozglue.so"); + } catch (Throwable ee) { + try { + Log.wtf("NativeCrypto", "Couldn't load mozglue: " + ee + ". Trying /data/data path."); + System.load("/data/data/" + AppConstants.ANDROID_PACKAGE_NAME + "/lib/libmozglue.so"); + } catch (UnsatisfiedLinkError eee) { + Log.wtf("NativeCrypto", "Failed every attempt to load mozglue. Giving up."); + throw new RuntimeException("Unable to load mozglue", eee); + } + } + } + } + + /** + * Wrapper to perform PBKDF2-HMAC-SHA-256 in native code. + */ + public native static byte[] pbkdf2SHA256(byte[] password, byte[] salt, int c, int dkLen) + throws GeneralSecurityException; + + /** + * Wrapper to perform SHA-1 in native code. + */ + public native static byte[] sha1(byte[] str); + + /** + * Wrapper to perform SHA-256 init in native code. Returns a SHA-256 context. + */ + public native static byte[] sha256init(); + + /** + * Wrapper to update a SHA-256 context in native code. + */ + public native static void sha256update(byte[] ctx, byte[] str, int len); + + /** + * Wrapper to finalize a SHA-256 context in native code. Returns digest. + */ + public native static byte[] sha256finalize(byte[] ctx); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java new file mode 100644 index 000000000..5bc5422c8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2013 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 org.mozilla.gecko.background.preferences; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.WeakReferenceHandler; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.preference.Preference; +import android.preference.PreferenceGroup; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.support.v4.app.Fragment; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnKeyListener; +import android.view.ViewGroup; +import android.widget.ListView; + +public abstract class PreferenceFragment extends Fragment implements PreferenceManagerCompat.OnPreferenceTreeClickListener { + private static final String PREFERENCES_TAG = "android:preferences"; + + private PreferenceManager mPreferenceManager; + private ListView mList; + private boolean mHavePrefs; + private boolean mInitDone; + + /** + * The starting request code given out to preference framework. + */ + private static final int FIRST_REQUEST_CODE = 100; + + private static final int MSG_BIND_PREFERENCES = 1; + + private static class PreferenceFragmentHandler extends WeakReferenceHandler<PreferenceFragment> { + public PreferenceFragmentHandler(final PreferenceFragment that) { + super(that); + } + + @Override + public void handleMessage(Message msg) { + final PreferenceFragment that = mTarget.get(); + if (that == null) { + return; + } + + switch (msg.what) { + + case MSG_BIND_PREFERENCES: + that.bindPreferences(); + break; + } + } + } + + private final Handler mHandler = new PreferenceFragmentHandler(this); + + final private Runnable mRequestFocus = new Runnable() { + @Override + public void run() { + mList.focusableViewAvailable(mList); + } + }; + + /** + * Interface that PreferenceFragment's containing activity should + * implement to be able to process preference items that wish to + * switch to a new fragment. + */ + public interface OnPreferenceStartFragmentCallback { + /** + * Called when the user has clicked on a Preference that has + * a fragment class name associated with it. The implementation + * to should instantiate and switch to an instance of the given + * fragment. + */ + boolean onPreferenceStartFragment(PreferenceFragment caller, Preference pref); + } + + @Override + public void onCreate(Bundle paramBundle) { + super.onCreate(paramBundle); + mPreferenceManager = PreferenceManagerCompat.newInstance(getActivity(), FIRST_REQUEST_CODE); + PreferenceManagerCompat.setFragment(mPreferenceManager, this); + } + + @Override + public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle) { + return paramLayoutInflater.inflate(R.layout.fxaccount_preference_list_fragment, paramViewGroup, + false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (mHavePrefs) { + bindPreferences(); + } + + mInitDone = true; + + if (savedInstanceState != null) { + Bundle container = savedInstanceState.getBundle(PREFERENCES_TAG); + if (container != null) { + final PreferenceScreen preferenceScreen = getPreferenceScreen(); + if (preferenceScreen != null) { + preferenceScreen.restoreHierarchyState(container); + } + } + } + } + + @Override + public void onStart() { + super.onStart(); + PreferenceManagerCompat.setOnPreferenceTreeClickListener(mPreferenceManager, this); + } + + @Override + public void onStop() { + super.onStop(); + PreferenceManagerCompat.dispatchActivityStop(mPreferenceManager); + PreferenceManagerCompat.setOnPreferenceTreeClickListener(mPreferenceManager, null); + } + + @Override + public void onDestroyView() { + mList = null; + mHandler.removeCallbacks(mRequestFocus); + mHandler.removeMessages(MSG_BIND_PREFERENCES); + super.onDestroyView(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + PreferenceManagerCompat.dispatchActivityDestroy(mPreferenceManager); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + final PreferenceScreen preferenceScreen = getPreferenceScreen(); + if (preferenceScreen != null) { + Bundle container = new Bundle(); + preferenceScreen.saveHierarchyState(container); + outState.putBundle(PREFERENCES_TAG, container); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + PreferenceManagerCompat.dispatchActivityResult(mPreferenceManager, requestCode, resultCode, data); + } + + /** + * Returns the {@link PreferenceManager} used by this fragment. + * @return The {@link PreferenceManager}. + */ + public PreferenceManager getPreferenceManager() { + return mPreferenceManager; + } + + /** + * Sets the root of the preference hierarchy that this fragment is showing. + * + * @param preferenceScreen The root {@link PreferenceScreen} of the preference hierarchy. + */ + public void setPreferenceScreen(PreferenceScreen preferenceScreen) { + if (PreferenceManagerCompat.setPreferences(mPreferenceManager, preferenceScreen) && preferenceScreen != null) { + mHavePrefs = true; + if (mInitDone) { + postBindPreferences(); + } + } + } + + /** + * Gets the root of the preference hierarchy that this fragment is showing. + * + * @return The {@link PreferenceScreen} that is the root of the preference + * hierarchy. + */ + public PreferenceScreen getPreferenceScreen() { + return PreferenceManagerCompat.getPreferenceScreen(mPreferenceManager); + } + + /** + * Adds preferences from activities that match the given {@link Intent}. + * + * @param intent The {@link Intent} to query activities. + */ + public void addPreferencesFromIntent(Intent intent) { + requirePreferenceManager(); + + setPreferenceScreen(PreferenceManagerCompat.inflateFromIntent(mPreferenceManager, intent, getPreferenceScreen())); + } + + /** + * Inflates the given XML resource and adds the preference hierarchy to the current + * preference hierarchy. + * + * @param preferencesResId The XML resource ID to inflate. + */ + public void addPreferencesFromResource(int preferencesResId) { + requirePreferenceManager(); + + setPreferenceScreen(PreferenceManagerCompat.inflateFromResource(mPreferenceManager, getActivity(), + preferencesResId, getPreferenceScreen())); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, + Preference preference) { + //if (preference.getFragment() != null && + if ( + getActivity() instanceof OnPreferenceStartFragmentCallback) { + return ((OnPreferenceStartFragmentCallback)getActivity()).onPreferenceStartFragment( + this, preference); + } + return false; + } + + /** + * Finds a {@link Preference} based on its key. + * + * @param key The key of the preference to retrieve. + * @return The {@link Preference} with the key, or null. + * @see PreferenceGroup#findPreference(CharSequence) + */ + public Preference findPreference(CharSequence key) { + if (mPreferenceManager == null) { + return null; + } + return mPreferenceManager.findPreference(key); + } + + private void requirePreferenceManager() { + if (mPreferenceManager == null) { + throw new RuntimeException("This should be called after super.onCreate."); + } + } + + private void postBindPreferences() { + if (mHandler.hasMessages(MSG_BIND_PREFERENCES)) return; + mHandler.obtainMessage(MSG_BIND_PREFERENCES).sendToTarget(); + } + + private void bindPreferences() { + final PreferenceScreen preferenceScreen = getPreferenceScreen(); + if (preferenceScreen != null) { + preferenceScreen.bind(getListView()); + } + } + + public ListView getListView() { + ensureList(); + return mList; + } + + private void ensureList() { + if (mList != null) { + return; + } + View root = getView(); + if (root == null) { + throw new IllegalStateException("Content view not yet created"); + } + View rawListView = root.findViewById(android.R.id.list); + if (!(rawListView instanceof ListView)) { + throw new RuntimeException( + "Content has view with id attribute 'android.R.id.list' " + + "that is not a ListView class"); + } + mList = (ListView)rawListView; + if (mList == null) { + throw new RuntimeException( + "Your content must have a ListView whose id attribute is " + + "'android.R.id.list'"); + } + mList.setOnKeyListener(mListOnKeyListener); + mHandler.post(mRequestFocus); + } + + private final OnKeyListener mListOnKeyListener = new OnKeyListener() { + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + Object selectedItem = mList.getSelectedItem(); + if (selectedItem instanceof Preference) { + @SuppressWarnings("unused") + View selectedView = mList.getSelectedView(); + //return ((Preference)selectedItem).onKey( + // selectedView, keyCode, event); + return false; + } + return false; + } + + }; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java new file mode 100644 index 000000000..22c62e431 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2013 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 org.mozilla.gecko.background.preferences; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.preference.Preference; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.util.Log; + +public class PreferenceManagerCompat { + + private static final String TAG = PreferenceManagerCompat.class.getSimpleName(); + + /** + * Interface definition for a callback to be invoked when a {@link Preference} in the hierarchy + * rooted at this {@link PreferenceScreen} is clicked. + */ + interface OnPreferenceTreeClickListener { + /** + * Called when a preference in the tree rooted at this {@link PreferenceScreen} has been + * clicked. + * + * @param preferenceScreen The {@link PreferenceScreen} that the preference is located in. + * @param preference The preference that was clicked. + * + * @return Whether the click was handled. + */ + boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference); + } + + static PreferenceManager newInstance(Activity activity, int firstRequestCode) { + try { + Constructor<PreferenceManager> c = PreferenceManager.class.getDeclaredConstructor(Activity.class, int.class); + c.setAccessible(true); + return c.newInstance(activity, firstRequestCode); + } catch (Exception e) { + Log.w(TAG, "Couldn't call constructor PreferenceManager by reflection", e); + } + return null; + } + + /** + * Sets the owning preference fragment + */ + static void setFragment(PreferenceManager manager, PreferenceFragment fragment) { + // stub + } + + /** + * Sets the callback to be invoked when a {@link Preference} in the hierarchy rooted at this + * {@link PreferenceManager} is clicked. + * + * @param listener The callback to be invoked. + */ + static void setOnPreferenceTreeClickListener(PreferenceManager manager, final OnPreferenceTreeClickListener listener) { + try { + Field onPreferenceTreeClickListener = PreferenceManager.class.getDeclaredField("mOnPreferenceTreeClickListener"); + onPreferenceTreeClickListener.setAccessible(true); + if (listener != null) { + Object proxy = Proxy.newProxyInstance( + onPreferenceTreeClickListener.getType().getClassLoader(), + new Class<?>[] { onPreferenceTreeClickListener.getType() }, + new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + if (method.getName().equals("onPreferenceTreeClick")) { + return listener.onPreferenceTreeClick((PreferenceScreen) args[0], (Preference) args[1]); + } else { + return null; + } + } + }); + onPreferenceTreeClickListener.set(manager, proxy); + } else { + onPreferenceTreeClickListener.set(manager, null); + } + } catch (Exception e) { + Log.w(TAG, "Couldn't set PreferenceManager.mOnPreferenceTreeClickListener by reflection", e); + } + } + + /** + * Inflates a preference hierarchy from the preference hierarchies of {@link Activity Activities} + * that match the given {@link Intent}. An {@link Activity} defines its preference hierarchy with + * meta-data using the {@link #METADATA_KEY_PREFERENCES} key. + * <p/> + * If a preference hierarchy is given, the new preference hierarchies will be merged in. + * + * @param queryIntent The intent to match activities. + * @param rootPreferences Optional existing hierarchy to merge the new hierarchies into. + * + * @return The root hierarchy (if one was not provided, the new hierarchy's root). + */ + static PreferenceScreen inflateFromIntent(PreferenceManager manager, Intent intent, PreferenceScreen screen) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("inflateFromIntent", Intent.class, PreferenceScreen.class); + m.setAccessible(true); + PreferenceScreen prefScreen = (PreferenceScreen) m.invoke(manager, intent, screen); + return prefScreen; + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.inflateFromIntent by reflection", e); + } + return null; + } + + /** + * Inflates a preference hierarchy from XML. If a preference hierarchy is given, the new + * preference hierarchies will be merged in. + * + * @param context The context of the resource. + * @param resId The resource ID of the XML to inflate. + * @param rootPreferences Optional existing hierarchy to merge the new hierarchies into. + * + * @return The root hierarchy (if one was not provided, the new hierarchy's root). + * + * @hide + */ + static PreferenceScreen inflateFromResource(PreferenceManager manager, Activity activity, int resId, PreferenceScreen screen) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("inflateFromResource", Context.class, int.class, PreferenceScreen.class); + m.setAccessible(true); + PreferenceScreen prefScreen = (PreferenceScreen) m.invoke(manager, activity, resId, screen); + return prefScreen; + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.inflateFromResource by reflection", e); + } + return null; + } + + /** + * Returns the root of the preference hierarchy managed by this class. + * + * @return The {@link PreferenceScreen} object that is at the root of the hierarchy. + */ + static PreferenceScreen getPreferenceScreen(PreferenceManager manager) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("getPreferenceScreen"); + m.setAccessible(true); + return (PreferenceScreen) m.invoke(manager); + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.getPreferenceScreen by reflection", e); + } + return null; + } + + /** + * Called by the {@link PreferenceManager} to dispatch a subactivity result. + */ + static void dispatchActivityResult(PreferenceManager manager, int requestCode, int resultCode, Intent data) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityResult", int.class, int.class, Intent.class); + m.setAccessible(true); + m.invoke(manager, requestCode, resultCode, data); + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityResult by reflection", e); + } + } + + /** + * Called by the {@link PreferenceManager} to dispatch the activity stop event. + */ + static void dispatchActivityStop(PreferenceManager manager) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityStop"); + m.setAccessible(true); + m.invoke(manager); + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityStop by reflection", e); + } + } + + /** + * Called by the {@link PreferenceManager} to dispatch the activity destroy event. + */ + static void dispatchActivityDestroy(PreferenceManager manager) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityDestroy"); + m.setAccessible(true); + m.invoke(manager); + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityDestroy by reflection", e); + } + } + + /** + * Sets the root of the preference hierarchy. + * + * @param preferenceScreen The root {@link PreferenceScreen} of the preference hierarchy. + * + * @return Whether the {@link PreferenceScreen} given is different than the previous. + */ + static boolean setPreferences(PreferenceManager manager, PreferenceScreen screen) { + try { + Method m = PreferenceManager.class.getDeclaredMethod("setPreferences", PreferenceScreen.class); + m.setAccessible(true); + return ((Boolean) m.invoke(manager, screen)); + } catch (Exception e) { + Log.w(TAG, "Couldn't call PreferenceManager.setPreferences by reflection", e); + } + return false; + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java new file mode 100644 index 000000000..b032067c5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java @@ -0,0 +1,82 @@ +/* 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.browserid; + + +/** + * Java produces signature in ASN.1 format. Here's some hard-coded encoding and decoding + * code, courtesy of a comment in + * <a href="http://stackoverflow.com/questions/10921733/how-sign-method-of-the-digital-signature-combines-the-r-s-values-in-to-array">http://stackoverflow.com/questions/10921733/how-sign-method-of-the-digital-signature-combines-the-r-s-values-in-to-array</a>. + */ +public class ASNUtils { + /** + * Decode two short arrays from ASN.1 bytes. + * @param input to extract. + * @return length 2 array of byte arrays. + */ + public static byte[][] decodeTwoArraysFromASN1(byte[] input) throws IllegalArgumentException { + if (input == null) { + throw new IllegalArgumentException("input must not be null"); + } + if (input.length <= 3) + throw new IllegalArgumentException("bad length"); + if (input[0] != 0x30) + throw new IllegalArgumentException("bad encoding"); + if ((input[1] & ((byte) 0x80)) != 0) + throw new IllegalArgumentException("bad length encoding"); + if (input[2] != 0x02) + throw new IllegalArgumentException("bad encoding"); + if ((input[3] & ((byte) 0x80)) != 0) + throw new IllegalArgumentException("bad length encoding"); + byte rLength = input[3]; + if (input.length <= 5 + rLength) + throw new IllegalArgumentException("bad length"); + if (input[4 + rLength] != 0x02) + throw new IllegalArgumentException("bad encoding"); + if ((input[5 + rLength] & (byte) 0x80) !=0) + throw new IllegalArgumentException("bad length encoding"); + byte sLength = input[5 + rLength]; + if (input.length != 6 + sLength + rLength) + throw new IllegalArgumentException("bad length"); + byte[] rArr = new byte[rLength]; + byte[] sArr = new byte[sLength]; + System.arraycopy(input, 4, rArr, 0, rLength); + System.arraycopy(input, 6 + rLength, sArr, 0, sLength); + return new byte[][] { rArr, sArr }; + } + + /** + * Encode two short arrays into ASN.1 bytes. + * @param first array to encode. + * @param second array to encode. + * @return array. + */ + public static byte[] encodeTwoArraysToASN1(byte[] first, byte[] second) throws IllegalArgumentException { + if (first == null) { + throw new IllegalArgumentException("first must not be null"); + } + if (second == null) { + throw new IllegalArgumentException("second must not be null"); + } + byte[] output = new byte[6 + first.length + second.length]; + output[0] = 0x30; + if (4 + first.length + second.length > 255) + throw new IllegalArgumentException("bad length"); + output[1] = (byte) (4 + first.length + second.length); + if ((output[1] & ((byte) 0x80)) != 0) + throw new IllegalArgumentException("bad length encoding"); + output[2] = 0x02; + output[3] = (byte) first.length; + if ((output[3] & ((byte) 0x80)) != 0) + throw new IllegalArgumentException("bad length encoding"); + System.arraycopy(first, 0, output, 4, first.length); + output[4 + first.length] = 0x02; + output[5 + first.length] = (byte) second.length; + if ((output[5 + first.length] & ((byte) 0x80)) != 0) + throw new IllegalArgumentException("bad length encoding"); + System.arraycopy(second, 0, output, 6 + first.length, second.length); + return output; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java new file mode 100644 index 000000000..7283a0299 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java @@ -0,0 +1,35 @@ +/* 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.browserid; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public class BrowserIDKeyPair { + public static final String JSON_KEY_PRIVATEKEY = "privateKey"; + public static final String JSON_KEY_PUBLICKEY = "publicKey"; + + protected final SigningPrivateKey privateKey; + protected final VerifyingPublicKey publicKey; + + public BrowserIDKeyPair(SigningPrivateKey privateKey, VerifyingPublicKey publicKey) { + this.privateKey = privateKey; + this.publicKey = publicKey; + } + + public SigningPrivateKey getPrivate() { + return this.privateKey; + } + + public VerifyingPublicKey getPublic() { + return this.publicKey; + } + + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put(JSON_KEY_PRIVATEKEY, privateKey.toJSONObject()); + o.put(JSON_KEY_PUBLICKEY, publicKey.toJSONObject()); + return o; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java new file mode 100644 index 000000000..a04a89c8e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java @@ -0,0 +1,255 @@ +/* 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.browserid; + +import android.annotation.SuppressLint; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.PRNGFixes; + +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.security.interfaces.DSAParams; +import java.security.interfaces.DSAPrivateKey; +import java.security.interfaces.DSAPublicKey; +import java.security.spec.DSAPrivateKeySpec; +import java.security.spec.DSAPublicKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; + +public class DSACryptoImplementation { + private static final String LOG_TAG = DSACryptoImplementation.class.getSimpleName(); + + public static final String SIGNATURE_ALGORITHM = "SHA1withDSA"; + public static final int SIGNATURE_LENGTH_BYTES = 40; // DSA signatures are always 40 bytes long. + + /** + * Parameters are serialized as hex strings. Hex-versus-decimal was + * reverse-engineered from what the Persona public verifier accepted. We + * expect to follow the JOSE/JWT spec as it solidifies, and that will probably + * mean unifying this base. + */ + protected static final int SERIALIZATION_BASE = 16; + + protected static class DSAVerifyingPublicKey implements VerifyingPublicKey { + protected final DSAPublicKey publicKey; + + public DSAVerifyingPublicKey(DSAPublicKey publicKey) { + this.publicKey = publicKey; + } + + /** + * Serialize to a JSON object. + * <p> + * Parameters are serialized as hex strings. Hex-versus-decimal was + * reverse-engineered from what the Persona public verifier accepted. + */ + @Override + public ExtendedJSONObject toJSONObject() { + DSAParams params = publicKey.getParams(); + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("algorithm", "DS"); + o.put("y", publicKey.getY().toString(SERIALIZATION_BASE)); + o.put("g", params.getG().toString(SERIALIZATION_BASE)); + o.put("p", params.getP().toString(SERIALIZATION_BASE)); + o.put("q", params.getQ().toString(SERIALIZATION_BASE)); + return o; + } + + @Override + public boolean verifyMessage(byte[] bytes, byte[] signature) + throws GeneralSecurityException { + if (bytes == null) { + throw new IllegalArgumentException("bytes must not be null"); + } + if (signature == null) { + throw new IllegalArgumentException("signature must not be null"); + } + if (signature.length != SIGNATURE_LENGTH_BYTES) { + return false; + } + byte[] first = new byte[signature.length / 2]; + byte[] second = new byte[signature.length / 2]; + System.arraycopy(signature, 0, first, 0, first.length); + System.arraycopy(signature, first.length, second, 0, second.length); + BigInteger r = new BigInteger(Utils.byte2Hex(first), 16); + BigInteger s = new BigInteger(Utils.byte2Hex(second), 16); + // This is awful, but encoding an extra 0 byte works better on devices. + byte[] encoded = ASNUtils.encodeTwoArraysToASN1( + Utils.hex2Byte(r.toString(16), 1 + SIGNATURE_LENGTH_BYTES / 2), + Utils.hex2Byte(s.toString(16), 1 + SIGNATURE_LENGTH_BYTES / 2)); + + final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM); + signer.initVerify(publicKey); + signer.update(bytes); + return signer.verify(encoded); + } + } + + protected static class DSASigningPrivateKey implements SigningPrivateKey { + protected final DSAPrivateKey privateKey; + + public DSASigningPrivateKey(DSAPrivateKey privateKey) { + this.privateKey = privateKey; + } + + @Override + public String getAlgorithm() { + return "DS" + (privateKey.getParams().getP().bitLength() + 7)/8; + } + + /** + * Serialize to a JSON object. + * <p> + * Parameters are serialized as decimal strings. Hex-versus-decimal was + * reverse-engineered from what the Persona public verifier accepted. + */ + @Override + public ExtendedJSONObject toJSONObject() { + DSAParams params = privateKey.getParams(); + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("algorithm", "DS"); + o.put("x", privateKey.getX().toString(SERIALIZATION_BASE)); + o.put("g", params.getG().toString(SERIALIZATION_BASE)); + o.put("p", params.getP().toString(SERIALIZATION_BASE)); + o.put("q", params.getQ().toString(SERIALIZATION_BASE)); + return o; + } + + @SuppressLint("TrulyRandom") + @Override + public byte[] signMessage(byte[] bytes) + throws GeneralSecurityException { + if (bytes == null) { + throw new IllegalArgumentException("bytes must not be null"); + } + + try { + PRNGFixes.apply(); + } catch (Exception e) { + // Not much to be done here: it was weak before, and we couldn't patch it, so it's weak now. Not worth aborting. + Logger.error(LOG_TAG, "Got exception applying PRNGFixes! Cryptographic data produced on this device may be weak. Ignoring.", e); + } + + final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM); + signer.initSign(privateKey); + signer.update(bytes); + final byte[] signature = signer.sign(); + + final byte[][] arrays = ASNUtils.decodeTwoArraysFromASN1(signature); + BigInteger r = new BigInteger(arrays[0]); + BigInteger s = new BigInteger(arrays[1]); + // This is awful, but signatures are always 40 bytes long. + byte[] decoded = Utils.concatAll( + Utils.hex2Byte(r.toString(16), SIGNATURE_LENGTH_BYTES / 2), + Utils.hex2Byte(s.toString(16), SIGNATURE_LENGTH_BYTES / 2)); + return decoded; + } + } + + public static BrowserIDKeyPair generateKeyPair(int keysize) + throws NoSuchAlgorithmException { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA"); + keyPairGenerator.initialize(keysize); + final KeyPair keyPair = keyPairGenerator.generateKeyPair(); + DSAPrivateKey privateKey = (DSAPrivateKey) keyPair.getPrivate(); + DSAPublicKey publicKey = (DSAPublicKey) keyPair.getPublic(); + return new BrowserIDKeyPair(new DSASigningPrivateKey(privateKey), new DSAVerifyingPublicKey(publicKey)); + } + + public static SigningPrivateKey createPrivateKey(BigInteger x, BigInteger p, BigInteger q, BigInteger g) throws NoSuchAlgorithmException, InvalidKeySpecException { + if (x == null) { + throw new IllegalArgumentException("x must not be null"); + } + if (p == null) { + throw new IllegalArgumentException("p must not be null"); + } + if (q == null) { + throw new IllegalArgumentException("q must not be null"); + } + if (g == null) { + throw new IllegalArgumentException("g must not be null"); + } + KeySpec keySpec = new DSAPrivateKeySpec(x, p, q, g); + KeyFactory keyFactory = KeyFactory.getInstance("DSA"); + DSAPrivateKey privateKey = (DSAPrivateKey) keyFactory.generatePrivate(keySpec); + return new DSASigningPrivateKey(privateKey); + } + + public static VerifyingPublicKey createPublicKey(BigInteger y, BigInteger p, BigInteger q, BigInteger g) throws NoSuchAlgorithmException, InvalidKeySpecException { + if (y == null) { + throw new IllegalArgumentException("n must not be null"); + } + if (p == null) { + throw new IllegalArgumentException("p must not be null"); + } + if (q == null) { + throw new IllegalArgumentException("q must not be null"); + } + if (g == null) { + throw new IllegalArgumentException("g must not be null"); + } + KeySpec keySpec = new DSAPublicKeySpec(y, p, q, g); + KeyFactory keyFactory = KeyFactory.getInstance("DSA"); + DSAPublicKey publicKey = (DSAPublicKey) keyFactory.generatePublic(keySpec); + return new DSAVerifyingPublicKey(publicKey); + } + + public static SigningPrivateKey createPrivateKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + String algorithm = o.getString("algorithm"); + if (!"DS".equals(algorithm)) { + throw new InvalidKeySpecException("algorithm must equal DS, was " + algorithm); + } + try { + BigInteger x = new BigInteger(o.getString("x"), SERIALIZATION_BASE); + BigInteger p = new BigInteger(o.getString("p"), SERIALIZATION_BASE); + BigInteger q = new BigInteger(o.getString("q"), SERIALIZATION_BASE); + BigInteger g = new BigInteger(o.getString("g"), SERIALIZATION_BASE); + return createPrivateKey(x, p, q, g); + } catch (NullPointerException | NumberFormatException e) { + throw new InvalidKeySpecException("x, p, q, and g must be integers encoded as strings, base " + SERIALIZATION_BASE); + } + } + + public static VerifyingPublicKey createPublicKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + String algorithm = o.getString("algorithm"); + if (!"DS".equals(algorithm)) { + throw new InvalidKeySpecException("algorithm must equal DS, was " + algorithm); + } + try { + BigInteger y = new BigInteger(o.getString("y"), SERIALIZATION_BASE); + BigInteger p = new BigInteger(o.getString("p"), SERIALIZATION_BASE); + BigInteger q = new BigInteger(o.getString("q"), SERIALIZATION_BASE); + BigInteger g = new BigInteger(o.getString("g"), SERIALIZATION_BASE); + return createPublicKey(y, p, q, g); + } catch (NullPointerException | NumberFormatException e) { + throw new InvalidKeySpecException("y, p, q, and g must be integers encoded as strings, base " + SERIALIZATION_BASE); + } + } + + public static BrowserIDKeyPair fromJSONObject(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + try { + ExtendedJSONObject privateKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PRIVATEKEY); + ExtendedJSONObject publicKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PUBLICKEY); + if (privateKey == null) { + throw new InvalidKeySpecException("privateKey must not be null"); + } + if (publicKey == null) { + throw new InvalidKeySpecException("publicKey must not be null"); + } + return new BrowserIDKeyPair(createPrivateKey(privateKey), createPublicKey(publicKey)); + } catch (NonObjectJSONException e) { + throw new InvalidKeySpecException("privateKey and publicKey must be JSON objects"); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java new file mode 100644 index 000000000..207accc76 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java @@ -0,0 +1,245 @@ +/* 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.browserid; + +import org.json.simple.JSONObject; +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.apache.commons.codec.binary.StringUtils; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.TreeMap; + +/** + * Encode and decode JSON Web Tokens. + * <p> + * Reverse-engineered from the Node.js jwcrypto library at + * <a href="https://github.com/mozilla/jwcrypto">https://github.com/mozilla/jwcrypto</a> + * and informed by the informal draft standard "JSON Web Token (JWT)" at + * <a href="http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html">http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html</a>. + */ +public class JSONWebTokenUtils { + public static final long DEFAULT_CERTIFICATE_DURATION_IN_MILLISECONDS = 60 * 60 * 1000; + public static final long DEFAULT_ASSERTION_DURATION_IN_MILLISECONDS = 60 * 60 * 1000; + public static final long DEFAULT_FUTURE_EXPIRES_AT_IN_MILLISECONDS = 9999999999999L; + public static final String DEFAULT_CERTIFICATE_ISSUER = "127.0.0.1"; + public static final String DEFAULT_ASSERTION_ISSUER = "127.0.0.1"; + + public static String encode(String payload, SigningPrivateKey privateKey) throws UnsupportedEncodingException, GeneralSecurityException { + final ExtendedJSONObject header = new ExtendedJSONObject(); + header.put("alg", privateKey.getAlgorithm()); + String encodedHeader = Base64.encodeBase64URLSafeString(header.toJSONString().getBytes("UTF-8")); + String encodedPayload = Base64.encodeBase64URLSafeString(payload.getBytes("UTF-8")); + ArrayList<String> segments = new ArrayList<String>(); + segments.add(encodedHeader); + segments.add(encodedPayload); + byte[] message = Utils.toDelimitedString(".", segments).getBytes("UTF-8"); + byte[] signature = privateKey.signMessage(message); + segments.add(Base64.encodeBase64URLSafeString(signature)); + return Utils.toDelimitedString(".", segments); + } + + public static String decode(String token, VerifyingPublicKey publicKey) throws GeneralSecurityException, UnsupportedEncodingException { + if (token == null) { + throw new IllegalArgumentException("token must not be null"); + } + String[] segments = token.split("\\."); + if (segments == null || segments.length != 3) { + throw new GeneralSecurityException("malformed token"); + } + byte[] message = (segments[0] + "." + segments[1]).getBytes("UTF-8"); + byte[] signature = Base64.decodeBase64(segments[2]); + boolean verifies = publicKey.verifyMessage(message, signature); + if (!verifies) { + throw new GeneralSecurityException("bad signature"); + } + String payload = StringUtils.newStringUtf8(Base64.decodeBase64(segments[1])); + return payload; + } + + /** + * Public for testing. + */ + @SuppressWarnings("unchecked") + public static String getPayloadString(String payloadString, String audience, String issuer, + Long issuedAt, long expiresAt) throws NonObjectJSONException, IOException { + ExtendedJSONObject payload; + if (payloadString != null) { + payload = new ExtendedJSONObject(payloadString); + } else { + payload = new ExtendedJSONObject(); + } + if (audience != null) { + payload.put("aud", audience); + } + payload.put("iss", issuer); + if (issuedAt != null) { + payload.put("iat", issuedAt); + } + payload.put("exp", expiresAt); + // TreeMap so that keys are sorted. A small attempt to keep output stable over time. + return JSONObject.toJSONString(new TreeMap<Object, Object>(payload.object)); + } + + protected static String getCertificatePayloadString(VerifyingPublicKey publicKeyToSign, String email) throws NonObjectJSONException, IOException { + ExtendedJSONObject payload = new ExtendedJSONObject(); + ExtendedJSONObject principal = new ExtendedJSONObject(); + principal.put("email", email); + payload.put("principal", principal); + payload.put("public-key", publicKeyToSign.toJSONObject()); + return payload.toJSONString(); + } + + public static String createCertificate(VerifyingPublicKey publicKeyToSign, String email, + String issuer, long issuedAt, long expiresAt, SigningPrivateKey privateKey) throws NonObjectJSONException, IOException, GeneralSecurityException { + String certificatePayloadString = getCertificatePayloadString(publicKeyToSign, email); + String payloadString = getPayloadString(certificatePayloadString, null, issuer, issuedAt, expiresAt); + return JSONWebTokenUtils.encode(payloadString, privateKey); + } + + /** + * Create a Browser ID assertion. + * + * @param privateKeyToSignWith + * private key to sign assertion with. + * @param certificate + * to include in assertion; no attempt is made to ensure the + * certificate is valid, or corresponds to the private key, or any + * other condition. + * @param audience + * to produce assertion for. + * @param issuer + * to produce assertion for. + * @param issuedAt + * timestamp for assertion, in milliseconds since the epoch; if null, + * no timestamp is included. + * @param expiresAt + * expiration timestamp for assertion, in milliseconds since the epoch. + * @return assertion. + * @throws NonObjectJSONException + * @throws IOException + * @throws GeneralSecurityException + */ + public static String createAssertion(SigningPrivateKey privateKeyToSignWith, String certificate, String audience, + String issuer, Long issuedAt, long expiresAt) throws NonObjectJSONException, IOException, GeneralSecurityException { + String emptyAssertionPayloadString = "{}"; + String payloadString = getPayloadString(emptyAssertionPayloadString, audience, issuer, issuedAt, expiresAt); + String signature = JSONWebTokenUtils.encode(payloadString, privateKeyToSignWith); + return certificate + "~" + signature; + } + + /** + * For debugging only! + * + * @param input + * certificate to dump. + * @return non-null object with keys header, payload, signature if the + * certificate is well-formed. + */ + public static ExtendedJSONObject parseCertificate(String input) { + try { + String[] parts = input.split("\\."); + if (parts.length != 3) { + return null; + } + String cHeader = new String(Base64.decodeBase64(parts[0])); + String cPayload = new String(Base64.decodeBase64(parts[1])); + String cSignature = Utils.byte2Hex(Base64.decodeBase64(parts[2])); + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("header", new ExtendedJSONObject(cHeader)); + o.put("payload", new ExtendedJSONObject(cPayload)); + o.put("signature", cSignature); + return o; + } catch (Exception e) { + return null; + } + } + + /** + * For debugging only! + * + * @param input certificate to dump. + * @return true if the certificate is well-formed. + */ + public static boolean dumpCertificate(String input) { + ExtendedJSONObject c = parseCertificate(input); + try { + if (c == null) { + System.out.println("Malformed certificate -- got exception trying to dump contents."); + return false; + } + System.out.println("certificate header: " + c.getObject("header").toJSONString()); + System.out.println("certificate payload: " + c.getObject("payload").toJSONString()); + System.out.println("certificate signature: " + c.getString("signature")); + return true; + } catch (Exception e) { + System.out.println("Malformed certificate -- got exception trying to dump contents."); + return false; + } + } + + /** + * For debugging only! + * + * @param input assertion to dump. + * @return true if the assertion is well-formed. + */ + public static ExtendedJSONObject parseAssertion(String input) { + try { + String[] parts = input.split("~"); + if (parts.length != 2) { + return null; + } + String certificate = parts[0]; + String assertion = parts[1]; + parts = assertion.split("\\."); + if (parts.length != 3) { + return null; + } + String aHeader = new String(Base64.decodeBase64(parts[0])); + String aPayload = new String(Base64.decodeBase64(parts[1])); + String aSignature = Utils.byte2Hex(Base64.decodeBase64(parts[2])); + // We do all the assertion parsing *before* dumping the certificate in + // case there's a malformed assertion. + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("header", new ExtendedJSONObject(aHeader)); + o.put("payload", new ExtendedJSONObject(aPayload)); + o.put("signature", aSignature); + o.put("certificate", certificate); + return o; + } catch (Exception e) { + return null; + } + } + + /** + * For debugging only! + * + * @param input assertion to dump. + * @return true if the assertion is well-formed. + */ + public static boolean dumpAssertion(String input) { + ExtendedJSONObject a = parseAssertion(input); + try { + if (a == null) { + System.out.println("Malformed assertion -- got exception trying to dump contents."); + return false; + } + dumpCertificate(a.getString("certificate")); + System.out.println("assertion header: " + a.getObject("header").toJSONString()); + System.out.println("assertion payload: " + a.getObject("payload").toJSONString()); + System.out.println("assertion signature: " + a.getString("signature")); + return true; + } catch (Exception e) { + System.out.println("Malformed assertion -- got exception trying to dump contents."); + return false; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java new file mode 100644 index 000000000..c807d4cbb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java @@ -0,0 +1,128 @@ +/* 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.browserid; + +import java.math.BigInteger; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; + +/** + * Generate certificates and assertions backed by mockmyid.com's private key. + * <p> + * These artifacts are for testing only. + */ +public class MockMyIDTokenFactory { + public static final BigInteger MOCKMYID_x = new BigInteger("385cb3509f086e110c5e24bdd395a84b335a09ae", 16); + public static final BigInteger MOCKMYID_y = new BigInteger("738ec929b559b604a232a9b55a5295afc368063bb9c20fac4e53a74970a4db7956d48e4c7ed523405f629b4cc83062f13029c4d615bbacb8b97f5e56f0c7ac9bc1d4e23809889fa061425c984061fca1826040c399715ce7ed385c4dd0d402256912451e03452d3c961614eb458f188e3e8d2782916c43dbe2e571251ce38262", 16); + public static final BigInteger MOCKMYID_p = new BigInteger("ff600483db6abfc5b45eab78594b3533d550d9f1bf2a992a7a8daa6dc34f8045ad4e6e0c429d334eeeaaefd7e23d4810be00e4cc1492cba325ba81ff2d5a5b305a8d17eb3bf4a06a349d392e00d329744a5179380344e82a18c47933438f891e22aeef812d69c8f75e326cb70ea000c3f776dfdbd604638c2ef717fc26d02e17", 16); + public static final BigInteger MOCKMYID_q = new BigInteger("e21e04f911d1ed7991008ecaab3bf775984309c3", 16); + public static final BigInteger MOCKMYID_g = new BigInteger("c52a4a0ff3b7e61fdf1867ce84138369a6154f4afa92966e3c827e25cfa6cf508b90e5de419e1337e07a2e9e2a3cd5dea704d175f8ebf6af397d69e110b96afb17c7a03259329e4829b0d03bbc7896b15b4ade53e130858cc34d96269aa89041f409136c7242a38895c9d5bccad4f389af1d7a4bd1398bd072dffa896233397a", 16); + + // Computed lazily by static <code>getMockMyIDPrivateKey</code>. + protected static SigningPrivateKey cachedMockMyIDPrivateKey; + + public static SigningPrivateKey getMockMyIDPrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + if (cachedMockMyIDPrivateKey == null) { + cachedMockMyIDPrivateKey = DSACryptoImplementation.createPrivateKey(MOCKMYID_x, MOCKMYID_p, MOCKMYID_q, MOCKMYID_g); + } + return cachedMockMyIDPrivateKey; + } + + /** + * Sign a public key asserting ownership of username@mockmyid.com with + * mockmyid.com's private key. + * + * @param publicKeyToSign + * public key to sign. + * @param username + * sign username@mockmyid.com + * @param issuedAt + * timestamp for certificate, in milliseconds since the epoch. + * @param expiresAt + * expiration timestamp for certificate, in milliseconds since the epoch. + * @return encoded certificate string. + * @throws Exception + */ + public String createMockMyIDCertificate(final VerifyingPublicKey publicKeyToSign, String username, + final long issuedAt, final long expiresAt) + throws Exception { + if (!username.endsWith("@mockmyid.com")) { + username = username + "@mockmyid.com"; + } + SigningPrivateKey mockMyIdPrivateKey = getMockMyIDPrivateKey(); + return JSONWebTokenUtils.createCertificate(publicKeyToSign, username, "mockmyid.com", issuedAt, expiresAt, mockMyIdPrivateKey); + } + + /** + * Sign a public key asserting ownership of username@mockmyid.com with + * mockmyid.com's private key. + * + * @param publicKeyToSign + * public key to sign. + * @param username + * sign username@mockmyid.com + * @return encoded certificate string. + * @throws Exception + */ + public String createMockMyIDCertificate(final VerifyingPublicKey publicKeyToSign, final String username) + throws Exception { + long ciat = System.currentTimeMillis(); + long cexp = ciat + JSONWebTokenUtils.DEFAULT_CERTIFICATE_DURATION_IN_MILLISECONDS; + return createMockMyIDCertificate(publicKeyToSign, username, ciat, cexp); + } + + /** + * Generate an assertion asserting ownership of username@mockmyid.com to a + * relying party. The underlying certificate is signed by mockymid.com's + * private key. + * + * @param keyPair + * to sign with. + * @param username + * sign username@mockmyid.com. + * @param certificateIssuedAt + * timestamp for certificate, in milliseconds since the epoch. + * @param certificateExpiresAt + * expiration timestamp for certificate, in milliseconds since the epoch. + * @param assertionIssuedAt + * timestamp for assertion, in milliseconds since the epoch; if null, + * no timestamp is included. + * @param assertionExpiresAt + * expiration timestamp for assertion, in milliseconds since the epoch. + * @return encoded assertion string. + * @throws Exception + */ + public String createMockMyIDAssertion(BrowserIDKeyPair keyPair, String username, String audience, + long certificateIssuedAt, long certificateExpiresAt, + Long assertionIssuedAt, long assertionExpiresAt) + throws Exception { + String certificate = createMockMyIDCertificate(keyPair.getPublic(), username, + certificateIssuedAt, certificateExpiresAt); + return JSONWebTokenUtils.createAssertion(keyPair.getPrivate(), certificate, audience, + JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER, assertionIssuedAt, assertionExpiresAt); + } + + /** + * Generate an assertion asserting ownership of username@mockmyid.com to a + * relying party. The underlying certificate is signed by mockymid.com's + * private key. + * + * @param keyPair + * to sign with. + * @param username + * sign username@mockmyid.com. + * @return encoded assertion string. + * @throws Exception + */ + public String createMockMyIDAssertion(BrowserIDKeyPair keyPair, String username, String audience) + throws Exception { + long ciat = System.currentTimeMillis(); + long cexp = ciat + JSONWebTokenUtils.DEFAULT_CERTIFICATE_DURATION_IN_MILLISECONDS; + long aiat = ciat + 1; + long aexp = aiat + JSONWebTokenUtils.DEFAULT_ASSERTION_DURATION_IN_MILLISECONDS; + return createMockMyIDAssertion(keyPair, username, audience, + ciat, cexp, aiat, aexp); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java new file mode 100644 index 000000000..902f6fb4d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java @@ -0,0 +1,182 @@ +/* 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.browserid; + +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.security.spec.RSAPrivateKeySpec; +import java.security.spec.RSAPublicKeySpec; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; + +public class RSACryptoImplementation { + public static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + + /** + * Parameters are serialized as decimal strings. Hex-versus-decimal was + * reverse-engineered from what the Persona public verifier accepted. We + * expect to follow the JOSE/JWT spec as it solidifies, and that will probably + * mean unifying this base. + */ + protected static final int SERIALIZATION_BASE = 10; + + protected static class RSAVerifyingPublicKey implements VerifyingPublicKey { + protected final RSAPublicKey publicKey; + + public RSAVerifyingPublicKey(RSAPublicKey publicKey) { + this.publicKey = publicKey; + } + + /** + * Serialize to a JSON object. + * <p> + * Parameters are serialized as decimal strings. Hex-versus-decimal was + * reverse-engineered from what the Persona public verifier accepted. + */ + @Override + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("algorithm", "RS"); + o.put("n", publicKey.getModulus().toString(SERIALIZATION_BASE)); + o.put("e", publicKey.getPublicExponent().toString(SERIALIZATION_BASE)); + return o; + } + + @Override + public boolean verifyMessage(byte[] bytes, byte[] signature) + throws GeneralSecurityException { + final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM); + signer.initVerify(publicKey); + signer.update(bytes); + return signer.verify(signature); + } + } + + protected static class RSASigningPrivateKey implements SigningPrivateKey { + protected final RSAPrivateKey privateKey; + + public RSASigningPrivateKey(RSAPrivateKey privateKey) { + this.privateKey = privateKey; + } + + @Override + public String getAlgorithm() { + return "RS" + (privateKey.getModulus().bitLength() + 7)/8; + } + + /** + * Serialize to a JSON object. + * <p> + * Parameters are serialized as decimal strings. Hex-versus-decimal was + * reverse-engineered from what the Persona public verifier accepted. + */ + @Override + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("algorithm", "RS"); + o.put("n", privateKey.getModulus().toString(SERIALIZATION_BASE)); + o.put("d", privateKey.getPrivateExponent().toString(SERIALIZATION_BASE)); + return o; + } + + @Override + public byte[] signMessage(byte[] bytes) + throws GeneralSecurityException { + final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM); + signer.initSign(privateKey); + signer.update(bytes); + return signer.sign(); + } + } + + public static BrowserIDKeyPair generateKeyPair(final int keysize) throws NoSuchAlgorithmException { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(keysize); + final KeyPair keyPair = keyPairGenerator.generateKeyPair(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + return new BrowserIDKeyPair(new RSASigningPrivateKey(privateKey), new RSAVerifyingPublicKey(publicKey)); + } + + public static SigningPrivateKey createPrivateKey(BigInteger n, BigInteger d) throws NoSuchAlgorithmException, InvalidKeySpecException { + if (n == null) { + throw new IllegalArgumentException("n must not be null"); + } + if (d == null) { + throw new IllegalArgumentException("d must not be null"); + } + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + KeySpec keySpec = new RSAPrivateKeySpec(n, d); + RSAPrivateKey privateKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + return new RSASigningPrivateKey(privateKey); + } + + public static VerifyingPublicKey createPublicKey(BigInteger n, BigInteger e) throws NoSuchAlgorithmException, InvalidKeySpecException { + if (n == null) { + throw new IllegalArgumentException("n must not be null"); + } + if (e == null) { + throw new IllegalArgumentException("e must not be null"); + } + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + KeySpec keySpec = new RSAPublicKeySpec(n, e); + RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec); + return new RSAVerifyingPublicKey(publicKey); + } + + public static SigningPrivateKey createPrivateKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + String algorithm = o.getString("algorithm"); + if (!"RS".equals(algorithm)) { + throw new InvalidKeySpecException("algorithm must equal RS, was " + algorithm); + } + try { + BigInteger n = new BigInteger(o.getString("n"), SERIALIZATION_BASE); + BigInteger d = new BigInteger(o.getString("d"), SERIALIZATION_BASE); + return createPrivateKey(n, d); + } catch (NullPointerException | NumberFormatException e) { + throw new InvalidKeySpecException("n and d must be integers encoded as strings, base " + SERIALIZATION_BASE); + } + } + + public static VerifyingPublicKey createPublicKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + String algorithm = o.getString("algorithm"); + if (!"RS".equals(algorithm)) { + throw new InvalidKeySpecException("algorithm must equal RS, was " + algorithm); + } + try { + BigInteger n = new BigInteger(o.getString("n"), SERIALIZATION_BASE); + BigInteger e = new BigInteger(o.getString("e"), SERIALIZATION_BASE); + return createPublicKey(n, e); + } catch (NullPointerException | NumberFormatException e) { + throw new InvalidKeySpecException("n and e must be integers encoded as strings, base " + SERIALIZATION_BASE); + } + } + + public static BrowserIDKeyPair fromJSONObject(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + try { + ExtendedJSONObject privateKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PRIVATEKEY); + ExtendedJSONObject publicKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PUBLICKEY); + if (privateKey == null) { + throw new InvalidKeySpecException("privateKey must not be null"); + } + if (publicKey == null) { + throw new InvalidKeySpecException("publicKey must not be null"); + } + return new BrowserIDKeyPair(createPrivateKey(privateKey), createPublicKey(publicKey)); + } catch (NonObjectJSONException e) { + throw new InvalidKeySpecException("privateKey and publicKey must be JSON objects"); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java new file mode 100644 index 000000000..6c388d167 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java @@ -0,0 +1,41 @@ +/* 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.browserid; + +import java.security.GeneralSecurityException; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public interface SigningPrivateKey { + /** + * Return the JSON Web Token "alg" header corresponding to this private key. + * <p> + * The header is used when formatting web tokens, and generally denotes the + * algorithm and an ad-hoc encoding of the key size. + * + * @return header. + */ + public String getAlgorithm(); + + /** + * Generate a JSON representation of a private key. + * <p> + * <b>This should only be used for debugging. No private keys should go over + * the wire at any time.</b> + * + * @param privateKey + * to represent. + * @return JSON representation. + */ + public ExtendedJSONObject toJSONObject(); + + /** + * Sign a message. + * @param message to sign. + * @return signature. + * @throws GeneralSecurityException + */ + public byte[] signMessage(byte[] message) throws GeneralSecurityException; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java new file mode 100644 index 000000000..74b534b90 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java @@ -0,0 +1,34 @@ +/* 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.browserid; + +import java.security.GeneralSecurityException; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + + +public interface VerifyingPublicKey { + /** + * Generate a JSON representation of a public key. + * + * @param publicKey + * to represent. + * @return JSON representation. + */ + public ExtendedJSONObject toJSONObject(); + + /** + * Verify a signature. + * + * @param message + * to verify signature of. + * @param signature + * to verify. + * @return true if signature is a signature of message produced by the private + * key corresponding to this public key. + * @throws GeneralSecurityException + */ + public boolean verifyMessage(byte[] message, byte[] signature) throws GeneralSecurityException; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java new file mode 100644 index 000000000..aa8db2d48 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java @@ -0,0 +1,95 @@ +/* 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.browserid.verifier; + +import java.io.IOException; +import java.net.URI; +import java.security.GeneralSecurityException; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierException.BrowserIDVerifierErrorResponseException; +import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierException.BrowserIDVerifierMalformedResponseException; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.Resource; +import org.mozilla.gecko.sync.net.SyncResponse; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; + +public abstract class AbstractBrowserIDRemoteVerifierClient implements BrowserIDVerifierClient { + public static final String LOG_TAG = AbstractBrowserIDRemoteVerifierClient.class.getSimpleName(); + + protected static class RemoteVerifierResourceDelegate extends BaseResourceDelegate { + private final BrowserIDVerifierDelegate delegate; + + protected RemoteVerifierResourceDelegate(Resource resource, BrowserIDVerifierDelegate delegate) { + super(resource); + this.delegate = delegate; + } + + @Override + public String getUserAgent() { + return null; + } + + @Override + public void handleHttpResponse(HttpResponse response) { + SyncResponse res = new SyncResponse(response); + int statusCode = res.getStatusCode(); + Logger.debug(LOG_TAG, "Got response with status code " + statusCode + "."); + + if (statusCode != 200) { + delegate.handleError(new BrowserIDVerifierErrorResponseException("Expected status code 200.")); + return; + } + + ExtendedJSONObject o = null; + try { + o = res.jsonObjectBody(); + } catch (Exception e) { + delegate.handleError(new BrowserIDVerifierMalformedResponseException(e)); + return; + } + + String status = o.getString("status"); + if ("failure".equals(status)) { + delegate.handleFailure(o); + return; + } + + if (!("okay".equals(status))) { + delegate.handleError(new BrowserIDVerifierMalformedResponseException("Expected status okay, got '" + status + "'.")); + return; + } + + delegate.handleSuccess(o); + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + Logger.warn(LOG_TAG, "Got transport exception.", e); + delegate.handleError(e); + } + + @Override + public void handleHttpProtocolException(ClientProtocolException e) { + Logger.warn(LOG_TAG, "Got protocol exception.", e); + delegate.handleError(e); + } + + @Override + public void handleHttpIOException(IOException e) { + Logger.warn(LOG_TAG, "Got IO exception.", e); + delegate.handleError(e); + } + } + + protected final URI verifierUri; + + public AbstractBrowserIDRemoteVerifierClient(URI verifierUri) { + this.verifierUri = verifierUri; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java new file mode 100644 index 000000000..f61a82323 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java @@ -0,0 +1,62 @@ +/* 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.browserid.verifier; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.List; + +import org.mozilla.gecko.sync.net.BaseResource; + +import ch.boye.httpclientandroidlib.NameValuePair; +import ch.boye.httpclientandroidlib.client.entity.UrlEncodedFormEntity; +import ch.boye.httpclientandroidlib.message.BasicNameValuePair; + +/** + * The verifier protocol changed: version 1 posts form-encoded data; version 2 + * posts JSON data. + */ +public class BrowserIDRemoteVerifierClient10 extends AbstractBrowserIDRemoteVerifierClient { + public static final String LOG_TAG = BrowserIDRemoteVerifierClient10.class.getSimpleName(); + + public static final String DEFAULT_VERIFIER_URL = "https://verifier.login.persona.org/verify"; + + public BrowserIDRemoteVerifierClient10() throws URISyntaxException { + super(new URI(DEFAULT_VERIFIER_URL)); + } + + public BrowserIDRemoteVerifierClient10(URI verifierUri) { + super(verifierUri); + } + + @Override + public void verify(String audience, String assertion, final BrowserIDVerifierDelegate delegate) { + if (audience == null) { + throw new IllegalArgumentException("audience cannot be null."); + } + if (assertion == null) { + throw new IllegalArgumentException("assertion cannot be null."); + } + if (delegate == null) { + throw new IllegalArgumentException("delegate cannot be null."); + } + + BaseResource r = new BaseResource(verifierUri); + + r.delegate = new RemoteVerifierResourceDelegate(r, delegate); + + List<NameValuePair> nvps = Arrays.asList(new NameValuePair[] { + new BasicNameValuePair("audience", audience), + new BasicNameValuePair("assertion", assertion) }); + + try { + r.post(new UrlEncodedFormEntity(nvps, "UTF-8")); + } catch (UnsupportedEncodingException e) { + delegate.handleError(e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java new file mode 100644 index 000000000..013856576 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java @@ -0,0 +1,58 @@ +/* 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.browserid.verifier; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.BaseResource; + +/** + * The verifier protocol changed: version 1 posts form-encoded data; version 2 + * posts JSON data. + */ +public class BrowserIDRemoteVerifierClient20 extends AbstractBrowserIDRemoteVerifierClient { + public static final String LOG_TAG = BrowserIDRemoteVerifierClient20.class.getSimpleName(); + + public static final String DEFAULT_VERIFIER_URL = "https://verifier.accounts.firefox.com/v2"; + + protected static final String JSON_KEY_ASSERTION = "assertion"; + protected static final String JSON_KEY_AUDIENCE = "audience"; + + public BrowserIDRemoteVerifierClient20() throws URISyntaxException { + super(new URI(DEFAULT_VERIFIER_URL)); + } + + public BrowserIDRemoteVerifierClient20(URI verifierUri) { + super(verifierUri); + } + + @Override + public void verify(String audience, String assertion, final BrowserIDVerifierDelegate delegate) { + if (audience == null) { + throw new IllegalArgumentException("audience cannot be null."); + } + if (assertion == null) { + throw new IllegalArgumentException("assertion cannot be null."); + } + if (delegate == null) { + throw new IllegalArgumentException("delegate cannot be null."); + } + + BaseResource r = new BaseResource(verifierUri); + r.delegate = new RemoteVerifierResourceDelegate(r, delegate); + + final ExtendedJSONObject requestBody = new ExtendedJSONObject(); + requestBody.put(JSON_KEY_AUDIENCE, audience); + requestBody.put(JSON_KEY_ASSERTION, assertion); + + try { + r.post(requestBody); + } catch (Exception e) { + delegate.handleError(e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java new file mode 100644 index 000000000..67a327f19 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java @@ -0,0 +1,9 @@ +/* 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.browserid.verifier; + +public interface BrowserIDVerifierClient { + public abstract void verify(String audience, String assertion, BrowserIDVerifierDelegate delegate); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java new file mode 100644 index 000000000..b58d03281 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java @@ -0,0 +1,13 @@ +/* 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.browserid.verifier; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public interface BrowserIDVerifierDelegate { + void handleSuccess(ExtendedJSONObject response); + void handleFailure(ExtendedJSONObject response); + void handleError(Exception e); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java new file mode 100644 index 000000000..dacaf6112 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java @@ -0,0 +1,41 @@ +/* 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.browserid.verifier; + +public class BrowserIDVerifierException extends Exception { + private static final long serialVersionUID = 2228946910754889975L; + + public BrowserIDVerifierException(String detailMessage) { + super(detailMessage); + } + + public BrowserIDVerifierException(Throwable throwable) { + super(throwable); + } + + public static class BrowserIDVerifierMalformedResponseException extends BrowserIDVerifierException { + private static final long serialVersionUID = 115377527009652839L; + + public BrowserIDVerifierMalformedResponseException(String detailMessage) { + super(detailMessage); + } + + public BrowserIDVerifierMalformedResponseException(Throwable throwable) { + super(throwable); + } + } + + public static class BrowserIDVerifierErrorResponseException extends BrowserIDVerifierException { + private static final long serialVersionUID = 115377527009652840L; + + public BrowserIDVerifierErrorResponseException(String detailMessage) { + super(detailMessage); + } + + public BrowserIDVerifierErrorResponseException(Throwable throwable) { + super(throwable); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java new file mode 100644 index 000000000..8a31c1ce0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java @@ -0,0 +1,227 @@ +/* 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.fxa; + +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Looper; +import android.content.AsyncTaskLoader; +import android.support.v4.content.LocalBroadcastManager; + +import java.lang.ref.WeakReference; + +/** + * A Loader that queries and updates based on the existence of Firefox and + * legacy Sync Android Accounts. + * + * The loader returns an Android Account (of either Account type) if an account + * exists, and null to indicate no Account is present. + * + * The loader listens for Accounts added and deleted, and also Accounts being + * updated by Sync or another Activity, via the use of + * {@link AndroidFxAccount#setState(org.mozilla.gecko.fxa.login.State)}. + * Be careful of message loops if you update the account state from an activity + * that uses this loader. + * + * This implementation is based on + * <a href="http://www.androiddesignpatterns.com/2012/08/implementing-loaders.html">http://www.androiddesignpatterns.com/2012/08/implementing-loaders.html</a>. + */ +public class AccountLoader extends AsyncTaskLoader<Account> { + protected Account account = null; + protected BroadcastReceiver broadcastReceiver = null; + + // Hold a weak reference to AccountLoader instance in this Runnable to avoid potentially leaking it + // after posting to a Handler in the BroadcastReceiver returned from makeNewObserver. + private final BroadcastReceiverRunnable broadcastReceiverRunnable = new BroadcastReceiverRunnable(this); + + public AccountLoader(final Context context) { + super(context); + } + + // Task that performs the asynchronous load. + @Override + public Account loadInBackground() { + return FirefoxAccounts.getFirefoxAccount(getContext()); + } + + // Deliver the results to the registered listener. + @Override + public void deliverResult(Account data) { + if (isReset()) { + // The Loader has been reset; ignore the result and invalidate the data. + releaseResources(data); + return; + } + + // Hold a reference to the old data so it doesn't get garbage collected. + // We must protect it until the new data has been delivered. + Account oldData = account; + account = data; + + if (isStarted()) { + // If the Loader is in a started state, deliver the results to the + // client. The superclass method does this for us. + super.deliverResult(data); + } + + // Invalidate the old data as we don't need it any more. + if (oldData != null && oldData != data) { + releaseResources(oldData); + } + } + + // The Loader’s state-dependent behavior. + @Override + protected void onStartLoading() { + if (account != null) { + // Deliver any previously loaded data immediately. + deliverResult(account); + } + + // Begin monitoring the underlying data source. + if (broadcastReceiver == null) { + broadcastReceiver = makeNewObserver(); + registerLocalObserver(getContext(), broadcastReceiver); + registerSystemObserver(getContext(), broadcastReceiver); + } + + if (takeContentChanged() || account == null) { + // When the observer detects a change, it should call onContentChanged() + // on the Loader, which will cause the next call to takeContentChanged() + // to return true. If this is ever the case (or if the current data is + // null), we force a new load. + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + // The Loader is in a stopped state, so we should attempt to cancel the + // current load (if there is one). + cancelLoad(); + + // Note that we leave the observer as is. Loaders in a stopped state + // should still monitor the data source for changes so that the Loader + // will know to force a new load if it is ever started again. + } + + @Override + protected void onReset() { + // Ensure the loader has been stopped. In CursorLoader and the template + // this code follows (see the class comment), this is onStopLoading, which + // appears to not set the started flag (see Loader itself). + stopLoading(); + + // At this point we can release the resources associated with 'mData'. + if (account != null) { + releaseResources(account); + account = null; + } + + // The Loader is being reset, so we should stop monitoring for changes. + if (broadcastReceiver != null) { + final BroadcastReceiver observer = broadcastReceiver; + broadcastReceiver = null; + unregisterObserver(getContext(), observer); + } + } + + @Override + public void onCanceled(final Account data) { + // Attempt to cancel the current asynchronous load. + super.onCanceled(data); + + // The load has been canceled, so we should release the resources + // associated with 'data'. + releaseResources(data); + } + + // Observer which receives notifications when the data changes. + protected BroadcastReceiver makeNewObserver() { + return new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + // onContentChanged must be called on the main thread. + // If we're already on the main thread, call it directly. + if (Looper.myLooper() == Looper.getMainLooper()) { + onContentChanged(); + return; + } + + // Otherwise, post a Runnable to a Handler bound to the main thread's message loop. + final Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(broadcastReceiverRunnable); + } + }; + } + + private static class BroadcastReceiverRunnable implements Runnable { + private final WeakReference<AccountLoader> accountLoaderWeakReference; + + public BroadcastReceiverRunnable(final AccountLoader accountLoader) { + accountLoaderWeakReference = new WeakReference<>(accountLoader); + } + + @Override + public void run() { + final AccountLoader accountLoader = accountLoaderWeakReference.get(); + if (accountLoader != null) { + accountLoader.onContentChanged(); + } + } + } + + private void releaseResources(Account data) { + // For a simple List, there is nothing to do. For something like a Cursor, we + // would close it in this method. All resources associated with the Loader + // should be released here. + } + + /** + * Register provided observer with the LocalBroadcastManager to listen for internal events. + * + * @param context <code>Context</code> to use for obtaining LocalBroadcastManager instance. + * @param observer <code>BroadcastReceiver</code> which will handle local events. + */ + protected static void registerLocalObserver(final Context context, final BroadcastReceiver observer) { + final IntentFilter intentFilter = new IntentFilter(); + // Firefox Account internal state changed. + intentFilter.addAction(FxAccountConstants.ACCOUNT_STATE_CHANGED_ACTION); + // Firefox Account profile state changed. + intentFilter.addAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION); + + LocalBroadcastManager.getInstance(context).registerReceiver(observer, intentFilter); + } + + /** + * Register provided observer for handling system-wide broadcasts. + * + * @param context <code>Context</code> to use for registering a receiver. + * @param observer <code>BroadcastReceiver</code> which will handle system events. + */ + protected static void registerSystemObserver(final Context context, final BroadcastReceiver observer) { + context.registerReceiver(observer, + // Android Account added or removed. + new IntentFilter(AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION), + // No broadcast permissions required. + null, + // Null handler ensures that broadcasts will be handled on the main thread. + null + ); + } + + protected static void unregisterObserver(final Context context, final BroadcastReceiver observer) { + LocalBroadcastManager.getInstance(context).unregisterReceiver(observer); + context.unregisterReceiver(observer); + } +} + diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java new file mode 100644 index 000000000..4184340ec --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java @@ -0,0 +1,222 @@ +/* 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.fxa; + +import java.io.File; +import java.util.concurrent.CountDownLatch; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.fxa.authenticator.AccountPickler; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.sync.FxAccountSyncStatusHelper; +import org.mozilla.gecko.sync.ThreadPool; +import org.mozilla.gecko.sync.Utils; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; +import android.content.Context; +import android.os.Bundle; + +/** + * Simple public accessors for Firefox account objects. + */ +public class FirefoxAccounts { + private static final String LOG_TAG = FirefoxAccounts.class.getSimpleName(); + + /** + * Returns true if a FirefoxAccount exists, false otherwise. + * + * @param context Android context. + * @return true if at least one Firefox account exists. + */ + public static boolean firefoxAccountsExist(final Context context) { + return getFirefoxAccounts(context).length > 0; + } + + /** + * Return Firefox accounts. + * <p> + * If no accounts exist in the AccountManager, one may be created + * via a pickled FirefoxAccount, if available, and that account + * will be added to the AccountManager and returned. + * <p> + * Note that this can be called from any thread. + * + * @param context Android context. + * @return Firefox account objects. + */ + public static Account[] getFirefoxAccounts(final Context context) { + final Account[] accounts = + AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE); + if (accounts.length > 0) { + return accounts; + } + + final Account pickledAccount = getPickledAccount(context); + return (pickledAccount != null) ? new Account[] {pickledAccount} : new Account[0]; + } + + private static Account getPickledAccount(final Context context) { + // To avoid a StrictMode violation for disk access, we call this from a background thread. + // We do this every time, so the caller doesn't have to care. + final CountDownLatch latch = new CountDownLatch(1); + final Account[] accounts = new Account[1]; + ThreadPool.run(new Runnable() { + @Override + public void run() { + try { + final File file = context.getFileStreamPath(FxAccountConstants.ACCOUNT_PICKLE_FILENAME); + if (!file.exists()) { + accounts[0] = null; + return; + } + + // There is a small race window here: if the user creates a new Firefox account + // between our checks, this could erroneously report that no Firefox accounts + // exist. + final AndroidFxAccount fxAccount = + AccountPickler.unpickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME); + accounts[0] = fxAccount != null ? fxAccount.getAndroidAccount() : null; + } finally { + latch.countDown(); + } + } + }); + + try { + latch.await(); // Wait for the background thread to return. + } catch (InterruptedException e) { + Logger.warn(LOG_TAG, + "Foreground thread unexpectedly interrupted while getting pickled account", e); + return null; + } + + return accounts[0]; + } + + /** + * @param context Android context. + * @return the configured Firefox account if one exists, or null otherwise. + */ + public static Account getFirefoxAccount(final Context context) { + Account[] accounts = getFirefoxAccounts(context); + if (accounts.length > 0) { + return accounts[0]; + } + return null; + } + + /** + * @return + * the {@link State} instance associated with the current account, or <code>null</code> if + * no accounts exist. + */ + public static State getFirefoxAccountState(final Context context) { + final Account account = getFirefoxAccount(context); + if (account == null) { + return null; + } + + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + try { + return fxAccount.getState(); + } catch (final Exception ex) { + Logger.warn(LOG_TAG, "Could not get FX account state.", ex); + return null; + } + } + + /* + * @param context Android context + * @return the email address associated with the configured Firefox account if one exists; null otherwise. + */ + public static String getFirefoxAccountEmail(final Context context) { + final Account account = getFirefoxAccount(context); + if (account == null) { + return null; + } + return account.name; + } + + public static void logSyncOptions(Bundle syncOptions) { + final boolean scheduleNow = syncOptions.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false); + + Logger.info(LOG_TAG, "Sync options -- scheduling now: " + scheduleNow); + } + + public static void requestImmediateSync(final Account account, String[] stagesToSync, String[] stagesToSkip) { + final Bundle syncOptions = new Bundle(); + syncOptions.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true); + syncOptions.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); + requestSync(account, syncOptions, stagesToSync, stagesToSkip); + } + + public static void requestEventualSync(final Account account, String[] stagesToSync, String[] stagesToSkip) { + requestSync(account, Bundle.EMPTY, stagesToSync, stagesToSkip); + } + + /** + * Request a sync for the given Android Account. + * <p> + * Any hints are strictly optional: the actual requested sync is scheduled by + * the Android sync scheduler, and the sync mechanism may ignore hints as it + * sees fit. + * <p> + * It is safe to call this method from any thread. + * + * @param account to sync. + * @param syncOptions to pass to sync. + * @param stagesToSync stage names to sync. + * @param stagesToSkip stage names to skip. + */ + protected static void requestSync(final Account account, final Bundle syncOptions, String[] stagesToSync, String[] stagesToSkip) { + if (account == null) { + throw new IllegalArgumentException("account must not be null"); + } + if (syncOptions == null) { + throw new IllegalArgumentException("syncOptions must not be null"); + } + + Utils.putStageNamesToSync(syncOptions, stagesToSync, stagesToSkip); + + Logger.info(LOG_TAG, "Requesting sync."); + logSyncOptions(syncOptions); + + // We get strict mode warnings on some devices, so make the request on a + // background thread. + ThreadPool.run(new Runnable() { + @Override + public void run() { + for (String authority : AndroidFxAccount.DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) { + ContentResolver.requestSync(account, authority, syncOptions); + } + } + }); + } + + /** + * Start notifying <code>syncStatusListener</code> of sync status changes. + * <p> + * Only a weak reference to <code>syncStatusListener</code> is held. + * + * @param syncStatusListener to start notifying. + */ + public static void addSyncStatusListener(SyncStatusListener syncStatusListener) { + // startObserving null-checks its argument. + FxAccountSyncStatusHelper.getInstance().startObserving(syncStatusListener); + } + + /** + * Stop notifying <code>syncStatusListener</code> of sync status changes. + * + * @param syncStatusListener to stop notifying. + */ + public static void removeSyncStatusListener(SyncStatusListener syncStatusListener) { + // stopObserving null-checks its argument. + FxAccountSyncStatusHelper.getInstance().stopObserving(syncStatusListener); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java new file mode 100644 index 000000000..c6147b323 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java @@ -0,0 +1,75 @@ +/* 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.fxa; + +import org.mozilla.gecko.AppConstants; + +public class FxAccountConstants { + public static final String GLOBAL_LOG_TAG = "FxAccounts"; + public static final String ACCOUNT_TYPE = AppConstants.MOZ_ANDROID_SHARED_FXACCOUNT_TYPE; + + // Must be a client ID allocated with "canGrant" privileges! + public static final String OAUTH_CLIENT_ID_FENNEC = "3332a18d142636cb"; + + public static final String DEFAULT_AUTH_SERVER_ENDPOINT = "https://api.accounts.firefox.com/v1"; + public static final String DEFAULT_TOKEN_SERVER_ENDPOINT = "https://token.services.mozilla.com/1.0/sync/1.5"; + public static final String DEFAULT_OAUTH_SERVER_ENDPOINT = "https://oauth.accounts.firefox.com/v1"; + public static final String DEFAULT_PROFILE_SERVER_ENDPOINT = "https://profile.accounts.firefox.com/v1"; + + public static final String STAGE_AUTH_SERVER_ENDPOINT = "https://stable.dev.lcip.org/auth/v1"; + public static final String STAGE_TOKEN_SERVER_ENDPOINT = "https://stable.dev.lcip.org/syncserver/token/1.0/sync/1.5"; + public static final String STAGE_OAUTH_SERVER_ENDPOINT = "https://oauth-stable.dev.lcip.org/v1"; + public static final String STAGE_PROFILE_SERVER_ENDPOINT = "https://latest.dev.lcip.org/profile/v1"; + + // Action to update on cached profile information. + public static final String ACCOUNT_PROFILE_JSON_UPDATED_ACTION = "org.mozilla.gecko.fxa.profile.JSON.updated"; + + // You must be at least 13 years old, on the day of creation, to create a Firefox Account. + public static final int MINIMUM_AGE_TO_CREATE_AN_ACCOUNT = 13; + + // Key for avatar URI in profile JSON. + public static final String KEY_PROFILE_JSON_AVATAR = "avatar"; + // Key for username in profile JSON. + public static final String KEY_PROFILE_JSON_USERNAME = "displayName"; + + // You must wait 15 minutes after failing an age check before trying to create a different account. + public static final long MINIMUM_TIME_TO_WAIT_AFTER_AGE_CHECK_FAILED_IN_MILLISECONDS = 15 * 60 * 1000; + + public static final String USER_AGENT = "Firefox-Android-FxAccounts/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_UA_NAME + ")"; + + public static final String ACCOUNT_PICKLE_FILENAME = "fxa.account.json"; + + + /** + * Version number of contents of SYNC_ACCOUNT_DELETED_ACTION intent. + */ + public static final long ACCOUNT_DELETED_INTENT_VERSION = 1; + + public static final String ACCOUNT_DELETED_INTENT_VERSION_KEY = "account_deleted_intent_version"; + public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_KEY = "account_deleted_intent_account"; + public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE = "account_deleted_intent_profile"; + public static final String ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY = "account_oauth_service_endpoint"; + public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS = "account_deleted_intent_auth_tokens"; + + /** + * This action is broadcast when an Android Firefox Account's internal state + * is changed. + * <p> + * It is protected by signing-level permission PER_ACCOUNT_TYPE_PERMISSION and + * can be received only by Firefox versions sharing the same Android Firefox + * Account type. + */ + public static final String ACCOUNT_STATE_CHANGED_ACTION = AppConstants.MOZ_ANDROID_SHARED_FXACCOUNT_TYPE + ".accounts.ACCOUNT_STATE_CHANGED_ACTION"; + + public static final String ACTION_FXA_CONFIRM_ACCOUNT = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_CONFIRM_ACCOUNT"; + public static final String ACTION_FXA_FINISH_MIGRATING = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_FINISH_MIGRATING"; + public static final String ACTION_FXA_GET_STARTED = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_GET_STARTED"; + public static final String ACTION_FXA_STATUS = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_STATUS"; + public static final String ACTION_FXA_UPDATE_CREDENTIALS = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_UPDATE_CREDENTIALS"; + + public static final String ENDPOINT_PREFERENCES = "preferences"; + public static final String ENDPOINT_NOTIFICATION = "notification"; + public static final String ENDPOINT_FIRSTRUN = "firstrun"; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java new file mode 100644 index 000000000..cd46ae2bd --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java @@ -0,0 +1,81 @@ +/* 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.fxa; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public class FxAccountDevice { + + public static final String JSON_KEY_NAME = "name"; + public static final String JSON_KEY_ID = "id"; + public static final String JSON_KEY_TYPE = "type"; + public static final String JSON_KEY_ISCURRENTDEVICE = "isCurrentDevice"; + public static final String JSON_KEY_PUSH_CALLBACK = "pushCallback"; + public static final String JSON_KEY_PUSH_PUBLICKEY = "pushPublicKey"; + public static final String JSON_KEY_PUSH_AUTHKEY = "pushAuthKey"; + + public final String id; + public final String name; + public final String type; + public final Boolean isCurrentDevice; + public final String pushCallback; + public final String pushPublicKey; + public final String pushAuthKey; + + public FxAccountDevice(String name, String id, String type, Boolean isCurrentDevice, + String pushCallback, String pushPublicKey, String pushAuthKey) { + this.name = name; + this.id = id; + this.type = type; + this.isCurrentDevice = isCurrentDevice; + this.pushCallback = pushCallback; + this.pushPublicKey = pushPublicKey; + this.pushAuthKey = pushAuthKey; + } + + public static FxAccountDevice forRegister(String name, String type, String pushCallback, + String pushPublicKey, String pushAuthKey) { + return new FxAccountDevice(name, null, type, null, pushCallback, pushPublicKey, pushAuthKey); + } + + public static FxAccountDevice forUpdate(String id, String name, String pushCallback, + String pushPublicKey, String pushAuthKey) { + return new FxAccountDevice(name, id, null, null, pushCallback, pushPublicKey, pushAuthKey); + } + + public static FxAccountDevice fromJson(ExtendedJSONObject json) { + String name = json.getString(JSON_KEY_NAME); + String id = json.getString(JSON_KEY_ID); + String type = json.getString(JSON_KEY_TYPE); + Boolean isCurrentDevice = json.getBoolean(JSON_KEY_ISCURRENTDEVICE); + String pushCallback = json.getString(JSON_KEY_PUSH_CALLBACK); + String pushPublicKey = json.getString(JSON_KEY_PUSH_PUBLICKEY); + String pushAuthKey = json.getString(JSON_KEY_PUSH_AUTHKEY); + return new FxAccountDevice(name, id, type, isCurrentDevice, pushCallback, pushPublicKey, pushAuthKey); + } + + public ExtendedJSONObject toJson() { + final ExtendedJSONObject body = new ExtendedJSONObject(); + if (this.name != null) { + body.put(JSON_KEY_NAME, this.name); + } + if (this.id != null) { + body.put(JSON_KEY_ID, this.id); + } + if (this.type != null) { + body.put(JSON_KEY_TYPE, this.type); + } + if (this.pushCallback != null) { + body.put(JSON_KEY_PUSH_CALLBACK, this.pushCallback); + } + if (this.pushPublicKey != null) { + body.put(JSON_KEY_PUSH_PUBLICKEY, this.pushPublicKey); + } + if (this.pushAuthKey != null) { + body.put(JSON_KEY_PUSH_AUTHKEY, this.pushAuthKey); + } + return body; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java new file mode 100644 index 000000000..66a8ad843 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java @@ -0,0 +1,282 @@ +/* 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.fxa; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountClient; +import org.mozilla.gecko.background.fxa.FxAccountClient20; +import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse; +import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate; +import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException; +import org.mozilla.gecko.background.fxa.FxAccountRemoteError; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount.InvalidFxAState; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; + +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.GeneralSecurityException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/* This class provides a way to register the current device against FxA + * and also stores the registration details in the Android FxAccount. + * This should be used in a state where we possess a sessionToken, most likely the Married state. + */ +public class FxAccountDeviceRegistrator implements BundleEventListener { + private static final String LOG_TAG = "FxADeviceRegistrator"; + + // The current version of the device registration, we use this to re-register + // devices after we update what we send on device registration. + public static final Integer DEVICE_REGISTRATION_VERSION = 2; + + private static FxAccountDeviceRegistrator instance; + private final WeakReference<Context> context; + + private FxAccountDeviceRegistrator(Context appContext) { + this.context = new WeakReference<Context>(appContext); + } + + private static FxAccountDeviceRegistrator getInstance(Context appContext) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException { + if (instance == null) { + FxAccountDeviceRegistrator tempInstance = new FxAccountDeviceRegistrator(appContext); + tempInstance.setupListeners(); // Set up listener for FxAccountPush:Subscribe:Response + instance = tempInstance; + } + return instance; + } + + public static void register(Context context) { + Context appContext = context.getApplicationContext(); + try { + getInstance(appContext).beginRegistration(appContext); + } catch (Exception e) { + Log.e(LOG_TAG, "Could not start FxA device registration", e); + } + } + + private void beginRegistration(Context context) { + // Fire up gecko and send event + // We create the Intent ourselves instead of using GeckoService.getIntentToCreateServices + // because we can't import these modules (circular dependency between browser and services) + final Intent geckoIntent = new Intent(); + geckoIntent.setAction("create-services"); + geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService"); + geckoIntent.putExtra("category", "android-push-service"); + geckoIntent.putExtra("data", "android-fxa-subscribe"); + final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context); + geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile()); + context.startService(geckoIntent); + // -> handleMessage() + } + + @Override + public void handleMessage(String event, Bundle message, EventCallback callback) { + if ("FxAccountsPush:Subscribe:Response".equals(event)) { + try { + doFxaRegistration(message.getBundle("subscription")); + } catch (InvalidFxAState e) { + Log.d(LOG_TAG, "Invalid state when trying to register with FxA ", e); + } + } else { + Log.e(LOG_TAG, "No action defined for " + event); + } + } + + private void doFxaRegistration(Bundle subscription) throws InvalidFxAState { + final Context context = this.context.get(); + if (this.context == null) { + throw new IllegalStateException("Application context has been gc'ed"); + } + doFxaRegistration(context, subscription, true); + } + + private static void doFxaRegistration(final Context context, final Bundle subscription, final boolean allowRecursion) throws InvalidFxAState { + String pushCallback = subscription.getString("pushCallback"); + String pushPublicKey = subscription.getString("pushPublicKey"); + String pushAuthKey = subscription.getString("pushAuthKey"); + + final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context); + if (fxAccount == null) { + Log.e(LOG_TAG, "AndroidFxAccount is null"); + return; + } + final byte[] sessionToken = fxAccount.getSessionToken(); + final FxAccountDevice device; + String deviceId = fxAccount.getDeviceId(); + String clientName = getClientName(fxAccount, context); + if (TextUtils.isEmpty(deviceId)) { + Log.i(LOG_TAG, "Attempting registration for a new device"); + device = FxAccountDevice.forRegister(clientName, "mobile", pushCallback, pushPublicKey, pushAuthKey); + } else { + Log.i(LOG_TAG, "Attempting registration for an existing device"); + Logger.pii(LOG_TAG, "Device ID: " + deviceId); + device = FxAccountDevice.forUpdate(deviceId, clientName, pushCallback, pushPublicKey, pushAuthKey); + } + + ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread + final FxAccountClient20 fxAccountClient = + new FxAccountClient20(fxAccount.getAccountServerURI(), executor); + fxAccountClient.registerOrUpdateDevice(sessionToken, device, new RequestDelegate<FxAccountDevice>() { + @Override + public void handleError(Exception e) { + Log.e(LOG_TAG, "Error while updating a device registration: ", e); + } + + @Override + public void handleFailure(FxAccountClientRemoteException error) { + Log.e(LOG_TAG, "Error while updating a device registration: ", error); + if (error.httpStatusCode == 400) { + if (error.apiErrorNumber == FxAccountRemoteError.UNKNOWN_DEVICE) { + recoverFromUnknownDevice(fxAccount); + } else if (error.apiErrorNumber == FxAccountRemoteError.DEVICE_SESSION_CONFLICT) { + recoverFromDeviceSessionConflict(error, fxAccountClient, sessionToken, fxAccount, context, + subscription, allowRecursion); + } + } else + if (error.httpStatusCode == 401 + && error.apiErrorNumber == FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN) { + handleTokenError(error, fxAccountClient, fxAccount); + } else { + logErrorAndResetDeviceRegistrationVersion(error, fxAccount); + } + } + + @Override + public void handleSuccess(FxAccountDevice result) { + Log.i(LOG_TAG, "Device registration complete"); + Logger.pii(LOG_TAG, "Registered device ID: " + result.id); + fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION); + } + }); + } + + private static void logErrorAndResetDeviceRegistrationVersion( + final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount) { + Log.e(LOG_TAG, "Device registration failed", error); + fxAccount.resetDeviceRegistrationVersion(); + } + + @Nullable + private static String getClientName(final AndroidFxAccount fxAccount, final Context context) { + try { + SharedPreferencesClientsDataDelegate clientsDataDelegate = + new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context); + return clientsDataDelegate.getClientName(); + } catch (UnsupportedEncodingException | GeneralSecurityException e) { + Log.e(LOG_TAG, "Unable to get client name.", e); + return null; + } + } + + private static void handleTokenError(final FxAccountClientRemoteException error, + final FxAccountClient fxAccountClient, + final AndroidFxAccount fxAccount) { + Log.i(LOG_TAG, "Recovering from invalid token error: ", error); + logErrorAndResetDeviceRegistrationVersion(error, fxAccount); + fxAccountClient.accountStatus(fxAccount.getState().uid, + new RequestDelegate<AccountStatusResponse>() { + @Override + public void handleError(Exception e) { + } + + @Override + public void handleFailure(FxAccountClientRemoteException e) { + } + + @Override + public void handleSuccess(AccountStatusResponse result) { + State doghouseState = fxAccount.getState().makeDoghouseState(); + if (!result.exists) { + Log.i(LOG_TAG, "token invalidated because the account no longer exists"); + // TODO: Should be in a "I have an Android account, but the FxA is gone." State. + // This will do for now.. + fxAccount.setState(doghouseState); + return; + } + Log.e(LOG_TAG, "sessionToken invalid"); + fxAccount.setState(doghouseState); + } + }); + } + + private static void recoverFromUnknownDevice(final AndroidFxAccount fxAccount) { + Log.i(LOG_TAG, "unknown device id, clearing the cached device id"); + fxAccount.setDeviceId(null); + } + + /** + * Will call delegate#complete in all cases + */ + private static void recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error, + final FxAccountClient fxAccountClient, + final byte[] sessionToken, + final AndroidFxAccount fxAccount, + final Context context, + final Bundle subscription, + final boolean allowRecursion) { + Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id"); + fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() { + private void onError() { + Log.e(LOG_TAG, "failed to recover from device-session conflict"); + logErrorAndResetDeviceRegistrationVersion(error, fxAccount); + } + + @Override + public void handleError(Exception e) { + onError(); + } + + @Override + public void handleFailure(FxAccountClientRemoteException e) { + onError(); + } + + @Override + public void handleSuccess(FxAccountDevice[] devices) { + for (FxAccountDevice device : devices) { + if (device.isCurrentDevice) { + fxAccount.setFxAUserData(device.id, 0); // Reset device registration version + if (!allowRecursion) { + Log.d(LOG_TAG, "Failure to register a device on the second try"); + break; + } + try { + doFxaRegistration(context, subscription, false); + return; + } catch (InvalidFxAState e) { + Log.d(LOG_TAG, "Invalid state when trying to recover from a session conflict ", e); + break; + } + } + } + onError(); + } + }); + } + + private void setupListeners() throws ClassNotFoundException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + // We have no choice but to use reflection here, sorry :( + Class<?> eventDispatcher = Class.forName("org.mozilla.gecko.EventDispatcher"); + Method getInstance = eventDispatcher.getMethod("getInstance"); + Object instance = getInstance.invoke(null); + Method registerBackgroundThreadListener = eventDispatcher.getMethod("registerBackgroundThreadListener", + BundleEventListener.class, String[].class); + registerBackgroundThreadListener.invoke(instance, this, new String[] { "FxAccountsPush:Subscribe:Response" }); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java new file mode 100644 index 000000000..0117e6320 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java @@ -0,0 +1,95 @@ +package org.mozilla.gecko.fxa; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; + +public class FxAccountPushHandler { + private static final String LOG_TAG = "FxAccountPush"; + + private static final String COMMAND_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected"; + private static final String COMMAND_COLLECTION_CHANGED = "sync:collection_changed"; + + private static final String CLIENTS_COLLECTION = "clients"; + + // Forbid instantiation + private FxAccountPushHandler() {} + + public static void handleFxAPushMessage(Context context, Bundle bundle) { + Log.i(LOG_TAG, "Handling FxA Push Message"); + String rawMessage = bundle.getString("message"); + JSONObject message = null; + if (!TextUtils.isEmpty(rawMessage)) { + try { + message = new JSONObject(rawMessage); + } catch (JSONException e) { + Log.e(LOG_TAG, "Could not parse JSON", e); + return; + } + } + if (message == null) { + // An empty body means we should check the verification state of the account (FxA sends this + // when the account email is verified for example). + // TODO: We're only registering the push endpoint when we are in the Married state, that's why we're skipping the message :( + Log.d(LOG_TAG, "Skipping empty message"); + return; + } + try { + String command = message.getString("command"); + JSONObject data = message.getJSONObject("data"); + switch (command) { + case COMMAND_DEVICE_DISCONNECTED: + handleDeviceDisconnection(context, data); + break; + case COMMAND_COLLECTION_CHANGED: + handleCollectionChanged(context, data); + break; + default: + Log.d(LOG_TAG, "No handler defined for FxA Push command " + command); + break; + } + } catch (JSONException e) { + Log.e(LOG_TAG, "Error while handling FxA push notification", e); + } + } + + private static void handleCollectionChanged(Context context, JSONObject data) throws JSONException { + JSONArray collections = data.getJSONArray("collections"); + int len = collections.length(); + for (int i = 0; i < len; i++) { + if (collections.getString(i).equals(CLIENTS_COLLECTION)) { + final Account account = FirefoxAccounts.getFirefoxAccount(context); + if (account == null) { + Log.e(LOG_TAG, "The account does not exist anymore"); + return; + } + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + fxAccount.requestImmediateSync(new String[] { CLIENTS_COLLECTION }, null); + return; + } + } + } + + private static void handleDeviceDisconnection(Context context, JSONObject data) throws JSONException { + final Account account = FirefoxAccounts.getFirefoxAccount(context); + if (account == null) { + Log.e(LOG_TAG, "The account does not exist anymore"); + return; + } + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + if (!fxAccount.getDeviceId().equals(data.getString("id"))) { + Log.e(LOG_TAG, "The device ID to disconnect doesn't match with the local device ID.\n" + + "Local: " + fxAccount.getDeviceId() + ", ID to disconnect: " + data.getString("id")); + return; + } + AccountManager.get(context).removeAccount(account, null, null); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java new file mode 100644 index 000000000..2f70a363a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java @@ -0,0 +1,31 @@ +/* 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.fxa; + +import android.accounts.Account; +import android.content.Context; +import android.support.annotation.UiThread; + +/** + * Interface definition for a callback to be invoked when an sync status change. + */ +public interface SyncStatusListener { + public Context getContext(); + public Account getAccount(); + + /** + * Called when sync has started. + * This is always called in UiThread. + */ + @UiThread + public void onSyncStarted(); + + /** + * Called when sync has finished. + * This is always called in UiThread. + */ + @UiThread + public void onSyncFinished(); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java new file mode 100644 index 000000000..5c4d7f3cc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java @@ -0,0 +1,52 @@ +/* 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.fxa.activities; + +import org.mozilla.gecko.R; +import android.content.Context; +import android.content.res.TypedArray; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + + /** + * This preference is used to define custom colors for both title and summary texts. + * Color code #777777 (placeholder_grey) is used as the fallback color for both title and summary. + */ +public class CustomColorPreference extends Preference { + private int mTitleColor; + private int mSummaryColor; + + public CustomColorPreference(Context context) { + super(context); + } + + public CustomColorPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public CustomColorPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context, attrs); + } + + public void init(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomColorPreference); + mTitleColor = a.getColor(R.styleable.CustomColorPreference_titleColor, R.color.placeholder_grey); + mSummaryColor = a.getColor(R.styleable.CustomColorPreference_summaryColor, R.color.placeholder_grey); + a.recycle(); + } + + @Override + protected void onBindView(View view) { + super.onBindView(view); + final TextView title = (TextView) view.findViewById(android.R.id.title); + final TextView summary = (TextView) view.findViewById(android.R.id.summary); + title.setTextColor(mTitleColor); + summary.setTextColor(mSummaryColor); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java new file mode 100644 index 000000000..fc8cbf0da --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java @@ -0,0 +1,80 @@ +/* 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.fxa.activities; + +import android.accounts.Account; +import android.app.Activity; +import android.content.Intent; + +import org.mozilla.gecko.Locales.LocaleAwareActivity; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; + +public abstract class FxAccountAbstractActivity extends LocaleAwareActivity { + private static final String LOG_TAG = FxAccountAbstractActivity.class.getSimpleName(); + + protected final boolean cannotResumeWhenAccountsExist; + protected final boolean cannotResumeWhenNoAccountsExist; + + public static final int CAN_ALWAYS_RESUME = 0; + public static final int CANNOT_RESUME_WHEN_ACCOUNTS_EXIST = 1 << 0; + public static final int CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST = 1 << 1; + + public FxAccountAbstractActivity(int resume) { + super(); + this.cannotResumeWhenAccountsExist = 0 != (resume & CANNOT_RESUME_WHEN_ACCOUNTS_EXIST); + this.cannotResumeWhenNoAccountsExist = 0 != (resume & CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST); + } + + /** + * Many Firefox Accounts activities shouldn't display if an account already + * exists. This function redirects as appropriate. + * + * @return true if redirected. + */ + protected boolean redirectIfAppropriate() { + if (cannotResumeWhenAccountsExist || cannotResumeWhenNoAccountsExist) { + final Account account = FirefoxAccounts.getFirefoxAccount(this); + if (cannotResumeWhenAccountsExist && account != null) { + redirectToAction(FxAccountConstants.ACTION_FXA_STATUS); + return true; + } + if (cannotResumeWhenNoAccountsExist && account == null) { + redirectToAction(FxAccountConstants.ACTION_FXA_GET_STARTED); + return true; + } + } + return false; + } + + @Override + public void onResume() { + super.onResume(); + redirectIfAppropriate(); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + overridePendingTransition(0, 0); + } + + protected void launchActivity(Class<? extends Activity> activityClass) { + Intent intent = new Intent(this, activityClass); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + } + + protected void redirectToAction(final String action) { + final Intent intent = new Intent(action); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + finish(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java new file mode 100644 index 000000000..b2afd9c5a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java @@ -0,0 +1,11 @@ +/* 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.fxa.activities; + +public class FxAccountConfirmAccountActivityWeb extends FxAccountWebFlowActivity { + public FxAccountConfirmAccountActivityWeb() { + super(CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST, "manage"); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java new file mode 100644 index 000000000..0e66f1d6c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java @@ -0,0 +1,11 @@ +/* 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.fxa.activities; + +public class FxAccountFinishMigratingActivityWeb extends FxAccountWebFlowActivity { + public FxAccountFinishMigratingActivityWeb() { + super(CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST, "signin", "migration=sync11"); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java new file mode 100644 index 000000000..39a907a44 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java @@ -0,0 +1,11 @@ +/* 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.fxa.activities; + +public class FxAccountGetStartedActivityWeb extends FxAccountWebFlowActivity { + public FxAccountGetStartedActivityWeb() { + super(CANNOT_RESUME_WHEN_ACCOUNTS_EXIST, "signup"); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java new file mode 100644 index 000000000..4bb929f0a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java @@ -0,0 +1,228 @@ +/* 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.fxa.activities; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.util.TypedValue; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.widget.Toast; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.Locales.LocaleAwareAppCompatActivity; +import org.mozilla.gecko.R; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.sync.Utils; + +/** + * Activity which displays account status. + */ +public class FxAccountStatusActivity extends LocaleAwareAppCompatActivity { + private static final String LOG_TAG = FxAccountStatusActivity.class.getSimpleName(); + + protected FxAccountStatusFragment statusFragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Display the fragment as the content. + statusFragment = new FxAccountStatusFragment(); + getSupportFragmentManager() + .beginTransaction() + .replace(android.R.id.content, statusFragment) + .commit(); + + maybeSetHomeButtonEnabled(); + } + + /** + * Sufficiently recent Android versions need additional code to receive taps + * on the status bar to go "up". See <a + * href="http://stackoverflow.com/a/8953148">this stackoverflow answer</a> for + * more information. + */ + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + protected void maybeSetHomeButtonEnabled() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + Logger.debug(LOG_TAG, "Not enabling home button; version too low."); + return; + } + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + Logger.debug(LOG_TAG, "Enabling home button."); + actionBar.setHomeButtonEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + return; + } + Logger.debug(LOG_TAG, "Not enabling home button."); + } + + @Override + public void onResume() { + super.onResume(); + + final AndroidFxAccount fxAccount = getAndroidFxAccount(); + if (fxAccount == null) { + Logger.warn(LOG_TAG, "Could not get Firefox Account."); + + // Gracefully redirect to get started. + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + + setResult(RESULT_CANCELED); + finish(); + return; + } + statusFragment.refresh(fxAccount); + } + + /** + * Helper to fetch (unique) Android Firefox Account if one exists, or return null. + */ + protected AndroidFxAccount getAndroidFxAccount() { + Account account = FirefoxAccounts.getFirefoxAccount(this); + if (account == null) { + return null; + } + return new AndroidFxAccount(this, account); + } + + + /** + * Helper function to maybe remove the given Android account. + */ + @SuppressLint("InlinedApi") + public static void maybeDeleteAndroidAccount(final Activity activity, final Account account, final Intent intent) { + if (account == null) { + Logger.warn(LOG_TAG, "Trying to delete null account; ignoring request."); + return; + } + + final AccountManagerCallback<Boolean> callback = new AccountManagerCallback<Boolean>() { + @Override + public void run(AccountManagerFuture<Boolean> future) { + Logger.info(LOG_TAG, "Account " + Utils.obfuscateEmail(account.name) + " removed."); + final String text = activity.getResources().getString(R.string.fxaccount_remove_account_toast, account.name); + Toast.makeText(activity, text, Toast.LENGTH_LONG).show(); + if (intent != null) { + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(intent); + } + activity.finish(); + } + }; + + /* + * Get the best dialog icon from the theme on v11+. + * See http://stackoverflow.com/questions/14910536/android-dialog-theme-makes-icon-too-light/14910945#14910945. + */ + final int icon; + final TypedValue typedValue = new TypedValue(); + activity.getTheme().resolveAttribute(android.R.attr.alertDialogIcon, typedValue, true); + icon = typedValue.resourceId; + + final AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle(R.string.fxaccount_remove_account_dialog_title) + .setIcon(icon) + .setMessage(R.string.fxaccount_remove_account_dialog_message) + .setPositiveButton(android.R.string.ok, new Dialog.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + AccountManager.get(activity).removeAccount(account, callback, null); + } + }) + .setNegativeButton(android.R.string.cancel, new Dialog.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }) + .create(); + + dialog.show(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } + + if (itemId == R.id.enable_debug_mode) { + FxAccountUtils.LOG_PERSONAL_INFORMATION = !FxAccountUtils.LOG_PERSONAL_INFORMATION; + Toast.makeText(this, (FxAccountUtils.LOG_PERSONAL_INFORMATION ? "Enabled" : "Disabled") + + " Firefox Account personal information!", Toast.LENGTH_LONG).show(); + item.setChecked(!item.isChecked()); + // Display or hide debug options. + statusFragment.hardRefresh(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + final MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.fxaccount_status_menu, menu); + // !defined(MOZILLA_OFFICIAL) || defined(NIGHTLY_BUILD) || defined(MOZ_DEBUG) + boolean enabled = !AppConstants.MOZILLA_OFFICIAL || AppConstants.NIGHTLY_BUILD || AppConstants.DEBUG_BUILD; + if (!enabled) { + menu.removeItem(R.id.enable_debug_mode); + } else { + final MenuItem debugModeItem = menu.findItem(R.id.enable_debug_mode); + if (debugModeItem != null) { + // Update checked state based on internal flag. + menu.findItem(R.id.enable_debug_mode).setChecked(FxAccountUtils.LOG_PERSONAL_INFORMATION); + } + } + return super.onCreateOptionsMenu(menu); + }; + + @Override + public void openOptionsMenu() { + // This is a workaround of an Android bug: + // https://code.google.com/p/android/issues/detail?id=185217 + // openOptionsMenu isn't overriden by WindowDecorActionBar, which is used by AppCompatActivity, + // meaning getSupportActionbar().openOptionsMenu doesn't work. + // Based loosely on the code in: + // http://androidxref.com/6.0.1_r10/xref/frameworks/support/v7/appcompat/src/android/support/v7/internal/app/WindowDecorActionBar.java#getDecorToolbar + + final Window window = getWindow(); + final View decor = window.getDecorView(); + final View view = decor.findViewById(R.id.action_bar); + + if (view instanceof Toolbar) { + final Toolbar toolbar = (Toolbar) view; + toolbar.showOverflowMenu(); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java new file mode 100644 index 000000000..a30b92e5f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java @@ -0,0 +1,949 @@ +/* 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.fxa.activities; + +import android.accounts.Account; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.preference.CheckBoxPreference; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceChangeListener; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceCategory; +import android.preference.PreferenceScreen; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.text.format.DateUtils; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.background.preferences.PreferenceFragment; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.SyncStatusListener; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.Married; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.sync.FxAccountSyncStatusHelper; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate; +import org.mozilla.gecko.sync.SyncConfiguration; +import org.mozilla.gecko.sync.setup.activities.ActivityUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * A fragment that displays the status of an AndroidFxAccount. + * <p> + * The owning activity is responsible for providing an AndroidFxAccount at + * appropriate times. + */ +public class FxAccountStatusFragment + extends PreferenceFragment + implements OnPreferenceClickListener, OnPreferenceChangeListener { + private static final String LOG_TAG = FxAccountStatusFragment.class.getSimpleName(); + + /** + * If a device claims to have synced before this date, we will assume it has never synced. + */ + private static final Date EARLIEST_VALID_SYNCED_DATE; + + static { + final Calendar c = GregorianCalendar.getInstance(); + c.set(2000, Calendar.JANUARY, 1, 0, 0, 0); + EARLIEST_VALID_SYNCED_DATE = c.getTime(); + } + + // When a checkbox is toggled, wait 5 seconds (for other checkbox actions) + // before trying to sync. Should we kill off the fragment before the sync + // request happens, that's okay: the runnable will run if the UI thread is + // still around to service it, and since we're not updating any UI, we'll just + // schedule the sync as usual. See also comment below about garbage + // collection. + private static final long DELAY_IN_MILLISECONDS_BEFORE_REQUESTING_SYNC = 5 * 1000; + private static final long LAST_SYNCED_TIME_UPDATE_INTERVAL_IN_MILLISECONDS = 60 * 1000; + private static final long PROFILE_FETCH_RETRY_INTERVAL_IN_MILLISECONDS = 60 * 1000; + + private static final String[] STAGES_TO_SYNC_ON_DEVICE_NAME_CHANGE = new String[] { "clients" }; + + // By default, the auth/account server preference is only shown when the + // account is configured to use a custom server. In debug mode, this is set. + private static boolean ALWAYS_SHOW_AUTH_SERVER = false; + + // By default, the Sync server preference is only shown when the account is + // configured to use a custom Sync server. In debug mode, this is set. + private static boolean ALWAYS_SHOW_SYNC_SERVER = false; + + protected PreferenceCategory accountCategory; + protected Preference profilePreference; + protected Preference manageAccountPreference; + protected Preference authServerPreference; + protected Preference removeAccountPreference; + + protected Preference needsPasswordPreference; + protected Preference needsUpgradePreference; + protected Preference needsVerificationPreference; + protected Preference needsMasterSyncAutomaticallyEnabledPreference; + protected Preference needsFinishMigratingPreference; + + protected PreferenceCategory syncCategory; + + protected CheckBoxPreference bookmarksPreference; + protected CheckBoxPreference historyPreference; + protected CheckBoxPreference tabsPreference; + protected CheckBoxPreference passwordsPreference; + protected CheckBoxPreference readingListPreference; + + protected EditTextPreference deviceNamePreference; + protected Preference syncServerPreference; + protected Preference morePreference; + protected Preference syncNowPreference; + + protected volatile AndroidFxAccount fxAccount; + // The contract is: when fxAccount is non-null, then clientsDataDelegate is + // non-null. If violated then an IllegalStateException is thrown. + protected volatile SharedPreferencesClientsDataDelegate clientsDataDelegate; + + // Used to post delayed sync requests. + protected Handler handler; + + // Member variable so that re-posting pushes back the already posted instance. + // This Runnable references the fxAccount above, but it is not specific to a + // single account. (That is, it does not capture a single account instance.) + protected Runnable requestSyncRunnable; + + // Runnable to update last synced time. + protected Runnable lastSyncedTimeUpdateRunnable; + + // Broadcast Receiver to update profile Information. + protected FxAccountProfileInformationReceiver accountProfileInformationReceiver; + + protected final InnerSyncStatusDelegate syncStatusDelegate = new InnerSyncStatusDelegate(); + private Target profileAvatarTarget; + + protected Preference ensureFindPreference(String key) { + Preference preference = findPreference(key); + if (preference == null) { + throw new IllegalStateException("Could not find preference with key: " + key); + } + return preference; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // We need to do this before we can query the hardware menu button state. + // We're guaranteed to have an activity at this point (onAttach is called + // before onCreate). It's okay to call this multiple times (with different + // contexts). + HardwareUtils.init(getActivity()); + + addPreferences(); + } + + protected void addPreferences() { + addPreferencesFromResource(R.xml.fxaccount_status_prefscreen); + + accountCategory = (PreferenceCategory) ensureFindPreference("signed_in_as_category"); + profilePreference = ensureFindPreference("profile"); + manageAccountPreference = ensureFindPreference("manage_account"); + authServerPreference = ensureFindPreference("auth_server"); + removeAccountPreference = ensureFindPreference("remove_account"); + + needsPasswordPreference = ensureFindPreference("needs_credentials"); + needsUpgradePreference = ensureFindPreference("needs_upgrade"); + needsVerificationPreference = ensureFindPreference("needs_verification"); + needsMasterSyncAutomaticallyEnabledPreference = ensureFindPreference("needs_master_sync_automatically_enabled"); + needsFinishMigratingPreference = ensureFindPreference("needs_finish_migrating"); + + syncCategory = (PreferenceCategory) ensureFindPreference("sync_category"); + + bookmarksPreference = (CheckBoxPreference) ensureFindPreference("bookmarks"); + historyPreference = (CheckBoxPreference) ensureFindPreference("history"); + tabsPreference = (CheckBoxPreference) ensureFindPreference("tabs"); + passwordsPreference = (CheckBoxPreference) ensureFindPreference("passwords"); + + if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) { + removeDebugButtons(); + } else { + connectDebugButtons(); + ALWAYS_SHOW_AUTH_SERVER = true; + ALWAYS_SHOW_SYNC_SERVER = true; + } + + profilePreference.setOnPreferenceClickListener(this); + manageAccountPreference.setOnPreferenceClickListener(this); + removeAccountPreference.setOnPreferenceClickListener(this); + + needsPasswordPreference.setOnPreferenceClickListener(this); + needsVerificationPreference.setOnPreferenceClickListener(this); + needsFinishMigratingPreference.setOnPreferenceClickListener(this); + + bookmarksPreference.setOnPreferenceClickListener(this); + historyPreference.setOnPreferenceClickListener(this); + tabsPreference.setOnPreferenceClickListener(this); + passwordsPreference.setOnPreferenceClickListener(this); + + deviceNamePreference = (EditTextPreference) ensureFindPreference("device_name"); + deviceNamePreference.setOnPreferenceChangeListener(this); + + syncServerPreference = ensureFindPreference("sync_server"); + morePreference = ensureFindPreference("more"); + morePreference.setOnPreferenceClickListener(this); + + syncNowPreference = ensureFindPreference("sync_now"); + syncNowPreference.setEnabled(true); + syncNowPreference.setOnPreferenceClickListener(this); + + ensureFindPreference("linktos").setOnPreferenceClickListener(this); + ensureFindPreference("linkprivacy").setOnPreferenceClickListener(this); + } + + /** + * We intentionally don't refresh here. Our owning activity is responsible for + * providing an AndroidFxAccount to our refresh method in its onResume method. + */ + @Override + public void onResume() { + super.onResume(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + if (preference == profilePreference) { + ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), "about:accounts?action=avatar"); + return true; + } + + if (preference == manageAccountPreference) { + ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), "about:accounts?action=manage"); + return true; + } + + if (preference == removeAccountPreference) { + FxAccountStatusActivity.maybeDeleteAndroidAccount(getActivity(), fxAccount.getAndroidAccount(), null); + return true; + } + + if (preference == needsPasswordPreference) { + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_UPDATE_CREDENTIALS); + intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + + return true; + } + + if (preference == needsFinishMigratingPreference) { + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_FINISH_MIGRATING); + intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + + return true; + } + + if (preference == needsVerificationPreference) { + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_CONFIRM_ACCOUNT); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES); + startActivity(intent); + + return true; + } + + if (preference == bookmarksPreference || + preference == historyPreference || + preference == passwordsPreference || + preference == tabsPreference) { + saveEngineSelections(); + return true; + } + + if (preference == morePreference) { + getActivity().openOptionsMenu(); + return true; + } + + if (preference == syncNowPreference) { + if (fxAccount != null) { + fxAccount.requestImmediateSync(null, null); + } + return true; + } + + if (TextUtils.equals("linktos", preference.getKey())) { + ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), getResources().getString(R.string.fxaccount_link_tos)); + return true; + } + + if (TextUtils.equals("linkprivacy", preference.getKey())) { + ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), getResources().getString(R.string.fxaccount_link_pn)); + return true; + } + + return false; + } + + protected void setCheckboxesEnabled(boolean enabled) { + bookmarksPreference.setEnabled(enabled); + historyPreference.setEnabled(enabled); + tabsPreference.setEnabled(enabled); + passwordsPreference.setEnabled(enabled); + // Since we can't sync, we can't update our remote client record. + deviceNamePreference.setEnabled(enabled); + syncNowPreference.setEnabled(enabled); + } + + /** + * Show at most one error preference, hiding all others. + * + * @param errorPreferenceToShow + * single error preference to show; if null, hide all error preferences + */ + protected void showOnlyOneErrorPreference(Preference errorPreferenceToShow) { + final Preference[] errorPreferences = new Preference[] { + this.needsPasswordPreference, + this.needsUpgradePreference, + this.needsVerificationPreference, + this.needsMasterSyncAutomaticallyEnabledPreference, + this.needsFinishMigratingPreference, + }; + for (Preference errorPreference : errorPreferences) { + final boolean currentlyShown = null != findPreference(errorPreference.getKey()); + final boolean shouldBeShown = errorPreference == errorPreferenceToShow; + if (currentlyShown == shouldBeShown) { + continue; + } + if (shouldBeShown) { + syncCategory.addPreference(errorPreference); + } else { + syncCategory.removePreference(errorPreference); + } + } + } + + protected void showNeedsPassword() { + syncCategory.setTitle(R.string.fxaccount_status_sync); + showOnlyOneErrorPreference(needsPasswordPreference); + setCheckboxesEnabled(false); + } + + protected void showNeedsUpgrade() { + syncCategory.setTitle(R.string.fxaccount_status_sync); + showOnlyOneErrorPreference(needsUpgradePreference); + setCheckboxesEnabled(false); + } + + protected void showNeedsVerification() { + syncCategory.setTitle(R.string.fxaccount_status_sync); + showOnlyOneErrorPreference(needsVerificationPreference); + setCheckboxesEnabled(false); + } + + protected void showNeedsMasterSyncAutomaticallyEnabled() { + syncCategory.setTitle(R.string.fxaccount_status_sync); + needsMasterSyncAutomaticallyEnabledPreference.setTitle(AppConstants.Versions.preLollipop ? + R.string.fxaccount_status_needs_master_sync_automatically_enabled : + R.string.fxaccount_status_needs_master_sync_automatically_enabled_v21); + showOnlyOneErrorPreference(needsMasterSyncAutomaticallyEnabledPreference); + setCheckboxesEnabled(false); + } + + protected void showNeedsFinishMigrating() { + syncCategory.setTitle(R.string.fxaccount_status_sync); + showOnlyOneErrorPreference(needsFinishMigratingPreference); + setCheckboxesEnabled(false); + } + + protected void showConnected() { + syncCategory.setTitle(R.string.fxaccount_status_sync_enabled); + showOnlyOneErrorPreference(null); + setCheckboxesEnabled(true); + } + + protected class InnerSyncStatusDelegate implements SyncStatusListener { + protected final Runnable refreshRunnable = new Runnable() { + @Override + public void run() { + refresh(); + } + }; + + @Override + public Context getContext() { + return FxAccountStatusFragment.this.getActivity(); + } + + @Override + public Account getAccount() { + return fxAccount.getAndroidAccount(); + } + + @Override + public void onSyncStarted() { + if (fxAccount == null) { + return; + } + Logger.info(LOG_TAG, "Got sync started message; refreshing."); + getActivity().runOnUiThread(refreshRunnable); + } + + @Override + public void onSyncFinished() { + if (fxAccount == null) { + return; + } + Logger.info(LOG_TAG, "Got sync finished message; refreshing."); + getActivity().runOnUiThread(refreshRunnable); + } + } + + /** + * Notify the fragment that a new AndroidFxAccount instance is current. + * <p> + * <b>Important:</b> call this method on the UI thread! + * <p> + * In future, this might be a Loader. + * + * @param fxAccount new instance. + */ + public void refresh(AndroidFxAccount fxAccount) { + if (fxAccount == null) { + throw new IllegalArgumentException("fxAccount must not be null"); + } + this.fxAccount = fxAccount; + try { + this.clientsDataDelegate = new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), getActivity().getApplicationContext()); + } catch (Exception e) { + Logger.error(LOG_TAG, "Got exception fetching Sync prefs associated to Firefox Account; aborting.", e); + // Something is terribly wrong; best to get a stack trace rather than + // continue with a null clients delegate. + throw new IllegalStateException(e); + } + + handler = new Handler(); // Attached to current (assumed to be UI) thread. + + // Runnable is not specific to one Firefox Account. This runnable will keep + // a reference to this fragment alive, but we expect posted runnables to be + // serviced very quickly, so this is not an issue. + requestSyncRunnable = new RequestSyncRunnable(); + lastSyncedTimeUpdateRunnable = new LastSyncTimeUpdateRunnable(); + + // We would very much like register these status observers in bookended + // onResume/onPause calls, but because the Fragment gets onResume during the + // Activity's super.onResume, it hasn't yet been told its Firefox Account. + // So we register the observer here (and remove it in onPause), and open + // ourselves to the possibility that we don't have properly paired + // register/unregister calls. + FxAccountSyncStatusHelper.getInstance().startObserving(syncStatusDelegate); + + // Register a local broadcast receiver to get profile cached notification. + final IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION); + accountProfileInformationReceiver = new FxAccountProfileInformationReceiver(); + LocalBroadcastManager.getInstance(getActivity()).registerReceiver(accountProfileInformationReceiver, intentFilter); + + // profilePreference is set during onCreate, so it's definitely not null here. + final float cornerRadius = getResources().getDimension(R.dimen.fxaccount_profile_image_width) / 2; + profileAvatarTarget = new PicassoPreferenceIconTarget(getResources(), profilePreference, cornerRadius); + + refresh(); + } + + @Override + public void onPause() { + super.onPause(); + FxAccountSyncStatusHelper.getInstance().stopObserving(syncStatusDelegate); + + // Focus lost, remove scheduled update if any. + if (lastSyncedTimeUpdateRunnable != null) { + handler.removeCallbacks(lastSyncedTimeUpdateRunnable); + } + + // Focus lost, unregister broadcast receiver. + if (accountProfileInformationReceiver != null) { + LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(accountProfileInformationReceiver); + } + + if (profileAvatarTarget != null) { + Picasso.with(getActivity()).cancelRequest(profileAvatarTarget); + profileAvatarTarget = null; + } + } + + protected void hardRefresh() { + // This is the only way to guarantee that the EditText dialogs created by + // EditTextPreferences are re-created. This works around the issue described + // at http://androiddev.orkitra.com/?p=112079. + final PreferenceScreen statusScreen = (PreferenceScreen) ensureFindPreference("status_screen"); + statusScreen.removeAll(); + addPreferences(); + + refresh(); + } + + protected void refresh() { + // refresh is called from our onResume, which can happen before the owning + // Activity tells us about an account (via our public + // refresh(AndroidFxAccount) method). + if (fxAccount == null) { + throw new IllegalArgumentException("fxAccount must not be null"); + } + + updateProfileInformation(); + updateAuthServerPreference(); + updateSyncServerPreference(); + + try { + // There are error states determined by Android, not the login state + // machine, and we have a chance to present these states here. We handle + // them specially, since we can't surface these states as part of syncing, + // because they generally stop syncs from happening regularly. Right now + // there are no such states. + + // Interrogate the Firefox Account's state. + State state = fxAccount.getState(); + switch (state.getNeededAction()) { + case NeedsUpgrade: + showNeedsUpgrade(); + break; + case NeedsPassword: + showNeedsPassword(); + break; + case NeedsVerification: + showNeedsVerification(); + break; + case NeedsFinishMigrating: + showNeedsFinishMigrating(); + break; + case None: + showConnected(); + break; + } + + // We check for the master setting last, since it is not strictly + // necessary for the user to address this error state: it's really a + // warning state. We surface it for the user's convenience, and to prevent + // confused folks wondering why Sync is not working at all. + final boolean masterSyncAutomatically = ContentResolver.getMasterSyncAutomatically(); + if (!masterSyncAutomatically) { + showNeedsMasterSyncAutomaticallyEnabled(); + return; + } + } finally { + // No matter our state, we should update the checkboxes. + updateSelectedEngines(); + } + + final String clientName = clientsDataDelegate.getClientName(); + deviceNamePreference.setSummary(clientName); + deviceNamePreference.setText(clientName); + + updateSyncNowPreference(); + } + + // This is a helper function similar to TabsAccessor.getLastSyncedString() to calculate relative "Last synced" time span. + private String getLastSyncedString(final long startTime) { + if (new Date(startTime).before(EARLIEST_VALID_SYNCED_DATE)) { + return getActivity().getString(R.string.fxaccount_status_never_synced); + } + final CharSequence relativeTimeSpanString = DateUtils.getRelativeTimeSpanString(startTime); + return getActivity().getResources().getString(R.string.fxaccount_status_last_synced, relativeTimeSpanString); + } + + protected void updateSyncNowPreference() { + final boolean currentlySyncing = fxAccount.isCurrentlySyncing(); + syncNowPreference.setEnabled(!currentlySyncing); + if (currentlySyncing) { + syncNowPreference.setTitle(R.string.fxaccount_status_syncing); + } else { + syncNowPreference.setTitle(R.string.fxaccount_status_sync_now); + } + scheduleAndUpdateLastSyncedTime(); + } + + private void updateProfileInformation() { + + final ExtendedJSONObject profileJSON = fxAccount.getProfileJSON(); + if (profileJSON == null) { + // Update the profile title with email as the fallback. + // Profile icon by default use the default avatar as the fallback. + profilePreference.setTitle(fxAccount.getEmail()); + return; + } + + updateProfileInformation(profileJSON); + } + + /** + * Update profile information from json on UI thread. + * + * @param profileJSON json fetched from server. + */ + protected void updateProfileInformation(final ExtendedJSONObject profileJSON) { + // View changes must always be done on UI thread. + ThreadUtils.assertOnUiThread(); + + FxAccountUtils.pii(LOG_TAG, "Profile JSON is: " + profileJSON.toJSONString()); + + final String userName = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_USERNAME); + // Update the profile username and email if available. + if (!TextUtils.isEmpty(userName)) { + profilePreference.setTitle(userName); + profilePreference.setSummary(fxAccount.getEmail()); + } else { + profilePreference.setTitle(fxAccount.getEmail()); + } + + // Avatar URI empty, skip profile image fetch. + final String avatarURI = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_AVATAR); + if (TextUtils.isEmpty(avatarURI)) { + Logger.info(LOG_TAG, "AvatarURI is empty, skipping profile image fetch."); + return; + } + + // Using noPlaceholder would avoid a pop of the default image, but it's not available in the version of Picasso + // we ship in the tree. + Picasso + .with(getActivity()) + .load(avatarURI) + .centerInside() + .resizeDimen(R.dimen.fxaccount_profile_image_width, R.dimen.fxaccount_profile_image_height) + .placeholder(R.drawable.sync_avatar_default) + .error(R.drawable.sync_avatar_default) + .into(profileAvatarTarget); + } + + private void scheduleAndUpdateLastSyncedTime() { + final String lastSynced = getLastSyncedString(fxAccount.getLastSyncedTimestamp()); + syncNowPreference.setSummary(lastSynced); + handler.postDelayed(lastSyncedTimeUpdateRunnable, LAST_SYNCED_TIME_UPDATE_INTERVAL_IN_MILLISECONDS); + } + + protected void updateAuthServerPreference() { + final String authServer = fxAccount.getAccountServerURI(); + final boolean shouldBeShown = ALWAYS_SHOW_AUTH_SERVER || !FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT.equals(authServer); + final boolean currentlyShown = null != findPreference(authServerPreference.getKey()); + if (currentlyShown != shouldBeShown) { + if (shouldBeShown) { + accountCategory.addPreference(authServerPreference); + } else { + accountCategory.removePreference(authServerPreference); + } + } + // Always set the summary, because on first run, the preference is visible, + // and the above block will be skipped if there is a custom value. + authServerPreference.setSummary(authServer); + } + + protected void updateSyncServerPreference() { + final String syncServer = fxAccount.getTokenServerURI(); + final boolean shouldBeShown = ALWAYS_SHOW_SYNC_SERVER || !FxAccountConstants.DEFAULT_TOKEN_SERVER_ENDPOINT.equals(syncServer); + final boolean currentlyShown = null != findPreference(syncServerPreference.getKey()); + if (currentlyShown != shouldBeShown) { + if (shouldBeShown) { + syncCategory.addPreference(syncServerPreference); + } else { + syncCategory.removePreference(syncServerPreference); + } + } + // Always set the summary, because on first run, the preference is visible, + // and the above block will be skipped if there is a custom value. + syncServerPreference.setSummary(syncServer); + } + + /** + * Query shared prefs for the current engine state, and update the UI + * accordingly. + * <p> + * In future, we might want this to be on a background thread, or implemented + * as a Loader. + */ + protected void updateSelectedEngines() { + try { + SharedPreferences syncPrefs = fxAccount.getSyncPrefs(); + Map<String, Boolean> engines = SyncConfiguration.getUserSelectedEngines(syncPrefs); + if (engines != null) { + bookmarksPreference.setChecked(engines.containsKey("bookmarks") && engines.get("bookmarks")); + historyPreference.setChecked(engines.containsKey("history") && engines.get("history")); + passwordsPreference.setChecked(engines.containsKey("passwords") && engines.get("passwords")); + tabsPreference.setChecked(engines.containsKey("tabs") && engines.get("tabs")); + return; + } + + // We don't have user specified preferences. Perhaps we have seen a meta/global? + Set<String> enabledNames = SyncConfiguration.getEnabledEngineNames(syncPrefs); + if (enabledNames != null) { + bookmarksPreference.setChecked(enabledNames.contains("bookmarks")); + historyPreference.setChecked(enabledNames.contains("history")); + passwordsPreference.setChecked(enabledNames.contains("passwords")); + tabsPreference.setChecked(enabledNames.contains("tabs")); + return; + } + + // Okay, we don't have userSelectedEngines or enabledEngines. That means + // the user hasn't specified to begin with, we haven't specified here, and + // we haven't already seen, Sync engines. We don't know our state, so + // let's check everything (the default) and disable everything. + bookmarksPreference.setChecked(true); + historyPreference.setChecked(true); + passwordsPreference.setChecked(true); + tabsPreference.setChecked(true); + setCheckboxesEnabled(false); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception getting engines to select; ignoring.", e); + return; + } + } + + /** + * Persist engine selections to local shared preferences, and request a sync + * to persist selections to remote storage. + */ + protected void saveEngineSelections() { + final Map<String, Boolean> engineSelections = new HashMap<String, Boolean>(); + engineSelections.put("bookmarks", bookmarksPreference.isChecked()); + engineSelections.put("history", historyPreference.isChecked()); + engineSelections.put("passwords", passwordsPreference.isChecked()); + engineSelections.put("tabs", tabsPreference.isChecked()); + + // No GlobalSession.config, so store directly to shared prefs. We do this on + // a background thread to avoid IO on the main thread and strict mode + // warnings. + new Thread(new PersistEngineSelectionsRunnable(engineSelections)).start(); + } + + protected void requestDelayedSync() { + Logger.info(LOG_TAG, "Posting a delayed request for a sync sometime soon."); + handler.removeCallbacks(requestSyncRunnable); + handler.postDelayed(requestSyncRunnable, DELAY_IN_MILLISECONDS_BEFORE_REQUESTING_SYNC); + } + + /** + * Remove all traces of debug buttons. By default, no debug buttons are shown. + */ + protected void removeDebugButtons() { + final PreferenceScreen statusScreen = (PreferenceScreen) ensureFindPreference("status_screen"); + final PreferenceCategory debugCategory = (PreferenceCategory) ensureFindPreference("debug_category"); + statusScreen.removePreference(debugCategory); + } + + /** + * A Runnable that persists engine selections to shared prefs, and then + * requests a delayed sync. + * <p> + * References the member <code>fxAccount</code> and is specific to the Android + * account associated to that account. + */ + protected class PersistEngineSelectionsRunnable implements Runnable { + private final Map<String, Boolean> engineSelections; + + protected PersistEngineSelectionsRunnable(Map<String, Boolean> engineSelections) { + this.engineSelections = engineSelections; + } + + @Override + public void run() { + try { + // Name shadowing -- do you like it, or do you love it? + AndroidFxAccount fxAccount = FxAccountStatusFragment.this.fxAccount; + if (fxAccount == null) { + return; + } + Logger.info(LOG_TAG, "Persisting engine selections: " + engineSelections.toString()); + SyncConfiguration.storeSelectedEnginesToPrefs(fxAccount.getSyncPrefs(), engineSelections); + requestDelayedSync(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception persisting selected engines; ignoring.", e); + return; + } + } + } + + /** + * A Runnable that requests a sync. + * <p> + * References the member <code>fxAccount</code>, but is not specific to the + * Android account associated to that account. + */ + protected class RequestSyncRunnable implements Runnable { + @Override + public void run() { + // Name shadowing -- do you like it, or do you love it? + AndroidFxAccount fxAccount = FxAccountStatusFragment.this.fxAccount; + if (fxAccount == null) { + return; + } + Logger.info(LOG_TAG, "Requesting a sync sometime soon."); + fxAccount.requestEventualSync(null, null); + } + } + + /** + * The Runnable that schedules a future update and updates the last synced time. + */ + protected class LastSyncTimeUpdateRunnable implements Runnable { + @Override + public void run() { + scheduleAndUpdateLastSyncedTime(); + } + } + + /** + * Broadcast receiver to receive updates for the cached profile action. + */ + public class FxAccountProfileInformationReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!intent.getAction().equals(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION)) { + return; + } + + Logger.info(LOG_TAG, "Profile avatar cache update action broadcast received."); + // Update the UI from cached profile json on the main thread. + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + updateProfileInformation(); + } + }); + } + } + + /** + * A separate listener to separate debug logic from main code paths. + */ + protected class DebugPreferenceClickListener implements OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(Preference preference) { + final String key = preference.getKey(); + if ("debug_refresh".equals(key)) { + Logger.info(LOG_TAG, "Refreshing."); + refresh(); + } else if ("debug_dump".equals(key)) { + fxAccount.dump(); + } else if ("debug_force_sync".equals(key)) { + Logger.info(LOG_TAG, "Force syncing."); + fxAccount.requestImmediateSync(null, null); + // No sense refreshing, since the sync will complete in the future. + } else if ("debug_forget_certificate".equals(key)) { + State state = fxAccount.getState(); + try { + Married married = (Married) state; + Logger.info(LOG_TAG, "Moving to Cohabiting state: Forgetting certificate."); + fxAccount.setState(married.makeCohabitingState()); + refresh(); + } catch (ClassCastException e) { + Logger.info(LOG_TAG, "Not in Married state; can't forget certificate."); + // Ignore. + } + } else if ("debug_invalidate_certificate".equals(key)) { + State state = fxAccount.getState(); + try { + Married married = (Married) state; + Logger.info(LOG_TAG, "Invalidating certificate."); + fxAccount.setState(married.makeCohabitingState().withCertificate("INVALID CERTIFICATE")); + refresh(); + } catch (ClassCastException e) { + Logger.info(LOG_TAG, "Not in Married state; can't invalidate certificate."); + // Ignore. + } + } else if ("debug_require_password".equals(key)) { + Logger.info(LOG_TAG, "Moving to Separated state: Forgetting password."); + State state = fxAccount.getState(); + fxAccount.setState(state.makeSeparatedState()); + refresh(); + } else if ("debug_require_upgrade".equals(key)) { + Logger.info(LOG_TAG, "Moving to Doghouse state: Requiring upgrade."); + State state = fxAccount.getState(); + fxAccount.setState(state.makeDoghouseState()); + refresh(); + } else if ("debug_migrated_from_sync11".equals(key)) { + Logger.info(LOG_TAG, "Moving to MigratedFromSync11 state: Requiring password."); + State state = fxAccount.getState(); + fxAccount.setState(state.makeMigratedFromSync11State(null)); + refresh(); + } else if ("debug_make_account_stage".equals(key)) { + Logger.info(LOG_TAG, "Moving Account endpoints, in place, to stage. Deleting Sync and RL prefs and requiring password."); + fxAccount.unsafeTransitionToStageEndpoints(); + refresh(); + } else if ("debug_make_account_default".equals(key)) { + Logger.info(LOG_TAG, "Moving Account endpoints, in place, to default (production). Deleting Sync and RL prefs and requiring password."); + fxAccount.unsafeTransitionToDefaultEndpoints(); + refresh(); + } else { + return false; + } + return true; + } + } + + /** + * Iterate through debug buttons, adding a special debug preference click + * listener to each of them. + */ + protected void connectDebugButtons() { + // Separate listener to really separate debug logic from main code paths. + final OnPreferenceClickListener listener = new DebugPreferenceClickListener(); + + // We don't want to use Android resource strings for debug UI, so we just + // use the keys throughout. + final PreferenceCategory debugCategory = (PreferenceCategory) ensureFindPreference("debug_category"); + debugCategory.setTitle(debugCategory.getKey()); + + for (int i = 0; i < debugCategory.getPreferenceCount(); i++) { + final Preference button = debugCategory.getPreference(i); + button.setTitle(button.getKey()); // Not very friendly, but this is for debugging only! + button.setOnPreferenceClickListener(listener); + } + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + if (preference == deviceNamePreference) { + String newClientName = (String) newValue; + if (TextUtils.isEmpty(newClientName)) { + newClientName = clientsDataDelegate.getDefaultClientName(); + } + final long now = System.currentTimeMillis(); + clientsDataDelegate.setClientName(newClientName, now); + // Force sync the client record, we want the user to see the device name change immediately + // on the FxA Device Manager if possible ( = we are online) to avoid confusion + // ("I changed my Android's device name but I don't see it on my computer"). + fxAccount.requestImmediateSync(STAGES_TO_SYNC_ON_DEVICE_NAME_CHANGE, null); + hardRefresh(); // Updates the value displayed to the user, among other things. + return true; + } + + // For everything else, accept the change. + return true; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java new file mode 100644 index 000000000..5a2ea79c8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java @@ -0,0 +1,11 @@ +/* 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.fxa.activities; + +public class FxAccountUpdateCredentialsActivityWeb extends FxAccountWebFlowActivity { + public FxAccountUpdateCredentialsActivityWeb() { + super(CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST, "force_auth"); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java new file mode 100644 index 000000000..e33e9c577 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java @@ -0,0 +1,91 @@ +/* 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.fxa.activities; + +import android.content.Intent; +import android.os.Bundle; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.sync.setup.activities.ActivityUtils; + +/** + * Activity which shows the status activity or passes through to web flow. + */ +public abstract class FxAccountWebFlowActivity extends FxAccountAbstractActivity { + protected static final String LOG_TAG = FxAccountWebFlowActivity.class.getSimpleName(); + + protected static final String ABOUT_ACCOUNTS = "about:accounts"; + + public static final String EXTRA_ENDPOINT = "entrypoint"; + + protected static final String[] EXTRAS_TO_PASSTHROUGH = new String[] { + EXTRA_ENDPOINT, + }; + + private final String action; + private final String extras; + + public FxAccountWebFlowActivity(int resume, String action) { + this(resume, action, null); + } + + public FxAccountWebFlowActivity(int resume, String action, String extras) { + super(resume); + this.action = action; + this.extras = (extras != null) ? ("&" + extras) : ""; + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreate(Bundle icicle) { + Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG); + Logger.debug(LOG_TAG, "onCreate(" + icicle + ")"); + + Locales.initializeLocale(getApplicationContext()); + + super.onCreate(icicle); + } + + protected boolean redirectIfAppropriate() { + final boolean redirected = super.redirectIfAppropriate(); + if (redirected) { + return true; + } + + final StringBuilder sb = new StringBuilder(); + sb.append(ABOUT_ACCOUNTS); + sb.append("?action="); + sb.append(action); + sb.append(extras); + + // Pass through a set of known string values from intent extras to about:accounts. + final Intent intent = getIntent(); + if (intent != null) { + for (String key : EXTRAS_TO_PASSTHROUGH) { + final String value = intent.getStringExtra(key); + if (value != null) { + sb.append("&"); + sb.append(key); + sb.append("="); + sb.append(value); + } + } + } + + ActivityUtils.openURLInFennec(getApplicationContext(), sb.toString()); + return true; + } + + @Override + public void onResume() { + super.onResume(); + + // We are always redirected. + this.finish(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java new file mode 100644 index 000000000..f71d3ed1c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java @@ -0,0 +1,63 @@ +/* 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.fxa.activities; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.preference.Preference; +import android.support.v4.graphics.drawable.RoundedBitmapDrawable; +import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; +import org.mozilla.gecko.AppConstants; + +/** + * A Picasso Target that updates a preference icon. + * + * Nota bene: Android grew support for updating preference icons programatically + * only in API 11. This class silently ignores requests before API 11. + */ +public class PicassoPreferenceIconTarget implements Target { + private final Preference preference; + private final Resources resources; + private final float cornerRadius; + + public PicassoPreferenceIconTarget(Resources resources, Preference preference) { + this(resources, preference, 0); + } + + public PicassoPreferenceIconTarget(Resources resources, Preference preference, float cornerRadius) { + this.resources = resources; + this.preference = preference; + this.cornerRadius = cornerRadius; + } + + @Override + public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { + final Drawable drawable; + if (cornerRadius > 0) { + final RoundedBitmapDrawable roundedBitmapDrawable; + roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(resources, bitmap); + roundedBitmapDrawable.setCornerRadius(cornerRadius); + roundedBitmapDrawable.setAntiAlias(true); + drawable = roundedBitmapDrawable; + } else { + drawable = new BitmapDrawable(resources, bitmap); + } + preference.setIcon(drawable); + } + + @Override + public void onBitmapFailed(Drawable errorDrawable) { + preference.setIcon(errorDrawable); + } + + @Override + public void onPrepareLoad(Drawable placeHolderDrawable) { + preference.setIcon(placeHolderDrawable); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java new file mode 100644 index 000000000..3f2c5620d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java @@ -0,0 +1,362 @@ +/* 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.fxa.authenticator; + +import java.io.FileOutputStream; +import java.io.PrintStream; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.fxa.login.StateFactory; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; + +import android.content.Context; + +/** + * Android deletes Account objects when the Authenticator that owns the Account + * disappears. This happens when an App is installed to the SD card and the SD + * card is un-mounted or the device is rebooted. + * <p> + * We work around this by pickling the current Firefox account data every sync + * and unpickling when we check if Firefox accounts exist (called from Fennec). + * <p> + * Android just doesn't support installing Apps that define long-lived Services + * and/or own Account types onto the SD card. The documentation says not to do + * it. There are hordes of developers who want to do it, and have tried to + * register for almost every "package installation changed" broadcast intent + * that Android supports. They all explicitly state that the package that has + * changed does *not* receive the broadcast intent, thereby preventing an App + * from re-establishing its state. + * <p> + * <a href="http://developer.android.com/guide/topics/data/install-location.html">Reference.</a> + * <p> + * <b>Quote</b>: Your AbstractThreadedSyncAdapter and all its sync functionality + * will not work until external storage is remounted. + * <p> + * <b>Quote</b>: Your running Service will be killed and will not be restarted + * when external storage is remounted. You can, however, register for the + * ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify + * your application when applications installed on external storage have become + * available to the system again. At which time, you can restart your Service. + * <p> + * Problem: <a href="http://code.google.com/p/android/issues/detail?id=8485">that intent doesn't work</a>! + * <p> + * See bug 768102 for more information in the context of Sync. + */ +public class AccountPickler { + public static final String LOG_TAG = AccountPickler.class.getSimpleName(); + + public static final long PICKLE_VERSION = 3; + + public static final String KEY_PICKLE_VERSION = "pickle_version"; + public static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp"; + + public static final String KEY_ACCOUNT_VERSION = "account_version"; + public static final String KEY_ACCOUNT_TYPE = "account_type"; + public static final String KEY_EMAIL = "email"; + public static final String KEY_PROFILE = "profile"; + public static final String KEY_IDP_SERVER_URI = "idpServerURI"; + public static final String KEY_TOKEN_SERVER_URI = "tokenServerURI"; + public static final String KEY_PROFILE_SERVER_URI = "profileServerURI"; + + public static final String KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP = "authoritiesToSyncAutomaticallyMap"; + + // Deprecated, but maintained for migration purposes. + public static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled"; + + public static final String KEY_BUNDLE = "bundle"; + + /** + * Remove Firefox account persisted to disk. + * This operation is synchronized to avoid race condition while deleting the account. + * + * @param context Android context. + * @param filename name of persisted pickle file; must not contain path separators. + * @return <code>true</code> if given pickle existed and was successfully deleted. + */ + public synchronized static boolean deletePickle(final Context context, final String filename) { + return context.deleteFile(filename); + } + + public static ExtendedJSONObject toJSON(final AndroidFxAccount account, final long now) { + final ExtendedJSONObject o = new ExtendedJSONObject(); + o.put(KEY_PICKLE_VERSION, PICKLE_VERSION); + o.put(KEY_PICKLE_TIMESTAMP, now); + + o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION); + o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE); + o.put(KEY_EMAIL, account.getEmail()); + o.put(KEY_PROFILE, account.getProfile()); + o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI()); + o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI()); + o.put(KEY_PROFILE_SERVER_URI, account.getProfileServerURI()); + + final ExtendedJSONObject p = new ExtendedJSONObject(); + for (Entry<String, Boolean> pair : account.getAuthoritiesToSyncAutomaticallyMap().entrySet()) { + p.put(pair.getKey(), pair.getValue()); + } + o.put(KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP, p); + + // TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs. + + final ExtendedJSONObject bundle = account.unbundle(); + if (bundle == null) { + Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting."); + return null; + } + o.put(KEY_BUNDLE, bundle); + + return o; + } + + /** + * Persist Firefox account to disk as a JSON object. + * This operation is synchronized to avoid race condition while deleting the account. + * + * @param account the AndroidFxAccount to persist to disk + * @param filename name of file to persist to; must not contain path separators. + */ + public synchronized static void pickle(final AndroidFxAccount account, final String filename) { + final ExtendedJSONObject o = toJSON(account, System.currentTimeMillis()); + writeToDisk(account.context, filename, o); + } + + private static void writeToDisk(final Context context, final String filename, + final ExtendedJSONObject pickle) { + try { + final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE); + try { + final PrintStream ps = new PrintStream(fos); + try { + ps.print(pickle.toJSONString()); + Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() + + " account settings to " + filename + "."); + } finally { + ps.close(); + } + } finally { + fos.close(); + } + } catch (Exception e) { + Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename + + "; ignoring.", e); + } + } + + /** + * Create Android account from saved JSON object. Assumes that an account does not exist. + * This operation is synchronized to avoid race condition while deleting the account. + * + * @param context + * Android context. + * @param filename + * name of file to read from; must not contain path separators. + * @return created Android account, or null on error. + */ + public synchronized static AndroidFxAccount unpickle(final Context context, final String filename) { + final String jsonString = Utils.readFile(context, filename); + if (jsonString == null) { + Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting."); + return null; + } + + ExtendedJSONObject json = null; + try { + json = new ExtendedJSONObject(jsonString); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e); + return null; + } + + final UnpickleParams params; + try { + params = UnpickleParams.fromJSON(json); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e); + return null; + } + + final AndroidFxAccount account; + try { + account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile, + params.authServerURI, params.tokenServerURI, params.profileServerURI, params.state, + params.authoritiesToSyncAutomaticallyMap, + params.accountVersion, + true, params.bundle); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e); + return null; + } + + if (account == null) { + Logger.warn(LOG_TAG, "Failed to add Android Account; aborting."); + return null; + } + + Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP); + if (timestamp == null) { + Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring."); + timestamp = -1L; + } + + Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " + + params.pickleVersion + ", pickled at " + timestamp + ")."); + + return account; + } + + private static class UnpickleParams { + private Long pickleVersion; + + private int accountVersion; + private String email; + private String profile; + private String authServerURI; + private String tokenServerURI; + private String profileServerURI; + private final Map<String, Boolean> authoritiesToSyncAutomaticallyMap = new HashMap<>(); + + private ExtendedJSONObject bundle; + private State state; + + private UnpickleParams() { + } + + private static UnpickleParams fromJSON(final ExtendedJSONObject json) + throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { + final UnpickleParams params = new UnpickleParams(); + params.pickleVersion = json.getLong(KEY_PICKLE_VERSION); + if (params.pickleVersion == null) { + throw new IllegalStateException("Pickle version not found."); + } + + /* + * Version 1 and version 2 are identical, except version 2 throws if the + * internal Android Account type has changed. Version 1 used to throw in + * this case, but we intentionally used the pickle file to migrate across + * Account types, bumping the version simultaneously. + * + * Version 3 replaces "isSyncEnabled" with a map (String -> Boolean) + * associating Android authorities to whether or not they are configured + * to sync automatically. + */ + switch (params.pickleVersion.intValue()) { + case 3: { + // Sanity check. + final String accountType = json.getString(KEY_ACCOUNT_TYPE); + if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { + throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "."); + } + + params.unpickleV3(json); + } + break; + + case 2: { + // Sanity check. + final String accountType = json.getString(KEY_ACCOUNT_TYPE); + if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { + throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "."); + } + + params.unpickleV1(json); + } + break; + + case 1: { + // Warn about account type changing, but don't throw over it. + final String accountType = json.getString(KEY_ACCOUNT_TYPE); + if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { + Logger.warn(LOG_TAG, "Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "; ignoring."); + } + + params.unpickleV1(json); + } + break; + + default: + throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + "."); + } + + return params; + } + + private void unpickleV1(final ExtendedJSONObject json) + throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException { + + this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION); + this.email = json.getString(KEY_EMAIL); + this.profile = json.getString(KEY_PROFILE); + this.authServerURI = json.getString(KEY_IDP_SERVER_URI); + this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI); + this.profileServerURI = json.getString(KEY_PROFILE_SERVER_URI); + + // Fallback to default value when profile server URI was not pickled. + if (this.profileServerURI == null) { + this.profileServerURI = FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT.equals(this.authServerURI) + ? FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT + : FxAccountConstants.STAGE_PROFILE_SERVER_ENDPOINT; + } + + // We get the default value for everything except syncing browser data. + this.authoritiesToSyncAutomaticallyMap.put(BrowserContract.AUTHORITY, json.getBoolean(KEY_IS_SYNCING_ENABLED)); + + this.bundle = json.getObject(KEY_BUNDLE); + if (bundle == null) { + throw new IllegalStateException("Pickle bundle is null."); + } + this.state = getState(bundle); + } + + private void unpickleV3(final ExtendedJSONObject json) + throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException { + // We'll overwrite the extracted sync automatically map. + unpickleV1(json); + + // Extract the map of authorities to sync automatically. + authoritiesToSyncAutomaticallyMap.clear(); + final ExtendedJSONObject o = json.getObject(KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP); + if (o == null) { + return; + } + for (String key : o.keySet()) { + final Boolean enabled = o.getBoolean(key); + if (enabled != null) { + authoritiesToSyncAutomaticallyMap.put(key, enabled); + } + } + } + + private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException, + NonObjectJSONException, NoSuchAlgorithmException { + // TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain + // old versions? + final StateLabel stateLabelString = StateLabel.valueOf( + bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL)); + final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE); + if (stateLabelString == null || stateString == null) { + throw new IllegalStateException("stateLabel and stateString must not be null, but: " + + "(stateLabel == null) = " + (stateLabelString == null) + + " and (stateString == null) = " + (stateString == null)); + } + + try { + return StateFactory.fromJSONObject(stateLabelString, new ExtendedJSONObject(stateString)); + } catch (Exception e) { + throw new IllegalStateException("could not get state", e); + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java new file mode 100644 index 000000000..d7ce7c47f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java @@ -0,0 +1,929 @@ +/* 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.fxa.authenticator; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.os.ResultReceiver; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.fxa.login.StateFactory; +import org.mozilla.gecko.fxa.login.TokensAndKeysState; +import org.mozilla.gecko.fxa.sync.FxAccountProfileService; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.setup.Constants; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; + +/** + * A Firefox Account that stores its details and state as user data attached to + * an Android Account instance. + * <p> + * Account user data is accessible only to the Android App(s) that own the + * Account type. Account user data is not removed when the App's private data is + * cleared. + */ +public class AndroidFxAccount { + protected static final String LOG_TAG = AndroidFxAccount.class.getSimpleName(); + + public static final int CURRENT_SYNC_PREFS_VERSION = 1; + public static final int CURRENT_RL_PREFS_VERSION = 1; + + // When updating the account, do not forget to update AccountPickler. + public static final int CURRENT_ACCOUNT_VERSION = 3; + public static final String ACCOUNT_KEY_ACCOUNT_VERSION = "version"; + public static final String ACCOUNT_KEY_PROFILE = "profile"; + public static final String ACCOUNT_KEY_IDP_SERVER = "idpServerURI"; + private static final String ACCOUNT_KEY_PROFILE_SERVER = "profileServerURI"; + + public static final String ACCOUNT_KEY_TOKEN_SERVER = "tokenServerURI"; // Sync-specific. + public static final String ACCOUNT_KEY_DESCRIPTOR = "descriptor"; + + public static final int CURRENT_BUNDLE_VERSION = 2; + public static final String BUNDLE_KEY_BUNDLE_VERSION = "version"; + public static final String BUNDLE_KEY_STATE_LABEL = "stateLabel"; + public static final String BUNDLE_KEY_STATE = "state"; + public static final String BUNDLE_KEY_PROFILE_JSON = "profile"; + + public static final String ACCOUNT_KEY_DEVICE_ID = "deviceId"; + public static final String ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION = "deviceRegistrationVersion"; + + // Account authentication token type for fetching account profile. + public static final String PROFILE_OAUTH_TOKEN_TYPE = "oauth::profile"; + + // Services may request OAuth tokens from the Firefox Account dynamically. + // Each such token is prefixed with "oauth::" and a service-dependent scope. + // Such tokens should be destroyed when the account is removed from the device. + // This list collects all the known "oauth::" token types in order to delete them when necessary. + private static final List<String> KNOWN_OAUTH_TOKEN_TYPES; + + static { + final List<String> list = new ArrayList<>(); + list.add(PROFILE_OAUTH_TOKEN_TYPE); + KNOWN_OAUTH_TOKEN_TYPES = Collections.unmodifiableList(list); + } + + public static final Map<String, Boolean> DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP; + static { + final HashMap<String, Boolean> m = new HashMap<String, Boolean>(); + // By default, Firefox Sync is enabled. + m.put(BrowserContract.AUTHORITY, true); + DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP = Collections.unmodifiableMap(m); + } + + private static final String PREF_KEY_LAST_SYNCED_TIMESTAMP = "lastSyncedTimestamp"; + + protected final Context context; + protected final AccountManager accountManager; + protected final Account account; + + /** + * A cache associating Account name (email address) to a representation of the + * account's internal bundle. + * <p> + * The cache is invalidated entirely when <it>any</it> new Account is added, + * because there is no reliable way to know that an Account has been removed + * and then re-added. + */ + protected static final ConcurrentHashMap<String, ExtendedJSONObject> perAccountBundleCache = + new ConcurrentHashMap<>(); + + public static void invalidateCaches() { + perAccountBundleCache.clear(); + } + + /** + * Create an Android Firefox Account instance backed by an Android Account + * instance. + * <p> + * We expect a long-lived application context to avoid life-cycle issues that + * might arise if the internally cached AccountManager instance surfaces UI. + * <p> + * We take care to not install any listeners or observers that might outlive + * the AccountManager; and Android ensures the AccountManager doesn't outlive + * the associated context. + * + * @param applicationContext + * to use as long-lived ambient Android context. + * @param account + * Android account to use for storage. + */ + public AndroidFxAccount(Context applicationContext, Account account) { + this.context = applicationContext; + this.account = account; + this.accountManager = AccountManager.get(this.context); + } + + public static AndroidFxAccount fromContext(Context context) { + context = context.getApplicationContext(); + Account account = FirefoxAccounts.getFirefoxAccount(context); + if (account == null) { + return null; + } + return new AndroidFxAccount(context, account); + } + + /** + * Persist the Firefox account to disk as a JSON object. Note that this is a wrapper around + * {@link AccountPickler#pickle}, and is identical to calling it directly. + * <p> + * Note that pickling is different from bundling, which involves operations on a + * {@link android.os.Bundle Bundle} object of miscellaneous data associated with the account. + * See {@link #persistBundle} and {@link #unbundle} for more. + */ + public void pickle(final String filename) { + AccountPickler.pickle(this, filename); + } + + public Account getAndroidAccount() { + return this.account; + } + + protected int getAccountVersion() { + String v = accountManager.getUserData(account, ACCOUNT_KEY_ACCOUNT_VERSION); + if (v == null) { + return 0; // Implicit. + } + + try { + return Integer.parseInt(v, 10); + } catch (NumberFormatException ex) { + return 0; + } + } + + /** + * Saves the given data as the internal bundle associated with this account. + * @param bundle to write to account. + */ + protected synchronized void persistBundle(ExtendedJSONObject bundle) { + perAccountBundleCache.put(account.name, bundle); + accountManager.setUserData(account, ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString()); + } + + protected ExtendedJSONObject unbundle() { + return unbundle(true); + } + + /** + * Retrieve the internal bundle associated with this account. + * @return bundle associated with account. + */ + protected synchronized ExtendedJSONObject unbundle(boolean allowCachedBundle) { + if (allowCachedBundle) { + final ExtendedJSONObject cachedBundle = perAccountBundleCache.get(account.name); + if (cachedBundle != null) { + Logger.debug(LOG_TAG, "Returning cached account bundle."); + return cachedBundle; + } + } + + final int version = getAccountVersion(); + if (version < CURRENT_ACCOUNT_VERSION) { + // Needs upgrade. For now, do nothing. We'd like to just put your account + // into the Separated state here and have you update your credentials. + return null; + } + + if (version > CURRENT_ACCOUNT_VERSION) { + // Oh dear. + return null; + } + + String bundleString = accountManager.getUserData(account, ACCOUNT_KEY_DESCRIPTOR); + if (bundleString == null) { + return null; + } + final ExtendedJSONObject bundle = unbundleAccountV2(bundleString); + perAccountBundleCache.put(account.name, bundle); + Logger.info(LOG_TAG, "Account bundle persisted to cache."); + return bundle; + } + + protected String getBundleData(String key) { + ExtendedJSONObject o = unbundle(); + if (o == null) { + return null; + } + return o.getString(key); + } + + protected boolean getBundleDataBoolean(String key, boolean def) { + ExtendedJSONObject o = unbundle(); + if (o == null) { + return def; + } + Boolean b = o.getBoolean(key); + if (b == null) { + return def; + } + return b; + } + + protected byte[] getBundleDataBytes(String key) { + ExtendedJSONObject o = unbundle(); + if (o == null) { + return null; + } + return o.getByteArrayHex(key); + } + + protected void updateBundleValues(String key, String value, String... more) { + if (more.length % 2 != 0) { + throw new IllegalArgumentException("more must be a list of key, value pairs"); + } + ExtendedJSONObject descriptor = unbundle(); + if (descriptor == null) { + return; + } + descriptor.put(key, value); + for (int i = 0; i + 1 < more.length; i += 2) { + descriptor.put(more[i], more[i+1]); + } + persistBundle(descriptor); + } + + private ExtendedJSONObject unbundleAccountV1(String bundle) { + ExtendedJSONObject o; + try { + o = new ExtendedJSONObject(bundle); + } catch (Exception e) { + return null; + } + if (CURRENT_BUNDLE_VERSION == o.getIntegerSafely(BUNDLE_KEY_BUNDLE_VERSION)) { + return o; + } + return null; + } + + private ExtendedJSONObject unbundleAccountV2(String bundle) { + return unbundleAccountV1(bundle); + } + + /** + * Note that if the user clears data, an account will be left pointing to a + * deleted profile. Such is life. + */ + public String getProfile() { + return accountManager.getUserData(account, ACCOUNT_KEY_PROFILE); + } + + public String getAccountServerURI() { + return accountManager.getUserData(account, ACCOUNT_KEY_IDP_SERVER); + } + + public String getTokenServerURI() { + return accountManager.getUserData(account, ACCOUNT_KEY_TOKEN_SERVER); + } + + public String getProfileServerURI() { + String profileURI = accountManager.getUserData(account, ACCOUNT_KEY_PROFILE_SERVER); + if (profileURI == null) { + if (isStaging()) { + return FxAccountConstants.STAGE_PROFILE_SERVER_ENDPOINT; + } + return FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT; + } + return profileURI; + } + + public String getOAuthServerURI() { + // Allow testing against stage. + if (isStaging()) { + return FxAccountConstants.STAGE_OAUTH_SERVER_ENDPOINT; + } else { + return FxAccountConstants.DEFAULT_OAUTH_SERVER_ENDPOINT; + } + } + + private boolean isStaging() { + return FxAccountConstants.STAGE_AUTH_SERVER_ENDPOINT.equals(getAccountServerURI()); + } + + private String constructPrefsPath(String product, long version, String extra) throws GeneralSecurityException, UnsupportedEncodingException { + String profile = getProfile(); + String username = account.name; + + if (profile == null) { + throw new IllegalStateException("Missing profile. Cannot fetch prefs."); + } + + if (username == null) { + throw new IllegalStateException("Missing username. Cannot fetch prefs."); + } + + final String fxaServerURI = getAccountServerURI(); + if (fxaServerURI == null) { + throw new IllegalStateException("No account server URI. Cannot fetch prefs."); + } + + // This is unique for each syncing 'view' of the account. + final String serverURLThing = fxaServerURI + "!" + extra; + return Utils.getPrefsPath(product, username, serverURLThing, profile, version); + } + + /** + * This needs to return a string because of the tortured prefs access in GlobalSession. + */ + public String getSyncPrefsPath() throws GeneralSecurityException, UnsupportedEncodingException { + final String tokenServerURI = getTokenServerURI(); + if (tokenServerURI == null) { + throw new IllegalStateException("No token server URI. Cannot fetch prefs."); + } + + final String product = GlobalConstants.BROWSER_INTENT_PACKAGE + ".fxa"; + final long version = CURRENT_SYNC_PREFS_VERSION; + return constructPrefsPath(product, version, tokenServerURI); + } + + public String getReadingListPrefsPath() throws GeneralSecurityException, UnsupportedEncodingException { + final String product = GlobalConstants.BROWSER_INTENT_PACKAGE + ".reading"; + final long version = CURRENT_RL_PREFS_VERSION; + return constructPrefsPath(product, version, ""); + } + + public SharedPreferences getSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException { + return context.getSharedPreferences(getSyncPrefsPath(), Utils.SHARED_PREFERENCES_MODE); + } + + public SharedPreferences getReadingListPrefs() throws UnsupportedEncodingException, GeneralSecurityException { + return context.getSharedPreferences(getReadingListPrefsPath(), Utils.SHARED_PREFERENCES_MODE); + } + + /** + * Extract a JSON dictionary of the string values associated to this account. + * <p> + * <b>For debugging use only!</b> The contents of this JSON object completely + * determine the user's Firefox Account status and yield access to whatever + * user data the device has access to. + * + * @return JSON-object of Strings. + */ + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = unbundle(); + o.put("email", account.name); + try { + o.put("emailUTF8", Utils.byte2Hex(account.name.getBytes("UTF-8"))); + } catch (UnsupportedEncodingException e) { + // Ignore. + } + o.put("fxaDeviceId", getDeviceId()); + o.put("fxaDeviceRegistrationVersion", getDeviceRegistrationVersion()); + return o; + } + + public static AndroidFxAccount addAndroidAccount( + Context context, + String email, + String profile, + String idpServerURI, + String tokenServerURI, + String profileServerURI, + State state, + final Map<String, Boolean> authoritiesToSyncAutomaticallyMap) + throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException { + return addAndroidAccount(context, email, profile, idpServerURI, tokenServerURI, profileServerURI, state, + authoritiesToSyncAutomaticallyMap, + CURRENT_ACCOUNT_VERSION, false, null); + } + + public static AndroidFxAccount addAndroidAccount( + Context context, + String email, + String profile, + String idpServerURI, + String tokenServerURI, + String profileServerURI, + State state, + final Map<String, Boolean> authoritiesToSyncAutomaticallyMap, + final int accountVersion, + final boolean fromPickle, + ExtendedJSONObject bundle) + throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException { + if (email == null) { + throw new IllegalArgumentException("email must not be null"); + } + if (profile == null) { + throw new IllegalArgumentException("profile must not be null"); + } + if (idpServerURI == null) { + throw new IllegalArgumentException("idpServerURI must not be null"); + } + if (tokenServerURI == null) { + throw new IllegalArgumentException("tokenServerURI must not be null"); + } + if (profileServerURI == null) { + throw new IllegalArgumentException("profileServerURI must not be null"); + } + if (state == null) { + throw new IllegalArgumentException("state must not be null"); + } + + // TODO: Add migration code. + if (accountVersion != CURRENT_ACCOUNT_VERSION) { + throw new IllegalStateException("Could not create account of version " + accountVersion + + ". Current version is " + CURRENT_ACCOUNT_VERSION + "."); + } + + // Android has internal restrictions that require all values in this + // bundle to be strings. *sigh* + Bundle userdata = new Bundle(); + userdata.putString(ACCOUNT_KEY_ACCOUNT_VERSION, "" + CURRENT_ACCOUNT_VERSION); + userdata.putString(ACCOUNT_KEY_IDP_SERVER, idpServerURI); + userdata.putString(ACCOUNT_KEY_TOKEN_SERVER, tokenServerURI); + userdata.putString(ACCOUNT_KEY_PROFILE_SERVER, profileServerURI); + userdata.putString(ACCOUNT_KEY_PROFILE, profile); + + if (bundle == null) { + bundle = new ExtendedJSONObject(); + // TODO: How to upgrade? + bundle.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION); + } + bundle.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name()); + bundle.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString()); + + userdata.putString(ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString()); + + Account account = new Account(email, FxAccountConstants.ACCOUNT_TYPE); + AccountManager accountManager = AccountManager.get(context); + // We don't set an Android password, because we don't want to persist the + // password (or anything else as powerful as the password). Instead, we + // internally manage a sessionToken with a remotely owned lifecycle. + boolean added = accountManager.addAccountExplicitly(account, null, userdata); + if (!added) { + return null; + } + + // Try to work around an intermittent issue described at + // http://stackoverflow.com/a/11698139. What happens is that tests that + // delete and re-create the same account frequently will find the account + // missing all or some of the userdata bundle, possibly due to an Android + // AccountManager caching bug. + for (String key : userdata.keySet()) { + accountManager.setUserData(account, key, userdata.getString(key)); + } + + AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + + if (!fromPickle) { + fxAccount.clearSyncPrefs(); + } + + fxAccount.setAuthoritiesToSyncAutomaticallyMap(authoritiesToSyncAutomaticallyMap); + + return fxAccount; + } + + public void clearSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException { + getSyncPrefs().edit().clear().commit(); + } + + public void setAuthoritiesToSyncAutomaticallyMap(Map<String, Boolean> authoritiesToSyncAutomaticallyMap) { + if (authoritiesToSyncAutomaticallyMap == null) { + throw new IllegalArgumentException("authoritiesToSyncAutomaticallyMap must not be null"); + } + + for (String authority : DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) { + boolean authorityEnabled = DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.get(authority); + final Boolean enabled = authoritiesToSyncAutomaticallyMap.get(authority); + if (enabled != null) { + authorityEnabled = enabled.booleanValue(); + } + // Accounts are always capable of being synced ... + ContentResolver.setIsSyncable(account, authority, 1); + // ... but not always automatically synced. + ContentResolver.setSyncAutomatically(account, authority, authorityEnabled); + } + } + + public Map<String, Boolean> getAuthoritiesToSyncAutomaticallyMap() { + final Map<String, Boolean> authoritiesToSync = new HashMap<>(); + for (String authority : DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) { + final boolean enabled = ContentResolver.getSyncAutomatically(account, authority); + authoritiesToSync.put(authority, enabled); + } + return authoritiesToSync; + } + + /** + * Is a sync currently in progress? + * + * @return true if Android is currently syncing the underlying Android Account. + */ + public boolean isCurrentlySyncing() { + boolean active = false; + for (String authority : AndroidFxAccount.DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) { + active |= ContentResolver.isSyncActive(account, authority); + } + return active; + } + + /** + * Request an immediate sync. Use this to sync as soon as possible in response to user action. + * + * @param stagesToSync stage names to sync; can be null to sync <b>all</b> known stages. + * @param stagesToSkip stage names to skip; can be null to skip <b>no</b> known stages. + */ + public void requestImmediateSync(String[] stagesToSync, String[] stagesToSkip) { + FirefoxAccounts.requestImmediateSync(getAndroidAccount(), stagesToSync, stagesToSkip); + } + + /** + * Request an eventual sync. Use this to request the system queue a sync for some time in the + * future. + * + * @param stagesToSync stage names to sync; can be null to sync <b>all</b> known stages. + * @param stagesToSkip stage names to skip; can be null to skip <b>no</b> known stages. + */ + public void requestEventualSync(String[] stagesToSync, String[] stagesToSkip) { + FirefoxAccounts.requestEventualSync(getAndroidAccount(), stagesToSync, stagesToSkip); + } + + public synchronized void setState(State state) { + if (state == null) { + throw new IllegalArgumentException("state must not be null"); + } + Logger.info(LOG_TAG, "Moving account named like " + getObfuscatedEmail() + + " to state " + state.getStateLabel().toString()); + updateBundleValues( + BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name(), + BUNDLE_KEY_STATE, state.toJSONObject().toJSONString()); + broadcastAccountStateChangedIntent(); + } + + protected void broadcastAccountStateChangedIntent() { + final Intent intent = new Intent(FxAccountConstants.ACCOUNT_STATE_CHANGED_ACTION); + intent.putExtra(Constants.JSON_KEY_ACCOUNT, account.name); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + + public synchronized State getState() { + String stateLabelString = getBundleData(BUNDLE_KEY_STATE_LABEL); + String stateString = getBundleData(BUNDLE_KEY_STATE); + if (stateLabelString == null || stateString == null) { + throw new IllegalStateException("stateLabelString and stateString must not be null, but: " + + "(stateLabelString == null) = " + (stateLabelString == null) + + " and (stateString == null) = " + (stateString == null)); + } + + try { + StateLabel stateLabel = StateLabel.valueOf(stateLabelString); + Logger.debug(LOG_TAG, "Account is in state " + stateLabel); + return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString)); + } catch (Exception e) { + throw new IllegalStateException("could not get state", e); + } + } + + public byte[] getSessionToken() throws InvalidFxAState { + State state = getState(); + StateLabel stateLabel = state.getStateLabel(); + if (stateLabel == StateLabel.Cohabiting || stateLabel == StateLabel.Married) { + TokensAndKeysState tokensAndKeysState = (TokensAndKeysState) state; + return tokensAndKeysState.getSessionToken(); + } + throw new InvalidFxAState("Cannot get sessionToken: not in a TokensAndKeysState state"); + } + + public static class InvalidFxAState extends Exception { + private static final long serialVersionUID = -8537626959811195978L; + + public InvalidFxAState(String message) { + super(message); + } + } + + /** + * <b>For debugging only!</b> + */ + public void dump() { + if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) { + return; + } + ExtendedJSONObject o = toJSONObject(); + ArrayList<String> list = new ArrayList<String>(o.keySet()); + Collections.sort(list); + for (String key : list) { + FxAccountUtils.pii(LOG_TAG, key + ": " + o.get(key)); + } + } + + /** + * Return the Firefox Account's local email address. + * <p> + * It is important to note that this is the local email address, and not + * necessarily the normalized remote email address that the server expects. + * + * @return local email address. + */ + public String getEmail() { + return account.name; + } + + /** + * Return the Firefox Account's local email address, obfuscated. + * <p> + * Use this when logging. + * + * @return local email address, obfuscated. + */ + public String getObfuscatedEmail() { + return Utils.obfuscateEmail(account.name); + } + + /** + * Populate an intent used for starting FxAccountDeletedService service. + * + * @param intent Intent to populate with necessary extras + * @return <code>Intent</code> with a deleted action and account/OAuth information extras + */ + public Intent populateDeletedAccountIntent(final Intent intent) { + final List<String> tokens = new ArrayList<>(); + + intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY, + Long.valueOf(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION)); + intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name); + intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE, getProfile()); + + // Get the tokens from AccountManager. Note: currently, only reading list service supports OAuth. The following logic will + // be extended in future to support OAuth for other services. + for (String tokenKey : KNOWN_OAUTH_TOKEN_TYPES) { + final String authToken = accountManager.peekAuthToken(account, tokenKey); + if (authToken != null) { + tokens.add(authToken); + } + } + + // Update intent with tokens and service URI. + intent.putExtra(FxAccountConstants.ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY, getOAuthServerURI()); + // Deleted broadcasts are package-private, so there's no security risk include the tokens in the extras + intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS, tokens.toArray(new String[tokens.size()])); + return intent; + } + + /** + * Create an intent announcing that the profile JSON attached to this Firefox Account has been updated. + * <p> + * It is not guaranteed that the profile JSON has changed. + * + * @return <code>Intent</code> to broadcast. + */ + private Intent makeProfileJSONUpdatedIntent() { + final Intent intent = new Intent(); + intent.setAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION); + return intent; + } + + public void setLastSyncedTimestamp(long now) { + try { + getSyncPrefs().edit().putLong(PREF_KEY_LAST_SYNCED_TIMESTAMP, now).commit(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception setting last synced time; ignoring.", e); + } + } + + public long getLastSyncedTimestamp() { + final long neverSynced = -1L; + try { + return getSyncPrefs().getLong(PREF_KEY_LAST_SYNCED_TIMESTAMP, neverSynced); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception getting last synced time; ignoring.", e); + return neverSynced; + } + } + + // Debug only! This is dangerous! + public void unsafeTransitionToDefaultEndpoints() { + unsafeTransitionToStageEndpoints( + FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT, + FxAccountConstants.DEFAULT_TOKEN_SERVER_ENDPOINT, + FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT); + } + + // Debug only! This is dangerous! + public void unsafeTransitionToStageEndpoints() { + unsafeTransitionToStageEndpoints( + FxAccountConstants.STAGE_AUTH_SERVER_ENDPOINT, + FxAccountConstants.STAGE_TOKEN_SERVER_ENDPOINT, + FxAccountConstants.STAGE_PROFILE_SERVER_ENDPOINT); + } + + protected void unsafeTransitionToStageEndpoints(String authServerEndpoint, String tokenServerEndpoint, String profileServerEndpoint) { + try { + getReadingListPrefs().edit().clear().commit(); + } catch (UnsupportedEncodingException | GeneralSecurityException e) { + // Ignore. + } + try { + getSyncPrefs().edit().clear().commit(); + } catch (UnsupportedEncodingException | GeneralSecurityException e) { + // Ignore. + } + State state = getState(); + setState(state.makeSeparatedState()); + accountManager.setUserData(account, ACCOUNT_KEY_IDP_SERVER, authServerEndpoint); + accountManager.setUserData(account, ACCOUNT_KEY_TOKEN_SERVER, tokenServerEndpoint); + accountManager.setUserData(account, ACCOUNT_KEY_PROFILE_SERVER, profileServerEndpoint); + ContentResolver.setIsSyncable(account, BrowserContract.READING_LIST_AUTHORITY, 1); + } + + /** + * Returns the current profile JSON if available, or null. + * + * @return profile JSON object. + */ + public ExtendedJSONObject getProfileJSON() { + final String profileString = getBundleData(BUNDLE_KEY_PROFILE_JSON); + if (profileString == null) { + return null; + } + + try { + return new ExtendedJSONObject(profileString); + } catch (Exception e) { + Logger.error(LOG_TAG, "Failed to parse profile JSON; ignoring and returning null.", e); + } + return null; + } + + /** + * Fetch the profile JSON associated to the underlying Firefox Account from the server and update the local store. + * <p> + * The LocalBroadcastManager is used to notify the receivers asynchronously after a successful fetch. + */ + public void fetchProfileJSON() { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // Fetch profile information from server. + String authToken; + try { + authToken = accountManager.blockingGetAuthToken(account, AndroidFxAccount.PROFILE_OAUTH_TOKEN_TYPE, true); + if (authToken == null) { + throw new RuntimeException("Couldn't get oauth token! Aborting profile fetch."); + } + } catch (Exception e) { + Logger.error(LOG_TAG, "Error fetching profile information; ignoring.", e); + return; + } + + Logger.info(LOG_TAG, "Intent service launched to fetch profile."); + final Intent intent = new Intent(context, FxAccountProfileService.class); + intent.putExtra(FxAccountProfileService.KEY_AUTH_TOKEN, authToken); + intent.putExtra(FxAccountProfileService.KEY_PROFILE_SERVER_URI, getProfileServerURI()); + intent.putExtra(FxAccountProfileService.KEY_RESULT_RECEIVER, new ProfileResultReceiver(new Handler())); + context.startService(intent); + } + }); + } + + @Nullable + public synchronized String getDeviceId() { + return accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_ID); + } + + @NonNull + public synchronized int getDeviceRegistrationVersion() { + String versionStr = accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION); + if (TextUtils.isEmpty(versionStr)) { + return 0; + } else { + try { + return Integer.parseInt(versionStr); + } catch (NumberFormatException ex) { + return 0; + } + } + } + + public synchronized void setDeviceId(String id) { + accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id); + } + + public synchronized void setDeviceRegistrationVersion(int deviceRegistrationVersion) { + accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION, + Integer.toString(deviceRegistrationVersion)); + } + + public synchronized void resetDeviceRegistrationVersion() { + setDeviceRegistrationVersion(0); + } + + public synchronized void setFxAUserData(String id, int deviceRegistrationVersion) { + accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id); + accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION, + Integer.toString(deviceRegistrationVersion)); + } + + @SuppressLint("ParcelCreator") // The CREATOR field is defined in the super class. + private class ProfileResultReceiver extends ResultReceiver { + public ProfileResultReceiver(Handler handler) { + super(handler); + } + + @Override + protected void onReceiveResult(int resultCode, Bundle bundle) { + super.onReceiveResult(resultCode, bundle); + switch (resultCode) { + case Activity.RESULT_OK: + final String resultData = bundle.getString(FxAccountProfileService.KEY_RESULT_STRING); + updateBundleValues(BUNDLE_KEY_PROFILE_JSON, resultData); + Logger.info(LOG_TAG, "Profile JSON fetch succeeeded!"); + FxAccountUtils.pii(LOG_TAG, "Profile JSON fetch returned: " + resultData); + LocalBroadcastManager.getInstance(context).sendBroadcast(makeProfileJSONUpdatedIntent()); + break; + case Activity.RESULT_CANCELED: + Logger.warn(LOG_TAG, "Failed to fetch profile JSON; ignoring."); + break; + default: + Logger.warn(LOG_TAG, "Invalid result code received; ignoring."); + break; + } + } + } + + /** + * Take the lock to own updating any Firefox Account's internal state. + * + * We use a <code>Semaphore</code> rather than a <code>ReentrantLock</code> + * because the callback that needs to release the lock may not be invoked on + * the thread that initially acquired the lock. Be aware! + */ + protected static final Semaphore sLock = new Semaphore(1, true /* fair */); + + // Which consumer took the lock? + // Synchronized by this. + protected String lockTag = null; + + // Are we locked? (It's not easy to determine who took the lock dynamically, + // so we maintain this flag internally.) + // Synchronized by this. + protected boolean locked = false; + + // Block until we can take the shared state lock. + public synchronized void acquireSharedAccountStateLock(final String tag) throws InterruptedException { + final long id = Thread.currentThread().getId(); + this.lockTag = tag; + Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id acquiring lock: " + lockTag + ", " + id + " ..."); + sLock.acquire(); + locked = true; + Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id acquiring lock: " + lockTag + ", " + id + " ... ACQUIRED"); + } + + // If we hold the shared state lock, release it. Otherwise, ignore the request. + public synchronized void releaseSharedAccountStateLock() { + final long id = Thread.currentThread().getId(); + Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ..."); + if (locked) { + sLock.release(); + locked = false; + Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ... RELEASED"); + } else { + Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ... NOT LOCKED"); + } + } + + @Override + protected synchronized void finalize() { + if (locked) { + // Should never happen, but... + sLock.release(); + locked = false; + final long id = Thread.currentThread().getId(); + Log.e(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ... RELEASED DURING FINALIZE"); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java new file mode 100644 index 000000000..ff3122322 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java @@ -0,0 +1,84 @@ +/* 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.fxa.authenticator; + +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountClient; +import org.mozilla.gecko.background.fxa.FxAccountClient20; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition; +import org.mozilla.gecko.fxa.login.Married; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.fxa.login.StateFactory; +import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager; +import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter; + +import android.content.Context; + +public abstract class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate { + protected final static String LOG_TAG = LoginStateMachineDelegate.class.getSimpleName(); + + protected final Context context; + protected final AndroidFxAccount fxAccount; + protected final Executor executor; + protected final FxAccountClient client; + + public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) { + this.context = context; + this.fxAccount = fxAccount; + this.executor = Executors.newSingleThreadExecutor(); + this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor); + } + + abstract public void handleNotMarried(State notMarried); + abstract public void handleMarried(Married married); + + @Override + public FxAccountClient getClient() { + return client; + } + + @Override + public long getCertificateDurationInMilliseconds() { + return 12 * 60 * 60 * 1000; + } + + @Override + public long getAssertionDurationInMilliseconds() { + return 15 * 60 * 1000; + } + + @Override + public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException { + return StateFactory.generateKeyPair(); + } + + @Override + public void handleTransition(Transition transition, State state) { + Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel()); + } + + @Override + public void handleFinal(State state) { + Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel()); + fxAccount.setState(state); + // Update any notifications displayed. + final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID); + notificationManager.update(context, fxAccount); + + if (state.getStateLabel() != StateLabel.Married) { + handleNotMarried(state); + return; + } else { + handleMarried((Married) state); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java new file mode 100644 index 000000000..259b1cb88 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java @@ -0,0 +1,385 @@ +/* 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.fxa.authenticator; + +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.AccountManager; +import android.accounts.NetworkErrorException; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountClient; +import org.mozilla.gecko.background.fxa.FxAccountClient20; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient.RequestDelegate; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException; +import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10; +import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10.AuthorizationResponse; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.browserid.JSONWebTokenUtils; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition; +import org.mozilla.gecko.fxa.login.Married; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.fxa.login.StateFactory; +import org.mozilla.gecko.fxa.receivers.FxAccountDeletedService; +import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager; +import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter; +import org.mozilla.gecko.util.ThreadUtils; + +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class FxAccountAuthenticator extends AbstractAccountAuthenticator { + public static final String LOG_TAG = FxAccountAuthenticator.class.getSimpleName(); + public static final int UNKNOWN_ERROR_CODE = 999; + + protected final Context context; + protected final AccountManager accountManager; + + public FxAccountAuthenticator(Context context) { + super(context); + this.context = context; + this.accountManager = AccountManager.get(context); + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse response, + String accountType, String authTokenType, String[] requiredFeatures, + Bundle options) + throws NetworkErrorException { + Logger.debug(LOG_TAG, "addAccount"); + + // The data associated to each Account should be invalidated when we change + // the set of Firefox Accounts on the system. + AndroidFxAccount.invalidateCaches(); + + final Bundle res = new Bundle(); + + if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { + res.putInt(AccountManager.KEY_ERROR_CODE, -1); + res.putString(AccountManager.KEY_ERROR_MESSAGE, "Not adding unknown account type."); + return res; + } + + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); + res.putParcelable(AccountManager.KEY_INTENT, intent); + return res; + } + + @Override + public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) + throws NetworkErrorException { + Logger.debug(LOG_TAG, "confirmCredentials"); + + return null; + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + Logger.debug(LOG_TAG, "editProperties"); + + return null; + } + + protected static class Responder { + final AccountAuthenticatorResponse response; + final AndroidFxAccount fxAccount; + + public Responder(AccountAuthenticatorResponse response, AndroidFxAccount fxAccount) { + this.response = response; + this.fxAccount = fxAccount; + } + + public void fail(Exception e) { + Logger.warn(LOG_TAG, "Responding with error!", e); + fxAccount.releaseSharedAccountStateLock(); + final Bundle result = new Bundle(); + result.putInt(AccountManager.KEY_ERROR_CODE, UNKNOWN_ERROR_CODE); + result.putString(AccountManager.KEY_ERROR_MESSAGE, e.toString()); + response.onResult(result); + } + + public void succeed(String authToken) { + Logger.info(LOG_TAG, "Responding with success!"); + fxAccount.releaseSharedAccountStateLock(); + final Bundle result = new Bundle(); + result.putString(AccountManager.KEY_ACCOUNT_NAME, fxAccount.account.name); + result.putString(AccountManager.KEY_ACCOUNT_TYPE, fxAccount.account.type); + result.putString(AccountManager.KEY_AUTHTOKEN, authToken); + response.onResult(result); + } + } + + public abstract static class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate { + protected final Context context; + protected final AndroidFxAccount fxAccount; + protected final Executor executor; + protected final FxAccountClient client; + + public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) { + this.context = context; + this.fxAccount = fxAccount; + this.executor = Executors.newSingleThreadExecutor(); + this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor); + } + + @Override + public FxAccountClient getClient() { + return client; + } + + @Override + public long getCertificateDurationInMilliseconds() { + return 12 * 60 * 60 * 1000; + } + + @Override + public long getAssertionDurationInMilliseconds() { + return 15 * 60 * 1000; + } + + @Override + public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException { + return StateFactory.generateKeyPair(); + } + + @Override + public void handleTransition(Transition transition, State state) { + Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel()); + } + + abstract public void handleNotMarried(State notMarried); + abstract public void handleMarried(Married married); + + @Override + public void handleFinal(State state) { + Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel()); + fxAccount.setState(state); + // Update any notifications displayed. + final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID); + notificationManager.update(context, fxAccount); + + if (state.getStateLabel() != StateLabel.Married) { + handleNotMarried(state); + return; + } else { + handleMarried((Married) state); + } + } + } + + protected void getOAuthToken(final AccountAuthenticatorResponse response, final AndroidFxAccount fxAccount, final String scope) throws NetworkErrorException { + Logger.info(LOG_TAG, "Fetching oauth token with scope: " + scope); + + final Responder responder = new Responder(response, fxAccount); + final String oauthServerUri = fxAccount.getOAuthServerURI(); + + final String audience; + try { + audience = FxAccountUtils.getAudienceForURL(oauthServerUri); // The assertion gets traded in for an oauth bearer token. + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e); + responder.fail(e); + return; + } + + final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine(); + + stateMachine.advance(fxAccount.getState(), StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) { + @Override + public void handleNotMarried(State state) { + final String message = "Cannot fetch oauth token from state: " + state.getStateLabel(); + Logger.warn(LOG_TAG, message); + responder.fail(new RuntimeException(message)); + } + + @Override + public void handleMarried(final Married married) { + final String assertion; + try { + assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER); + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + JSONWebTokenUtils.dumpAssertion(assertion); + } + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e); + responder.fail(e); + return; + } + + final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerUri, executor); + Logger.debug(LOG_TAG, "OAuth fetch for scope: " + scope); + oauthClient.authorization(FxAccountConstants.OAUTH_CLIENT_ID_FENNEC, assertion, null, scope, new RequestDelegate<FxAccountOAuthClient10.AuthorizationResponse>() { + @Override + public void handleSuccess(AuthorizationResponse result) { + Logger.debug(LOG_TAG, "OAuth success."); + FxAccountUtils.pii(LOG_TAG, "Fetched oauth token: " + result.access_token); + responder.succeed(result.access_token); + } + + @Override + public void handleFailure(FxAccountAbstractClientRemoteException e) { + Logger.error(LOG_TAG, "OAuth failure.", e); + if (e.isInvalidAuthentication()) { + // We were married, generated an assertion, and our assertion was rejected by the + // oauth client. If it's a 401, we probably have a stale certificate. If instead of + // a stale certificate we have bad credentials, the state machine will fail to sign + // our public key and drive us back to Separated. + fxAccount.setState(married.makeCohabitingState()); + } + responder.fail(e); + } + + @Override + public void handleError(Exception e) { + Logger.error(LOG_TAG, "OAuth error.", e); + responder.fail(e); + } + }); + } + }); + } + + @Override + public Bundle getAuthToken(final AccountAuthenticatorResponse response, + final Account account, final String authTokenType, final Bundle options) + throws NetworkErrorException { + Logger.debug(LOG_TAG, "getAuthToken: " + authTokenType); + + // If we have a cached authToken, hand it over. + final String cachedAuthToken = AccountManager.get(context).peekAuthToken(account, authTokenType); + if (cachedAuthToken != null && !cachedAuthToken.isEmpty()) { + Logger.info(LOG_TAG, "Return cached token."); + final Bundle result = new Bundle(); + result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); + result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); + result.putString(AccountManager.KEY_AUTHTOKEN, cachedAuthToken); + return result; + } + + // If we're asked for an oauth::scope token, try to generate one. + final String oauthPrefix = "oauth::"; + if (authTokenType != null && authTokenType.startsWith(oauthPrefix)) { + final String scope = authTokenType.substring(oauthPrefix.length()); + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + try { + fxAccount.acquireSharedAccountStateLock(LOG_TAG); + } catch (InterruptedException e) { + Logger.warn(LOG_TAG, "Could not acquire account state lock; return error bundle."); + final Bundle bundle = new Bundle(); + bundle.putInt(AccountManager.KEY_ERROR_CODE, 1); + bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "Could not acquire account state lock."); + return bundle; + } + getOAuthToken(response, fxAccount, scope); + return null; + } + + // Otherwise, fail. + Logger.warn(LOG_TAG, "Returning error bundle for getAuthToken with unknown token type."); + final Bundle bundle = new Bundle(); + bundle.putInt(AccountManager.KEY_ERROR_CODE, 2); + bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "Unknown token type: " + authTokenType); + return bundle; + } + + @Override + public String getAuthTokenLabel(String authTokenType) { + Logger.debug(LOG_TAG, "getAuthTokenLabel"); + + return null; + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse response, + Account account, String[] features) throws NetworkErrorException { + Logger.debug(LOG_TAG, "hasFeatures"); + + return null; + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse response, + Account account, String authTokenType, Bundle options) + throws NetworkErrorException { + Logger.debug(LOG_TAG, "updateCredentials"); + + return null; + } + + /** + * If the account is going to be removed, broadcast an "account deleted" + * intent. This allows us to clean up the account. + * <p> + * It is preferable to receive Android's LOGIN_ACCOUNTS_CHANGED_ACTION broadcast + * than to create our own hacky broadcast here, but that doesn't include enough + * information about which Accounts changed to correctly identify whether a Sync + * account has been removed (when some Firefox channels are installed on the SD + * card). We can work around this by storing additional state but it's both messy + * and expensive because the broadcast is noisy. + * <p> + * Note that this is <b>not</b> called when an Android Account is blown away + * due to the SD card being unmounted. + */ + @Override + public Bundle getAccountRemovalAllowed(final AccountAuthenticatorResponse response, Account account) + throws NetworkErrorException { + Bundle result = super.getAccountRemovalAllowed(response, account); + + if (result == null || + !result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) || + result.containsKey(AccountManager.KEY_INTENT)) { + return result; + } + + final boolean removalAllowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT); + if (!removalAllowed) { + return result; + } + + // Broadcast a message to all Firefox channels sharing this Android + // Account type telling that this Firefox account has been deleted. + // + // Broadcast intents protected with permissions are secure, so it's okay + // to include private information such as a password. + final AndroidFxAccount androidFxAccount = new AndroidFxAccount(context, account); + + // Deleting the pickle file in a blocking manner will avoid race conditions that might happen when + // an account is unpickled while an FxAccount is being deleted. + // Also we have an assumption that this method is always called from a background thread, so we delete + // the pickle file directly without being afraid from a StrictMode violation. + ThreadUtils.assertNotOnUiThread(); + + final Intent serviceIntent = androidFxAccount.populateDeletedAccountIntent( + new Intent(context, FxAccountDeletedService.class) + ); + Logger.info(LOG_TAG, "Account named " + account.name + " being removed; " + + "starting FxAccountDeletedService with action: " + serviceIntent.getAction() + "."); + context.startService(serviceIntent); + + Logger.info(LOG_TAG, "Firefox account named " + account.name + " being removed; " + + "deleting saved pickle file '" + FxAccountConstants.ACCOUNT_PICKLE_FILENAME + "'."); + deletePickle(); + + return result; + } + + private void deletePickle() { + try { + AccountPickler.deletePickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME); + } catch (Exception e) { + // This should never happen, but we really don't want to die in a background thread. + Logger.warn(LOG_TAG, "Got exception deleting saved pickle file; ignoring.", e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java new file mode 100644 index 000000000..d138e6c45 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java @@ -0,0 +1,55 @@ +/* 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.fxa.authenticator; + +import org.mozilla.gecko.background.common.log.Logger; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +public class FxAccountAuthenticatorService extends Service { + public static final String LOG_TAG = FxAccountAuthenticatorService.class.getSimpleName(); + + // Lazily initialized by <code>getAuthenticator</code>. + protected FxAccountAuthenticator accountAuthenticator; + + protected synchronized FxAccountAuthenticator getAuthenticator() { + if (accountAuthenticator == null) { + accountAuthenticator = new FxAccountAuthenticator(this); + } + + return accountAuthenticator; + } + + @Override + public void onCreate() { + Logger.debug(LOG_TAG, "onCreate"); + + accountAuthenticator = getAuthenticator(); + } + + @Override + public IBinder onBind(Intent intent) { + Logger.debug(LOG_TAG, "onBind"); + + if (intent == null) { + // Should never happen, but can -- Bug 1025937. + return null; + } + + if (!android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT.equals(intent.getAction())) { + return null; + } + + final FxAccountAuthenticator authenticator = getAuthenticator(); + if (authenticator == null) { + // Should never happen. + return null; + } + + return authenticator.getIBinder(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java new file mode 100644 index 000000000..71006e79d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java @@ -0,0 +1,26 @@ +/* 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.fxa.authenticator; + +/** + * Abstraction around things that might need to be signalled to the user via UI, + * such as: + * <ul> + * <li>account not yet verified;</li> + * <li>account password needs to be updated;</li> + * <li>account key management required or changed;</li> + * <li>auth protocol has changed and Firefox needs to be upgraded;</li> + * </ul> + * etc. + * <p> + * Consumers of this code should differentiate error classes based on the types + * of the exceptions thrown. Exceptions that do not have special meaning are of + * type <code>FxAccountLoginException</code> with an appropriate + * <code>cause</code> inner exception. + */ +public interface FxAccountLoginDelegate { + public void handleError(FxAccountLoginException e); + public void handleSuccess(String assertion); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java new file mode 100644 index 000000000..56c0140b2 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java @@ -0,0 +1,33 @@ +/* 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.fxa.authenticator; + +public class FxAccountLoginException extends Exception { + public FxAccountLoginException(String string) { + super(string); + } + + public FxAccountLoginException(Exception e) { + super(e); + } + + private static final long serialVersionUID = 397685959625820798L; + + public static class FxAccountLoginBadPasswordException extends FxAccountLoginException { + public FxAccountLoginBadPasswordException(String string) { + super(string); + } + + private static final long serialVersionUID = 397685959625820799L; + } + + public static class FxAccountLoginAccountNotVerifiedException extends FxAccountLoginException { + public FxAccountLoginAccountNotVerifiedException(String string) { + super(string); + } + + private static final long serialVersionUID = 397685959625820800L; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java new file mode 100644 index 000000000..5d3e71ece --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java @@ -0,0 +1,49 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.background.fxa.FxAccountClient20; +import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.AccountNeedsVerification; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LocalError; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.RemoteError; + +public abstract class BaseRequestDelegate<T> implements FxAccountClient20.RequestDelegate<T> { + protected final ExecuteDelegate delegate; + protected final State state; + + public BaseRequestDelegate(State state, ExecuteDelegate delegate) { + this.delegate = delegate; + this.state = state; + } + + @Override + public void handleFailure(FxAccountClientRemoteException e) { + // Order matters here: we don't want to ignore upgrade required responses + // even if the server tells us something else as well. We don't go directly + // to the Doghouse on upgrade required; we want the user to try to update + // their credentials, and then display UI telling them they need to upgrade. + // Then they go to the Doghouse. + if (e.isUpgradeRequired()) { + delegate.handleTransition(new RemoteError(e), new Separated(state.email, state.uid, state.verified)); + return; + } + if (e.isInvalidAuthentication()) { + delegate.handleTransition(new RemoteError(e), new Separated(state.email, state.uid, state.verified)); + return; + } + if (e.isUnverified()) { + delegate.handleTransition(new AccountNeedsVerification(), state); + return; + } + delegate.handleTransition(new RemoteError(e), state); + } + + @Override + public void handleError(Exception e) { + delegate.handleTransition(new LocalError(e), state); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java new file mode 100644 index 000000000..dd3477a79 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java @@ -0,0 +1,50 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.browserid.JSONWebTokenUtils; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public class Cohabiting extends TokensAndKeysState { + private static final String LOG_TAG = Cohabiting.class.getSimpleName(); + + public Cohabiting(String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair) { + super(StateLabel.Cohabiting, email, uid, sessionToken, kA, kB, keyPair); + } + + public Married withCertificate(String certificate) { + return new Married(email, uid, sessionToken, kA, kB, keyPair, certificate); + } + + @Override + public void execute(final ExecuteDelegate delegate) { + delegate.getClient().sign(sessionToken, keyPair.getPublic().toJSONObject(), delegate.getCertificateDurationInMilliseconds(), + new BaseRequestDelegate<String>(this, delegate) { + @Override + public void handleSuccess(String certificate) { + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + try { + FxAccountUtils.pii(LOG_TAG, "Fetched certificate: " + certificate); + ExtendedJSONObject c = JSONWebTokenUtils.parseCertificate(certificate); + if (c != null) { + FxAccountUtils.pii(LOG_TAG, "Header : " + c.getObject("header")); + FxAccountUtils.pii(LOG_TAG, "Payload : " + c.getObject("payload")); + FxAccountUtils.pii(LOG_TAG, "Signature: " + c.getString("signature")); + } else { + FxAccountUtils.pii(LOG_TAG, "Could not parse certificate!"); + } + } catch (Exception e) { + FxAccountUtils.pii(LOG_TAG, "Could not parse certificate!"); + } + } + delegate.handleTransition(new LogMessage("sign succeeded"), withCertificate(certificate)); + } + }); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java new file mode 100644 index 000000000..57600577d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java @@ -0,0 +1,25 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage; + + +public class Doghouse extends State { + public Doghouse(String email, String uid, boolean verified) { + super(StateLabel.Doghouse, email, uid, verified); + } + + @Override + public void execute(final ExecuteDelegate delegate) { + delegate.handleTransition(new LogMessage("Upgraded Firefox clients might know what to do here."), this); + } + + @Override + public Action getNeededAction() { + return Action.NeedsUpgrade; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java new file mode 100644 index 000000000..f192cb58b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java @@ -0,0 +1,91 @@ +/* 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.fxa.login; + +import java.security.NoSuchAlgorithmException; + +import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.AccountVerified; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LocalError; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.RemoteError; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; + +public class Engaged extends State { + private static final String LOG_TAG = Engaged.class.getSimpleName(); + + protected final byte[] sessionToken; + protected final byte[] keyFetchToken; + protected final byte[] unwrapkB; + + public Engaged(String email, String uid, boolean verified, byte[] unwrapkB, byte[] sessionToken, byte[] keyFetchToken) { + super(StateLabel.Engaged, email, uid, verified); + Utils.throwIfNull(unwrapkB, sessionToken, keyFetchToken); + this.unwrapkB = unwrapkB; + this.sessionToken = sessionToken; + this.keyFetchToken = keyFetchToken; + } + + @Override + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = super.toJSONObject(); + // Fields are non-null by constructor. + o.put("unwrapkB", Utils.byte2Hex(unwrapkB)); + o.put("sessionToken", Utils.byte2Hex(sessionToken)); + o.put("keyFetchToken", Utils.byte2Hex(keyFetchToken)); + return o; + } + + @Override + public void execute(final ExecuteDelegate delegate) { + BrowserIDKeyPair theKeyPair; + try { + theKeyPair = delegate.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + delegate.handleTransition(new LocalError(e), new Doghouse(email, uid, verified)); + return; + } + final BrowserIDKeyPair keyPair = theKeyPair; + + delegate.getClient().keys(keyFetchToken, new BaseRequestDelegate<TwoKeys>(this, delegate) { + @Override + public void handleSuccess(TwoKeys result) { + byte[] kB; + try { + kB = FxAccountUtils.unwrapkB(unwrapkB, result.wrapkB); + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + FxAccountUtils.pii(LOG_TAG, "Fetched kA: " + Utils.byte2Hex(result.kA)); + FxAccountUtils.pii(LOG_TAG, "And wrapkB: " + Utils.byte2Hex(result.wrapkB)); + FxAccountUtils.pii(LOG_TAG, "Giving kB : " + Utils.byte2Hex(kB)); + } + } catch (Exception e) { + delegate.handleTransition(new RemoteError(e), new Separated(email, uid, verified)); + return; + } + Transition transition = verified + ? new LogMessage("keys succeeded") + : new AccountVerified(); + delegate.handleTransition(transition, new Cohabiting(email, uid, sessionToken, result.kA, kB, keyPair)); + } + }); + } + + @Override + public Action getNeededAction() { + if (!verified) { + return Action.NeedsVerification; + } + return Action.None; + } + + public byte[] getSessionToken() { + return sessionToken; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java new file mode 100644 index 000000000..34e507541 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java @@ -0,0 +1,84 @@ +/* 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.fxa.login; + +import java.security.NoSuchAlgorithmException; +import java.util.EnumSet; +import java.util.Set; + +import org.mozilla.gecko.background.fxa.FxAccountClient; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition; +import org.mozilla.gecko.fxa.login.State.StateLabel; + +public class FxAccountLoginStateMachine { + public static final String LOG_TAG = FxAccountLoginStateMachine.class.getSimpleName(); + + public interface LoginStateMachineDelegate { + public FxAccountClient getClient(); + public long getCertificateDurationInMilliseconds(); + public long getAssertionDurationInMilliseconds(); + public void handleTransition(Transition transition, State state); + public void handleFinal(State state); + public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException; + } + + public static class ExecuteDelegate { + protected final LoginStateMachineDelegate delegate; + protected final StateLabel desiredStateLabel; + // It's as difficult to detect arbitrary cycles as repeated states. + protected final Set<StateLabel> stateLabelsSeen = EnumSet.noneOf(StateLabel.class); + + protected ExecuteDelegate(StateLabel initialStateLabel, StateLabel desiredStateLabel, LoginStateMachineDelegate delegate) { + this.delegate = delegate; + this.desiredStateLabel = desiredStateLabel; + this.stateLabelsSeen.add(initialStateLabel); + } + + public FxAccountClient getClient() { + return delegate.getClient(); + } + + public long getCertificateDurationInMilliseconds() { + return delegate.getCertificateDurationInMilliseconds(); + } + + public long getAssertionDurationInMilliseconds() { + return delegate.getAssertionDurationInMilliseconds(); + } + + public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException { + return delegate.generateKeyPair(); + } + + public void handleTransition(Transition transition, State state) { + // Always trigger the transition callback. + delegate.handleTransition(transition, state); + + // Possibly trigger the final callback. We trigger if we're at our desired + // state, or if we've seen this state before. + StateLabel stateLabel = state.getStateLabel(); + if (stateLabel == desiredStateLabel || stateLabelsSeen.contains(stateLabel)) { + delegate.handleFinal(state); + return; + } + + // If this wasn't the last state, leave a bread crumb and move on to the + // next state. + stateLabelsSeen.add(stateLabel); + state.execute(this); + } + } + + public void advance(State initialState, final StateLabel desiredStateLabel, final LoginStateMachineDelegate delegate) { + if (initialState.getStateLabel() == desiredStateLabel) { + // We're already where we want to be! + delegate.handleFinal(initialState); + return; + } + ExecuteDelegate executeDelegate = new ExecuteDelegate(initialState.getStateLabel(), desiredStateLabel, delegate); + initialState.execute(executeDelegate); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java new file mode 100644 index 000000000..683217853 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java @@ -0,0 +1,68 @@ +/* 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.fxa.login; + + +public class FxAccountLoginTransition { + public interface Transition { + } + + public static class LogMessage implements Transition { + public final String detailMessage; + + public LogMessage(String detailMessage) { + this.detailMessage = detailMessage; + } + + @Override + public String toString() { + return getClass().getSimpleName() + (this.detailMessage == null ? "" : "('" + this.detailMessage + "')"); + } + } + + public static class AccountNeedsVerification extends LogMessage { + public AccountNeedsVerification() { + super(null); + } + } + + public static class AccountVerified extends LogMessage { + public AccountVerified() { + super(null); + } + } + + public static class PasswordRequired extends LogMessage { + public PasswordRequired() { + super(null); + } + } + + public static class LocalError implements Transition { + public final Exception e; + + public LocalError(Exception e) { + this.e = e; + } + + @Override + public String toString() { + return "Log(" + this.e + ")"; + } + } + + public static class RemoteError implements Transition { + public final Exception e; + + public RemoteError(Exception e) { + this.e = e; + } + + @Override + public String toString() { + return "Log(" + (this.e == null ? "null" : this.e) + ")"; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java new file mode 100644 index 000000000..1ec7b4051 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java @@ -0,0 +1,117 @@ +/* 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.fxa.login; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; + +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.browserid.JSONWebTokenUtils; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.crypto.KeyBundle; + +public class Married extends TokensAndKeysState { + private static final String LOG_TAG = Married.class.getSimpleName(); + + protected final String certificate; + protected final String clientState; + + public Married(String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair, String certificate) { + super(StateLabel.Married, email, uid, sessionToken, kA, kB, keyPair); + Utils.throwIfNull(certificate); + this.certificate = certificate; + try { + this.clientState = FxAccountUtils.computeClientState(kB); + } catch (NoSuchAlgorithmException e) { + // This should never occur. + throw new IllegalStateException("Unable to compute client state from kB."); + } + } + + @Override + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = super.toJSONObject(); + // Fields are non-null by constructor. + o.put("certificate", certificate); + return o; + } + + @Override + public void execute(final ExecuteDelegate delegate) { + delegate.handleTransition(new LogMessage("staying married"), this); + } + + public String generateAssertion(String audience, String issuer) throws NonObjectJSONException, IOException, GeneralSecurityException { + // We generate assertions with no iat and an exp after 2050 to avoid + // invalid-timestamp errors from the token server. + final long expiresAt = JSONWebTokenUtils.DEFAULT_FUTURE_EXPIRES_AT_IN_MILLISECONDS; + String assertion = JSONWebTokenUtils.createAssertion(keyPair.getPrivate(), certificate, audience, issuer, null, expiresAt); + if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) { + return assertion; + } + + try { + FxAccountUtils.pii(LOG_TAG, "Generated assertion: " + assertion); + ExtendedJSONObject a = JSONWebTokenUtils.parseAssertion(assertion); + if (a != null) { + FxAccountUtils.pii(LOG_TAG, "aHeader : " + a.getObject("header")); + FxAccountUtils.pii(LOG_TAG, "aPayload : " + a.getObject("payload")); + FxAccountUtils.pii(LOG_TAG, "aSignature: " + a.getString("signature")); + String certificate = a.getString("certificate"); + if (certificate != null) { + ExtendedJSONObject c = JSONWebTokenUtils.parseCertificate(certificate); + FxAccountUtils.pii(LOG_TAG, "cHeader : " + c.getObject("header")); + FxAccountUtils.pii(LOG_TAG, "cPayload : " + c.getObject("payload")); + FxAccountUtils.pii(LOG_TAG, "cSignature: " + c.getString("signature")); + // Print the relevant timestamps in sorted order with labels. + HashMap<Long, String> map = new HashMap<Long, String>(); + map.put(a.getObject("payload").getLong("iat"), "aiat"); + map.put(a.getObject("payload").getLong("exp"), "aexp"); + map.put(c.getObject("payload").getLong("iat"), "ciat"); + map.put(c.getObject("payload").getLong("exp"), "cexp"); + ArrayList<Long> values = new ArrayList<Long>(map.keySet()); + Collections.sort(values); + for (Long value : values) { + FxAccountUtils.pii(LOG_TAG, map.get(value) + ": " + value); + } + } else { + FxAccountUtils.pii(LOG_TAG, "Could not parse certificate!"); + } + } else { + FxAccountUtils.pii(LOG_TAG, "Could not parse assertion!"); + } + } catch (Exception e) { + FxAccountUtils.pii(LOG_TAG, "Got exception dumping assertion debug info."); + } + return assertion; + } + + public KeyBundle getSyncKeyBundle() throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + // TODO Document this choice for deriving from kB. + return FxAccountUtils.generateSyncKeyBundle(kB); + } + + public String getClientState() { + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + FxAccountUtils.pii(LOG_TAG, "Client state: " + this.clientState); + } + return this.clientState; + } + + public Cohabiting makeCohabitingState() { + return new Cohabiting(email, uid, sessionToken, kA, kB, keyPair); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java new file mode 100644 index 000000000..c30ac2ff7 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java @@ -0,0 +1,28 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.PasswordRequired; + +public class MigratedFromSync11 extends State { + public final String password; + + public MigratedFromSync11(String email, String uid, boolean verified, String password) { + super(StateLabel.MigratedFromSync11, email, uid, verified); + // Null password is allowed. + this.password = password; + } + + @Override + public void execute(final ExecuteDelegate delegate) { + delegate.handleTransition(new PasswordRequired(), this); + } + + @Override + public Action getNeededAction() { + return Action.NeedsFinishMigrating; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java new file mode 100644 index 000000000..bda620df9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java @@ -0,0 +1,25 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.PasswordRequired; + + +public class Separated extends State { + public Separated(String email, String uid, boolean verified) { + super(StateLabel.Separated, email, uid, verified); + } + + @Override + public void execute(final ExecuteDelegate delegate) { + delegate.handleTransition(new PasswordRequired(), this); + } + + @Override + public Action getNeededAction() { + return Action.NeedsPassword; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java new file mode 100644 index 000000000..797011ec2 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java @@ -0,0 +1,72 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; + +public abstract class State { + public static final long CURRENT_VERSION = 3L; + + public enum StateLabel { + Engaged, + Cohabiting, + Married, + Separated, + Doghouse, + MigratedFromSync11, + } + + public enum Action { + NeedsUpgrade, + NeedsPassword, + NeedsVerification, + NeedsFinishMigrating, + None, + } + + protected final StateLabel stateLabel; + public final String email; + public final String uid; + public final boolean verified; + + public State(StateLabel stateLabel, String email, String uid, boolean verified) { + Utils.throwIfNull(email, uid); + this.stateLabel = stateLabel; + this.email = email; + this.uid = uid; + this.verified = verified; + } + + public StateLabel getStateLabel() { + return this.stateLabel; + } + + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put("version", State.CURRENT_VERSION); + o.put("email", email); + o.put("uid", uid); + o.put("verified", verified); + return o; + } + + public State makeSeparatedState() { + return new Separated(email, uid, verified); + } + + public State makeDoghouseState() { + return new Doghouse(email, uid, verified); + } + + public State makeMigratedFromSync11State(String password) { + return new MigratedFromSync11(email, uid, verified, password); + } + + public abstract void execute(ExecuteDelegate delegate); + + public abstract Action getNeededAction(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java new file mode 100644 index 000000000..a98f2fb27 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java @@ -0,0 +1,206 @@ +/* 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.fxa.login; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.browserid.DSACryptoImplementation; +import org.mozilla.gecko.browserid.RSACryptoImplementation; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; + +/** + * Create {@link State} instances from serialized representations. + * <p> + * Version 1 recognizes 5 state labels (Engaged, Cohabiting, Married, Separated, + * Doghouse). In the Cohabiting and Married states, the associated key pairs are + * always RSA key pairs. + * <p> + * Version 2 is identical to version 1, except that in the Cohabiting and + * Married states, the associated keypairs are always DSA key pairs. + */ +public class StateFactory { + private static final String LOG_TAG = StateFactory.class.getSimpleName(); + + private static final int KEY_PAIR_SIZE_IN_BITS_V1 = 1024; + + public static BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException { + // New key pairs are always DSA. + return DSACryptoImplementation.generateKeyPair(KEY_PAIR_SIZE_IN_BITS_V1); + } + + protected static BrowserIDKeyPair keyPairFromJSONObjectV1(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + // V1 key pairs are RSA. + return RSACryptoImplementation.fromJSONObject(o); + } + + protected static BrowserIDKeyPair keyPairFromJSONObjectV2(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException { + // V2 key pairs are DSA. + return DSACryptoImplementation.fromJSONObject(o); + } + + public static State fromJSONObject(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { + Long version = o.getLong("version"); + if (version == null) { + throw new IllegalStateException("version must not be null"); + } + + final int v = version.intValue(); + if (v == 3) { + // The most common case is the most recent version. + return fromJSONObjectV3(stateLabel, o); + } + if (v == 2) { + return fromJSONObjectV2(stateLabel, o); + } + if (v == 1) { + final State state = fromJSONObjectV1(stateLabel, o); + return migrateV1toV2(stateLabel, state); + } + throw new IllegalStateException("version must be in {1, 2}"); + } + + protected static State fromJSONObjectV1(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { + switch (stateLabel) { + case Engaged: + return new Engaged( + o.getString("email"), + o.getString("uid"), + o.getBoolean("verified"), + Utils.hex2Byte(o.getString("unwrapkB")), + Utils.hex2Byte(o.getString("sessionToken")), + Utils.hex2Byte(o.getString("keyFetchToken"))); + case Cohabiting: + return new Cohabiting( + o.getString("email"), + o.getString("uid"), + Utils.hex2Byte(o.getString("sessionToken")), + Utils.hex2Byte(o.getString("kA")), + Utils.hex2Byte(o.getString("kB")), + keyPairFromJSONObjectV1(o.getObject("keyPair"))); + case Married: + return new Married( + o.getString("email"), + o.getString("uid"), + Utils.hex2Byte(o.getString("sessionToken")), + Utils.hex2Byte(o.getString("kA")), + Utils.hex2Byte(o.getString("kB")), + keyPairFromJSONObjectV1(o.getObject("keyPair")), + o.getString("certificate")); + case Separated: + return new Separated( + o.getString("email"), + o.getString("uid"), + o.getBoolean("verified")); + case Doghouse: + return new Doghouse( + o.getString("email"), + o.getString("uid"), + o.getBoolean("verified")); + default: + throw new IllegalStateException("unrecognized state label: " + stateLabel); + } + } + + /** + * Exactly the same as {@link fromJSONObjectV1}, except that all key pairs are DSA key pairs. + */ + protected static State fromJSONObjectV2(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { + switch (stateLabel) { + case Cohabiting: + return new Cohabiting( + o.getString("email"), + o.getString("uid"), + Utils.hex2Byte(o.getString("sessionToken")), + Utils.hex2Byte(o.getString("kA")), + Utils.hex2Byte(o.getString("kB")), + keyPairFromJSONObjectV2(o.getObject("keyPair"))); + case Married: + return new Married( + o.getString("email"), + o.getString("uid"), + Utils.hex2Byte(o.getString("sessionToken")), + Utils.hex2Byte(o.getString("kA")), + Utils.hex2Byte(o.getString("kB")), + keyPairFromJSONObjectV2(o.getObject("keyPair")), + o.getString("certificate")); + default: + return fromJSONObjectV1(stateLabel, o); + } + } + + /** + * Exactly the same as {@link fromJSONObjectV2}, except that there's a new + * MigratedFromSyncV11 state. + */ + protected static State fromJSONObjectV3(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { + switch (stateLabel) { + case MigratedFromSync11: + return new MigratedFromSync11( + o.getString("email"), + o.getString("uid"), + o.getBoolean("verified"), + o.getString("password")); + default: + return fromJSONObjectV2(stateLabel, o); + } + } + + protected static void logMigration(State from, State to) { + if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) { + return; + } + try { + FxAccountUtils.pii(LOG_TAG, "V1 persisted state is: " + from.toJSONObject().toJSONString()); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Error producing JSON representation of V1 state.", e); + } + FxAccountUtils.pii(LOG_TAG, "Generated new V2 state: " + to.toJSONObject().toJSONString()); + } + + protected static State migrateV1toV2(StateLabel stateLabel, State state) throws NoSuchAlgorithmException { + if (state == null) { + // This should never happen, but let's be careful. + Logger.error(LOG_TAG, "Got null state in migrateV1toV2; returning null."); + return state; + } + + Logger.info(LOG_TAG, "Migrating V1 persisted State to V2; stateLabel: " + stateLabel); + + // In V1, we use an RSA keyPair. In V2, we use a DSA keyPair. Only + // Cohabiting and Married states have a persisted keyPair at all; all + // other states need no conversion at all. + switch (stateLabel) { + case Cohabiting: { + // In the Cohabiting state, we can just generate a new key pair and move on. + final Cohabiting cohabiting = (Cohabiting) state; + final BrowserIDKeyPair keyPair = generateKeyPair(); + final State migrated = new Cohabiting(cohabiting.email, cohabiting.uid, cohabiting.sessionToken, cohabiting.kA, cohabiting.kB, keyPair); + logMigration(cohabiting, migrated); + return migrated; + } + case Married: { + // In the Married state, we cannot only change the key pair: the stored + // certificate signs the public key of the now obsolete key pair. We + // regress to the Cohabiting state; the next time we sync, we should + // advance back to Married. + final Married married = (Married) state; + final BrowserIDKeyPair keyPair = generateKeyPair(); + final State migrated = new Cohabiting(married.email, married.uid, married.sessionToken, married.kA, married.kB, keyPair); + logMigration(married, migrated); + return migrated; + } + default: + // Otherwise, V1 and V2 states are identical. + return state; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java new file mode 100644 index 000000000..b5121a4d4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java @@ -0,0 +1,45 @@ +/* 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.fxa.login; + +import org.mozilla.gecko.browserid.BrowserIDKeyPair; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; + +public abstract class TokensAndKeysState extends State { + protected final byte[] sessionToken; + protected final byte[] kA; + protected final byte[] kB; + protected final BrowserIDKeyPair keyPair; + + public TokensAndKeysState(StateLabel stateLabel, String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair) { + super(stateLabel, email, uid, true); + Utils.throwIfNull(sessionToken, kA, kB, keyPair); + this.sessionToken = sessionToken; + this.kA = kA; + this.kB = kB; + this.keyPair = keyPair; + } + + @Override + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject o = super.toJSONObject(); + // Fields are non-null by constructor. + o.put("sessionToken", Utils.byte2Hex(sessionToken)); + o.put("kA", Utils.byte2Hex(kA)); + o.put("kB", Utils.byte2Hex(kB)); + o.put("keyPair", keyPair.toJSONObject()); + return o; + } + + public byte[] getSessionToken() { + return sessionToken; + } + + @Override + public Action getNeededAction() { + return Action.None; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java new file mode 100644 index 000000000..60a63a5e1 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java @@ -0,0 +1,154 @@ +/* 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.fxa.receivers; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException; +import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager; +import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter; +import org.mozilla.gecko.sync.repositories.android.ClientsDatabase; +import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository; + +import java.util.concurrent.Executor; + +/** + * A background service to clean up after a Firefox Account is deleted. + * <p> + * Note that we specifically handle deleting the pickle file using a Service and a + * BroadcastReceiver, rather than a background thread, to allow channels sharing a Firefox account + * to delete their respective pickle files (since, if one remains, the account will be restored + * when that channel is used). + */ +public class FxAccountDeletedService extends IntentService { + public static final String LOG_TAG = FxAccountDeletedService.class.getSimpleName(); + + public FxAccountDeletedService() { + super(LOG_TAG); + } + + @Override + protected void onHandleIntent(final Intent intent) { + // We have an in-memory accounts cache which we use for a variety of tasks; it needs to be cleared. + // It should be fine to invalidate it before doing anything else, as the tasks below do not rely + // on this data. + AndroidFxAccount.invalidateCaches(); + + // Intent can, in theory, be null. Bug 1025937. + if (intent == null) { + Logger.debug(LOG_TAG, "Short-circuiting on null intent."); + return; + } + + final Context context = this; + + long intentVersion = intent.getLongExtra( + FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY, 0); + long expectedVersion = FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION; + if (intentVersion != expectedVersion) { + Logger.warn(LOG_TAG, "Intent malformed: version " + intentVersion + " given but " + + "version " + expectedVersion + "expected. Not cleaning up after deleted Account."); + return; + } + + // Android Account name, not Sync encoded account name. + final String accountName = intent.getStringExtra( + FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY); + if (accountName == null) { + Logger.warn(LOG_TAG, "Intent malformed: no account name given. Not cleaning up after " + + "deleted Account."); + return; + } + + + // Fire up gecko and unsubscribe push + final Intent geckoIntent = new Intent(); + geckoIntent.setAction("create-services"); + geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService"); + geckoIntent.putExtra("category", "android-push-service"); + geckoIntent.putExtra("data", "android-fxa-unsubscribe"); + final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context); + geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", + intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE)); + context.startService(geckoIntent); + + // Delete client database and non-local tabs. + Logger.info(LOG_TAG, "Deleting the entire Fennec clients database and non-local tabs"); + FennecTabsRepository.deleteNonLocalClientsAndTabs(context); + + + // Clear Firefox Sync client tables. + try { + Logger.info(LOG_TAG, "Deleting the Firefox Sync clients database."); + ClientsDatabase db = null; + try { + db = new ClientsDatabase(context); + db.wipeClientsTable(); + db.wipeCommandsTable(); + } finally { + if (db != null) { + db.close(); + } + } + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception deleting the Firefox Sync clients database; ignoring.", e); + } + + // Remove any displayed notifications. + new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID).clear(context); + + // Bug 1147275: Delete cached oauth tokens. There's no way to query all + // oauth tokens from Android, so this is tricky to do comprehensively. We + // can query, individually, for specific oauth tokens to delete, however. + final String oauthServerURI = intent.getStringExtra(FxAccountConstants.ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY); + final String[] tokens = intent.getStringArrayExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS); + if (oauthServerURI != null && tokens != null) { + final Executor directExecutor = new Executor() { + @Override + public void execute(Runnable runnable) { + runnable.run(); + } + }; + + final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerURI, directExecutor); + + for (String token : tokens) { + if (token == null) { + Logger.error(LOG_TAG, "Cached OAuth token is null; should never happen. Ignoring."); + continue; + } + try { + oauthClient.deleteToken(token, new FxAccountAbstractClient.RequestDelegate<Void>() { + @Override + public void handleSuccess(Void result) { + Logger.info(LOG_TAG, "Successfully deleted cached OAuth token."); + } + + @Override + public void handleError(Exception e) { + Logger.error(LOG_TAG, "Failed to delete cached OAuth token; ignoring.", e); + } + + @Override + public void handleFailure(FxAccountAbstractClientRemoteException e) { + Logger.error(LOG_TAG, "Exception during cached OAuth token deletion; ignoring.", e); + } + }); + } catch (Exception e) { + Logger.error(LOG_TAG, "Exception during cached OAuth token deletion; ignoring.", e); + } + } + } else { + Logger.error(LOG_TAG, "Cached OAuth server URI is null or cached OAuth tokens are null; ignoring."); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java new file mode 100644 index 000000000..ad81e0488 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java @@ -0,0 +1,133 @@ +/* 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.fxa.receivers; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.sync.Utils; + +import android.accounts.Account; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * A receiver that takes action when our Android package is upgraded (replaced). + */ +public class FxAccountUpgradeReceiver extends BroadcastReceiver { + private static final String LOG_TAG = FxAccountUpgradeReceiver.class.getSimpleName(); + + /** + * Produce a list of Runnable instances to be executed sequentially on + * upgrade. + * <p> + * Each Runnable will be executed sequentially on a background thread. Any + * unchecked Exception thrown will be caught and ignored. + * + * @param context Android context. + * @return list of Runnable instances. + */ + protected List<Runnable> onUpgradeRunnables(Context context) { + List<Runnable> runnables = new LinkedList<Runnable>(); + runnables.add(new MaybeUnpickleRunnable(context)); + // Recovering accounts that are in the Doghouse should happen *after* we + // unpickle any accounts saved to disk. + runnables.add(new AdvanceFromDoghouseRunnable(context)); + return runnables; + } + + @Override + public void onReceive(final Context context, Intent intent) { + Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG); + Logger.info(LOG_TAG, "Upgrade broadcast received."); + + // Iterate Runnable instances one at a time. + final Executor executor = Executors.newSingleThreadExecutor(); + for (final Runnable runnable : onUpgradeRunnables(context)) { + executor.execute(new Runnable() { + @Override + public void run() { + try { + runnable.run(); + } catch (Exception e) { + // We really don't want to throw on a background thread, so we + // catch, log, and move on. + Logger.error(LOG_TAG, "Got exception executing background upgrade Runnable; ignoring.", e); + } + } + }); + } + } + + /** + * A Runnable that tries to unpickle any pickled Firefox Accounts. + */ + protected static class MaybeUnpickleRunnable implements Runnable { + protected final Context context; + + public MaybeUnpickleRunnable(Context context) { + this.context = context; + } + + @Override + public void run() { + // Querying the accounts will unpickle any pickled Firefox Account. + Logger.info(LOG_TAG, "Trying to unpickle any pickled Firefox Account."); + FirefoxAccounts.getFirefoxAccounts(context); + } + } + + /** + * A Runnable that tries to advance existing Firefox Accounts that are in the + * Doghouse state to the Separated state. + * <p> + * This is our main deprecation-and-upgrade mechanism: in some way, the + * Account gets moved to the Doghouse state. If possible, an upgraded version + * of the package advances to Separated, prompting the user to re-connect the + * Account. + */ + protected static class AdvanceFromDoghouseRunnable implements Runnable { + protected final Context context; + + public AdvanceFromDoghouseRunnable(Context context) { + this.context = context; + } + + @Override + public void run() { + final Account[] accounts = FirefoxAccounts.getFirefoxAccounts(context); + Logger.info(LOG_TAG, "Trying to advance " + accounts.length + " existing Firefox Accounts from the Doghouse to Separated (if necessary)."); + for (Account account : accounts) { + try { + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + // For great debugging. + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + fxAccount.dump(); + } + State state = fxAccount.getState(); + if (state == null || state.getStateLabel() != StateLabel.Doghouse) { + Logger.debug(LOG_TAG, "Account named like " + Utils.obfuscateEmail(account.name) + " is not in the Doghouse; skipping."); + continue; + } + Logger.debug(LOG_TAG, "Account named like " + Utils.obfuscateEmail(account.name) + " is in the Doghouse; advancing to Separated."); + fxAccount.setState(state.makeSeparatedState()); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception trying to advance account named like " + Utils.obfuscateEmail(account.name) + + " from Doghouse to Separated state; ignoring.", e); + } + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java new file mode 100644 index 000000000..b44da76fc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java @@ -0,0 +1,114 @@ +/* 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.fxa.sync; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.Builder; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.Action; +import org.mozilla.gecko.sync.telemetry.TelemetryContract; + +/** + * Abstraction that manages notifications shown or hidden for a Firefox Account. + * <p> + * In future, we anticipate this tracking things like: + * <ul> + * <li>new engines to offer to Sync;</li> + * <li>service interruption updates;</li> + * <li>messages from other clients.</li> + * </ul> + */ +public class FxAccountNotificationManager { + private static final String LOG_TAG = FxAccountNotificationManager.class.getSimpleName(); + + protected final int notificationId; + + // We're lazy about updating our locale info, because most syncs don't notify. + private volatile boolean localeUpdated; + + public FxAccountNotificationManager(int notificationId) { + this.notificationId = notificationId; + } + + /** + * Remove all Firefox Account related notifications from the notification manager. + * + * @param context + * Android context. + */ + public void clear(Context context) { + final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(notificationId); + } + + /** + * Reflect new Firefox Account state to the notification manager: show or hide + * notifications reflecting the state of a Firefox Account. + * + * @param context + * Android context. + * @param fxAccount + * Firefox Account to reflect to the notification manager. + */ + public void update(Context context, AndroidFxAccount fxAccount) { + final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + final State state = fxAccount.getState(); + final Action action = state.getNeededAction(); + if (action == Action.None) { + Logger.info(LOG_TAG, "State " + state.getStateLabel() + " needs no action; cancelling any existing notification."); + notificationManager.cancel(notificationId); + return; + } + + if (!localeUpdated) { + localeUpdated = true; + Locales.getLocaleManager().getAndApplyPersistedLocale(context); + } + + final String title; + final String text; + final Intent notificationIntent; + if (action == Action.NeedsFinishMigrating) { + TelemetryWrapper.addToHistogram(TelemetryContract.SYNC11_MIGRATION_NOTIFICATIONS_OFFERED, 1); + + title = context.getResources().getString(R.string.fxaccount_sync_finish_migrating_notification_title); + text = context.getResources().getString(R.string.fxaccount_sync_finish_migrating_notification_text, state.email); + notificationIntent = new Intent(FxAccountConstants.ACTION_FXA_FINISH_MIGRATING); + } else { + title = context.getResources().getString(R.string.fxaccount_sync_sign_in_error_notification_title); + text = context.getResources().getString(R.string.fxaccount_sync_sign_in_error_notification_text, state.email); + notificationIntent = new Intent(FxAccountConstants.ACTION_FXA_STATUS); + } + + notificationIntent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_NOTIFICATION); + + Logger.info(LOG_TAG, "State " + state.getStateLabel() + " needs action; offering notification with title: " + title); + FxAccountUtils.pii(LOG_TAG, "And text: " + text); + + final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0); + + final Builder builder = new NotificationCompat.Builder(context); + builder + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(R.drawable.ic_status_logo) + .setAutoCancel(true) + .setContentIntent(pendingIntent); + notificationManager.notify(notificationId, builder.build()); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java new file mode 100644 index 000000000..7f03eff1c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java @@ -0,0 +1,107 @@ +/* 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.fxa.sync; + +import android.accounts.AccountManager; +import android.app.Activity; +import android.app.IntentService; +import android.content.Intent; +import android.os.Bundle; +import android.os.ResultReceiver; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient; +import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException; +import org.mozilla.gecko.background.fxa.profile.FxAccountProfileClient10; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class FxAccountProfileService extends IntentService { + private static final String LOG_TAG = "FxAccountProfileService"; + private static final Executor EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + public static final String KEY_AUTH_TOKEN = "auth_token"; + public static final String KEY_PROFILE_SERVER_URI = "profileServerURI"; + public static final String KEY_RESULT_RECEIVER = "resultReceiver"; + public static final String KEY_RESULT_STRING = "RESULT_STRING"; + + public FxAccountProfileService() { + super("FxAccountProfileService"); + } + + @Override + protected void onHandleIntent(Intent intent) { + final String authToken = intent.getStringExtra(KEY_AUTH_TOKEN); + final String profileServerURI = intent.getStringExtra(KEY_PROFILE_SERVER_URI); + final ResultReceiver resultReceiver = intent.getParcelableExtra(KEY_RESULT_RECEIVER); + + if (resultReceiver == null) { + Logger.warn(LOG_TAG, "Result receiver must not be null; ignoring intent."); + return; + } + + if (authToken == null || authToken.length() == 0) { + Logger.warn(LOG_TAG, "Invalid Auth Token"); + sendResult("Invalid Auth Token", resultReceiver, Activity.RESULT_CANCELED); + return; + } + + if (profileServerURI == null || profileServerURI.length() == 0) { + Logger.warn(LOG_TAG, "Invalid profile Server Endpoint"); + sendResult("Invalid profile Server Endpoint", resultReceiver, Activity.RESULT_CANCELED); + return; + } + + // This delegate fetches the profile avatar json. + FxAccountProfileClient10.RequestDelegate<ExtendedJSONObject> delegate = new FxAccountAbstractClient.RequestDelegate<ExtendedJSONObject>() { + @Override + public void handleError(Exception e) { + Logger.error(LOG_TAG, "Error fetching Account profile.", e); + sendResult("Error fetching Account profile.", resultReceiver, Activity.RESULT_CANCELED); + } + + @Override + public void handleFailure(FxAccountAbstractClientException.FxAccountAbstractClientRemoteException e) { + Logger.warn(LOG_TAG, "Failed to fetch Account profile.", e); + + if (e.isInvalidAuthentication()) { + // The profile server rejected the cached oauth token! Invalidate it. + // A new token will be generated upon next request. + Logger.info(LOG_TAG, "Invalidating oauth token after 401!"); + AccountManager.get(FxAccountProfileService.this).invalidateAuthToken(FxAccountConstants.ACCOUNT_TYPE, authToken); + } + + sendResult("Failed to fetch Account profile.", resultReceiver, Activity.RESULT_CANCELED); + } + + @Override + public void handleSuccess(ExtendedJSONObject result) { + if (result != null){ + FxAccountUtils.pii(LOG_TAG, "Profile server return profile: " + result.toJSONString()); + sendResult(result.toJSONString(), resultReceiver, Activity.RESULT_OK); + } + } + }; + + FxAccountProfileClient10 client = new FxAccountProfileClient10(profileServerURI, EXECUTOR_SERVICE); + try { + client.profile(authToken, delegate); + } catch (Exception e) { + Logger.error(LOG_TAG, "Got exception fetching profile.", e); + delegate.handleError(e); + } + } + + private void sendResult(final String result, final ResultReceiver resultReceiver, final int code) { + if (resultReceiver != null) { + final Bundle bundle = new Bundle(); + bundle.putString(KEY_RESULT_STRING, result); + resultReceiver.send(code, bundle); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java new file mode 100644 index 000000000..708686e72 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java @@ -0,0 +1,178 @@ +/* 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.fxa.sync; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.State.Action; +import org.mozilla.gecko.sync.BackoffHandler; + +import android.accounts.Account; +import android.content.ContentResolver; +import android.content.Context; +import android.os.Bundle; + +public class FxAccountSchedulePolicy implements SchedulePolicy { + private static final String LOG_TAG = "FxAccountSchedulePolicy"; + + // Our poll intervals are used to trigger automatic background syncs + // in the absence of user activity. + // + // We also receive sync requests as a result of network tickles, so + // these intervals are long, with the exception of the rapid polling + // while we wait for verification: if we're waiting for the user to + // click on a verification link, we sync very often in order to detect + // a change in state. + // + // In the case of unverified -> unverified (no transition), this should be + // very close to a single HTTP request (with the SyncAdapter overhead, of + // course, but that's not wildly different from alarm manager overhead). + // + // The /account/status endpoint is HAWK authed by sessionToken, so we still + // have to do some crypto no matter what. + + // TODO: only do this for a while... + public static final long POLL_INTERVAL_PENDING_VERIFICATION = 60; // 1 minute. + + // If we're in some kind of error state, there's no point trying often. + // This is not the same as a server-imposed backoff, which will be + // reflected dynamically. + public static final long POLL_INTERVAL_ERROR_STATE_SEC = 24 * 60 * 60; // 24 hours. + + // If we're the only device, just sync once or twice a day in case that + // changes. + public static final long POLL_INTERVAL_SINGLE_DEVICE_SEC = 18 * 60 * 60; // 18 hours. + + // And if we know there are other devices, let's sync often enough that + // we'll be more likely to be caught up (even if not completely) by the + // time you next use this device. This is also achieved via Android's + // network tickles. + public static final long POLL_INTERVAL_MULTI_DEVICE_SEC = 12 * 60 * 60; // 12 hours. + + // This is used solely as an optimization for backoff handling, so it's not + // persisted. + private static volatile long POLL_INTERVAL_CURRENT_SEC = POLL_INTERVAL_SINGLE_DEVICE_SEC; + + // Never sync more frequently than this, unless forced. + // This is to avoid overly-frequent syncs during active browsing. + public static final long RATE_LIMIT_FUNDAMENTAL_SEC = 90; // 90 seconds. + + /** + * We are prompted to sync by several inputs: + * * Periodic syncs that we schedule at long intervals. See the POLL constants. + * * Network-tickle-based syncs that Android starts. + * * Upload-only syncs that are caused by local database writes. + * + * We rate-limit periodic and network-sourced events with this constant. + * We rate limit <b>both</b> with {@link FxAccountSchedulePolicy#RATE_LIMIT_FUNDAMENTAL_SEC}. + */ + public static final long RATE_LIMIT_BACKGROUND_SEC = 60 * 60; // 1 hour. + + private final AndroidFxAccount account; + private final Context context; + + public FxAccountSchedulePolicy(Context context, AndroidFxAccount account) { + this.account = account; + this.context = context; + } + + /** + * Return a millisecond timestamp in the future, offset from the current + * time by the provided amount. + * @param millis the duration by which to delay + * @return a timestamp. + */ + private static long delay(long millis) { + return System.currentTimeMillis() + millis; + } + + /** + * Updates the existing system periodic sync interval to the specified duration. + * + * @param intervalSeconds the requested period, which Android will vary by up to 4%. + */ + protected void requestPeriodicSync(final long intervalSeconds) { + final String authority = BrowserContract.AUTHORITY; + final Account account = this.account.getAndroidAccount(); + this.context.getContentResolver(); + Logger.info(LOG_TAG, "Scheduling periodic sync for " + intervalSeconds + "."); + ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, intervalSeconds); + POLL_INTERVAL_CURRENT_SEC = intervalSeconds; + } + + @Override + public void onSuccessfulSync(int otherClientsCount) { + this.account.setLastSyncedTimestamp(System.currentTimeMillis()); + // This undoes the change made in observeBackoffMillis -- once we hit backoff we'll + // periodically sync at the backoff duration, but as soon as we succeed we'll switch + // into the client-count-dependent interval. + long interval = (otherClientsCount > 0) ? POLL_INTERVAL_MULTI_DEVICE_SEC : POLL_INTERVAL_SINGLE_DEVICE_SEC; + requestPeriodicSync(interval); + } + + @Override + public void onHandleFinal(Action needed) { + switch (needed) { + case NeedsPassword: + case NeedsUpgrade: + case NeedsFinishMigrating: + requestPeriodicSync(POLL_INTERVAL_ERROR_STATE_SEC); + break; + case NeedsVerification: + requestPeriodicSync(POLL_INTERVAL_PENDING_VERIFICATION); + break; + case None: + // No action needed: we'll set the periodic sync interval + // when the sync finishes, via the SessionCallback. + break; + } + } + + @Override + public void onUpgradeRequired() { + // TODO: this shouldn't occur in FxA, but when we upgrade we + // need to reduce the interval again. + requestPeriodicSync(POLL_INTERVAL_ERROR_STATE_SEC); + } + + @Override + public void onUnauthorized() { + // TODO: this shouldn't occur in FxA, but when we fix our credentials + // we need to reduce the interval again. + requestPeriodicSync(POLL_INTERVAL_ERROR_STATE_SEC); + } + + @Override + public void configureBackoffMillisOnBackoff(BackoffHandler backoffHandler, long backoffMillis, boolean onlyExtend) { + if (onlyExtend) { + backoffHandler.extendEarliestNextRequest(delay(backoffMillis)); + } else { + backoffHandler.setEarliestNextRequest(delay(backoffMillis)); + } + + // Yes, we might be part-way through the interval, in which case the backoff + // code will do its job. But we certainly don't want to reduce the interval + // if we're given a small backoff instruction. + // We'll reset the poll interval next time we sync without a backoff instruction. + if (backoffMillis > (POLL_INTERVAL_CURRENT_SEC * 1000)) { + // Slightly inflate the backoff duration to ensure that a fuzzed + // periodic sync doesn't occur before our backoff has passed. Android + // 19+ default to a 4% fuzz factor. + requestPeriodicSync((long) Math.ceil((1.05 * backoffMillis) / 1000)); + } + } + + /** + * Accepts two {@link BackoffHandler} instances as input. These are used + * respectively to track fundamental rate limiting, and to separately + * rate-limit periodic and network-tickled syncs. + */ + @Override + public void configureBackoffMillisBeforeSyncing(BackoffHandler fundamentalRateHandler, BackoffHandler backgroundRateHandler) { + fundamentalRateHandler.setEarliestNextRequest(delay(RATE_LIMIT_FUNDAMENTAL_SEC * 1000)); + backgroundRateHandler.setEarliestNextRequest(delay(RATE_LIMIT_BACKGROUND_SEC * 1000)); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java new file mode 100644 index 000000000..30990cf7f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java @@ -0,0 +1,568 @@ +/* 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.fxa.sync; + +import android.accounts.Account; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SyncResult; +import android.os.Bundle; +import android.os.SystemClock; +import android.text.TextUtils; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.background.fxa.SkewHandler; +import org.mozilla.gecko.browserid.JSONWebTokenUtils; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator; +import org.mozilla.gecko.fxa.authenticator.AccountPickler; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.authenticator.FxADefaultLoginStateMachineDelegate; +import org.mozilla.gecko.fxa.authenticator.FxAccountAuthenticator; +import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine; +import org.mozilla.gecko.fxa.login.Married; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.fxa.login.State.StateLabel; +import org.mozilla.gecko.fxa.sync.FxAccountSyncDelegate.Result; +import org.mozilla.gecko.sync.BackoffHandler; +import org.mozilla.gecko.sync.GlobalSession; +import org.mozilla.gecko.sync.PrefsBackoffHandler; +import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate; +import org.mozilla.gecko.sync.SyncConfiguration; +import org.mozilla.gecko.sync.ThreadPool; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.delegates.GlobalSessionCallback; +import org.mozilla.gecko.sync.delegates.ClientsDataDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider; +import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage; +import org.mozilla.gecko.sync.telemetry.TelemetryContract; +import org.mozilla.gecko.tokenserver.TokenServerClient; +import org.mozilla.gecko.tokenserver.TokenServerClientDelegate; +import org.mozilla.gecko.tokenserver.TokenServerException; +import org.mozilla.gecko.tokenserver.TokenServerToken; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; + +public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter { + private static final String LOG_TAG = FxAccountSyncAdapter.class.getSimpleName(); + + public static final int NOTIFICATION_ID = LOG_TAG.hashCode(); + + // Tracks the last seen storage hostname for backoff purposes. + private static final String PREF_BACKOFF_STORAGE_HOST = "backoffStorageHost"; + + // Used to do cheap in-memory rate limiting. Don't sync again if we + // successfully synced within this duration. + private static final int MINIMUM_SYNC_DELAY_MILLIS = 15 * 1000; // 15 seconds. + private volatile long lastSyncRealtimeMillis; + + protected final ExecutorService executor; + protected final FxAccountNotificationManager notificationManager; + + public FxAccountSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + this.executor = Executors.newSingleThreadExecutor(); + this.notificationManager = new FxAccountNotificationManager(NOTIFICATION_ID); + } + + protected static class SyncDelegate extends FxAccountSyncDelegate { + @Override + public void handleSuccess() { + Logger.info(LOG_TAG, "Sync succeeded."); + super.handleSuccess(); + TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_COMPLETED, 1); + } + + @Override + public void handleError(Exception e) { + Logger.error(LOG_TAG, "Got exception syncing.", e); + super.handleError(e); + TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_FAILED, 1); + } + + @Override + public void handleCannotSync(State finalState) { + Logger.warn(LOG_TAG, "Cannot sync from state: " + finalState.getStateLabel()); + super.handleCannotSync(finalState); + } + + @Override + public void postponeSync(long millis) { + if (millis <= 0) { + Logger.debug(LOG_TAG, "Asked to postpone sync, but zero delay."); + } + super.postponeSync(millis); + } + + @Override + public void rejectSync() { + super.rejectSync(); + } + + protected final Collection<String> stageNamesToSync; + + public SyncDelegate(BlockingQueue<Result> latch, SyncResult syncResult, AndroidFxAccount fxAccount, Collection<String> stageNamesToSync) { + super(latch, syncResult); + this.stageNamesToSync = Collections.unmodifiableCollection(stageNamesToSync); + } + + public Collection<String> getStageNamesToSync() { + return this.stageNamesToSync; + } + } + + protected static class SessionCallback implements GlobalSessionCallback { + protected final SyncDelegate syncDelegate; + protected final SchedulePolicy schedulePolicy; + protected volatile BackoffHandler storageBackoffHandler; + + public SessionCallback(SyncDelegate syncDelegate, SchedulePolicy schedulePolicy) { + this.syncDelegate = syncDelegate; + this.schedulePolicy = schedulePolicy; + } + + public void setBackoffHandler(BackoffHandler backoffHandler) { + this.storageBackoffHandler = backoffHandler; + } + + @Override + public boolean shouldBackOffStorage() { + return storageBackoffHandler.delayMilliseconds() > 0; + } + + @Override + public void requestBackoff(long backoffMillis) { + final boolean onlyExtend = true; // Because we trust what the storage server says. + schedulePolicy.configureBackoffMillisOnBackoff(storageBackoffHandler, backoffMillis, onlyExtend); + } + + @Override + public void informUpgradeRequiredResponse(GlobalSession session) { + schedulePolicy.onUpgradeRequired(); + } + + @Override + public void informUnauthorizedResponse(GlobalSession globalSession, URI oldClusterURL) { + schedulePolicy.onUnauthorized(); + } + + @Override + public void informMigrated(GlobalSession globalSession) { + // It's not possible to migrate a Firefox Account to another Account type + // yet. Yell loudly but otherwise ignore. + Logger.error(LOG_TAG, + "Firefox Account informMigrated called, but it's not yet possible to migrate. " + + "Ignoring even though something is terribly wrong."); + } + + @Override + public void handleStageCompleted(Stage currentState, GlobalSession globalSession) { + } + + @Override + public void handleSuccess(GlobalSession globalSession) { + Logger.info(LOG_TAG, "Global session succeeded."); + + // Get the number of clients, so we can schedule the sync interval accordingly. + try { + int otherClientsCount = globalSession.getClientsDelegate().getClientsCount(); + Logger.debug(LOG_TAG, "" + otherClientsCount + " other client(s)."); + this.schedulePolicy.onSuccessfulSync(otherClientsCount); + } finally { + // Continue with the usual success flow. + syncDelegate.handleSuccess(); + } + } + + @Override + public void handleError(GlobalSession globalSession, Exception e) { + Logger.warn(LOG_TAG, "Global session failed."); // Exception will be dumped by delegate below. + syncDelegate.handleError(e); + // TODO: should we reduce the periodic sync interval? + } + + @Override + public void handleAborted(GlobalSession globalSession, String reason) { + Logger.warn(LOG_TAG, "Global session aborted: " + reason); + syncDelegate.handleError(null); + // TODO: should we reduce the periodic sync interval? + } + }; + + /** + * Return true if the provided {@link BackoffHandler} isn't reporting that we're in + * a backoff state, or the provided {@link Bundle} contains flags that indicate + * we should force a sync. + */ + private boolean shouldPerformSync(final BackoffHandler backoffHandler, final String kind, final Bundle extras) { + final long delay = backoffHandler.delayMilliseconds(); + if (delay <= 0) { + return true; + } + + if (extras == null) { + return false; + } + + final boolean forced = extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false); + if (forced) { + Logger.info(LOG_TAG, "Forced sync (" + kind + "): overruling remaining backoff of " + delay + "ms."); + } else { + Logger.info(LOG_TAG, "Not syncing (" + kind + "): must wait another " + delay + "ms."); + } + return forced; + } + + protected void syncWithAssertion(final String audience, + final String assertion, + final URI tokenServerEndpointURI, + final BackoffHandler tokenBackoffHandler, + final SharedPreferences sharedPrefs, + final KeyBundle syncKeyBundle, + final String clientState, + final SessionCallback callback, + final Bundle extras, + final AndroidFxAccount fxAccount) { + final TokenServerClientDelegate delegate = new TokenServerClientDelegate() { + private boolean didReceiveBackoff = false; + + @Override + public String getUserAgent() { + return FxAccountConstants.USER_AGENT; + } + + @Override + public void handleSuccess(final TokenServerToken token) { + FxAccountUtils.pii(LOG_TAG, "Got token! uid is " + token.uid + " and endpoint is " + token.endpoint + "."); + fxAccount.releaseSharedAccountStateLock(); + + if (!didReceiveBackoff) { + // We must be OK to touch this token server. + tokenBackoffHandler.setEarliestNextRequest(0L); + } + + final URI storageServerURI; + try { + storageServerURI = new URI(token.endpoint); + } catch (URISyntaxException e) { + handleError(e); + return; + } + final String storageHostname = storageServerURI.getHost(); + + // We back off on a per-host basis. When we have an endpoint URI from a token, we + // can check on the backoff status for that host. + // If we're supposed to be backing off, we abort the not-yet-started session. + final BackoffHandler storageBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "sync.storage"); + callback.setBackoffHandler(storageBackoffHandler); + + String lastStorageHost = sharedPrefs.getString(PREF_BACKOFF_STORAGE_HOST, null); + final boolean storageHostIsUnchanged = lastStorageHost != null && + lastStorageHost.equalsIgnoreCase(storageHostname); + if (storageHostIsUnchanged) { + Logger.debug(LOG_TAG, "Storage host is unchanged."); + if (!shouldPerformSync(storageBackoffHandler, "storage", extras)) { + Logger.info(LOG_TAG, "Not syncing: storage server requested backoff."); + callback.handleAborted(null, "Storage backoff"); + return; + } + } else { + Logger.debug(LOG_TAG, "Received new storage host."); + } + + // Invalidate the previous backoff, because our storage host has changed, + // or we never had one at all, or we're OK to sync. + storageBackoffHandler.setEarliestNextRequest(0L); + + GlobalSession globalSession = null; + try { + final ClientsDataDelegate clientsDataDelegate = new SharedPreferencesClientsDataDelegate(sharedPrefs, getContext()); + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + FxAccountUtils.pii(LOG_TAG, "Client device name is: '" + clientsDataDelegate.getClientName() + "'."); + FxAccountUtils.pii(LOG_TAG, "Client device data last modified: " + clientsDataDelegate.getLastModifiedTimestamp()); + } + + // We compute skew over time using SkewHandler. This yields an unchanging + // skew adjustment that the HawkAuthHeaderProvider uses to adjust its + // timestamps. Eventually we might want this to adapt within the scope of a + // global session. + final SkewHandler storageServerSkewHandler = SkewHandler.getSkewHandlerForHostname(storageHostname); + final long storageServerSkew = storageServerSkewHandler.getSkewInSeconds(); + // We expect Sync to upload large sets of records. Calculating the + // payload verification hash for these record sets could be expensive, + // so we explicitly do not send payload verification hashes to the + // Sync storage endpoint. + final boolean includePayloadVerificationHash = false; + final AuthHeaderProvider authHeaderProvider = new HawkAuthHeaderProvider(token.id, token.key.getBytes("UTF-8"), includePayloadVerificationHash, storageServerSkew); + + final Context context = getContext(); + final SyncConfiguration syncConfig = new SyncConfiguration(token.uid, authHeaderProvider, sharedPrefs, syncKeyBundle); + + Collection<String> knownStageNames = SyncConfiguration.validEngineNames(); + syncConfig.stagesToSync = Utils.getStagesToSyncFromBundle(knownStageNames, extras); + syncConfig.setClusterURL(storageServerURI); + + globalSession = new GlobalSession(syncConfig, callback, context, clientsDataDelegate); + globalSession.start(); + } catch (Exception e) { + callback.handleError(globalSession, e); + return; + } + } + + @Override + public void handleFailure(TokenServerException e) { + Logger.error(LOG_TAG, "Failed to get token.", e); + try { + // We should only get here *after* we're locked into the married state. + State state = fxAccount.getState(); + if (state.getStateLabel() == StateLabel.Married) { + Married married = (Married) state; + fxAccount.setState(married.makeCohabitingState()); + } + } finally { + fxAccount.releaseSharedAccountStateLock(); + } + callback.handleError(null, e); + } + + @Override + public void handleError(Exception e) { + Logger.error(LOG_TAG, "Failed to get token.", e); + fxAccount.releaseSharedAccountStateLock(); + callback.handleError(null, e); + } + + @Override + public void handleBackoff(int backoffSeconds) { + // This is the token server telling us to back off. + Logger.info(LOG_TAG, "Token server requesting backoff of " + backoffSeconds + "s. Backoff handler: " + tokenBackoffHandler); + didReceiveBackoff = true; + + // If we've already stored a backoff, overrule it: we only use the server + // value for token server scheduling. + tokenBackoffHandler.setEarliestNextRequest(delay(backoffSeconds * 1000)); + } + + private long delay(long delay) { + return System.currentTimeMillis() + delay; + } + }; + + TokenServerClient tokenServerclient = new TokenServerClient(tokenServerEndpointURI, executor); + tokenServerclient.getTokenFromBrowserIDAssertion(assertion, true, clientState, delegate); + } + + /** + * A trivial Sync implementation that does not cache client keys, + * certificates, or tokens. + * + * This should be replaced with a full {@link FxAccountAuthenticator}-based + * token implementation. + */ + @Override + public void onPerformSync(final Account account, final Bundle extras, final String authority, ContentProviderClient provider, final SyncResult syncResult) { + Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG); + Logger.resetLogging(); + + final Context context = getContext(); + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + + Logger.info(LOG_TAG, "Syncing FxAccount" + + " account named like " + Utils.obfuscateEmail(account.name) + + " for authority " + authority + + " with instance " + this + "."); + + Logger.info(LOG_TAG, "Account last synced at: " + fxAccount.getLastSyncedTimestamp()); + + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { + fxAccount.dump(); + } + + FirefoxAccounts.logSyncOptions(extras); + + if (this.lastSyncRealtimeMillis > 0L && + (this.lastSyncRealtimeMillis + MINIMUM_SYNC_DELAY_MILLIS) > SystemClock.elapsedRealtime() && + !extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false)) { + Logger.info(LOG_TAG, "Not syncing FxAccount " + Utils.obfuscateEmail(account.name) + + ": minimum interval not met."); + TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_FAILED_BACKOFF, 1); + return; + } + + // Pickle in a background thread to avoid strict mode warnings. + ThreadPool.run(new Runnable() { + @Override + public void run() { + try { + AccountPickler.pickle(fxAccount, FxAccountConstants.ACCOUNT_PICKLE_FILENAME); + } catch (Exception e) { + // Should never happen, but we really don't want to die in a background thread. + Logger.warn(LOG_TAG, "Got exception pickling current account details; ignoring.", e); + } + } + }); + + final BlockingQueue<Result> latch = new LinkedBlockingQueue<>(1); + + Collection<String> knownStageNames = SyncConfiguration.validEngineNames(); + Collection<String> stageNamesToSync = Utils.getStagesToSyncFromBundle(knownStageNames, extras); + + final SyncDelegate syncDelegate = new SyncDelegate(latch, syncResult, fxAccount, stageNamesToSync); + + try { + // This will be the same chunk of SharedPreferences that we pass through to GlobalSession/SyncConfiguration. + final SharedPreferences sharedPrefs = fxAccount.getSyncPrefs(); + + final BackoffHandler backgroundBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "background"); + final BackoffHandler rateLimitBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "rate"); + + // If this sync was triggered by user action, this will be true. + final boolean isImmediate = (extras != null) && + (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false) || + extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false)); + + // If it's not an immediate sync, it must be either periodic or tickled. + // Check our background rate limiter. + if (!isImmediate) { + if (!shouldPerformSync(backgroundBackoffHandler, "background", extras)) { + syncDelegate.rejectSync(); + return; + } + } + + // Regardless, let's make sure we're not syncing too often. + if (!shouldPerformSync(rateLimitBackoffHandler, "rate", extras)) { + syncDelegate.postponeSync(rateLimitBackoffHandler.delayMilliseconds()); + return; + } + + final SchedulePolicy schedulePolicy = new FxAccountSchedulePolicy(context, fxAccount); + + // Set a small scheduled 'backoff' to rate-limit the next sync, + // and extend the background delay even further into the future. + schedulePolicy.configureBackoffMillisBeforeSyncing(rateLimitBackoffHandler, backgroundBackoffHandler); + + final String tokenServerEndpoint = fxAccount.getTokenServerURI(); + final URI tokenServerEndpointURI = new URI(tokenServerEndpoint); + final String audience = FxAccountUtils.getAudienceForURL(tokenServerEndpoint); + + try { + // The clock starts... now! + fxAccount.acquireSharedAccountStateLock(FxAccountSyncAdapter.LOG_TAG); + } catch (InterruptedException e) { + // OK, skip this sync. + syncDelegate.handleError(e); + return; + } + + final State state; + try { + state = fxAccount.getState(); + } catch (Exception e) { + fxAccount.releaseSharedAccountStateLock(); + syncDelegate.handleError(e); + return; + } + + TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_STARTED, 1); + + final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine(); + stateMachine.advance(state, StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) { + @Override + public void handleNotMarried(State notMarried) { + Logger.info(LOG_TAG, "handleNotMarried: in " + notMarried.getStateLabel()); + schedulePolicy.onHandleFinal(notMarried.getNeededAction()); + syncDelegate.handleCannotSync(notMarried); + } + + private boolean shouldRequestToken(final BackoffHandler tokenBackoffHandler, final Bundle extras) { + return shouldPerformSync(tokenBackoffHandler, "token", extras); + } + + @Override + public void handleMarried(Married married) { + schedulePolicy.onHandleFinal(married.getNeededAction()); + Logger.info(LOG_TAG, "handleMarried: in " + married.getStateLabel()); + + try { + final String assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER); + + /* + * At this point we're in the correct state to sync, and we're ready to fetch + * a token and do some work. + * + * But first we need to do two things: + * 1. Check to see whether we're in a backoff situation for the token server. + * If we are, but we're not forcing a sync, then we go no further. + * 2. Clear an existing backoff (if we're syncing it doesn't matter, and if + * we're forcing we'll get a new backoff if things are still bad). + * + * Note that we don't check the storage backoff before the token dance: the token + * server tells us which server we're syncing to! + * + * That logic lives in the TokenServerClientDelegate elsewhere in this file. + */ + + // Strictly speaking this backoff check could be done prior to walking through + // the login state machine, allowing us to short-circuit sooner. + // We don't expect many token server backoffs, and most users will be sitting + // in the Married state, so instead we simply do this here, once. + final BackoffHandler tokenBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "token"); + if (!shouldRequestToken(tokenBackoffHandler, extras)) { + Logger.info(LOG_TAG, "Not syncing (token server)."); + syncDelegate.postponeSync(tokenBackoffHandler.delayMilliseconds()); + return; + } + + final SessionCallback sessionCallback = new SessionCallback(syncDelegate, schedulePolicy); + final KeyBundle syncKeyBundle = married.getSyncKeyBundle(); + final String clientState = married.getClientState(); + syncWithAssertion(audience, assertion, tokenServerEndpointURI, tokenBackoffHandler, sharedPrefs, syncKeyBundle, clientState, sessionCallback, extras, fxAccount); + + // Register the device if necessary (asynchronous, in another thread) + if (fxAccount.getDeviceRegistrationVersion() != FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION + || TextUtils.isEmpty(fxAccount.getDeviceId())) { + FxAccountDeviceRegistrator.register(context); + } + + // Force fetch the profile avatar information. (asynchronous, in another thread) + Logger.info(LOG_TAG, "Fetching profile avatar information."); + fxAccount.fetchProfileJSON(); + } catch (Exception e) { + syncDelegate.handleError(e); + return; + } + } + }); + + latch.take(); + } catch (Exception e) { + Logger.error(LOG_TAG, "Got error syncing.", e); + syncDelegate.handleError(e); + } finally { + fxAccount.releaseSharedAccountStateLock(); + } + + Logger.info(LOG_TAG, "Syncing done."); + lastSyncRealtimeMillis = SystemClock.elapsedRealtime(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java new file mode 100644 index 000000000..71148f66c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java @@ -0,0 +1,110 @@ +/* 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.fxa.sync; + +import java.util.concurrent.BlockingQueue; + +import org.mozilla.gecko.fxa.login.State; + +import android.content.SyncResult; + +public class FxAccountSyncDelegate { + public enum Result { + Success, + Error, + Postponed, + Rejected, + } + + protected final BlockingQueue<Result> latch; + protected final SyncResult syncResult; + + public FxAccountSyncDelegate(BlockingQueue<Result> latch, SyncResult syncResult) { + if (latch == null) { + throw new IllegalArgumentException("latch must not be null"); + } + if (syncResult == null) { + throw new IllegalArgumentException("syncResult must not be null"); + } + this.latch = latch; + this.syncResult = syncResult; + } + + /** + * No error! Say that we made progress. + */ + protected void setSyncResultSuccess() { + syncResult.stats.numUpdates += 1; + } + + /** + * Soft error. Say that we made progress, so that Android will sync us again + * after exponential backoff. + */ + protected void setSyncResultSoftError() { + syncResult.stats.numUpdates += 1; + syncResult.stats.numIoExceptions += 1; + } + + /** + * Hard error. We don't want Android to sync us again, even if we make + * progress, until the user intervenes. + */ + protected void setSyncResultHardError() { + syncResult.stats.numAuthExceptions += 1; + } + + public void handleSuccess() { + setSyncResultSuccess(); + latch.offer(Result.Success); + } + + public void handleError(Exception e) { + setSyncResultSoftError(); + latch.offer(Result.Error); + } + + /** + * When the login machine terminates, we might not be in the + * <code>Married</code> state, and therefore we can't sync. This method + * messages as much to the user. + * <p> + * To avoid stopping us syncing altogether, we set a soft error rather than + * a hard error. In future, we would like to set a hard error if we are in, + * for example, the <code>Separated</code> state, and then have some user + * initiated activity mark the Android account as ready to sync again. This + * is tricky, though, so we play it safe for now. + * + * @param finalState + * that login machine ended in. + */ + public void handleCannotSync(State finalState) { + setSyncResultSoftError(); + latch.offer(Result.Error); + } + + public void postponeSync(long millis) { + if (millis > 0) { + // delayUntil is broken: https://code.google.com/p/android/issues/detail?id=65669 + // So we don't bother doing this. Instead, we rely on the periodic sync + // we schedule, and the backoff handler for the rest. + /* + Logger.warn(LOG_TAG, "Postponing sync by " + millis + "ms."); + syncResult.delayUntil = millis / 1000; + */ + } + setSyncResultSoftError(); + latch.offer(Result.Postponed); + } + + /** + * Simply don't sync, without setting any error flags. + * This is the appropriate behavior when a routine backoff has not yet + * been met. + */ + public void rejectSync() { + latch.offer(Result.Rejected); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java new file mode 100644 index 000000000..59c06ca97 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java @@ -0,0 +1,28 @@ +/* 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.fxa.sync; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +public class FxAccountSyncService extends Service { + private static final Object syncAdapterLock = new Object(); + private static FxAccountSyncAdapter syncAdapter; + + @Override + public void onCreate() { + synchronized (syncAdapterLock) { + if (syncAdapter == null) { + syncAdapter = new FxAccountSyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java new file mode 100644 index 000000000..ca64d4f87 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java @@ -0,0 +1,113 @@ +/* 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.fxa.sync; + +import java.util.Map; +import java.util.Map.Entry; +import java.util.WeakHashMap; + +import org.mozilla.gecko.fxa.SyncStatusListener; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.ContentResolver; +import android.content.SyncStatusObserver; + +/** + * Abstract away some details of Android's SyncStatusObserver. + * <p> + * Provides a simplified sync started/sync finished delegate. + */ +public class FxAccountSyncStatusHelper implements SyncStatusObserver { + @SuppressWarnings("unused") + private static final String LOG_TAG = FxAccountSyncStatusHelper.class.getSimpleName(); + + protected static FxAccountSyncStatusHelper sInstance; + + public synchronized static FxAccountSyncStatusHelper getInstance() { + if (sInstance == null) { + sInstance = new FxAccountSyncStatusHelper(); + } + return sInstance; + } + + // Used to unregister this as a listener. + protected Object handle; + + // Maps delegates to whether their underlying Android account was syncing the + // last time we observed a status change. + protected Map<SyncStatusListener, Boolean> delegates = new WeakHashMap<SyncStatusListener, Boolean>(); + + @Override + public synchronized void onStatusChanged(int which) { + for (Entry<SyncStatusListener, Boolean> entry : delegates.entrySet()) { + final SyncStatusListener delegate = entry.getKey(); + final AndroidFxAccount fxAccount = new AndroidFxAccount(delegate.getContext(), delegate.getAccount()); + final boolean active = fxAccount.isCurrentlySyncing(); + // Remember for later. + boolean wasActiveLastTime = entry.getValue(); + // It's okay to update the value of an entry while iterating the entrySet. + entry.setValue(active); + + if (active && !wasActiveLastTime) { + // We've started a sync. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + delegate.onSyncStarted(); + } + }); + } + + if (!active && wasActiveLastTime) { + // We've finished a sync. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + delegate.onSyncFinished(); + } + }); + } + } + } + + protected void addListener() { + final int mask = ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE; + if (this.handle != null) { + throw new IllegalStateException("Already registered this as an observer?"); + } + this.handle = ContentResolver.addStatusChangeListener(mask, this); + } + + protected void removeListener() { + Object handle = this.handle; + this.handle = null; + if (handle != null) { + ContentResolver.removeStatusChangeListener(handle); + } + } + + public synchronized void startObserving(SyncStatusListener delegate) { + if (delegate == null) { + throw new IllegalArgumentException("delegate must not be null"); + } + if (delegates.containsKey(delegate)) { + return; + } + // If we are the first delegate to the party, start listening. + if (delegates.isEmpty()) { + addListener(); + } + delegates.put(delegate, Boolean.FALSE); + } + + public synchronized void stopObserving(SyncStatusListener delegate) { + delegates.remove(delegate); + // If we are the last delegate leaving the party, stop listening. + if (delegates.isEmpty()) { + removeListener(); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java new file mode 100644 index 000000000..809191f5e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java @@ -0,0 +1,43 @@ +/* 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.fxa.sync; + +import org.mozilla.gecko.fxa.login.State.Action; +import org.mozilla.gecko.sync.BackoffHandler; + +public interface SchedulePolicy { + /** + * Call this with the number of other clients syncing to the account. + */ + public abstract void onSuccessfulSync(int otherClientsCount); + public abstract void onHandleFinal(Action needed); + public abstract void onUpgradeRequired(); + public abstract void onUnauthorized(); + + /** + * Before a sync we typically wish to adjust our backoff policy. This cleans + * the slate prior to encountering a new backoff, and also functions as a rate + * limiter. + * + * The {@link SchedulePolicy} acts as a controller for the {@link BackoffHandler}. + * As a result of calling these two methods, the {@link BackoffHandler} will be + * mutated, and additional side-effects (such as scheduling periodic syncs) can + * occur. + * + * @param rateHandler the backoff handler to configure for basic rate limiting. + * @param backgroundHandler the backoff handler to configure for background operations. + */ + public abstract void configureBackoffMillisBeforeSyncing(BackoffHandler rateHandler, BackoffHandler backgroundHandler); + + /** + * We received an explicit backoff instruction, typically from a server. + * + * @param onlyExtend + * if <code>true</code>, the backoff handler will be asked to update + * its backoff only if the provided value is greater than the current + * backoff. + */ + public abstract void configureBackoffMillisOnBackoff(BackoffHandler backoffHandler, long backoffMillis, boolean onlyExtend); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java new file mode 100644 index 000000000..3bbb7e8b4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java @@ -0,0 +1,19 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.push; + +/** + * Thin container for a register User-Agent response. + */ +public class RegisterUserAgentResponse { + public final String uaid; + public final String secret; + + public RegisterUserAgentResponse(String uaid, String secret) { + this.uaid = uaid; + this.secret = secret; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java new file mode 100644 index 000000000..009a7f838 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java @@ -0,0 +1,19 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.push; + +/** + * Thin container for a subscribe channel response. + */ +public class SubscribeChannelResponse { + public final String channelID; + public final String endpoint; + + public SubscribeChannelResponse(String channelID, String endpoint) { + this.channelID = channelID; + this.endpoint = endpoint; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java new file mode 100644 index 000000000..8edd92f9e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java @@ -0,0 +1,410 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * 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.push.autopush; + +import android.text.TextUtils; + +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.push.RegisterUserAgentResponse; +import org.mozilla.gecko.push.SubscribeChannelResponse; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider; +import org.mozilla.gecko.sync.net.Resource; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.Locale; +import java.util.concurrent.Executor; + +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + + +/** + * Interact with the autopush endpoint HTTP API. + * <p/> + * The API is a Mozilla-proprietary interface, and not even specified to Mozilla's usual ad-hoc standards. + * This client is written against a work-in-progress, un-deployed upstream commit. + */ +public class AutopushClient { + protected static final String LOG_TAG = AutopushClient.class.getSimpleName(); + + protected static final String ACCEPT_HEADER = "application/json;charset=utf-8"; + protected static final String TYPE = "gcm"; + + protected static final String JSON_KEY_UAID = "uaid"; + protected static final String JSON_KEY_SECRET = "secret"; + protected static final String JSON_KEY_CHANNEL_ID = "channelID"; + protected static final String JSON_KEY_ENDPOINT = "endpoint"; + + protected static final String[] REGISTER_USER_AGENT_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_UAID, JSON_KEY_SECRET, JSON_KEY_CHANNEL_ID, JSON_KEY_ENDPOINT }; + protected static final String[] REGISTER_CHANNEL_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_CHANNEL_ID, JSON_KEY_ENDPOINT }; + + public static final String JSON_KEY_CODE = "code"; + public static final String JSON_KEY_ERRNO = "errno"; + public static final String JSON_KEY_ERROR = "error"; + public static final String JSON_KEY_MESSAGE = "message"; + + protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE }; + protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO }; + + /** + * The server's URI. + * <p> + * We assume throughout that this ends with a trailing slash (and guarantee as + * much in the constructor). + */ + public final String serverURI; + + protected final Executor executor; + + public AutopushClient(String serverURI, Executor executor) { + if (serverURI == null) { + throw new IllegalArgumentException("Must provide a server URI."); + } + if (executor == null) { + throw new IllegalArgumentException("Must provide a non-null executor."); + } + this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/"; + if (!this.serverURI.endsWith("/")) { + throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI); + } + this.executor = executor; + } + + /** + * A legal autopush server URL includes a sender ID embedded into it. Extract it. + * + * @return a non-null non-empty sender ID. + * @throws AutopushClientException on failure. + */ + public String getSenderIDFromServerURI() throws AutopushClientException { + // Turn "https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407/" into "829133274407". + final String[] parts = serverURI.split("/", -1); // The -1 keeps the trailing empty part. + if (parts.length < 3) { + throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI); + } + if (!TextUtils.isEmpty(parts[parts.length - 1])) { + // We guarantee a trailing slash, so we should always have an empty part at the tail. + throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI); + } + if (!TextUtils.equals("gcm", parts[parts.length - 3])) { + // We should always have /gcm/senderID/. + throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI); + } + final String senderID = parts[parts.length - 2]; + if (TextUtils.isEmpty(senderID)) { + // Something is horribly wrong -- we have /gcm//. Abort. + throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI); + } + return senderID; + } + + /** + * Process a typed value extracted from a successful response (in an + * endpoint-dependent way). + */ + public interface RequestDelegate<T> { + void handleError(Exception e); + void handleFailure(AutopushClientException e); + void handleSuccess(T result); + } + + /** + * Intepret a response from the autopush server. + * <p> + * Throw an appropriate exception on errors; otherwise, return the response's + * status code. + * + * @return response's HTTP status code. + * @throws AutopushClientException + */ + public static int validateResponse(HttpResponse response) throws AutopushClientException { + final int status = response.getStatusLine().getStatusCode(); + if (200 <= status && status <= 299) { + return status; + } + long code; + long errno; + String error; + String message; + String info; + ExtendedJSONObject body; + try { + body = new SyncStorageResponse(response).jsonObjectBody(); + // TODO: The service doesn't do the right thing yet :( + // body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class); + body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class); + // Would throw above if missing; the -1 defaults quiet NPE warnings. + code = body.getLong(JSON_KEY_CODE, -1); + errno = body.getLong(JSON_KEY_ERRNO, -1); + error = body.getString(JSON_KEY_ERROR); + message = body.getString(JSON_KEY_MESSAGE); + } catch (Exception e) { + throw new AutopushClientException.AutopushClientMalformedResponseException(response); + } + throw new AutopushClientException.AutopushClientRemoteException(response, code, errno, error, message, body); + } + + protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleError(e); + } + }); + } + + protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody, final RequestDelegate<T> delegate) { + try { + if (requestBody == null) { + resource.post((HttpEntity) null); + } else { + resource.post(requestBody); + } + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + } + + /** + * Translate resource callbacks into request callbacks invoked on the provided + * executor. + * <p> + * Override <code>handleSuccess</code> to parse the body of the resource + * request and call the request callback. <code>handleSuccess</code> is + * invoked via the executor, so you don't need to delegate further. + */ + protected abstract class ResourceDelegate<T> extends BaseResourceDelegate { + protected abstract void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body); + + protected final String secret; + protected final RequestDelegate<T> delegate; + + /** + * Create a delegate for an un-authenticated resource. + */ + public ResourceDelegate(final Resource resource, final String secret, final RequestDelegate<T> delegate) { + super(resource); + this.delegate = delegate; + this.secret = secret; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + if (secret != null) { + return new BearerAuthHeaderProvider(secret); + } + return null; + } + + @Override + public String getUserAgent() { + return FxAccountConstants.USER_AGENT; + } + + @Override + public void handleHttpResponse(HttpResponse response) { + try { + final int status = validateResponse(response); + invokeHandleSuccess(status, response); + } catch (AutopushClientException e) { + invokeHandleFailure(e); + } + } + + protected void invokeHandleFailure(final AutopushClientException e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleFailure(e); + } + }); + } + + protected void invokeHandleSuccess(final int status, final HttpResponse response) { + executor.execute(new Runnable() { + @Override + public void run() { + try { + ExtendedJSONObject body = new SyncResponse(response).jsonObjectBody(); + ResourceDelegate.this.handleSuccess(status, response, body); + } catch (Exception e) { + delegate.handleError(e); + } + } + }); + } + + @Override + public void handleHttpProtocolException(final ClientProtocolException e) { + invokeHandleError(delegate, e); + } + + @Override + public void handleHttpIOException(IOException e) { + invokeHandleError(delegate, e); + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + invokeHandleError(delegate, e); + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + super.addHeaders(request, client); + + // The basics. + final Locale locale = Locale.getDefault(); + request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale)); + request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER); + } + } + + public void registerUserAgent(final String token, RequestDelegate<RegisterUserAgentResponse> delegate) { + BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "registration")); + } catch (URISyntaxException e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<RegisterUserAgentResponse>(resource, null, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + body.throwIfFieldsMissingOrMisTyped(REGISTER_USER_AGENT_RESPONSE_REQUIRED_STRING_FIELDS, String.class); + final String uaid = body.getString(JSON_KEY_UAID); + final String secret = body.getString(JSON_KEY_SECRET); + delegate.handleSuccess(new RegisterUserAgentResponse(uaid, secret)); + return; + } catch (Exception e) { + delegate.handleError(e); + return; + } + } + }; + + final ExtendedJSONObject body = new ExtendedJSONObject(); + body.put("type", TYPE); + body.put("token", token); + + resource.post(body); + } + + public void reregisterUserAgent(final String uaid, final String secret, final String token, RequestDelegate<Void> delegate) { + final BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "registration/" + uaid)); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + delegate.handleSuccess(null); + return; + } catch (Exception e) { + delegate.handleError(e); + return; + } + } + }; + + final ExtendedJSONObject body = new ExtendedJSONObject(); + body.put("type", TYPE); + body.put("token", token); + + resource.put(body); + } + + + public void subscribeChannel(final String uaid, final String secret, final String appServerKey, RequestDelegate<SubscribeChannelResponse> delegate) { + final BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription")); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<SubscribeChannelResponse>(resource, secret, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + try { + body.throwIfFieldsMissingOrMisTyped(REGISTER_CHANNEL_RESPONSE_REQUIRED_STRING_FIELDS, String.class); + final String channelID = body.getString(JSON_KEY_CHANNEL_ID); + final String endpoint = body.getString(JSON_KEY_ENDPOINT); + delegate.handleSuccess(new SubscribeChannelResponse(channelID, endpoint)); + return; + } catch (Exception e) { + delegate.handleError(e); + return; + } + } + }; + + final ExtendedJSONObject body = new ExtendedJSONObject(); + body.put("key", appServerKey); + resource.post(body); + } + + public void unsubscribeChannel(final String uaid, final String secret, final String channelID, RequestDelegate<Void> delegate) { + final BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription/" + channelID)); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + delegate.handleSuccess(null); + } + }; + + resource.delete(); + } + + public void unregisterUserAgent(final String uaid, final String secret, RequestDelegate<Void> delegate) { + final BaseResource resource; + try { + resource = new BaseResource(new URI(serverURI + "registration/" + uaid)); + } catch (Exception e) { + invokeHandleError(delegate, e); + return; + } + + resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) { + @Override + public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { + delegate.handleSuccess(null); + } + }; + + resource.delete(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java new file mode 100644 index 000000000..e3fda7a45 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java @@ -0,0 +1,81 @@ +/* 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.push.autopush; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.HttpStatus; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +public class AutopushClientException extends Exception { + private static final long serialVersionUID = 7953459541558266500L; + + public AutopushClientException(String detailMessage) { + super(detailMessage); + } + + public AutopushClientException(Exception e) { + super(e); + } + + public boolean isTransientError() { + return false; + } + + public static class AutopushClientRemoteException extends AutopushClientException { + private static final long serialVersionUID = 2209313149952001000L; + + public final HttpResponse response; + public final long httpStatusCode; + public final long apiErrorNumber; + public final String error; + public final String message; + public final ExtendedJSONObject body; + + public AutopushClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, ExtendedJSONObject body) { + super(new HTTPFailureException(new SyncStorageResponse(response))); + if (body == null) { + throw new IllegalArgumentException("body must not be null"); + } + this.response = response; + this.httpStatusCode = httpStatusCode; + this.apiErrorNumber = apiErrorNumber; + this.error = error; + this.message = message; + this.body = body; + } + + @Override + public String toString() { + return "<AutopushClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">"; + } + + public boolean isInvalidAuthentication() { + return httpStatusCode == HttpStatus.SC_UNAUTHORIZED; + } + + public boolean isNotFound() { + return httpStatusCode == HttpStatus.SC_NOT_FOUND; + } + + public boolean isGone() { + return httpStatusCode == HttpStatus.SC_GONE; + } + + @Override + public boolean isTransientError() { + return httpStatusCode >= 500; + } + } + + public static class AutopushClientMalformedResponseException extends AutopushClientRemoteException { + private static final long serialVersionUID = 2209313149952001909L; + + public AutopushClientMalformedResponseException(HttpResponse response) { + super(response, 0, 999, "Response malformed", "Response malformed", new ExtendedJSONObject()); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java new file mode 100644 index 000000000..75eb5ad37 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java @@ -0,0 +1,22 @@ +/* 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 org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage; + +import android.content.SyncResult; + +public class AlreadySyncingException extends SyncException { + Stage inState; + public AlreadySyncingException(Stage currentState) { + inState = currentState; + } + + private static final long serialVersionUID = -5647548462539009893L; + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java new file mode 100644 index 000000000..abb880621 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java @@ -0,0 +1,34 @@ +/* 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; + + +public interface BackoffHandler { + public long getEarliestNextRequest(); + + /** + * Provide a timestamp in millis before which we shouldn't sync again. + * Overrides any existing value. + * + * @param next + * a timestamp in milliseconds. + */ + public void setEarliestNextRequest(long next); + + /** + * Provide a timestamp in millis before which we shouldn't sync again. Only + * change our persisted value if it's later than the existing time. + * + * @param next + * a timestamp in milliseconds. + */ + public void extendEarliestNextRequest(long next); + + /** + * Return the number of milliseconds until we're allowed to sync again, + * or 0 if now is fine. + */ + public long delayMilliseconds(); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java new file mode 100644 index 000000000..3db93652d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java @@ -0,0 +1,5 @@ +/* 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; diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java new file mode 100644 index 000000000..1fd363bcb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java @@ -0,0 +1,199 @@ +/* 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.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map.Entry; +import java.util.Set; + +import org.json.simple.JSONArray; +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.sync.crypto.CryptoException; +import org.mozilla.gecko.sync.crypto.KeyBundle; + +public class CollectionKeys { + private KeyBundle defaultKeyBundle = null; + private final HashMap<String, KeyBundle> collectionKeyBundles = new HashMap<String, KeyBundle>(); + + /** + * Randomly generate a basic CollectionKeys object. + * @throws CryptoException + */ + public static CollectionKeys generateCollectionKeys() throws CryptoException { + CollectionKeys ck = new CollectionKeys(); + ck.clear(); + ck.defaultKeyBundle = KeyBundle.withRandomKeys(); + // TODO: eventually we would like to keep per-collection keys, just generate + // new ones as appropriate. + return ck; + } + + public KeyBundle defaultKeyBundle() throws NoCollectionKeysSetException { + if (this.defaultKeyBundle == null) { + throw new NoCollectionKeysSetException(); + } + return this.defaultKeyBundle; + } + + public boolean keyBundleForCollectionIsNotDefault(String collection) { + return collectionKeyBundles.containsKey(collection); + } + + public KeyBundle keyBundleForCollection(String collection) + throws NoCollectionKeysSetException { + if (this.defaultKeyBundle == null) { + throw new NoCollectionKeysSetException(); + } + if (keyBundleForCollectionIsNotDefault(collection)) { + return collectionKeyBundles.get(collection); + } + return this.defaultKeyBundle; + } + + /** + * Take a pair of values in a JSON array, handing them off to KeyBundle to + * produce a usable keypair. + */ + private static KeyBundle arrayToKeyBundle(JSONArray array) throws UnsupportedEncodingException { + String encKeyStr = (String) array.get(0); + String hmacKeyStr = (String) array.get(1); + return KeyBundle.fromBase64EncodedKeys(encKeyStr, hmacKeyStr); + } + + @SuppressWarnings("unchecked") + private static JSONArray keyBundleToArray(KeyBundle bundle) { + // Generate JSON. + JSONArray keysArray = new JSONArray(); + keysArray.add(new String(Base64.encodeBase64(bundle.getEncryptionKey()))); + keysArray.add(new String(Base64.encodeBase64(bundle.getHMACKey()))); + return keysArray; + } + + private ExtendedJSONObject asRecordContents() throws NoCollectionKeysSetException { + ExtendedJSONObject json = new ExtendedJSONObject(); + json.put("id", "keys"); + json.put("collection", "crypto"); + json.put("default", keyBundleToArray(this.defaultKeyBundle())); + ExtendedJSONObject colls = new ExtendedJSONObject(); + for (Entry<String, KeyBundle> collKey : collectionKeyBundles.entrySet()) { + colls.put(collKey.getKey(), keyBundleToArray(collKey.getValue())); + } + json.put("collections", colls); + return json; + } + + public CryptoRecord asCryptoRecord() throws NoCollectionKeysSetException { + ExtendedJSONObject payload = this.asRecordContents(); + CryptoRecord record = new CryptoRecord(payload); + record.collection = "crypto"; + record.guid = "keys"; + record.deleted = false; + return record; + } + + /** + * Set my key bundle and collection keys with the given key bundle and data + * (possibly decrypted) from the given record. + * + * @param keys + * A "crypto/keys" <code>CryptoRecord</code>, encrypted with + * <code>syncKeyBundle</code> if <code>syncKeyBundle</code> is non-null. + * @param syncKeyBundle + * If non-null, the sync key bundle to decrypt <code>keys</code> with. + */ + public void setKeyPairsFromWBO(CryptoRecord keys, KeyBundle syncKeyBundle) + throws CryptoException, IOException, NonObjectJSONException { + if (keys == null) { + throw new IllegalArgumentException("cannot set key pairs from null record"); + } + if (syncKeyBundle != null) { + keys.keyBundle = syncKeyBundle; + keys.decrypt(); + } + ExtendedJSONObject cleartext = keys.payload; + KeyBundle defaultKey = arrayToKeyBundle((JSONArray) cleartext.get("default")); + + ExtendedJSONObject collections = cleartext.getObject("collections"); + HashMap<String, KeyBundle> collectionKeys = new HashMap<String, KeyBundle>(); + for (Entry<String, Object> pair : collections.entrySet()) { + KeyBundle bundle = arrayToKeyBundle((JSONArray) pair.getValue()); + collectionKeys.put(pair.getKey(), bundle); + } + + this.collectionKeyBundles.clear(); + this.collectionKeyBundles.putAll(collectionKeys); + this.defaultKeyBundle = defaultKey; + } + + public void setKeyBundleForCollection(String collection, KeyBundle keys) { + this.collectionKeyBundles.put(collection, keys); + } + + public void setDefaultKeyBundle(KeyBundle keys) { + this.defaultKeyBundle = keys; + } + + public void clear() { + this.defaultKeyBundle = null; + this.collectionKeyBundles.clear(); + } + + /** + * Return set of collections where key is either missing from one collection + * or not the same in both collections. + * <p> + * Does not check for different default keys. + */ + public static Set<String> differences(CollectionKeys a, CollectionKeys b) { + Set<String> differences = new HashSet<String>(); + Set<String> collections = new HashSet<String>(a.collectionKeyBundles.keySet()); + collections.addAll(b.collectionKeyBundles.keySet()); + + // Iterate through one collection, collecting missing and differences. + for (String collection : collections) { + KeyBundle keyA; + KeyBundle keyB; + try { + keyA = a.keyBundleForCollection(collection); // Will return default key as appropriate. + keyB = b.keyBundleForCollection(collection); // Will return default key as appropriate. + } catch (NoCollectionKeysSetException e) { + differences.add(collection); + continue; + } + // keyA and keyB are not null at this point. + if (!keyA.equals(keyB)) { + differences.add(collection); + } + } + + return differences; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof CollectionKeys)) { + return false; + } + CollectionKeys other = (CollectionKeys) o; + try { + // It would be nice to use map equality here, but there can be map entries + // where the key is the default key that should compare equal to a missing + // map entry. Therefore, we always compute the set of differences. + return defaultKeyBundle().equals(other.defaultKeyBundle()) && + CollectionKeys.differences(this, other).isEmpty(); + } catch (NoCollectionKeysSetException e) { + // If either default key bundle is not set, we'll say the bundles are not equal. + return false; + } + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java new file mode 100644 index 000000000..371603de5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java @@ -0,0 +1,261 @@ +/* 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 android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor; +import org.mozilla.gecko.sync.repositories.domain.ClientRecord; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Process commands received from Sync clients. + * <p> + * We need a command processor at two different times: + * <ol> + * <li>We execute commands during the "clients" engine stage of a Sync. Each + * command takes a <code>GlobalSession</code> instance as a parameter.</li> + * <li>We queue commands to be executed or propagated to other Sync clients + * during an activity completely unrelated to a sync</li> + * </ol> + * To provide a processor for both these time frames, we maintain a static + * long-lived singleton. + */ +public class CommandProcessor { + private static final String LOG_TAG = "Command"; + private static final AtomicInteger currentId = new AtomicInteger(); + protected ConcurrentHashMap<String, CommandRunner> commands = new ConcurrentHashMap<String, CommandRunner>(); + + private final static CommandProcessor processor = new CommandProcessor(); + + /** + * Get the global singleton command processor. + * + * @return the singleton processor. + */ + public static CommandProcessor getProcessor() { + return processor; + } + + public static class Command { + public final String commandType; + public final JSONArray args; + private List<String> argsList; + + public Command(String commandType, JSONArray args) { + this.commandType = commandType; + this.args = args; + } + + /** + * Get list of arguments as strings. Individual arguments may be null. + * + * @return list of strings. + */ + public synchronized List<String> getArgsList() { + if (argsList == null) { + ArrayList<String> argsList = new ArrayList<String>(args.size()); + + for (int i = 0; i < args.size(); i++) { + final Object arg = args.get(i); + if (arg == null) { + argsList.add(null); + continue; + } + argsList.add(arg.toString()); + } + this.argsList = argsList; + } + return this.argsList; + } + + @SuppressWarnings("unchecked") + public JSONObject asJSONObject() { + JSONObject out = new JSONObject(); + out.put("command", this.commandType); + out.put("args", this.args); + return out; + } + } + + /** + * Register a command. + * <p> + * Any existing registration is overwritten. + * + * @param commandType + * the name of the command, i.e., "displayURI". + * @param command + * the <code>CommandRunner</code> instance that should handle the + * command. + */ + public void registerCommand(String commandType, CommandRunner command) { + commands.put(commandType, command); + } + + /** + * Process a command in the context of the given global session. + * + * @param session + * the <code>GlobalSession</code> instance currently executing. + * @param unparsedCommand + * command as a <code>ExtendedJSONObject</code> instance. + */ + public void processCommand(final GlobalSession session, ExtendedJSONObject unparsedCommand) { + Command command = parseCommand(unparsedCommand); + if (command == null) { + Logger.debug(LOG_TAG, "Invalid command: " + unparsedCommand + " will not be processed."); + return; + } + + CommandRunner executableCommand = commands.get(command.commandType); + if (executableCommand == null) { + Logger.debug(LOG_TAG, "Command \"" + command.commandType + "\" not registered and will not be processed."); + return; + } + + executableCommand.executeCommand(session, command.getArgsList()); + } + + /** + * Parse a JSON command into a ParsedCommand object for easier handling. + * + * @param unparsedCommand - command as ExtendedJSONObject + * @return - null if command is invalid, else return ParsedCommand with + * no null attributes. + */ + protected static Command parseCommand(ExtendedJSONObject unparsedCommand) { + String type = (String) unparsedCommand.get("command"); + if (type == null) { + return null; + } + + try { + JSONArray unparsedArgs = unparsedCommand.getArray("args"); + if (unparsedArgs == null) { + return null; + } + + return new Command(type, unparsedArgs); + } catch (NonArrayJSONException e) { + Logger.debug(LOG_TAG, "Unable to parse args array. Invalid command"); + return null; + } + } + + @SuppressWarnings("unchecked") + public void sendURIToClientForDisplay(String uri, String clientID, String title, String sender, Context context) { + Logger.info(LOG_TAG, "Sending URI to client " + clientID + "."); + if (Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(LOG_TAG, "URI is " + uri + "; title is '" + title + "'."); + } + + final JSONArray args = new JSONArray(); + args.add(uri); + args.add(sender); + args.add(title); + + final Command displayURICommand = new Command("displayURI", args); + this.sendCommand(clientID, displayURICommand, context); + } + + /** + * Validates and sends a command to a client or all clients. + * + * Calling this does not actually sync the command data to the server. If the + * client already has the command/args pair, it won't receive a duplicate + * command. + * + * @param clientID + * Client ID to send command to. If null, send to all remote + * clients. + * @param command + * Command to invoke on remote clients + */ + public void sendCommand(String clientID, Command command, Context context) { + Logger.debug(LOG_TAG, "In sendCommand."); + + CommandRunner commandData = commands.get(command.commandType); + + // Don't send commands that we don't know about. + if (commandData == null) { + Logger.error(LOG_TAG, "Unknown command to send: " + command); + return; + } + + // Don't send a command with the wrong number of arguments. + if (!commandData.argumentsAreValid(command.getArgsList())) { + Logger.error(LOG_TAG, "Expected " + commandData.argCount + " args for '" + + command + "', but got " + command.args); + return; + } + + if (clientID != null) { + this.sendCommandToClient(clientID, command, context); + return; + } + + ClientsDatabaseAccessor db = new ClientsDatabaseAccessor(context); + try { + Map<String, ClientRecord> clientMap = db.fetchAllClients(); + for (ClientRecord client : clientMap.values()) { + this.sendCommandToClient(client.guid, command, context); + } + } catch (NullCursorException e) { + Logger.error(LOG_TAG, "NullCursorException when fetching all GUIDs"); + } finally { + db.close(); + } + } + + protected void sendCommandToClient(String clientID, Command command, Context context) { + Logger.info(LOG_TAG, "Sending " + command.commandType + " to " + clientID); + + ClientsDatabaseAccessor db = new ClientsDatabaseAccessor(context); + try { + db.store(clientID, command); + } catch (NullCursorException e) { + Logger.error(LOG_TAG, "NullCursorException: Unable to send command."); + } finally { + db.close(); + } + } + + public static void displayURI(final List<String> args, final Context context) { + // We trust the client sender that these exist. + final String uri = args.get(0); + final String clientId = args.get(1); + Logger.pii(LOG_TAG, "Received a URI for display: " + uri + " from " + clientId); + + if (uri == null) { + Logger.pii(LOG_TAG, "URI is null – ignoring"); + return; + } + + String title = null; + if (args.size() == 3) { + title = args.get(2); + } + + final Intent sendTabNotificationIntent = new Intent(); + sendTabNotificationIntent.setClassName(context, BrowserContract.TAB_RECEIVED_SERVICE_CLASS_NAME); + sendTabNotificationIntent.setData(Uri.parse(uri)); + sendTabNotificationIntent.putExtra(Intent.EXTRA_TITLE, title); + sendTabNotificationIntent.putExtra(BrowserContract.EXTRA_CLIENT_GUID, clientId); + final ComponentName componentName = context.startService(sendTabNotificationIntent); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java new file mode 100644 index 000000000..c7a0f1762 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java @@ -0,0 +1,22 @@ +/* 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.util.List; + +public abstract class CommandRunner { + public final int argCount; + + public CommandRunner(int argCount) { + this.argCount = argCount; + } + + public abstract void executeCommand(GlobalSession session, List<String> args); + + public boolean argumentsAreValid(List<String> args) { + return args != null && + args.size() == argCount; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java new file mode 100644 index 000000000..f9004e14c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java @@ -0,0 +1,56 @@ +/* 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 android.content.SyncResult; + +/** + * There was a problem with the Sync account's credentials: bad username, + * missing password, malformed sync key, etc. + */ +public abstract class CredentialException extends SyncException { + private static final long serialVersionUID = 833010553314100538L; + + public CredentialException() { + super(); + } + + public CredentialException(final Throwable e) { + super(e); + } + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + syncResult.stats.numAuthExceptions += 1; + } + + /** + * No credentials at all. + */ + public static class MissingAllCredentialsException extends CredentialException { + private static final long serialVersionUID = 3763937096217604611L; + + public MissingAllCredentialsException() { + super(); + } + + public MissingAllCredentialsException(final Throwable e) { + super(e); + } + } + + /** + * Some credential is missing. + */ + public static class MissingCredentialException extends CredentialException { + private static final long serialVersionUID = -7543031216547596248L; + + public final String missingCredential; + + public MissingCredentialException(final String missingCredential) { + this.missingCredential = missingCredential; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java new file mode 100644 index 000000000..65563d344 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java @@ -0,0 +1,255 @@ +/* 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.io.IOException; +import java.io.UnsupportedEncodingException; + +import org.json.simple.JSONObject; +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.sync.crypto.CryptoException; +import org.mozilla.gecko.sync.crypto.CryptoInfo; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.crypto.MissingCryptoInputException; +import org.mozilla.gecko.sync.crypto.NoKeyBundleException; +import org.mozilla.gecko.sync.repositories.domain.Record; +import org.mozilla.gecko.sync.repositories.domain.RecordParseException; + +/** + * A Sync crypto record has: + * + * <ul> + * <li>a collection of fields which are not encrypted (id and collection);</il> + * <li>a set of metadata fields (index, modified, ttl);</il> + * <li>a payload, which is encrypted and decrypted on request.</il> + * </ul> + * + * The payload flips between being a blob of JSON with hmac/IV/ciphertext + * attributes and the cleartext itself. + * + * Until there's some benefit to the abstraction, we're simply going to call + * this <code>CryptoRecord</code>. + * + * <code>CryptoRecord</code> uses <code>CryptoInfo</code> to do the actual + * encryption and decryption. + */ +public class CryptoRecord extends Record { + + // JSON related constants. + private static final String KEY_ID = "id"; + private static final String KEY_COLLECTION = "collection"; + private static final String KEY_PAYLOAD = "payload"; + private static final String KEY_MODIFIED = "modified"; + private static final String KEY_SORTINDEX = "sortindex"; + private static final String KEY_TTL = "ttl"; + private static final String KEY_CIPHERTEXT = "ciphertext"; + private static final String KEY_HMAC = "hmac"; + private static final String KEY_IV = "IV"; + + /** + * Helper method for doing actual decryption. + * + * Input: JSONObject containing a valid payload (cipherText, IV, HMAC), + * KeyBundle with keys for decryption. Output: byte[] clearText + * @throws CryptoException + * @throws UnsupportedEncodingException + */ + private static byte[] decryptPayload(ExtendedJSONObject payload, KeyBundle keybundle) throws CryptoException, UnsupportedEncodingException { + byte[] ciphertext = Base64.decodeBase64(((String) payload.get(KEY_CIPHERTEXT)).getBytes("UTF-8")); + byte[] iv = Base64.decodeBase64(((String) payload.get(KEY_IV)).getBytes("UTF-8")); + byte[] hmac = Utils.hex2Byte((String) payload.get(KEY_HMAC)); + + return CryptoInfo.decrypt(ciphertext, iv, hmac, keybundle).getMessage(); + } + + // The encrypted JSON body object. + // The decrypted JSON body object. Fields are copied from `body`. + + public ExtendedJSONObject payload; + public KeyBundle keyBundle; + + /** + * Don't forget to set cleartext or body! + */ + public CryptoRecord() { + super(null, null, 0, false); + } + + public CryptoRecord(ExtendedJSONObject payload) { + super(null, null, 0, false); + if (payload == null) { + throw new IllegalArgumentException( + "No payload provided to CryptoRecord constructor."); + } + this.payload = payload; + } + + public CryptoRecord(String jsonString) throws IOException, NonObjectJSONException { + + this(new ExtendedJSONObject(jsonString)); + } + + /** + * Create a new CryptoRecord with the same metadata as an existing record. + * + * @param source + */ + public CryptoRecord(Record source) { + super(source.guid, source.collection, source.lastModified, source.deleted); + this.ttl = source.ttl; + } + + @Override + public Record copyWithIDs(String guid, long androidID) { + CryptoRecord out = new CryptoRecord(this); + out.guid = guid; + out.androidID = androidID; + out.sortIndex = this.sortIndex; + out.ttl = this.ttl; + out.payload = (this.payload == null) ? null : new ExtendedJSONObject(this.payload.object); + out.keyBundle = this.keyBundle; // TODO: copy me? + return out; + } + + /** + * Take a whole record as JSON -- i.e., something like + * + * {"payload": "{...}", "id":"foobarbaz"} + * + * and turn it into a CryptoRecord object. + * + * @param jsonRecord + * @return + * A CryptoRecord that encapsulates the provided record. + * + * @throws NonObjectJSONException + * @throws IOException + */ + public static CryptoRecord fromJSONRecord(String jsonRecord) + throws NonObjectJSONException, IOException, RecordParseException { + byte[] bytes = jsonRecord.getBytes("UTF-8"); + ExtendedJSONObject object = ExtendedJSONObject.parseUTF8AsJSONObject(bytes); + + return CryptoRecord.fromJSONRecord(object); + } + + // TODO: defensive programming. + public static CryptoRecord fromJSONRecord(ExtendedJSONObject jsonRecord) + throws IOException, NonObjectJSONException, RecordParseException { + String id = (String) jsonRecord.get(KEY_ID); + String collection = (String) jsonRecord.get(KEY_COLLECTION); + String jsonEncodedPayload = (String) jsonRecord.get(KEY_PAYLOAD); + + ExtendedJSONObject payload = new ExtendedJSONObject(jsonEncodedPayload); + + CryptoRecord record = new CryptoRecord(payload); + record.guid = id; + record.collection = collection; + if (jsonRecord.containsKey(KEY_MODIFIED)) { + Long timestamp = jsonRecord.getTimestamp(KEY_MODIFIED); + if (timestamp == null) { + throw new RecordParseException("timestamp could not be parsed"); + } + record.lastModified = timestamp; + } + if (jsonRecord.containsKey(KEY_SORTINDEX)) { + // getLong tries to cast to Long, and might return null. We catch all + // exceptions, just to be safe. + try { + record.sortIndex = jsonRecord.getLong(KEY_SORTINDEX); + } catch (Exception e) { + throw new RecordParseException("timestamp could not be parsed"); + } + } + if (jsonRecord.containsKey(KEY_TTL)) { + // TTLs are never returned by the sync server, so should never be true if + // the record was fetched. + try { + record.ttl = jsonRecord.getLong(KEY_TTL); + } catch (Exception e) { + throw new RecordParseException("TTL could not be parsed"); + } + } + // TODO: deleted? + return record; + } + + public void setKeyBundle(KeyBundle bundle) { + this.keyBundle = bundle; + } + + public CryptoRecord decrypt() throws CryptoException, IOException, NonObjectJSONException { + if (keyBundle == null) { + throw new NoKeyBundleException(); + } + + // Check that payload contains all pieces for crypto. + if (!payload.containsKey(KEY_CIPHERTEXT) || + !payload.containsKey(KEY_IV) || + !payload.containsKey(KEY_HMAC)) { + throw new MissingCryptoInputException(); + } + + // There's no difference between handling the crypto/keys object and + // anything else; we just get this.keyBundle from a different source. + byte[] cleartext = decryptPayload(payload, keyBundle); + payload = ExtendedJSONObject.parseUTF8AsJSONObject(cleartext); + return this; + } + + public CryptoRecord encrypt() throws CryptoException, UnsupportedEncodingException { + if (this.keyBundle == null) { + throw new NoKeyBundleException(); + } + String cleartext = payload.toJSONString(); + byte[] cleartextBytes = cleartext.getBytes("UTF-8"); + CryptoInfo info = CryptoInfo.encrypt(cleartextBytes, keyBundle); + String message = new String(Base64.encodeBase64(info.getMessage())); + String iv = new String(Base64.encodeBase64(info.getIV())); + String hmac = Utils.byte2Hex(info.getHMAC()); + ExtendedJSONObject ciphertext = new ExtendedJSONObject(); + ciphertext.put(KEY_CIPHERTEXT, message); + ciphertext.put(KEY_HMAC, hmac); + ciphertext.put(KEY_IV, iv); + this.payload = ciphertext; + return this; + } + + @Override + public void initFromEnvelope(CryptoRecord payload) { + throw new IllegalStateException("Can't do this with a CryptoRecord."); + } + + @Override + public CryptoRecord getEnvelope() { + throw new IllegalStateException("Can't do this with a CryptoRecord."); + } + + @Override + protected void populatePayload(ExtendedJSONObject payload) { + throw new IllegalStateException("Can't do this with a CryptoRecord."); + } + + @Override + protected void initFromPayload(ExtendedJSONObject payload) { + throw new IllegalStateException("Can't do this with a CryptoRecord."); + } + + // TODO: this only works with encrypted object, and has other limitations. + public JSONObject toJSONObject() { + ExtendedJSONObject o = new ExtendedJSONObject(); + o.put(KEY_PAYLOAD, payload.toJSONString()); + o.put(KEY_ID, this.guid); + if (this.ttl > 0) { + o.put(KEY_TTL, this.ttl); + } + return o.object; + } + + @Override + public String toJSONString() { + return toJSONObject().toJSONString(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java new file mode 100644 index 000000000..ddcb5411c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java @@ -0,0 +1,69 @@ +/* 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 org.mozilla.gecko.background.common.log.Logger; + +/** + * A little class to allow us to maintain a count of extant + * things (in our case, callbacks that need to fire), and + * some work that we want done when that count hits 0. + * + * @author rnewman + * + */ +public class DelayedWorkTracker { + private static final String LOG_TAG = "DelayedWorkTracker"; + protected Runnable workItem = null; + protected int outstandingCount = 0; + + public int incrementOutstanding() { + Logger.trace(LOG_TAG, "Incrementing outstanding."); + synchronized(this) { + return ++outstandingCount; + } + } + public int decrementOutstanding() { + Logger.trace(LOG_TAG, "Decrementing outstanding."); + Runnable job = null; + int count; + synchronized(this) { + if ((count = --outstandingCount) == 0 && + workItem != null) { + job = workItem; + workItem = null; + } else { + return count; + } + } + job.run(); + // In case it's changed. + return getOutstandingOperations(); + } + public int getOutstandingOperations() { + synchronized(this) { + return outstandingCount; + } + } + public void delayWorkItem(Runnable item) { + Logger.trace(LOG_TAG, "delayWorkItem."); + boolean runnableNow = false; + synchronized(this) { + Logger.trace(LOG_TAG, "outstandingCount: " + outstandingCount); + if (outstandingCount == 0) { + runnableNow = true; + } else { + if (workItem != null) { + throw new IllegalStateException("Work item already set!"); + } + workItem = item; + } + } + if (runnableNow) { + Logger.trace(LOG_TAG, "Running item now."); + item.run(); + } + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java new file mode 100644 index 000000000..035816088 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java @@ -0,0 +1,31 @@ +/* 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; + +public class EngineSettings { + public final String syncID; + public final int version; + + public EngineSettings(final String syncID, final int version) { + this.syncID = syncID; + this.version = version; + } + + public EngineSettings(ExtendedJSONObject object) { + try { + this.syncID = object.getString("syncID"); + this.version = object.getIntegerSafely("version"); + } catch (Exception e ) { + throw new IllegalArgumentException(e); + } + } + + public ExtendedJSONObject toJSONObject() { + ExtendedJSONObject json = new ExtendedJSONObject(); + json.put("syncID", syncID); + json.put("version", version); + return json; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java new file mode 100644 index 000000000..f5fac0009 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java @@ -0,0 +1,426 @@ +/* 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 org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** + * Extend JSONObject to do little things, like, y'know, accessing members. + * + * @author rnewman + * + */ +public class ExtendedJSONObject { + + public JSONObject object; + + /** + * Return a <code>JSONParser</code> instance for immediate use. + * <p> + * <code>JSONParser</code> is not thread-safe, so we return a new instance + * each call. This is extremely inefficient in execution time and especially + * memory use -- each instance allocates a 16kb temporary buffer -- and we + * hope to improve matters eventually. + */ + protected static JSONParser getJSONParser() { + return new JSONParser(); + } + + /** + * Parse a JSON encoded string. + * + * @param in <code>Reader</code> over a JSON-encoded input to parse; not + * necessarily a JSON object. + * @return a regular Java <code>Object</code>. + * @throws ParseException + * @throws IOException + */ + protected static Object parseRaw(Reader in) throws ParseException, IOException { + try { + return getJSONParser().parse(in); + } catch (Error e) { + // Don't be stupid, org.json.simple. Bug 1042929. + throw new ParseException(ParseException.ERROR_UNEXPECTED_EXCEPTION, e); + } + } + + /** + * Parse a JSON encoded string. + * <p> + * You should prefer the streaming interface {@link #parseRaw(Reader)}. + * + * @param input JSON-encoded input string to parse; not necessarily a JSON object. + * @return a regular Java <code>Object</code>. + * @throws ParseException + */ + protected static Object parseRaw(String input) throws ParseException { + try { + return getJSONParser().parse(input); + } catch (Error e) { + // Don't be stupid, org.json.simple. Bug 1042929. + throw new ParseException(ParseException.ERROR_UNEXPECTED_EXCEPTION, e); + } + } + + /** + * Helper method to get a JSON array from a stream. + * + * @param in <code>Reader</code> over a JSON-encoded array to parse. + * @throws ParseException + * @throws IOException + * @throws NonArrayJSONException if the object is valid JSON, but not an array. + */ + public static JSONArray parseJSONArray(Reader in) + throws IOException, ParseException, NonArrayJSONException { + Object o = parseRaw(in); + + if (o == null) { + return null; + } + + if (o instanceof JSONArray) { + return (JSONArray) o; + } + + throw new NonArrayJSONException("value must be a JSON array"); + } + + /** + * Helper method to get a JSON array from a string. + * <p> + * You should prefer the stream interface {@link #parseJSONArray(Reader)}. + * + * @param jsonString input. + * @throws IOException + * @throws NonArrayJSONException if the object is invalid JSON or not an array. + */ + public static JSONArray parseJSONArray(String jsonString) + throws IOException, NonArrayJSONException { + Object o = null; + try { + o = parseRaw(jsonString); + } catch (ParseException e) { + throw new NonArrayJSONException(e); + } + + if (o == null) { + return null; + } + + if (o instanceof JSONArray) { + return (JSONArray) o; + } + + throw new NonArrayJSONException("value must be a JSON array"); + } + + /** + * Helper method to get a JSON object from a UTF-8 byte array. + * + * @param in UTF-8 bytes. + * @throws NonObjectJSONException if the object is not valid JSON or not an object. + * @throws IOException + */ + public static ExtendedJSONObject parseUTF8AsJSONObject(byte[] in) + throws NonObjectJSONException, IOException { + return new ExtendedJSONObject(new String(in, "UTF-8")); + } + + public ExtendedJSONObject() { + this.object = new JSONObject(); + } + + public ExtendedJSONObject(JSONObject o) { + this.object = o; + } + + public ExtendedJSONObject(Reader in) throws IOException, NonObjectJSONException { + if (in == null) { + this.object = new JSONObject(); + return; + } + + Object obj = null; + try { + obj = parseRaw(in); + } catch (ParseException e) { + throw new NonObjectJSONException(e); + } + + if (obj instanceof JSONObject) { + this.object = ((JSONObject) obj); + } else { + throw new NonObjectJSONException("value must be a JSON object"); + } + } + + public ExtendedJSONObject(String jsonString) throws IOException, NonObjectJSONException { + this(jsonString == null ? null : new StringReader(jsonString)); + } + + @Override + public ExtendedJSONObject clone() { + return new ExtendedJSONObject((JSONObject) this.object.clone()); + } + + // Passthrough methods. + public Object get(String key) { + return this.object.get(key); + } + + public long getLong(String key, long def) { + if (!object.containsKey(key)) { + return def; + } + + Long val = getLong(key); + if (val == null) { + return def; + } + return val.longValue(); + } + + public Long getLong(String key) { + return (Long) this.get(key); + } + + public String getString(String key) { + return (String) this.get(key); + } + + public Boolean getBoolean(String key) { + return (Boolean) this.get(key); + } + + /** + * Return an Integer if the value for this key is an Integer, Long, or String + * that can be parsed as a base 10 Integer. + * Passes through null. + * + * @throws NumberFormatException + */ + public Integer getIntegerSafely(String key) throws NumberFormatException { + Object val = this.object.get(key); + if (val == null) { + return null; + } + if (val instanceof Integer) { + return (Integer) val; + } + if (val instanceof Long) { + return ((Long) val).intValue(); + } + if (val instanceof String) { + return Integer.parseInt((String) val, 10); + } + throw new NumberFormatException("Expecting Integer, got " + val.getClass()); + } + + /** + * Return a server timestamp value as milliseconds since epoch. + * + * @param key + * @return A Long, or null if the value is non-numeric or doesn't exist. + */ + public Long getTimestamp(String key) { + Object val = this.object.get(key); + + // This is absurd. + if (val instanceof Double) { + double millis = ((Double) val) * 1000; + return Double.valueOf(millis).longValue(); + } + if (val instanceof Float) { + double millis = ((Float) val).doubleValue() * 1000; + return Double.valueOf(millis).longValue(); + } + if (val instanceof Number) { + // Must be an integral number. + return ((Number) val).longValue() * 1000; + } + + return null; + } + + public boolean containsKey(String key) { + return this.object.containsKey(key); + } + + public String toJSONString() { + return this.object.toJSONString(); + } + + @Override + public String toString() { + return this.object.toString(); + } + + protected void putRaw(String key, Object value) { + @SuppressWarnings("unchecked") + Map<Object, Object> map = this.object; + map.put(key, value); + } + + public void put(String key, String value) { + this.putRaw(key, value); + } + + public void put(String key, boolean value) { + this.putRaw(key, value); + } + + public void put(String key, long value) { + this.putRaw(key, value); + } + + public void put(String key, int value) { + this.putRaw(key, value); + } + + public void put(String key, ExtendedJSONObject value) { + this.putRaw(key, value); + } + + public void put(String key, JSONArray value) { + this.putRaw(key, value); + } + + @SuppressWarnings("unchecked") + public void putArray(String key, List<String> value) { + // Frustratingly inefficient, but there you have it. + final JSONArray jsonArray = new JSONArray(); + jsonArray.addAll(value); + this.putRaw(key, jsonArray); + } + + /** + * Remove key-value pair from JSONObject. + * + * @param key + * to be removed. + * @return true if key exists and was removed, false otherwise. + */ + public boolean remove(String key) { + Object res = this.object.remove(key); + return (res != null); + } + + public ExtendedJSONObject getObject(String key) throws NonObjectJSONException { + Object o = this.object.get(key); + if (o == null) { + return null; + } + if (o instanceof ExtendedJSONObject) { + return (ExtendedJSONObject) o; + } + if (o instanceof JSONObject) { + return new ExtendedJSONObject((JSONObject) o); + } + throw new NonObjectJSONException("value must be a JSON object for key: " + key); + } + + @SuppressWarnings("unchecked") + public Set<Entry<String, Object>> entrySet() { + return this.object.entrySet(); + } + + @SuppressWarnings("unchecked") + public Set<String> keySet() { + return this.object.keySet(); + } + + public org.json.simple.JSONArray getArray(String key) throws NonArrayJSONException { + Object o = this.object.get(key); + if (o == null) { + return null; + } + if (o instanceof JSONArray) { + return (JSONArray) o; + } + throw new NonArrayJSONException("key must be a JSON array: " + key); + } + + public int size() { + return this.object.size(); + } + + @Override + public int hashCode() { + if (this.object == null) { + return getClass().hashCode(); + } + return this.object.hashCode() ^ getClass().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ExtendedJSONObject)) { + return false; + } + if (o == this) { + return true; + } + ExtendedJSONObject other = (ExtendedJSONObject) o; + if (this.object == null) { + return other.object == null; + } + return this.object.equals(other.object); + } + + /** + * Throw if keys are missing or values have wrong types. + * + * @param requiredFields list of required keys. + * @param requiredFieldClass class that values must be coercable to; may be null, which means don't check. + * @throws UnexpectedJSONException + */ + public void throwIfFieldsMissingOrMisTyped(String[] requiredFields, Class<?> requiredFieldClass) throws BadRequiredFieldJSONException { + // Defensive as possible: verify object has expected key(s) with string value. + for (String k : requiredFields) { + Object value = get(k); + if (value == null) { + throw new BadRequiredFieldJSONException("Expected key not present in result: " + k); + } + if (requiredFieldClass != null && !(requiredFieldClass.isInstance(value))) { + throw new BadRequiredFieldJSONException("Value for key not an instance of " + requiredFieldClass + ": " + k); + } + } + } + + /** + * Return a base64-encoded string value as a byte array. + */ + public byte[] getByteArrayBase64(String key) { + String s = (String) this.object.get(key); + if (s == null) { + return null; + } + return Base64.decodeBase64(s); + } + + /** + * Return a hex-encoded string value as a byte array. + */ + public byte[] getByteArrayHex(String key) { + String s = (String) this.object.get(key); + if (s == null) { + return null; + } + return Utils.hex2Byte(s); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java new file mode 100644 index 000000000..e28bbe4cc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java @@ -0,0 +1,1167 @@ +/* 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 android.content.Context; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.crypto.CryptoException; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.delegates.ClientsDataDelegate; +import org.mozilla.gecko.sync.delegates.FreshStartDelegate; +import org.mozilla.gecko.sync.delegates.GlobalSessionCallback; +import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate; +import org.mozilla.gecko.sync.delegates.KeyUploadDelegate; +import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate; +import org.mozilla.gecko.sync.delegates.WipeServerDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.HttpResponseObserver; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageRecordRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; +import org.mozilla.gecko.sync.stage.AndroidBrowserBookmarksServerSyncStage; +import org.mozilla.gecko.sync.stage.AndroidBrowserHistoryServerSyncStage; +import org.mozilla.gecko.sync.stage.CheckPreconditionsStage; +import org.mozilla.gecko.sync.stage.CompletedStage; +import org.mozilla.gecko.sync.stage.EnsureCrypto5KeysStage; +import org.mozilla.gecko.sync.stage.FennecTabsServerSyncStage; +import org.mozilla.gecko.sync.stage.FetchInfoCollectionsStage; +import org.mozilla.gecko.sync.stage.FetchInfoConfigurationStage; +import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage; +import org.mozilla.gecko.sync.stage.FormHistoryServerSyncStage; +import org.mozilla.gecko.sync.stage.GlobalSyncStage; +import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage; +import org.mozilla.gecko.sync.stage.NoSuchStageException; +import org.mozilla.gecko.sync.stage.PasswordsServerSyncStage; +import org.mozilla.gecko.sync.stage.SyncClientsEngineStage; +import org.mozilla.gecko.sync.stage.UploadMetaGlobalStage; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; + +public class GlobalSession implements HttpResponseObserver { + private static final String LOG_TAG = "GlobalSession"; + + public static final long STORAGE_VERSION = 5; + + public SyncConfiguration config = null; + + protected Map<Stage, GlobalSyncStage> stages; + public Stage currentState = Stage.idle; + + public final GlobalSessionCallback callback; + protected final Context context; + protected final ClientsDataDelegate clientsDelegate; + + /** + * Map from engine name to new settings for an updated meta/global record. + * Engines to remove will have <code>null</code> EngineSettings. + */ + public final Map<String, EngineSettings> enginesToUpdate = new HashMap<String, EngineSettings>(); + + /* + * Key accessors. + */ + public KeyBundle keyBundleForCollection(String collection) throws NoCollectionKeysSetException { + return config.getCollectionKeys().keyBundleForCollection(collection); + } + + /* + * Config passthrough for convenience. + */ + public AuthHeaderProvider getAuthHeaderProvider() { + return config.getAuthHeaderProvider(); + } + + public URI wboURI(String collection, String id) throws URISyntaxException { + return config.wboURI(collection, id); + } + + public GlobalSession(SyncConfiguration config, + GlobalSessionCallback callback, + Context context, + ClientsDataDelegate clientsDelegate) + throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException { + + if (callback == null) { + throw new IllegalArgumentException("Must provide a callback to GlobalSession constructor."); + } + + this.callback = callback; + this.context = context; + this.clientsDelegate = clientsDelegate; + + this.config = config; + registerCommands(); + prepareStages(); + + if (config.stagesToSync == null) { + Logger.info(LOG_TAG, "No stages to sync specified; defaulting to all valid engine names."); + config.stagesToSync = Collections.unmodifiableCollection(SyncConfiguration.validEngineNames()); + } + + // TODO: data-driven plan for the sync, referring to prepareStages. + } + + /** + * Register commands this global session knows how to process. + * <p> + * Re-registering a command overwrites any existing registration. + */ + protected static void registerCommands() { + final CommandProcessor processor = CommandProcessor.getProcessor(); + + processor.registerCommand("resetEngine", new CommandRunner(1) { + @Override + public void executeCommand(final GlobalSession session, List<String> args) { + HashSet<String> names = new HashSet<String>(); + names.add(args.get(0)); + session.resetStagesByName(names); + } + }); + + processor.registerCommand("resetAll", new CommandRunner(0) { + @Override + public void executeCommand(final GlobalSession session, List<String> args) { + session.resetAllStages(); + } + }); + + processor.registerCommand("wipeEngine", new CommandRunner(1) { + @Override + public void executeCommand(final GlobalSession session, List<String> args) { + HashSet<String> names = new HashSet<String>(); + names.add(args.get(0)); + session.wipeStagesByName(names); + } + }); + + processor.registerCommand("wipeAll", new CommandRunner(0) { + @Override + public void executeCommand(final GlobalSession session, List<String> args) { + session.wipeAllStages(); + } + }); + + processor.registerCommand("displayURI", new CommandRunner(3) { + @Override + public void executeCommand(final GlobalSession session, List<String> args) { + CommandProcessor.displayURI(args, session.getContext()); + } + }); + } + + protected void prepareStages() { + Map<Stage, GlobalSyncStage> stages = new EnumMap<Stage, GlobalSyncStage>(Stage.class); + + stages.put(Stage.checkPreconditions, new CheckPreconditionsStage()); + stages.put(Stage.fetchInfoCollections, new FetchInfoCollectionsStage()); + stages.put(Stage.fetchMetaGlobal, new FetchMetaGlobalStage()); + stages.put(Stage.fetchInfoConfiguration, new FetchInfoConfigurationStage( + config.infoConfigurationURL(), getAuthHeaderProvider())); + stages.put(Stage.ensureKeysStage, new EnsureCrypto5KeysStage()); + + stages.put(Stage.syncClientsEngine, new SyncClientsEngineStage()); + + stages.put(Stage.syncTabs, new FennecTabsServerSyncStage()); + stages.put(Stage.syncPasswords, new PasswordsServerSyncStage()); + stages.put(Stage.syncBookmarks, new AndroidBrowserBookmarksServerSyncStage()); + stages.put(Stage.syncHistory, new AndroidBrowserHistoryServerSyncStage()); + stages.put(Stage.syncFormHistory, new FormHistoryServerSyncStage()); + + stages.put(Stage.uploadMetaGlobal, new UploadMetaGlobalStage()); + stages.put(Stage.completed, new CompletedStage()); + + this.stages = Collections.unmodifiableMap(stages); + } + + public GlobalSyncStage getSyncStageByName(String name) throws NoSuchStageException { + return getSyncStageByName(Stage.byName(name)); + } + + public GlobalSyncStage getSyncStageByName(Stage next) throws NoSuchStageException { + GlobalSyncStage stage = stages.get(next); + if (stage == null) { + throw new NoSuchStageException(next); + } + return stage; + } + + public Collection<GlobalSyncStage> getSyncStagesByEnum(Collection<Stage> enums) { + ArrayList<GlobalSyncStage> out = new ArrayList<GlobalSyncStage>(); + for (Stage name : enums) { + try { + GlobalSyncStage stage = this.getSyncStageByName(name); + out.add(stage); + } catch (NoSuchStageException e) { + Logger.warn(LOG_TAG, "Unable to find stage with name " + name); + } + } + return out; + } + + public Collection<GlobalSyncStage> getSyncStagesByName(Collection<String> names) { + ArrayList<GlobalSyncStage> out = new ArrayList<GlobalSyncStage>(); + for (String name : names) { + try { + GlobalSyncStage stage = this.getSyncStageByName(name); + out.add(stage); + } catch (NoSuchStageException e) { + Logger.warn(LOG_TAG, "Unable to find stage with name " + name); + } + } + return out; + } + + /** + * Advance and loop around the stages of a sync. + * @param current + * @return + * The next stage to execute. + */ + public static Stage nextStage(Stage current) { + int index = current.ordinal() + 1; + int max = Stage.completed.ordinal() + 1; + return Stage.values()[index % max]; + } + + /** + * Move to the next stage in the syncing process. + */ + public void advance() { + // If we have a backoff, request a backoff and don't advance to next stage. + long existingBackoff = largestBackoffObserved.get(); + if (existingBackoff > 0) { + this.abort(null, "Aborting sync because of backoff of " + existingBackoff + " milliseconds."); + return; + } + + this.callback.handleStageCompleted(this.currentState, this); + Stage next = nextStage(this.currentState); + GlobalSyncStage nextStage; + try { + nextStage = this.getSyncStageByName(next); + } catch (NoSuchStageException e) { + this.abort(e, "No such stage " + next); + return; + } + this.currentState = next; + Logger.info(LOG_TAG, "Running next stage " + next + " (" + nextStage + ")..."); + try { + nextStage.execute(this); + } catch (Exception ex) { + Logger.warn(LOG_TAG, "Caught exception " + ex + " running stage " + next); + this.abort(ex, "Uncaught exception in stage."); + return; + } + } + + public Context getContext() { + return this.context; + } + + /** + * Begin a sync. + * <p> + * The caller is responsible for: + * <ul> + * <li>Verifying that any backoffs/minimum next sync requests are respected.</li> + * <li>Ensuring that the device is online.</li> + * <li>Ensuring that dependencies are ready.</li> + * </ul> + * + * @throws AlreadySyncingException + */ + public void start() throws AlreadySyncingException { + if (this.currentState != GlobalSyncStage.Stage.idle) { + throw new AlreadySyncingException(this.currentState); + } + installAsHttpResponseObserver(); // Uninstalled by completeSync or abort. + this.advance(); + } + + /** + * Stop this sync and start again. + * @throws AlreadySyncingException + */ + protected void restart() throws AlreadySyncingException { + this.currentState = GlobalSyncStage.Stage.idle; + if (callback.shouldBackOffStorage()) { + this.callback.handleAborted(this, "Told to back off."); + return; + } + this.start(); + } + + /** + * We're finished (aborted or succeeded): release resources. + */ + protected void cleanUp() { + uninstallAsHttpResponseObserver(); + this.stages = null; + } + + public void completeSync() { + cleanUp(); + this.currentState = GlobalSyncStage.Stage.idle; + this.callback.handleSuccess(this); + } + + /** + * Record that an updated meta/global record should be uploaded with the given + * settings for the given engine. + * + * @param engineName engine to update. + * @param engineSettings new syncID and version. + */ + public void recordForMetaGlobalUpdate(String engineName, EngineSettings engineSettings) { + enginesToUpdate.put(engineName, engineSettings); + } + + /** + * Record that an updated meta/global record should be uploaded without the + * given engine name. + * + * @param engineName + * engine to remove. + */ + public void removeEngineFromMetaGlobal(String engineName) { + enginesToUpdate.put(engineName, null); + } + + public boolean hasUpdatedMetaGlobal() { + if (enginesToUpdate.isEmpty()) { + Logger.info(LOG_TAG, "Not uploading updated meta/global record since there are no engines requesting upload."); + return false; + } + + if (Logger.shouldLogVerbose(LOG_TAG)) { + Logger.trace(LOG_TAG, "Uploading updated meta/global record since there are engine changes to meta/global."); + Logger.trace(LOG_TAG, "Engines requesting update [" + Utils.toCommaSeparatedString(enginesToUpdate.keySet()) + "]"); + } + + return true; + } + + public void updateMetaGlobalInPlace() { + config.metaGlobal.declined = this.declinedEngineNames(); + ExtendedJSONObject engines = config.metaGlobal.getEngines(); + for (Entry<String, EngineSettings> pair : enginesToUpdate.entrySet()) { + if (pair.getValue() == null) { + engines.remove(pair.getKey()); + } else { + engines.put(pair.getKey(), pair.getValue().toJSONObject()); + } + } + + enginesToUpdate.clear(); + } + + /** + * Synchronously upload an updated meta/global. + * <p> + * All problems are logged and ignored. + */ + public void uploadUpdatedMetaGlobal() { + updateMetaGlobalInPlace(); + + Logger.debug(LOG_TAG, "Uploading updated meta/global record."); + final Object monitor = new Object(); + + Runnable doUpload = new Runnable() { + @Override + public void run() { + config.metaGlobal.upload(new MetaGlobalDelegate() { + @Override + public void handleSuccess(MetaGlobal global, SyncStorageResponse response) { + Logger.info(LOG_TAG, "Successfully uploaded updated meta/global record."); + // Engine changes are stored as diffs, so update enabled engines in config to match uploaded meta/global. + config.enabledEngineNames = config.metaGlobal.getEnabledEngineNames(); + // Clear userSelectedEngines because they are updated in config and meta/global. + config.userSelectedEngines = null; + + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void handleMissing(MetaGlobal global, SyncStorageResponse response) { + Logger.warn(LOG_TAG, "Got 404 missing uploading updated meta/global record; shouldn't happen. Ignoring."); + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void handleFailure(SyncStorageResponse response) { + Logger.warn(LOG_TAG, "Failed to upload updated meta/global record; ignoring."); + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void handleError(Exception e) { + Logger.warn(LOG_TAG, "Got exception trying to upload updated meta/global record; ignoring.", e); + synchronized (monitor) { + monitor.notify(); + } + } + }); + } + }; + + final Thread upload = new Thread(doUpload); + synchronized (monitor) { + try { + upload.start(); + monitor.wait(); + Logger.debug(LOG_TAG, "Uploaded updated meta/global record."); + } catch (InterruptedException e) { + Logger.error(LOG_TAG, "Uploading updated meta/global interrupted; continuing."); + } + } + } + + + public void abort(Exception e, String reason) { + Logger.warn(LOG_TAG, "Aborting sync: " + reason, e); + cleanUp(); + long existingBackoff = largestBackoffObserved.get(); + if (existingBackoff > 0) { + callback.requestBackoff(existingBackoff); + } + if (!(e instanceof HTTPFailureException)) { + // e is null, or we aborted for a non-HTTP reason; okay to upload new meta/global record. + if (this.hasUpdatedMetaGlobal()) { + this.uploadUpdatedMetaGlobal(); // Only logs errors; does not call abort. + } + } + this.callback.handleError(this, e); + } + + public void handleHTTPError(SyncStorageResponse response, String reason) { + // TODO: handling of 50x (backoff), 401 (node reassignment or auth error). + // Fall back to aborting. + Logger.warn(LOG_TAG, "Aborting sync due to HTTP " + response.getStatusCode()); + this.interpretHTTPFailure(response.httpResponse()); + this.abort(new HTTPFailureException(response), reason); + } + + /** + * Perform appropriate backoff etc. extraction. + */ + public void interpretHTTPFailure(HttpResponse response) { + // TODO: handle permanent rejection. + long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds(); + if (responseBackoff > 0) { + callback.requestBackoff(responseBackoff); + } + + if (response.getStatusLine() != null) { + final int statusCode = response.getStatusLine().getStatusCode(); + switch(statusCode) { + + case 400: + SyncStorageResponse storageResponse = new SyncStorageResponse(response); + this.interpretHTTPBadRequestBody(storageResponse); + break; + + case 401: + /* + * Alert our callback we have a 401 on a cluster URL. This GlobalSession + * will fail, but the next one will fetch a new cluster URL and will + * distinguish between "node reassignment" and "user password changed". + */ + callback.informUnauthorizedResponse(this, config.getClusterURL()); + break; + } + } + } + + protected void interpretHTTPBadRequestBody(final SyncStorageResponse storageResponse) { + try { + final String body = storageResponse.body(); + if (body == null) { + return; + } + if (SyncStorageResponse.RESPONSE_CLIENT_UPGRADE_REQUIRED.equals(body)) { + callback.informUpgradeRequiredResponse(this); + return; + } + } catch (Exception e) { + Logger.warn(LOG_TAG, "Exception parsing HTTP 400 body.", e); + } + } + + public void fetchInfoCollections(JSONRecordFetchDelegate callback) throws URISyntaxException { + final JSONRecordFetcher fetcher = new JSONRecordFetcher(config.infoCollectionsURL(), getAuthHeaderProvider()); + fetcher.fetch(callback); + } + + /** + * Upload new crypto/keys. + * + * @param keys + * new keys. + * @param keyUploadDelegate + * a delegate. + */ + public void uploadKeys(final CollectionKeys keys, + final KeyUploadDelegate keyUploadDelegate) { + SyncStorageRecordRequest request; + try { + request = new SyncStorageRecordRequest(this.config.keysURI()); + } catch (URISyntaxException e) { + keyUploadDelegate.onKeyUploadFailed(e); + return; + } + + request.delegate = new SyncStorageRequestDelegate() { + + @Override + public String ifUnmodifiedSince() { + return null; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + Logger.debug(LOG_TAG, "Keys uploaded."); + BaseResource.consumeEntity(response); // We don't need the response at all. + keyUploadDelegate.onKeysUploaded(); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + Logger.debug(LOG_TAG, "Failed to upload keys."); + GlobalSession.this.interpretHTTPFailure(response.httpResponse()); + BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response. + keyUploadDelegate.onKeyUploadFailed(new HTTPFailureException(response)); + } + + @Override + public void handleRequestError(Exception ex) { + Logger.warn(LOG_TAG, "Got exception trying to upload keys", ex); + keyUploadDelegate.onKeyUploadFailed(ex); + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return GlobalSession.this.getAuthHeaderProvider(); + } + }; + + // Convert keys to an encrypted crypto record. + CryptoRecord keysRecord; + try { + keysRecord = keys.asCryptoRecord(); + keysRecord.setKeyBundle(config.syncKeyBundle); + keysRecord.encrypt(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception trying creating crypto record from keys", e); + keyUploadDelegate.onKeyUploadFailed(e); + return; + } + + request.put(keysRecord); + } + + /* + * meta/global callbacks. + */ + public void processMetaGlobal(MetaGlobal global) { + config.metaGlobal = global; + + Long storageVersion = global.getStorageVersion(); + if (storageVersion == null) { + Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote storage version."); + freshStart(); + return; + } + if (storageVersion < STORAGE_VERSION) { + Logger.warn(LOG_TAG, "Outdated server: reported " + + "remote storage version " + storageVersion + " < " + + "local storage version " + STORAGE_VERSION); + freshStart(); + return; + } + if (storageVersion > STORAGE_VERSION) { + Logger.warn(LOG_TAG, "Outdated client: reported " + + "remote storage version " + storageVersion + " > " + + "local storage version " + STORAGE_VERSION); + requiresUpgrade(); + return; + } + String remoteSyncID = global.getSyncID(); + if (remoteSyncID == null) { + Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote syncID."); + freshStart(); + return; + } + String localSyncID = config.syncID; + if (!remoteSyncID.equals(localSyncID)) { + Logger.warn(LOG_TAG, "Remote syncID different from local syncID: resetting client and assuming remote syncID."); + resetAllStages(); + config.purgeCryptoKeys(); + config.syncID = remoteSyncID; + } + // Compare lastModified timestamps for remote/local engine selection times. + Logger.debug(LOG_TAG, "Comparing local engine selection timestamp [" + config.userSelectedEnginesTimestamp + "] to server meta/global timestamp [" + config.persistedMetaGlobal().lastModified() + "]."); + if (config.userSelectedEnginesTimestamp < config.persistedMetaGlobal().lastModified()) { + // Remote has later meta/global timestamp. Don't upload engine changes. + config.userSelectedEngines = null; + } + // Persist enabled engine names. + config.enabledEngineNames = global.getEnabledEngineNames(); + if (config.enabledEngineNames == null) { + Logger.warn(LOG_TAG, "meta/global reported no enabled engine names!"); + } else { + if (Logger.shouldLogVerbose(LOG_TAG)) { + Logger.trace(LOG_TAG, "Persisting enabled engine names '" + + Utils.toCommaSeparatedString(config.enabledEngineNames) + "' from meta/global."); + } + } + + // Persist declined. + // Our declined engines at any point are: + // Whatever they were remotely, plus whatever they were locally, less any + // engines that were just enabled locally or remotely. + // If remote just 'won', our recently enabled list just got cleared. + final HashSet<String> allDeclined = new HashSet<String>(); + + final Set<String> newRemoteDeclined = global.getDeclinedEngineNames(); + final Set<String> oldLocalDeclined = config.declinedEngineNames; + + allDeclined.addAll(newRemoteDeclined); + allDeclined.addAll(oldLocalDeclined); + + if (config.userSelectedEngines != null) { + for (Entry<String, Boolean> selection : config.userSelectedEngines.entrySet()) { + if (selection.getValue()) { + allDeclined.remove(selection.getKey()); + } + } + } + + config.declinedEngineNames = allDeclined; + if (config.declinedEngineNames.isEmpty()) { + Logger.debug(LOG_TAG, "meta/global reported no declined engine names, and we have none declined locally."); + } else { + if (Logger.shouldLogVerbose(LOG_TAG)) { + Logger.trace(LOG_TAG, "Persisting declined engine names '" + + Utils.toCommaSeparatedString(config.declinedEngineNames) + "' from meta/global."); + } + } + + config.persistToPrefs(); + advance(); + } + + public void processMissingMetaGlobal(MetaGlobal global) { + freshStart(); + } + + /** + * Do a fresh start then quietly finish the sync, starting another. + */ + public void freshStart() { + final GlobalSession globalSession = this; + freshStart(this, new FreshStartDelegate() { + + @Override + public void onFreshStartFailed(Exception e) { + globalSession.abort(e, "Fresh start failed."); + } + + @Override + public void onFreshStart() { + try { + Logger.warn(LOG_TAG, "Fresh start succeeded; restarting global session."); + globalSession.config.persistToPrefs(); + globalSession.restart(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception when restarting sync after freshStart.", e); + globalSession.abort(e, "Got exception after freshStart."); + } + } + }); + } + + /** + * Clean the server, aborting the current sync. + * <p> + * <ol> + * <li>Wipe the server storage.</li> + * <li>Reset all stages and purge cached state: (meta/global and crypto/keys records).</li> + * <li>Upload fresh meta/global record.</li> + * <li>Upload fresh crypto/keys record.</li> + * <li>Restart the sync entirely in order to re-download meta/global and crypto/keys record.</li> + * </ol> + * @param session the current session. + * @param freshStartDelegate delegate to notify on fresh start or failure. + */ + protected static void freshStart(final GlobalSession session, final FreshStartDelegate freshStartDelegate) { + Logger.debug(LOG_TAG, "Fresh starting."); + + final MetaGlobal mg = session.generateNewMetaGlobal(); + + session.wipeServer(session.getAuthHeaderProvider(), new WipeServerDelegate() { + + @Override + public void onWiped(long timestamp) { + Logger.debug(LOG_TAG, "Successfully wiped server. Resetting all stages and purging cached meta/global and crypto/keys records."); + + session.resetAllStages(); + session.config.purgeMetaGlobal(); + session.config.purgeCryptoKeys(); + session.config.persistToPrefs(); + + Logger.info(LOG_TAG, "Uploading new meta/global with sync ID " + mg.syncID + "."); + + // It would be good to set the X-If-Unmodified-Since header to `timestamp` + // for this PUT to ensure at least some level of transactionality. + // Unfortunately, the servers don't support it after a wipe right now + // (bug 693893), so we're going to defer this until bug 692700. + mg.upload(new MetaGlobalDelegate() { + @Override + public void handleSuccess(MetaGlobal uploadedGlobal, SyncStorageResponse uploadResponse) { + Logger.info(LOG_TAG, "Uploaded new meta/global with sync ID " + uploadedGlobal.syncID + "."); + + // Generate new keys. + CollectionKeys keys = null; + try { + keys = session.generateNewCryptoKeys(); + } catch (CryptoException e) { + Logger.warn(LOG_TAG, "Got exception generating new keys; failing fresh start.", e); + freshStartDelegate.onFreshStartFailed(e); + } + if (keys == null) { + Logger.warn(LOG_TAG, "Got null keys from generateNewKeys; failing fresh start."); + freshStartDelegate.onFreshStartFailed(null); + } + + // Upload new keys. + Logger.info(LOG_TAG, "Uploading new crypto/keys."); + session.uploadKeys(keys, new KeyUploadDelegate() { + @Override + public void onKeysUploaded() { + Logger.info(LOG_TAG, "Uploaded new crypto/keys."); + freshStartDelegate.onFreshStart(); + } + + @Override + public void onKeyUploadFailed(Exception e) { + Logger.warn(LOG_TAG, "Got exception uploading new keys.", e); + freshStartDelegate.onFreshStartFailed(e); + } + }); + } + + @Override + public void handleMissing(MetaGlobal global, SyncStorageResponse response) { + // Shouldn't happen on upload. + Logger.warn(LOG_TAG, "Got 'missing' response uploading new meta/global."); + freshStartDelegate.onFreshStartFailed(new Exception("meta/global missing while uploading.")); + } + + @Override + public void handleFailure(SyncStorageResponse response) { + Logger.warn(LOG_TAG, "Got failure " + response.getStatusCode() + " uploading new meta/global."); + session.interpretHTTPFailure(response.httpResponse()); + freshStartDelegate.onFreshStartFailed(new HTTPFailureException(response)); + } + + @Override + public void handleError(Exception e) { + Logger.warn(LOG_TAG, "Got error uploading new meta/global.", e); + freshStartDelegate.onFreshStartFailed(e); + } + }); + } + + @Override + public void onWipeFailed(Exception e) { + Logger.warn(LOG_TAG, "Wipe failed."); + freshStartDelegate.onFreshStartFailed(e); + } + }); + } + + // Note that we do not yet implement wipeRemote: it's only necessary for + // first sync options. + // -- reset local stages, wipe server for each stage *except* clients + // (stages only, not whole server!), send wipeEngine commands to each client. + // + // Similarly for startOver (because we don't receive that notification). + // -- remove client data from server, reset local stages, clear keys, reset + // backoff, clear all prefs, discard credentials. + // + // Change passphrase: wipe entire server, reset client to force upload, sync. + // + // When an engine is disabled: wipe its collections on the server, reupload + // meta/global. + // + // On syncing each stage: if server has engine version 0 or old, wipe server, + // reset client to prompt reupload. + // If sync ID mismatch: take that syncID and reset client. + + protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) { + SyncStorageRequest request; + final GlobalSession self = this; + + try { + request = new SyncStorageRequest(config.storageURL()); + } catch (URISyntaxException ex) { + Logger.warn(LOG_TAG, "Invalid URI in wipeServer."); + wipeDelegate.onWipeFailed(ex); + return; + } + + request.delegate = new SyncStorageRequestDelegate() { + + @Override + public String ifUnmodifiedSince() { + return null; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + BaseResource.consumeEntity(response); + wipeDelegate.onWiped(response.normalizedWeaveTimestamp()); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + Logger.warn(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer."); + // Process HTTP failures here to pick up backoffs, etc. + self.interpretHTTPFailure(response.httpResponse()); + BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response. + wipeDelegate.onWipeFailed(new HTTPFailureException(response)); + } + + @Override + public void handleRequestError(Exception ex) { + Logger.warn(LOG_TAG, "Got exception in wipeServer.", ex); + wipeDelegate.onWipeFailed(ex); + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return GlobalSession.this.getAuthHeaderProvider(); + } + }; + request.delete(); + } + + public void wipeAllStages() { + Logger.info(LOG_TAG, "Wiping all stages."); + // Includes "clients". + this.wipeStagesByEnum(Stage.getNamedStages()); + } + + public void wipeStages(Collection<GlobalSyncStage> stages) { + for (GlobalSyncStage stage : stages) { + try { + Logger.info(LOG_TAG, "Wiping " + stage); + stage.wipeLocal(this); + } catch (Exception e) { + Logger.error(LOG_TAG, "Ignoring wipe failure for stage " + stage, e); + } + } + } + + public void wipeStagesByEnum(Collection<Stage> stages) { + wipeStages(this.getSyncStagesByEnum(stages)); + } + + public void wipeStagesByName(Collection<String> names) { + wipeStages(this.getSyncStagesByName(names)); + } + + public void resetAllStages() { + Logger.info(LOG_TAG, "Resetting all stages."); + // Includes "clients". + this.resetStagesByEnum(Stage.getNamedStages()); + } + + public void resetStages(Collection<GlobalSyncStage> stages) { + for (GlobalSyncStage stage : stages) { + try { + Logger.info(LOG_TAG, "Resetting " + stage); + stage.resetLocal(this); + } catch (Exception e) { + Logger.error(LOG_TAG, "Ignoring reset failure for stage " + stage, e); + } + } + } + + public void resetStagesByEnum(Collection<Stage> stages) { + resetStages(this.getSyncStagesByEnum(stages)); + } + + public void resetStagesByName(Collection<String> names) { + resetStages(this.getSyncStagesByName(names)); + } + + /** + * Engines to explicitly mark as declined in a fresh meta/global record. + * <p> + * Returns an empty array if the user hasn't elected to customize data types, + * or an array of engines that the user un-checked during customization. + * <p> + * Engines that Android Sync doesn't recognize are <b>not</b> included in + * the returned array. + * + * @return a new JSONArray of engine names. + */ + @SuppressWarnings("unchecked") + protected JSONArray declinedEngineNames() { + final JSONArray declined = new JSONArray(); + for (String engine : config.declinedEngineNames) { + declined.add(engine); + }; + + return declined; + } + + /** + * Engines to include in a fresh meta/global record. + * <p> + * Returns either the persisted engine names (perhaps we have been node + * re-assigned and are initializing a clean server: we want to upload the + * persisted engine names so that we don't accidentally disable engines that + * Android Sync doesn't recognize), or the set of engines names that Android + * Sync implements. + * + * @return set of engine names. + */ + protected Set<String> enabledEngineNames() { + if (config.enabledEngineNames != null) { + return config.enabledEngineNames; + } + + // These are the default set of engine names. + Set<String> validEngineNames = SyncConfiguration.validEngineNames(); + + // If the user hasn't set any selected engines, that's okay -- default to + // everything. + if (config.userSelectedEngines == null) { + return validEngineNames; + } + + // userSelectedEngines has keys that are engine names, and boolean values + // corresponding to whether the user asked for the engine to sync or not. If + // an engine is not present, that means the user didn't change its sync + // setting. Since we default to everything on, that means the user didn't + // turn it off; therefore, it's included in the set of engines to sync. + Set<String> validAndSelectedEngineNames = new HashSet<String>(); + for (String engineName : validEngineNames) { + if (config.userSelectedEngines.containsKey(engineName) && + !config.userSelectedEngines.get(engineName)) { + continue; + } + validAndSelectedEngineNames.add(engineName); + } + return validAndSelectedEngineNames; + } + + /** + * Generate fresh crypto/keys collection. + * @return crypto/keys collection. + * @throws CryptoException + */ + @SuppressWarnings("static-method") + public CollectionKeys generateNewCryptoKeys() throws CryptoException { + return CollectionKeys.generateCollectionKeys(); + } + + /** + * Generate a fresh meta/global record. + * @return meta/global record. + */ + public MetaGlobal generateNewMetaGlobal() { + final String newSyncID = Utils.generateGuid(); + final String metaURL = this.config.metaURL(); + + ExtendedJSONObject engines = new ExtendedJSONObject(); + for (String engineName : enabledEngineNames()) { + EngineSettings engineSettings = null; + try { + GlobalSyncStage globalStage = this.getSyncStageByName(engineName); + Integer version = globalStage.getStorageVersion(); + if (version == null) { + continue; // Don't want this stage to be included in meta/global. + } + engineSettings = new EngineSettings(Utils.generateGuid(), version); + } catch (NoSuchStageException e) { + // No trouble; Android Sync might not recognize this engine yet. + // By default, version 0. Other clients will see the 0 version and reset/wipe accordingly. + engineSettings = new EngineSettings(Utils.generateGuid(), 0); + } + engines.put(engineName, engineSettings.toJSONObject()); + } + + MetaGlobal metaGlobal = new MetaGlobal(metaURL, this.getAuthHeaderProvider()); + metaGlobal.setSyncID(newSyncID); + metaGlobal.setStorageVersion(STORAGE_VERSION); + metaGlobal.setEngines(engines); + + // We assume that the config's declined engines have been updated + // according to the user's selections. + metaGlobal.setDeclinedEngineNames(this.declinedEngineNames()); + + return metaGlobal; + } + + /** + * Suggest that your Sync client needs to be upgraded to work + * with this server. + */ + public void requiresUpgrade() { + Logger.info(LOG_TAG, "Client outdated storage version; requires update."); + // TODO: notify UI. + this.abort(null, "Requires upgrade"); + } + + /** + * If meta/global is missing or malformed, throws a MetaGlobalException. + * Otherwise, returns true if there is an entry for this engine in the + * meta/global "engines" object. + * <p> + * This is a global/permanent setting, not a local/temporary setting. For the + * latter, see {@link GlobalSession#isEngineLocallyEnabled(String)}. + * + * @param engineName the name to check (e.g., "bookmarks"). + * @param engineSettings + * if non-null, verify that the server engine settings are congruent + * with this, throwing the appropriate MetaGlobalException if not. + * @return + * true if the engine with the provided name is present in the + * meta/global "engines" object, and verification passed. + * + * @throws MetaGlobalException + */ + public boolean isEngineRemotelyEnabled(String engineName, EngineSettings engineSettings) throws MetaGlobalException { + if (this.config.metaGlobal == null) { + throw new MetaGlobalNotSetException(); + } + + // This should not occur. + if (this.config.enabledEngineNames == null) { + Logger.error(LOG_TAG, "No enabled engines in config. Giving up."); + throw new MetaGlobalMissingEnginesException(); + } + + if (!(this.config.enabledEngineNames.contains(engineName))) { + Logger.debug(LOG_TAG, "Engine " + engineName + " not enabled: no meta/global entry."); + return false; + } + + // If we have a meta/global, check that it's safe for us to sync. + // (If we don't, we'll create one later, which is why we return `true` above.) + if (engineSettings != null) { + // Throws if there's a problem. + this.config.metaGlobal.verifyEngineSettings(engineName, engineSettings); + } + + return true; + } + + + /** + * Return true if the named stage should be synced this session. + * <p> + * This is a local/temporary setting, in contrast to the meta/global record, + * which is a global/permanent setting. For the latter, see + * {@link GlobalSession#isEngineRemotelyEnabled(String, EngineSettings)}. + * + * @param stageName + * to query. + * @return true if named stage is enabled for this sync. + */ + public boolean isEngineLocallyEnabled(String stageName) { + if (config.stagesToSync == null) { + return true; + } + return config.stagesToSync.contains(stageName); + } + + public ClientsDataDelegate getClientsDelegate() { + return this.clientsDelegate; + } + + /** + * The longest backoff observed to date; -1 means no backoff observed. + */ + protected final AtomicLong largestBackoffObserved = new AtomicLong(-1); + + /** + * Reset any observed backoff and start observing HTTP responses for backoff + * requests. + */ + protected void installAsHttpResponseObserver() { + Logger.debug(LOG_TAG, "Adding " + this + " as a BaseResource HttpResponseObserver."); + BaseResource.addHttpResponseObserver(this); + largestBackoffObserved.set(-1); + } + + /** + * Stop observing HttpResponses for backoff requests. + */ + protected void uninstallAsHttpResponseObserver() { + Logger.debug(LOG_TAG, "Removing " + this + " as a BaseResource HttpResponseObserver."); + BaseResource.removeHttpResponseObserver(this); + } + + /** + * Observe all HTTP response for backoff requests on all status codes, not just errors. + */ + @Override + public void observeHttpResponse(HttpUriRequest request, HttpResponse response) { + // Ignore non-Sync storage requests. + final URI clusterURL = config.getClusterURL(); + if (clusterURL != null && !clusterURL.getHost().equals(request.getURI().getHost())) { + // It's possible to see requests without a clusterURL (in particular, + // during testing); allow some extra backoffs in this case. + return; + } + + long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds(); // TODO: don't allocate object? + if (responseBackoff <= 0) { + return; + } + + Logger.debug(LOG_TAG, "Observed " + responseBackoff + " millisecond backoff request."); + while (true) { + long existingBackoff = largestBackoffObserved.get(); + if (existingBackoff >= responseBackoff) { + return; + } + if (largestBackoffObserved.compareAndSet(existingBackoff, responseBackoff)) { + return; + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java new file mode 100644 index 000000000..69bba8841 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java @@ -0,0 +1,47 @@ +/* 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 org.mozilla.gecko.sync.net.SyncStorageResponse; + +import android.content.SyncResult; + +public class HTTPFailureException extends SyncException { + private static final long serialVersionUID = -5415864029780770619L; + public SyncStorageResponse response; + + public HTTPFailureException(SyncStorageResponse response) { + this.response = response; + } + + @Override + public String toString() { + String errorMessage; + try { + errorMessage = this.response.getErrorMessage(); + } catch (Exception e) { + // Oh well. + errorMessage = "[unknown error message]"; + } + return "<HTTPFailureException " + this.response.getStatusCode() + + " :: (" + errorMessage + ")>"; + } + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + switch (response.getStatusCode()) { + case 401: + // Node reassignment 401s get handled internally. + syncResult.stats.numAuthExceptions++; + return; + case 500: + case 501: + case 503: + // TODO: backoff. + syncResult.stats.numIoExceptions++; + return; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java new file mode 100644 index 000000000..374fa5cf5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java @@ -0,0 +1,103 @@ +/* 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.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.mozilla.gecko.background.common.log.Logger; + +/** + * Fetches the timestamp information in <code>info/collections</code> on the + * Sync server. Provides access to those timestamps, along with logic to check + * for whether a collection requires an update. + */ +public class InfoCollections { + private static final String LOG_TAG = "InfoCollections"; + + /** + * Fields fetched from the server, or <code>null</code> if not yet fetched. + * <p> + * Rather than storing decimal/double timestamps, as provided by the server, + * we convert immediately to milliseconds since epoch. + */ + final Map<String, Long> timestamps; + + public InfoCollections() { + this(new ExtendedJSONObject()); + } + + public InfoCollections(final ExtendedJSONObject record) { + Logger.debug(LOG_TAG, "info/collections is " + record.toJSONString()); + HashMap<String, Long> map = new HashMap<String, Long>(); + + for (Entry<String, Object> entry : record.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + + // These objects are most likely going to be Doubles. Regardless, we + // want to get them in a more sane time format. + if (value instanceof Double) { + map.put(key, Utils.decimalSecondsToMilliseconds((Double) value)); + continue; + } + if (value instanceof Long) { + map.put(key, Utils.decimalSecondsToMilliseconds((Long) value)); + continue; + } + if (value instanceof Integer) { + map.put(key, Utils.decimalSecondsToMilliseconds((Integer) value)); + continue; + } + Logger.warn(LOG_TAG, "Skipping info/collections entry for " + key); + } + + this.timestamps = Collections.unmodifiableMap(map); + } + + /** + * Return the timestamp for the given collection, or null if the timestamps + * have not been fetched or the given collection does not have a timestamp. + * + * @param collection + * The collection to inspect. + * @return the timestamp in milliseconds since epoch. + */ + public Long getTimestamp(String collection) { + if (timestamps == null) { + return null; + } + return timestamps.get(collection); + } + + /** + * Test if a given collection needs to be updated. + * + * @param collection + * The collection to test. + * @param lastModified + * Timestamp when local record was last modified. + */ + public boolean updateNeeded(String collection, long lastModified) { + Logger.trace(LOG_TAG, "Testing " + collection + " for updateNeeded. Local last modified is " + lastModified + "."); + + // No local record of modification time? Need an update. + if (lastModified <= 0) { + return true; + } + + // No meta/global on the server? We need an update. The server fetch will fail and + // then we will upload a fresh meta/global. + Long serverLastModified = getTimestamp(collection); + if (serverLastModified == null) { + return true; + } + + // Otherwise, we need an update if our modification time is stale. + return serverLastModified > lastModified; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java new file mode 100644 index 000000000..eb2428433 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java @@ -0,0 +1,93 @@ +/* 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 android.util.Log; + +import org.mozilla.gecko.background.common.log.Logger; + +/** + * Wraps and provides access to configuration data returned from info/configuration. + * Docs: https://docs.services.mozilla.com/storage/apis-1.5.html#general-info + * + * - <bold>max_request_bytes</bold>: the maximum size in bytes of the overall + * HTTP request body that will be accepted by the server. + * + * - <bold>max_post_records</bold>: the maximum number of records that can be + * uploaded to a collection in a single POST request. + * + * - <bold>max_post_bytes</bold>: the maximum combined size in bytes of the + * record payloads that can be uploaded to a collection in a single + * POST request. + * + * - <bold>max_total_records</bold>: the maximum number of records that can be + * uploaded to a collection as part of a batched upload. + * + * - <bold>max_total_bytes</bold>: the maximum combined size in bytes of the + * record payloads that can be uploaded to a collection as part of + * a batched upload. + */ +public class InfoConfiguration { + private static final String LOG_TAG = "InfoConfiguration"; + + public static final String MAX_REQUEST_BYTES = "max_request_bytes"; + public static final String MAX_POST_RECORDS = "max_post_records"; + public static final String MAX_POST_BYTES = "max_post_bytes"; + public static final String MAX_TOTAL_RECORDS = "max_total_records"; + public static final String MAX_TOTAL_BYTES = "max_total_bytes"; + + private static final long DEFAULT_MAX_REQUEST_BYTES = 1048576; + private static final long DEFAULT_MAX_POST_RECORDS = 100; + private static final long DEFAULT_MAX_POST_BYTES = 1048576; + private static final long DEFAULT_MAX_TOTAL_RECORDS = 10000; + private static final long DEFAULT_MAX_TOTAL_BYTES = 104857600; + + // While int's upper range is (2^31-1), which in bytes is equivalent to 2.147 GB, let's be optimistic + // about the future and use long here, so that this code works if the server decides its clients are + // all on fiber and have congress-library sized bookmark collections. + // Record counts are long for the sake of simplicity. + public final long maxRequestBytes; + public final long maxPostRecords; + public final long maxPostBytes; + public final long maxTotalRecords; + public final long maxTotalBytes; + + public InfoConfiguration() { + Logger.debug(LOG_TAG, "info/configuration is unavailable, using defaults"); + + maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES; + maxPostRecords = DEFAULT_MAX_POST_RECORDS; + maxPostBytes = DEFAULT_MAX_POST_BYTES; + maxTotalRecords = DEFAULT_MAX_TOTAL_RECORDS; + maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES; + } + + public InfoConfiguration(final ExtendedJSONObject record) { + Logger.debug(LOG_TAG, "info/configuration is " + record.toJSONString()); + + maxRequestBytes = getValueFromRecord(record, MAX_REQUEST_BYTES, DEFAULT_MAX_REQUEST_BYTES); + maxPostRecords = getValueFromRecord(record, MAX_POST_RECORDS, DEFAULT_MAX_POST_RECORDS); + maxPostBytes = getValueFromRecord(record, MAX_POST_BYTES, DEFAULT_MAX_POST_BYTES); + maxTotalRecords = getValueFromRecord(record, MAX_TOTAL_RECORDS, DEFAULT_MAX_TOTAL_RECORDS); + maxTotalBytes = getValueFromRecord(record, MAX_TOTAL_BYTES, DEFAULT_MAX_TOTAL_BYTES); + } + + private static Long getValueFromRecord(ExtendedJSONObject record, String key, long defaultValue) { + if (!record.containsKey(key)) { + return defaultValue; + } + + try { + Long val = record.getLong(key); + if (val == null) { + return defaultValue; + } + return val; + } catch (NumberFormatException e) { + Log.w(LOG_TAG, "Could not parse key " + key + " from record: " + record, e); + return defaultValue; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java new file mode 100644 index 000000000..832e97d10 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java @@ -0,0 +1,67 @@ +/* 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.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.mozilla.gecko.background.common.log.Logger; + +public class InfoCounts { + static final String LOG_TAG = "InfoCounts"; + + /** + * Counts fetched from the server, or <code>null</code> if not yet fetched. + */ + private Map<String, Integer> counts = null; + + @SuppressWarnings("unchecked") + public InfoCounts(final ExtendedJSONObject record) { + Logger.debug(LOG_TAG, "info/collection_counts is " + record.toJSONString()); + HashMap<String, Integer> map = new HashMap<String, Integer>(); + + Set<Entry<String, Object>> entrySet = record.object.entrySet(); + + String key; + Object value; + + for (Entry<String, Object> entry : entrySet) { + key = entry.getKey(); + value = entry.getValue(); + + if (value instanceof Integer) { + map.put(key, (Integer) value); + continue; + } + + if (value instanceof Long) { + map.put(key, ((Long) value).intValue()); + continue; + } + + Logger.warn(LOG_TAG, "Skipping info/collection_counts entry for " + key); + } + + this.counts = Collections.unmodifiableMap(map); + } + + /** + * Return the server count for the given collection, or null if the counts + * have not been fetched or the given collection does not have a count. + * + * @param collection + * The collection to inspect. + * @return the number of elements in the named collection. + */ + public Integer getCount(String collection) { + if (counts == null) { + return null; + } + return counts.get(collection); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java new file mode 100644 index 000000000..982b5b026 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java @@ -0,0 +1,145 @@ +/* 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.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.SyncStorageRecordRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +/** + * An object which fetches a chunk of JSON from a URI, using certain credentials, + * and informs its delegate of the result. + */ +public class JSONRecordFetcher { + private static final long DEFAULT_AWAIT_TIMEOUT_MSEC = 2 * 60 * 1000; // Two minutes. + private static final String LOG_TAG = "JSONRecordFetcher"; + + protected final AuthHeaderProvider authHeaderProvider; + protected final String uri; + protected JSONRecordFetchDelegate delegate; + + public JSONRecordFetcher(final String uri, final AuthHeaderProvider authHeaderProvider) { + if (uri == null) { + throw new IllegalArgumentException("uri must not be null"); + } + this.uri = uri; + this.authHeaderProvider = authHeaderProvider; + } + + protected String getURI() { + return this.uri; + } + + private class JSONFetchHandler implements SyncStorageRequestDelegate { + + // SyncStorageRequestDelegate methods for fetching. + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return authHeaderProvider; + } + + @Override + public String ifUnmodifiedSince() { + return null; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + if (response.wasSuccessful()) { + try { + delegate.handleSuccess(response.jsonObjectBody()); + } catch (Exception e) { + handleRequestError(e); + } + return; + } + handleRequestFailure(response); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + delegate.handleFailure(response); + } + + @Override + public void handleRequestError(Exception ex) { + delegate.handleError(ex); + } + } + + public void fetch(final JSONRecordFetchDelegate delegate) { + this.delegate = delegate; + try { + final SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.getURI()); + r.delegate = new JSONFetchHandler(); + r.get(); + } catch (Exception e) { + delegate.handleError(e); + } + } + + private class LatchedJSONRecordFetchDelegate implements JSONRecordFetchDelegate { + public ExtendedJSONObject body = null; + public Exception exception = null; + private final CountDownLatch latch; + + public LatchedJSONRecordFetchDelegate(CountDownLatch latch) { + this.latch = latch; + } + + @Override + public void handleFailure(SyncStorageResponse response) { + this.exception = new HTTPFailureException(response); + latch.countDown(); + } + + @Override + public void handleError(Exception e) { + this.exception = e; + latch.countDown(); + } + + @Override + public void handleSuccess(ExtendedJSONObject body) { + this.body = body; + latch.countDown(); + } + } + + /** + * Fetch the info record, blocking until it returns. + * @return the info record. + */ + public ExtendedJSONObject fetchBlocking() throws HTTPFailureException, Exception { + CountDownLatch latch = new CountDownLatch(1); + LatchedJSONRecordFetchDelegate delegate = new LatchedJSONRecordFetchDelegate(latch); + this.delegate = delegate; + this.fetch(delegate); + + // Sanity wait: the resource itself will time out and throw after two + // minutes, so we just want to avoid coding errors causing us to block + // endlessly. + if (!latch.await(DEFAULT_AWAIT_TIMEOUT_MSEC, TimeUnit.MILLISECONDS)) { + Logger.warn(LOG_TAG, "Interrupted fetching info record."); + throw new InterruptedException("info fetch timed out."); + } + + if (delegate.body != null) { + return delegate.body; + } + + if (delegate.exception != null) { + throw delegate.exception; + } + + throw new Exception("Unknown error."); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java new file mode 100644 index 000000000..4a2be2a9b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java @@ -0,0 +1,11 @@ +/* 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 org.mozilla.gecko.sync.crypto.KeyBundle; + +public interface KeyBundleProvider { + public abstract KeyBundle keyBundle(); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java new file mode 100644 index 000000000..a90c0fee8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java @@ -0,0 +1,372 @@ +/* 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.io.IOException; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.MetaGlobalException.MetaGlobalMalformedSyncIDException; +import org.mozilla.gecko.sync.MetaGlobalException.MetaGlobalMalformedVersionException; +import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.SyncStorageRecordRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +public class MetaGlobal implements SyncStorageRequestDelegate { + private static final String LOG_TAG = "MetaGlobal"; + protected String metaURL; + + // Fields. + protected ExtendedJSONObject engines; + protected JSONArray declined; + protected Long storageVersion; + protected String syncID; + + // Lookup tables. + protected Map<String, String> syncIDs; + protected Map<String, Integer> versions; + protected Map<String, MetaGlobalException> exceptions; + + // Temporary location to store our callback. + private MetaGlobalDelegate callback; + + // A little hack so we can use the same delegate implementation for upload and download. + private boolean isUploading; + protected final AuthHeaderProvider authHeaderProvider; + + public MetaGlobal(String metaURL, AuthHeaderProvider authHeaderProvider) { + this.metaURL = metaURL; + this.authHeaderProvider = authHeaderProvider; + } + + public void fetch(MetaGlobalDelegate delegate) { + this.callback = delegate; + try { + this.isUploading = false; + SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.metaURL); + r.delegate = this; + r.get(); + } catch (URISyntaxException e) { + this.callback.handleError(e); + } + } + + public void upload(MetaGlobalDelegate callback) { + try { + this.isUploading = true; + SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.metaURL); + + r.delegate = this; + this.callback = callback; + r.put(this.asCryptoRecord()); + } catch (Exception e) { + callback.handleError(e); + } + } + + protected ExtendedJSONObject asRecordContents() { + ExtendedJSONObject json = new ExtendedJSONObject(); + json.put("storageVersion", storageVersion); + json.put("engines", engines); + json.put("syncID", syncID); + json.put("declined", declined); + return json; + } + + /** + * Return a copy ready for upload. + * @return an unencrypted <code>CryptoRecord</code>. + */ + public CryptoRecord asCryptoRecord() { + ExtendedJSONObject payload = this.asRecordContents(); + CryptoRecord record = new CryptoRecord(payload); + record.collection = "meta"; + record.guid = "global"; + record.deleted = false; + return record; + } + + public void setFromRecord(CryptoRecord record) throws IllegalStateException, IOException, NonObjectJSONException, NonArrayJSONException { + if (record == null) { + throw new IllegalArgumentException("Cannot set meta/global from null record"); + } + Logger.debug(LOG_TAG, "meta/global is " + record.payload.toJSONString()); + this.storageVersion = (Long) record.payload.get("storageVersion"); + this.syncID = (String) record.payload.get("syncID"); + + setEngines(record.payload.getObject("engines")); + + // Accepts null -- declined can be missing. + setDeclinedEngineNames(record.payload.getArray("declined")); + } + + public Long getStorageVersion() { + return this.storageVersion; + } + + public void setStorageVersion(Long version) { + this.storageVersion = version; + } + + public ExtendedJSONObject getEngines() { + return engines; + } + + @SuppressWarnings("unchecked") + public void declineEngine(String engine) { + if (this.declined == null) { + JSONArray replacement = new JSONArray(); + replacement.add(engine); + setDeclinedEngineNames(replacement); + return; + } + + this.declined.add(engine); + } + + @SuppressWarnings("unchecked") + public void declineEngineNames(Collection<String> additional) { + if (this.declined == null) { + JSONArray replacement = new JSONArray(); + replacement.addAll(additional); + setDeclinedEngineNames(replacement); + return; + } + + for (String engine : additional) { + if (!this.declined.contains(engine)) { + this.declined.add(engine); + } + } + } + + public void setDeclinedEngineNames(JSONArray declined) { + if (declined == null) { + this.declined = new JSONArray(); + return; + } + this.declined = declined; + } + + /** + * Return the set of engines that we support (given as an argument) + * but the user hasn't explicitly declined on another device. + * + * Can return the input if the user hasn't declined any engines. + */ + public Set<String> getNonDeclinedEngineNames(Set<String> supported) { + if (this.declined == null || + this.declined.isEmpty()) { + return supported; + } + + final Set<String> result = new HashSet<String>(supported); + result.removeAll(this.declined); + return result; + } + + public void setEngines(ExtendedJSONObject engines) { + if (engines == null) { + engines = new ExtendedJSONObject(); + } + this.engines = engines; + final int count = engines.size(); + versions = new HashMap<String, Integer>(count); + syncIDs = new HashMap<String, String>(count); + exceptions = new HashMap<String, MetaGlobalException>(count); + for (String engineName : engines.keySet()) { + try { + ExtendedJSONObject engineEntry = engines.getObject(engineName); + recordEngineState(engineName, engineEntry); + } catch (NonObjectJSONException e) { + Logger.error(LOG_TAG, "Engine field for " + engineName + " in meta/global is not an object."); + recordEngineState(engineName, new ExtendedJSONObject()); // Doesn't have a version or syncID, for example, so will be server wiped. + } + } + } + + /** + * Take a JSON object corresponding to the 'engines' field for the provided engine name, + * updating {@link #syncIDs} and {@link #versions} accordingly. + * + * If the record is malformed, an entry is added to {@link #exceptions}, to be rethrown + * during validation. + */ + protected void recordEngineState(String engineName, ExtendedJSONObject engineEntry) { + if (engineEntry == null) { + throw new IllegalArgumentException("engineEntry cannot be null."); + } + + // Record syncID first, so that engines with bad versions are recorded. + try { + String syncID = engineEntry.getString("syncID"); + if (syncID == null) { + Logger.warn(LOG_TAG, "No syncID for " + engineName + ". Recording exception."); + exceptions.put(engineName, new MetaGlobalMalformedSyncIDException()); + } + syncIDs.put(engineName, syncID); + } catch (ClassCastException e) { + // Malformed syncID on the server. Wipe the server. + Logger.warn(LOG_TAG, "Malformed syncID " + engineEntry.get("syncID") + + " for " + engineName + ". Recording exception."); + exceptions.put(engineName, new MetaGlobalMalformedSyncIDException()); + } + + try { + Integer version = engineEntry.getIntegerSafely("version"); + Logger.trace(LOG_TAG, "Engine " + engineName + " has server version " + version); + if (version == null || + version == 0) { + // Invalid version. Wipe the server. + Logger.warn(LOG_TAG, "Malformed version " + version + + " for " + engineName + ". Recording exception."); + exceptions.put(engineName, new MetaGlobalMalformedVersionException()); + return; + } + versions.put(engineName, version); + } catch (NumberFormatException e) { + // Invalid version. Wipe the server. + Logger.warn(LOG_TAG, "Malformed version " + engineEntry.get("version") + + " for " + engineName + ". Recording exception."); + exceptions.put(engineName, new MetaGlobalMalformedVersionException()); + return; + } + } + + /** + * Get enabled engine names. + * + * @return a collection of engine names or <code>null</code> if meta/global + * was malformed. + */ + public Set<String> getEnabledEngineNames() { + if (engines == null) { + return null; + } + return new HashSet<String>(engines.keySet()); + } + + @SuppressWarnings("unchecked") + public Set<String> getDeclinedEngineNames() { + if (declined == null) { + return null; + } + return new HashSet<String>(declined); + } + + /** + * Returns if the server settings and local settings match. + * Throws a specific MetaGlobalException if that's not the case. + */ + public void verifyEngineSettings(String engineName, EngineSettings engineSettings) + throws MetaGlobalException { + + // We use syncIDs as our canary. + if (syncIDs == null) { + throw new IllegalStateException("No meta/global record yet processed."); + } + + if (engineSettings == null) { + throw new IllegalArgumentException("engineSettings cannot be null."); + } + + // First, see if we had a parsing problem. + final MetaGlobalException exception = exceptions.get(engineName); + if (exception != null) { + throw exception; + } + + final String syncID = syncIDs.get(engineName); + if (syncID == null) { + // We have checked engineName against enabled engine names before this, so + // we should either have a syncID or an exception for this engine already. + throw new IllegalArgumentException("Unknown engine " + engineName); + } + + // Since we don't have an exception, and we do have a syncID, we should have a version. + final Integer version = versions.get(engineName); + if (version > engineSettings.version) { + // We're out of date. + throw new MetaGlobalException.MetaGlobalStaleClientVersionException(version); + } + + if (!syncID.equals(engineSettings.syncID)) { + // Our syncID is wrong. Reset client and take the server syncID. + throw new MetaGlobalException.MetaGlobalStaleClientSyncIDException(syncID); + } + } + + public String getSyncID() { + return syncID; + } + + public void setSyncID(String syncID) { + this.syncID = syncID; + } + + // SyncStorageRequestDelegate methods for fetching. + public String credentials() { + return null; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return authHeaderProvider; + } + + @Override + public String ifUnmodifiedSince() { + return null; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + if (this.isUploading) { + this.handleUploadSuccess(response); + } else { + this.handleDownloadSuccess(response); + } + } + + private void handleUploadSuccess(SyncStorageResponse response) { + this.callback.handleSuccess(this, response); + } + + private void handleDownloadSuccess(SyncStorageResponse response) { + if (response.wasSuccessful()) { + try { + CryptoRecord record = CryptoRecord.fromJSONRecord(response.jsonObjectBody()); + this.setFromRecord(record); + this.callback.handleSuccess(this, response); + } catch (Exception e) { + this.callback.handleError(e); + } + return; + } + this.callback.handleFailure(response); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + if (response.getStatusCode() == 404) { + this.callback.handleMissing(this, response); + return; + } + this.callback.handleFailure(response); + } + + @Override + public void handleRequestError(Exception e) { + this.callback.handleError(e); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java new file mode 100644 index 000000000..bec531d11 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java @@ -0,0 +1,45 @@ +/* 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; + +public class MetaGlobalException extends SyncException { + private static final long serialVersionUID = -6182315615113508925L; + + public static class MetaGlobalMalformedSyncIDException extends MetaGlobalException { + private static final long serialVersionUID = 1L; + } + + public static class MetaGlobalMalformedVersionException extends MetaGlobalException { + private static final long serialVersionUID = 1L; + } + + public static class MetaGlobalOutdatedVersionException extends MetaGlobalException { + private static final long serialVersionUID = 1L; + } + + public static class MetaGlobalStaleClientVersionException extends MetaGlobalException { + private static final long serialVersionUID = 1L; + public final int serverVersion; + public MetaGlobalStaleClientVersionException(final int version) { + this.serverVersion = version; + } + } + + public static class MetaGlobalStaleClientSyncIDException extends MetaGlobalException { + private static final long serialVersionUID = 1L; + public final String serverSyncID; + public MetaGlobalStaleClientSyncIDException(final String syncID) { + this.serverSyncID = syncID; + } + } + + public static class MetaGlobalEngineStateChangedException extends MetaGlobalException { + private static final long serialVersionUID = 1L; + public final boolean isEnabled; + public MetaGlobalEngineStateChangedException(boolean isEnabled) { + this.isEnabled = isEnabled; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java new file mode 100644 index 000000000..91bfd2f76 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java @@ -0,0 +1,9 @@ +/* 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; + +public class MetaGlobalMissingEnginesException extends MetaGlobalException { + private static final long serialVersionUID = -2662107402622277865L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java new file mode 100644 index 000000000..ef059c71d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java @@ -0,0 +1,9 @@ +/* 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; + +public class MetaGlobalNotSetException extends MetaGlobalException { + private static final long serialVersionUID = 2959032409571832970L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java new file mode 100644 index 000000000..323e355b4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java @@ -0,0 +1,16 @@ +/* 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 android.content.SyncResult; + +public class NoCollectionKeysSetException extends SyncException { + private static final long serialVersionUID = -6185128075412771120L; + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + syncResult.stats.numAuthExceptions++; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java new file mode 100644 index 000000000..a5cd5f0eb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java @@ -0,0 +1,16 @@ +/* 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 android.content.SyncResult; + +public class NodeAuthenticationException extends SyncException { + private static final long serialVersionUID = 8156745873212364352L; + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + syncResult.stats.numAuthExceptions++; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java new file mode 100644 index 000000000..554645b11 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java @@ -0,0 +1,17 @@ +/* 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; + +public class NonArrayJSONException extends UnexpectedJSONException { + private static final long serialVersionUID = 5582918057432365749L; + + public NonArrayJSONException(String detailMessage) { + super(detailMessage); + } + + public NonArrayJSONException(Throwable throwable) { + super(throwable); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java new file mode 100644 index 000000000..fd50d465e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java @@ -0,0 +1,17 @@ +/* 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; + +public class NonObjectJSONException extends UnexpectedJSONException { + private static final long serialVersionUID = 2214238763035650087L; + + public NonObjectJSONException(String detailMessage) { + super(detailMessage); + } + + public NonObjectJSONException(Throwable throwable) { + super(throwable); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java new file mode 100644 index 000000000..c1d8833b6 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java @@ -0,0 +1,16 @@ +/* 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 android.content.SyncResult; + +public class NullClusterURLException extends SyncException { + private static final long serialVersionUID = 4277845518548393161L; + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + syncResult.stats.numAuthExceptions++; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java new file mode 100644 index 000000000..d3467545c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java @@ -0,0 +1,86 @@ +/* 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 org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; + +import android.content.SharedPreferences; + +public class PersistedMetaGlobal { + public static final String LOG_TAG = "PersistedMetaGlobal"; + + public static final String META_GLOBAL_SERVER_RESPONSE_BODY = "metaGlobalServerResponseBody"; + public static final String META_GLOBAL_LAST_MODIFIED = "metaGlobalLastModified"; + + protected SharedPreferences prefs; + + public PersistedMetaGlobal(SharedPreferences prefs) { + this.prefs = prefs; + } + + /** + * Sets a <code>MetaGlobal</code> from persisted prefs. + * + * @param metaUrl + * meta/global server URL + * @param credentials + * Sync credentials + * + * @return <MetaGlobal> set from previously fetched meta/global record from + * server + */ + public MetaGlobal metaGlobal(String metaUrl, AuthHeaderProvider authHeaderProvider) { + String json = prefs.getString(META_GLOBAL_SERVER_RESPONSE_BODY, null); + if (json == null) { + return null; + } + MetaGlobal metaGlobal = null; + try { + CryptoRecord cryptoRecord = CryptoRecord.fromJSONRecord(json); + MetaGlobal mg = new MetaGlobal(metaUrl, authHeaderProvider); + mg.setFromRecord(cryptoRecord); + metaGlobal = mg; + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception decrypting persisted meta/global.", e); + } + return metaGlobal; + } + + public void persistMetaGlobal(MetaGlobal metaGlobal) { + if (metaGlobal == null) { + Logger.debug(LOG_TAG, "Clearing persisted meta/global."); + prefs.edit().remove(META_GLOBAL_SERVER_RESPONSE_BODY).commit(); + return; + } + try { + CryptoRecord cryptoRecord = metaGlobal.asCryptoRecord(); + String json = cryptoRecord.toJSONString(); + Logger.debug(LOG_TAG, "Persisting meta/global."); + prefs.edit().putString(META_GLOBAL_SERVER_RESPONSE_BODY, json).commit(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception encrypting while persisting meta/global.", e); + } + } + + public long lastModified() { + return prefs.getLong(META_GLOBAL_LAST_MODIFIED, -1); + } + + public void persistLastModified(long lastModified) { + if (lastModified <= 0) { + Logger.debug(LOG_TAG, "Clearing persisted meta/global last modified timestamp."); + prefs.edit().remove(META_GLOBAL_LAST_MODIFIED).commit(); + return; + } + Logger.debug(LOG_TAG, "Persisting meta/global last modified timestamp " + lastModified + "."); + prefs.edit().putLong(META_GLOBAL_LAST_MODIFIED, lastModified).commit(); + } + + public void purge() { + persistLastModified(-1); + persistMetaGlobal(null); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java new file mode 100644 index 000000000..63f6446da --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java @@ -0,0 +1,59 @@ +/* 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 android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +public class PrefsBackoffHandler implements BackoffHandler { + public static final String PREF_EARLIEST_NEXT = "earliestnext"; + + private final SharedPreferences prefs; + private final String prefEarliest; + + public PrefsBackoffHandler(final SharedPreferences prefs, final String prefSuffix) { + if (prefs == null) { + throw new IllegalArgumentException("prefs must not be null."); + } + this.prefs = prefs; + this.prefEarliest = PREF_EARLIEST_NEXT + "." + prefSuffix; + } + + @Override + public synchronized long getEarliestNextRequest() { + return prefs.getLong(prefEarliest, 0); + } + + @Override + public synchronized void setEarliestNextRequest(final long next) { + final Editor edit = prefs.edit(); + edit.putLong(prefEarliest, next); + edit.commit(); + } + + @Override + public synchronized void extendEarliestNextRequest(final long next) { + if (prefs.getLong(prefEarliest, 0) >= next) { + return; + } + final Editor edit = prefs.edit(); + edit.putLong(prefEarliest, next); + edit.commit(); + } + + /** + * Return the number of milliseconds until we're allowed to touch the server again, + * or 0 if now is fine. + */ + @Override + public long delayMilliseconds() { + long earliestNextRequest = getEarliestNextRequest(); + if (earliestNextRequest <= 0) { + return 0; + } + long now = System.currentTimeMillis(); + return Math.max(0, earliestNextRequest - now); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt new file mode 100644 index 000000000..cf4624ca4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt @@ -0,0 +1 @@ +These files are managed in the android-sync repo. Do not modify directly, or your changes will be lost. diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java new file mode 100644 index 000000000..4ea77f37c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java @@ -0,0 +1,12 @@ +/* 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; + +/** + * A previous POST failed, so we won't send any more records this session. + */ +public class Server11PreviousPostFailedException extends SyncException { + private static final long serialVersionUID = -3582490631414624310L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java new file mode 100644 index 000000000..d654d3116 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java @@ -0,0 +1,12 @@ +/* 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; + +/** + * The server rejected a record in its "failure" array. + */ +public class Server11RecordPostFailedException extends SyncException { + private static final long serialVersionUID = -8517471217486190314L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java new file mode 100644 index 000000000..4c1584d5a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java @@ -0,0 +1,121 @@ +/* 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 org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.sync.delegates.ClientsDataDelegate; +import org.mozilla.gecko.util.HardwareUtils; + +import android.accounts.Account; +import android.content.Context; +import android.content.SharedPreferences; + +/** + * A <code>ClientsDataDelegate</code> implementation that persists to a + * <code>SharedPreferences</code> instance. + */ +public class SharedPreferencesClientsDataDelegate implements ClientsDataDelegate { + protected final SharedPreferences sharedPreferences; + protected final Context context; + + public SharedPreferencesClientsDataDelegate(SharedPreferences sharedPreferences, Context context) { + this.sharedPreferences = sharedPreferences; + this.context = context; + + // It's safe to init this multiple times. + HardwareUtils.init(context); + } + + @Override + public synchronized String getAccountGUID() { + String accountGUID = sharedPreferences.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null); + if (accountGUID == null) { + accountGUID = Utils.generateGuid(); + sharedPreferences.edit().putString(SyncConfiguration.PREF_ACCOUNT_GUID, accountGUID).commit(); + } + return accountGUID; + } + + private synchronized void saveClientNameToSharedPreferences(String clientName, long now) { + sharedPreferences + .edit() + .putString(SyncConfiguration.PREF_CLIENT_NAME, clientName) + .putLong(SyncConfiguration.PREF_CLIENT_DATA_TIMESTAMP, now) + .apply(); + } + + /** + * Set client name. + * + * @param clientName to change to. + */ + @Override + public synchronized void setClientName(String clientName, long now) { + saveClientNameToSharedPreferences(clientName, now); + + // Update the FxA device registration + final Account account = FirefoxAccounts.getFirefoxAccount(context); + if (account != null) { + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + fxAccount.resetDeviceRegistrationVersion(); + } + } + + @Override + public String getDefaultClientName() { + return FxAccountUtils.defaultClientName(context); + } + + @Override + public synchronized String getClientName() { + String clientName = sharedPreferences.getString(SyncConfiguration.PREF_CLIENT_NAME, null); + if (clientName == null) { + clientName = getDefaultClientName(); + long now = System.currentTimeMillis(); + saveClientNameToSharedPreferences(clientName, now); // Save locally only to avoid a recursion loop + } + return clientName; + } + + @Override + public synchronized void setClientsCount(int clientsCount) { + sharedPreferences.edit().putLong(SyncConfiguration.PREF_NUM_CLIENTS, clientsCount).commit(); + } + + @Override + public boolean isLocalGUID(String guid) { + return getAccountGUID().equals(guid); + } + + @Override + public synchronized int getClientsCount() { + return (int) sharedPreferences.getLong(SyncConfiguration.PREF_NUM_CLIENTS, 0); + } + + @Override + public long getLastModifiedTimestamp() { + return sharedPreferences.getLong(SyncConfiguration.PREF_CLIENT_DATA_TIMESTAMP, 0); + } + + @Override + public String getFormFactor() { + if (HardwareUtils.isLargeTablet()) { + return "largetablet"; + } + + if (HardwareUtils.isSmallTablet()) { + return "smalltablet"; + } + + if (HardwareUtils.isTelevision()) { + return "tv"; + } + + return "phone"; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java new file mode 100644 index 000000000..4b2280895 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java @@ -0,0 +1,84 @@ +/* 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 org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +/** + * Override SyncConfiguration to restore the old behavior of clusterURL -- + * that is, a URL without the protocol version etc. + * + */ +public class Sync11Configuration extends SyncConfiguration { + private static final String LOG_TAG = "Sync11Configuration"; + private static final String API_VERSION = "1.1"; + + public Sync11Configuration(String username, + AuthHeaderProvider authHeaderProvider, + SharedPreferences prefs) { + super(username, authHeaderProvider, prefs); + } + + public Sync11Configuration(String username, + AuthHeaderProvider authHeaderProvider, + SharedPreferences prefs, + KeyBundle keyBundle) { + super(username, authHeaderProvider, prefs, keyBundle); + } + + @Override + public String getAPIVersion() { + return API_VERSION; + } + + @Override + public String storageURL() { + return clusterURL + API_VERSION + "/" + username + "/storage"; + } + + @Override + protected String infoBaseURL() { + return clusterURL + API_VERSION + "/" + username + "/info/"; + } + + protected void setAndPersistClusterURL(URI u, SharedPreferences prefs) { + boolean shouldPersist = (prefs != null) && (clusterURL == null); + + Logger.trace(LOG_TAG, "Setting cluster URL to " + u.toASCIIString() + + (shouldPersist ? ". Persisting." : ". Not persisting.")); + clusterURL = u; + if (shouldPersist) { + Editor edit = prefs.edit(); + edit.putString(PREF_CLUSTER_URL, clusterURL.toASCIIString()); + edit.commit(); + } + } + + protected void setClusterURL(URI u, SharedPreferences prefs) { + if (u == null) { + Logger.warn(LOG_TAG, "Refusing to set cluster URL to null."); + return; + } + URI uri = u.normalize(); + if (uri.toASCIIString().endsWith("/")) { + setAndPersistClusterURL(u, prefs); + return; + } + setAndPersistClusterURL(uri.resolve("/"), prefs); + Logger.trace(LOG_TAG, "Set cluster URL to " + clusterURL.toASCIIString() + ", given input " + u.toASCIIString()); + } + + @Override + public void setClusterURL(URI u) { + setClusterURL(u, this.getPrefs()); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java new file mode 100644 index 000000000..53edf5f84 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java @@ -0,0 +1,480 @@ +/* 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.PrefsBranch; +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 { + private static final String LOG_TAG = "SyncConfiguration"; + + // These must be set in GlobalSession's constructor. + public URI clusterURL; + public KeyBundle syncKeyBundle; + + public InfoConfiguration infoConfiguration; + + public CollectionKeys collectionKeys; + public InfoCollections infoCollections; + public MetaGlobal metaGlobal; + public String syncID; + + protected final String username; + + /** + * Persisted collection of enabledEngineNames. + * <p> + * Can contain engines Android Sync is not currently aware of, such as "prefs" + * or "addons". + * <p> + * Copied from latest downloaded meta/global record and used to generate a + * fresh meta/global record for upload. + */ + public Set<String> enabledEngineNames; + public Set<String> declinedEngineNames = new HashSet<String>(); + + /** + * Names of stages to sync <it>this sync</it>, or <code>null</code> to sync + * all known stages. + * <p> + * Generated <it>each sync</it> from extras bundle passed to + * <code>SyncAdapter.onPerformSync</code> and not persisted. + * <p> + * Not synchronized! Set this exactly once per global session and don't modify + * it -- especially not from multiple threads. + */ + public Collection<String> 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 <code>enabledEngineNames</code> in that + * <code>enabledEngineNames</code> reflects the downloaded meta/global, + * whereas <code>userSelectedEngines</code> 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<String, Boolean> 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 MIGRATION_SENTINEL_CHECK_TIMESTAMP = "migrationSentinelCheckTimestamp"; // When we last looked in meta/fxa_credentials. + + 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<String> of valid engine names that Android Sync implements. + */ + public static Set<String> validEngineNames() { + Set<String> engineNames = new HashSet<String>(); + for (Stage stage : Stage.getNamedStages()) { + engineNames.add(stage.getRepositoryName()); + } + return engineNames; + } + + /** + * Return a convenient accessor for part of prefs. + * @return + * A PrefsBranch object representing this + * section of the preferences space. + */ + public PrefsBranch getBranch(String prefix) { + return new PrefsBranch(this.getPrefs(), 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<String> of the enabled engine names if they have been stored, + * or null otherwise. + */ + protected static Set<String> getEngineNamesFromPref(SharedPreferences prefs, String pref) { + final String json = prefs.getString(pref, null); + if (json == null) { + return null; + } + try { + final ExtendedJSONObject o = new ExtendedJSONObject(json); + return new HashSet<String>(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, <code>null</code> is returned. + */ + public static Set<String> getEnabledEngineNames(SharedPreferences prefs) { + return getEngineNamesFromPref(prefs, PREF_ENABLED_ENGINE_NAMES); + } + + /** + * Returns the set of engine names that the user has declined. + */ + public static Set<String> getDeclinedEngineNames(SharedPreferences prefs) { + final Set<String> names = getEngineNamesFromPref(prefs, PREF_DECLINED_ENGINE_NAMES); + if (names == null) { + return new HashSet<String>(); + } + 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<String, Boolean> of changed engines. Key is the lower-cased + * engine name, Value is the new sync state. + */ + public static Map<String, Boolean> getUserSelectedEngines(SharedPreferences prefs) { + String json = prefs.getString(PREF_USER_SELECTED_ENGINES_TO_SYNC, null); + if (json == null) { + return null; + } + try { + ExtendedJSONObject o = new ExtendedJSONObject(json); + Map<String, Boolean> map = new HashMap<String, Boolean>(); + for (Entry<String, Object> 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<String, Boolean> of engine name to sync state + */ + public static void storeSelectedEnginesToPrefs(SharedPreferences prefs, Map<String, Boolean> selectedEngines) { + ExtendedJSONObject jObj = new ExtendedJSONObject(); + HashSet<String> declined = new HashSet<String>(); + for (Entry<String, Boolean> 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<String> 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 infoConfigurationURL() { + return infoBaseURL() + "configuration"; + } + + 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, 0L); + } + + public void persistServerClientsTimestamp(long timestamp) { + getEditor().putLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, timestamp).commit(); + } + + public long getPersistedServerClientsTimestamp() { + return getPrefs().getLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, 0L); + } + + public void persistLastMigrationSentinelCheckTimestamp(long timestamp) { + getEditor().putLong(SyncConfiguration.MIGRATION_SENTINEL_CHECK_TIMESTAMP, timestamp).commit(); + } + + public long getLastMigrationSentinelCheckTimestamp() { + return getPrefs().getLong(SyncConfiguration.MIGRATION_SENTINEL_CHECK_TIMESTAMP, 0L); + } + + 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()); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java new file mode 100644 index 000000000..02ba118c5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java @@ -0,0 +1,16 @@ +/* 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 android.content.SyncResult; + +public class SyncConfigurationException extends SyncException { + private static final long serialVersionUID = 1107080177269358381L; + + @Override + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + syncResult.stats.numAuthExceptions++; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java new file mode 100644 index 000000000..5dc7b289f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java @@ -0,0 +1,20 @@ +/* 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 org.mozilla.gecko.AppConstants; + +public class SyncConstants { + public static final String GLOBAL_LOG_TAG = "FxSync"; + public static final String SYNC_MAJOR_VERSION = "1"; + public static final String SYNC_MINOR_VERSION = "0"; + public static final String SYNC_VERSION_STRING = SYNC_MAJOR_VERSION + "." + + AppConstants.MOZ_APP_VERSION + "." + + SYNC_MINOR_VERSION; + + public static final String USER_AGENT = "Firefox AndroidSync " + + SYNC_VERSION_STRING + " (" + + AppConstants.MOZ_APP_UA_NAME + ")"; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java new file mode 100644 index 000000000..ee0902568 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java @@ -0,0 +1,34 @@ +/* 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 android.content.SyncResult; + +public abstract class SyncException extends Exception { + private static final long serialVersionUID = -6928990004393234738L; + + public SyncException() { + super(); + } + + public SyncException(final Throwable e) { + super(e); + } + + /** + * Update sync result statistics with information particular to this + * exception. + * + * @param globalSession + * current session, or null. + * @param syncResult + * Android sync result to update. + */ + public void updateStats(GlobalSession globalSession, SyncResult syncResult) { + // Assume storage error. + // TODO: this logic is overly simplistic. + syncResult.databaseError = true; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java new file mode 100644 index 000000000..2b08be9c4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java @@ -0,0 +1,68 @@ +/* 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 android.content.SharedPreferences.Editor; + +import org.mozilla.gecko.background.common.PrefsBranch; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; + +import java.io.IOException; + +public class SynchronizerConfiguration { + private static final String LOG_TAG = "SynczrConfiguration"; + + public String syncID; + public RepositorySessionBundle remoteBundle; + public RepositorySessionBundle localBundle; + + public SynchronizerConfiguration(PrefsBranch config) throws NonObjectJSONException, IOException { + this.load(config); + } + + public SynchronizerConfiguration(String syncID, RepositorySessionBundle remoteBundle, RepositorySessionBundle localBundle) { + this.syncID = syncID; + this.remoteBundle = remoteBundle; + this.localBundle = localBundle; + } + + // This should get partly shuffled back into SyncConfiguration, I think. + public void load(PrefsBranch config) throws NonObjectJSONException, IOException { + if (config == null) { + throw new IllegalArgumentException("config cannot be null."); + } + String remoteJSON = config.getString("remote", null); + String localJSON = config.getString("local", null); + RepositorySessionBundle rB = new RepositorySessionBundle(remoteJSON); + RepositorySessionBundle lB = new RepositorySessionBundle(localJSON); + if (remoteJSON == null) { + rB.setTimestamp(0); + } + if (localJSON == null) { + lB.setTimestamp(0); + } + syncID = config.getString("syncID", null); + remoteBundle = rB; + localBundle = lB; + Logger.debug(LOG_TAG, "Loaded SynchronizerConfiguration. syncID: " + syncID + ", remoteBundle: " + remoteBundle + ", localBundle: " + localBundle); + } + + public void persist(PrefsBranch config) { + if (config == null) { + throw new IllegalArgumentException("config cannot be null."); + } + String jsonRemote = remoteBundle.toJSONString(); + String jsonLocal = localBundle.toJSONString(); + Editor editor = config.edit(); + editor.putString("remote", jsonRemote); + editor.putString("local", jsonLocal); + editor.putString("syncID", syncID); + + // Synchronous. + editor.commit(); + Logger.debug(LOG_TAG, "Persisted SynchronizerConfiguration. syncID: " + syncID + ", remoteBundle: " + remoteBundle + ", localBundle: " + localBundle); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java new file mode 100644 index 000000000..7f2029566 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java @@ -0,0 +1,15 @@ +/* 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.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ThreadPool { + public static ExecutorService executorService = Executors.newCachedThreadPool(); + public static void run(Runnable runnable) { + executorService.submit(runnable); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java new file mode 100644 index 000000000..e5771452c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java @@ -0,0 +1,25 @@ +/* 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; + +public class UnexpectedJSONException extends Exception { + private static final long serialVersionUID = 4797570033096443169L; + + public UnexpectedJSONException(String detailMessage) { + super(detailMessage); + } + + public UnexpectedJSONException(Throwable throwable) { + super(throwable); + } + + public static class BadRequiredFieldJSONException extends UnexpectedJSONException { + private static final long serialVersionUID = -9207736984784497612L; + + public BadRequiredFieldJSONException(String string) { + super(string); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java new file mode 100644 index 000000000..e2350095e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java @@ -0,0 +1,16 @@ +/* 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; + +public class UnknownSynchronizerConfigurationVersionException extends + SyncConfigurationException { + public int badVersion; + private static final long serialVersionUID = -8497255862099517395L; + + public UnknownSynchronizerConfigurationVersionException(int version) { + super(); + badVersion = version; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java new file mode 100644 index 000000000..ef8859b4a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java @@ -0,0 +1,575 @@ +/* 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.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URLDecoder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.Executor; + +import org.json.simple.JSONArray; +import org.mozilla.apache.commons.codec.binary.Base32; +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.nativecode.NativeCrypto; +import org.mozilla.gecko.sync.setup.Constants; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; + +public class Utils { + + private static final String LOG_TAG = "Utils"; + + private static final SecureRandom sharedSecureRandom = new SecureRandom(); + + // See <http://developer.android.com/reference/android/content/Context.html#getSharedPreferences%28java.lang.String,%20int%29> + public static final int SHARED_PREFERENCES_MODE = 0; + + public static String generateGuid() { + byte[] encodedBytes = Base64.encodeBase64(generateRandomBytes(9), false); + return new String(encodedBytes).replace("+", "-").replace("/", "_"); + } + + /** + * Helper to generate secure random bytes. + * + * @param length + * Number of bytes to generate. + */ + public static byte[] generateRandomBytes(int length) { + byte[] bytes = new byte[length]; + sharedSecureRandom.nextBytes(bytes); + return bytes; + } + + /** + * Helper to generate a random integer in a specified range. + * + * @param r + * Generate an integer between 0 and r-1 inclusive. + */ + public static BigInteger generateBigIntegerLessThan(BigInteger r) { + int maxBytes = (int) Math.ceil(((double) r.bitLength()) / 8); + BigInteger randInt = new BigInteger(generateRandomBytes(maxBytes)); + return randInt.mod(r); + } + + /** + * Helper to convert a byte array to a hex-encoded string + */ + public static String byte2Hex(final byte[] b) { + return byte2Hex(b, 2 * b.length); + } + + public static String byte2Hex(final byte[] b, int hexLength) { + final StringBuilder hs = new StringBuilder(Math.max(2*b.length, hexLength)); + String stmp; + + for (int n = 0; n < hexLength - 2*b.length; n++) { + hs.append("0"); + } + + for (int n = 0; n < b.length; n++) { + stmp = Integer.toHexString(b[n] & 0XFF); + + if (stmp.length() == 1) { + hs.append("0"); + } + hs.append(stmp); + } + + return hs.toString(); + } + + public static byte[] concatAll(byte[] first, byte[]... rest) { + int totalLength = first.length; + for (byte[] array : rest) { + totalLength += array.length; + } + + byte[] result = new byte[totalLength]; + int offset = first.length; + + System.arraycopy(first, 0, result, 0, offset); + + for (byte[] array : rest) { + System.arraycopy(array, 0, result, offset, array.length); + offset += array.length; + } + return result; + } + + /** + * Utility for Base64 decoding. Should ensure that the correct + * Apache Commons version is used. + * + * @param base64 + * An input string. Will be decoded as UTF-8. + * @return + * A byte array of decoded values. + * @throws UnsupportedEncodingException + * Should not occur. + */ + public static byte[] decodeBase64(String base64) throws UnsupportedEncodingException { + return Base64.decodeBase64(base64.getBytes("UTF-8")); + } + + public static byte[] decodeFriendlyBase32(String base32) { + Base32 converter = new Base32(); + final String translated = base32.replace('8', 'l').replace('9', 'o'); + return converter.decode(translated.toUpperCase(Locale.US)); + } + + public static byte[] hex2Byte(String str, int byteLength) { + byte[] second = hex2Byte(str); + if (second.length >= byteLength) { + return second; + } + // New Java arrays are zeroed: + // http://docs.oracle.com/javase/specs/jls/se7/html/jls-4.html#jls-4.12.5 + byte[] first = new byte[byteLength - second.length]; + return Utils.concatAll(first, second); + } + + public static byte[] hex2Byte(String str) { + if (str.length() % 2 == 1) { + str = "0" + str; + } + + byte[] bytes = new byte[str.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(str.substring(2 * i, 2 * i + 2), 16); + } + return bytes; + } + + public static String millisecondsToDecimalSecondsString(long ms) { + return millisecondsToDecimalSeconds(ms).toString(); + } + + // For dumping into JSON without quotes. + public static BigDecimal millisecondsToDecimalSeconds(long ms) { + return new BigDecimal(ms).movePointLeft(3); + } + + // This lives until Bug 708956 lands, and we don't have to do it any more. + public static long decimalSecondsToMilliseconds(String decimal) { + try { + return new BigDecimal(decimal).movePointRight(3).longValue(); + } catch (Exception e) { + return -1; + } + } + + // Oh, Java. + public static long decimalSecondsToMilliseconds(Double decimal) { + // Truncates towards 0. + return (long)(decimal * 1000); + } + + public static long decimalSecondsToMilliseconds(Long decimal) { + return decimal * 1000; + } + + public static long decimalSecondsToMilliseconds(Integer decimal) { + return (decimal * 1000); + } + + public static byte[] sha256(byte[] in) + throws NoSuchAlgorithmException { + MessageDigest sha1 = MessageDigest.getInstance("SHA-256"); + return sha1.digest(in); + } + + protected static byte[] sha1(final String utf8) + throws NoSuchAlgorithmException, UnsupportedEncodingException { + final byte[] bytes = utf8.getBytes("UTF-8"); + try { + return NativeCrypto.sha1(bytes); + } catch (final LinkageError e) { + // This will throw UnsatisifiedLinkError (missing mozglue) the first time it is called, and + // ClassNotDefFoundError, for the uninitialized NativeCrypto class, each subsequent time this + // is called; LinkageError is their common ancestor. + Logger.warn(LOG_TAG, "Got throwable stretching password using native sha1 implementation; " + + "ignoring and using Java implementation.", e); + final MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + return sha1.digest(utf8.getBytes("UTF-8")); + } + } + + protected static String sha1Base32(final String utf8) + throws NoSuchAlgorithmException, UnsupportedEncodingException { + return new Base32().encodeAsString(sha1(utf8)).toLowerCase(Locale.US); + } + + /** + * If we encounter characters not allowed by the API (as found for + * instance in an email address), hash the value. + * @param account + * An account string. + * @return + * An acceptable string. + * @throws UnsupportedEncodingException + * @throws NoSuchAlgorithmException + */ + public static String usernameFromAccount(final String account) throws NoSuchAlgorithmException, UnsupportedEncodingException { + if (account == null || account.equals("")) { + throw new IllegalArgumentException("No account name provided."); + } + if (account.matches("^[A-Za-z0-9._-]+$")) { + return account.toLowerCase(Locale.US); + } + return sha1Base32(account.toLowerCase(Locale.US)); + } + + public static SharedPreferences getSharedPreferences(final Context context, final String product, final String username, final String serverURL, final String profile, final long version) + throws NoSuchAlgorithmException, UnsupportedEncodingException { + String prefsPath = getPrefsPath(product, username, serverURL, profile, version); + return context.getSharedPreferences(prefsPath, SHARED_PREFERENCES_MODE); + } + + /** + * Get shared preferences path for a Sync account. + * + * @param product the Firefox Sync product package name (like "org.mozilla.firefox"). + * @param username the Sync account name, optionally encoded with <code>Utils.usernameFromAccount</code>. + * @param serverURL the Sync account server URL. + * @param profile the Firefox profile name. + * @param version the version of preferences to reference. + * @return the path. + * @throws NoSuchAlgorithmException + * @throws UnsupportedEncodingException + */ + public static String getPrefsPath(final String product, final String username, final String serverURL, final String profile, final long version) + throws NoSuchAlgorithmException, UnsupportedEncodingException { + final String encodedAccount = sha1Base32(serverURL + ":" + usernameFromAccount(username)); + + if (version <= 0) { + return "sync.prefs." + encodedAccount; + } else { + final String sanitizedProduct = product.replace('.', '!').replace(' ', '!'); + return "sync.prefs." + sanitizedProduct + "." + encodedAccount + "." + profile + "." + version; + } + } + + public static void addToIndexBucketMap(TreeMap<Long, ArrayList<String>> map, long index, String value) { + ArrayList<String> bucket = map.get(index); + if (bucket == null) { + bucket = new ArrayList<String>(); + } + bucket.add(value); + map.put(index, bucket); + } + + /** + * Yes, an equality method that's null-safe. + */ + private static boolean same(Object a, Object b) { + if (a == b) { + return true; + } + if (a == null || b == null) { + return false; // If both null, case above applies. + } + return a.equals(b); + } + + /** + * Return true if the two arrays are both null, or are both arrays + * containing the same elements in the same order. + */ + public static boolean sameArrays(JSONArray a, JSONArray b) { + if (a == b) { + return true; + } + if (a == null || b == null) { + return false; + } + final int size = a.size(); + if (size != b.size()) { + return false; + } + for (int i = 0; i < size; ++i) { + if (!same(a.get(i), b.get(i))) { + return false; + } + } + return true; + } + + /** + * Takes a URI, extracting URI components. + * @param scheme the URI scheme on which to match. + */ + @SuppressWarnings("deprecation") + public static Map<String, String> extractURIComponents(String scheme, String uri) { + if (uri.indexOf(scheme) != 0) { + throw new IllegalArgumentException("URI scheme does not match: " + scheme); + } + + // Do this the hard way to avoid taking a large dependency on + // HttpClient or getting all regex-tastic. + String components = uri.substring(scheme.length()); + HashMap<String, String> out = new HashMap<String, String>(); + String[] parts = components.split("&"); + for (int i = 0; i < parts.length; ++i) { + String part = parts[i]; + if (part.length() == 0) { + continue; + } + String[] pair = part.split("=", 2); + switch (pair.length) { + case 0: + continue; + case 1: + out.put(URLDecoder.decode(pair[0]), null); + break; + case 2: + out.put(URLDecoder.decode(pair[0]), URLDecoder.decode(pair[1])); + break; + } + } + return out; + } + + // Because TextUtils.join is not stubbed. + public static String toDelimitedString(String delimiter, Collection<? extends Object> items) { + if (items == null || items.size() == 0) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + int i = 0; + int c = items.size(); + for (Object object : items) { + sb.append(object.toString()); + if (++i < c) { + sb.append(delimiter); + } + } + return sb.toString(); + } + + public static String toCommaSeparatedString(Collection<? extends Object> items) { + return toDelimitedString(", ", items); + } + + /** + * Names of stages to sync: (ALL intersect SYNC) intersect (ALL minus SKIP). + * + * @param knownStageNames collection of known stage names (set ALL above). + * @param toSync set SYNC above, or <code>null</code> to sync all known stages. + * @param toSkip set SKIP above, or <code>null</code> to not skip any stages. + * @return stage names. + */ + public static Collection<String> getStagesToSync(final Collection<String> knownStageNames, Collection<String> toSync, Collection<String> toSkip) { + if (toSkip == null) { + toSkip = new HashSet<String>(); + } else { + toSkip = new HashSet<String>(toSkip); + } + + if (toSync == null) { + toSync = new HashSet<String>(knownStageNames); + } else { + toSync = new HashSet<String>(toSync); + } + toSync.retainAll(knownStageNames); + toSync.removeAll(toSkip); + return toSync; + } + + /** + * Get names of stages to sync: (ALL intersect SYNC) intersect (ALL minus SKIP). + * + * @param knownStageNames collection of known stage names (set ALL above). + * @param extras + * a <code>Bundle</code> instance (possibly null) optionally containing keys + * <code>EXTRAS_KEY_STAGES_TO_SYNC</code> (set SYNC above) and + * <code>EXTRAS_KEY_STAGES_TO_SKIP</code> (set SKIP above). + * @return stage names. + */ + public static Collection<String> getStagesToSyncFromBundle(final Collection<String> knownStageNames, final Bundle extras) { + if (extras == null) { + return knownStageNames; + } + String toSyncString = extras.getString(Constants.EXTRAS_KEY_STAGES_TO_SYNC); + String toSkipString = extras.getString(Constants.EXTRAS_KEY_STAGES_TO_SKIP); + if (toSyncString == null && toSkipString == null) { + return knownStageNames; + } + + ArrayList<String> toSync = null; + ArrayList<String> toSkip = null; + if (toSyncString != null) { + try { + toSync = new ArrayList<String>(new ExtendedJSONObject(toSyncString).keySet()); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception parsing stages to sync: '" + toSyncString + "'.", e); + } + } + if (toSkipString != null) { + try { + toSkip = new ArrayList<String>(new ExtendedJSONObject(toSkipString).keySet()); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception parsing stages to skip: '" + toSkipString + "'.", e); + } + } + + Logger.info(LOG_TAG, "Asked to sync '" + Utils.toCommaSeparatedString(toSync) + + "' and to skip '" + Utils.toCommaSeparatedString(toSkip) + "'."); + return getStagesToSync(knownStageNames, toSync, toSkip); + } + + /** + * Put names of stages to sync and to skip into sync extras bundle. + * + * @param bundle + * a <code>Bundle</code> instance (possibly null). + * @param stagesToSync + * collection of stage names to sync: key + * <code>EXTRAS_KEY_STAGES_TO_SYNC</code>; ignored if <code>null</code>. + * @param stagesToSkip + * collection of stage names to skip: key + * <code>EXTRAS_KEY_STAGES_TO_SKIP</code>; ignored if <code>null</code>. + */ + public static void putStageNamesToSync(final Bundle bundle, final String[] stagesToSync, final String[] stagesToSkip) { + if (bundle == null) { + return; + } + + if (stagesToSync != null) { + ExtendedJSONObject o = new ExtendedJSONObject(); + for (String stageName : stagesToSync) { + o.put(stageName, 0); + } + bundle.putString(Constants.EXTRAS_KEY_STAGES_TO_SYNC, o.toJSONString()); + } + + if (stagesToSkip != null) { + ExtendedJSONObject o = new ExtendedJSONObject(); + for (String stageName : stagesToSkip) { + o.put(stageName, 0); + } + bundle.putString(Constants.EXTRAS_KEY_STAGES_TO_SKIP, o.toJSONString()); + } + } + + /** + * Read contents of file as a string. + * + * @param context Android context. + * @param filename name of file to read; must not be null. + * @return <code>String</code> instance. + */ + public static String readFile(final Context context, final String filename) { + if (filename == null) { + throw new IllegalArgumentException("Passed null filename in readFile."); + } + + FileInputStream fis = null; + InputStreamReader isr = null; + BufferedReader br = null; + + try { + fis = context.openFileInput(filename); + isr = new InputStreamReader(fis); + br = new BufferedReader(isr); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } catch (Exception e) { + return null; + } finally { + if (isr != null) { + try { + isr.close(); + } catch (IOException e) { + // Ignore. + } + } + if (fis != null) { + try { + fis.close(); + } catch (IOException e) { + // Ignore. + } + } + } + } + + /** + * Format a duration as a string, like "0.56 seconds". + * + * @param startMillis start time in milliseconds. + * @param endMillis end time in milliseconds. + * @return formatted string. + */ + public static String formatDuration(long startMillis, long endMillis) { + final long duration = endMillis - startMillis; + return new DecimalFormat("#0.00 seconds").format(((double) duration) / 1000); + } + + /** + * This will take a string containing a UTF-8 representation of a UTF-8 + * byte array — e.g., "pïgéons1" — and return UTF-8 (e.g., "pïgéons1"). + * + * This is the format produced by desktop Firefox when exchanging credentials + * containing non-ASCII characters. + */ + public static String decodeUTF8(final String in) throws UnsupportedEncodingException { + final int length = in.length(); + final byte[] asciiBytes = new byte[length]; + for (int i = 0; i < length; ++i) { + asciiBytes[i] = (byte) in.codePointAt(i); + } + return new String(asciiBytes, "UTF-8"); + } + + /** + * Replace "foo@bar.com" with "XXX@XXX.XXX". + */ + public static String obfuscateEmail(final String in) { + return in.replaceAll("[^@\\.]", "X"); + } + + public static void throwIfNull(Object... objects) { + for (Object object : objects) { + if (object == null) { + throw new IllegalArgumentException("object must not be null"); + } + } + } + + public static Executor newSynchronousExecutor() { + return new Executor() { + @Override + public void execute(Runnable runnable) { + runnable.run(); + } + }; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java new file mode 100644 index 000000000..a8d0483c9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java @@ -0,0 +1,19 @@ +/* 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.crypto; + +import java.security.GeneralSecurityException; + +public class CryptoException extends Exception { + public GeneralSecurityException cause; + public CryptoException(GeneralSecurityException e) { + this(); + this.cause = e; + } + public CryptoException() { + + } + private static final long serialVersionUID = -5219310989960126830L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java new file mode 100644 index 000000000..355571c6a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java @@ -0,0 +1,232 @@ +/* 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.crypto; + +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.mozilla.apache.commons.codec.binary.Base64; + +/* + * All info in these objects should be decoded (i.e. not BaseXX encoded). + */ +public class CryptoInfo { + private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; + private static final String KEY_ALGORITHM_SPEC = "AES"; + + private byte[] message; + private byte[] iv; + private byte[] hmac; + private KeyBundle keys; + + /** + * Return a CryptoInfo with given plaintext encrypted using given keys. + */ + public static CryptoInfo encrypt(byte[] plaintextBytes, KeyBundle keys) throws CryptoException { + CryptoInfo info = new CryptoInfo(plaintextBytes, keys); + info.encrypt(); + return info; + } + + /** + * Return a CryptoInfo with given plaintext encrypted using given keys and initial vector. + */ + public static CryptoInfo encrypt(byte[] plaintextBytes, byte[] iv, KeyBundle keys) throws CryptoException { + CryptoInfo info = new CryptoInfo(plaintextBytes, iv, null, keys); + info.encrypt(); + return info; + } + + /** + * Return a CryptoInfo with given ciphertext decrypted using given keys and initial vector, verifying that given HMAC validates. + */ + public static CryptoInfo decrypt(byte[] ciphertext, byte[] iv, byte[] hmac, KeyBundle keys) throws CryptoException { + CryptoInfo info = new CryptoInfo(ciphertext, iv, hmac, keys); + info.decrypt(); + return info; + } + + /* + * Constructor typically used when encrypting. + */ + public CryptoInfo(byte[] message, KeyBundle keys) { + this.setMessage(message); + this.setKeys(keys); + } + + /* + * Constructor typically used when decrypting. + */ + public CryptoInfo(byte[] message, byte[] iv, byte[] hmac, KeyBundle keys) { + this.setMessage(message); + this.setIV(iv); + this.setHMAC(hmac); + this.setKeys(keys); + } + + public byte[] getMessage() { + return message; + } + + public void setMessage(byte[] message) { + this.message = message; + } + + public byte[] getIV() { + return iv; + } + + public void setIV(byte[] iv) { + this.iv = iv; + } + + public byte[] getHMAC() { + return hmac; + } + + public void setHMAC(byte[] hmac) { + this.hmac = hmac; + } + + public KeyBundle getKeys() { + return keys; + } + + public void setKeys(KeyBundle keys) { + this.keys = keys; + } + + /* + * Generate HMAC for given cipher text. + */ + public static byte[] generatedHMACFor(byte[] message, KeyBundle keys) throws NoSuchAlgorithmException, InvalidKeyException { + Mac hmacHasher = HKDF.makeHMACHasher(keys.getHMACKey()); + return hmacHasher.doFinal(Base64.encodeBase64(message)); + } + + /* + * Return true if generated HMAC is the same as the specified HMAC. + */ + public boolean generatedHMACIsHMAC() throws NoSuchAlgorithmException, InvalidKeyException { + byte[] generatedHMAC = generatedHMACFor(getMessage(), getKeys()); + byte[] expectedHMAC = getHMAC(); + return Arrays.equals(generatedHMAC, expectedHMAC); + } + + /** + * Performs functionality common to both encryption and decryption. + * + * @param cipher + * @param inputMessage non-BaseXX-encoded message + * @return encrypted/decrypted message + * @throws CryptoException + */ + private static byte[] commonCrypto(Cipher cipher, byte[] inputMessage) + throws CryptoException { + byte[] outputMessage = null; + try { + outputMessage = cipher.doFinal(inputMessage); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new CryptoException(e); + } + return outputMessage; + } + + /** + * Encrypt a CryptoInfo in-place. + * + * @throws CryptoException + */ + public void encrypt() throws CryptoException { + + Cipher cipher = CryptoInfo.getCipher(TRANSFORMATION); + try { + byte[] encryptionKey = getKeys().getEncryptionKey(); + SecretKeySpec spec = new SecretKeySpec(encryptionKey, KEY_ALGORITHM_SPEC); + + // If no IV is provided, we allow the cipher to provide one. + if (getIV() == null || getIV().length == 0) { + cipher.init(Cipher.ENCRYPT_MODE, spec); + } else { + cipher.init(Cipher.ENCRYPT_MODE, spec, new IvParameterSpec(getIV())); + } + } catch (GeneralSecurityException ex) { + throw new CryptoException(ex); + } + + // Encrypt. + byte[] encryptedBytes = commonCrypto(cipher, getMessage()); + byte[] iv = cipher.getIV(); + + byte[] hmac; + // Generate HMAC. + try { + hmac = generatedHMACFor(encryptedBytes, keys); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new CryptoException(e); + } + + // Update in place. keys is already set. + this.setHMAC(hmac); + this.setIV(iv); + this.setMessage(encryptedBytes); + } + + /** + * Decrypt a CryptoInfo in-place. + * + * @throws CryptoException + */ + public void decrypt() throws CryptoException { + + // Check HMAC. + try { + if (!generatedHMACIsHMAC()) { + throw new HMACVerificationException(); + } + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new CryptoException(e); + } + + Cipher cipher = CryptoInfo.getCipher(TRANSFORMATION); + try { + byte[] encryptionKey = getKeys().getEncryptionKey(); + SecretKeySpec spec = new SecretKeySpec(encryptionKey, KEY_ALGORITHM_SPEC); + cipher.init(Cipher.DECRYPT_MODE, spec, new IvParameterSpec(getIV())); + } catch (GeneralSecurityException ex) { + throw new CryptoException(ex); + } + byte[] decryptedBytes = commonCrypto(cipher, getMessage()); + byte[] iv = cipher.getIV(); + + // Update in place. keys is already set. + this.setHMAC(null); + this.setIV(iv); + this.setMessage(decryptedBytes); + } + + /** + * Helper to get a Cipher object. + * + * @param transformation The type of Cipher to get. + */ + private static Cipher getCipher(String transformation) throws CryptoException { + try { + return Cipher.getInstance(transformation); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new CryptoException(e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java new file mode 100644 index 000000000..16c0d8147 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java @@ -0,0 +1,128 @@ +/* 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.crypto; + +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.mozilla.gecko.sync.Utils; + +/* + * A standards-compliant implementation of RFC 5869 + * for HMAC-based Key Derivation Function. + * HMAC uses HMAC SHA256 standard. + */ +public class HKDF { + public static String HMAC_ALGORITHM = "hmacSHA256"; + + /** + * Used for conversion in cases in which you *know* the encoding exists. + */ + public static final byte[] bytes(String in) { + try { + return in.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return null; + } + } + + public static final int BLOCKSIZE = 256 / 8; + public static final byte[] HMAC_INPUT = bytes("Sync-AES_256_CBC-HMAC256"); + + /* + * Step 1 of RFC 5869 + * Get sha256HMAC Bytes + * Input: salt (message), IKM (input keyring material) + * Output: PRK (pseudorandom key) + */ + public static byte[] hkdfExtract(byte[] salt, byte[] IKM) throws NoSuchAlgorithmException, InvalidKeyException { + return digestBytes(IKM, makeHMACHasher(salt)); + } + + /* + * Step 2 of RFC 5869. + * Input: PRK from step 1, info, length. + * Output: OKM (output keyring material). + */ + public static byte[] hkdfExpand(byte[] prk, byte[] info, int len) throws NoSuchAlgorithmException, InvalidKeyException { + Mac hmacHasher = makeHMACHasher(prk); + + byte[] T = {}; + byte[] Tn = {}; + + int iterations = (int) Math.ceil(((double)len) / (BLOCKSIZE)); + for (int i = 0; i < iterations; i++) { + Tn = digestBytes(Utils.concatAll(Tn, info, Utils.hex2Byte(Integer.toHexString(i + 1))), + hmacHasher); + T = Utils.concatAll(T, Tn); + } + + byte[] result = new byte[len]; + System.arraycopy(T, 0, result, 0, len); + return result; + } + + /* + * Make HMAC key + * Input: key (salt) + * Output: Key HMAC-Key + */ + public static Key makeHMACKey(byte[] key) { + if (key.length == 0) { + key = new byte[BLOCKSIZE]; + } + return new SecretKeySpec(key, HMAC_ALGORITHM); + } + + /* + * Make an HMAC hasher + * Input: Key hmacKey + * Ouput: An HMAC Hasher + */ + public static Mac makeHMACHasher(byte[] key) throws NoSuchAlgorithmException, InvalidKeyException { + Mac hmacHasher = null; + hmacHasher = Mac.getInstance(HMAC_ALGORITHM); + + // If Mac.getInstance doesn't throw NoSuchAlgorithmException, hmacHasher is + // non-null. + assert(hmacHasher != null); + + hmacHasher.init(makeHMACKey(key)); + return hmacHasher; + } + + /* + * Hash bytes with given hasher + * Input: message to hash, HMAC hasher + * Output: hashed byte[]. + */ + public static byte[] digestBytes(byte[] message, Mac hasher) { + hasher.update(message); + byte[] ret = hasher.doFinal(); + hasher.reset(); + return ret; + } + + public static byte[] derive(byte[] skm, byte[] xts, byte[] ctxInfo, int dkLen) throws InvalidKeyException, NoSuchAlgorithmException { + return hkdfExpand(hkdfExtract(xts, skm), ctxInfo, dkLen); + } + + public static void deriveMany(byte[] skm, byte[] xts, byte[] ctxInfo, byte[]... keys) throws InvalidKeyException, NoSuchAlgorithmException { + int length = 0; + for (byte[] key : keys) { + length += key.length; + } + byte[] derived = hkdfExpand(hkdfExtract(xts, skm), ctxInfo, length); + int offset = 0; + for (byte[] key : keys) { + System.arraycopy(derived, offset, key, 0, key.length); + offset += key.length; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java new file mode 100644 index 000000000..f33babd52 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java @@ -0,0 +1,12 @@ +/* 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.crypto; + +public class HMACVerificationException extends CryptoException { + private static final long serialVersionUID = 1235311303567074897L; + public HMACVerificationException() { + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java new file mode 100644 index 000000000..2063b1e32 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java @@ -0,0 +1,135 @@ +/* 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.crypto; + +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; + +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.sync.Utils; + +public class KeyBundle { + private static final String KEY_ALGORITHM_SPEC = "AES"; + private static final int KEY_SIZE = 256; + + private byte[] encryptionKey; + private byte[] hmacKey; + + // These are the same for every sync key bundle. + private static final byte[] EMPTY_BYTES = {}; + private static final byte[] ENCR_INPUT_BYTES = {1}; + private static final byte[] HMAC_INPUT_BYTES = {2}; + + /* + * Mozilla's use of HKDF for getting keys from the Sync Key string. + * + * We do exactly 2 HKDF iterations and make the first iteration the + * encryption key and the second iteration the HMAC key. + * + */ + public KeyBundle(String username, String base32SyncKey) throws CryptoException { + if (base32SyncKey == null) { + throw new IllegalArgumentException("No sync key provided."); + } + if (username == null || username.equals("")) { + throw new IllegalArgumentException("No username provided."); + } + // Hash appropriately. + try { + username = Utils.usernameFromAccount(username); + } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { + throw new IllegalArgumentException("Invalid username."); + } + + byte[] syncKey = Utils.decodeFriendlyBase32(base32SyncKey); + byte[] user = username.getBytes(); + + Mac hmacHasher; + try { + hmacHasher = HKDF.makeHMACHasher(syncKey); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new CryptoException(e); + } + assert(hmacHasher != null); // If makeHMACHasher doesn't throw, then hmacHasher is non-null. + + byte[] encrBytes = Utils.concatAll(EMPTY_BYTES, HKDF.HMAC_INPUT, user, ENCR_INPUT_BYTES); + byte[] encrKey = HKDF.digestBytes(encrBytes, hmacHasher); + byte[] hmacBytes = Utils.concatAll(encrKey, HKDF.HMAC_INPUT, user, HMAC_INPUT_BYTES); + + this.hmacKey = HKDF.digestBytes(hmacBytes, hmacHasher); + this.encryptionKey = encrKey; + } + + public KeyBundle(byte[] encryptionKey, byte[] hmacKey) { + this.setEncryptionKey(encryptionKey); + this.setHMACKey(hmacKey); + } + + /** + * Make a KeyBundle with the specified base64-encoded keys. + * + * @return A KeyBundle with the specified keys. + */ + public static KeyBundle fromBase64EncodedKeys(String base64EncryptionKey, String base64HmacKey) throws UnsupportedEncodingException { + return new KeyBundle(Base64.decodeBase64(base64EncryptionKey.getBytes("UTF-8")), + Base64.decodeBase64(base64HmacKey.getBytes("UTF-8"))); + } + + /** + * Make a KeyBundle with two random 256 bit keys (encryption and HMAC). + * + * @return A KeyBundle with random keys. + */ + public static KeyBundle withRandomKeys() throws CryptoException { + KeyGenerator keygen; + try { + keygen = KeyGenerator.getInstance(KEY_ALGORITHM_SPEC); + } catch (NoSuchAlgorithmException e) { + throw new CryptoException(e); + } + + keygen.init(KEY_SIZE); + byte[] encryptionKey = keygen.generateKey().getEncoded(); + byte[] hmacKey = keygen.generateKey().getEncoded(); + + return new KeyBundle(encryptionKey, hmacKey); + } + + public byte[] getEncryptionKey() { + return encryptionKey; + } + + public void setEncryptionKey(byte[] encryptionKey) { + this.encryptionKey = encryptionKey; + } + + public byte[] getHMACKey() { + return hmacKey; + } + + public void setHMACKey(byte[] hmacKey) { + this.hmacKey = hmacKey; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof KeyBundle)) { + return false; + } + KeyBundle other = (KeyBundle) o; + return Arrays.equals(other.encryptionKey, this.encryptionKey) && + Arrays.equals(other.hmacKey, this.hmacKey); + } + + @Override + public int hashCode() { + throw new UnsupportedOperationException("No hashCode for KeyBundle."); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java new file mode 100644 index 000000000..8add1cf11 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java @@ -0,0 +1,9 @@ +/* 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.crypto; + +public class MissingCryptoInputException extends CryptoException { + private static final long serialVersionUID = 5334412407012972445L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java new file mode 100644 index 000000000..00e0f8b18 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java @@ -0,0 +1,9 @@ +/* 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.crypto; + +public class NoKeyBundleException extends CryptoException { + private static final long serialVersionUID = -6627154503154040915L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java new file mode 100644 index 000000000..636b2105c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java @@ -0,0 +1,78 @@ +/* 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.crypto; + +import java.security.GeneralSecurityException; +import java.util.Arrays; + +import javax.crypto.Mac; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.SecretKeySpec; + +public class PBKDF2 { + public static byte[] pbkdf2SHA256(byte[] password, byte[] salt, int c, int dkLen) + throws GeneralSecurityException { + final String algorithm = "HmacSHA256"; + SecretKeySpec keyspec = new SecretKeySpec(password, algorithm); + Mac prf = Mac.getInstance(algorithm); + prf.init(keyspec); + + int hLen = prf.getMacLength(); + + byte U_r[] = new byte[hLen]; + byte U_i[] = new byte[salt.length + 4]; + byte scratch[] = new byte[hLen]; + + int l = Math.max(dkLen, hLen); + int r = dkLen - (l - 1) * hLen; + byte T[] = new byte[l * hLen]; + int ti_offset = 0; + for (int i = 1; i <= l; i++) { + Arrays.fill(U_r, (byte) 0); + F(T, ti_offset, prf, salt, c, i, U_r, U_i, scratch); + ti_offset += hLen; + } + + if (r < hLen) { + // Incomplete last block. + byte DK[] = new byte[dkLen]; + System.arraycopy(T, 0, DK, 0, dkLen); + return DK; + } + + return T; + } + + private static void F(byte[] dest, int offset, Mac prf, byte[] S, int c, int blockIndex, byte U_r[], byte U_i[], byte[] scratch) + throws ShortBufferException, IllegalStateException { + final int hLen = prf.getMacLength(); + + // U0 = S || INT (i); + System.arraycopy(S, 0, U_i, 0, S.length); + INT(U_i, S.length, blockIndex); + + for (int i = 0; i < c; i++) { + prf.update(U_i); + prf.doFinal(scratch, 0); + U_i = scratch; + xor(U_r, U_i); + } + + System.arraycopy(U_r, 0, dest, offset, hLen); + } + + private static void xor(byte[] dest, byte[] src) { + for (int i = 0; i < dest.length; i++) { + dest[i] ^= src[i]; + } + } + + private static void INT(byte[] dest, int offset, int i) { + dest[offset + 0] = (byte) (i / (256 * 256 * 256)); + dest[offset + 1] = (byte) (i / (256 * 256)); + dest[offset + 2] = (byte) (i / (256)); + dest[offset + 3] = (byte) (i); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java new file mode 100644 index 000000000..4dba4f258 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java @@ -0,0 +1,103 @@ +/* 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.crypto; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.CollectionKeys; +import org.mozilla.gecko.sync.CryptoRecord; + +import android.content.SharedPreferences; + +public class PersistedCrypto5Keys { + public static final String LOG_TAG = "PersistedC5Keys"; + + public static final String CRYPTO5_KEYS_SERVER_RESPONSE_BODY = "crypto5KeysServerResponseBody"; + public static final String CRYPTO5_KEYS_LAST_MODIFIED = "crypto5KeysLastModified"; + + protected SharedPreferences prefs; + protected KeyBundle syncKeyBundle; + + public PersistedCrypto5Keys(SharedPreferences prefs, KeyBundle syncKeyBundle) { + if (syncKeyBundle == null) { + throw new IllegalArgumentException("Null syncKeyBundle passed in to PersistedCrypto5Keys constructor."); + } + this.prefs = prefs; + this.syncKeyBundle = syncKeyBundle; + } + + /** + * Get persisted crypto/keys. + * <p> + * crypto/keys is fetched from an encrypted JSON-encoded <code>CryptoRecord</code>. + * + * @return A <code>CollectionKeys</code> instance or <code>null</code> if none + * is currently persisted. + */ + public CollectionKeys keys() { + String keysJSON = prefs.getString(CRYPTO5_KEYS_SERVER_RESPONSE_BODY, null); + if (keysJSON == null) { + return null; + } + try { + CryptoRecord cryptoRecord = CryptoRecord.fromJSONRecord(keysJSON); + CollectionKeys keys = new CollectionKeys(); + keys.setKeyPairsFromWBO(cryptoRecord, syncKeyBundle); + return keys; + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception decrypting persisted crypto/keys.", e); + return null; + } + } + + /** + * Persist crypto/keys. + * <p> + * crypto/keys is stored as an encrypted JSON-encoded <code>CryptoRecord</code>. + * + * @param keys + * The <code>CollectionKeys</code> object to persist, which should + * have the same default key bundle as the sync key bundle. + */ + public void persistKeys(CollectionKeys keys) { + if (keys == null) { + Logger.debug(LOG_TAG, "Clearing persisted crypto/keys."); + prefs.edit().remove(CRYPTO5_KEYS_SERVER_RESPONSE_BODY).commit(); + return; + } + try { + CryptoRecord cryptoRecord = keys.asCryptoRecord(); + cryptoRecord.keyBundle = syncKeyBundle; + cryptoRecord.encrypt(); + String keysJSON = cryptoRecord.toJSONString(); + Logger.debug(LOG_TAG, "Persisting crypto/keys."); + prefs.edit().putString(CRYPTO5_KEYS_SERVER_RESPONSE_BODY, keysJSON).commit(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception encrypting while persisting crypto/keys.", e); + } + } + + public boolean persistedKeysExist() { + return lastModified() > 0; + } + + public long lastModified() { + return prefs.getLong(CRYPTO5_KEYS_LAST_MODIFIED, -1); + } + + public void persistLastModified(long lastModified) { + if (lastModified <= 0) { + Logger.debug(LOG_TAG, "Clearing persisted crypto/keys last modified timestamp."); + prefs.edit().remove(CRYPTO5_KEYS_LAST_MODIFIED).commit(); + return; + } + Logger.debug(LOG_TAG, "Persisting crypto/keys last modified timestamp " + lastModified + "."); + prefs.edit().putLong(CRYPTO5_KEYS_LAST_MODIFIED, lastModified).commit(); + } + + public void purge() { + persistLastModified(-1); + persistKeys(null); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java new file mode 100644 index 000000000..07e9179f0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java @@ -0,0 +1,28 @@ +/* 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.delegates; + +public interface ClientsDataDelegate { + public String getAccountGUID(); + public String getDefaultClientName(); + public void setClientName(String clientName, long now); + public String getClientName(); + public void setClientsCount(int clientsCount); + public int getClientsCount(); + public boolean isLocalGUID(String guid); + public String getFormFactor(); + + /** + * The last time the client's data was modified in a way that should be + * reflected remotely. + * <p> + * Changing the client's name should be reflected remotely, while changing the + * clients count should not (since that data is only used to inform local + * policy.) + * + * @return timestamp in milliseconds. + */ + public long getLastModifiedTimestamp(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java new file mode 100644 index 000000000..2e5347061 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java @@ -0,0 +1,10 @@ +/* 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.delegates; + +public interface FreshStartDelegate { + void onFreshStart(); + void onFreshStartFailed(Exception e); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java new file mode 100644 index 000000000..9829f5b34 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java @@ -0,0 +1,49 @@ +/* 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.delegates; + +import java.net.URI; + +import org.mozilla.gecko.sync.GlobalSession; +import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage; + +public interface GlobalSessionCallback { + /** + * Request that no further syncs occur within the next `backoff` milliseconds. + * @param backoff a duration in milliseconds. + */ + void requestBackoff(long backoff); + + /** + * Called on a 401 HTTP response. + */ + void informUnauthorizedResponse(GlobalSession globalSession, URI oldClusterURL); + + + /** + * Called when an HTTP failure indicates that a software upgrade is required. + */ + void informUpgradeRequiredResponse(GlobalSession session); + + /** + * Called when a migration sentinel has been found and processed successfully. + * <p> + * This account should stop syncing immediately, and arrange to delete itself. + */ + void informMigrated(GlobalSession session); + + void handleAborted(GlobalSession globalSession, String reason); + void handleError(GlobalSession globalSession, Exception ex); + void handleSuccess(GlobalSession globalSession); + void handleStageCompleted(Stage currentState, GlobalSession globalSession); + + /** + * Called when a {@link GlobalSession} wants to know if it should continue + * to make storage requests. + * + * @return false if the session should make no further requests. + */ + boolean shouldBackOffStorage(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java new file mode 100644 index 000000000..90b73a33a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java @@ -0,0 +1,19 @@ +/* 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.delegates; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +/** + * A fairly generic delegate to handle fetches of single JSON object blobs, as + * provided by <code>info/configuration</code>, <code>info/collections</code> + * and <code>info/collection_counts</code>. + */ +public interface JSONRecordFetchDelegate { + public void handleSuccess(ExtendedJSONObject body); + public void handleFailure(SyncStorageResponse response); + public void handleError(Exception e); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java new file mode 100644 index 000000000..0cd5ec732 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java @@ -0,0 +1,21 @@ +/* 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.delegates; + +public interface KeyUploadDelegate { + /** + * Called when keys have been successfully uploaded to the server. + * <p> + * The uploaded keys are intentionally not exposed. It is possible for two + * clients to simultaneously upload keys and for each client to conclude that + * its keys are current (since the server returned 200 on upload). To shorten + * the window wherein two such clients can race, all clients should upload and + * then immediately re-download the fetched keys. + * <p> + * See Bug 692700, Bug 693893. + */ + void onKeysUploaded(); + void onKeyUploadFailed(Exception e); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java new file mode 100644 index 000000000..13854cb5a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java @@ -0,0 +1,15 @@ +/* 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.delegates; + +import org.mozilla.gecko.sync.MetaGlobal; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +public interface MetaGlobalDelegate { + public void handleSuccess(MetaGlobal global, SyncStorageResponse response); + public void handleMissing(MetaGlobal global, SyncStorageResponse response); + public void handleFailure(SyncStorageResponse response); + public void handleError(Exception e); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java new file mode 100644 index 000000000..ef3565812 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java @@ -0,0 +1,10 @@ +/* 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.delegates; + +public interface WipeServerDelegate { + public void onWiped(long timestamp); + public void onWipeFailed(Exception e); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java new file mode 100644 index 000000000..79319aff5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java @@ -0,0 +1,76 @@ +/* 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.middleware; + +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.repositories.IdentityRecordFactory; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCleanDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; + +/** + * Wrap an existing repository in middleware that encrypts and decrypts records + * passing through. + * + * @author rnewman + * + */ +public class Crypto5MiddlewareRepository extends MiddlewareRepository { + + public RecordFactory recordFactory = new IdentityRecordFactory(); + + public class Crypto5MiddlewareRepositorySessionCreationDelegate extends MiddlewareRepository.SessionCreationDelegate { + private final Crypto5MiddlewareRepository repository; + private final RepositorySessionCreationDelegate outerDelegate; + + public Crypto5MiddlewareRepositorySessionCreationDelegate(Crypto5MiddlewareRepository repository, RepositorySessionCreationDelegate outerDelegate) { + this.repository = repository; + this.outerDelegate = outerDelegate; + } + + @Override + public void onSessionCreateFailed(Exception ex) { + this.outerDelegate.onSessionCreateFailed(ex); + } + + @Override + public void onSessionCreated(RepositorySession session) { + // Do some work, then report success with the wrapping session. + Crypto5MiddlewareRepositorySession cryptoSession; + try { + // Synchronous, baby. + cryptoSession = new Crypto5MiddlewareRepositorySession(session, this.repository, recordFactory); + } catch (Exception ex) { + this.outerDelegate.onSessionCreateFailed(ex); + return; + } + this.outerDelegate.onSessionCreated(cryptoSession); + } + } + + public KeyBundle keyBundle; + private final Repository inner; + + public Crypto5MiddlewareRepository(Repository inner, KeyBundle keys) { + super(); + this.inner = inner; + this.keyBundle = keys; + } + @Override + public void createSession(RepositorySessionCreationDelegate delegate, Context context) { + Crypto5MiddlewareRepositorySessionCreationDelegate delegateWrapper = new Crypto5MiddlewareRepositorySessionCreationDelegate(this, delegate); + inner.createSession(delegateWrapper, context); + } + + @Override + public void clean(boolean success, RepositorySessionCleanDelegate delegate, + Context context) { + this.inner.clean(success, delegate, context); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java new file mode 100644 index 000000000..46de7a236 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java @@ -0,0 +1,172 @@ +/* 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.middleware; + +import java.io.UnsupportedEncodingException; +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.crypto.CryptoException; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.NoStoreDelegateException; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; + +/** + * It's a RepositorySession that accepts Records as input, producing CryptoRecords + * for submission to a remote service. + * Takes a RecordFactory as a parameter. This is in charge of taking decrypted CryptoRecords + * as input and producing some expected kind of Record as output for local use. + * + * + + + + +------------------------------------+ + | Server11RepositorySession | + +-------------------------+----------+ + ^ | + | | + Encrypted CryptoRecords + | | + | v + +---------+--------------------------+ + | Crypto5MiddlewareRepositorySession | + +------------------------------------+ + ^ | + | | Decrypted CryptoRecords + | | + | +---------------+ + | | RecordFactory | + | +--+------------+ + | | + Local Record instances + | | + | v + +---------+--------------------------+ + | Local RepositorySession instance | + +------------------------------------+ + + + * @author rnewman + * + */ +public class Crypto5MiddlewareRepositorySession extends MiddlewareRepositorySession { + private final KeyBundle keyBundle; + private final RecordFactory recordFactory; + + public Crypto5MiddlewareRepositorySession(RepositorySession session, Crypto5MiddlewareRepository repository, RecordFactory recordFactory) { + super(session, repository); + this.keyBundle = repository.keyBundle; + this.recordFactory = recordFactory; + } + + public class DecryptingTransformingFetchDelegate implements RepositorySessionFetchRecordsDelegate { + private final RepositorySessionFetchRecordsDelegate next; + private final KeyBundle keyBundle; + private final RecordFactory recordFactory; + + DecryptingTransformingFetchDelegate(RepositorySessionFetchRecordsDelegate next, KeyBundle bundle, RecordFactory recordFactory) { + this.next = next; + this.keyBundle = bundle; + this.recordFactory = recordFactory; + } + + @Override + public void onFetchFailed(Exception ex, Record record) { + next.onFetchFailed(ex, record); + } + + @Override + public void onFetchedRecord(Record record) { + CryptoRecord r; + try { + r = (CryptoRecord) record; + } catch (ClassCastException e) { + next.onFetchFailed(e, record); + return; + } + r.keyBundle = keyBundle; + try { + r.decrypt(); + } catch (Exception e) { + next.onFetchFailed(e, r); + return; + } + Record transformed; + try { + transformed = this.recordFactory.createRecord(r); + } catch (Exception e) { + next.onFetchFailed(e, r); + return; + } + next.onFetchedRecord(transformed); + } + + @Override + public void onFetchCompleted(final long fetchEnd) { + next.onFetchCompleted(fetchEnd); + } + + @Override + public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) { + // Synchronously perform *our* work, passing through appropriately. + RepositorySessionFetchRecordsDelegate deferredNext = next.deferredFetchDelegate(executor); + return new DecryptingTransformingFetchDelegate(deferredNext, keyBundle, recordFactory); + } + } + + private DecryptingTransformingFetchDelegate makeUnwrappingDelegate(RepositorySessionFetchRecordsDelegate inner) { + if (inner == null) { + throw new IllegalArgumentException("Inner delegate cannot be null!"); + } + return new DecryptingTransformingFetchDelegate(inner, this.keyBundle, this.recordFactory); + } + + @Override + public void fetchSince(long timestamp, + RepositorySessionFetchRecordsDelegate delegate) { + inner.fetchSince(timestamp, makeUnwrappingDelegate(delegate)); + } + + @Override + public void fetch(String[] guids, + RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException { + inner.fetch(guids, makeUnwrappingDelegate(delegate)); + } + + @Override + public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) { + inner.fetchAll(makeUnwrappingDelegate(delegate)); + } + + @Override + public void setStoreDelegate(RepositorySessionStoreDelegate delegate) { + // TODO: it remains to be seen how this will work. + inner.setStoreDelegate(delegate); + this.delegate = delegate; // So we can handle errors without involving inner. + } + + @Override + public void store(Record record) throws NoStoreDelegateException { + if (delegate == null) { + throw new NoStoreDelegateException(); + } + CryptoRecord rec = record.getEnvelope(); + rec.keyBundle = this.keyBundle; + try { + rec.encrypt(); + } catch (UnsupportedEncodingException | CryptoException e) { + delegate.onRecordStoreFailed(e, record.guid); + return; + } + // Allow the inner session to do delegate handling. + inner.store(rec); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java new file mode 100644 index 000000000..d807aa5c0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java @@ -0,0 +1,22 @@ +/* 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.middleware; + +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +public abstract class MiddlewareRepository extends Repository { + + public abstract class SessionCreationDelegate implements + RepositorySessionCreationDelegate { + + // We call through to our inner repository, so we don't need our own + // deferral scheme. + @Override + public RepositorySessionCreationDelegate deferredCreationDelegate() { + return this; + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java new file mode 100644 index 000000000..e14ef5226 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java @@ -0,0 +1,185 @@ +/* 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.middleware; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; + +public abstract class MiddlewareRepositorySession extends RepositorySession { + private static final String LOG_TAG = "MiddlewareSession"; + protected final RepositorySession inner; + + public MiddlewareRepositorySession(RepositorySession innerSession, MiddlewareRepository repository) { + super(repository); + this.inner = innerSession; + } + + @Override + public void wipe(RepositorySessionWipeDelegate delegate) { + inner.wipe(delegate); + } + + public class MiddlewareRepositorySessionBeginDelegate implements RepositorySessionBeginDelegate { + + private final MiddlewareRepositorySession outerSession; + private final RepositorySessionBeginDelegate next; + + public MiddlewareRepositorySessionBeginDelegate(MiddlewareRepositorySession outerSession, RepositorySessionBeginDelegate next) { + this.outerSession = outerSession; + this.next = next; + } + + @Override + public void onBeginFailed(Exception ex) { + next.onBeginFailed(ex); + } + + @Override + public void onBeginSucceeded(RepositorySession session) { + next.onBeginSucceeded(outerSession); + } + + @Override + public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) { + final RepositorySessionBeginDelegate deferred = next.deferredBeginDelegate(executor); + return new RepositorySessionBeginDelegate() { + @Override + public void onBeginSucceeded(RepositorySession session) { + if (inner != session) { + Logger.warn(LOG_TAG, "Got onBeginSucceeded for session " + session + ", not our inner session!"); + } + deferred.onBeginSucceeded(outerSession); + } + + @Override + public void onBeginFailed(Exception ex) { + deferred.onBeginFailed(ex); + } + + @Override + public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) { + return this; + } + }; + } + } + + @Override + public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { + inner.begin(new MiddlewareRepositorySessionBeginDelegate(this, delegate)); + } + + public class MiddlewareRepositorySessionFinishDelegate implements RepositorySessionFinishDelegate { + private final MiddlewareRepositorySession outerSession; + private final RepositorySessionFinishDelegate next; + + public MiddlewareRepositorySessionFinishDelegate(MiddlewareRepositorySession outerSession, RepositorySessionFinishDelegate next) { + this.outerSession = outerSession; + this.next = next; + } + + @Override + public void onFinishFailed(Exception ex) { + next.onFinishFailed(ex); + } + + @Override + public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) { + next.onFinishSucceeded(outerSession, bundle); + } + + @Override + public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) { + return this; + } + } + + @Override + public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + inner.finish(new MiddlewareRepositorySessionFinishDelegate(this, delegate)); + } + + + @Override + public synchronized void ensureActive() throws InactiveSessionException { + inner.ensureActive(); + } + + @Override + public synchronized boolean isActive() { + return inner.isActive(); + } + + @Override + public synchronized SessionStatus getStatus() { + return inner.getStatus(); + } + + @Override + public synchronized void setStatus(SessionStatus status) { + inner.setStatus(status); + } + + @Override + public synchronized void transitionFrom(SessionStatus from, SessionStatus to) + throws InvalidSessionTransitionException { + inner.transitionFrom(from, to); + } + + @Override + public void abort() { + inner.abort(); + } + + @Override + public void abort(RepositorySessionFinishDelegate delegate) { + inner.abort(new MiddlewareRepositorySessionFinishDelegate(this, delegate)); + } + + @Override + public void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate) { + // TODO: need to do anything here? + inner.guidsSince(timestamp, delegate); + } + + @Override + public void storeDone() { + inner.storeDone(); + } + + @Override + public void storeDone(long storeEnd) { + inner.storeDone(storeEnd); + } + + @Override + public boolean shouldSkip() { + return inner.shouldSkip(); + } + + @Override + public boolean dataAvailable() { + return inner.dataAvailable(); + } + + @Override + public void unbundle(RepositorySessionBundle bundle) { + inner.unbundle(bundle); + } + + @Override + public long getLastSyncTimestamp() { + return inner.getLastSyncTimestamp(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java new file mode 100644 index 000000000..e3b4f25b1 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java @@ -0,0 +1,34 @@ +/* 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.net; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.message.BasicHeader; +import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; + +/** + * An <code>AuthHeaderProvider</code> that returns an Authorization header for + * bearer tokens, adding a simple prefix. + */ +public abstract class AbstractBearerTokenAuthHeaderProvider implements AuthHeaderProvider { + protected final String header; + + public AbstractBearerTokenAuthHeaderProvider(String token) { + if (token == null) { + throw new IllegalArgumentException("token must not be null."); + } + + this.header = getPrefix() + " " + token; + } + + protected abstract String getPrefix(); + + @Override + public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) { + return new BasicHeader("Authorization", header); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java new file mode 100644 index 000000000..7be6fef3d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java @@ -0,0 +1,30 @@ +/* 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.net; + +import java.security.GeneralSecurityException; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; + +/** + * An <code>AuthHeaderProvider</code> generates HTTP Authorization headers for + * HTTP requests. + */ +public interface AuthHeaderProvider { + /** + * Generate an HTTP Authorization header. + * + * @param request HTTP request. + * @param context HTTP context. + * @param client HTTP client. + * @return HTTP Authorization header. + * @throws GeneralSecurityException usually wrapping a more specific exception. + */ + Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) + throws GeneralSecurityException; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java new file mode 100644 index 000000000..60bbc86bb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java @@ -0,0 +1,565 @@ +/* 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.net; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.net.ssl.SSLContext; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.HttpVersion; +import ch.boye.httpclientandroidlib.client.AuthCache; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity; +import ch.boye.httpclientandroidlib.client.methods.HttpDelete; +import ch.boye.httpclientandroidlib.client.methods.HttpGet; +import ch.boye.httpclientandroidlib.client.methods.HttpPatch; +import ch.boye.httpclientandroidlib.client.methods.HttpPost; +import ch.boye.httpclientandroidlib.client.methods.HttpPut; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; +import ch.boye.httpclientandroidlib.client.protocol.ClientContext; +import ch.boye.httpclientandroidlib.conn.ClientConnectionManager; +import ch.boye.httpclientandroidlib.conn.scheme.PlainSocketFactory; +import ch.boye.httpclientandroidlib.conn.scheme.Scheme; +import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry; +import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory; +import ch.boye.httpclientandroidlib.entity.StringEntity; +import ch.boye.httpclientandroidlib.impl.client.BasicAuthCache; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.impl.conn.tsccm.ThreadSafeClientConnManager; +import ch.boye.httpclientandroidlib.params.HttpConnectionParams; +import ch.boye.httpclientandroidlib.params.HttpParams; +import ch.boye.httpclientandroidlib.params.HttpProtocolParams; +import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; +import ch.boye.httpclientandroidlib.protocol.HttpContext; +import ch.boye.httpclientandroidlib.util.EntityUtils; + +/** + * Provide simple HTTP access to a Sync server or similar. + * Implements Basic Auth by asking its delegate for credentials. + * Communicates with a ResourceDelegate to asynchronously return responses and errors. + * Exposes simple get/post/put/delete methods. + */ +@SuppressWarnings("deprecation") +public class BaseResource implements Resource { + private static final String ANDROID_LOOPBACK_IP = "10.0.2.2"; + + private static final int MAX_TOTAL_CONNECTIONS = 20; + private static final int MAX_CONNECTIONS_PER_ROUTE = 10; + + private boolean retryOnFailedRequest = true; + + public static boolean rewriteLocalhost = true; + + private static final String LOG_TAG = "BaseResource"; + + protected final URI uri; + protected BasicHttpContext context; + protected DefaultHttpClient client; + public ResourceDelegate delegate; + protected HttpRequestBase request; + public final String charset = "utf-8"; + + private boolean shouldGzipCompress = false; + // A hint whether uploaded payloads are chunked. Default true to use GzipCompressingEntity, which is built-in functionality. + private boolean shouldChunkUploadsHint = true; + + /** + * We have very few writes (observers tend to be installed around sync + * sessions) and many iterations (every HTTP request iterates observers), so + * CopyOnWriteArrayList is a reasonable choice. + */ + protected static final CopyOnWriteArrayList<WeakReference<HttpResponseObserver>> + httpResponseObservers = new CopyOnWriteArrayList<>(); + + public BaseResource(String uri) throws URISyntaxException { + this(uri, rewriteLocalhost); + } + + public BaseResource(URI uri) { + this(uri, rewriteLocalhost); + } + + public BaseResource(String uri, boolean rewrite) throws URISyntaxException { + this(new URI(uri), rewrite); + } + + public BaseResource(URI uri, boolean rewrite) { + if (uri == null) { + throw new IllegalArgumentException("uri must not be null"); + } + if (rewrite && "localhost".equals(uri.getHost())) { + // Rewrite localhost URIs to refer to the special Android emulator loopback passthrough interface. + Logger.debug(LOG_TAG, "Rewriting " + uri + " to point to " + ANDROID_LOOPBACK_IP + "."); + try { + this.uri = new URI(uri.getScheme(), uri.getUserInfo(), ANDROID_LOOPBACK_IP, uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment()); + } catch (URISyntaxException e) { + Logger.error(LOG_TAG, "Got error rewriting URI for Android emulator.", e); + throw new IllegalArgumentException("Invalid URI", e); + } + } else { + this.uri = uri; + } + } + + public static void addHttpResponseObserver(HttpResponseObserver newHttpResponseObserver) { + if (newHttpResponseObserver == null) { + return; + } + httpResponseObservers.add(new WeakReference<HttpResponseObserver>(newHttpResponseObserver)); + } + + public static boolean isHttpResponseObserver(HttpResponseObserver httpResponseObserver) { + for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) { + HttpResponseObserver innerHttpResponseObserver = weakReference.get(); + if (innerHttpResponseObserver == httpResponseObserver) { + return true; + } + } + return false; + } + + public static boolean removeHttpResponseObserver(HttpResponseObserver httpResponseObserver) { + for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) { + HttpResponseObserver innerHttpResponseObserver = weakReference.get(); + if (innerHttpResponseObserver == httpResponseObserver) { + // It's safe to mutate the observers while iterating. + httpResponseObservers.remove(weakReference); + return true; + } + } + return false; + } + + @Override + public URI getURI() { + return this.uri; + } + + @Override + public String getURIString() { + return this.uri.toString(); + } + + @Override + public String getHostname() { + return this.getURI().getHost(); + } + + /** + * Causes the Resource to compress the uploaded entity payload in requests with payloads (e.g. post, put) + * @param shouldCompress true if the entity should be compressed, false otherwise + */ + public void setShouldCompressUploadedEntity(final boolean shouldCompress) { + shouldGzipCompress = shouldCompress; + } + + /** + * Causes the Resource to chunk the uploaded entity payload in requests with payloads (e.g. post, put). + * Note: this flag is only a hint - chunking is not guaranteed. + * + * Chunking is currently supported with gzip compression. + * + * @param shouldChunk true if the transfer should be chunked, false otherwise + */ + public void setShouldChunkUploadsHint(final boolean shouldChunk) { + shouldChunkUploadsHint = shouldChunk; + } + + private HttpEntity getMaybeCompressedEntity(final HttpEntity entity) { + if (!shouldGzipCompress) { + return entity; + } + + return shouldChunkUploadsHint ? new GzipCompressingEntity(entity) : new GzipNonChunkedCompressingEntity(entity); + } + + /** + * This shuts up HttpClient, which will otherwise debug log about there + * being no auth cache in the context. + */ + private static void addAuthCacheToContext(HttpUriRequest request, HttpContext context) { + AuthCache authCache = new BasicAuthCache(); // Not thread safe. + context.setAttribute(ClientContext.AUTH_CACHE, authCache); + } + + /** + * Invoke this after delegate and request have been set. + * @throws NoSuchAlgorithmException + * @throws KeyManagementException + */ + protected void prepareClient() throws KeyManagementException, NoSuchAlgorithmException, GeneralSecurityException { + context = new BasicHttpContext(); + + // We could reuse these client instances, except that we mess around + // with their parameters… so we'd need a pool of some kind. + client = new DefaultHttpClient(getConnectionManager()); + + // TODO: Eventually we should use Apache HttpAsyncClient. It's not out of alpha yet. + // Until then, we synchronously make the request, then invoke our delegate's callback. + AuthHeaderProvider authHeaderProvider = delegate.getAuthHeaderProvider(); + if (authHeaderProvider != null) { + Header authHeader = authHeaderProvider.getAuthHeader(request, context, client); + if (authHeader != null) { + request.addHeader(authHeader); + Logger.debug(LOG_TAG, "Added auth header."); + } + } + + addAuthCacheToContext(request, context); + + HttpParams params = client.getParams(); + HttpConnectionParams.setConnectionTimeout(params, delegate.connectionTimeout()); + HttpConnectionParams.setSoTimeout(params, delegate.socketTimeout()); + HttpConnectionParams.setStaleCheckingEnabled(params, false); + HttpProtocolParams.setContentCharset(params, charset); + HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1); + final String userAgent = delegate.getUserAgent(); + if (userAgent != null) { + HttpProtocolParams.setUserAgent(params, userAgent); + } + delegate.addHeaders(request, client); + } + + private static final Object connManagerMonitor = new Object(); + private static ClientConnectionManager connManager; + + // Call within a synchronized block on connManagerMonitor. + private static ClientConnectionManager enableTLSConnectionManager() throws KeyManagementException, NoSuchAlgorithmException { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, new SecureRandom()); + + Logger.debug(LOG_TAG, "Using protocols and cipher suites for Android API " + android.os.Build.VERSION.SDK_INT); + SSLSocketFactory sf = new SSLSocketFactory(sslContext, GlobalConstants.DEFAULT_PROTOCOLS, GlobalConstants.DEFAULT_CIPHER_SUITES, null); + SchemeRegistry schemeRegistry = new SchemeRegistry(); + schemeRegistry.register(new Scheme("https", 443, sf)); + schemeRegistry.register(new Scheme("http", 80, new PlainSocketFactory())); + ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(schemeRegistry); + + cm.setMaxTotal(MAX_TOTAL_CONNECTIONS); + cm.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE); + connManager = cm; + return cm; + } + + public static ClientConnectionManager getConnectionManager() throws KeyManagementException, NoSuchAlgorithmException + { + // TODO: shutdown. + synchronized (connManagerMonitor) { + if (connManager != null) { + return connManager; + } + return enableTLSConnectionManager(); + } + } + + /** + * Do some cleanup, so we don't need the stale connection check. + */ + public static void closeExpiredConnections() { + ClientConnectionManager connectionManager; + synchronized (connManagerMonitor) { + connectionManager = connManager; + } + if (connectionManager == null) { + return; + } + Logger.trace(LOG_TAG, "Closing expired connections."); + connectionManager.closeExpiredConnections(); + } + + public static void shutdownConnectionManager() { + ClientConnectionManager connectionManager; + synchronized (connManagerMonitor) { + connectionManager = connManager; + connManager = null; + } + if (connectionManager == null) { + return; + } + Logger.debug(LOG_TAG, "Shutting down connection manager."); + connectionManager.shutdown(); + } + + private void execute() { + HttpResponse response; + try { + response = client.execute(request, context); + Logger.debug(LOG_TAG, "Response: " + response.getStatusLine().toString()); + } catch (ClientProtocolException e) { + delegate.handleHttpProtocolException(e); + return; + } catch (IOException e) { + Logger.debug(LOG_TAG, "I/O exception returned from execute."); + if (!retryOnFailedRequest) { + delegate.handleHttpIOException(e); + } else { + retryRequest(); + } + return; + } catch (Exception e) { + // Bug 740731: Don't let an exception fall through. Wrapping isn't + // optimal, but often the exception is treated as an Exception anyway. + if (!retryOnFailedRequest) { + // Bug 769671: IOException(Throwable cause) was added only in API level 9. + final IOException ex = new IOException(); + ex.initCause(e); + delegate.handleHttpIOException(ex); + } else { + retryRequest(); + } + return; + } + + // Don't retry if the observer or delegate throws! + for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) { + HttpResponseObserver observer = weakReference.get(); + if (observer != null) { + observer.observeHttpResponse(request, response); + } + } + delegate.handleHttpResponse(response); + } + + private void retryRequest() { + // Only retry once. + retryOnFailedRequest = false; + Logger.debug(LOG_TAG, "Retrying request..."); + this.execute(); + } + + private void go(HttpRequestBase request) { + if (delegate == null) { + throw new IllegalArgumentException("No delegate provided."); + } + this.request = request; + try { + this.prepareClient(); + } catch (KeyManagementException e) { + Logger.error(LOG_TAG, "Couldn't prepare client.", e); + delegate.handleTransportException(e); + return; + } catch (GeneralSecurityException e) { + Logger.error(LOG_TAG, "Couldn't prepare client.", e); + delegate.handleTransportException(e); + return; + } catch (Exception e) { + // Bug 740731: Don't let an exception fall through. Wrapping isn't + // optimal, but often the exception is treated as an Exception anyway. + delegate.handleTransportException(new GeneralSecurityException(e)); + return; + } + this.execute(); + } + + @Override + public void get() { + Logger.debug(LOG_TAG, "HTTP GET " + this.uri.toASCIIString()); + this.go(new HttpGet(this.uri)); + } + + /** + * Perform an HTTP GET as with {@link BaseResource#get()}, returning only + * after callbacks have been invoked. + */ + public void getBlocking() { + // Until we use the asynchronous Apache HttpClient, we can simply call + // through. + this.get(); + } + + @Override + public void delete() { + Logger.debug(LOG_TAG, "HTTP DELETE " + this.uri.toASCIIString()); + this.go(new HttpDelete(this.uri)); + } + + @Override + public void post(HttpEntity body) { + Logger.debug(LOG_TAG, "HTTP POST " + this.uri.toASCIIString()); + body = getMaybeCompressedEntity(body); + HttpPost request = new HttpPost(this.uri); + request.setEntity(body); + this.go(request); + } + + @Override + public void patch(HttpEntity body) { + Logger.debug(LOG_TAG, "HTTP PATCH " + this.uri.toASCIIString()); + body = getMaybeCompressedEntity(body); + HttpPatch request = new HttpPatch(this.uri); + request.setEntity(body); + this.go(request); + } + + @Override + public void put(HttpEntity body) { + Logger.debug(LOG_TAG, "HTTP PUT " + this.uri.toASCIIString()); + body = getMaybeCompressedEntity(body); + HttpPut request = new HttpPut(this.uri); + request.setEntity(body); + this.go(request); + } + + protected static StringEntity stringEntityWithContentTypeApplicationJSON(String s) { + StringEntity e = new StringEntity(s, "UTF-8"); + e.setContentType("application/json"); + return e; + } + + /** + * Helper for turning a JSON object into a payload. + * @throws UnsupportedEncodingException + */ + protected static StringEntity jsonEntity(JSONObject body) { + return stringEntityWithContentTypeApplicationJSON(body.toJSONString()); + } + + /** + * Helper for turning an extended JSON object into a payload. + * @throws UnsupportedEncodingException + */ + protected static StringEntity jsonEntity(ExtendedJSONObject body) { + return stringEntityWithContentTypeApplicationJSON(body.toJSONString()); + } + + /** + * Helper for turning a JSON array into a payload. + * @throws UnsupportedEncodingException + */ + protected static HttpEntity jsonEntity(JSONArray toPOST) throws UnsupportedEncodingException { + return stringEntityWithContentTypeApplicationJSON(toPOST.toJSONString()); + } + + /** + * Best-effort attempt to ensure that the entity has been fully consumed and + * that the underlying stream has been closed. + * + * This releases the connection back to the connection pool. + * + * @param entity The HttpEntity to be consumed. + */ + public static void consumeEntity(HttpEntity entity) { + try { + EntityUtils.consume(entity); + } catch (IOException e) { + // Doesn't matter. + } + } + + /** + * Best-effort attempt to ensure that the entity corresponding to the given + * HTTP response has been fully consumed and that the underlying stream has + * been closed. + * + * This releases the connection back to the connection pool. + * + * @param response + * The HttpResponse to be consumed. + */ + public static void consumeEntity(HttpResponse response) { + if (response == null) { + return; + } + try { + EntityUtils.consume(response.getEntity()); + } catch (IOException e) { + } + } + + /** + * Best-effort attempt to ensure that the entity corresponding to the given + * Sync storage response has been fully consumed and that the underlying + * stream has been closed. + * + * This releases the connection back to the connection pool. + * + * @param response + * The SyncStorageResponse to be consumed. + */ + public static void consumeEntity(SyncStorageResponse response) { + if (response.httpResponse() == null) { + return; + } + consumeEntity(response.httpResponse()); + } + + /** + * Best-effort attempt to ensure that the reader has been fully consumed, so + * that the underlying stream will be closed. + * + * This should allow the connection to be released back to the connection pool. + * + * @param reader The BufferedReader to be consumed. + */ + public static void consumeReader(BufferedReader reader) { + try { + reader.close(); + } catch (IOException e) { + // Do nothing. + } + } + + public void post(JSONArray jsonArray) throws UnsupportedEncodingException { + post(jsonEntity(jsonArray)); + } + + public void put(JSONObject jsonObject) throws UnsupportedEncodingException { + put(jsonEntity(jsonObject)); + } + + public void put(ExtendedJSONObject o) { + put(jsonEntity(o)); + } + + public void post(ExtendedJSONObject o) { + post(jsonEntity(o)); + } + + /** + * Perform an HTTP POST as with {@link BaseResource#post(ExtendedJSONObject)}, returning only + * after callbacks have been invoked. + */ + public void postBlocking(final ExtendedJSONObject o) { + // Until we use the asynchronous Apache HttpClient, we can simply call + // through. + post(jsonEntity(o)); + } + + public void post(JSONObject jsonObject) throws UnsupportedEncodingException { + post(jsonEntity(jsonObject)); + } + + public void patch(JSONArray jsonArray) throws UnsupportedEncodingException { + patch(jsonEntity(jsonArray)); + } + + public void patch(ExtendedJSONObject o) { + patch(jsonEntity(o)); + } + + public void patch(JSONObject jsonObject) throws UnsupportedEncodingException { + patch(jsonEntity(jsonObject)); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java new file mode 100644 index 000000000..84ae7a3d5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java @@ -0,0 +1,44 @@ +/* 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.net; + +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +/** + * Shared abstract class for resource delegate that use the same timeouts + * and no credentials. + * + * @author rnewman + * + */ +public abstract class BaseResourceDelegate implements ResourceDelegate { + public static int connectionTimeoutInMillis = 1000 * 30; // Wait 30s for a connection to open. + public static int socketTimeoutInMillis = 1000 * 2 * 60; // Wait 2 minutes for data. + + protected Resource resource; + public BaseResourceDelegate(Resource resource) { + this.resource = resource; + } + + @Override + public int connectionTimeout() { + return connectionTimeoutInMillis; + } + + @Override + public int socketTimeout() { + return socketTimeoutInMillis; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return null; + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java new file mode 100644 index 000000000..d8a371ddc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java @@ -0,0 +1,51 @@ +/* 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.net; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.auth.Credentials; +import ch.boye.httpclientandroidlib.auth.UsernamePasswordCredentials; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.auth.BasicScheme; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; + +/** + * An <code>AuthHeaderProvider</code> that returns an HTTP Basic auth header. + */ +public class BasicAuthHeaderProvider implements AuthHeaderProvider { + protected final String credentials; + + /** + * Constructor. + * + * @param credentials string in form "user:pass". + */ + public BasicAuthHeaderProvider(String credentials) { + this.credentials = credentials; + } + + /** + * Constructor. + * + * @param user username. + * @param pass password. + */ + public BasicAuthHeaderProvider(String user, String pass) { + this(user + ":" + pass); + } + + /** + * Return a Header object representing an Authentication header for HTTP + * Basic. + */ + @Override + public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) { + Credentials creds = new UsernamePasswordCredentials(credentials); + + // This must be UTF-8 to generate the same Basic Auth headers as desktop for non-ASCII passwords. + return BasicScheme.authenticate(creds, "UTF-8", false); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java new file mode 100644 index 000000000..d142d50d9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java @@ -0,0 +1,22 @@ +/* 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.net; + +/** + * An <code>AuthHeaderProvider</code> that returns an Authorization header for + * Bearer tokens in the format expected by a Mozilla Firefox Accounts Profile Server. + * <p> + * See <a href="https://github.com/mozilla/fxa-profile-server/blob/master/docs/API.md">https://github.com/mozilla/fxa-profile-server/blob/master/docs/API.md</a>. + */ +public class BearerAuthHeaderProvider extends AbstractBearerTokenAuthHeaderProvider { + public BearerAuthHeaderProvider(String token) { + super(token); + } + + @Override + protected String getPrefix() { + return "Bearer"; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java new file mode 100644 index 000000000..5004673b3 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java @@ -0,0 +1,23 @@ +/* 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.net; + +/** + * An <code>AuthHeaderProvider</code> that returns an Authorization header for + * BrowserID assertions in the format expected by a Mozilla Services Token + * Server. + * <p> + * See <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>. + */ +public class BrowserIDAuthHeaderProvider extends AbstractBearerTokenAuthHeaderProvider { + public BrowserIDAuthHeaderProvider(String assertion) { + super(assertion); + } + + @Override + protected String getPrefix() { + return "BrowserID"; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java new file mode 100644 index 000000000..1a2011771 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java @@ -0,0 +1,44 @@ +/* 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.net; + +import org.mozilla.gecko.background.common.log.Logger; + +/** + * Every <code>REAP_INTERVAL</code> milliseconds, wake up + * and expire any connections that need cleaning up. + * + * When we're told to shut down, take the connection manager + * with us. + */ +public class ConnectionMonitorThread extends Thread { + private static final long REAP_INTERVAL = 5000; // 5 seconds. + private static final String LOG_TAG = "ConnectionMonitorThread"; + + private volatile boolean stopping; + + @Override + public void run() { + try { + while (!stopping) { + synchronized (this) { + wait(REAP_INTERVAL); + BaseResource.closeExpiredConnections(); + } + } + } catch (InterruptedException e) { + Logger.trace(LOG_TAG, "Interrupted."); + } + BaseResource.shutdownConnectionManager(); + } + + public void shutdown() { + Logger.debug(LOG_TAG, "ConnectionMonitorThread told to shut down."); + stopping = true; + synchronized (this) { + notifyAll(); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java new file mode 100644 index 000000000..1e238c022 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java @@ -0,0 +1,92 @@ +/* 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.net; + +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Wrapping entity that compresses content when {@link #writeTo writing}. + * + * This differs from {@link GzipCompressingEntity} in that it does not chunk + * the sent data, therefore replacing the "Transfer-Encoding" HTTP header with + * the "Content-Length" header required by some servers. + * + * However, to measure the content length, the gzipped content will be temporarily + * stored in memory so be careful what content you send! + */ +public class GzipNonChunkedCompressingEntity extends GzipCompressingEntity { + final int MAX_BUFFER_SIZE_BYTES = 10 * 1000 * 1000; // 10 MB. + + private byte[] gzippedContent; + + public GzipNonChunkedCompressingEntity(final HttpEntity entity) { + super(entity); + } + + /** + * @return content length for gzipped content or -1 if there is an error + */ + @Override + public long getContentLength() { + try { + initBuffer(); + } catch (final IOException e) { + // GzipCompressingEntity always returns -1 in which case a 'Content-Length' header is omitted. + // Presumably, without it the request will fail (either client-side or server-side). + return -1; + } + return gzippedContent.length; + } + + @Override + public boolean isChunked() { + // "Content-Length" & chunked encoding are mutually exclusive: + // https://en.wikipedia.org/wiki/Chunked_transfer_encoding + return false; + } + + @Override + public InputStream getContent() throws IOException { + initBuffer(); + return new ByteArrayInputStream(gzippedContent); + } + + @Override + public void writeTo(final OutputStream outstream) throws IOException { + initBuffer(); + outstream.write(gzippedContent); + } + + private void initBuffer() throws IOException { + if (gzippedContent != null) { + return; + } + + final long unzippedContentLength = wrappedEntity.getContentLength(); + if (unzippedContentLength > MAX_BUFFER_SIZE_BYTES) { + throw new IOException( + "Wrapped entity content length, " + unzippedContentLength + " bytes, exceeds max: " + MAX_BUFFER_SIZE_BYTES); + } + + // The buffer size needed by the gzipped content should be smaller than this, + // but it's more efficient just to allocate one larger buffer than allocate + // twice if the gzipped content is too large for the default buffer. + final ByteArrayOutputStream s = new ByteArrayOutputStream((int) unzippedContentLength); + try { + super.writeTo(s); + } finally { + s.close(); + } + + gzippedContent = s.toByteArray(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java new file mode 100644 index 000000000..5314d345b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java @@ -0,0 +1,257 @@ +/* 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.net; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.Utils; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.message.BasicHeader; +import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; + +/** + * An <code>AuthHeaderProvider</code> that returns an Authorization header for + * HMAC-SHA1-signed requests in the format expected by Mozilla Services + * identity-attached services and specified by the MAC Authentication spec, available at + * <a href="https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac">https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac</a>. + * <p> + * See <a href="https://wiki.mozilla.org/Services/Sagrada/ServiceClientFlow#Access">https://wiki.mozilla.org/Services/Sagrada/ServiceClientFlow#Access</a>. + */ +public class HMACAuthHeaderProvider implements AuthHeaderProvider { + public static final String LOG_TAG = "HMACAuthHeaderProvider"; + + public static final int NONCE_LENGTH_IN_BYTES = 8; + + public static final String HMAC_SHA1_ALGORITHM = "hmacSHA1"; + + public final String identifier; + public final String key; + + public HMACAuthHeaderProvider(String identifier, String key) { + // Validate identifier string. From the MAC Authentication spec: + // id = "id" "=" string-value + // string-value = ( <"> plain-string <"> ) / plain-string + // plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E ) + // We add quotes around the id string, so input identifier must be a plain-string. + if (identifier == null) { + throw new IllegalArgumentException("identifier must not be null."); + } + if (!isPlainString(identifier)) { + throw new IllegalArgumentException("identifier must be a plain-string."); + } + + if (key == null) { + throw new IllegalArgumentException("key must not be null."); + } + + this.identifier = identifier; + this.key = key; + } + + @Override + public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException { + long timestamp = System.currentTimeMillis() / 1000; + String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES)); + String extra = ""; + + try { + return getAuthHeader(request, context, client, timestamp, nonce, extra); + } catch (InvalidKeyException | NoSuchAlgorithmException | UnsupportedEncodingException e) { + // We lie a little and make every exception a GeneralSecurityException. + throw new GeneralSecurityException(e); + } + } + + /** + * Test if input is a <code>plain-string</code>. + * <p> + * A plain-string is defined by the MAC Authentication spec as + * <code>plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E )</code>. + * + * @param input + * as a String of "US-ASCII" bytes. + * @return true if input is a <code>plain-string</code>; false otherwise. + * @throws UnsupportedEncodingException + */ + protected static boolean isPlainString(String input) { + if (input == null || input.length() == 0) { + return false; + } + + byte[] bytes; + try { + bytes = input.getBytes("US-ASCII"); + } catch (UnsupportedEncodingException e) { + // Should never happen. + Logger.warn(LOG_TAG, "Got exception in isPlainString; returning false.", e); + return false; + } + + for (byte b : bytes) { + if ((0x20 <= b && b <= 0x21) || (0x23 <= b && b <= 0x5B) || (0x5D <= b && b <= 0x7E)) { + continue; + } + return false; + } + + return true; + } + + /** + * Helper function that generates an HTTP Authorization header given + * additional MAC Authentication specific data. + * + * @throws UnsupportedEncodingException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + */ + protected Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client, + long timestamp, String nonce, String extra) + throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException { + // Validate timestamp. From the MAC Authentication spec: + // timestamp = 1*DIGIT + // This is equivalent to timestamp >= 0. + if (timestamp < 0) { + throw new IllegalArgumentException("timestamp must contain only [0-9]."); + } + + // Validate nonce string. From the MAC Authentication spec: + // nonce = "nonce" "=" string-value + // string-value = ( <"> plain-string <"> ) / plain-string + // plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E ) + // We add quotes around the nonce string, so input nonce must be a plain-string. + if (nonce == null) { + throw new IllegalArgumentException("nonce must not be null."); + } + if (nonce.length() == 0) { + throw new IllegalArgumentException("nonce must not be empty."); + } + if (!isPlainString(nonce)) { + throw new IllegalArgumentException("nonce must be a plain-string."); + } + + // Validate extra string. From the MAC Authentication spec: + // ext = "ext" "=" string-value + // string-value = ( <"> plain-string <"> ) / plain-string + // plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E ) + // We add quotes around the extra string, so input extra must be a plain-string. + // We break the spec by allowing ext to be an empty string, i.e. to match 0*(...). + if (extra == null) { + throw new IllegalArgumentException("extra must not be null."); + } + if (extra.length() > 0 && !isPlainString(extra)) { + throw new IllegalArgumentException("extra must be a plain-string."); + } + + String requestString = getRequestString(request, timestamp, nonce, extra); + String macString = getSignature(requestString, this.key); + + String h = "MAC id=\"" + this.identifier + "\", " + + "ts=\"" + timestamp + "\", " + + "nonce=\"" + nonce + "\", " + + "mac=\"" + macString + "\""; + + if (extra != null) { + h += ", ext=\"" + extra + "\""; + } + + Header header = new BasicHeader("Authorization", h); + + return header; + } + + protected static byte[] sha1(byte[] message, byte[] key) + throws NoSuchAlgorithmException, InvalidKeyException { + + SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA1_ALGORITHM); + + Mac hasher = Mac.getInstance(HMAC_SHA1_ALGORITHM); + hasher.init(keySpec); + hasher.update(message); + + byte[] hmac = hasher.doFinal(); + + return hmac; + } + + /** + * Sign an HMAC request string. + * + * @param requestString to sign. + * @param key as <code>String</code>. + * @return signature as base-64 encoded string. + * @throws InvalidKeyException + * @throws NoSuchAlgorithmException + * @throws UnsupportedEncodingException + */ + protected static String getSignature(String requestString, String key) + throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + String macString = Base64.encodeBase64String(sha1(requestString.getBytes("UTF-8"), key.getBytes("UTF-8"))); + + return macString; + } + + /** + * Generate an HMAC request string. + * <p> + * This method trusts its inputs to be valid as per the MAC Authentication spec. + * + * @param request HTTP request. + * @param timestamp to use. + * @param nonce to use. + * @param extra to use. + * @return request string. + */ + protected static String getRequestString(HttpUriRequest request, long timestamp, String nonce, String extra) { + String method = request.getMethod().toUpperCase(); + + URI uri = request.getURI(); + String host = uri.getHost(); + + String path = uri.getRawPath(); + if (uri.getRawQuery() != null) { + path += "?"; + path += uri.getRawQuery(); + } + if (uri.getRawFragment() != null) { + path += "#"; + path += uri.getRawFragment(); + } + + int port = uri.getPort(); + String scheme = uri.getScheme(); + if (port != -1) { + } else if ("http".equalsIgnoreCase(scheme)) { + port = 80; + } else if ("https".equalsIgnoreCase(scheme)) { + port = 443; + } else { + throw new IllegalArgumentException("Unsupported URI scheme: " + scheme + "."); + } + + String requestString = timestamp + "\n" + + nonce + "\n" + + method + "\n" + + path + "\n" + + host + "\n" + + port + "\n" + + extra + "\n"; + + return requestString; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java new file mode 100644 index 000000000..27ec74b66 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java @@ -0,0 +1,15 @@ +/* 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.net; + +import org.mozilla.gecko.sync.SyncException; + +public class HandleProgressException extends SyncException { + private static final long serialVersionUID = -4444933937013161059L; + + public HandleProgressException(Exception ex) { + super(ex); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java new file mode 100644 index 000000000..2bdd5604a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java @@ -0,0 +1,403 @@ +/* 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.net; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.mozilla.apache.commons.codec.binary.Base64; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.Utils; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpEntityEnclosingRequest; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.message.BasicHeader; +import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; + +/** + * An <code>AuthHeaderProvider</code> that returns an Authorization header for + * Hawk: <a href="https://github.com/hueniverse/hawk">https://github.com/hueniverse/hawk</a>. + * + * Hawk is an HTTP authentication scheme using a message authentication code + * (MAC) algorithm to provide partial HTTP request cryptographic verification. + * Hawk is the successor to the HMAC authentication scheme. + */ +public class HawkAuthHeaderProvider implements AuthHeaderProvider { + public static final String LOG_TAG = HawkAuthHeaderProvider.class.getSimpleName(); + + public static final int HAWK_HEADER_VERSION = 1; + + protected static final int NONCE_LENGTH_IN_BYTES = 8; + protected static final String HMAC_SHA256_ALGORITHM = "hmacSHA256"; + + protected final String id; + protected final byte[] key; + protected final boolean includePayloadHash; + protected final long skewSeconds; + + /** + * Create a Hawk Authorization header provider. + * <p> + * Hawk specifies no mechanism by which a client receives an + * identifier-and-key pair from the server. + * <p> + * Hawk requests can include a payload verification hash with requests that + * enclose an entity (PATCH, POST, and PUT requests). <b>You should default + * to including the payload verification hash<b> unless you have a good reason + * not to -- the server can always ignore payload verification hashes provided + * by the client. + * + * @param id + * to name requests with. + * @param key + * to sign request with. + * + * @param includePayloadHash + * true if payload verification hash should be included in signed + * request header. See <a href="https://github.com/hueniverse/hawk#payload-validation">https://github.com/hueniverse/hawk#payload-validation</a>. + * + * @param skewSeconds + * a number of seconds by which to skew the current time when + * computing a header. + */ + public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash, long skewSeconds) { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + this.id = id; + this.key = key; + this.includePayloadHash = includePayloadHash; + this.skewSeconds = skewSeconds; + } + + /** + * @return the current time in milliseconds. + */ + @SuppressWarnings("static-method") + protected long now() { + return System.currentTimeMillis(); + } + + /** + * @return the current time in seconds, adjusted for skew. This should + * approximate the server's timestamp. + */ + protected long getTimestampSeconds() { + return (now() / 1000) + skewSeconds; + } + + @Override + public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException { + long timestamp = getTimestampSeconds(); + String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES)); + String extra = ""; + + try { + return getAuthHeader(request, context, client, timestamp, nonce, extra, this.includePayloadHash); + } catch (Exception e) { + // We lie a little and make every exception a GeneralSecurityException. + throw new GeneralSecurityException(e); + } + } + + /** + * Helper function that generates an HTTP Authorization: Hawk header given + * additional Hawk specific data. + * + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + * @throws IOException + */ + protected Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client, + long timestamp, String nonce, String extra, boolean includePayloadHash) + throws InvalidKeyException, NoSuchAlgorithmException, IOException { + if (timestamp < 0) { + throw new IllegalArgumentException("timestamp must contain only [0-9]."); + } + + if (nonce == null) { + throw new IllegalArgumentException("nonce must not be null."); + } + if (nonce.length() == 0) { + throw new IllegalArgumentException("nonce must not be empty."); + } + + String payloadHash = null; + if (includePayloadHash) { + payloadHash = getPayloadHashString(request); + } else { + Logger.debug(LOG_TAG, "Configured to not include payload hash for this request."); + } + + String app = null; + String dlg = null; + String requestString = getRequestString(request, "header", timestamp, nonce, payloadHash, extra, app, dlg); + String macString = getSignature(requestString.getBytes("UTF-8"), this.key); + + StringBuilder sb = new StringBuilder(); + sb.append("Hawk id=\""); + sb.append(this.id); + sb.append("\", "); + sb.append("ts=\""); + sb.append(timestamp); + sb.append("\", "); + sb.append("nonce=\""); + sb.append(nonce); + sb.append("\", "); + if (payloadHash != null) { + sb.append("hash=\""); + sb.append(payloadHash); + sb.append("\", "); + } + if (extra != null && extra.length() > 0) { + sb.append("ext=\""); + sb.append(escapeExtraHeaderAttribute(extra)); + sb.append("\", "); + } + sb.append("mac=\""); + sb.append(macString); + sb.append("\""); + + return new BasicHeader("Authorization", sb.toString()); + } + + /** + * Get the payload verification hash for the given request, if possible. + * <p> + * Returns null if the request does not enclose an entity (is not an HTTP + * PATCH, POST, or PUT). Throws if the payload verification hash cannot be + * computed. + * + * @param request + * to compute hash for. + * @return verification hash, or null if the request does not enclose an entity. + * @throws IllegalArgumentException if the request does not enclose a valid non-null entity. + * @throws UnsupportedEncodingException + * @throws NoSuchAlgorithmException + * @throws IOException + */ + protected static String getPayloadHashString(HttpRequestBase request) + throws UnsupportedEncodingException, NoSuchAlgorithmException, IOException, IllegalArgumentException { + final boolean shouldComputePayloadHash = request instanceof HttpEntityEnclosingRequest; + if (!shouldComputePayloadHash) { + Logger.debug(LOG_TAG, "Not computing payload verification hash for non-enclosing request."); + return null; + } + if (!(request instanceof HttpEntityEnclosingRequest)) { + throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request without an entity"); + } + final HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); + if (entity == null) { + throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request with a null entity"); + } + return Base64.encodeBase64String(getPayloadHash(entity)); + } + + /** + * Escape the user-provided extra string for the ext="" header attribute. + * <p> + * Hawk escapes the header ext="" attribute differently than it does the extra + * line in the normalized request string. + * <p> + * See <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385</a>. + * + * @param extra to escape. + * @return extra escaped for the ext="" header attribute. + */ + protected static String escapeExtraHeaderAttribute(String extra) { + return extra.replaceAll("\\\\", "\\\\").replaceAll("\"", "\\\""); + } + + /** + * Escape the user-provided extra string for inserting into the normalized + * request string. + * <p> + * Hawk escapes the header ext="" attribute differently than it does the extra + * line in the normalized request string. + * <p> + * See <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67</a>. + * + * @param extra to escape. + * @return extra escaped for the normalized request string. + */ + protected static String escapeExtraString(String extra) { + return extra.replaceAll("\\\\", "\\\\").replaceAll("\n", "\\n"); + } + + /** + * Return the content type with no parameters (pieces following ;). + * + * @param contentTypeHeader to interrogate. + * @return base content type. + */ + protected static String getBaseContentType(Header contentTypeHeader) { + if (contentTypeHeader == null) { + throw new IllegalArgumentException("contentTypeHeader must not be null."); + } + String contentType = contentTypeHeader.getValue(); + if (contentType == null) { + throw new IllegalArgumentException("contentTypeHeader value must not be null."); + } + int index = contentType.indexOf(";"); + if (index < 0) { + return contentType.trim(); + } + return contentType.substring(0, index).trim(); + } + + /** + * Generate the SHA-256 hash of a normalized Hawk payload generated from an + * HTTP entity. + * <p> + * <b>Warning:</b> the entity <b>must</b> be repeatable. If it is not, this + * code throws an <code>IllegalArgumentException</code>. + * <p> + * This is under-specified; the code here was reverse engineered from the code + * at + * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81</a>. + * @param entity to normalize and hash. + * @return hash. + * @throws IllegalArgumentException if entity is not repeatable. + */ + protected static byte[] getPayloadHash(HttpEntity entity) throws UnsupportedEncodingException, IOException, NoSuchAlgorithmException { + if (!entity.isRepeatable()) { + throw new IllegalArgumentException("entity must be repeatable"); + } + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(("hawk." + HAWK_HEADER_VERSION + ".payload\n").getBytes("UTF-8")); + digest.update(getBaseContentType(entity.getContentType()).getBytes("UTF-8")); + digest.update("\n".getBytes("UTF-8")); + InputStream stream = entity.getContent(); + try { + int numRead; + byte[] buffer = new byte[4096]; + while (-1 != (numRead = stream.read(buffer))) { + if (numRead > 0) { + digest.update(buffer, 0, numRead); + } + } + digest.update("\n".getBytes("UTF-8")); // Trailing newline is specified by Hawk. + return digest.digest(); + } finally { + stream.close(); + } + } + + /** + * Generate a normalized Hawk request string. This is under-specified; the + * code here was reverse engineered from the code at + * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55</a>. + * <p> + * This method trusts its inputs to be valid. + */ + protected static String getRequestString(HttpUriRequest request, String type, long timestamp, String nonce, String hash, String extra, String app, String dlg) { + String method = request.getMethod().toUpperCase(Locale.US); + + URI uri = request.getURI(); + String host = uri.getHost(); + + String path = uri.getRawPath(); + if (uri.getRawQuery() != null) { + path += "?"; + path += uri.getRawQuery(); + } + if (uri.getRawFragment() != null) { + path += "#"; + path += uri.getRawFragment(); + } + + int port = uri.getPort(); + String scheme = uri.getScheme(); + if (port != -1) { + } else if ("http".equalsIgnoreCase(scheme)) { + port = 80; + } else if ("https".equalsIgnoreCase(scheme)) { + port = 443; + } else { + throw new IllegalArgumentException("Unsupported URI scheme: " + scheme + "."); + } + + StringBuilder sb = new StringBuilder(); + sb.append("hawk."); + sb.append(HAWK_HEADER_VERSION); + sb.append('.'); + sb.append(type); + sb.append('\n'); + sb.append(timestamp); + sb.append('\n'); + sb.append(nonce); + sb.append('\n'); + sb.append(method); + sb.append('\n'); + sb.append(path); + sb.append('\n'); + sb.append(host); + sb.append('\n'); + sb.append(port); + sb.append('\n'); + if (hash != null) { + sb.append(hash); + } + sb.append("\n"); + if (extra != null && extra.length() > 0) { + sb.append(escapeExtraString(extra)); + } + sb.append("\n"); + if (app != null) { + sb.append(app); + sb.append("\n"); + if (dlg != null) { + sb.append(dlg); + } + sb.append("\n"); + } + + return sb.toString(); + } + + protected static byte[] hmacSha256(byte[] message, byte[] key) + throws NoSuchAlgorithmException, InvalidKeyException { + + SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM); + + Mac hasher = Mac.getInstance(HMAC_SHA256_ALGORITHM); + hasher.init(keySpec); + hasher.update(message); + + return hasher.doFinal(); + } + + /** + * Sign a Hawk request string. + * + * @param requestString to sign. + * @param key as <code>String</code>. + * @return signature as base-64 encoded string. + * @throws InvalidKeyException + * @throws NoSuchAlgorithmException + * @throws UnsupportedEncodingException + */ + protected static String getSignature(byte[] requestString, byte[] key) + throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + return Base64.encodeBase64String(hmacSha256(requestString, key)); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java new file mode 100644 index 000000000..24b37a0e6 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java @@ -0,0 +1,20 @@ +/* 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.net; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; + +public interface HttpResponseObserver { + /** + * Observe an HTTP response. + * @param request + * The <code>HttpUriRequest<code> that elicited the response. + * + * @param response + * The <code>HttpResponse</code> to observe. + */ + public void observeHttpResponse(HttpUriRequest request, HttpResponse response); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java new file mode 100644 index 000000000..3f76f929f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java @@ -0,0 +1,225 @@ +/* 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.net; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.Scanner; + +import org.json.simple.JSONArray; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.NonObjectJSONException; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.HttpStatus; +import ch.boye.httpclientandroidlib.impl.cookie.DateParseException; +import ch.boye.httpclientandroidlib.impl.cookie.DateUtils; + +public class MozResponse { + private static final String LOG_TAG = "MozResponse"; + + private static final String HEADER_RETRY_AFTER = "retry-after"; + + protected HttpResponse response; + private String body = null; + + public HttpResponse httpResponse() { + return this.response; + } + + public int getStatusCode() { + return this.response.getStatusLine().getStatusCode(); + } + + public boolean wasSuccessful() { + return this.getStatusCode() == 200; + } + + public boolean isInvalidAuthentication() { + return this.getStatusCode() == HttpStatus.SC_UNAUTHORIZED; + } + + /** + * Fetch the content type of the HTTP response body. + * + * @return a <code>Header</code> instance, or <code>null</code> if there was + * no body or no valid Content-Type. + */ + public Header getContentType() { + HttpEntity entity = this.response.getEntity(); + if (entity == null) { + return null; + } + return entity.getContentType(); + } + + private static boolean missingHeader(String value) { + return value == null || + value.trim().length() == 0; + } + + public String body() throws IllegalStateException, IOException { + if (body != null) { + return body; + } + final HttpEntity entity = this.response.getEntity(); + if (entity == null) { + body = null; + return null; + } + + InputStreamReader is = new InputStreamReader(entity.getContent()); + // Oh, Java, you are so evil. + body = new Scanner(is).useDelimiter("\\A").next(); + return body; + } + + /** + * Return the body as a <b>non-null</b> <code>ExtendedJSONObject</code>. + * + * @return A non-null <code>ExtendedJSONObject</code>. + * + * @throws IllegalStateException + * @throws IOException + * @throws NonObjectJSONException + */ + public ExtendedJSONObject jsonObjectBody() throws IllegalStateException, IOException, NonObjectJSONException { + if (body != null) { + // Do it from the cached String. + return new ExtendedJSONObject(body); + } + + HttpEntity entity = this.response.getEntity(); + if (entity == null) { + throw new IOException("no entity"); + } + + InputStream content = entity.getContent(); + try { + Reader in = new BufferedReader(new InputStreamReader(content, "UTF-8")); + return new ExtendedJSONObject(in); + } finally { + content.close(); + } + } + + public JSONArray jsonArrayBody() throws NonArrayJSONException, IOException { + final JSONParser parser = new JSONParser(); + try { + if (body != null) { + // Do it from the cached String. + return (JSONArray) parser.parse(body); + } + + final HttpEntity entity = this.response.getEntity(); + if (entity == null) { + throw new IOException("no entity"); + } + + final InputStream content = entity.getContent(); + final Reader in = new BufferedReader(new InputStreamReader(content, "UTF-8")); + try { + return (JSONArray) parser.parse(in); + } finally { + in.close(); + } + } catch (ClassCastException | ParseException e) { + NonArrayJSONException exception = new NonArrayJSONException("value must be a json array"); + exception.initCause(e); + throw exception; + } + } + + protected boolean hasHeader(String h) { + return this.response.containsHeader(h); + } + + public MozResponse(HttpResponse res) { + response = res; + } + + protected String getNonMissingHeader(String h) { + if (!this.hasHeader(h)) { + return null; + } + + final Header header = this.response.getFirstHeader(h); + final String value = header.getValue(); + if (missingHeader(value)) { + Logger.warn(LOG_TAG, h + " header present but empty."); + return null; + } + return value; + } + + protected long getLongHeader(String h) throws NumberFormatException { + final String value = getNonMissingHeader(h); + if (value == null) { + return -1L; + } + return Long.parseLong(value, 10); + } + + protected int getIntegerHeader(String h) throws NumberFormatException { + final String value = getNonMissingHeader(h); + if (value == null) { + return -1; + } + return Integer.parseInt(value, 10); + } + + /** + * @return A number of seconds, or -1 if the 'Retry-After' header was not present. + */ + public int retryAfterInSeconds() throws NumberFormatException { + final String retryAfter = getNonMissingHeader(HEADER_RETRY_AFTER); + if (retryAfter == null) { + return -1; + } + + try { + return Integer.parseInt(retryAfter, 10); + } catch (NumberFormatException e) { + // Fall through to try date format. + } + + try { + final long then = DateUtils.parseDate(retryAfter).getTime(); + final long now = System.currentTimeMillis(); + return (int)((then - now) / 1000); // Convert milliseconds to seconds. + } catch (DateParseException e) { + Logger.warn(LOG_TAG, "Retry-After header neither integer nor date: " + retryAfter); + return -1; + } + } + + /** + * @return A number of seconds, or -1 if the 'Backoff' header was not + * present. + */ + public int backoffInSeconds() throws NumberFormatException { + return this.getIntegerHeader("backoff"); + } + + public void logResponseBody(final String logTag) { + if (!Logger.LOG_PERSONAL_INFORMATION) { + return; + } + try { + Logger.pii(logTag, "Response body: " + body()); + } catch (Throwable e) { + Logger.debug(logTag, "No response body."); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java new file mode 100644 index 000000000..ab7b98aff --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java @@ -0,0 +1,20 @@ +/* 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.net; + +import java.net.URI; + +import ch.boye.httpclientandroidlib.HttpEntity; + +public interface Resource { + public abstract URI getURI(); + public abstract String getURIString(); + public abstract String getHostname(); + public abstract void get(); + public abstract void delete(); + public abstract void post(HttpEntity body); + public abstract void patch(HttpEntity body); + public abstract void put(HttpEntity body); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java new file mode 100644 index 000000000..0dea9432b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java @@ -0,0 +1,55 @@ +/* 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.net; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +/** + * ResourceDelegate implementers must ensure that HTTP responses + * are fully consumed to ensure that connections are returned to + * the pool: + * + * EntityUtils.consume(entity); + * @author rnewman + * + */ +public interface ResourceDelegate { + // Request augmentation. + AuthHeaderProvider getAuthHeaderProvider(); + void addHeaders(HttpRequestBase request, DefaultHttpClient client); + + /** + * The value of the User-Agent header to include with the request. + * + * @return User-Agent header value; null means do not set User-Agent header. + */ + public String getUserAgent(); + + // Response handling. + + /** + * Override this to handle an HttpResponse. + * + * ResourceDelegate implementers <b>must</b> ensure that HTTP responses are + * fully consumed to ensure that connections are returned to the pool, for + * example by calling <code>EntityUtils.consume(response.getEntity())</code>. + */ + void handleHttpResponse(HttpResponse response); + void handleHttpProtocolException(ClientProtocolException e); + void handleHttpIOException(IOException e); + + // During preparation. + void handleTransportException(GeneralSecurityException e); + + // Connection parameters. + int connectionTimeout(); + int socketTimeout(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java new file mode 100644 index 000000000..5dfe660ef --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java @@ -0,0 +1,174 @@ +/* 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.net; + +import java.math.BigInteger; + +/** + * SRP Group Parameters from + * <a href="http://tools.ietf.org/html/rfc5054#appendix-A">Appendix A of RFC 5054</a>. + * + * The 1024-, 1536-, and 2048-bit groups are taken from software + * developed by Tom Wu and Eugene Jhong for the Stanford SRP + * distribution, and subsequently proven to be prime. The larger primes + * are taken from [MODP], but generators have been calculated that are + * primitive roots of N, unlike the generators in [MODP]. + * + * The 1024-bit and 1536-bit groups <b>MUST</b> be supported. + */ +public class SRPConstants { + public static class Parameters { + public final BigInteger N; + public final BigInteger g; + public final int bitLength; + public final int byteLength; + public final int hexLength; + + protected Parameters(String N, long g) { + if (N == null) { + throw new IllegalArgumentException("N must not be null"); + } + this.N = new BigInteger(N.replaceAll(" ", ""), 16); // Hex. + this.g = BigInteger.valueOf(g); + this.hexLength = this.N.toString(16).length(); + this.byteLength = hexLength / 2; + this.bitLength = this.byteLength * 8; + } + } + + public static final Parameters _1024 = new Parameters("" + + "EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C" + + "9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4" + + "8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29" + + "7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A" + + "FD5138FE 8376435B 9FC61D2F C0EB06E3", 2L); + + public static final Parameters _1536 = new Parameters("" + + "9DEF3CAF B939277A B1F12A86 17A47BBB DBA51DF4 99AC4C80 BEEEA961" + + "4B19CC4D 5F4F5F55 6E27CBDE 51C6A94B E4607A29 1558903B A0D0F843" + + "80B655BB 9A22E8DC DF028A7C EC67F0D0 8134B1C8 B9798914 9B609E0B" + + "E3BAB63D 47548381 DBC5B1FC 764E3F4B 53DD9DA1 158BFD3E 2B9C8CF5" + + "6EDF0195 39349627 DB2FD53D 24B7C486 65772E43 7D6C7F8C E442734A" + + "F7CCB7AE 837C264A E3A9BEB8 7F8A2FE9 B8B5292E 5A021FFF 5E91479E" + + "8CE7A28C 2442C6F3 15180F93 499A234D CF76E3FE D135F9BB", 2L); + + public static final Parameters _2048 = new Parameters("" + + "AC6BDB41 324A9A9B F166DE5E 1389582F AF72B665 1987EE07 FC319294" + + "3DB56050 A37329CB B4A099ED 8193E075 7767A13D D52312AB 4B03310D" + + "CD7F48A9 DA04FD50 E8083969 EDB767B0 CF609517 9A163AB3 661A05FB" + + "D5FAAAE8 2918A996 2F0B93B8 55F97993 EC975EEA A80D740A DBF4FF74" + + "7359D041 D5C33EA7 1D281E44 6B14773B CA97B43A 23FB8016 76BD207A" + + "436C6481 F1D2B907 8717461A 5B9D32E6 88F87748 544523B5 24B0D57D" + + "5EA77A27 75D2ECFA 032CFBDB F52FB378 61602790 04E57AE6 AF874E73" + + "03CE5329 9CCC041C 7BC308D8 2A5698F3 A8D0C382 71AE35F8 E9DBFBB6" + + "94B5C803 D89F7AE4 35DE236D 525F5475 9B65E372 FCD68EF2 0FA7111F" + + "9E4AFF73", 2L); + + public static final Parameters _3072 = new Parameters("" + + "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" + + "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" + + "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" + + "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" + + "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" + + "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" + + "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" + + "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" + + "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" + + "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" + + "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" + + "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" + + "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" + + "E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF", 5L); + + public static final Parameters _4096 = new Parameters("" + + "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" + + "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" + + "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" + + "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" + + "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" + + "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" + + "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" + + "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" + + "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" + + "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" + + "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" + + "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" + + "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" + + "E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26" + + "99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB" + + "04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2" + + "233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127" + + "D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34063199" + + "FFFFFFFF FFFFFFFF", 5L); + + public static final Parameters _6144 = new Parameters("" + + "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" + + "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" + + "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" + + "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" + + "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" + + "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" + + "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" + + "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" + + "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" + + "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" + + "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" + + "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" + + "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" + + "E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26" + + "99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB" + + "04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2" + + "233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127" + + "D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34028492" + + "36C3FAB4 D27C7026 C1D4DCB2 602646DE C9751E76 3DBA37BD F8FF9406" + + "AD9E530E E5DB382F 413001AE B06A53ED 9027D831 179727B0 865A8918" + + "DA3EDBEB CF9B14ED 44CE6CBA CED4BB1B DB7F1447 E6CC254B 33205151" + + "2BD7AF42 6FB8F401 378CD2BF 5983CA01 C64B92EC F032EA15 D1721D03" + + "F482D7CE 6E74FEF6 D55E702F 46980C82 B5A84031 900B1C9E 59E7C97F" + + "BEC7E8F3 23A97A7E 36CC88BE 0F1D45B7 FF585AC5 4BD407B2 2B4154AA" + + "CC8F6D7E BF48E1D8 14CC5ED2 0F8037E0 A79715EE F29BE328 06A1D58B" + + "B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C DA56C9EC 2EF29632" + + "387FE8D7 6E3C0468 043E8F66 3F4860EE 12BF2D5B 0B7474D6 E694F91E" + + "6DCC4024 FFFFFFFF FFFFFFFF", 5L); + + public static final Parameters _8192 = new Parameters("" + + "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" + + "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" + + "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" + + "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" + + "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" + + "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" + + "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" + + "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" + + "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" + + "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" + + "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" + + "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" + + "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" + + "E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26" + + "99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB" + + "04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2" + + "233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127" + + "D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34028492" + + "36C3FAB4 D27C7026 C1D4DCB2 602646DE C9751E76 3DBA37BD F8FF9406" + + "AD9E530E E5DB382F 413001AE B06A53ED 9027D831 179727B0 865A8918" + + "DA3EDBEB CF9B14ED 44CE6CBA CED4BB1B DB7F1447 E6CC254B 33205151" + + "2BD7AF42 6FB8F401 378CD2BF 5983CA01 C64B92EC F032EA15 D1721D03" + + "F482D7CE 6E74FEF6 D55E702F 46980C82 B5A84031 900B1C9E 59E7C97F" + + "BEC7E8F3 23A97A7E 36CC88BE 0F1D45B7 FF585AC5 4BD407B2 2B4154AA" + + "CC8F6D7E BF48E1D8 14CC5ED2 0F8037E0 A79715EE F29BE328 06A1D58B" + + "B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C DA56C9EC 2EF29632" + + "387FE8D7 6E3C0468 043E8F66 3F4860EE 12BF2D5B 0B7474D6 E694F91E" + + "6DBE1159 74A3926F 12FEE5E4 38777CB6 A932DF8C D8BEC4D0 73B931BA" + + "3BC832B6 8D9DD300 741FA7BF 8AFC47ED 2576F693 6BA42466 3AAB639C" + + "5AE4F568 3423B474 2BF1C978 238F16CB E39D652D E3FDB8BE FC848AD9" + + "22222E04 A4037C07 13EB57A8 1A23F0C7 3473FC64 6CEA306B 4BCBC886" + + "2F8385DD FA9D4B7F A2C087E8 79683303 ED5BDD3A 062B3CF5 B3A278A6" + + "6D2A13F8 3F44F82D DF310EE0 74AB6A36 4597E899 A0255DC1 64F31CC5" + + "0846851D F9AB4819 5DED7EA1 B1D510BD 7EE74D73 FAF36BC3 1ECFA268" + + "359046F4 EB879F92 4009438B 481C6CD7 889A002E D5EE382B C9190DA6" + + "FC026E47 9558E447 5677E9AA 9E3050E2 765694DF C81F56E8 80B96E71" + + "60C980DD 98EDD3DF FFFFFFFF FFFFFFFF", 19L); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java new file mode 100644 index 000000000..177d7aaba --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java @@ -0,0 +1,157 @@ +/* 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.net; + +import android.support.annotation.Nullable; + +import org.mozilla.gecko.sync.Utils; + +import ch.boye.httpclientandroidlib.HttpResponse; + +public class SyncResponse extends MozResponse { + public static final String X_WEAVE_BACKOFF = "x-weave-backoff"; + public static final String X_BACKOFF = "x-backoff"; + public static final String X_LAST_MODIFIED = "x-last-modified"; + public static final String X_WEAVE_TIMESTAMP = "x-weave-timestamp"; + public static final String X_WEAVE_RECORDS = "x-weave-records"; + public static final String X_WEAVE_QUOTA_REMAINING = "x-weave-quota-remaining"; + public static final String X_WEAVE_ALERT = "x-weave-alert"; + public static final String X_WEAVE_NEXT_OFFSET = "x-weave-next-offset"; + + public SyncResponse(HttpResponse res) { + super(res); + } + + /** + * @return A number of seconds, or -1 if the 'X-Weave-Backoff' header was not + * present. + */ + public int weaveBackoffInSeconds() throws NumberFormatException { + return this.getIntegerHeader(X_WEAVE_BACKOFF); + } + + /** + * @return A number of seconds, or -1 if the 'X-Backoff' header was not + * present. + */ + public int xBackoffInSeconds() throws NumberFormatException { + return this.getIntegerHeader(X_BACKOFF); + } + + /** + * Extract a number of seconds, or -1 if none of the specified headers were present. + * + * @param includeRetryAfter + * if <code>true</code>, the Retry-After header is excluded. This is + * useful for processing non-error responses where a Retry-After + * header would be unexpected. + * @return the maximum of the three possible backoff headers, in seconds. + */ + public int totalBackoffInSeconds(boolean includeRetryAfter) { + int retryAfterInSeconds = -1; + if (includeRetryAfter) { + try { + retryAfterInSeconds = retryAfterInSeconds(); + } catch (NumberFormatException e) { + } + } + + int weaveBackoffInSeconds = -1; + try { + weaveBackoffInSeconds = weaveBackoffInSeconds(); + } catch (NumberFormatException e) { + } + + int backoffInSeconds = -1; + try { + backoffInSeconds = xBackoffInSeconds(); + } catch (NumberFormatException e) { + } + + int totalBackoff = Math.max(retryAfterInSeconds, Math.max(backoffInSeconds, weaveBackoffInSeconds)); + if (totalBackoff < 0) { + return -1; + } else { + return totalBackoff; + } + } + + /** + * @return A number of milliseconds, or -1 if neither the 'Retry-After', + * 'X-Backoff', or 'X-Weave-Backoff' header were present. + */ + public long totalBackoffInMilliseconds() { + long totalBackoff = totalBackoffInSeconds(true); + if (totalBackoff < 0) { + return -1; + } else { + return 1000 * totalBackoff; + } + } + + public long normalizedWeaveTimestamp() { + return normalizedTimestampForHeader(X_WEAVE_TIMESTAMP); + } + + /** + * Timestamps returned from a Sync server are decimal numbers of seconds, + * e.g., 1323393518.04. + * + * We want milliseconds since epoch. + * + * @return milliseconds since the epoch, as a long, or -1 if the header + * was missing or invalid. + */ + public long normalizedTimestampForHeader(String header) { + if (!this.hasHeader(header)) { + return -1; + } + + return Utils.decimalSecondsToMilliseconds( + this.response.getFirstHeader(header).getValue() + ); + } + + public int weaveRecords() throws NumberFormatException { + return this.getIntegerHeader(X_WEAVE_RECORDS); + } + + public int weaveQuotaRemaining() throws NumberFormatException { + return this.getIntegerHeader(X_WEAVE_QUOTA_REMAINING); + } + + public String weaveAlert() { + return this.getNonMissingHeader(X_WEAVE_ALERT); + } + + /** + * This header may be sent back with multi-record responses where the request included a limit parameter. + * Its presence indicates that the number of available records exceeded the given limit. + * The value from this header can be passed back in the offset parameter to retrieve additional records. + * The value of this header will always be a string of characters from the urlsafe-base64 alphabet. + * The specific contents of the string are an implementation detail of the server, + * so clients should treat it as an opaque token. + * + * @return the offset header + */ + public String weaveOffset() { + return this.getNonMissingHeader(X_WEAVE_NEXT_OFFSET); + } + + /** + * This header gives the last-modified time of the target resource as seen during processing of the request, + * and will be included in all success responses (200, 201, 204). + * When given in response to a write request, this will be equal to the server’s current time and + * to the new last-modified time of any BSOs created or changed by the request. + * It is similar to the standard HTTP Last-Modified header, + * but the value is a decimal timestamp rather than a HTTP-format date. + * + * @return the last modified header + */ + @Nullable + public String lastModified() { + return this.getNonMissingHeader(X_LAST_MODIFIED); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java new file mode 100644 index 000000000..3ae672f21 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java @@ -0,0 +1,145 @@ +/* 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.net; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; + +import org.mozilla.gecko.background.common.log.Logger; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +/** + * A request class that handles line-by-line responses. Eventually this will + * handle real stream processing; for now, just parse the returned body + * line-by-line. + * + * @author rnewman + * + */ +public class SyncStorageCollectionRequest extends SyncStorageRequest { + private static final String LOG_TAG = "CollectionRequest"; + + public SyncStorageCollectionRequest(URI uri) { + super(uri); + } + + protected volatile boolean aborting = false; + + /** + * Instruct the request that it should process no more records, + * and decline to notify any more delegate callbacks. + */ + public void abort() { + aborting = true; + try { + this.resource.request.abort(); + } catch (Exception e) { + // Just in case. + Logger.warn(LOG_TAG, "Got exception in abort: " + e); + } + } + + @Override + protected BaseResourceDelegate makeResourceDelegate(SyncStorageRequest request) { + return new SyncCollectionResourceDelegate((SyncStorageCollectionRequest) request); + } + + // TODO: this is awful. + public class SyncCollectionResourceDelegate extends + SyncStorageResourceDelegate { + + private static final String CONTENT_TYPE_INCREMENTAL = "application/newlines"; + private static final int FETCH_BUFFER_SIZE = 16 * 1024; // 16K chars. + + SyncCollectionResourceDelegate(SyncStorageCollectionRequest request) { + super(request); + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + super.addHeaders(request, client); + request.setHeader("Accept", CONTENT_TYPE_INCREMENTAL); + // Caller is responsible for setting full=1. + } + + @Override + public void handleHttpResponse(HttpResponse response) { + if (aborting) { + return; + } + + if (response.getStatusLine().getStatusCode() != 200) { + super.handleHttpResponse(response); + return; + } + + HttpEntity entity = response.getEntity(); + Header contentType = entity.getContentType(); + if (!contentType.getValue().startsWith(CONTENT_TYPE_INCREMENTAL)) { + // Not incremental! + super.handleHttpResponse(response); + return; + } + + // TODO: at this point we can access X-Weave-Timestamp, compare + // that to our local timestamp, and compute an estimate of clock + // skew. We can provide this to the incremental delegate, which + // will allow it to seamlessly correct timestamps on the records + // it processes. Bug 721887. + + // Line-by-line processing, then invoke success. + SyncStorageCollectionRequestDelegate delegate = (SyncStorageCollectionRequestDelegate) this.request.delegate; + InputStream content = null; + BufferedReader br = null; + try { + content = entity.getContent(); + br = new BufferedReader(new InputStreamReader(content), FETCH_BUFFER_SIZE); + String line; + + // This relies on connection timeouts at the HTTP layer. + while (!aborting && + null != (line = br.readLine())) { + try { + delegate.handleRequestProgress(line); + } catch (Exception ex) { + delegate.handleRequestError(new HandleProgressException(ex)); + BaseResource.consumeEntity(entity); + return; + } + } + if (aborting) { + // So we don't hit the success case below. + return; + } + } catch (IOException ex) { + if (!aborting) { + delegate.handleRequestError(ex); + } + BaseResource.consumeEntity(entity); + return; + } finally { + // Attempt to close the stream and reader. + if (br != null) { + try { + br.close(); + } catch (IOException e) { + // We don't care if this fails. + } + } + } + // We're done processing the entity. Don't let fetching the body succeed! + BaseResource.consumeEntity(entity); + delegate.handleRequestSuccess(new SyncStorageResponse(response)); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java new file mode 100644 index 000000000..ddf52007b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java @@ -0,0 +1,9 @@ +/* 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.net; + +public abstract class SyncStorageCollectionRequestDelegate implements + SyncStorageRequestIncrementalDelegate, SyncStorageRequestDelegate { +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java new file mode 100644 index 000000000..c18c4fe15 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java @@ -0,0 +1,95 @@ +/* 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.net; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.sync.CryptoRecord; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Resource class that implements expected headers and processing for Sync. + * Accepts a simplified delegate. + * + * Includes: + * * Basic Auth headers (via Resource) + * * Error responses: + * * 401 + * * 503 + * * Headers: + * * Retry-After + * * X-Weave-Backoff + * * X-Backoff + * * X-Weave-Records? + * * ... + * * Timeouts + * * Network errors + * * application/newlines + * * JSON parsing + * * Content-Type and Content-Length validation. + */ +public class SyncStorageRecordRequest extends SyncStorageRequest { + + public class SyncStorageRecordResourceDelegate extends SyncStorageResourceDelegate { + SyncStorageRecordResourceDelegate(SyncStorageRequest request) { + super(request); + } + } + + public SyncStorageRecordRequest(URI uri) { + super(uri); + } + + public SyncStorageRecordRequest(String url) throws URISyntaxException { + this(new URI(url)); + } + + @Override + protected BaseResourceDelegate makeResourceDelegate(SyncStorageRequest request) { + return new SyncStorageRecordResourceDelegate(request); + } + + @SuppressWarnings("unchecked") + public void post(JSONObject body) { + // Let's do this the trivial way for now. + // Note that POSTs should be an array, so we wrap here. + final JSONArray toPOST = new JSONArray(); + toPOST.add(body); + try { + this.resource.post(toPOST); + } catch (UnsupportedEncodingException e) { + this.delegate.handleRequestError(e); + } + } + + public void post(JSONArray body) { + // Let's do this the trivial way for now. + try { + this.resource.post(body); + } catch (UnsupportedEncodingException e) { + this.delegate.handleRequestError(e); + } + } + + public void put(JSONObject body) { + // Let's do this the trivial way for now. + try { + this.resource.put(body); + } catch (UnsupportedEncodingException e) { + this.delegate.handleRequestError(e); + } + } + + public void post(CryptoRecord record) { + this.post(record.toJSONObject()); + } + + public void put(CryptoRecord record) { + this.put(record.toJSONObject()); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java new file mode 100644 index 000000000..3ede9cded --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java @@ -0,0 +1,204 @@ +/* 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.net; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.HashMap; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.SyncConstants; + +import ch.boye.httpclientandroidlib.HttpEntity; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; + +public class SyncStorageRequest implements Resource { + public static HashMap<String, String> SERVER_ERROR_MESSAGES; + static { + HashMap<String, String> errors = new HashMap<String, String>(); + + // Sync protocol errors. + errors.put("1", "Illegal method/protocol"); + errors.put("2", "Incorrect/missing CAPTCHA"); + errors.put("3", "Invalid/missing username"); + errors.put("4", "Attempt to overwrite data that can't be overwritten (such as creating a user ID that already exists)"); + errors.put("5", "User ID does not match account in path"); + errors.put("6", "JSON parse failure"); + errors.put("7", "Missing password field"); + errors.put("8", "Invalid Weave Basic Object"); + errors.put("9", "Requested password not strong enough"); + errors.put("10", "Invalid/missing password reset code"); + errors.put("11", "Unsupported function"); + errors.put("12", "No email address on file"); + errors.put("13", "Invalid collection"); + errors.put("14", "User over quota"); + errors.put("15", "The email does not match the username"); + errors.put("16", "Client upgrade required"); + errors.put("255", "An unexpected server error occurred: pool is empty."); + + // Infrastructure-generated errors. + errors.put("\"server issue: getVS failed\"", "server issue: getVS failed"); + errors.put("\"server issue: prefix not set\"", "server issue: prefix not set"); + errors.put("\"server issue: host header not received from client\"", "server issue: host header not received from client"); + errors.put("\"server issue: database lookup failed\"", "server issue: database lookup failed"); + errors.put("\"server issue: database is not healthy\"", "server issue: database is not healthy"); + errors.put("\"server issue: database not in pool\"", "server issue: database not in pool"); + errors.put("\"server issue: database marked as down\"", "server issue: database marked as down"); + SERVER_ERROR_MESSAGES = errors; + } + public static String getServerErrorMessage(String body) { + if (SERVER_ERROR_MESSAGES.containsKey(body)) { + return SERVER_ERROR_MESSAGES.get(body); + } + return body; + } + + /** + * @param uri + * @throws URISyntaxException + */ + public SyncStorageRequest(String uri) throws URISyntaxException { + this(new URI(uri)); + } + + /** + * @param uri + */ + public SyncStorageRequest(URI uri) { + this.resource = new BaseResource(uri); + this.resourceDelegate = this.makeResourceDelegate(this); + this.resource.delegate = this.resourceDelegate; + } + + @Override + public URI getURI() { + return this.resource.getURI(); + } + + @Override + public String getURIString() { + return this.resource.getURIString(); + } + + @Override + public String getHostname() { + return this.resource.getHostname(); + } + + /** + * A ResourceDelegate that mediates between Resource-level notifications and the SyncStorageRequest. + */ + public class SyncStorageResourceDelegate extends BaseResourceDelegate { + private static final String LOG_TAG = "SSResourceDelegate"; + protected SyncStorageRequest request; + + SyncStorageResourceDelegate(SyncStorageRequest request) { + super(request); + this.request = request; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return request.delegate.getAuthHeaderProvider(); + } + + @Override + public String getUserAgent() { + return SyncConstants.USER_AGENT; + } + + @Override + public void handleHttpResponse(HttpResponse response) { + Logger.debug(LOG_TAG, "SyncStorageResourceDelegate handling response: " + response.getStatusLine() + "."); + SyncStorageRequestDelegate d = this.request.delegate; + SyncStorageResponse res = new SyncStorageResponse(response); + // It is the responsibility of the delegate handlers to completely consume the response. + // In context of a Sync storage response, success is either a 200 OK or 202 Accepted. + // 202 is returned during uploads of data in a batching mode, indicating that more is expected. + if (res.getStatusCode() == 200 || res.getStatusCode() == 202) { + d.handleRequestSuccess(res); + } else { + Logger.warn(LOG_TAG, "HTTP request failed."); + try { + Logger.warn(LOG_TAG, "HTTP response body: " + res.getErrorMessage()); + } catch (Exception e) { + Logger.error(LOG_TAG, "Can't fetch HTTP response body.", e); + } + d.handleRequestFailure(res); + } + } + + @Override + public void handleHttpProtocolException(ClientProtocolException e) { + this.request.delegate.handleRequestError(e); + } + + @Override + public void handleHttpIOException(IOException e) { + this.request.delegate.handleRequestError(e); + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + this.request.delegate.handleRequestError(e); + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + // Clients can use their delegate interface to specify X-If-Unmodified-Since. + String ifUnmodifiedSince = this.request.delegate.ifUnmodifiedSince(); + if (ifUnmodifiedSince != null) { + Logger.debug(LOG_TAG, "Making request with X-If-Unmodified-Since = " + ifUnmodifiedSince); + request.setHeader("x-if-unmodified-since", ifUnmodifiedSince); + } + if (request.getMethod().equalsIgnoreCase("DELETE")) { + request.addHeader("x-confirm-delete", "1"); + } + } + } + + protected BaseResourceDelegate resourceDelegate; + public SyncStorageRequestDelegate delegate; + protected BaseResource resource; + + public SyncStorageRequest() { + super(); + } + + // Default implementation. Override this. + protected BaseResourceDelegate makeResourceDelegate(SyncStorageRequest request) { + return new SyncStorageResourceDelegate(request); + } + + @Override + public void get() { + this.resource.get(); + } + + @Override + public void delete() { + this.resource.delete(); + } + + @Override + public void post(HttpEntity body) { + this.resource.post(body); + } + + @Override + public void patch(HttpEntity body) { + this.resource.patch(body); + } + + @Override + public void put(HttpEntity body) { + this.resource.put(body); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java new file mode 100644 index 000000000..29f42cc28 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java @@ -0,0 +1,38 @@ +/* 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.net; + +public interface SyncStorageRequestDelegate { + public AuthHeaderProvider getAuthHeaderProvider(); + + String ifUnmodifiedSince(); + + // TODO: at this point we can access X-Weave-Timestamp, compare + // that to our local timestamp, and compute an estimate of clock + // skew. Bug 721887. + + /** + * Override this to handle a successful SyncStorageRequest. + * + * SyncStorageResourceDelegate implementers <b>must</b> ensure that the HTTP + * responses underlying SyncStorageResponses are fully consumed to ensure that + * connections are returned to the pool, for example by calling + * <code>BaseResource.consumeEntity(response)</code>. + */ + void handleRequestSuccess(SyncStorageResponse response); + + /** + * Override this to handle a failed SyncStorageRequest. + * + * + * SyncStorageResourceDelegate implementers <b>must</b> ensure that the HTTP + * responses underlying SyncStorageResponses are fully consumed to ensure that + * connections are returned to the pool, for example by calling + * <code>BaseResource.consumeEntity(response)</code>. + */ + void handleRequestFailure(SyncStorageResponse response); + + void handleRequestError(Exception ex); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java new file mode 100644 index 000000000..aa5d735bf --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java @@ -0,0 +1,9 @@ +/* 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.net; + +public interface SyncStorageRequestIncrementalDelegate { + void handleRequestProgress(String progress); // For line-by-line. +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java new file mode 100644 index 000000000..644df314c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java @@ -0,0 +1,85 @@ +/* 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.net; + +import java.io.IOException; +import java.util.HashMap; + +import org.mozilla.gecko.background.common.log.Logger; + +import ch.boye.httpclientandroidlib.HttpResponse; + +public class SyncStorageResponse extends SyncResponse { + private static final String LOG_TAG = "SyncStorageResponse"; + + // Responses that are actionable get constant status codes. + public static final String RESPONSE_CLIENT_UPGRADE_REQUIRED = "16"; + + public static HashMap<String, String> SERVER_ERROR_MESSAGES; + static { + HashMap<String, String> errors = new HashMap<String, String>(); + + // Sync protocol errors. + errors.put("1", "Illegal method/protocol"); + errors.put("2", "Incorrect/missing CAPTCHA"); + errors.put("3", "Invalid/missing username"); + errors.put("4", "Attempt to overwrite data that can't be overwritten (such as creating a user ID that already exists)"); + errors.put("5", "User ID does not match account in path"); + errors.put("6", "JSON parse failure"); + errors.put("7", "Missing password field"); + errors.put("8", "Invalid Weave Basic Object"); + errors.put("9", "Requested password not strong enough"); + errors.put("10", "Invalid/missing password reset code"); + errors.put("11", "Unsupported function"); + errors.put("12", "No email address on file"); + errors.put("13", "Invalid collection"); + errors.put("14", "User over quota"); + errors.put("15", "The email does not match the username"); + errors.put(RESPONSE_CLIENT_UPGRADE_REQUIRED, "Client upgrade required"); + errors.put("255", "An unexpected server error occurred: pool is empty."); + + // Infrastructure-generated errors. + errors.put("\"server issue: getVS failed\"", "server issue: getVS failed"); + errors.put("\"server issue: prefix not set\"", "server issue: prefix not set"); + errors.put("\"server issue: host header not received from client\"", "server issue: host header not received from client"); + errors.put("\"server issue: database lookup failed\"", "server issue: database lookup failed"); + errors.put("\"server issue: database is not healthy\"", "server issue: database is not healthy"); + errors.put("\"server issue: database not in pool\"", "server issue: database not in pool"); + errors.put("\"server issue: database marked as down\"", "server issue: database marked as down"); + SERVER_ERROR_MESSAGES = errors; + } + public static String getServerErrorMessage(String body) { + Logger.debug(LOG_TAG, "Looking up message for body \"" + body + "\""); + if (SERVER_ERROR_MESSAGES.containsKey(body)) { + return SERVER_ERROR_MESSAGES.get(body); + } + return body; + } + + + public SyncStorageResponse(HttpResponse res) { + super(res); + } + + public String getErrorMessage() throws IllegalStateException, IOException { + return SyncStorageResponse.getServerErrorMessage(this.body().trim()); + } + + /** + * This header gives the last-modified time of the target resource as seen during processing of + * the request, and will be included in all success responses (200, 201, 204). + * When given in response to a write request, this will be equal to the server’s current time and + * to the new last-modified time of any BSOs created or changed by the request. + */ + public String getLastModified() { + if (!response.containsHeader(X_LAST_MODIFIED)) { + return null; + } + return response.getFirstHeader(X_LAST_MODIFIED).getValue(); + } + + // TODO: Content-Type and Content-Length validation. + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java new file mode 100644 index 000000000..dd68c0515 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java @@ -0,0 +1,62 @@ +/* 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.net; + +import java.io.IOException; +import java.net.Socket; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; + +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.background.common.log.Logger; + +import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory; +import ch.boye.httpclientandroidlib.params.HttpParams; + +public class TLSSocketFactory extends SSLSocketFactory { + private static final String LOG_TAG = "TLSSocketFactory"; + + // Guarded by `this`. + private static String[] cipherSuites = GlobalConstants.DEFAULT_CIPHER_SUITES; + + public TLSSocketFactory(SSLContext sslContext) { + super(sslContext); + } + + /** + * Attempt to specify the cipher suites to use for a connection. If + * setting fails (as it will on Android 2.2, because the wrong names + * are in use to specify ciphers), attempt to set the defaults. + * + * We store the list of cipher suites in `cipherSuites`, which + * avoids this fallback handling having to be executed more than once. + * + * This method is synchronized to ensure correct use of that member. + * + * See Bug 717691 for more details. + * + * @param socket + * The SSLSocket on which to operate. + */ + public static synchronized void setEnabledCipherSuites(SSLSocket socket) { + try { + socket.setEnabledCipherSuites(cipherSuites); + } catch (IllegalArgumentException e) { + cipherSuites = socket.getSupportedCipherSuites(); + Logger.warn(LOG_TAG, "Setting enabled cipher suites failed: " + e.getMessage()); + Logger.warn(LOG_TAG, "Using " + cipherSuites.length + " supported suites."); + socket.setEnabledCipherSuites(cipherSuites); + } + } + + @Override + public Socket createSocket(HttpParams params) throws IOException { + SSLSocket socket = (SSLSocket) super.createSocket(params); + socket.setEnabledProtocols(GlobalConstants.DEFAULT_PROTOCOLS); + setEnabledCipherSuites(socket); + return socket; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java new file mode 100644 index 000000000..2e26f041b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java @@ -0,0 +1,35 @@ +/* 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.net; + +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.KeyBundleProvider; + +/** + * Subclass this to handle collection fetches. + * @author rnewman + * + */ +public abstract class WBOCollectionRequestDelegate +extends SyncStorageCollectionRequestDelegate +implements KeyBundleProvider { + + @Override + public abstract KeyBundle keyBundle(); + public abstract void handleWBO(CryptoRecord record); + + @Override + public void handleRequestProgress(String progress) { + try { + CryptoRecord record = CryptoRecord.fromJSONRecord(progress); + record.keyBundle = this.keyBundle(); + this.handleWBO(record); + } catch (Exception e) { + this.handleRequestError(e); + // TODO: abort?! Allow exception to propagate to fail? + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java new file mode 100644 index 000000000..8a09e0c7f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java @@ -0,0 +1,14 @@ +/* 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.net; + +import org.mozilla.gecko.sync.KeyBundleProvider; +import org.mozilla.gecko.sync.crypto.KeyBundle; + +public abstract class WBORequestDelegate +implements SyncStorageRequestDelegate, KeyBundleProvider { + @Override + public abstract KeyBundle keyBundle(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java new file mode 100644 index 000000000..5fe3dc9fa --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class BookmarkNeedsReparentingException extends SyncException { + + private static final long serialVersionUID = -7018336108709392800L; + + public BookmarkNeedsReparentingException(Exception ex) { + super(ex); + } + +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java new file mode 100644 index 000000000..289fc48ec --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java @@ -0,0 +1,16 @@ +/* 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.repositories; + +/** + * Shared interface for repositories that consume and produce + * bookmark records. + * + * @author rnewman + * + */ +public interface BookmarksRepository { + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java new file mode 100644 index 000000000..a6dc3f6b8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java @@ -0,0 +1,51 @@ +/* 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.repositories; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.sync.InfoCollections; +import org.mozilla.gecko.sync.InfoConfiguration; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; + +/** + * A kind of Server11Repository that supports explicit setting of total fetch limit, per-batch fetch limit, and a sort order. + * + * @author rnewman + * + */ +public class ConstrainedServer11Repository extends Server11Repository { + + private final String sort; + private final long batchLimit; + private final long totalLimit; + + public ConstrainedServer11Repository(String collection, String storageURL, + AuthHeaderProvider authHeaderProvider, + InfoCollections infoCollections, + InfoConfiguration infoConfiguration, + long batchLimit, long totalLimit, String sort) + throws URISyntaxException { + super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration); + this.batchLimit = batchLimit; + this.totalLimit = totalLimit; + this.sort = sort; + } + + @Override + public String getDefaultSort() { + return sort; + } + + @Override + public long getDefaultBatchLimit() { + return batchLimit; + } + + @Override + public long getDefaultTotalLimit() { + return totalLimit; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java new file mode 100644 index 000000000..8b29a37ba --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java @@ -0,0 +1,11 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class FetchFailedException extends SyncException { + private static final long serialVersionUID = -7533105300182522946L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java new file mode 100644 index 000000000..3b6facc31 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java @@ -0,0 +1,61 @@ +/* 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.repositories; + +import java.util.HashSet; +import java.util.Iterator; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +public class HashSetStoreTracker implements StoreTracker { + + // Guarded by `this`. + // Used to store GUIDs that were not locally modified but + // have been modified by a call to `store`, and thus + // should not be returned by a subsequent fetch. + private final HashSet<String> guids; + + public HashSetStoreTracker() { + guids = new HashSet<String>(); + } + + @Override + public String toString() { + return "#<Tracker: " + guids.size() + " guids tracked.>"; + } + + @Override + public synchronized boolean trackRecordForExclusion(String guid) { + return (guid != null) && guids.add(guid); + } + + @Override + public synchronized boolean isTrackedForExclusion(String guid) { + return (guid != null) && guids.contains(guid); + } + + @Override + public synchronized boolean untrackStoredForExclusion(String guid) { + return (guid != null) && guids.remove(guid); + } + + @Override + public RecordFilter getFilter() { + if (guids.size() == 0) { + return null; + } + return new RecordFilter() { + @Override + public boolean excludeRecord(Record r) { + return isTrackedForExclusion(r.guid); + } + }; + } + + @Override + public Iterator<String> recordsTrackedForExclusion() { + return this.guids.iterator(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java new file mode 100644 index 000000000..eddc32102 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java @@ -0,0 +1,16 @@ +/* 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.repositories; + +/** + * Shared interface for repositories that consume and produce + * history records. + * + * @author rnewman + * + */ +public interface HistoryRepository { + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java new file mode 100644 index 000000000..acedc66e2 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java @@ -0,0 +1,15 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +public class IdentityRecordFactory extends RecordFactory { + + @Override + public Record createRecord(Record record) { + return record; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java new file mode 100644 index 000000000..185f0d724 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class InactiveSessionException extends SyncException { + + private static final long serialVersionUID = 537241160815940991L; + + public InactiveSessionException(Exception ex) { + super(ex); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java new file mode 100644 index 000000000..3597276a4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class InvalidBookmarkTypeException extends SyncException { + + private static final long serialVersionUID = -6098516814844387449L; + + public InvalidBookmarkTypeException(Exception e) { + super(e); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java new file mode 100644 index 000000000..3f761e540 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java @@ -0,0 +1,16 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class InvalidRequestException extends SyncException { + + private static final long serialVersionUID = 4502951350743608243L; + + public InvalidRequestException(Exception ex) { + super(ex); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java new file mode 100644 index 000000000..0963892c9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class InvalidSessionTransitionException extends SyncException { + + private static final long serialVersionUID = 4157729859314427281L; + + public InvalidSessionTransitionException(Exception ex) { + super(ex); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java new file mode 100644 index 000000000..58cca4a49 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java @@ -0,0 +1,16 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class MultipleRecordsForGuidException extends SyncException { + + private static final long serialVersionUID = 7426987323485324741L; + + public MultipleRecordsForGuidException(Exception ex) { + super(ex); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java new file mode 100644 index 000000000..85d119a5d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java @@ -0,0 +1,25 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +import android.net.Uri; + +/** + * Raised when a Content Provider cannot be retrieved. + * + * @author rnewman + * + */ +public class NoContentProviderException extends SyncException { + private static final long serialVersionUID = 1L; + + public final Uri requestedProvider; + public NoContentProviderException(Uri requested) { + super(); + this.requestedProvider = requested; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java new file mode 100644 index 000000000..3681deffd --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java @@ -0,0 +1,16 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class NoGuidForIdException extends SyncException { + + private static final long serialVersionUID = -675614284405829041L; + + public NoGuidForIdException(Exception ex) { + super(ex); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java new file mode 100644 index 000000000..5747039aa --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java @@ -0,0 +1,11 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class NoStoreDelegateException extends SyncException { + private static final long serialVersionUID = 6631689468978422074L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java new file mode 100644 index 000000000..4d9057992 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class NullCursorException extends SyncException { + + private static final long serialVersionUID = 3146506225701104661L; + + public NullCursorException(Exception e) { + super(e); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java new file mode 100644 index 000000000..991fd7426 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class ParentNotFoundException extends SyncException { + + private static final long serialVersionUID = -2687003621705922982L; + + public ParentNotFoundException(Exception ex) { + super(ex); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java new file mode 100644 index 000000000..0f8075133 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java @@ -0,0 +1,17 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class ProfileDatabaseException extends SyncException { + + private static final long serialVersionUID = -4916908502042261602L; + + public ProfileDatabaseException(Exception ex) { + super(ex); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java new file mode 100644 index 000000000..6a8d81a77 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java @@ -0,0 +1,13 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +// Take a record retrieved from some middleware, producing +// some concrete record type for application to some local repository. +public abstract class RecordFactory { + public abstract Record createRecord(Record record); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java new file mode 100644 index 000000000..733448ded --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java @@ -0,0 +1,11 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +public interface RecordFilter { + public boolean excludeRecord(Record r); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java new file mode 100644 index 000000000..3dd3fd2c4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java @@ -0,0 +1,18 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCleanDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; + +public abstract class Repository { + public abstract void createSession(RepositorySessionCreationDelegate delegate, Context context); + + public void clean(boolean success, RepositorySessionCleanDelegate delegate, Context context) { + delegate.onCleaned(this); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java new file mode 100644 index 000000000..84fca1379 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java @@ -0,0 +1,384 @@ +/* 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.repositories; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; + +/** + * A <code>RepositorySession</code> is created and used thusly: + * + *<ul> + * <li>Construct, with a reference to its parent {@link Repository}, by calling + * {@link Repository#createSession(RepositorySessionCreationDelegate, android.content.Context)}.</li> + * <li>Populate with saved information by calling {@link #unbundle(RepositorySessionBundle)}.</li> + * <li>Begin a sync by calling {@link #begin(RepositorySessionBeginDelegate)}. <code>begin()</code> + * is an appropriate place to initialize expensive resources.</li> + * <li>Perform operations such as {@link #fetchSince(long, RepositorySessionFetchRecordsDelegate)} and + * {@link #store(Record)}.</li> + * <li>Finish by calling {@link #finish(RepositorySessionFinishDelegate)}, retrieving and storing + * the current bundle.</li> + *</ul> + * + * If <code>finish()</code> is not called, {@link #abort()} must be called. These calls must + * <em>always</em> be paired with <code>begin()</code>. + * + */ +public abstract class RepositorySession { + + public enum SessionStatus { + UNSTARTED, + ACTIVE, + ABORTED, + DONE + } + + private static final String LOG_TAG = "RepositorySession"; + + protected static void trace(String message) { + Logger.trace(LOG_TAG, message); + } + + private SessionStatus status = SessionStatus.UNSTARTED; + protected Repository repository; + protected RepositorySessionStoreDelegate delegate; + + /** + * A queue of Runnables which call out into delegates. + */ + protected ExecutorService delegateQueue = Executors.newSingleThreadExecutor(); + + /** + * A queue of Runnables which effect storing. + * This includes actual store work, and also the consequences of storeDone. + * This provides strict ordering. + */ + protected ExecutorService storeWorkQueue = Executors.newSingleThreadExecutor(); + + // The time that the last sync on this collection completed, in milliseconds since epoch. + private long lastSyncTimestamp = 0; + + public long getLastSyncTimestamp() { + return lastSyncTimestamp; + } + + public static long now() { + return System.currentTimeMillis(); + } + + public RepositorySession(Repository repository) { + this.repository = repository; + } + + public abstract void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate); + public abstract void fetchSince(long timestamp, RepositorySessionFetchRecordsDelegate delegate); + public abstract void fetch(String[] guids, RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException; + public abstract void fetchAll(RepositorySessionFetchRecordsDelegate delegate); + + /** + * Override this if you wish to short-circuit a sync when you know -- + * e.g., by inspecting the database or info/collections -- that no new + * data are available. + * + * @return true if a sync should proceed. + */ + public boolean dataAvailable() { + return true; + } + + /** + * @return true if we cannot safely sync from this <code>RepositorySession</code>. + */ + public boolean shouldSkip() { + return false; + } + + /* + * Store operations proceed thusly: + * + * * Set a delegate + * * Store an arbitrary number of records. At any time the delegate can be + * notified of an error. + * * Call storeDone to notify the session that no more items are forthcoming. + * * The store delegate will be notified of error or completion. + * + * This arrangement of calls allows for batching at the session level. + * + * Store success calls are not guaranteed. + */ + public void setStoreDelegate(RepositorySessionStoreDelegate delegate) { + Logger.debug(LOG_TAG, "Setting store delegate to " + delegate); + this.delegate = delegate; + } + public abstract void store(Record record) throws NoStoreDelegateException; + + public void storeDone() { + // Our default behavior will be to assume that the Runnable is + // executed as soon as all the stores synchronously finish, so + // our end timestamp can just be… now. + storeDone(now()); + } + + public void storeDone(final long end) { + Logger.debug(LOG_TAG, "Scheduling onStoreCompleted for after storing is done: " + end); + Runnable command = new Runnable() { + @Override + public void run() { + delegate.onStoreCompleted(end); + } + }; + storeWorkQueue.execute(command); + } + + public abstract void wipe(RepositorySessionWipeDelegate delegate); + + /** + * Synchronously perform the shared work of beginning. Throws on failure. + * @throws InvalidSessionTransitionException + * + */ + protected void sharedBegin() throws InvalidSessionTransitionException { + Logger.debug(LOG_TAG, "Shared begin."); + if (delegateQueue.isShutdown()) { + throw new InvalidSessionTransitionException(null); + } + if (storeWorkQueue.isShutdown()) { + throw new InvalidSessionTransitionException(null); + } + this.transitionFrom(SessionStatus.UNSTARTED, SessionStatus.ACTIVE); + } + + /** + * Start the session. This is an appropriate place to initialize + * data access components such as database handles. + * + * @param delegate + * @throws InvalidSessionTransitionException + */ + public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { + sharedBegin(); + delegate.deferredBeginDelegate(delegateQueue).onBeginSucceeded(this); + } + + public void unbundle(RepositorySessionBundle bundle) { + this.lastSyncTimestamp = bundle == null ? 0 : bundle.getTimestamp(); + } + + /** + * Override this in your subclasses to return values to save between sessions. + * Note that RepositorySession automatically bumps the timestamp to the time + * the last sync began. If unbundled but not begun, this will be the same as the + * value in the input bundle. + * + * The Synchronizer most likely wants to bump the bundle timestamp to be a value + * return from a fetch call. + */ + protected RepositorySessionBundle getBundle() { + // Why don't we just persist the old bundle? + long timestamp = getLastSyncTimestamp(); + RepositorySessionBundle bundle = new RepositorySessionBundle(timestamp); + Logger.debug(LOG_TAG, "Setting bundle timestamp to " + timestamp + "."); + + return bundle; + } + + /** + * Just like finish(), but doesn't do any work that should only be performed + * at the end of a successful sync, and can be called any time. + */ + public void abort(RepositorySessionFinishDelegate delegate) { + this.abort(); + delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle()); + } + + /** + * Abnormally terminate the repository session, freeing or closing + * any resources that were opened during the lifetime of the session. + */ + public void abort() { + // TODO: do something here. + this.setStatus(SessionStatus.ABORTED); + try { + storeWorkQueue.shutdownNow(); + } catch (Exception e) { + Logger.error(LOG_TAG, "Caught exception shutting down store work queue.", e); + } + try { + delegateQueue.shutdown(); + } catch (Exception e) { + Logger.error(LOG_TAG, "Caught exception shutting down delegate queue.", e); + } + } + + /** + * End the repository session, freeing or closing any resources + * that were opened during the lifetime of the session. + * + * @param delegate notified of success or failure. + * @throws InactiveSessionException + */ + public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + try { + this.transitionFrom(SessionStatus.ACTIVE, SessionStatus.DONE); + delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle()); + } catch (InvalidSessionTransitionException e) { + Logger.error(LOG_TAG, "Tried to finish() an unstarted or already finished session"); + throw new InactiveSessionException(e); + } + + Logger.trace(LOG_TAG, "Shutting down work queues."); + storeWorkQueue.shutdown(); + delegateQueue.shutdown(); + } + + /** + * Run the provided command if we're active and our delegate queue + * is not shut down. + */ + protected synchronized void executeDelegateCommand(Runnable command) + throws InactiveSessionException { + if (!isActive() || delegateQueue.isShutdown()) { + throw new InactiveSessionException(null); + } + delegateQueue.execute(command); + } + + public synchronized void ensureActive() throws InactiveSessionException { + if (!isActive()) { + throw new InactiveSessionException(null); + } + } + + public synchronized boolean isActive() { + return status == SessionStatus.ACTIVE; + } + + public synchronized SessionStatus getStatus() { + return status; + } + + public synchronized void setStatus(SessionStatus status) { + this.status = status; + } + + public synchronized void transitionFrom(SessionStatus from, SessionStatus to) throws InvalidSessionTransitionException { + if (from == null || this.status == from) { + Logger.trace(LOG_TAG, "Successfully transitioning from " + this.status + " to " + to); + + this.status = to; + return; + } + Logger.warn(LOG_TAG, "Wanted to transition from " + from + " but in state " + this.status); + throw new InvalidSessionTransitionException(null); + } + + /** + * Produce a record that is some combination of the remote and local records + * provided. + * + * The returned record must be produced without mutating either remoteRecord + * or localRecord. It is acceptable to return either remoteRecord or localRecord + * if no modifications are to be propagated. + * + * The returned record *should* have the local androidID and the remote GUID, + * and some optional merge of data from the two records. + * + * This method can be called with records that are identical, or differ in + * any regard. + * + * This method will not be called if: + * + * * either record is marked as deleted, or + * * there is no local mapping for a new remote record. + * + * Otherwise, it will be called precisely once. + * + * Side-effects (e.g., for transactional storage) can be hooked in here. + * + * @param remoteRecord + * The record retrieved from upstream, already adjusted for clock skew. + * @param localRecord + * The record retrieved from local storage. + * @param lastRemoteRetrieval + * The timestamp of the last retrieved set of remote records, adjusted for + * clock skew. + * @param lastLocalRetrieval + * The timestamp of the last retrieved set of local records. + * @return + * A Record instance to apply, or null to apply nothing. + */ + protected Record reconcileRecords(final Record remoteRecord, + final Record localRecord, + final long lastRemoteRetrieval, + final long lastLocalRetrieval) { + Logger.debug(LOG_TAG, "Reconciling remote " + remoteRecord.guid + " against local " + localRecord.guid); + + if (localRecord.equalPayloads(remoteRecord)) { + if (remoteRecord.lastModified > localRecord.lastModified) { + Logger.debug(LOG_TAG, "Records are equal. No record application needed."); + return null; + } + + // Local wins. + return null; + } + + // TODO: Decide what to do based on: + // * Which of the two records is modified; + // * Whether they are equal or congruent; + // * The modified times of each record (interpreted through the lens of clock skew); + // * ... + boolean localIsMoreRecent = localRecord.lastModified > remoteRecord.lastModified; + Logger.debug(LOG_TAG, "Local record is more recent? " + localIsMoreRecent); + Record donor = localIsMoreRecent ? localRecord : remoteRecord; + + // Modify the local record to match the remote record's GUID and values. + // Preserve the local Android ID, and merge data where possible. + // It sure would be nice if copyWithIDs didn't give a shit about androidID, mm? + Record out = donor.copyWithIDs(remoteRecord.guid, localRecord.androidID); + + // We don't want to upload the record if the remote record was + // applied without changes. + // This logic will become more complicated as reconciling becomes smarter. + if (!localIsMoreRecent) { + trackGUID(out.guid); + } + return out; + } + + /** + * Depending on the RepositorySession implementation, track + * that a record — most likely a brand-new record that has been + * applied unmodified — should be tracked so as to not be uploaded + * redundantly. + * + * The default implementations do nothing. + */ + protected void trackGUID(String guid) { + } + + protected synchronized void untrackGUIDs(Collection<String> guids) { + } + + protected void untrackGUID(String guid) { + } + + // Ah, Java. You wretched creature. + public Iterator<String> getTrackedRecordIDs() { + return new ArrayList<String>().iterator(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java new file mode 100644 index 000000000..7908ec797 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java @@ -0,0 +1,55 @@ +/* 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.repositories; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; + +import java.io.IOException; + +public class RepositorySessionBundle { + public static final String LOG_TAG = RepositorySessionBundle.class.getSimpleName(); + + protected static final String JSON_KEY_TIMESTAMP = "timestamp"; + + protected final ExtendedJSONObject object; + + public RepositorySessionBundle(String jsonString) throws IOException, NonObjectJSONException { + + object = new ExtendedJSONObject(jsonString); + } + + public RepositorySessionBundle(long lastSyncTimestamp) { + object = new ExtendedJSONObject(); + this.setTimestamp(lastSyncTimestamp); + } + + public long getTimestamp() { + if (object.containsKey(JSON_KEY_TIMESTAMP)) { + return object.getLong(JSON_KEY_TIMESTAMP); + } + + return -1; + } + + public void setTimestamp(long timestamp) { + Logger.debug(LOG_TAG, "Setting timestamp to " + timestamp + "."); + object.put(JSON_KEY_TIMESTAMP, timestamp); + } + + public void bumpTimestamp(long timestamp) { + long existing = this.getTimestamp(); + if (timestamp > existing) { + this.setTimestamp(timestamp); + } else { + Logger.debug(LOG_TAG, "Timestamp " + timestamp + " not greater than " + existing + "; not bumping."); + } + } + + public String toJSONString() { + return object.toJSONString(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java new file mode 100644 index 000000000..4404fda25 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java @@ -0,0 +1,144 @@ +/* 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.repositories; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; + +import org.mozilla.gecko.sync.InfoCollections; +import org.mozilla.gecko.sync.InfoConfiguration; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * A Server11Repository implements fetching and storing against the Sync 1.1 API. + * It doesn't do crypto: that's the job of the middleware. + * + * @author rnewman + */ +public class Server11Repository extends Repository { + protected String collection; + protected URI collectionURI; + protected final AuthHeaderProvider authHeaderProvider; + protected final InfoCollections infoCollections; + + private final InfoConfiguration infoConfiguration; + + /** + * Construct a new repository that fetches and stores against the Sync 1.1. API. + * + * @param collection name. + * @param storageURL full URL to storage endpoint. + * @param authHeaderProvider to use in requests; may be null. + * @param infoCollections instance; must not be null. + * @throws URISyntaxException + */ + public Server11Repository(@NonNull String collection, @NonNull String storageURL, AuthHeaderProvider authHeaderProvider, @NonNull InfoCollections infoCollections, @NonNull InfoConfiguration infoConfiguration) throws URISyntaxException { + if (collection == null) { + throw new IllegalArgumentException("collection must not be null"); + } + if (storageURL == null) { + throw new IllegalArgumentException("storageURL must not be null"); + } + if (infoCollections == null) { + throw new IllegalArgumentException("infoCollections must not be null"); + } + this.collection = collection; + this.collectionURI = new URI(storageURL + (storageURL.endsWith("/") ? collection : "/" + collection)); + this.authHeaderProvider = authHeaderProvider; + this.infoCollections = infoCollections; + this.infoConfiguration = infoConfiguration; + } + + @Override + public void createSession(RepositorySessionCreationDelegate delegate, + Context context) { + delegate.onSessionCreated(new Server11RepositorySession(this)); + } + + public URI collectionURI() { + return this.collectionURI; + } + + public URI collectionURI(boolean full, long newer, long limit, String sort, String ids, String offset) throws URISyntaxException { + ArrayList<String> params = new ArrayList<String>(); + if (full) { + params.add("full=1"); + } + if (newer >= 0) { + // Translate local millisecond timestamps into server decimal seconds. + String newerString = Utils.millisecondsToDecimalSecondsString(newer); + params.add("newer=" + newerString); + } + if (limit > 0) { + params.add("limit=" + limit); + } + if (sort != null) { + params.add("sort=" + sort); // We trust these values. + } + if (ids != null) { + params.add("ids=" + ids); // We trust these values. + } + if (offset != null) { + // Offset comes straight out of HTTP headers and it is the responsibility of the caller to URI-escape it. + params.add("offset=" + offset); + } + if (params.size() == 0) { + return this.collectionURI; + } + + StringBuilder out = new StringBuilder(); + char indicator = '?'; + for (String param : params) { + out.append(indicator); + indicator = '&'; + out.append(param); + } + String uri = this.collectionURI + out.toString(); + return new URI(uri); + } + + public URI wboURI(String id) throws URISyntaxException { + return new URI(this.collectionURI + "/" + id); + } + + // Override these. + @SuppressWarnings("static-method") + public long getDefaultBatchLimit() { + return -1; + } + + @SuppressWarnings("static-method") + public String getDefaultSort() { + return null; + } + + public long getDefaultTotalLimit() { + return -1; + } + + public AuthHeaderProvider getAuthHeaderProvider() { + return authHeaderProvider; + } + + public boolean updateNeeded(long lastSyncTimestamp) { + return infoCollections.updateNeeded(collection, lastSyncTimestamp); + } + + @Nullable + public Long getCollectionLastModified() { + return infoCollections.getTimestamp(collection); + } + + public InfoConfiguration getInfoConfiguration() { + return infoConfiguration; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java new file mode 100644 index 000000000..20c735a6b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java @@ -0,0 +1,104 @@ +/* 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.repositories; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; +import org.mozilla.gecko.sync.repositories.downloaders.BatchingDownloader; +import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader; + +public class Server11RepositorySession extends RepositorySession { + public static final String LOG_TAG = "Server11Session"; + + Server11Repository serverRepository; + private BatchingUploader uploader; + private final BatchingDownloader downloader; + + public Server11RepositorySession(Repository repository) { + super(repository); + serverRepository = (Server11Repository) repository; + this.downloader = new BatchingDownloader(serverRepository, this); + } + + public Server11Repository getServerRepository() { + return serverRepository; + } + + @Override + public void setStoreDelegate(RepositorySessionStoreDelegate delegate) { + this.delegate = delegate; + + // Now that we have the delegate, we can initialize our uploader. + this.uploader = new BatchingUploader(this, storeWorkQueue, delegate); + } + + @Override + public void guidsSince(long timestamp, + RepositorySessionGuidsSinceDelegate delegate) { + // TODO Auto-generated method stub + + } + + @Override + public void fetchSince(long timestamp, + RepositorySessionFetchRecordsDelegate delegate) { + this.downloader.fetchSince(timestamp, delegate); + } + + @Override + public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) { + this.fetchSince(-1, delegate); + } + + @Override + public void fetch(String[] guids, + RepositorySessionFetchRecordsDelegate delegate) { + this.downloader.fetch(guids, delegate); + } + + @Override + public void wipe(RepositorySessionWipeDelegate delegate) { + if (!isActive()) { + delegate.onWipeFailed(new InactiveSessionException(null)); + return; + } + // TODO: implement wipe. + } + + @Override + public void store(Record record) throws NoStoreDelegateException { + if (delegate == null) { + throw new NoStoreDelegateException(); + } + + // If delegate was set, this shouldn't happen. + if (uploader == null) { + throw new IllegalStateException("Uploader haven't been initialized"); + } + + uploader.process(record); + } + + @Override + public void storeDone() { + Logger.debug(LOG_TAG, "storeDone()."); + + // If delegate was set, this shouldn't happen. + if (uploader == null) { + throw new IllegalStateException("Uploader haven't been initialized"); + } + + uploader.noMoreRecordsToUpload(); + } + + @Override + public boolean dataAvailable() { + return serverRepository.updateNeeded(getLastSyncTimestamp()); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java new file mode 100644 index 000000000..fcb09e32e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java @@ -0,0 +1,11 @@ +/* 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.repositories; + +import org.mozilla.gecko.sync.SyncException; + +public class StoreFailedException extends SyncException { + private static final long serialVersionUID = 6080340122855859752L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java new file mode 100644 index 000000000..b6a3071a9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java @@ -0,0 +1,82 @@ +/* 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.repositories; + +import java.util.Iterator; + +/** + * Our hacky version of transactional semantics. The goal is to prevent + * the following situation: + * + * * AAA is not modified locally. + * * A modified AAA is downloaded during the storing phase. Its local + * timestamp is advanced. + * * The direction of syncing changes, and AAA is now uploaded to the server. + * + * The following situation should still be supported: + * + * * AAA is not modified locally. + * * A modified AAA is downloaded and merged with the local AAA. + * * The merged AAA is uploaded to the server. + * + * As should: + * + * * AAA is modified locally. + * * A modified AAA is downloaded, and discarded or merged. + * * The current version of AAA is uploaded to the server. + * + * We achieve this by tracking GUIDs during the storing phase. If we + * apply a record such that the local copy is substantially the same + * as the record we just downloaded, we add it to a list of records + * to avoid uploading. The definition of "substantially the same" + * depends on the particular repository. The only consideration is "do we + * want to upload this record in this sync?". + * + * Note that items are removed from this list when a fetch that + * considers them for upload completes successfully. The entire list + * is discarded when the session is completed. + * + * This interface exposes methods to: + * + * * During a store, recording that a record has been stored, and should + * thus not be returned in subsequent fetches; + * * During a fetch, checking whether a record should be returned. + * + * In the future this might also grow self-persistence. + * + * See also RepositorySession.trackRecord. + * + * @author rnewman + * + */ +public interface StoreTracker { + + /** + * @param guid + * The GUID of the item to track. + * @return + * Whether the GUID was a newly tracked value. + */ + public boolean trackRecordForExclusion(String guid); + + /** + * @param guid + * The GUID of the item to check. + * @return + * true if the item is already tracked. + */ + public boolean isTrackedForExclusion(String guid); + + /** + * + * @param guid + * @return true if the specified GUID was removed from the tracked set. + */ + public boolean untrackStoredForExclusion(String guid); + + public RecordFilter getFilter(); + + public Iterator<String> recordsTrackedForExclusion(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java new file mode 100644 index 000000000..1a5c1e96a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java @@ -0,0 +1,102 @@ +/* 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.repositories; + +import java.util.Collection; +import java.util.Iterator; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; + +public abstract class StoreTrackingRepositorySession extends RepositorySession { + private static final String LOG_TAG = "StoreTrackSession"; + protected StoreTracker storeTracker; + + protected static StoreTracker createStoreTracker() { + return new HashSetStoreTracker(); + } + + public StoreTrackingRepositorySession(Repository repository) { + super(repository); + } + + @Override + public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { + RepositorySessionBeginDelegate deferredDelegate = delegate.deferredBeginDelegate(delegateQueue); + try { + super.sharedBegin(); + } catch (InvalidSessionTransitionException e) { + deferredDelegate.onBeginFailed(e); + return; + } + // Or do this in your own subclass. + storeTracker = createStoreTracker(); + deferredDelegate.onBeginSucceeded(this); + } + + @Override + protected synchronized void trackGUID(String guid) { + if (this.storeTracker == null) { + throw new IllegalStateException("Store tracker not yet initialized!"); + } + this.storeTracker.trackRecordForExclusion(guid); + } + + @Override + protected synchronized void untrackGUID(String guid) { + if (this.storeTracker == null) { + throw new IllegalStateException("Store tracker not yet initialized!"); + } + this.storeTracker.untrackStoredForExclusion(guid); + } + + @Override + protected synchronized void untrackGUIDs(Collection<String> guids) { + if (this.storeTracker == null) { + throw new IllegalStateException("Store tracker not yet initialized!"); + } + if (guids == null) { + return; + } + for (String guid : guids) { + this.storeTracker.untrackStoredForExclusion(guid); + } + } + + protected void trackRecord(Record record) { + + Logger.debug(LOG_TAG, "Tracking record " + record.guid + + " (" + record.lastModified + ") to avoid re-upload."); + // Future: we care about the timestamp… + trackGUID(record.guid); + } + + protected void untrackRecord(Record record) { + Logger.debug(LOG_TAG, "Un-tracking record " + record.guid + "."); + untrackGUID(record.guid); + } + + @Override + public Iterator<String> getTrackedRecordIDs() { + if (this.storeTracker == null) { + throw new IllegalStateException("Store tracker not yet initialized!"); + } + return this.storeTracker.recordsTrackedForExclusion(); + } + + @Override + public void abort(RepositorySessionFinishDelegate delegate) { + this.storeTracker = null; + super.abort(delegate); + } + + @Override + public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + super.finish(delegate); + this.storeTracker = null; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java new file mode 100644 index 000000000..fd3c35da0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java @@ -0,0 +1,326 @@ +/* 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.repositories.android; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +public class AndroidBrowserBookmarksDataAccessor extends AndroidBrowserRepositoryDataAccessor { + + private static final String LOG_TAG = "BookmarksDataAccessor"; + + /* + * Fragments of SQL to make our lives easier. + */ + private static final String BOOKMARK_IS_FOLDER = BrowserContract.Bookmarks.TYPE + " = " + + BrowserContract.Bookmarks.TYPE_FOLDER; + + // SQL fragment to retrieve GUIDs whose ID mappings should be tracked by this session. + // Exclude folders we don't want to sync. + private static final String GUID_SHOULD_TRACK = BrowserContract.SyncColumns.GUID + " NOT IN ('" + + BrowserContract.Bookmarks.TAGS_FOLDER_GUID + "', '" + + BrowserContract.Bookmarks.PLACES_FOLDER_GUID + "', '" + + BrowserContract.Bookmarks.PINNED_FOLDER_GUID + "')"; + + private static final String EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE; + static { + if (AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS.length > 0) { + StringBuilder b = new StringBuilder(BrowserContract.SyncColumns.GUID + " NOT IN ("); + + int remaining = AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS.length - 1; + for (String specialGuid : AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS) { + b.append('"'); + b.append(specialGuid); + b.append('"'); + if (remaining-- > 0) { + b.append(", "); + } + } + b.append(')'); + EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE = b.toString(); + } else { + EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE = null; // null is a valid WHERE clause. + } + } + + public static final String TYPE_FOLDER = "folder"; + public static final String TYPE_BOOKMARK = "bookmark"; + + private final RepoUtils.QueryHelper queryHelper; + + public AndroidBrowserBookmarksDataAccessor(Context context) { + super(context); + this.queryHelper = new RepoUtils.QueryHelper(context, getUri(), LOG_TAG); + } + + @Override + protected Uri getUri() { + return BrowserContractHelpers.BOOKMARKS_CONTENT_URI; + } + + protected static Uri getPositionsUri() { + return BrowserContractHelpers.BOOKMARKS_POSITIONS_CONTENT_URI; + } + + @Override + public void wipe() { + Uri uri = getUri(); + Logger.info(LOG_TAG, "wiping (except for special guids): " + uri); + context.getContentResolver().delete(uri, EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE, null); + } + + private final String[] GUID_AND_ID = new String[] { BrowserContract.Bookmarks.GUID, + BrowserContract.Bookmarks._ID }; + + protected Cursor getGuidsIDsForFolders() throws NullCursorException { + // Exclude items that we don't want to sync (pinned items, reading list, + // tags, the places root), in case they've ended up in the DB. + String where = BOOKMARK_IS_FOLDER + " AND " + GUID_SHOULD_TRACK; + return queryHelper.safeQuery(".getGuidsIDsForFolders", GUID_AND_ID, where, null, null); + } + + /** + * Issue a request to the Content Provider to update the positions of the + * records named by the provided GUIDs to the index of their GUID in the + * provided array. + * + * @param childArray + * A sequence of GUID strings. + */ + public int updatePositions(ArrayList<String> childArray) { + final int size = childArray.size(); + if (size == 0) { + return 0; + } + + Logger.debug(LOG_TAG, "Updating positions for " + size + " items."); + String[] args = childArray.toArray(new String[size]); + return context.getContentResolver().update(getPositionsUri(), new ContentValues(), null, args); + } + + public int bumpModifiedByGUID(Collection<String> ids, long modified) { + final int size = ids.size(); + if (size == 0) { + return 0; + } + + Logger.debug(LOG_TAG, "Bumping modified for " + size + " items to " + modified); + String where = RepoUtils.computeSQLInClause(size, BrowserContract.Bookmarks.GUID); + String[] selectionArgs = ids.toArray(new String[size]); + ContentValues values = new ContentValues(); + values.put(BrowserContract.Bookmarks.DATE_MODIFIED, modified); + + return context.getContentResolver().update(getUri(), values, where, selectionArgs); + } + + /** + * Bump the modified time of a record by ID. + */ + public int bumpModified(long id, long modified) { + Logger.debug(LOG_TAG, "Bumping modified for " + id + " to " + modified); + String where = BrowserContract.Bookmarks._ID + " = ?"; + String[] selectionArgs = new String[] { String.valueOf(id) }; + ContentValues values = new ContentValues(); + values.put(BrowserContract.Bookmarks.DATE_MODIFIED, modified); + + return context.getContentResolver().update(getUri(), values, where, selectionArgs); + } + + protected void updateParentAndPosition(String guid, long newParentId, long position) { + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.Bookmarks.PARENT, newParentId); + if (position >= 0) { + cv.put(BrowserContract.Bookmarks.POSITION, position); + } + updateByGuid(guid, cv); + } + + protected Map<String, Long> idsForGUIDs(String[] guids) throws NullCursorException { + final String where = RepoUtils.computeSQLInClause(guids.length, BrowserContract.Bookmarks.GUID); + Cursor c = queryHelper.safeQuery(".idsForGUIDs", GUID_AND_ID, where, guids, null); + try { + HashMap<String, Long> out = new HashMap<String, Long>(); + if (!c.moveToFirst()) { + return out; + } + final int guidIndex = c.getColumnIndexOrThrow(BrowserContract.Bookmarks.GUID); + final int idIndex = c.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID); + while (!c.isAfterLast()) { + out.put(c.getString(guidIndex), c.getLong(idIndex)); + c.moveToNext(); + } + return out; + } finally { + c.close(); + } + } + + /** + * Move the children of each source folder to the destination folder. + * Bump the modified time of each child. + * The caller should bump the modified time of the destination if desired. + * + * @param fromIDs the Android IDs of the source folders. + * @param to the Android ID of the destination folder. + * @return the number of updated rows. + */ + protected int moveChildren(String[] fromIDs, long to) { + long now = System.currentTimeMillis(); + long pos = -1; + + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.Bookmarks.PARENT, to); + cv.put(BrowserContract.Bookmarks.DATE_MODIFIED, now); + cv.put(BrowserContract.Bookmarks.POSITION, pos); + + final String where = RepoUtils.computeSQLInClause(fromIDs.length, BrowserContract.Bookmarks.PARENT); + return context.getContentResolver().update(getUri(), cv, where, fromIDs); + } + + /* + * Verify that all special GUIDs are present and that they aren't marked as deleted. + * Insert them if they aren't there. + */ + public void checkAndBuildSpecialGuids() throws NullCursorException { + final String[] specialGUIDs = AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS; + Cursor cur = fetch(specialGUIDs); + long placesRoot = 0; + + // Map from GUID to whether deleted. Non-presence implies just that. + HashMap<String, Boolean> statuses = new HashMap<String, Boolean>(specialGUIDs.length); + try { + if (cur.moveToFirst()) { + while (!cur.isAfterLast()) { + String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID); + if ("places".equals(guid)) { + placesRoot = RepoUtils.getLongFromCursor(cur, BrowserContract.CommonColumns._ID); + } + // Make sure none of these folders are marked as deleted. + boolean deleted = RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1; + statuses.put(guid, deleted); + cur.moveToNext(); + } + } + } finally { + cur.close(); + } + + // Insert or undelete them if missing. + for (String guid : specialGUIDs) { + if (statuses.containsKey(guid)) { + if (statuses.get(guid)) { + // Undelete. + Logger.info(LOG_TAG, "Undeleting special GUID " + guid); + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.SyncColumns.IS_DELETED, 0); + updateByGuid(guid, cv); + } + } else { + // Insert. + if (guid.equals("places")) { + // This is awkward. + Logger.info(LOG_TAG, "No places root. Inserting one."); + placesRoot = insertSpecialFolder("places", 0); + } else if (guid.equals("mobile")) { + Logger.info(LOG_TAG, "No mobile folder. Inserting one under the places root."); + insertSpecialFolder("mobile", placesRoot); + } else { + // unfiled, menu, toolbar. + Logger.info(LOG_TAG, "No " + guid + " root. Inserting one under places (" + placesRoot + ")."); + insertSpecialFolder(guid, placesRoot); + } + } + } + } + + private long insertSpecialFolder(String guid, long parentId) { + BookmarkRecord record = new BookmarkRecord(guid); + record.title = AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS_MAP.get(guid); + record.type = "folder"; + record.androidParentID = parentId; + return ContentUris.parseId(insert(record)); + } + + @Override + protected ContentValues getContentValues(Record record) { + BookmarkRecord rec = (BookmarkRecord) record; + + if (rec.deleted) { + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.SyncColumns.GUID, rec.guid); + cv.put(BrowserContract.Bookmarks.IS_DELETED, 1); + return cv; + } + + final int recordType = BrowserContractHelpers.typeCodeForString(rec.type); + if (recordType == -1) { + throw new IllegalStateException("Unexpected record type " + rec.type); + } + + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.SyncColumns.GUID, rec.guid); + cv.put(BrowserContract.Bookmarks.TYPE, recordType); + cv.put(BrowserContract.Bookmarks.TITLE, rec.title); + cv.put(BrowserContract.Bookmarks.URL, rec.bookmarkURI); + cv.put(BrowserContract.Bookmarks.DESCRIPTION, rec.description); + if (rec.tags == null) { + rec.tags = new JSONArray(); + } + cv.put(BrowserContract.Bookmarks.TAGS, rec.tags.toJSONString()); + cv.put(BrowserContract.Bookmarks.KEYWORD, rec.keyword); + cv.put(BrowserContract.Bookmarks.PARENT, rec.androidParentID); + cv.put(BrowserContract.Bookmarks.POSITION, rec.androidPosition); + + // Note that we don't set the modified timestamp: we allow the + // content provider to do that for us. + return cv; + } + + /** + * Returns a cursor over non-deleted records that list the given androidID as a parent. + */ + public Cursor getChildren(long androidID) throws NullCursorException { + return getChildren(androidID, false); + } + + /** + * Returns a cursor with any records that list the given androidID as a parent. + * Excludes 'places', and optionally any deleted records. + */ + public Cursor getChildren(long androidID, boolean includeDeleted) throws NullCursorException { + final String where = BrowserContract.Bookmarks.PARENT + " = ? AND " + + BrowserContract.SyncColumns.GUID + " <> ? " + + (!includeDeleted ? ("AND " + BrowserContract.SyncColumns.IS_DELETED + " = 0") : ""); + + final String[] args = new String[] { String.valueOf(androidID), "places" }; + + // Order by position, falling back on creation date and ID. + final String order = BrowserContract.Bookmarks.POSITION + ", " + + BrowserContract.SyncColumns.DATE_CREATED + ", " + + BrowserContract.Bookmarks._ID; + return queryHelper.safeQuery(".getChildren", getAllColumns(), where, args, order); + } + + + @Override + protected String[] getAllColumns() { + return BrowserContractHelpers.BookmarkColumns; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java new file mode 100644 index 000000000..38520fd7a --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java @@ -0,0 +1,25 @@ +/* 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.repositories.android; + +import org.mozilla.gecko.sync.repositories.BookmarksRepository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; + +public class AndroidBrowserBookmarksRepository extends AndroidBrowserRepository implements BookmarksRepository { + + @Override + protected void sessionCreator(RepositorySessionCreationDelegate delegate, Context context) { + AndroidBrowserBookmarksRepositorySession session = new AndroidBrowserBookmarksRepositorySession(AndroidBrowserBookmarksRepository.this, context); + final RepositorySessionCreationDelegate deferredCreationDelegate = delegate.deferredCreationDelegate(); + deferredCreationDelegate.onSessionCreated(session); + } + + @Override + protected AndroidBrowserRepositoryDataAccessor getDataAccessor(Context context) { + return new AndroidBrowserBookmarksDataAccessor(context); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java new file mode 100644 index 000000000..fb79901a1 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java @@ -0,0 +1,1107 @@ +/* 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.repositories.android; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.R; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.NoGuidForIdException; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.ParentNotFoundException; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +public class AndroidBrowserBookmarksRepositorySession extends AndroidBrowserRepositorySession + implements BookmarksInsertionManager.BookmarkInserter { + + public static final int DEFAULT_DELETION_FLUSH_THRESHOLD = 50; + public static final int DEFAULT_INSERTION_FLUSH_THRESHOLD = 50; + + // TODO: synchronization for these. + private final HashMap<String, Long> parentGuidToIDMap = new HashMap<String, Long>(); + private final HashMap<Long, String> parentIDToGuidMap = new HashMap<Long, String>(); + + /** + * Some notes on reparenting/reordering. + * + * Fennec stores new items with a high-negative position, because it doesn't care. + * On the other hand, it also doesn't give us any help managing positions. + * + * We can process records and folders in any order, though we'll usually see folders + * first because their sortindex is larger. + * + * We can also see folders that refer to children we haven't seen, and children we + * won't see (perhaps due to a TTL, perhaps due to a limit on our fetch). + * + * And of course folders can refer to local children (including ones that might + * be reconciled into oblivion!), or local children in other folders. And the local + * version of a folder -- which might be a reconciling target, or might not -- can + * have local additions or removals. (That causes complications with on-the-fly + * reordering: we don't know in advance which records will even exist by the end + * of the sync.) + * + * We opt to leave records in a reasonable state as we go, applying reordering/ + * reparenting operations whenever possible. A final sequence is applied after all + * incoming records have been handled. + * + * As such, we need to track a bunch of stuff as we go: + * + * • For each downloaded folder, the array of children. These will be server GUIDs, + * but not necessarily identical to the remote list: if we download a record and + * it's been locally moved, it must be removed from this child array. + * + * This mapping can be discarded when final reordering has occurred, either on + * store completion or when every child has been seen within this session. + * + * • A list of orphans: records whose parent folder does not yet exist. This can be + * trimmed as orphans are reparented. + * + * • Mappings from folder GUIDs to folder IDs, so that we can parent items without + * having to look in the DB. Of course, this must be kept up-to-date as we + * reconcile. + * + * Reordering also needs to occur during fetch. That is, a folder might have been + * created locally, or modified locally without any remote changes. An order must + * be generated for the folder's children array, and it must be persisted into the + * database to act as a starting point for future changes. But of course we don't + * want to incur a database write if the children already have a satisfactory order. + * + * Do we also need a list of "adopters", parents that are still waiting for children? + * As items get picked out of the orphans list, we can do on-the-fly ordering, until + * we're left with lonely records at the end. + * + * As we modify local folders, perhaps by moving children out of their purview, we + * must bump their modification time so as to cause them to be uploaded on the next + * stage of syncing. The same applies to simple reordering. + */ + + // TODO: can we guarantee serial access to these? + private final HashMap<String, ArrayList<String>> missingParentToChildren = new HashMap<String, ArrayList<String>>(); + private final HashMap<String, JSONArray> parentToChildArray = new HashMap<String, JSONArray>(); + private int needsReparenting = 0; + + private final AndroidBrowserBookmarksDataAccessor dataAccessor; + + protected BookmarksDeletionManager deletionManager; + protected BookmarksInsertionManager insertionManager; + + /** + * An array of known-special GUIDs. + */ + public static final String[] SPECIAL_GUIDS = new String[] { + // Mobile and desktop places roots have to come first. + "places", + "mobile", + "toolbar", + "menu", + "unfiled" + }; + + /** + * = A note about folder mapping = + * + * Note that _none_ of Places's folders actually have a special GUID. They're all + * randomly generated. Special folders are indicated by membership in the + * moz_bookmarks_roots table, and by having the parent `1`. + * + * Additionally, the mobile root is annotated. In Firefox Sync, PlacesUtils is + * used to find the IDs of these special folders. + * + * We need to consume records with these various GUIDs, producing a local + * representation which we are able to stably map upstream. + * + * Android Sync skips over the contents of some special GUIDs -- `places`, `tags`, + * etc. -- when finding IDs. + * Some of these special GUIDs are part of desktop structure (places, tags). Some + * are part of Fennec's custom data (readinglist, pinned). + * + * We don't want to upload or apply these records. + * + * That is: + * + * * We should not upload a `places`,`tags`, `readinglist`, or `pinned` record. + * * We can stably _store_ menu/toolbar/unfiled/mobile as special GUIDs, and set + * their parent ID as appropriate on upload. + * + * Fortunately, Fennec stores our representation of the data, not Places: that is, + * there's a "places" root, containing "mobile", "menu", "toolbar", etc. + * + * These are guaranteed to exist when the database is created. + * + * = Places folders = + * + * guid root_name folder_id parent + * ---------- ---------- ---------- ---------- + * ? places 1 0 + * ? menu 2 1 + * ? toolbar 3 1 + * ? tags 4 1 + * ? unfiled 5 1 + * + * ? mobile* 474 1 + * + * + * = Fennec folders = + * + * guid folder_id parent + * ---------- ---------- ---------- + * places 0 0 + * mobile 1 0 + * menu 2 0 + * etc. + * + */ + public static final Map<String, String> SPECIAL_GUID_PARENTS; + static { + HashMap<String, String> m = new HashMap<String, String>(); + m.put("places", null); + m.put("menu", "places"); + m.put("toolbar", "places"); + m.put("tags", "places"); + m.put("unfiled", "places"); + m.put("mobile", "places"); + SPECIAL_GUID_PARENTS = Collections.unmodifiableMap(m); + } + + + /** + * A map of guids to their localized name strings. + */ + // Oh, if only we could make this final and initialize it in the static initializer. + public static Map<String, String> SPECIAL_GUIDS_MAP; + + /** + * Return true if the provided record GUID should be skipped + * in child lists or fetch results. + * + * @param recordGUID the GUID of the record to check. + * @return true if the record should be skipped. + */ + public static boolean forbiddenGUID(final String recordGUID) { + return recordGUID == null || + BrowserContract.Bookmarks.PINNED_FOLDER_GUID.equals(recordGUID) || + BrowserContract.Bookmarks.PLACES_FOLDER_GUID.equals(recordGUID) || + BrowserContract.Bookmarks.TAGS_FOLDER_GUID.equals(recordGUID); + } + + /** + * Return true if the provided parent GUID's children should + * be skipped in child lists or fetch results. + * This differs from {@link #forbiddenGUID(String)} in that we're skipping + * part of the hierarchy. + * + * @param parentGUID the GUID of parent of the record to check. + * @return true if the record should be skipped. + */ + public static boolean forbiddenParent(final String parentGUID) { + return parentGUID == null || + BrowserContract.Bookmarks.PINNED_FOLDER_GUID.equals(parentGUID); + } + + public AndroidBrowserBookmarksRepositorySession(Repository repository, Context context) { + super(repository); + + if (SPECIAL_GUIDS_MAP == null) { + HashMap<String, String> m = new HashMap<String, String>(); + + // Note that we always use the literal name "mobile" for the Mobile Bookmarks + // folder, regardless of its actual name in the database or the Fennec UI. + // This is to match desktop (working around Bug 747699) and to avoid a similar + // issue locally. See Bug 748898. + m.put("mobile", "mobile"); + + // Other folders use their contextualized names, and we simply rely on + // these not changing, matching desktop, and such to avoid issues. + m.put("menu", context.getString(R.string.bookmarks_folder_menu)); + m.put("places", context.getString(R.string.bookmarks_folder_places)); + m.put("toolbar", context.getString(R.string.bookmarks_folder_toolbar)); + m.put("unfiled", context.getString(R.string.bookmarks_folder_unfiled)); + + SPECIAL_GUIDS_MAP = Collections.unmodifiableMap(m); + } + + dbHelper = new AndroidBrowserBookmarksDataAccessor(context); + dataAccessor = (AndroidBrowserBookmarksDataAccessor) dbHelper; + } + + private static int getTypeFromCursor(Cursor cur) { + return RepoUtils.getIntFromCursor(cur, BrowserContract.Bookmarks.TYPE); + } + + private static boolean rowIsFolder(Cursor cur) { + return getTypeFromCursor(cur) == BrowserContract.Bookmarks.TYPE_FOLDER; + } + + private String getGUIDForID(long androidID) { + String guid = parentIDToGuidMap.get(androidID); + trace(" " + androidID + " => " + guid); + return guid; + } + + private long getIDForGUID(String guid) { + Long id = parentGuidToIDMap.get(guid); + if (id == null) { + Logger.warn(LOG_TAG, "Couldn't find local ID for GUID " + guid); + return -1; + } + return id; + } + + private String getGUID(Cursor cur) { + return RepoUtils.getStringFromCursor(cur, "guid"); + } + + private long getParentID(Cursor cur) { + return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.PARENT); + } + + // More efficient for bulk operations. + private long getPosition(Cursor cur, int positionIndex) { + return cur.getLong(positionIndex); + } + private long getPosition(Cursor cur) { + return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION); + } + + private String getParentName(String parentGUID) throws ParentNotFoundException, NullCursorException { + if (parentGUID == null) { + return ""; + } + if (SPECIAL_GUIDS_MAP.containsKey(parentGUID)) { + return SPECIAL_GUIDS_MAP.get(parentGUID); + } + + // Get parent name from database. + String parentName = ""; + Cursor name = dataAccessor.fetch(new String[] { parentGUID }); + try { + name.moveToFirst(); + if (!name.isAfterLast()) { + parentName = RepoUtils.getStringFromCursor(name, BrowserContract.Bookmarks.TITLE); + } + else { + Logger.error(LOG_TAG, "Couldn't find record with guid '" + parentGUID + "' when looking for parent name."); + throw new ParentNotFoundException(null); + } + } finally { + name.close(); + } + return parentName; + } + + /** + * Retrieve the child array for a record, repositioning and updating the database as necessary. + * + * @param folderID + * The database ID of the folder. + * @param persist + * True if generated positions should be written to the database. The modified + * time of the parent folder is only bumped if this is true. + * @param childArray + * A new, empty JSONArray which will be populated with an array of GUIDs. + * @return + * True if the resulting array is "clean" (i.e., reflects the content of the database). + * @throws NullCursorException + */ + @SuppressWarnings("unchecked") + private boolean getChildrenArray(long folderID, boolean persist, JSONArray childArray) throws NullCursorException { + trace("Calling getChildren for androidID " + folderID); + Cursor children = dataAccessor.getChildren(folderID); + try { + if (!children.moveToFirst()) { + trace("No children: empty cursor."); + return true; + } + final int positionIndex = children.getColumnIndex(BrowserContract.Bookmarks.POSITION); + final int count = children.getCount(); + Logger.debug(LOG_TAG, "Expecting " + count + " children."); + + // Sorted by requested position. + TreeMap<Long, ArrayList<String>> guids = new TreeMap<Long, ArrayList<String>>(); + + while (!children.isAfterLast()) { + final String childGuid = getGUID(children); + final long childPosition = getPosition(children, positionIndex); + trace(" Child GUID: " + childGuid); + trace(" Child position: " + childPosition); + Utils.addToIndexBucketMap(guids, Math.abs(childPosition), childGuid); + children.moveToNext(); + } + + // This will suffice for taking a jumble of records and indices and + // producing a sorted sequence that preserves some kind of order -- + // from the abs of the position, falling back on cursor order (that + // is, creation time and ID). + // Note that this code is not intended to merge values from two sources! + boolean changed = false; + int i = 0; + for (Entry<Long, ArrayList<String>> entry : guids.entrySet()) { + long pos = entry.getKey(); + int atPos = entry.getValue().size(); + + // If every element has a different index, and the indices are + // in strict natural order, then changed will be false. + if (atPos > 1 || pos != i) { + changed = true; + } + + ++i; + + for (String guid : entry.getValue()) { + if (!forbiddenGUID(guid)) { + childArray.add(guid); + } + } + } + + if (Logger.shouldLogVerbose(LOG_TAG)) { + // Don't JSON-encode unless we're logging. + Logger.trace(LOG_TAG, "Output child array: " + childArray.toJSONString()); + } + + if (!changed) { + Logger.debug(LOG_TAG, "Nothing moved! Database reflects child array."); + return true; + } + + if (!persist) { + Logger.debug(LOG_TAG, "Returned array does not match database, and not persisting."); + return false; + } + + Logger.debug(LOG_TAG, "Generating child array required moving records. Updating DB."); + final long time = now(); + if (0 < dataAccessor.updatePositions(childArray)) { + Logger.debug(LOG_TAG, "Bumping parent time to " + time + "."); + dataAccessor.bumpModified(folderID, time); + } + return true; + } finally { + children.close(); + } + } + + protected static boolean isDeleted(Cursor cur) { + return RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) != 0; + } + + @Override + protected Record retrieveDuringStore(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + // During storing of a retrieved record, we never care about the children + // array that's already present in the database -- we don't use it for + // reconciling. Skip all that effort for now. + return retrieveRecord(cur, false); + } + + @Override + protected Record retrieveDuringFetch(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + return retrieveRecord(cur, true); + } + + /** + * Build a record from a cursor, with a flag to dictate whether the + * children array should be computed and written back into the database. + */ + protected BookmarkRecord retrieveRecord(Cursor cur, boolean computeAndPersistChildren) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + String recordGUID = getGUID(cur); + Logger.trace(LOG_TAG, "Record from mirror cursor: " + recordGUID); + + if (forbiddenGUID(recordGUID)) { + Logger.debug(LOG_TAG, "Ignoring " + recordGUID + " record in recordFromMirrorCursor."); + return null; + } + + // Short-cut for deleted items. + if (isDeleted(cur)) { + return AndroidBrowserBookmarksRepositorySession.bookmarkFromMirrorCursor(cur, null, null, null); + } + + long androidParentID = getParentID(cur); + + // Ensure special folders stay in the right place. + String androidParentGUID = SPECIAL_GUID_PARENTS.get(recordGUID); + if (androidParentGUID == null) { + androidParentGUID = getGUIDForID(androidParentID); + } + + boolean needsReparenting = false; + + if (androidParentGUID == null) { + Logger.debug(LOG_TAG, "No parent GUID for record " + recordGUID + " with parent " + androidParentID); + // If the parent has been stored and somehow has a null GUID, throw an error. + if (parentIDToGuidMap.containsKey(androidParentID)) { + Logger.error(LOG_TAG, "Have the parent android ID for the record but the parent's GUID wasn't found."); + throw new NoGuidForIdException(null); + } + + // We have a parent ID but it's wrong. If the record is deleted, + // we'll just say that it was in the Unsorted Bookmarks folder. + // If not, we'll move it into Mobile Bookmarks. + needsReparenting = true; + } + + // If record is a folder, and we want to see children at this time, then build out the children array. + final JSONArray childArray; + if (computeAndPersistChildren) { + childArray = getChildrenArrayForRecordCursor(cur, recordGUID, true); + } else { + childArray = null; + } + String parentName = getParentName(androidParentGUID); + BookmarkRecord bookmark = AndroidBrowserBookmarksRepositorySession.bookmarkFromMirrorCursor(cur, androidParentGUID, parentName, childArray); + + if (bookmark == null) { + Logger.warn(LOG_TAG, "Unable to extract bookmark from cursor. Record GUID " + recordGUID + + ", parent " + androidParentGUID + "/" + androidParentID); + return null; + } + + if (needsReparenting) { + Logger.warn(LOG_TAG, "Bookmark record " + recordGUID + " has a bad parent pointer. Reparenting now."); + + String destination = bookmark.deleted ? "unfiled" : "mobile"; + bookmark.androidParentID = getIDForGUID(destination); + bookmark.androidPosition = getPosition(cur); + bookmark.parentID = destination; + bookmark.parentName = getParentName(destination); + if (!bookmark.deleted) { + // Actually move it. + // TODO: compute position. Persist. + relocateBookmark(bookmark); + } + } + + return bookmark; + } + + /** + * Ensure that the local database row for the provided bookmark + * reflects this record's parent information. + * + * @param bookmark + */ + private void relocateBookmark(BookmarkRecord bookmark) { + dataAccessor.updateParentAndPosition(bookmark.guid, bookmark.androidParentID, bookmark.androidPosition); + } + + protected JSONArray getChildrenArrayForRecordCursor(Cursor cur, String recordGUID, boolean persist) throws NullCursorException { + boolean isFolder = rowIsFolder(cur); + if (!isFolder) { + return null; + } + + long androidID = parentGuidToIDMap.get(recordGUID); + JSONArray childArray = new JSONArray(); + getChildrenArray(androidID, persist, childArray); + + Logger.debug(LOG_TAG, "Fetched " + childArray.size() + " children for " + recordGUID); + return childArray; + } + + @Override + public boolean shouldIgnore(Record record) { + if (!(record instanceof BookmarkRecord)) { + return true; + } + if (record.deleted) { + return false; + } + + BookmarkRecord bmk = (BookmarkRecord) record; + + if (forbiddenGUID(bmk.guid)) { + Logger.debug(LOG_TAG, "Ignoring forbidden record with guid: " + bmk.guid); + return true; + } + + if (forbiddenParent(bmk.parentID)) { + Logger.debug(LOG_TAG, "Ignoring child " + bmk.guid + " of forbidden parent folder " + bmk.parentID); + return true; + } + + if (BrowserContractHelpers.isSupportedType(bmk.type)) { + return false; + } + + Logger.debug(LOG_TAG, "Ignoring record with guid: " + bmk.guid + " and type: " + bmk.type); + return true; + } + + @Override + public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { + // Check for the existence of special folders + // and insert them if they don't exist. + Cursor cur; + try { + Logger.debug(LOG_TAG, "Check and build special GUIDs."); + dataAccessor.checkAndBuildSpecialGuids(); + cur = dataAccessor.getGuidsIDsForFolders(); + Logger.debug(LOG_TAG, "Got GUIDs for folders."); + } catch (android.database.sqlite.SQLiteConstraintException e) { + Logger.error(LOG_TAG, "Got sqlite constraint exception working with Fennec bookmark DB.", e); + delegate.onBeginFailed(e); + return; + } catch (Exception e) { + delegate.onBeginFailed(e); + return; + } + + // To deal with parent mapping of bookmarks we have to do some + // hairy stuff. Here's the setup for it. + + Logger.debug(LOG_TAG, "Preparing folder ID mappings."); + + // Fake our root. + Logger.debug(LOG_TAG, "Tracking places root as ID 0."); + parentIDToGuidMap.put(0L, "places"); + parentGuidToIDMap.put("places", 0L); + try { + cur.moveToFirst(); + while (!cur.isAfterLast()) { + String guid = getGUID(cur); + long id = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID); + parentGuidToIDMap.put(guid, id); + parentIDToGuidMap.put(id, guid); + Logger.debug(LOG_TAG, "GUID " + guid + " maps to " + id); + cur.moveToNext(); + } + } finally { + cur.close(); + } + deletionManager = new BookmarksDeletionManager(dataAccessor, DEFAULT_DELETION_FLUSH_THRESHOLD); + + // We just crawled the database enumerating all folders; we'll start the + // insertion manager with exactly these folders as the known parents (the + // collection is copied) in the manager constructor. + insertionManager = new BookmarksInsertionManager(DEFAULT_INSERTION_FLUSH_THRESHOLD, parentGuidToIDMap.keySet(), this); + + Logger.debug(LOG_TAG, "Done with initial setup of bookmarks session."); + super.begin(delegate); + } + + /** + * Implement method of BookmarksInsertionManager.BookmarkInserter. + */ + @Override + public boolean insertFolder(BookmarkRecord record) { + // A folder that is *not* deleted needs its androidID updated, so that + // updateBookkeeping can re-parent, etc. + Record toStore = prepareRecord(record); + try { + Uri recordURI = dbHelper.insert(toStore); + if (recordURI == null) { + delegate.onRecordStoreFailed(new RuntimeException("Got null URI inserting folder with guid " + toStore.guid + "."), record.guid); + return false; + } + toStore.androidID = ContentUris.parseId(recordURI); + Logger.debug(LOG_TAG, "Inserted folder with guid " + toStore.guid + " as androidID " + toStore.androidID); + + updateBookkeeping(toStore); + } catch (Exception e) { + delegate.onRecordStoreFailed(e, record.guid); + return false; + } + trackRecord(toStore); + delegate.onRecordStoreSucceeded(record.guid); + return true; + } + + /** + * Implement method of BookmarksInsertionManager.BookmarkInserter. + */ + @Override + public void bulkInsertNonFolders(Collection<BookmarkRecord> records) { + // All of these records are *not* deleted and *not* folders, so we don't + // need to update androidID at all! + // TODO: persist records that fail to insert for later retry. + ArrayList<Record> toStores = new ArrayList<Record>(records.size()); + for (Record record : records) { + toStores.add(prepareRecord(record)); + } + + try { + int stored = dataAccessor.bulkInsert(toStores); + if (stored != toStores.size()) { + // Something failed; most pessimistic action is to declare that all insertions failed. + // TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed? + for (Record failed : toStores) { + delegate.onRecordStoreFailed(new RuntimeException("Possibly failed to bulkInsert non-folder with guid " + failed.guid + "."), failed.guid); + } + return; + } + } catch (NullCursorException e) { + for (Record failed : toStores) { + delegate.onRecordStoreFailed(e, failed.guid); + } + return; + } + + // Success For All! + for (Record succeeded : toStores) { + try { + updateBookkeeping(succeeded); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception updating bookkeeping of non-folder with guid " + succeeded.guid + ".", e); + } + trackRecord(succeeded); + delegate.onRecordStoreSucceeded(succeeded.guid); + } + } + + @Override + public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + // Allow these to be GCed. + deletionManager = null; + insertionManager = null; + + // Override finish to do this check; make sure all records + // needing re-parenting have been re-parented. + if (needsReparenting != 0) { + Logger.error(LOG_TAG, "Finish called but " + needsReparenting + + " bookmark(s) have been placed in unsorted bookmarks and not been reparented."); + + // TODO: handling of failed reparenting. + // E.g., delegate.onFinishFailed(new BookmarkNeedsReparentingException(null)); + } + super.finish(delegate); + }; + + @Override + public void setStoreDelegate(RepositorySessionStoreDelegate delegate) { + super.setStoreDelegate(delegate); + + if (deletionManager != null) { + deletionManager.setDelegate(delegate); + } + } + + @Override + protected Record reconcileRecords(Record remoteRecord, Record localRecord, + long lastRemoteRetrieval, + long lastLocalRetrieval) { + + BookmarkRecord reconciled = (BookmarkRecord) super.reconcileRecords(remoteRecord, localRecord, + lastRemoteRetrieval, + lastLocalRetrieval); + + // For now we *always* use the remote record's children array as a starting point. + // We won't write it into the database yet; we'll record it and process as we go. + reconciled.children = ((BookmarkRecord) remoteRecord).children; + + // *Always* track folders, though: if we decide we need to reposition items, we'll + // untrack later. + if (reconciled.isFolder()) { + trackRecord(reconciled); + } + return reconciled; + } + + /** + * Rename mobile folders to "mobile", both in and out. The other half of + * this logic lives in {@link #computeParentFields(BookmarkRecord, String, String)}, where + * the parent name of a record is set from {@link #SPECIAL_GUIDS_MAP} rather than + * from source data. + * + * Apply this approach generally for symmetry. + */ + @Override + protected void fixupRecord(Record record) { + final BookmarkRecord r = (BookmarkRecord) record; + final String parentName = SPECIAL_GUIDS_MAP.get(r.parentID); + if (parentName == null) { + return; + } + if (Logger.shouldLogVerbose(LOG_TAG)) { + Logger.trace(LOG_TAG, "Replacing parent name \"" + r.parentName + "\" with \"" + parentName + "\"."); + } + r.parentName = parentName; + } + + @Override + protected Record prepareRecord(Record record) { + if (record.deleted) { + Logger.debug(LOG_TAG, "No need to prepare deleted record " + record.guid); + return record; + } + + BookmarkRecord bmk = (BookmarkRecord) record; + + if (!isSpecialRecord(record)) { + // We never want to reparent special records. + handleParenting(bmk); + } + + if (Logger.LOG_PERSONAL_INFORMATION) { + if (bmk.isFolder()) { + Logger.pii(LOG_TAG, "Inserting folder " + bmk.guid + ", " + bmk.title + + " with parent " + bmk.androidParentID + + " (" + bmk.parentID + ", " + bmk.parentName + + ", " + bmk.androidPosition + ")"); + } else { + Logger.pii(LOG_TAG, "Inserting bookmark " + bmk.guid + ", " + bmk.title + ", " + + bmk.bookmarkURI + " with parent " + bmk.androidParentID + + " (" + bmk.parentID + ", " + bmk.parentName + + ", " + bmk.androidPosition + ")"); + } + } else { + if (bmk.isFolder()) { + Logger.debug(LOG_TAG, "Inserting folder " + bmk.guid + ", parent " + + bmk.androidParentID + + " (" + bmk.parentID + ", " + bmk.androidPosition + ")"); + } else { + Logger.debug(LOG_TAG, "Inserting bookmark " + bmk.guid + " with parent " + + bmk.androidParentID + + " (" + bmk.parentID + ", " + ", " + bmk.androidPosition + ")"); + } + } + return bmk; + } + + /** + * If the provided record doesn't have correct parent information, + * update appropriate bookkeeping to improve the situation. + * + * @param bmk + */ + private void handleParenting(BookmarkRecord bmk) { + if (parentGuidToIDMap.containsKey(bmk.parentID)) { + bmk.androidParentID = parentGuidToIDMap.get(bmk.parentID); + + // Might as well set a basic position from the downloaded children array. + JSONArray children = parentToChildArray.get(bmk.parentID); + if (children != null) { + int index = children.indexOf(bmk.guid); + if (index >= 0) { + bmk.androidPosition = index; + } + } + } + else { + bmk.androidParentID = parentGuidToIDMap.get("unfiled"); + ArrayList<String> children; + if (missingParentToChildren.containsKey(bmk.parentID)) { + children = missingParentToChildren.get(bmk.parentID); + } else { + children = new ArrayList<String>(); + } + children.add(bmk.guid); + needsReparenting++; + missingParentToChildren.put(bmk.parentID, children); + } + } + + private boolean isSpecialRecord(Record record) { + return SPECIAL_GUID_PARENTS.containsKey(record.guid); + } + + @Override + protected void updateBookkeeping(Record record) throws NoGuidForIdException, + NullCursorException, + ParentNotFoundException { + super.updateBookkeeping(record); + BookmarkRecord bmk = (BookmarkRecord) record; + + // If record is folder, update maps and re-parent children if necessary. + if (!bmk.isFolder()) { + Logger.debug(LOG_TAG, "Not a folder. No bookkeeping."); + return; + } + + Logger.debug(LOG_TAG, "Updating bookkeeping for folder " + record.guid); + + // Mappings between ID and GUID. + // TODO: update our persisted children arrays! + // TODO: if our Android ID just changed, replace parents for all of our children. + parentGuidToIDMap.put(bmk.guid, bmk.androidID); + parentIDToGuidMap.put(bmk.androidID, bmk.guid); + + JSONArray childArray = bmk.children; + + if (Logger.shouldLogVerbose(LOG_TAG)) { + Logger.trace(LOG_TAG, bmk.guid + " has children " + childArray.toJSONString()); + } + parentToChildArray.put(bmk.guid, childArray); + + // Re-parent. + if (missingParentToChildren.containsKey(bmk.guid)) { + for (String child : missingParentToChildren.get(bmk.guid)) { + // This might return -1; that's OK, the bookmark will + // be properly repositioned later. + long position = childArray.indexOf(child); + dataAccessor.updateParentAndPosition(child, bmk.androidID, position); + needsReparenting--; + } + missingParentToChildren.remove(bmk.guid); + } + } + + @Override + protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + try { + insertionManager.enqueueRecord((BookmarkRecord) record); + } catch (Exception e) { + throw new NullCursorException(e); + } + } + + @Override + protected void storeRecordDeletion(final Record record, final Record existingRecord) { + if (SPECIAL_GUIDS_MAP.containsKey(record.guid)) { + Logger.debug(LOG_TAG, "Told to delete record " + record.guid + ". Ignoring."); + return; + } + final BookmarkRecord bookmarkRecord = (BookmarkRecord) record; + final BookmarkRecord existingBookmark = (BookmarkRecord) existingRecord; + final boolean isFolder = existingBookmark.isFolder(); + final String parentGUID = existingBookmark.parentID; + deletionManager.deleteRecord(bookmarkRecord.guid, isFolder, parentGUID); + } + + protected void flushQueues() { + long now = now(); + Logger.debug(LOG_TAG, "Applying remaining insertions."); + try { + insertionManager.finishUp(); + Logger.debug(LOG_TAG, "Done applying remaining insertions."); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Unable to apply remaining insertions.", e); + } + + Logger.debug(LOG_TAG, "Applying deletions."); + try { + untrackGUIDs(deletionManager.flushAll(getIDForGUID("unfiled"), now)); + Logger.debug(LOG_TAG, "Done applying deletions."); + } catch (Exception e) { + Logger.error(LOG_TAG, "Unable to apply deletions.", e); + } + } + + @SuppressWarnings("unchecked") + private void finishUp() { + try { + flushQueues(); + Logger.debug(LOG_TAG, "Have " + parentToChildArray.size() + " folders whose children might need repositioning."); + for (Entry<String, JSONArray> entry : parentToChildArray.entrySet()) { + String guid = entry.getKey(); + JSONArray onServer = entry.getValue(); + try { + final long folderID = getIDForGUID(guid); + final JSONArray inDB = new JSONArray(); + final boolean clean = getChildrenArray(folderID, false, inDB); + final boolean sameArrays = Utils.sameArrays(onServer, inDB); + + // If the local children and the remote children are already + // the same, then we don't need to bump the modified time of the + // parent: we wouldn't upload a different record, so avoid the cycle. + if (!sameArrays) { + int added = 0; + for (Object o : inDB) { + if (!onServer.contains(o)) { + onServer.add(o); + added++; + } + } + Logger.debug(LOG_TAG, "Added " + added + " items locally."); + Logger.debug(LOG_TAG, "Untracking and bumping " + guid + "(" + folderID + ")"); + dataAccessor.bumpModified(folderID, now()); + untrackGUID(guid); + } + + // If the arrays are different, or they're the same but not flushed to disk, + // write them out now. + if (!sameArrays || !clean) { + dataAccessor.updatePositions(new ArrayList<String>(onServer)); + } + } catch (Exception e) { + Logger.warn(LOG_TAG, "Error repositioning children for " + guid, e); + } + } + } finally { + super.storeDone(); + } + } + + /** + * Hook into the deletion manager on wipe. + */ + class BookmarkWipeRunnable extends WipeRunnable { + public BookmarkWipeRunnable(RepositorySessionWipeDelegate delegate) { + super(delegate); + } + + @Override + public void run() { + try { + // Clear our queued deletions. + deletionManager.clear(); + insertionManager.clear(); + super.run(); + } catch (Exception ex) { + delegate.onWipeFailed(ex); + return; + } + } + } + + @Override + protected WipeRunnable getWipeRunnable(RepositorySessionWipeDelegate delegate) { + return new BookmarkWipeRunnable(delegate); + } + + @Override + public void storeDone() { + Runnable command = new Runnable() { + @Override + public void run() { + finishUp(); + } + }; + storeWorkQueue.execute(command); + } + + @Override + protected String buildRecordString(Record record) { + BookmarkRecord bmk = (BookmarkRecord) record; + String parent = bmk.parentName + "/"; + if (bmk.isBookmark()) { + return "b" + parent + bmk.bookmarkURI + ":" + bmk.title; + } + if (bmk.isFolder()) { + return "f" + parent + bmk.title; + } + if (bmk.isSeparator()) { + return "s" + parent + bmk.androidPosition; + } + if (bmk.isQuery()) { + return "q" + parent + bmk.bookmarkURI; + } + return null; + } + + public static BookmarkRecord computeParentFields(BookmarkRecord rec, String suggestedParentGUID, String suggestedParentName) { + final String guid = rec.guid; + if (guid == null) { + // Oh dear. + Logger.error(LOG_TAG, "No guid in computeParentFields!"); + return null; + } + + String realParent = SPECIAL_GUID_PARENTS.get(guid); + if (realParent == null) { + // No magic parent. Use whatever the caller suggests. + realParent = suggestedParentGUID; + } else { + Logger.debug(LOG_TAG, "Ignoring suggested parent ID " + suggestedParentGUID + + " for " + guid + "; using " + realParent); + } + + if (realParent == null) { + // Oh dear. + Logger.error(LOG_TAG, "No parent for record " + guid); + return null; + } + + // Always set the parent name for special folders back to default. + String parentName = SPECIAL_GUIDS_MAP.get(realParent); + if (parentName == null) { + parentName = suggestedParentName; + } + + rec.parentID = realParent; + rec.parentName = parentName; + return rec; + } + + private static BookmarkRecord logBookmark(BookmarkRecord rec) { + try { + Logger.debug(LOG_TAG, "Returning " + (rec.deleted ? "deleted " : "") + + "bookmark record " + rec.guid + " (" + rec.androidID + + ", parent " + rec.parentID + ")"); + if (!rec.deleted && Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(LOG_TAG, "> Parent name: " + rec.parentName); + Logger.pii(LOG_TAG, "> Title: " + rec.title); + Logger.pii(LOG_TAG, "> Type: " + rec.type); + Logger.pii(LOG_TAG, "> URI: " + rec.bookmarkURI); + Logger.pii(LOG_TAG, "> Position: " + rec.androidPosition); + if (rec.isFolder()) { + Logger.pii(LOG_TAG, "FOLDER: Children are " + + (rec.children == null ? + "null" : + rec.children.toJSONString())); + } + } + } catch (Exception e) { + Logger.debug(LOG_TAG, "Exception logging bookmark record " + rec, e); + } + return rec; + } + + // Create a BookmarkRecord object from a cursor on a row containing a Fennec bookmark. + public static BookmarkRecord bookmarkFromMirrorCursor(Cursor cur, String parentGUID, String parentName, JSONArray children) { + final String collection = "bookmarks"; + final String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID); + final long lastModified = RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED); + final boolean deleted = isDeleted(cur); + BookmarkRecord rec = new BookmarkRecord(guid, collection, lastModified, deleted); + + // No point in populating it. + if (deleted) { + return logBookmark(rec); + } + + int rowType = getTypeFromCursor(cur); + String typeString = BrowserContractHelpers.typeStringForCode(rowType); + + if (typeString == null) { + Logger.warn(LOG_TAG, "Unsupported type code " + rowType); + return null; + } + + Logger.trace(LOG_TAG, "Record " + guid + " has type " + typeString); + + rec.type = typeString; + rec.title = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.TITLE); + rec.bookmarkURI = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.URL); + rec.description = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.DESCRIPTION); + rec.tags = RepoUtils.getJSONArrayFromCursor(cur, BrowserContract.Bookmarks.TAGS); + rec.keyword = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.KEYWORD); + + rec.androidID = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID); + rec.androidPosition = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION); + rec.children = children; + + // Need to restore the parentId since it isn't stored in content provider. + // We also take this opportunity to fix up parents for special folders, + // allowing us to map between the hierarchies used by Fennec and Places. + BookmarkRecord withParentFields = computeParentFields(rec, parentGUID, parentName); + if (withParentFields == null) { + // Oh dear. Something went wrong. + return null; + } + return logBookmark(withParentFields); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java new file mode 100644 index 000000000..c09d64708 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java @@ -0,0 +1,188 @@ +/* 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.repositories.android; + +import java.util.ArrayList; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.domain.HistoryRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; + +public class AndroidBrowserHistoryDataAccessor extends + AndroidBrowserRepositoryDataAccessor { + + public AndroidBrowserHistoryDataAccessor(Context context) { + super(context); + } + + @Override + protected Uri getUri() { + return BrowserContractHelpers.HISTORY_CONTENT_URI; + } + + @Override + protected ContentValues getContentValues(Record record) { + ContentValues cv = new ContentValues(); + HistoryRecord rec = (HistoryRecord) record; + cv.put(BrowserContract.History.GUID, rec.guid); + cv.put(BrowserContract.History.TITLE, rec.title); + cv.put(BrowserContract.History.URL, rec.histURI); + if (rec.visits != null) { + JSONArray visits = rec.visits; + long mostRecent = getLastVisited(visits); + + // Fennec stores history timestamps in milliseconds, and visit timestamps in microseconds. + // The rest of Sync works in microseconds. This is the conversion point for records coming form Sync. + cv.put(BrowserContract.History.DATE_LAST_VISITED, mostRecent / 1000); + cv.put(BrowserContract.History.REMOTE_DATE_LAST_VISITED, mostRecent / 1000); + cv.put(BrowserContract.History.VISITS, Long.toString(visits.size())); + } + return cv; + } + + @Override + protected String[] getAllColumns() { + return BrowserContractHelpers.HistoryColumns; + } + + @Override + public Uri insert(Record record) { + HistoryRecord rec = (HistoryRecord) record; + + Logger.debug(LOG_TAG, "Storing record " + record.guid); + Uri newRecordUri = super.insert(record); + + Logger.debug(LOG_TAG, "Storing visits for " + record.guid); + context.getContentResolver().bulkInsert( + BrowserContract.Visits.CONTENT_URI, + VisitsHelper.getVisitsContentValues(rec.guid, rec.visits) + ); + + return newRecordUri; + } + + /** + * Given oldGUID, first updates corresponding history record with new values (super operation), + * and then inserts visits from the new record. + * Existing visits from the old record are updated on database level to point to new GUID if necessary. + * + * @param oldGUID GUID of old <code>HistoryRecord</code> + * @param newRecord new <code>HistoryRecord</code> to replace old one with, and insert visits from + */ + @Override + public void update(String oldGUID, Record newRecord) { + // First, update existing history records with new values. This might involve changing history GUID, + // and thanks to ON UPDATE CASCADE clause on Visits.HISTORY_GUID foreign key, visits will be "ported over" + // to the new GUID. + super.update(oldGUID, newRecord); + + // Now we need to insert any visits from the new record + HistoryRecord rec = (HistoryRecord) newRecord; + String newGUID = newRecord.guid; + Logger.debug(LOG_TAG, "Storing visits for " + newGUID + ", replacing " + oldGUID); + + context.getContentResolver().bulkInsert( + BrowserContract.Visits.CONTENT_URI, + VisitsHelper.getVisitsContentValues(newGUID, rec.visits) + ); + } + + /** + * Insert records. + * <p> + * This inserts all the records (using <code>ContentProvider.bulkInsert</code>), + * then inserts all the visit information (also using <code>ContentProvider.bulkInsert</code>). + * + * @param records + * the records to insert. + * @return + * the number of records actually inserted. + * @throws NullCursorException + */ + public int bulkInsert(ArrayList<HistoryRecord> records) throws NullCursorException { + if (records.isEmpty()) { + Logger.debug(LOG_TAG, "No records to insert, returning."); + } + + int size = records.size(); + ContentValues[] cvs = new ContentValues[size]; + int index = 0; + for (Record record : records) { + if (record.guid == null) { + throw new IllegalArgumentException("Record with null GUID passed in to bulkInsert."); + } + cvs[index] = getContentValues(record); + index += 1; + } + + // First update the history records. + int inserted = context.getContentResolver().bulkInsert(getUri(), cvs); + if (inserted == size) { + Logger.debug(LOG_TAG, "Inserted " + inserted + " records, as expected."); + } else { + Logger.debug(LOG_TAG, "Inserted " + + inserted + " records but expected " + + size + " records; continuing to update visits."); + } + + final ContentValues remoteVisitAggregateValues = new ContentValues(); + final Uri historyIncrementRemoteAggregateUri = getUri().buildUpon() + .appendQueryParameter(BrowserContract.PARAM_INCREMENT_REMOTE_AGGREGATES, "true") + .build(); + for (Record record : records) { + HistoryRecord rec = (HistoryRecord) record; + if (rec.visits != null && rec.visits.size() != 0) { + int remoteVisitsInserted = context.getContentResolver().bulkInsert( + BrowserContract.Visits.CONTENT_URI, + VisitsHelper.getVisitsContentValues(rec.guid, rec.visits) + ); + + // If we just inserted any visits, update remote visit aggregate values. + // While inserting visits, we might not insert all of rec.visits - if we already have a local + // visit record with matching (guid,date), we will skip that visit. + // Remote visits aggregate value will be incremented by number of visits inserted. + // Note that we don't need to set REMOTE_DATE_LAST_VISITED, because it already gets set above. + if (remoteVisitsInserted > 0) { + // Note that REMOTE_VISITS must be set before calling cr.update(...) with a URI + // that has PARAM_INCREMENT_REMOTE_AGGREGATES=true. + remoteVisitAggregateValues.put(BrowserContract.History.REMOTE_VISITS, remoteVisitsInserted); + context.getContentResolver().update( + historyIncrementRemoteAggregateUri, + remoteVisitAggregateValues, + BrowserContract.History.GUID + " = ?", new String[] {rec.guid} + ); + } + } + } + + return inserted; + } + + /** + * Helper method used to find largest <code>VisitsHelper.SYNC_DATE_KEY</code> value in a provided JSONArray. + * + * @param visits Array of objects which will be searched. + * @return largest value of <code>VisitsHelper.SYNC_DATE_KEY</code>. + */ + private long getLastVisited(JSONArray visits) { + long mostRecent = 0; + for (int i = 0; i < visits.size(); i++) { + final JSONObject visit = (JSONObject) visits.get(i); + long visitDate = (Long) visit.get(VisitsHelper.SYNC_DATE_KEY); + if (visitDate > mostRecent) { + mostRecent = visitDate; + } + } + return mostRecent; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java new file mode 100644 index 000000000..bd2b5d31f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java @@ -0,0 +1,25 @@ +/* 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.repositories.android; + +import org.mozilla.gecko.sync.repositories.HistoryRepository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; + +public class AndroidBrowserHistoryRepository extends AndroidBrowserRepository implements HistoryRepository { + + @Override + protected void sessionCreator(RepositorySessionCreationDelegate delegate, Context context) { + AndroidBrowserHistoryRepositorySession session = new AndroidBrowserHistoryRepositorySession(AndroidBrowserHistoryRepository.this, context); + delegate.onSessionCreated(session); + } + + @Override + protected AndroidBrowserRepositoryDataAccessor getDataAccessor(Context context) { + return new AndroidBrowserHistoryDataAccessor(context); + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java new file mode 100644 index 000000000..7c462abc3 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java @@ -0,0 +1,208 @@ +/* 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.repositories.android; + +import java.util.ArrayList; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.NoGuidForIdException; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.ParentNotFoundException; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.domain.HistoryRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentProviderClient; +import android.content.Context; +import android.database.Cursor; +import android.os.RemoteException; + +public class AndroidBrowserHistoryRepositorySession extends AndroidBrowserRepositorySession { + public static final String LOG_TAG = "ABHistoryRepoSess"; + + /** + * The number of records to queue for insertion before writing to databases. + */ + public static final int INSERT_RECORD_THRESHOLD = 50; + public static final int RECENT_VISITS_LIMIT = 20; + + public AndroidBrowserHistoryRepositorySession(Repository repository, Context context) { + super(repository); + dbHelper = new AndroidBrowserHistoryDataAccessor(context); + } + + @Override + public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { + // HACK: Fennec creates history records without a GUID. Mercilessly drop + // them on the floor. See Bug 739514. + try { + dbHelper.delete(BrowserContract.History.GUID + " IS NULL", null); + } catch (Exception e) { + // Ignore. + } + super.begin(delegate); + } + + @Override + protected Record retrieveDuringStore(Cursor cur) { + return RepoUtils.historyFromMirrorCursor(cur); + } + + @Override + protected Record retrieveDuringFetch(Cursor cur) { + return RepoUtils.historyFromMirrorCursor(cur); + } + + @Override + protected String buildRecordString(Record record) { + HistoryRecord hist = (HistoryRecord) record; + return hist.histURI; + } + + @Override + public boolean shouldIgnore(Record record) { + if (super.shouldIgnore(record)) { + return true; + } + if (!(record instanceof HistoryRecord)) { + return true; + } + HistoryRecord r = (HistoryRecord) record; + return !RepoUtils.isValidHistoryURI(r.histURI); + } + + @Override + protected Record transformRecord(Record record) throws NullCursorException { + return addVisitsToRecord(record); + } + + private Record addVisitsToRecord(Record record) throws NullCursorException { + Logger.debug(LOG_TAG, "Adding visits for GUID " + record.guid); + + // Sync is an object store, so what we attach here will replace what's already present on the Sync servers. + // We upload just a recent subset of visits for each history record for space and bandwidth reasons. + // We chose 20 to be conservative. See Bug 1164660 for details. + ContentProviderClient visitsClient = dbHelper.context.getContentResolver().acquireContentProviderClient(BrowserContractHelpers.VISITS_CONTENT_URI); + if (visitsClient == null) { + throw new IllegalStateException("Could not obtain a ContentProviderClient for Visits URI"); + } + + try { + ((HistoryRecord) record).visits = VisitsHelper.getRecentHistoryVisitsForGUID( + visitsClient, record.guid, RECENT_VISITS_LIMIT); + } catch (RemoteException e) { + throw new IllegalStateException("Error while obtaining visits for a record", e); + } finally { + visitsClient.release(); + } + + return record; + } + + @Override + protected Record prepareRecord(Record record) { + return record; + } + + protected final Object recordsBufferMonitor = new Object(); + protected ArrayList<HistoryRecord> recordsBuffer = new ArrayList<HistoryRecord>(); + + /** + * Queue record for insertion, possibly flushing the queue. + * <p> + * Must be called on <code>storeWorkQueue</code> thread! But this is only + * called from <code>store</code>, which is called on the queue thread. + * + * @param record + * A <code>Record</code> with a GUID that is not present locally. + */ + @Override + protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + enqueueNewRecord((HistoryRecord) prepareRecord(record)); + } + + /** + * Batch incoming records until some reasonable threshold is hit or storeDone + * is received. + * <p> + * Must be called on <code>storeWorkQueue</code> thread! + * + * @param record A <code>Record</code> with a GUID that is not present locally. + * @throws NullCursorException + */ + protected void enqueueNewRecord(HistoryRecord record) throws NullCursorException { + synchronized (recordsBufferMonitor) { + if (recordsBuffer.size() >= INSERT_RECORD_THRESHOLD) { + flushNewRecords(); + } + Logger.debug(LOG_TAG, "Enqueuing new record with GUID " + record.guid); + recordsBuffer.add(record); + } + } + + /** + * Flush queue of incoming records to database. + * <p> + * Must be called on <code>storeWorkQueue</code> thread! + * <p> + * Must be locked by recordsBufferMonitor! + * @throws NullCursorException + */ + protected void flushNewRecords() throws NullCursorException { + if (recordsBuffer.size() < 1) { + Logger.debug(LOG_TAG, "No records to flush, returning."); + return; + } + + final ArrayList<HistoryRecord> outgoing = recordsBuffer; + recordsBuffer = new ArrayList<HistoryRecord>(); + Logger.debug(LOG_TAG, "Flushing " + outgoing.size() + " records to database."); + // TODO: move bulkInsert to AndroidBrowserDataAccessor? + int inserted = ((AndroidBrowserHistoryDataAccessor) dbHelper).bulkInsert(outgoing); + if (inserted != outgoing.size()) { + // Something failed; most pessimistic action is to declare that all insertions failed. + // TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed? + for (HistoryRecord failed : outgoing) { + delegate.onRecordStoreFailed(new RuntimeException("Failed to insert history item with guid " + failed.guid + "."), failed.guid); + } + return; + } + + // All good, everybody succeeded. + for (HistoryRecord succeeded : outgoing) { + try { + // Does not use androidID -- just GUID -> String map. + updateBookkeeping(succeeded); + } catch (NoGuidForIdException | ParentNotFoundException e) { + // Should not happen. + throw new NullCursorException(e); + } catch (NullCursorException e) { + throw e; + } + trackRecord(succeeded); + delegate.onRecordStoreSucceeded(succeeded.guid); // At this point, we are really inserted. + } + } + + @Override + public void storeDone() { + storeWorkQueue.execute(new Runnable() { + @Override + public void run() { + synchronized (recordsBufferMonitor) { + try { + flushNewRecords(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Error flushing records to database.", e); + } + } + storeDone(System.currentTimeMillis()); + } + }); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java new file mode 100644 index 000000000..6c5c661ee --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java @@ -0,0 +1,74 @@ +/* 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.repositories.android; + +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCleanDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; + +public abstract class AndroidBrowserRepository extends Repository { + + @Override + public void createSession(RepositorySessionCreationDelegate delegate, Context context) { + new CreateSessionThread(delegate, context).start(); + } + + @Override + public void clean(boolean success, RepositorySessionCleanDelegate delegate, Context context) { + // Only clean deleted records if success + if (success) { + new CleanThread(delegate, context).start(); + } + } + + class CleanThread extends Thread { + private final RepositorySessionCleanDelegate delegate; + private final Context context; + + public CleanThread(RepositorySessionCleanDelegate delegate, Context context) { + if (context == null) { + throw new IllegalArgumentException("context is null"); + } + this.delegate = delegate; + this.context = context; + } + + @Override + public void run() { + try { + getDataAccessor(context).purgeDeleted(); + } catch (Exception e) { + delegate.onCleanFailed(AndroidBrowserRepository.this, e); + return; + } + delegate.onCleaned(AndroidBrowserRepository.this); + } + } + + protected abstract AndroidBrowserRepositoryDataAccessor getDataAccessor(Context context); + protected abstract void sessionCreator(RepositorySessionCreationDelegate delegate, Context context); + + class CreateSessionThread extends Thread { + private final RepositorySessionCreationDelegate delegate; + private final Context context; + + public CreateSessionThread(RepositorySessionCreationDelegate delegate, Context context) { + if (context == null) { + throw new IllegalArgumentException("context is null."); + } + this.delegate = delegate; + this.context = context; + } + + @Override + public void run() { + sessionCreator(delegate, context); + } + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java new file mode 100644 index 000000000..138d63d4c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java @@ -0,0 +1,232 @@ +/* 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.repositories.android; + +import java.util.List; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.db.CursorDumper; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +public abstract class AndroidBrowserRepositoryDataAccessor { + + private static final String[] GUID_COLUMNS = new String[] { BrowserContract.SyncColumns.GUID }; + protected Context context; + protected static String LOG_TAG = "BrowserDataAccessor"; + protected final RepoUtils.QueryHelper queryHelper; + + public AndroidBrowserRepositoryDataAccessor(Context context) { + this.context = context; + this.queryHelper = new RepoUtils.QueryHelper(context, getUri(), LOG_TAG); + } + + protected abstract String[] getAllColumns(); + + /** + * Produce a <code>ContentValues</code> instance that represents the provided <code>Record</code>. + * + * @param record The <code>Record</code> to be converted. + * @return The <code>ContentValues</code> corresponding to <code>record</code>. + */ + protected abstract ContentValues getContentValues(Record record); + + protected abstract Uri getUri(); + + /** + * Dump all the records in raw format. + */ + public void dumpDB() { + Cursor cur = null; + try { + cur = queryHelper.safeQuery(".dumpDB", null, null, null, null); + CursorDumper.dumpCursor(cur); + } catch (NullCursorException e) { + } finally { + if (cur != null) { + cur.close(); + } + } + } + + public String dateModifiedWhere(long timestamp) { + return BrowserContract.SyncColumns.DATE_MODIFIED + " >= " + Long.toString(timestamp); + } + + public void delete(String where, String[] args) { + Uri uri = getUri(); + context.getContentResolver().delete(uri, where, args); + } + + public void wipe() { + Logger.debug(LOG_TAG, "Wiping."); + delete(null, null); + } + + public void purgeDeleted() throws NullCursorException { + String where = BrowserContract.SyncColumns.IS_DELETED + "= 1"; + Uri uri = getUri(); + Logger.info(LOG_TAG, "Purging deleted from: " + uri); + context.getContentResolver().delete(uri, where, null); + } + + /** + * Remove matching records from the database entirely, i.e., do not set a + * deleted flag, delete entirely. + * + * @param guid + * The GUID of the record to be deleted. + * @return The number of records deleted. + */ + public int purgeGuid(String guid) { + String where = BrowserContract.SyncColumns.GUID + " = ?"; + String[] args = new String[] { guid }; + + int deleted = context.getContentResolver().delete(getUri(), where, args); + if (deleted != 1) { + Logger.warn(LOG_TAG, "Unexpectedly deleted " + deleted + " records for guid " + guid); + } + return deleted; + } + + public void update(String guid, Record newRecord) { + String where = BrowserContract.SyncColumns.GUID + " = ?"; + String[] args = new String[] { guid }; + ContentValues cv = getContentValues(newRecord); + int updated = context.getContentResolver().update(getUri(), cv, where, args); + if (updated != 1) { + Logger.warn(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + guid); + } + } + + public Uri insert(Record record) { + ContentValues cv = getContentValues(record); + return context.getContentResolver().insert(getUri(), cv); + } + + /** + * Fetch all records. + * <p> + * The caller is responsible for closing the cursor. + * + * @return A cursor. You </b>must</b> close this when you're done with it. + * @throws NullCursorException + */ + public Cursor fetchAll() throws NullCursorException { + return queryHelper.safeQuery(".fetchAll", getAllColumns(), null, null, null); + } + + /** + * Fetch GUIDs for records modified since the provided timestamp. + * <p> + * The caller is responsible for closing the cursor. + * + * @param timestamp A timestamp in milliseconds. + * @return A cursor. You <b>must</b> close this when you're done with it. + * @throws NullCursorException + */ + public Cursor getGUIDsSince(long timestamp) throws NullCursorException { + return queryHelper.safeQuery(".getGUIDsSince", + GUID_COLUMNS, + dateModifiedWhere(timestamp), + null, null); + } + + /** + * Fetch records modified since the provided timestamp. + * <p> + * The caller is responsible for closing the cursor. + * + * @param timestamp A timestamp in milliseconds. + * @return A cursor. You <b>must</b> close this when you're done with it. + * @throws NullCursorException + */ + public Cursor fetchSince(long timestamp) throws NullCursorException { + return queryHelper.safeQuery(".fetchSince", + getAllColumns(), + dateModifiedWhere(timestamp), + null, null); + } + + /** + * Fetch records for the provided GUIDs. + * <p> + * The caller is responsible for closing the cursor. + * + * @param guids The GUIDs of the records to fetch. + * @return A cursor. You <b>must</b> close this when you're done with it. + * @throws NullCursorException + */ + public Cursor fetch(String guids[]) throws NullCursorException { + String where = RepoUtils.computeSQLInClause(guids.length, "guid"); + return queryHelper.safeQuery(".fetch", getAllColumns(), where, guids, null); + } + + public void updateByGuid(String guid, ContentValues cv) { + String where = BrowserContract.SyncColumns.GUID + " = ?"; + String[] args = new String[] { guid }; + + int updated = context.getContentResolver().update(getUri(), cv, where, args); + if (updated == 1) { + return; + } + Logger.warn(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + guid); + } + + /** + * Insert records. + * <p> + * This inserts all the records (using <code>ContentProvider.bulkInsert</code>), + * but does <b>not</b> update the <code>androidID</code> of each record. + * + * @param records + * the records to insert. + * @return + * the number of records actually inserted. + * @throws NullCursorException + */ + public int bulkInsert(List<Record> records) throws NullCursorException { + if (records.isEmpty()) { + Logger.debug(LOG_TAG, "No records to insert, returning."); + } + + int size = records.size(); + ContentValues[] cvs = new ContentValues[size]; + int index = 0; + for (Record record : records) { + try { + cvs[index] = getContentValues(record); + index += 1; + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception in getContentValues for record with guid " + record.guid, e); + } + } + + if (index != size) { + // bulkInsert treats null ContentValues as blank rows, which we don't want + // to insert into the database. + // We expect exceptions in getContentValues to be exceedingly rare, so we + // re-allocate in the (rare) error case and maintain a fast path for the + // success case. + size = index; + } + + int inserted = context.getContentResolver().bulkInsert(getUri(), cvs); + if (inserted == size) { + Logger.debug(LOG_TAG, "Inserted " + inserted + " records, as expected."); + } else { + Logger.debug(LOG_TAG, "Inserted " + + inserted + " records but expected " + + size + " records."); + } + return inserted; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java new file mode 100644 index 000000000..4f0da0bcc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java @@ -0,0 +1,792 @@ +/* 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.repositories.android; + +import java.util.ArrayList; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.InvalidRequestException; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.MultipleRecordsForGuidException; +import org.mozilla.gecko.sync.repositories.NoGuidForIdException; +import org.mozilla.gecko.sync.repositories.NoStoreDelegateException; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.ParentNotFoundException; +import org.mozilla.gecko.sync.repositories.ProfileDatabaseException; +import org.mozilla.gecko.sync.repositories.RecordFilter; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentUris; +import android.database.Cursor; +import android.net.Uri; +import android.util.SparseArray; + +/** + * You'll notice that all delegate calls *either*: + * + * - request a deferred delegate with the appropriate work queue, then + * make the appropriate call, or + * - create a Runnable which makes the appropriate call, and pushes it + * directly into the appropriate work queue. + * + * This is to ensure that all delegate callbacks happen off the current + * thread. This provides lock safety (we don't enter another method that + * might try to take a lock already taken in our caller), and ensures + * that operations take place off the main thread. + * + * Don't do both -- the two approaches are equivalent -- and certainly + * don't do neither unless you know what you're doing! + * + * Similarly, all store calls go through the appropriate store queue. This + * ensures that store() and storeDone() consequences occur before-after. + * + * @author rnewman + * + */ +public abstract class AndroidBrowserRepositorySession extends StoreTrackingRepositorySession { + public static final String LOG_TAG = "BrowserRepoSession"; + + protected AndroidBrowserRepositoryDataAccessor dbHelper; + + /** + * In order to reconcile the "same record" with two *different* GUIDs (for + * example, the same bookmark created by two different clients), we maintain a + * mapping for each local record from a "record string" to + * "local record GUID". + * <p> + * The "record string" above is a "record identifying unique key" produced by + * <code>buildRecordString</code>. + * <p> + * Since we hash each "record string", this map may produce a false positive. + * In this case, we search the database for a matching record explicitly using + * <code>findByRecordString</code>. + */ + protected SparseArray<String> recordToGuid; + + public AndroidBrowserRepositorySession(Repository repository) { + super(repository); + } + + /** + * Retrieve a record from a cursor. Act as if we don't know the final contents of + * the record: for example, a folder's child array might change. + * + * Return null if this record should not be processed. + * + * @throws NoGuidForIdException + * @throws NullCursorException + * @throws ParentNotFoundException + */ + protected abstract Record retrieveDuringStore(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException; + + /** + * Retrieve a record from a cursor. Ensure that the contents of the database are + * updated to match the record that we're constructing: for example, the children + * of a folder might be repositioned as we generate the folder's record. + * + * @throws NoGuidForIdException + * @throws NullCursorException + * @throws ParentNotFoundException + */ + protected abstract Record retrieveDuringFetch(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException; + + /** + * Override this to allow records to be skipped during insertion. + * + * For example, a session subclass might skip records of an unsupported type. + */ + @SuppressWarnings("static-method") + public boolean shouldIgnore(Record record) { + return false; + } + + /** + * Perform any necessary transformation of a record prior to searching by + * any field other than GUID. + * + * Example: translating remote folder names into local names. + */ + @SuppressWarnings("static-method") + protected void fixupRecord(Record record) { + return; + } + + /** + * Override in subclass to implement record extension. + * + * Populate any fields of the record that are expensive to calculate, + * prior to reconciling. + * + * Example: computing children arrays. + * + * Return null if this record should not be processed. + * + * @param record + * The record to transform. Can be null. + * @return The transformed record. Can be null. + * @throws NullCursorException + */ + @SuppressWarnings("static-method") + protected Record transformRecord(Record record) throws NullCursorException { + return record; + } + + @Override + public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { + RepositorySessionBeginDelegate deferredDelegate = delegate.deferredBeginDelegate(delegateQueue); + super.sharedBegin(); + + try { + // We do this check here even though it results in one extra call to the DB + // because if we didn't, we have to do a check on every other call since there + // is no way of knowing which call would be hit first. + checkDatabase(); + } catch (ProfileDatabaseException e) { + Logger.error(LOG_TAG, "ProfileDatabaseException from begin. Fennec must be launched once until this error is fixed"); + deferredDelegate.onBeginFailed(e); + return; + } catch (Exception e) { + deferredDelegate.onBeginFailed(e); + return; + } + storeTracker = createStoreTracker(); + deferredDelegate.onBeginSucceeded(this); + } + + @Override + public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + dbHelper = null; + recordToGuid = null; + super.finish(delegate); + } + + /** + * Produce a "record string" (record identifying unique key). + * + * @param record + * the <code>Record</code> to identify. + * @return a <code>String</code> instance. + */ + protected abstract String buildRecordString(Record record); + + protected void checkDatabase() throws ProfileDatabaseException, NullCursorException { + Logger.debug(LOG_TAG, "BEGIN: checking database."); + try { + dbHelper.fetch(new String[] { "none" }).close(); + Logger.debug(LOG_TAG, "END: checking database."); + } catch (NullPointerException e) { + throw new ProfileDatabaseException(e); + } + } + + @Override + public void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate) { + GuidsSinceRunnable command = new GuidsSinceRunnable(timestamp, delegate); + delegateQueue.execute(command); + } + + class GuidsSinceRunnable implements Runnable { + + private final RepositorySessionGuidsSinceDelegate delegate; + private final long timestamp; + + public GuidsSinceRunnable(long timestamp, + RepositorySessionGuidsSinceDelegate delegate) { + this.timestamp = timestamp; + this.delegate = delegate; + } + + @Override + public void run() { + if (!isActive()) { + delegate.onGuidsSinceFailed(new InactiveSessionException(null)); + return; + } + + Cursor cur; + try { + cur = dbHelper.getGUIDsSince(timestamp); + } catch (Exception e) { + delegate.onGuidsSinceFailed(e); + return; + } + + ArrayList<String> guids; + try { + if (!cur.moveToFirst()) { + delegate.onGuidsSinceSucceeded(new String[] {}); + return; + } + guids = new ArrayList<String>(); + while (!cur.isAfterLast()) { + guids.add(RepoUtils.getStringFromCursor(cur, "guid")); + cur.moveToNext(); + } + } finally { + Logger.debug(LOG_TAG, "Closing cursor after guidsSince."); + cur.close(); + } + + String guidsArray[] = new String[guids.size()]; + guids.toArray(guidsArray); + delegate.onGuidsSinceSucceeded(guidsArray); + } + } + + @Override + public void fetch(String[] guids, + RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException { + FetchRunnable command = new FetchRunnable(guids, now(), null, delegate); + executeDelegateCommand(command); + } + + abstract class FetchingRunnable implements Runnable { + protected final RepositorySessionFetchRecordsDelegate delegate; + + public FetchingRunnable(RepositorySessionFetchRecordsDelegate delegate) { + this.delegate = delegate; + } + + protected void fetchFromCursor(Cursor cursor, RecordFilter filter, long end) { + Logger.debug(LOG_TAG, "Fetch from cursor:"); + try { + try { + if (!cursor.moveToFirst()) { + delegate.onFetchCompleted(end); + return; + } + while (!cursor.isAfterLast()) { + Record r = retrieveDuringFetch(cursor); + if (r != null) { + if (filter == null || !filter.excludeRecord(r)) { + Logger.trace(LOG_TAG, "Processing record " + r.guid); + delegate.onFetchedRecord(transformRecord(r)); + } else { + Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid); + } + } + cursor.moveToNext(); + } + delegate.onFetchCompleted(end); + } catch (NoGuidForIdException e) { + Logger.warn(LOG_TAG, "No GUID for ID.", e); + delegate.onFetchFailed(e, null); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Exception in fetchFromCursor.", e); + delegate.onFetchFailed(e, null); + return; + } + } finally { + Logger.trace(LOG_TAG, "Closing cursor after fetch."); + cursor.close(); + } + } + } + + public class FetchRunnable extends FetchingRunnable { + private final String[] guids; + private final long end; + private final RecordFilter filter; + + public FetchRunnable(String[] guids, + long end, + RecordFilter filter, + RepositorySessionFetchRecordsDelegate delegate) { + super(delegate); + this.guids = guids; + this.end = end; + this.filter = filter; + } + + @Override + public void run() { + if (!isActive()) { + delegate.onFetchFailed(new InactiveSessionException(null), null); + return; + } + + if (guids == null || guids.length < 1) { + Logger.error(LOG_TAG, "No guids sent to fetch"); + delegate.onFetchFailed(new InvalidRequestException(null), null); + return; + } + + try { + Cursor cursor = dbHelper.fetch(guids); + this.fetchFromCursor(cursor, filter, end); + } catch (NullCursorException e) { + delegate.onFetchFailed(e, null); + } + } + } + + @Override + public void fetchSince(long timestamp, + RepositorySessionFetchRecordsDelegate delegate) { + if (this.storeTracker == null) { + throw new IllegalStateException("Store tracker not yet initialized!"); + } + + Logger.debug(LOG_TAG, "Running fetchSince(" + timestamp + ")."); + FetchSinceRunnable command = new FetchSinceRunnable(timestamp, now(), this.storeTracker.getFilter(), delegate); + delegateQueue.execute(command); + } + + class FetchSinceRunnable extends FetchingRunnable { + private final long since; + private final long end; + private final RecordFilter filter; + + public FetchSinceRunnable(long since, + long end, + RecordFilter filter, + RepositorySessionFetchRecordsDelegate delegate) { + super(delegate); + this.since = since; + this.end = end; + this.filter = filter; + } + + @Override + public void run() { + if (!isActive()) { + delegate.onFetchFailed(new InactiveSessionException(null), null); + return; + } + + try { + Cursor cursor = dbHelper.fetchSince(since); + this.fetchFromCursor(cursor, filter, end); + } catch (NullCursorException e) { + delegate.onFetchFailed(e, null); + return; + } + } + } + + @Override + public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) { + this.fetchSince(0, delegate); + } + + protected int storeCount = 0; + + @Override + public void store(final Record record) throws NoStoreDelegateException { + if (delegate == null) { + throw new NoStoreDelegateException(); + } + if (record == null) { + Logger.error(LOG_TAG, "Record sent to store was null"); + throw new IllegalArgumentException("Null record passed to AndroidBrowserRepositorySession.store()."); + } + + storeCount += 1; + Logger.debug(LOG_TAG, "Storing record with GUID " + record.guid + " (stored " + storeCount + " records this session)."); + + // Store Runnables *must* complete synchronously. It's OK, they + // run on a background thread. + Runnable command = new Runnable() { + + @Override + public void run() { + if (!isActive()) { + Logger.warn(LOG_TAG, "AndroidBrowserRepositorySession is inactive. Store failing."); + delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid); + return; + } + + // Check that the record is a valid type. + // Fennec only supports bookmarks and folders. All other types of records, + // including livemarks and queries, are simply ignored. + // See Bug 708149. This might be resolved by Fennec changing its database + // schema, or by Sync storing non-applied records in its own private database. + if (shouldIgnore(record)) { + Logger.debug(LOG_TAG, "Ignoring record " + record.guid); + + // Don't throw: we don't want to abort the entire sync when we get a livemark! + // delegate.onRecordStoreFailed(new InvalidBookmarkTypeException(null)); + return; + } + + + // TODO: lift these into the session. + // Temporary: this matches prior syncing semantics, in which only + // the relationship between the local and remote record is considered. + // In the future we'll track these two timestamps and use them to + // determine which records have changed, and thus process incoming + // records more efficiently. + long lastLocalRetrieval = 0; // lastSyncTimestamp? + long lastRemoteRetrieval = 0; // TODO: adjust for clock skew. + boolean remotelyModified = record.lastModified > lastRemoteRetrieval; + + Record existingRecord; + try { + // GUID matching only: deleted records don't have a payload with which to search. + existingRecord = retrieveByGUIDDuringStore(record.guid); + if (record.deleted) { + if (existingRecord == null) { + // We're done. Don't bother with a callback. That can change later + // if we want it to. + trace("Incoming record " + record.guid + " is deleted, and no local version. Bye!"); + return; + } + + if (existingRecord.deleted) { + trace("Local record already deleted. Bye!"); + return; + } + + // Which one wins? + if (!remotelyModified) { + trace("Ignoring deleted record from the past."); + return; + } + + boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval; + if (!locallyModified) { + trace("Remote modified, local not. Deleting."); + storeRecordDeletion(record, existingRecord); + return; + } + + trace("Both local and remote records have been modified."); + if (record.lastModified > existingRecord.lastModified) { + trace("Remote is newer, and deleted. Deleting local."); + storeRecordDeletion(record, existingRecord); + return; + } + + trace("Remote is older, local is not deleted. Ignoring."); + return; + } + // End deletion logic. + + // Now we're processing a non-deleted incoming record. + // Apply any changes we need in order to correctly find existing records. + fixupRecord(record); + + if (existingRecord == null) { + trace("Looking up match for record " + record.guid); + existingRecord = findExistingRecord(record); + } + + if (existingRecord == null) { + // The record is new. + trace("No match. Inserting."); + insert(record); + return; + } + + // We found a local dupe. + trace("Incoming record " + record.guid + " dupes to local record " + existingRecord.guid); + + // Populate more expensive fields prior to reconciling. + existingRecord = transformRecord(existingRecord); + Record toStore = reconcileRecords(record, existingRecord, lastRemoteRetrieval, lastLocalRetrieval); + + if (toStore == null) { + Logger.debug(LOG_TAG, "Reconciling returned null. Not inserting a record."); + return; + } + + // TODO: pass in timestamps? + + // This section of code will only run if the incoming record is not + // marked as deleted, so we never want to just drop ours from the database: + // we need to upload it later. + // Allowing deleted items to propagate through `replace` allows normal + // logging and side-effects to occur, and is no more expensive than simply + // bumping the modified time. + Logger.debug(LOG_TAG, "Replacing existing " + existingRecord.guid + + (toStore.deleted ? " with deleted record " : " with record ") + + toStore.guid); + Record replaced = replace(toStore, existingRecord); + + // Note that we don't track records here; deciding that is the job + // of reconcileRecords. + Logger.debug(LOG_TAG, "Calling delegate callback with guid " + replaced.guid + + "(" + replaced.androidID + ")"); + delegate.onRecordStoreSucceeded(replaced.guid); + return; + + } catch (MultipleRecordsForGuidException e) { + Logger.error(LOG_TAG, "Multiple records returned for given guid: " + record.guid); + delegate.onRecordStoreFailed(e, record.guid); + return; + } catch (NoGuidForIdException e) { + Logger.error(LOG_TAG, "Store failed for " + record.guid, e); + delegate.onRecordStoreFailed(e, record.guid); + return; + } catch (Exception e) { + Logger.error(LOG_TAG, "Store failed for " + record.guid, e); + delegate.onRecordStoreFailed(e, record.guid); + return; + } + } + }; + storeWorkQueue.execute(command); + } + + /** + * Process a request for deletion of a record. + * Neither argument will ever be null. + * + * @param record the incoming record. This will be mostly blank, given that it's a deletion. + * @param existingRecord the existing record. Use this to decide how to process the deletion. + */ + protected void storeRecordDeletion(final Record record, final Record existingRecord) { + // TODO: we ought to mark the record as deleted rather than purging it, + // in order to support syncing to multiple destinations. Bug 722607. + dbHelper.purgeGuid(record.guid); + delegate.onRecordStoreSucceeded(record.guid); + } + + protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + Record toStore = prepareRecord(record); + Uri recordURI = dbHelper.insert(toStore); + if (recordURI == null) { + throw new NullCursorException(new RuntimeException("Got null URI inserting record with guid " + record.guid)); + } + toStore.androidID = ContentUris.parseId(recordURI); + + updateBookkeeping(toStore); + trackRecord(toStore); + delegate.onRecordStoreSucceeded(toStore.guid); + + Logger.debug(LOG_TAG, "Inserted record with guid " + toStore.guid + " as androidID " + toStore.androidID); + } + + protected Record replace(Record newRecord, Record existingRecord) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + Record toStore = prepareRecord(newRecord); + + // newRecord should already have suitable androidID and guid. + dbHelper.update(existingRecord.guid, toStore); + updateBookkeeping(toStore); + Logger.debug(LOG_TAG, "replace() returning record " + toStore.guid); + return toStore; + } + + /** + * Retrieve a record from the store by GUID, without writing unnecessarily to the + * database. + * + * @throws NoGuidForIdException + * @throws NullCursorException + * @throws ParentNotFoundException + * @throws MultipleRecordsForGuidException + */ + protected Record retrieveByGUIDDuringStore(String guid) throws + NoGuidForIdException, + NullCursorException, + ParentNotFoundException, + MultipleRecordsForGuidException { + Cursor cursor = dbHelper.fetch(new String[] { guid }); + try { + if (!cursor.moveToFirst()) { + return null; + } + + Record r = retrieveDuringStore(cursor); + + cursor.moveToNext(); + if (cursor.isAfterLast()) { + // Got one record! + return r; // Not transformed. + } + + // More than one. Oh dear. + throw (new MultipleRecordsForGuidException(null)); + } finally { + cursor.close(); + } + } + + /** + * Attempt to find an equivalent record through some means other than GUID. + * + * @param record + * The record for which to search. + * @return + * An equivalent Record object, or null if none is found. + * + * @throws MultipleRecordsForGuidException + * @throws NoGuidForIdException + * @throws NullCursorException + * @throws ParentNotFoundException + */ + protected Record findExistingRecord(Record record) throws MultipleRecordsForGuidException, + NoGuidForIdException, NullCursorException, ParentNotFoundException { + + Logger.debug(LOG_TAG, "Finding existing record for incoming record with GUID " + record.guid); + String recordString = buildRecordString(record); + if (recordString == null) { + Logger.debug(LOG_TAG, "No record string for incoming record " + record.guid); + return null; + } + + if (Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(LOG_TAG, "Searching with record string " + recordString); + } else { + Logger.debug(LOG_TAG, "Searching with record string."); + } + String guid = getGuidForString(recordString); + if (guid == null) { + Logger.debug(LOG_TAG, "Failed to find existing record for " + record.guid); + return null; + } + + // Our map contained a match, but it could be a false positive. Since + // computed record string is supposed to be a unique key, we can easily + // verify our positive. + Logger.debug(LOG_TAG, "Found one. Checking stored record."); + Record stored = retrieveByGUIDDuringStore(guid); + String storedRecordString = buildRecordString(record); + if (recordString.equals(storedRecordString)) { + Logger.debug(LOG_TAG, "Existing record matches incoming record. Returning existing record."); + return stored; + } + + // Oh no, we got a false positive! (This should be *very* rare -- + // essentially, we got a hash collision.) Search the DB for this record + // explicitly by hand. + Logger.debug(LOG_TAG, "Existing record does not match incoming record. Trying to find record by record string."); + return findByRecordString(recordString); + } + + protected String getGuidForString(String recordString) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + if (recordToGuid == null) { + createRecordToGuidMap(); + } + return recordToGuid.get(recordString.hashCode()); + } + + protected void createRecordToGuidMap() throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + Logger.info(LOG_TAG, "BEGIN: creating record -> GUID map."); + recordToGuid = new SparseArray<String>(); + + // TODO: we should be able to do this entire thing with string concatenations within SQL. + // Also consider whether it's better to fetch and process every record in the DB into + // memory, or run a query per record to do the same thing. + Cursor cur = dbHelper.fetchAll(); + try { + if (!cur.moveToFirst()) { + return; + } + while (!cur.isAfterLast()) { + Record record = retrieveDuringStore(cur); + if (record != null) { + final String recordString = buildRecordString(record); + if (recordString != null) { + recordToGuid.put(recordString.hashCode(), record.guid); + } + } + cur.moveToNext(); + } + } finally { + cur.close(); + } + Logger.info(LOG_TAG, "END: creating record -> GUID map."); + } + + /** + * Search the local database for a record with the same "record string". + * <p> + * We expect to do this only in the unlikely event of a hash + * collision, so we iterate the database completely. Since we want + * to include information about the parents of bookmarks, it is + * difficult to do better purely using the + * <code>ContentProvider</code> interface. + * + * @param recordString + * the "record string" to search for; must be n + * @return a <code>Record</code> with the same "record string", or + * <code>null</code> if none is present. + * @throws ParentNotFoundException + * @throws NullCursorException + * @throws NoGuidForIdException + */ + protected Record findByRecordString(String recordString) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + Cursor cur = dbHelper.fetchAll(); + try { + if (!cur.moveToFirst()) { + return null; + } + while (!cur.isAfterLast()) { + Record record = retrieveDuringStore(cur); + if (record != null) { + final String storedRecordString = buildRecordString(record); + if (recordString.equals(storedRecordString)) { + return record; + } + } + cur.moveToNext(); + } + return null; + } finally { + cur.close(); + } + } + + public void putRecordToGuidMap(String recordString, String guid) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { + if (recordString == null) { + return; + } + + if (recordToGuid == null) { + createRecordToGuidMap(); + } + recordToGuid.put(recordString.hashCode(), guid); + } + + protected abstract Record prepareRecord(Record record); + + protected void updateBookkeeping(Record record) throws NoGuidForIdException, + NullCursorException, + ParentNotFoundException { + putRecordToGuidMap(buildRecordString(record), record.guid); + } + + protected WipeRunnable getWipeRunnable(RepositorySessionWipeDelegate delegate) { + return new WipeRunnable(delegate); + } + + @Override + public void wipe(RepositorySessionWipeDelegate delegate) { + Runnable command = getWipeRunnable(delegate); + storeWorkQueue.execute(command); + } + + class WipeRunnable implements Runnable { + protected RepositorySessionWipeDelegate delegate; + + public WipeRunnable(RepositorySessionWipeDelegate delegate) { + this.delegate = delegate; + } + + @Override + public void run() { + if (!isActive()) { + delegate.onWipeFailed(new InactiveSessionException(null)); + return; + } + dbHelper.wipe(); + delegate.onWipeSucceeded(); + } + } + + // For testing purposes. + public AndroidBrowserRepositoryDataAccessor getDBHelper() { + return dbHelper; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java new file mode 100644 index 000000000..d8d8756f7 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java @@ -0,0 +1,239 @@ +/* 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.repositories.android; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; + +/** + * Queue up deletions. Process them at the end. + * + * Algorithm: + * + * * Collect GUIDs as we go. For convenience we partition these into + * folders and non-folders. + * + * * Non-folders can be deleted in batches as we go. + * + * * At the end of the sync: + * * Delete all that aren't folders. + * * Move the remaining children of any that are folders to an "Orphans" folder. + * - We do this even for children that are _marked_ as deleted -- we still want + * to upload them, and their parent is irrelevant. + * * Delete all the folders. + * + * * Any outstanding records -- the ones we moved to "Orphans" -- are true orphans. + * These should be reuploaded (because their parent has changed), as should their + * new parent (because its children array has changed). + * We achieve the former by moving them without tracking (but we don't make any + * special effort here -- warning! Lurking bug!). + * We achieve the latter by bumping its mtime. The caller should take care of untracking it. + * + * Note that we make no particular effort to handle repositioning or reparenting: + * batching deletes at the end should be handled seamlessly by existing code, + * because the deleted records could have arrived in a batch at the end regardless. + * + * Note that this class is not thread safe. This should be fine: call it only + * from within a store runnable. + * + */ +public class BookmarksDeletionManager { + private static final String LOG_TAG = "BookmarkDelete"; + + private final AndroidBrowserBookmarksDataAccessor dataAccessor; + private RepositorySessionStoreDelegate delegate; + + private final int flushThreshold; + + private final HashSet<String> folders = new HashSet<String>(); + private final HashSet<String> nonFolders = new HashSet<String>(); + private int nonFolderCount = 0; + + // Records that we need to touch once we've deleted the non-folders. + private HashSet<String> nonFolderParents = new HashSet<String>(); + private HashSet<String> folderParents = new HashSet<String>(); + + /** + * Create an instance to be used for tracking deletions in a bookmarks + * repository session. + * + * @param dataAccessor + * Used to effect database changes. + * + * @param flushThreshold + * When this many non-folder records have been stored for deletion, + * an incremental flush occurs. + */ + public BookmarksDeletionManager(AndroidBrowserBookmarksDataAccessor dataAccessor, int flushThreshold) { + this.dataAccessor = dataAccessor; + this.flushThreshold = flushThreshold; + } + + /** + * Set the delegate to use for callbacks. + * If not invoked, no callbacks will be submitted. + * + * @param delegate a delegate, which should already be a delayed delegate. + */ + public void setDelegate(RepositorySessionStoreDelegate delegate) { + this.delegate = delegate; + } + + public void deleteRecord(String guid, boolean isFolder, String parentGUID) { + if (guid == null) { + Logger.warn(LOG_TAG, "Cannot queue deletion of record with no GUID."); + return; + } + Logger.debug(LOG_TAG, "Queuing deletion of " + guid); + + if (isFolder) { + folders.add(guid); + if (!folders.contains(parentGUID)) { + // We're not going to delete its parent; will need to bump it. + folderParents.add(parentGUID); + } + + nonFolderParents.remove(guid); + folderParents.remove(guid); + return; + } + + if (!folders.contains(parentGUID)) { + // We're not going to delete its parent; will need to bump it. + nonFolderParents.add(parentGUID); + } + + if (nonFolders.add(guid)) { + if (++nonFolderCount >= flushThreshold) { + deleteNonFolders(); + } + } + } + + /** + * Flush deletions that can be easily taken care of right now. + */ + public void incrementalFlush() { + // Yes, this means we only bump when we finish, not during an incremental flush. + deleteNonFolders(); + } + + /** + * Apply all pending deletions and reset state for the next batch of stores. + * + * @param orphanDestination the ID of the folder to which orphaned children + * should be moved. + * + * @throws NullCursorException + * @return a set of IDs to untrack. Will not be null. + */ + public Set<String> flushAll(long orphanDestination, long now) throws NullCursorException { + Logger.debug(LOG_TAG, "Doing complete flush of deleted items. Moving orphans to " + orphanDestination); + deleteNonFolders(); + + // Find out which parents *won't* be deleted, and thus need to have their + // modified times bumped. + nonFolderParents.removeAll(folders); + + Logger.debug(LOG_TAG, "Bumping modified times for " + nonFolderParents.size() + + " parents of deleted non-folders."); + dataAccessor.bumpModifiedByGUID(nonFolderParents, now); + + if (folders.size() > 0) { + final String[] folderGUIDs = folders.toArray(new String[folders.size()]); + final String[] folderIDs = getIDs(folderGUIDs); // Throws if any don't exist. + int moved = dataAccessor.moveChildren(folderIDs, orphanDestination); + if (moved > 0) { + dataAccessor.bumpModified(orphanDestination, now); + } + + // We've deleted or moved anything that might be under these folders. + // Just delete them. + final String folderWhere = RepoUtils.computeSQLInClause(folders.size(), BrowserContract.Bookmarks.GUID); + dataAccessor.delete(folderWhere, folderGUIDs); + invokeCallbacks(delegate, folderGUIDs); + + folderParents.removeAll(folders); + Logger.debug(LOG_TAG, "Bumping modified times for " + folderParents.size() + + " parents of deleted folders."); + dataAccessor.bumpModifiedByGUID(folderParents, now); + + // Clean up. + folders.clear(); + } + + HashSet<String> ret = nonFolderParents; + ret.addAll(folderParents); + + nonFolderParents = new HashSet<String>(); + folderParents = new HashSet<String>(); + return ret; + } + + private String[] getIDs(String[] guids) throws NullCursorException { + // Convert GUIDs to numeric IDs. + String[] ids = new String[guids.length]; + Map<String, Long> guidsToIDs = dataAccessor.idsForGUIDs(guids); + for (int i = 0; i < guids.length; ++i) { + String guid = guids[i]; + Long id = guidsToIDs.get(guid); + if (id == null) { + throw new IllegalArgumentException("Can't get ID for unknown record " + guid); + } + ids[i] = id.toString(); + } + return ids; + } + + /** + * Flush non-folder deletions. This can be called at any time. + */ + private void deleteNonFolders() { + if (nonFolderCount == 0) { + Logger.debug(LOG_TAG, "No non-folders to delete."); + return; + } + + Logger.debug(LOG_TAG, "Applying deletion of " + nonFolderCount + " non-folders."); + final String[] nonFolderGUIDs = nonFolders.toArray(new String[nonFolderCount]); + final String nonFolderWhere = RepoUtils.computeSQLInClause(nonFolderCount, BrowserContract.Bookmarks.GUID); + dataAccessor.delete(nonFolderWhere, nonFolderGUIDs); + + invokeCallbacks(delegate, nonFolderGUIDs); + + // Discard these. + // Note that we maintain folderParents and nonFolderParents; we need them later. + nonFolders.clear(); + nonFolderCount = 0; + } + + private void invokeCallbacks(RepositorySessionStoreDelegate delegate, + String[] nonFolderGUIDs) { + if (delegate == null) { + return; + } + Logger.trace(LOG_TAG, "Invoking store callback for " + nonFolderGUIDs.length + " GUIDs."); + for (String guid : nonFolderGUIDs) { + delegate.onRecordStoreSucceeded(guid); + } + } + + /** + * Clear state in case of redundancy (e.g., wipe). + */ + public void clear() { + nonFolders.clear(); + nonFolderCount = 0; + folders.clear(); + nonFolderParents.clear(); + folderParents.clear(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java new file mode 100644 index 000000000..98670d39b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java @@ -0,0 +1,298 @@ +/* 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.repositories.android; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord; + +/** + * Queue up insertions: + * <ul> + * <li>Folder inserts where the parent is known. Do these immediately, because + * they allow other records to be inserted. Requires bookkeeping updates. On + * insert, flush the next set.</li> + * <li>Regular inserts where the parent is known. These can happen whenever. + * Batch for speed.</li> + * <li>Records where the parent is not known. These can be flushed out when the + * parent is known, or entered as orphans. This can be a queue earlier in the + * process, so they don't get assigned to Unsorted. Feed into the main batch + * when the parent arrives.</li> + * </ul> + * <p> + * Deletions are always done at the end so that orphaning is minimized, and + * that's why we are batching folders and non-folders separately. + * <p> + * Updates are always applied as they arrive. + * <p> + * Note that this class is not thread safe. This should be fine: call it only + * from within a store runnable. + */ +public class BookmarksInsertionManager { + public static final String LOG_TAG = "BookmarkInsert"; + public static boolean DEBUG = false; + + protected final int flushThreshold; + protected final BookmarkInserter inserter; + + /** + * Folders that have been successfully inserted. + */ + private final Set<String> insertedFolders = new HashSet<String>(); + + /** + * Non-folders waiting for bulk insertion. + * <p> + * We write in insertion order to keep things easy to debug. + */ + private final Set<BookmarkRecord> nonFoldersToWrite = new LinkedHashSet<BookmarkRecord>(); + + /** + * Map from parent folder GUID to child records (folders and non-folders) + * waiting to be enqueued after parent folder is inserted. + */ + private final Map<String, Set<BookmarkRecord>> recordsWaitingForParent = new HashMap<String, Set<BookmarkRecord>>(); + + /** + * Create an instance to be used for tracking insertions in a bookmarks + * repository session. + * + * @param flushThreshold + * When this many non-folder records have been stored for insertion, + * an incremental flush occurs. + * @param insertedFolders + * The GUIDs of all the folders already inserted into the database. + * @param inserter + * The <code>BookmarkInsert</code> to use. + */ + public BookmarksInsertionManager(int flushThreshold, Collection<String> insertedFolders, BookmarkInserter inserter) { + this.flushThreshold = flushThreshold; + this.insertedFolders.addAll(insertedFolders); + this.inserter = inserter; + } + + protected void addRecordWithUnwrittenParent(BookmarkRecord record) { + Set<BookmarkRecord> destination = recordsWaitingForParent.get(record.parentID); + if (destination == null) { + destination = new LinkedHashSet<BookmarkRecord>(); + recordsWaitingForParent.put(record.parentID, destination); + } + destination.add(record); + } + + /** + * If <code>record</code> is a folder, insert it immediately; if it is a + * non-folder, enqueue it. Then do the same for any records waiting for this record. + * + * @param record + * the <code>BookmarkRecord</code> to enqueue. + */ + protected void recursivelyEnqueueRecordAndChildren(BookmarkRecord record) { + if (record.isFolder()) { + if (!inserter.insertFolder(record)) { + Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!"); + return; + } + Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders."); + insertedFolders.add(record.guid); + } else { + Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue."); + nonFoldersToWrite.add(record); + } + + // Now process record's children. + Set<BookmarkRecord> waiting = recordsWaitingForParent.remove(record.guid); + if (waiting == null) { + return; + } + for (BookmarkRecord waiter : waiting) { + recursivelyEnqueueRecordAndChildren(waiter); + } + } + + /** + * Enqueue a folder. + * + * @param record + * the folder to enqueue. + */ + protected void enqueueFolder(BookmarkRecord record) { + Logger.debug(LOG_TAG, "Inserting folder with guid " + record.guid); + + if (!insertedFolders.contains(record.parentID)) { + Logger.debug(LOG_TAG, "Folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent."); + addRecordWithUnwrittenParent(record); + return; + } + + // Parent is known; add as much of the tree as this roots. + recursivelyEnqueueRecordAndChildren(record); + flushNonFoldersIfNecessary(); + } + + /** + * Enqueue a non-folder. + * + * @param record + * the non-folder to enqueue. + */ + protected void enqueueNonFolder(BookmarkRecord record) { + Logger.debug(LOG_TAG, "Inserting non-folder with guid " + record.guid); + + if (!insertedFolders.contains(record.parentID)) { + Logger.debug(LOG_TAG, "Non-folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent."); + addRecordWithUnwrittenParent(record); + return; + } + + // Parent is known; add to insertion queue and maybe write. + Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue."); + nonFoldersToWrite.add(record); + flushNonFoldersIfNecessary(); + } + + /** + * Enqueue a bookmark record for eventual insertion. + * + * @param record + * the <code>BookmarkRecord</code> to enqueue. + */ + public void enqueueRecord(BookmarkRecord record) { + if (record.isFolder()) { + enqueueFolder(record); + } else { + enqueueNonFolder(record); + } + if (DEBUG) { + dumpState(); + } + } + + /** + * Flush non-folders; empties the insertion queue entirely. + */ + protected void flushNonFolders() { + inserter.bulkInsertNonFolders(nonFoldersToWrite); // All errors are handled in bulkInsertNonFolders. + nonFoldersToWrite.clear(); + } + + /** + * Flush non-folder insertions if there are many of them; empties the + * insertion queue entirely. + */ + protected void flushNonFoldersIfNecessary() { + int num = nonFoldersToWrite.size(); + if (num < flushThreshold) { + Logger.debug(LOG_TAG, "Incremental flush called with " + num + " < " + flushThreshold + " non-folders; not flushing."); + return; + } + Logger.debug(LOG_TAG, "Incremental flush called with " + num + " non-folders; flushing."); + flushNonFolders(); + } + + /** + * Insert all remaining folders followed by all remaining non-folders, + * regardless of whether parent records have been successfully inserted. + */ + public void finishUp() { + // Iterate through all waiting records, writing the folders and collecting + // the non-folders for bulk insertion. + int numFolders = 0; + int numNonFolders = 0; + for (Set<BookmarkRecord> records : recordsWaitingForParent.values()) { + for (BookmarkRecord record : records) { + if (!record.isFolder()) { + numNonFolders += 1; + nonFoldersToWrite.add(record); + continue; + } + + numFolders += 1; + if (!inserter.insertFolder(record)) { + Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!"); + continue; + } + + Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders."); + insertedFolders.add(record.guid); + } + } + recordsWaitingForParent.clear(); + flushNonFolders(); + + Logger.debug(LOG_TAG, "finishUp inserted " + + numFolders + " folders without known parents and " + + numNonFolders + " non-folders without known parents."); + if (DEBUG) { + dumpState(); + } + } + + public void clear() { + this.insertedFolders.clear(); + this.nonFoldersToWrite.clear(); + this.recordsWaitingForParent.clear(); + } + + // For debugging. + public boolean isClear() { + return nonFoldersToWrite.isEmpty() && recordsWaitingForParent.isEmpty(); + } + + // For debugging. + public void dumpState() { + ArrayList<String> readies = new ArrayList<String>(); + for (BookmarkRecord record : nonFoldersToWrite) { + readies.add(record.guid); + } + String ready = Utils.toCommaSeparatedString(new ArrayList<String>(readies)); + + ArrayList<String> waits = new ArrayList<String>(); + for (Set<BookmarkRecord> recs : recordsWaitingForParent.values()) { + for (BookmarkRecord rec : recs) { + waits.add(rec.guid); + } + } + String waiting = Utils.toCommaSeparatedString(waits); + String known = Utils.toCommaSeparatedString(insertedFolders); + + Logger.debug(LOG_TAG, "Q=(" + ready + "), W = (" + waiting + "), P=(" + known + ")"); + } + + public interface BookmarkInserter { + /** + * Insert a single folder. + * <p> + * All exceptions should be caught and all delegate callbacks invoked here. + * + * @param record + * the record to insert. + * @return + * <code>true</code> if the folder was inserted; <code>false</code> otherwise. + */ + public boolean insertFolder(BookmarkRecord record); + + /** + * Insert many non-folders. Each non-folder's parent was already present in + * the database before this <code>BookmarkInsertionsManager</code> was + * created, or had <code>insertFolder</code> called with it as argument (and + * possibly was not inserted). + * <p> + * All exceptions should be caught and all delegate callbacks invoked here. + * + * @param records + * the records to insert. + */ + public void bulkInsertNonFolders(Collection<BookmarkRecord> records); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java new file mode 100644 index 000000000..e83aea087 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java @@ -0,0 +1,154 @@ +/* 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.repositories.android; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.setup.Constants; + +import android.net.Uri; + +public class BrowserContractHelpers extends BrowserContract { + + protected static Uri withSyncAndDeletedAndProfile(Uri u) { + return u.buildUpon() + .appendQueryParameter(PARAM_PROFILE, Constants.DEFAULT_PROFILE) + .appendQueryParameter(PARAM_IS_SYNC, "true") + .appendQueryParameter(PARAM_SHOW_DELETED, "true") + .build(); + } + protected static Uri withSyncAndProfile(Uri u) { + return u.buildUpon() + .appendQueryParameter(PARAM_PROFILE, Constants.DEFAULT_PROFILE) + .appendQueryParameter(PARAM_IS_SYNC, "true") + .build(); + } + + public static final Uri BOOKMARKS_CONTENT_URI = withSyncAndDeletedAndProfile(Bookmarks.CONTENT_URI); + public static final Uri BOOKMARKS_PARENTS_CONTENT_URI = withSyncAndDeletedAndProfile(Bookmarks.PARENTS_CONTENT_URI); + public static final Uri BOOKMARKS_POSITIONS_CONTENT_URI = withSyncAndDeletedAndProfile(Bookmarks.POSITIONS_CONTENT_URI); + public static final Uri HISTORY_CONTENT_URI = withSyncAndDeletedAndProfile(History.CONTENT_URI); + public static final Uri VISITS_CONTENT_URI = withSyncAndDeletedAndProfile(Visits.CONTENT_URI); + public static final Uri SCHEMA_CONTENT_URI = withSyncAndDeletedAndProfile(Schema.CONTENT_URI); + public static final Uri PASSWORDS_CONTENT_URI = withSyncAndDeletedAndProfile(Passwords.CONTENT_URI); + public static final Uri DELETED_PASSWORDS_CONTENT_URI = withSyncAndDeletedAndProfile(DeletedPasswords.CONTENT_URI); + public static final Uri FORM_HISTORY_CONTENT_URI = withSyncAndProfile(FormHistory.CONTENT_URI); + public static final Uri DELETED_FORM_HISTORY_CONTENT_URI = withSyncAndProfile(DeletedFormHistory.CONTENT_URI); + public static final Uri TABS_CONTENT_URI = withSyncAndProfile(Tabs.CONTENT_URI); + public static final Uri CLIENTS_CONTENT_URI = withSyncAndProfile(Clients.CONTENT_URI); + public static final Uri LOGINS_CONTENT_URI = withSyncAndProfile(Logins.CONTENT_URI); + + public static final String[] PasswordColumns = new String[] { + Passwords.ID, + Passwords.HOSTNAME, + Passwords.HTTP_REALM, + Passwords.FORM_SUBMIT_URL, + Passwords.USERNAME_FIELD, + Passwords.PASSWORD_FIELD, + Passwords.ENCRYPTED_USERNAME, + Passwords.ENCRYPTED_PASSWORD, + Passwords.ENC_TYPE, + Passwords.TIME_CREATED, + Passwords.TIME_LAST_USED, + Passwords.TIME_PASSWORD_CHANGED, + Passwords.TIMES_USED, + Passwords.GUID + }; + + public static final String[] HistoryColumns = new String[] { + CommonColumns._ID, + SyncColumns.GUID, + SyncColumns.DATE_CREATED, + SyncColumns.DATE_MODIFIED, + SyncColumns.IS_DELETED, + History.TITLE, + History.URL, + History.DATE_LAST_VISITED, + History.VISITS + }; + + public static final String[] BookmarkColumns = new String[] { + CommonColumns._ID, + SyncColumns.GUID, + SyncColumns.DATE_CREATED, + SyncColumns.DATE_MODIFIED, + SyncColumns.IS_DELETED, + Bookmarks.TITLE, + Bookmarks.URL, + Bookmarks.TYPE, + Bookmarks.PARENT, + Bookmarks.POSITION, + Bookmarks.TAGS, + Bookmarks.DESCRIPTION, + Bookmarks.KEYWORD + }; + + public static final String[] FormHistoryColumns = new String[] { + FormHistory.ID, + FormHistory.GUID, + FormHistory.FIELD_NAME, + FormHistory.VALUE, + FormHistory.TIMES_USED, + FormHistory.FIRST_USED, + FormHistory.LAST_USED + }; + + public static final String[] DeletedColumns = new String[] { + BrowserContract.DeletedColumns.ID, + BrowserContract.DeletedColumns.GUID, + BrowserContract.DeletedColumns.TIME_DELETED + }; + + // Mapping from Sync types to Fennec types. + public static final String[] BOOKMARK_TYPE_CODE_TO_STRING = { + // Observe omissions: "microsummary", "item". + "folder", "bookmark", "separator", "livemark", "query" + }; + private static final int MAX_BOOKMARK_TYPE_CODE = BOOKMARK_TYPE_CODE_TO_STRING.length - 1; + public static final Map<String, Integer> BOOKMARK_TYPE_STRING_TO_CODE; + static { + HashMap<String, Integer> t = new HashMap<String, Integer>(); + t.put("folder", Bookmarks.TYPE_FOLDER); + t.put("bookmark", Bookmarks.TYPE_BOOKMARK); + t.put("separator", Bookmarks.TYPE_SEPARATOR); + t.put("livemark", Bookmarks.TYPE_LIVEMARK); + t.put("query", Bookmarks.TYPE_QUERY); + BOOKMARK_TYPE_STRING_TO_CODE = Collections.unmodifiableMap(t); + } + + /** + * Convert a database bookmark type code into the Sync string equivalent. + * + * @param code one of the <code>Bookmarks.TYPE_*</code> enumerations. + * @return the string equivalent, or null if not found. + */ + public static String typeStringForCode(int code) { + if (0 <= code && code <= MAX_BOOKMARK_TYPE_CODE) { + return BOOKMARK_TYPE_CODE_TO_STRING[code]; + } + return null; + } + + /** + * Convert a Sync type string into a Fennec type code. + * + * @param type a type string, such as "livemark". + * @return the type code, or -1 if not found. + */ + public static int typeCodeForString(String type) { + Integer found = BOOKMARK_TYPE_STRING_TO_CODE.get(type); + if (found == null) { + return -1; + } + return found; + } + + public static boolean isSupportedType(String type) { + return BOOKMARK_TYPE_STRING_TO_CODE.containsKey(type); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java new file mode 100644 index 000000000..5c17f9b85 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java @@ -0,0 +1,62 @@ +/* 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.repositories.android; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDatabase.CursorFactory; +import android.database.sqlite.SQLiteOpenHelper; + +public abstract class CachedSQLiteOpenHelper extends SQLiteOpenHelper { + + public CachedSQLiteOpenHelper(Context context, String name, CursorFactory factory, + int version) { + super(context, name, factory, version); + } + + // Cache these so we don't have to track them across cursors. Call `close` + // when you're done. + private SQLiteDatabase readableDatabase; + private SQLiteDatabase writableDatabase; + + synchronized protected SQLiteDatabase getCachedReadableDatabase() { + if (readableDatabase == null) { + if (writableDatabase == null) { + readableDatabase = this.getReadableDatabase(); + return readableDatabase; + } else { + return writableDatabase; + } + } else { + return readableDatabase; + } + } + + synchronized protected SQLiteDatabase getCachedWritableDatabase() { + if (writableDatabase == null) { + writableDatabase = this.getWritableDatabase(); + } + return writableDatabase; + } + + @Override + synchronized public void close() { + if (readableDatabase != null) { + readableDatabase.close(); + readableDatabase = null; + } + if (writableDatabase != null) { + writableDatabase.close(); + writableDatabase = null; + } + super.close(); + } + + // Used for testing. + public boolean isClosed() { + return readableDatabase == null && + writableDatabase == null; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java new file mode 100644 index 000000000..4962a20c6 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java @@ -0,0 +1,252 @@ +/* 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.repositories.android; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.domain.ClientRecord; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +public class ClientsDatabase extends CachedSQLiteOpenHelper { + + public static final String LOG_TAG = "ClientsDatabase"; + + // Database Specifications. + protected static final String DB_NAME = "clients_database"; + protected static final int SCHEMA_VERSION = 3; + + // Clients Table. + public static final String TBL_CLIENTS = "clients"; + public static final String COL_ACCOUNT_GUID = "guid"; + public static final String COL_PROFILE = "profile"; + public static final String COL_NAME = "name"; + public static final String COL_TYPE = "device_type"; + + // Optional fields. + public static final String COL_FORMFACTOR = "formfactor"; + public static final String COL_OS = "os"; + public static final String COL_APPLICATION = "application"; + public static final String COL_APP_PACKAGE = "appPackage"; + public static final String COL_DEVICE = "device"; + + public static final String[] TBL_CLIENTS_COLUMNS = new String[] { COL_ACCOUNT_GUID, COL_PROFILE, COL_NAME, COL_TYPE, + COL_FORMFACTOR, COL_OS, COL_APPLICATION, COL_APP_PACKAGE, COL_DEVICE }; + public static final String TBL_CLIENTS_KEY = COL_ACCOUNT_GUID + " = ? AND " + + COL_PROFILE + " = ?"; + + // Commands Table. + public static final String TBL_COMMANDS = "commands"; + public static final String COL_COMMAND = "command"; + public static final String COL_ARGS = "args"; + + public static final String[] TBL_COMMANDS_COLUMNS = new String[] { COL_ACCOUNT_GUID, COL_COMMAND, COL_ARGS }; + public static final String TBL_COMMANDS_KEY = COL_ACCOUNT_GUID + " = ? AND " + + COL_COMMAND + " = ? AND " + + COL_ARGS + " = ?"; + public static final String TBL_COMMANDS_GUID_QUERY = COL_ACCOUNT_GUID + " = ? "; + + private final RepoUtils.QueryHelper queryHelper; + + public ClientsDatabase(Context context) { + super(context, DB_NAME, null, SCHEMA_VERSION); + this.queryHelper = new RepoUtils.QueryHelper(context, null, LOG_TAG); + Logger.debug(LOG_TAG, "ClientsDatabase instantiated."); + } + + @Override + public void onCreate(SQLiteDatabase db) { + Logger.debug(LOG_TAG, "ClientsDatabase.onCreate()."); + createClientsTable(db); + createCommandsTable(db); + } + + public static void createClientsTable(SQLiteDatabase db) { + Logger.debug(LOG_TAG, "ClientsDatabase.createClientsTable()."); + String createClientsTableSql = "CREATE TABLE " + TBL_CLIENTS + " (" + + COL_ACCOUNT_GUID + " TEXT, " + + COL_PROFILE + " TEXT, " + + COL_NAME + " TEXT, " + + COL_TYPE + " TEXT, " + + COL_FORMFACTOR + " TEXT, " + + COL_OS + " TEXT, " + + COL_APPLICATION + " TEXT, " + + COL_APP_PACKAGE + " TEXT, " + + COL_DEVICE + " TEXT, " + + "PRIMARY KEY (" + COL_ACCOUNT_GUID + ", " + COL_PROFILE + "))"; + db.execSQL(createClientsTableSql); + } + + public static void createCommandsTable(SQLiteDatabase db) { + Logger.debug(LOG_TAG, "ClientsDatabase.createCommandsTable()."); + String createCommandsTableSql = "CREATE TABLE " + TBL_COMMANDS + " (" + + COL_ACCOUNT_GUID + " TEXT, " + + COL_COMMAND + " TEXT, " + + COL_ARGS + " TEXT, " + + "PRIMARY KEY (" + COL_ACCOUNT_GUID + ", " + COL_COMMAND + ", " + COL_ARGS + "), " + + "FOREIGN KEY (" + COL_ACCOUNT_GUID + ") REFERENCES " + TBL_CLIENTS + " (" + COL_ACCOUNT_GUID + "))"; + db.execSQL(createCommandsTableSql); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Logger.debug(LOG_TAG, "ClientsDatabase.onUpgrade(" + oldVersion + ", " + newVersion + ")."); + if (oldVersion < 2) { + // For now we'll just drop and recreate the tables. + db.execSQL("DROP TABLE IF EXISTS " + TBL_CLIENTS); + db.execSQL("DROP TABLE IF EXISTS " + TBL_COMMANDS); + onCreate(db); + return; + } + + if (newVersion >= 3) { + // Add the optional columns to clients. + db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_FORMFACTOR + " TEXT"); + db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_OS + " TEXT"); + db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_APPLICATION + " TEXT"); + db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_APP_PACKAGE + " TEXT"); + db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_DEVICE + " TEXT"); + } + } + + public void wipeDB() { + SQLiteDatabase db = this.getCachedWritableDatabase(); + onUpgrade(db, 0, SCHEMA_VERSION); + } + + public void wipeClientsTable() { + SQLiteDatabase db = this.getCachedWritableDatabase(); + db.execSQL("DELETE FROM " + TBL_CLIENTS); + } + + public void wipeCommandsTable() { + SQLiteDatabase db = this.getCachedWritableDatabase(); + db.execSQL("DELETE FROM " + TBL_COMMANDS); + } + + // If a record with given GUID exists, we'll update it, + // otherwise we'll insert it. + public void store(String profileId, ClientRecord record) { + SQLiteDatabase db = this.getCachedWritableDatabase(); + + ContentValues cv = new ContentValues(); + cv.put(COL_ACCOUNT_GUID, record.guid); + cv.put(COL_PROFILE, profileId); + cv.put(COL_NAME, record.name); + cv.put(COL_TYPE, record.type); + + if (record.formfactor != null) { + cv.put(COL_FORMFACTOR, record.formfactor); + } + + if (record.os != null) { + cv.put(COL_OS, record.os); + } + + if (record.application != null) { + cv.put(COL_APPLICATION, record.application); + } + + if (record.appPackage != null) { + cv.put(COL_APP_PACKAGE, record.appPackage); + } + + if (record.device != null) { + cv.put(COL_DEVICE, record.device); + } + + String[] args = new String[] { record.guid, profileId }; + int rowsUpdated = db.update(TBL_CLIENTS, cv, TBL_CLIENTS_KEY, args); + + if (rowsUpdated >= 1) { + Logger.debug(LOG_TAG, "Replaced client record for row with accountGUID " + record.guid); + } else { + long rowId = db.insert(TBL_CLIENTS, null, cv); + Logger.debug(LOG_TAG, "Inserted client record into row: " + rowId); + } + } + + /** + * Store a command in the commands database if it doesn't already exist. + * + * @param accountGUID + * @param command - The command type + * @param args - A JSON string of args + * @throws NullCursorException + */ + public void store(String accountGUID, String command, String args) throws NullCursorException { + if (Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(LOG_TAG, "Storing command " + command + " with args " + args); + } else { + Logger.trace(LOG_TAG, "Storing command " + command + "."); + } + SQLiteDatabase db = this.getCachedWritableDatabase(); + + ContentValues cv = new ContentValues(); + cv.put(COL_ACCOUNT_GUID, accountGUID); + cv.put(COL_COMMAND, command); + if (args == null) { + cv.put(COL_ARGS, "[]"); + } else { + cv.put(COL_ARGS, args); + } + + Cursor cur = this.fetchSpecificCommand(accountGUID, command, args); + try { + if (cur.moveToFirst()) { + Logger.debug(LOG_TAG, "Command already exists in database."); + return; + } + } finally { + cur.close(); + } + + long rowId = db.insert(TBL_COMMANDS, null, cv); + Logger.debug(LOG_TAG, "Inserted command into row: " + rowId); + } + + public Cursor fetchClientsCursor(String accountGUID, String profileId) throws NullCursorException { + String[] args = new String[] { accountGUID, profileId }; + SQLiteDatabase db = this.getCachedReadableDatabase(); + + return queryHelper.safeQuery(db, ".fetchClientsCursor", TBL_CLIENTS, TBL_CLIENTS_COLUMNS, TBL_CLIENTS_KEY, args); + } + + public Cursor fetchSpecificCommand(String accountGUID, String command, String commandArgs) throws NullCursorException { + String[] args = new String[] { accountGUID, command, commandArgs }; + SQLiteDatabase db = this.getCachedReadableDatabase(); + + return queryHelper.safeQuery(db, ".fetchSpecificCommand", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, TBL_COMMANDS_KEY, args); + } + + public Cursor fetchCommandsForClient(String accountGUID) throws NullCursorException { + String[] args = new String[] { accountGUID }; + SQLiteDatabase db = this.getCachedReadableDatabase(); + + return queryHelper.safeQuery(db, ".fetchCommandsForClient", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, TBL_COMMANDS_GUID_QUERY, args); + } + + public Cursor fetchAllClients() throws NullCursorException { + SQLiteDatabase db = this.getCachedReadableDatabase(); + + return queryHelper.safeQuery(db, ".fetchAllClients", TBL_CLIENTS, TBL_CLIENTS_COLUMNS, null, null); + } + + public Cursor fetchAllCommands() throws NullCursorException { + SQLiteDatabase db = this.getCachedReadableDatabase(); + + return queryHelper.safeQuery(db, ".fetchAllCommands", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, null, null); + } + + public void deleteClient(String accountGUID, String profileId) { + String[] args = new String[] { accountGUID, profileId }; + + SQLiteDatabase db = this.getCachedWritableDatabase(); + db.delete(TBL_CLIENTS, TBL_CLIENTS_KEY, args); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java new file mode 100644 index 000000000..4af84ceaf --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java @@ -0,0 +1,178 @@ +/* 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.repositories.android; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.json.simple.JSONArray; + +import org.mozilla.gecko.sync.CommandProcessor.Command; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.domain.ClientRecord; +import org.mozilla.gecko.sync.setup.Constants; + +import android.content.Context; +import android.database.Cursor; + +public class ClientsDatabaseAccessor { + + public static final String LOG_TAG = "ClientsDatabaseAccessor"; + + private ClientsDatabase db; + + // Need this so we can properly stub out the class for testing. + public ClientsDatabaseAccessor() {} + + public ClientsDatabaseAccessor(Context context) { + db = new ClientsDatabase(context); + } + + public void store(ClientRecord record) { + db.store(getProfileId(), record); + } + + public void store(Collection<ClientRecord> records) { + for (ClientRecord record : records) { + this.store(record); + } + } + + public void store(String accountGUID, Command command) throws NullCursorException { + db.store(accountGUID, command.commandType, command.args.toJSONString()); + } + + public ClientRecord fetchClient(String accountGUID) throws NullCursorException { + final Cursor cur = db.fetchClientsCursor(accountGUID, getProfileId()); + try { + if (!cur.moveToFirst()) { + return null; + } + return recordFromCursor(cur); + } finally { + cur.close(); + } + } + + public Map<String, ClientRecord> fetchAllClients() throws NullCursorException { + final HashMap<String, ClientRecord> map = new HashMap<String, ClientRecord>(); + final Cursor cur = db.fetchAllClients(); + try { + if (!cur.moveToFirst()) { + return Collections.unmodifiableMap(map); + } + + while (!cur.isAfterLast()) { + ClientRecord clientRecord = recordFromCursor(cur); + map.put(clientRecord.guid, clientRecord); + cur.moveToNext(); + } + return Collections.unmodifiableMap(map); + } finally { + cur.close(); + } + } + + public List<Command> fetchAllCommands() throws NullCursorException { + final List<Command> commands = new ArrayList<Command>(); + final Cursor cur = db.fetchAllCommands(); + try { + if (!cur.moveToFirst()) { + return Collections.unmodifiableList(commands); + } + + while (!cur.isAfterLast()) { + Command command = commandFromCursor(cur); + commands.add(command); + cur.moveToNext(); + } + return Collections.unmodifiableList(commands); + } finally { + cur.close(); + } + } + + public List<Command> fetchCommandsForClient(String accountGUID) throws NullCursorException { + final List<Command> commands = new ArrayList<Command>(); + final Cursor cur = db.fetchCommandsForClient(accountGUID); + try { + if (!cur.moveToFirst()) { + return Collections.unmodifiableList(commands); + } + + while(!cur.isAfterLast()) { + Command command = commandFromCursor(cur); + commands.add(command); + cur.moveToNext(); + } + return Collections.unmodifiableList(commands); + } finally { + cur.close(); + } + } + + protected static ClientRecord recordFromCursor(Cursor cur) { + final String accountGUID = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID); + final String clientName = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_NAME); + final String clientType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_TYPE); + + final ClientRecord record = new ClientRecord(accountGUID); + record.name = clientName; + record.type = clientType; + + // Optional fields. These will either be null or strings. + record.formfactor = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_FORMFACTOR); + record.os = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_OS); + record.device = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_DEVICE); + record.appPackage = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_APP_PACKAGE); + record.application = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_APPLICATION); + + return record; + } + + protected static Command commandFromCursor(Cursor cur) { + String commandType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_COMMAND); + JSONArray commandArgs = RepoUtils.getJSONArrayFromCursor(cur, ClientsDatabase.COL_ARGS); + return new Command(commandType, commandArgs); + } + + public int clientsCount() { + try { + final Cursor cur = db.fetchAllClients(); + try { + return cur.getCount(); + } finally { + cur.close(); + } + } catch (NullCursorException e) { + return 0; + } + + } + + private String getProfileId() { + return Constants.DEFAULT_PROFILE; + } + + public void wipeDB() { + db.wipeDB(); + } + + public void wipeClientsTable() { + db.wipeClientsTable(); + } + + public void wipeCommandsTable() { + db.wipeCommandsTable(); + } + + public void close() { + db.close(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java new file mode 100644 index 000000000..720d856eb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java @@ -0,0 +1,383 @@ +/* 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.repositories.android; + +import java.util.ArrayList; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.db.Tab; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.Clients; +import org.mozilla.gecko.sync.delegates.ClientsDataDelegate; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.NoContentProviderException; +import org.mozilla.gecko.sync.repositories.NoStoreDelegateException; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.ClientRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; +import org.mozilla.gecko.sync.repositories.domain.TabsRecord; + +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; + +public class FennecTabsRepository extends Repository { + private static final String LOG_TAG = "FennecTabsRepository"; + + protected final ClientsDataDelegate clientsDataDelegate; + + public FennecTabsRepository(ClientsDataDelegate clientsDataDelegate) { + this.clientsDataDelegate = clientsDataDelegate; + } + + /** + * Note that -- unlike most repositories -- this will only fetch Fennec's tabs, + * and only store tabs from other clients. + * + * It will never retrieve tabs from other clients, or store tabs for Fennec, + * unless you use {@link #fetch(String[], RepositorySessionFetchRecordsDelegate)} + * and specify an explicit GUID. + */ + public class FennecTabsRepositorySession extends RepositorySession { + protected static final String LOG_TAG = "FennecTabsSession"; + + private final ContentProviderClient tabsProvider; + private final ContentProviderClient clientsProvider; + + protected final RepoUtils.QueryHelper tabsHelper; + + protected final ClientsDatabaseAccessor clientsDatabase; + + protected ContentProviderClient getContentProvider(final Context context, final Uri uri) throws NoContentProviderException { + ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(uri); + if (client == null) { + throw new NoContentProviderException(uri); + } + return client; + } + + protected void releaseProviders() { + try { + clientsProvider.release(); + } catch (Exception e) {} + try { + tabsProvider.release(); + } catch (Exception e) {} + clientsDatabase.close(); + } + + public FennecTabsRepositorySession(Repository repository, Context context) throws NoContentProviderException { + super(repository); + clientsProvider = getContentProvider(context, BrowserContractHelpers.CLIENTS_CONTENT_URI); + try { + tabsProvider = getContentProvider(context, BrowserContractHelpers.TABS_CONTENT_URI); + } catch (NoContentProviderException e) { + clientsProvider.release(); + throw e; + } catch (Exception e) { + clientsProvider.release(); + // Oh, Java. + throw new RuntimeException(e); + } + + tabsHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.TABS_CONTENT_URI, LOG_TAG); + clientsDatabase = new ClientsDatabaseAccessor(context); + } + + @Override + public void abort() { + releaseProviders(); + super.abort(); + } + + @Override + public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + releaseProviders(); + super.finish(delegate); + } + + // Default parameters for local data: local client has null GUID. Override + // these to test against non-live data. + protected String localClientSelection() { + return BrowserContract.Tabs.CLIENT_GUID + " IS NULL"; + } + + protected String[] localClientSelectionArgs() { + return null; + } + + @Override + public void guidsSince(final long timestamp, + final RepositorySessionGuidsSinceDelegate delegate) { + // Bug 783692: Now that Bug 730039 has landed, we could implement this, + // but it's not a priority since it's not used (yet). + Logger.warn(LOG_TAG, "Not returning anything from guidsSince."); + delegateQueue.execute(new Runnable() { + @Override + public void run() { + delegate.onGuidsSinceSucceeded(new String[] {}); + } + }); + } + + @Override + public void fetchSince(final long timestamp, + final RepositorySessionFetchRecordsDelegate delegate) { + if (tabsProvider == null) { + throw new IllegalArgumentException("tabsProvider was null."); + } + if (tabsHelper == null) { + throw new IllegalArgumentException("tabsHelper was null."); + } + + final String positionAscending = BrowserContract.Tabs.POSITION + " ASC"; + + final String localClientSelection = localClientSelection(); + final String[] localClientSelectionArgs = localClientSelectionArgs(); + + final Runnable command = new Runnable() { + @Override + public void run() { + // We fetch all local tabs (since the record must contain them all) + // but only process the record if the timestamp is sufficiently + // recent, or if the client data has been modified. + try { + final Cursor cursor = tabsHelper.safeQuery(tabsProvider, ".fetchSince()", null, + localClientSelection, localClientSelectionArgs, positionAscending); + try { + final String localClientGuid = clientsDataDelegate.getAccountGUID(); + final String localClientName = clientsDataDelegate.getClientName(); + final TabsRecord tabsRecord = FennecTabsRepository.tabsRecordFromCursor(cursor, localClientGuid, localClientName); + + if (tabsRecord.lastModified >= timestamp || + clientsDataDelegate.getLastModifiedTimestamp() >= timestamp) { + delegate.onFetchedRecord(tabsRecord); + } + } finally { + cursor.close(); + } + } catch (Exception e) { + delegate.onFetchFailed(e, null); + return; + } + delegate.onFetchCompleted(now()); + } + }; + + delegateQueue.execute(command); + } + + @Override + public void fetch(final String[] guids, + final RepositorySessionFetchRecordsDelegate delegate) { + // Bug 783692: Now that Bug 730039 has landed, we could implement this, + // but it's not a priority since it's not used (yet). + Logger.warn(LOG_TAG, "Not returning anything from fetch"); + delegateQueue.execute(new Runnable() { + @Override + public void run() { + delegate.onFetchCompleted(now()); + } + }); + } + + @Override + public void fetchAll(final RepositorySessionFetchRecordsDelegate delegate) { + fetchSince(0, delegate); + } + + private static final String TABS_CLIENT_GUID_IS = BrowserContract.Tabs.CLIENT_GUID + " = ?"; + private static final String CLIENT_GUID_IS = BrowserContract.Clients.GUID + " = ?"; + + @Override + public void store(final Record record) throws NoStoreDelegateException { + if (delegate == null) { + Logger.warn(LOG_TAG, "No store delegate."); + throw new NoStoreDelegateException(); + } + if (record == null) { + Logger.error(LOG_TAG, "Record sent to store was null"); + throw new IllegalArgumentException("Null record passed to FennecTabsRepositorySession.store()."); + } + if (!(record instanceof TabsRecord)) { + Logger.error(LOG_TAG, "Can't store anything but a TabsRecord"); + throw new IllegalArgumentException("Non-TabsRecord passed to FennecTabsRepositorySession.store()."); + } + final TabsRecord tabsRecord = (TabsRecord) record; + + Runnable command = new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Storing tabs for client " + tabsRecord.guid); + if (!isActive()) { + delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid); + return; + } + if (tabsRecord.guid == null) { + delegate.onRecordStoreFailed(new RuntimeException("Can't store record with null GUID."), record.guid); + return; + } + + try { + // This is nice and easy: we *always* store. + final String[] selectionArgs = new String[] { tabsRecord.guid }; + if (tabsRecord.deleted) { + try { + Logger.debug(LOG_TAG, "Clearing entry for client " + tabsRecord.guid); + clientsProvider.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, + CLIENT_GUID_IS, + selectionArgs); + delegate.onRecordStoreSucceeded(record.guid); + } catch (Exception e) { + delegate.onRecordStoreFailed(e, record.guid); + } + return; + } + + // If it exists, update the client record; otherwise insert. + final ContentValues clientsCV = tabsRecord.getClientsContentValues(); + + final ClientRecord clientRecord = clientsDatabase.fetchClient(tabsRecord.guid); + if (null != clientRecord) { + // Null is an acceptable device type. + clientsCV.put(Clients.DEVICE_TYPE, clientRecord.type); + } + + Logger.debug(LOG_TAG, "Updating clients provider."); + final int updated = clientsProvider.update(BrowserContractHelpers.CLIENTS_CONTENT_URI, + clientsCV, + CLIENT_GUID_IS, + selectionArgs); + if (0 == updated) { + clientsProvider.insert(BrowserContractHelpers.CLIENTS_CONTENT_URI, clientsCV); + } + + // Now insert tabs. + final ContentValues[] tabsArray = tabsRecord.getTabsContentValues(); + Logger.debug(LOG_TAG, "Inserting " + tabsArray.length + " tabs for client " + tabsRecord.guid); + + tabsProvider.delete(BrowserContractHelpers.TABS_CONTENT_URI, TABS_CLIENT_GUID_IS, selectionArgs); + final int inserted = tabsProvider.bulkInsert(BrowserContractHelpers.TABS_CONTENT_URI, tabsArray); + Logger.trace(LOG_TAG, "Inserted: " + inserted); + + delegate.onRecordStoreSucceeded(record.guid); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Error storing tabs.", e); + delegate.onRecordStoreFailed(e, record.guid); + } + } + }; + + storeWorkQueue.execute(command); + } + + @Override + public void wipe(RepositorySessionWipeDelegate delegate) { + try { + tabsProvider.delete(BrowserContractHelpers.TABS_CONTENT_URI, null, null); + clientsProvider.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, null, null); + } catch (RemoteException e) { + Logger.warn(LOG_TAG, "Got RemoteException in wipe.", e); + delegate.onWipeFailed(e); + return; + } + delegate.onWipeSucceeded(); + } + } + + @Override + public void createSession(RepositorySessionCreationDelegate delegate, + Context context) { + try { + final FennecTabsRepositorySession session = new FennecTabsRepositorySession(this, context); + delegate.onSessionCreated(session); + } catch (Exception e) { + delegate.onSessionCreateFailed(e); + } + } + + /** + * Extract a <code>TabsRecord</code> from a cursor. + * <p> + * Caller is responsible for creating and closing cursor. Each row of the + * cursor should be an individual tab record. + * <p> + * The extracted tabs record has the given client GUID and client name. + * + * @param cursor + * to inspect. + * @param clientGuid + * returned tabs record will have this client GUID. + * @param clientName + * returned tabs record will have this client name. + * @return <code>TabsRecord</code> instance. + */ + public static TabsRecord tabsRecordFromCursor(final Cursor cursor, final String clientGuid, final String clientName) { + final String collection = "tabs"; + final TabsRecord record = new TabsRecord(clientGuid, collection, 0, false); + record.tabs = new ArrayList<Tab>(); + record.clientName = clientName; + + record.androidID = -1; + record.deleted = false; + + record.lastModified = 0; + + int position = cursor.getPosition(); + try { + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + final Tab tab = Tab.fromCursor(cursor); + record.tabs.add(tab); + + if (tab.lastUsed > record.lastModified) { + record.lastModified = tab.lastUsed; + } + + cursor.moveToNext(); + } + } finally { + cursor.moveToPosition(position); + } + + return record; + } + + /** + * Deletes all non-local clients and their associated remote tabs. + */ + public static void deleteNonLocalClientsAndTabs(Context context) { + final String nonLocalClientSelection = BrowserContract.Clients.GUID + " IS NOT NULL"; + + ContentProviderClient clientsProvider = context.getContentResolver() + .acquireContentProviderClient(BrowserContractHelpers.CLIENTS_CONTENT_URI); + if (clientsProvider == null) { + Logger.warn(LOG_TAG, "Unable to create clientsProvider!"); + return; + } + + try { + Logger.info(LOG_TAG, "Clearing all non-local clients and their associated remote tabs for default profile."); + clientsProvider.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, nonLocalClientSelection, null); + } catch (RemoteException e) { + Logger.warn(LOG_TAG, "Error while deleting", e); + } finally { + try { + clientsProvider.release(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception releasing clientsProvider!", e); + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java new file mode 100644 index 000000000..9beafa712 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java @@ -0,0 +1,723 @@ +/* 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.repositories.android; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.DeletedFormHistory; +import org.mozilla.gecko.db.BrowserContract.FormHistory; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.NoContentProviderException; +import org.mozilla.gecko.sync.repositories.NoStoreDelegateException; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.RecordFilter; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; + +public class FormHistoryRepositorySession extends + StoreTrackingRepositorySession { + public static final String LOG_TAG = "FormHistoryRepoSess"; + + /** + * Number of records to insert in one batch. + */ + public static final int INSERT_ITEM_THRESHOLD = 200; + + private static final Uri FORM_HISTORY_CONTENT_URI = BrowserContractHelpers.FORM_HISTORY_CONTENT_URI; + private static final Uri DELETED_FORM_HISTORY_CONTENT_URI = BrowserContractHelpers.DELETED_FORM_HISTORY_CONTENT_URI; + + public static class FormHistoryRepository extends Repository { + + @Override + public void createSession(RepositorySessionCreationDelegate delegate, + Context context) { + try { + final FormHistoryRepositorySession session = new FormHistoryRepositorySession(this, context); + delegate.onSessionCreated(session); + } catch (Exception e) { + delegate.onSessionCreateFailed(e); + } + } + } + + protected final ContentProviderClient formsProvider; + protected final RepoUtils.QueryHelper regularHelper; + protected final RepoUtils.QueryHelper deletedHelper; + + /** + * Acquire the content provider client. + * <p> + * The caller is responsible for releasing the client. + * + * @param context The application context. + * @return The <code>ContentProviderClient</code>. + * @throws NoContentProviderException + */ + public static ContentProviderClient acquireContentProvider(final Context context) + throws NoContentProviderException { + Uri uri = BrowserContract.FORM_HISTORY_AUTHORITY_URI; + ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(uri); + if (client == null) { + throw new NoContentProviderException(uri); + } + return client; + } + + protected void releaseProviders() { + try { + if (formsProvider != null) { + formsProvider.release(); + } + } catch (Exception e) { + } + } + + // Only used for testing. + public ContentProviderClient getFormsProvider() { + return formsProvider; + } + + public FormHistoryRepositorySession(Repository repository, Context context) + throws NoContentProviderException { + super(repository); + formsProvider = acquireContentProvider(context); + regularHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.FORM_HISTORY_CONTENT_URI, LOG_TAG); + deletedHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.DELETED_FORM_HISTORY_CONTENT_URI, LOG_TAG); + } + + @Override + public void abort() { + releaseProviders(); + super.abort(); + } + + @Override + public void finish(final RepositorySessionFinishDelegate delegate) + throws InactiveSessionException { + releaseProviders(); + super.finish(delegate); + } + + protected static final String[] GUID_COLUMNS = new String[] { FormHistory.GUID }; + + @Override + public void guidsSince(final long timestamp, final RepositorySessionGuidsSinceDelegate delegate) { + Runnable command = new Runnable() { + @Override + public void run() { + if (!isActive()) { + delegate.onGuidsSinceFailed(new InactiveSessionException(null)); + return; + } + + ArrayList<String> guids = new ArrayList<String>(); + + final long sharedEnd = now(); + Cursor cur = null; + try { + cur = regularHelper.safeQuery(formsProvider, "", GUID_COLUMNS, regularBetween(timestamp, sharedEnd), null, null); + cur.moveToFirst(); + while (!cur.isAfterLast()) { + guids.add(cur.getString(0)); + cur.moveToNext(); + } + } catch (RemoteException | NullCursorException e) { + delegate.onGuidsSinceFailed(e); + return; + } finally { + if (cur != null) { + cur.close(); + } + } + + try { + cur = deletedHelper.safeQuery(formsProvider, "", GUID_COLUMNS, deletedBetween(timestamp, sharedEnd), null, null); + cur.moveToFirst(); + while (!cur.isAfterLast()) { + guids.add(cur.getString(0)); + cur.moveToNext(); + } + } catch (RemoteException | NullCursorException e) { + delegate.onGuidsSinceFailed(e); + return; + } finally { + if (cur != null) { + cur.close(); + } + } + + String guidsArray[] = guids.toArray(new String[guids.size()]); + delegate.onGuidsSinceSucceeded(guidsArray); + } + }; + delegateQueue.execute(command); + } + + protected static FormHistoryRecord retrieveDuringFetch(final Cursor cursor) { + // A simple and efficient way to distinguish two tables. + if (cursor.getColumnCount() == BrowserContractHelpers.FormHistoryColumns.length) { + return formHistoryRecordFromCursor(cursor); + } else { + return deletedFormHistoryRecordFromCursor(cursor); + } + } + + protected static FormHistoryRecord formHistoryRecordFromCursor(final Cursor cursor) { + String guid = RepoUtils.getStringFromCursor(cursor, FormHistory.GUID); + String collection = "forms"; + FormHistoryRecord record = new FormHistoryRecord(guid, collection, 0, false); + + record.fieldName = RepoUtils.getStringFromCursor(cursor, FormHistory.FIELD_NAME); + record.fieldValue = RepoUtils.getStringFromCursor(cursor, FormHistory.VALUE); + record.androidID = RepoUtils.getLongFromCursor(cursor, FormHistory.ID); + record.lastModified = RepoUtils.getLongFromCursor(cursor, FormHistory.FIRST_USED) / 1000; // Convert microseconds to milliseconds. + record.deleted = false; + + record.log(LOG_TAG); + return record; + } + + protected static FormHistoryRecord deletedFormHistoryRecordFromCursor(final Cursor cursor) { + String guid = RepoUtils.getStringFromCursor(cursor, DeletedFormHistory.GUID); + String collection = "forms"; + FormHistoryRecord record = new FormHistoryRecord(guid, collection, 0, false); + + record.guid = RepoUtils.getStringFromCursor(cursor, DeletedFormHistory.GUID); + record.androidID = RepoUtils.getLongFromCursor(cursor, DeletedFormHistory.ID); + record.lastModified = RepoUtils.getLongFromCursor(cursor, DeletedFormHistory.TIME_DELETED); + record.deleted = true; + + record.log(LOG_TAG); + return record; + } + + protected static void fetchFromCursor(final Cursor cursor, final RecordFilter filter, final RepositorySessionFetchRecordsDelegate delegate) + throws NullCursorException { + Logger.debug(LOG_TAG, "Fetch from cursor"); + if (cursor == null) { + throw new NullCursorException(null); + } + try { + if (!cursor.moveToFirst()) { + return; + } + while (!cursor.isAfterLast()) { + Record r = retrieveDuringFetch(cursor); + if (r != null) { + if (filter == null || !filter.excludeRecord(r)) { + Logger.trace(LOG_TAG, "Processing record " + r.guid); + delegate.onFetchedRecord(r); + } else { + Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid); + } + } + cursor.moveToNext(); + } + } finally { + Logger.trace(LOG_TAG, "Closing cursor after fetch."); + cursor.close(); + } + } + + protected void fetchHelper(final RepositorySessionFetchRecordsDelegate delegate, final long end, final List<Callable<Cursor>> cursorCallables) { + if (this.storeTracker == null) { + throw new IllegalStateException("Store tracker not yet initialized!"); + } + + final RecordFilter filter = this.storeTracker.getFilter(); + + Runnable command = new Runnable() { + @Override + public void run() { + if (!isActive()) { + delegate.onFetchFailed(new InactiveSessionException(null), null); + return; + } + + for (Callable<Cursor> cursorCallable : cursorCallables) { + Cursor cursor = null; + try { + cursor = cursorCallable.call(); + fetchFromCursor(cursor, filter, delegate); // Closes cursor. + } catch (Exception e) { + Logger.warn(LOG_TAG, "Exception during fetchHelper", e); + delegate.onFetchFailed(e, null); + return; + } + } + + delegate.onFetchCompleted(end); + } + }; + + delegateQueue.execute(command); + } + + protected static String regularBetween(long start, long end) { + return FormHistory.FIRST_USED + " >= " + Long.toString(1000 * start) + " AND " + + FormHistory.FIRST_USED + " <= " + Long.toString(1000 * end); // Microseconds. + } + + protected static String deletedBetween(long start, long end) { + return DeletedFormHistory.TIME_DELETED + " >= " + Long.toString(start) + " AND " + + DeletedFormHistory.TIME_DELETED + " <= " + Long.toString(end); // Milliseconds. + } + + @Override + public void fetchSince(final long timestamp, final RepositorySessionFetchRecordsDelegate delegate) { + Logger.trace(LOG_TAG, "Running fetchSince(" + timestamp + ")."); + + /* + * We need to be careful about the timestamp we complete the fetch with. If + * the first cursor Callable takes a year, then the second could return + * records long after the first was kicked off. To protect against this, we + * set an end point and bound our search. + */ + final long sharedEnd = now(); + + Callable<Cursor> regularCallable = new Callable<Cursor>() { + @Override + public Cursor call() throws Exception { + return regularHelper.safeQuery(formsProvider, ".fetchSince(regular)", null, regularBetween(timestamp, sharedEnd), null, null); + } + }; + + Callable<Cursor> deletedCallable = new Callable<Cursor>() { + @Override + public Cursor call() throws Exception { + return deletedHelper.safeQuery(formsProvider, ".fetchSince(deleted)", null, deletedBetween(timestamp, sharedEnd), null, null); + } + }; + + @SuppressWarnings("unchecked") + List<Callable<Cursor>> callableCursors = Arrays.asList(regularCallable, deletedCallable); + + fetchHelper(delegate, sharedEnd, callableCursors); + } + + @Override + public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) { + Logger.trace(LOG_TAG, "Running fetchAll."); + fetchSince(0, delegate); + } + + @Override + public void fetch(final String[] guids, final RepositorySessionFetchRecordsDelegate delegate) { + Logger.trace(LOG_TAG, "Running fetch."); + + final long sharedEnd = now(); + final String where = RepoUtils.computeSQLInClause(guids.length, FormHistory.GUID); + + Callable<Cursor> regularCallable = new Callable<Cursor>() { + @Override + public Cursor call() throws Exception { + String regularWhere = where + " AND " + FormHistory.FIRST_USED + " <= " + Long.toString(1000 * sharedEnd); // Microseconds. + return regularHelper.safeQuery(formsProvider, ".fetch(regular)", null, regularWhere, guids, null); + } + }; + + Callable<Cursor> deletedCallable = new Callable<Cursor>() { + @Override + public Cursor call() throws Exception { + String deletedWhere = where + " AND " + DeletedFormHistory.TIME_DELETED + " <= " + Long.toString(sharedEnd); // Milliseconds. + return deletedHelper.safeQuery(formsProvider, ".fetch(deleted)", null, deletedWhere, guids, null); + } + }; + + @SuppressWarnings("unchecked") + List<Callable<Cursor>> callableCursors = Arrays.asList(regularCallable, deletedCallable); + + fetchHelper(delegate, sharedEnd, callableCursors); + } + + protected static final String GUID_IS = FormHistory.GUID + " = ?"; + + protected Record findExistingRecordByGuid(String guid) + throws RemoteException, NullCursorException { + Cursor cursor = null; + try { + cursor = regularHelper.safeQuery(formsProvider, ".findExistingRecordByGuid(regular)", + null, GUID_IS, new String[] { guid }, null); + if (cursor.moveToFirst()) { + return formHistoryRecordFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + try { + cursor = deletedHelper.safeQuery(formsProvider, ".findExistingRecordByGuid(deleted)", + null, GUID_IS, new String[] { guid }, null); + if (cursor.moveToFirst()) { + return deletedFormHistoryRecordFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return null; + } + + protected Record findExistingRecordByPayload(Record rawRecord) + throws RemoteException, NullCursorException { + if (!rawRecord.deleted) { + FormHistoryRecord record = (FormHistoryRecord) rawRecord; + Cursor cursor = null; + try { + String where = FormHistory.FIELD_NAME + " = ? AND " + FormHistory.VALUE + " = ?"; + cursor = regularHelper.safeQuery(formsProvider, ".findExistingRecordByPayload", + null, where, new String[] { record.fieldName, record.fieldValue }, null); + if (cursor.moveToFirst()) { + return formHistoryRecordFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + return null; + } + + /** + * Called when a record with locally known GUID has been reported deleted by + * the server. + * <p> + * We purge the record's GUID from the regular and deleted tables. + * + * @param existingRecord + * The local <code>Record</code> to replace. + * @throws RemoteException + */ + protected void deleteExistingRecord(Record existingRecord) throws RemoteException { + if (existingRecord.deleted) { + formsProvider.delete(DELETED_FORM_HISTORY_CONTENT_URI, GUID_IS, new String[] { existingRecord.guid }); + return; + } + formsProvider.delete(FORM_HISTORY_CONTENT_URI, GUID_IS, new String[] { existingRecord.guid }); + } + + protected static ContentValues contentValuesForRegularRecord(Record rawRecord) { + if (rawRecord.deleted) { + throw new IllegalArgumentException("Deleted record passed to insertNewRegularRecord."); + } + + FormHistoryRecord record = (FormHistoryRecord) rawRecord; + ContentValues cv = new ContentValues(); + cv.put(FormHistory.GUID, record.guid); + cv.put(FormHistory.FIELD_NAME, record.fieldName); + cv.put(FormHistory.VALUE, record.fieldValue); + cv.put(FormHistory.FIRST_USED, 1000 * record.lastModified); // Microseconds. + return cv; + } + + protected final Object recordsBufferMonitor = new Object(); + protected ArrayList<ContentValues> recordsBuffer = new ArrayList<ContentValues>(); + + protected void enqueueRegularRecord(Record record) { + synchronized (recordsBufferMonitor) { + if (recordsBuffer.size() >= INSERT_ITEM_THRESHOLD) { + // Insert the existing contents, then enqueue. + try { + flushInsertQueue(); + } catch (Exception e) { + delegate.onRecordStoreFailed(e, record.guid); + return; + } + } + // Store the ContentValues, rather than the record. + recordsBuffer.add(contentValuesForRegularRecord(record)); + } + } + + // Should always be called from storeWorkQueue. + protected void flushInsertQueue() throws RemoteException { + synchronized (recordsBufferMonitor) { + if (recordsBuffer.size() > 0) { + final ContentValues[] outgoing = recordsBuffer.toArray(new ContentValues[recordsBuffer.size()]); + recordsBuffer = new ArrayList<ContentValues>(); + + if (outgoing == null || outgoing.length == 0) { + Logger.debug(LOG_TAG, "No form history items to insert; returning immediately."); + return; + } + + long before = System.currentTimeMillis(); + formsProvider.bulkInsert(FORM_HISTORY_CONTENT_URI, outgoing); + long after = System.currentTimeMillis(); + Logger.debug(LOG_TAG, "Inserted " + outgoing.length + " form history items in (" + (after - before) + " milliseconds)."); + } + } + } + + @Override + public void storeDone() { + Runnable command = new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Checking for residual form history items to insert."); + try { + synchronized (recordsBufferMonitor) { + flushInsertQueue(); + } + storeDone(now()); + } catch (Exception e) { + // XXX TODO + delegate.onRecordStoreFailed(e, null); + } + } + }; + storeWorkQueue.execute(command); + } + + /** + * Called when a regular record with locally unknown GUID has been fetched + * from the server. + * <p> + * Since the record is regular, we insert it into the regular table. + * + * @param record The regular <code>Record</code> from the server. + * @throws RemoteException + */ + protected void insertNewRegularRecord(Record record) + throws RemoteException { + enqueueRegularRecord(record); + } + + /** + * Called when a regular record with has been fetched from the server and + * should replace an existing record. + * <p> + * We delete the existing record entirely, and then insert the new record into + * the regular table. + * + * @param toStore + * The regular <code>Record</code> from the server. + * @param existingRecord + * The local <code>Record</code> to replace. + * @throws RemoteException + */ + protected void replaceExistingRecordWithRegularRecord(Record toStore, Record existingRecord) + throws RemoteException { + if (existingRecord.deleted) { + // Need two database operations -- purge from deleted table, insert into regular table. + deleteExistingRecord(existingRecord); + insertNewRegularRecord(toStore); + return; + } + + final ContentValues cv = contentValuesForRegularRecord(toStore); + int updated = formsProvider.update(FORM_HISTORY_CONTENT_URI, cv, GUID_IS, new String[] { existingRecord.guid }); + if (updated != 1) { + Logger.warn(LOG_TAG, "Expected to update 1 record with guid " + existingRecord.guid + " but updated " + updated + " records."); + } + } + + @Override + public void store(Record rawRecord) throws NoStoreDelegateException { + if (delegate == null) { + Logger.warn(LOG_TAG, "No store delegate."); + throw new NoStoreDelegateException(); + } + if (rawRecord == null) { + Logger.error(LOG_TAG, "Record sent to store was null"); + throw new IllegalArgumentException("Null record passed to FormHistoryRepositorySession.store()."); + } + if (!(rawRecord instanceof FormHistoryRecord)) { + Logger.error(LOG_TAG, "Can't store anything but a FormHistoryRecord"); + throw new IllegalArgumentException("Non-FormHistoryRecord passed to FormHistoryRepositorySession.store()."); + } + final FormHistoryRecord record = (FormHistoryRecord) rawRecord; + + Runnable command = new Runnable() { + @Override + public void run() { + if (!isActive()) { + Logger.warn(LOG_TAG, "FormHistoryRepositorySession is inactive. Store failing."); + delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid); + return; + } + + // TODO: lift these into the session. + // Temporary: this matches prior syncing semantics, in which only + // the relationship between the local and remote record is considered. + // In the future we'll track these two timestamps and use them to + // determine which records have changed, and thus process incoming + // records more efficiently. + long lastLocalRetrieval = 0; // lastSyncTimestamp? + long lastRemoteRetrieval = 0; // TODO: adjust for clock skew. + boolean remotelyModified = record.lastModified > lastRemoteRetrieval; + + Record existingRecord; + try { + // GUID matching only: deleted records don't have a payload with which to search. + existingRecord = findExistingRecordByGuid(record.guid); + if (record.deleted) { + if (existingRecord == null) { + // We're done. Don't bother with a callback. That can change later + // if we want it to. + Logger.trace(LOG_TAG, "Incoming record " + record.guid + " is deleted, and no local version. Bye!"); + return; + } + + if (existingRecord.deleted) { + Logger.trace(LOG_TAG, "Local record already deleted. Purging local."); + deleteExistingRecord(existingRecord); + return; + } + + // Which one wins? + if (!remotelyModified) { + Logger.trace(LOG_TAG, "Ignoring deleted record from the past."); + return; + } + + boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval; + if (!locallyModified) { + Logger.trace(LOG_TAG, "Remote modified, local not. Deleting."); + deleteExistingRecord(existingRecord); + trackRecord(record); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + + Logger.trace(LOG_TAG, "Both local and remote records have been modified."); + if (record.lastModified > existingRecord.lastModified) { + Logger.trace(LOG_TAG, "Remote is newer, and deleted. Purging local."); + deleteExistingRecord(existingRecord); + trackRecord(record); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + + Logger.trace(LOG_TAG, "Remote is older, local is not deleted. Ignoring."); + if (!locallyModified) { + Logger.warn(LOG_TAG, "Inconsistency: old remote record is deleted, but local record not modified!"); + // Ensure that this is tracked for upload. + } + return; + } + // End deletion logic. + + // Now we're processing a non-deleted incoming record. + if (existingRecord == null) { + Logger.trace(LOG_TAG, "Looking up match for record " + record.guid); + existingRecord = findExistingRecordByPayload(record); + } + + if (existingRecord == null) { + // The record is new. + Logger.trace(LOG_TAG, "No match. Inserting."); + insertNewRegularRecord(record); + trackRecord(record); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + + // We found a local duplicate. + Logger.trace(LOG_TAG, "Incoming record " + record.guid + " dupes to local record " + existingRecord.guid); + + if (!RepoUtils.stringsEqual(record.guid, existingRecord.guid)) { + // We found a local record that does NOT have the same GUID -- keep the server's version. + Logger.trace(LOG_TAG, "Remote guid different from local guid. Storing to keep remote guid."); + replaceExistingRecordWithRegularRecord(record, existingRecord); + trackRecord(record); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + + // We found a local record that does have the same GUID -- check modification times. + boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval; + if (!locallyModified) { + Logger.trace(LOG_TAG, "Remote modified, local not. Storing."); + replaceExistingRecordWithRegularRecord(record, existingRecord); + trackRecord(record); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + + Logger.trace(LOG_TAG, "Both local and remote records have been modified."); + if (record.lastModified > existingRecord.lastModified) { + Logger.trace(LOG_TAG, "Remote is newer, and not deleted. Storing."); + replaceExistingRecordWithRegularRecord(record, existingRecord); + trackRecord(record); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + + Logger.trace(LOG_TAG, "Remote is older, local is not deleted. Ignoring."); + if (!locallyModified) { + Logger.warn(LOG_TAG, "Inconsistency: old remote record is not deleted, but local record not modified!"); + } + return; + } catch (Exception e) { + Logger.error(LOG_TAG, "Store failed for " + record.guid, e); + delegate.onRecordStoreFailed(e, record.guid); + return; + } + } + }; + + storeWorkQueue.execute(command); + } + + /** + * Purge all data from the underlying databases. + */ + public static void purgeDatabases(ContentProviderClient formsProvider) + throws RemoteException { + formsProvider.delete(FORM_HISTORY_CONTENT_URI, null, null); + formsProvider.delete(DELETED_FORM_HISTORY_CONTENT_URI, null, null); + } + + @Override + public void wipe(final RepositorySessionWipeDelegate delegate) { + Runnable command = new Runnable() { + @Override + public void run() { + if (!isActive()) { + delegate.onWipeFailed(new InactiveSessionException(null)); + return; + } + + try { + Logger.debug(LOG_TAG, "Wiping form history and deleted form history..."); + purgeDatabases(formsProvider); + Logger.debug(LOG_TAG, "Wiping form history and deleted form history... DONE"); + } catch (Exception e) { + delegate.onWipeFailed(e); + return; + } + + delegate.onWipeSucceeded(); + } + }; + storeWorkQueue.execute(command); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java new file mode 100644 index 000000000..f7b7416df --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java @@ -0,0 +1,725 @@ +/* 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.repositories.android; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.DeletedColumns; +import org.mozilla.gecko.db.BrowserContract.DeletedPasswords; +import org.mozilla.gecko.db.BrowserContract.Passwords; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.NoStoreDelegateException; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.RecordFilter; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession; +import org.mozilla.gecko.sync.repositories.android.RepoUtils.QueryHelper; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.repositories.domain.PasswordRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import android.content.ContentProviderClient; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; + +public class PasswordsRepositorySession extends + StoreTrackingRepositorySession { + + public static class PasswordsRepository extends Repository { + @Override + public void createSession(RepositorySessionCreationDelegate delegate, + Context context) { + PasswordsRepositorySession session = new PasswordsRepositorySession(PasswordsRepository.this, context); + final RepositorySessionCreationDelegate deferredCreationDelegate = delegate.deferredCreationDelegate(); + deferredCreationDelegate.onSessionCreated(session); + } + } + + private static final String LOG_TAG = "PasswordsRepoSession"; + private static final String COLLECTION = "passwords"; + + private final RepoUtils.QueryHelper passwordsHelper; + private final RepoUtils.QueryHelper deletedPasswordsHelper; + private final ContentProviderClient passwordsProvider; + + private final Context context; + + public PasswordsRepositorySession(Repository repository, Context context) { + super(repository); + this.context = context; + this.passwordsHelper = new QueryHelper(context, BrowserContractHelpers.PASSWORDS_CONTENT_URI, LOG_TAG); + this.deletedPasswordsHelper = new QueryHelper(context, BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI, LOG_TAG); + this.passwordsProvider = context.getContentResolver().acquireContentProviderClient(BrowserContract.PASSWORDS_AUTHORITY_URI); + } + + private static final String[] GUID_COLS = new String[] { Passwords.GUID }; + private static final String[] DELETED_GUID_COLS = new String[] { DeletedColumns.GUID }; + + private static final String WHERE_GUID_IS = Passwords.GUID + " = ?"; + private static final String WHERE_DELETED_GUID_IS = DeletedPasswords.GUID + " = ?"; + + @Override + public void guidsSince(final long timestamp, final RepositorySessionGuidsSinceDelegate delegate) { + final Runnable guidsSinceRunnable = new Runnable() { + @Override + public void run() { + + if (!isActive()) { + delegate.onGuidsSinceFailed(new InactiveSessionException(null)); + return; + } + + // Checks succeeded, now get GUIDs. + final List<String> guids = new ArrayList<String>(); + try { + Logger.debug(LOG_TAG, "Fetching guidsSince from data table."); + final Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".getGUIDsSince", GUID_COLS, dateModifiedWhere(timestamp), null, null); + try { + if (data.moveToFirst()) { + while (!data.isAfterLast()) { + guids.add(RepoUtils.getStringFromCursor(data, Passwords.GUID)); + data.moveToNext(); + } + } + } finally { + data.close(); + } + + // Fetch guids from deleted table. + Logger.debug(LOG_TAG, "Fetching guidsSince from deleted table."); + final Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".getGUIDsSince", DELETED_GUID_COLS, dateModifiedWhereDeleted(timestamp), null, null); + try { + if (deleted.moveToFirst()) { + while (!deleted.isAfterLast()) { + guids.add(RepoUtils.getStringFromCursor(deleted, DeletedColumns.GUID)); + deleted.moveToNext(); + } + } + } finally { + deleted.close(); + } + } catch (Exception e) { + Logger.error(LOG_TAG, "Exception in fetch."); + delegate.onGuidsSinceFailed(e); + return; + } + String[] guidStrings = new String[guids.size()]; + delegate.onGuidsSinceSucceeded(guids.toArray(guidStrings)); + } + }; + + delegateQueue.execute(guidsSinceRunnable); + } + + @Override + public void fetchSince(final long timestamp, final RepositorySessionFetchRecordsDelegate delegate) { + final RecordFilter filter = this.storeTracker.getFilter(); + final Runnable fetchSinceRunnable = new Runnable() { + @Override + public void run() { + if (!isActive()) { + delegate.onFetchFailed(new InactiveSessionException(null), null); + return; + } + + final long end = now(); + try { + // Fetch from data table. + Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".fetchSince", + getAllColumns(), + dateModifiedWhere(timestamp), + null, null); + if (!fetchAndCloseCursorDeleted(data, false, filter, delegate)) { + return; + } + + // Fetch from deleted table. + Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".fetchSince", + getAllDeletedColumns(), + dateModifiedWhereDeleted(timestamp), + null, null); + if (!fetchAndCloseCursorDeleted(deleted, true, filter, delegate)) { + return; + } + + // Success! + try { + delegate.onFetchCompleted(end); + } catch (Exception e) { + Logger.error(LOG_TAG, "Delegate fetch completed callback failed.", e); + // Don't call failure callback. + return; + } + } catch (Exception e) { + Logger.error(LOG_TAG, "Exception in fetch."); + delegate.onFetchFailed(e, null); + } + } + }; + + delegateQueue.execute(fetchSinceRunnable); + } + + @Override + public void fetch(final String[] guids, final RepositorySessionFetchRecordsDelegate delegate) { + if (guids == null || guids.length < 1) { + Logger.error(LOG_TAG, "No guids to be fetched."); + final long end = now(); + delegateQueue.execute(new Runnable() { + @Override + public void run() { + delegate.onFetchCompleted(end); + } + }); + return; + } + + // Checks succeeded, now fetch. + final RecordFilter filter = this.storeTracker.getFilter(); + final Runnable fetchRunnable = new Runnable() { + @Override + public void run() { + if (!isActive()) { + delegate.onFetchFailed(new InactiveSessionException(null), null); + return; + } + + final long end = now(); + final String where = RepoUtils.computeSQLInClause(guids.length, "guid"); + Logger.trace(LOG_TAG, "Fetch guids where: " + where); + + try { + // Fetch records from data table. + Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".fetch", + getAllColumns(), + where, guids, null); + if (!fetchAndCloseCursorDeleted(data, false, filter, delegate)) { + return; + } + + // Fetch records from deleted table. + Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".fetch", + getAllDeletedColumns(), + where, guids, null); + if (!fetchAndCloseCursorDeleted(deleted, true, filter, delegate)) { + return; + } + + delegate.onFetchCompleted(end); + + } catch (Exception e) { + Logger.error(LOG_TAG, "Exception in fetch."); + delegate.onFetchFailed(e, null); + } + } + }; + + delegateQueue.execute(fetchRunnable); + } + + @Override + public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) { + fetchSince(0, delegate); + } + + @Override + public void store(final Record record) throws NoStoreDelegateException { + if (delegate == null) { + Logger.error(LOG_TAG, "No store delegate."); + throw new NoStoreDelegateException(); + } + if (record == null) { + Logger.error(LOG_TAG, "Record sent to store was null."); + throw new IllegalArgumentException("Null record passed to PasswordsRepositorySession.store()."); + } + if (!(record instanceof PasswordRecord)) { + Logger.error(LOG_TAG, "Can't store anything but a PasswordRecord."); + throw new IllegalArgumentException("Non-PasswordRecord passed to PasswordsRepositorySession.store()."); + } + + final PasswordRecord remoteRecord = (PasswordRecord) record; + + final Runnable storeRunnable = new Runnable() { + @Override + public void run() { + if (!isActive()) { + Logger.warn(LOG_TAG, "RepositorySession is inactive. Store failing."); + delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid); + return; + } + + final String guid = remoteRecord.guid; + if (guid == null) { + delegate.onRecordStoreFailed(new RuntimeException("Can't store record with null GUID."), record.guid); + return; + } + + PasswordRecord existingRecord; + try { + existingRecord = retrieveByGUID(guid); + } catch (NullCursorException | RemoteException e) { + // Indicates a serious problem. + delegate.onRecordStoreFailed(e, record.guid); + return; + } + + long lastLocalRetrieval = 0; // lastSyncTimestamp? + long lastRemoteRetrieval = 0; // TODO: adjust for clock skew. + boolean remotelyModified = remoteRecord.lastModified > lastRemoteRetrieval; + + // Check deleted state first. + if (remoteRecord.deleted) { + if (existingRecord == null) { + // Do nothing, record does not exist anyways. + Logger.info(LOG_TAG, "Incoming record " + remoteRecord.guid + " is deleted, and no local version."); + return; + } + + if (existingRecord.deleted) { + // Record is already tracked as deleted. Delete from local. + storeRecordDeletion(existingRecord); // different from ABRepoSess. + Logger.info(LOG_TAG, "Incoming record " + remoteRecord.guid + " and local are both deleted."); + return; + } + + // Which one wins? + if (!remotelyModified) { + trace("Ignoring deleted record from the past."); + return; + } + + boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval; + if (!locallyModified) { + trace("Remote modified, local not. Deleting."); + storeRecordDeletion(remoteRecord); + return; + } + + trace("Both local and remote records have been modified."); + if (remoteRecord.lastModified > existingRecord.lastModified) { + trace("Remote is newer, and deleted. Deleting local."); + storeRecordDeletion(remoteRecord); + return; + } + + trace("Remote is older, local is not deleted. Ignoring."); + if (!locallyModified) { + Logger.warn(LOG_TAG, "Inconsistency: old remote record is deleted, but local record not modified!"); + // Ensure that this is tracked for upload. + } + return; + } + // End deletion logic. + + // Validate the incoming record. + if (!remoteRecord.isValid()) { + Logger.warn(LOG_TAG, "Incoming record is invalid. Reporting store failed."); + delegate.onRecordStoreFailed(new RuntimeException("Can't store invalid password record."), record.guid); + return; + } + + // Now we're processing a non-deleted incoming record. + if (existingRecord == null) { + trace("Looking up match for record " + remoteRecord.guid); + try { + existingRecord = findExistingRecord(remoteRecord); + } catch (RemoteException e) { + Logger.error(LOG_TAG, "Remote exception in findExistingRecord."); + delegate.onRecordStoreFailed(e, record.guid); + } catch (NullCursorException e) { + Logger.error(LOG_TAG, "Null cursor in findExistingRecord."); + delegate.onRecordStoreFailed(e, record.guid); + } + } + + if (existingRecord == null) { + // The record is new. + trace("No match. Inserting."); + Logger.debug(LOG_TAG, "Didn't find matching record. Inserting."); + Record inserted = null; + try { + inserted = insert(remoteRecord); + } catch (RemoteException e) { + Logger.debug(LOG_TAG, "Record insert caused a RemoteException."); + delegate.onRecordStoreFailed(e, record.guid); + return; + } + trackRecord(inserted); + delegate.onRecordStoreSucceeded(inserted.guid); + return; + } + + // We found a local dupe. + trace("Incoming record " + remoteRecord.guid + " dupes to local record " + existingRecord.guid); + Logger.debug(LOG_TAG, "remote " + remoteRecord.guid + " dupes to " + existingRecord.guid); + + if (existingRecord.deleted && existingRecord.lastModified > remoteRecord.lastModified) { + Logger.debug(LOG_TAG, "Local deletion is newer, not storing remote record."); + return; + } + + Record toStore = reconcileRecords(remoteRecord, existingRecord, lastRemoteRetrieval, lastLocalRetrieval); + if (toStore == null) { + Logger.debug(LOG_TAG, "Reconciling returned null. Not inserting a record."); + return; + } + + // TODO: pass in timestamps? + Logger.debug(LOG_TAG, "Replacing " + existingRecord.guid + " with record " + toStore.guid); + Record replaced = null; + try { + replaced = replace(existingRecord, toStore); + } catch (RemoteException e) { + Logger.debug(LOG_TAG, "Record replace caused a RemoteException."); + delegate.onRecordStoreFailed(e, record.guid); + return; + } + + // Note that we don't track records here; deciding that is the job + // of reconcileRecords. + Logger.debug(LOG_TAG, "Calling delegate callback with guid " + replaced.guid + + "(" + replaced.androidID + ")"); + delegate.onRecordStoreSucceeded(record.guid); + return; + } + }; + storeWorkQueue.execute(storeRunnable); + } + + @Override + public void wipe(final RepositorySessionWipeDelegate delegate) { + Logger.info(LOG_TAG, "Wiping " + BrowserContractHelpers.PASSWORDS_CONTENT_URI + ", " + BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI); + + Runnable wipeRunnable = new Runnable() { + @Override + public void run() { + if (!isActive()) { + delegate.onWipeFailed(new InactiveSessionException(null)); + return; + } + + // Wipe both data and deleted. + try { + context.getContentResolver().delete(BrowserContractHelpers.PASSWORDS_CONTENT_URI, null, null); + context.getContentResolver().delete(BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI, null, null); + } catch (Exception e) { + delegate.onWipeFailed(e); + return; + } + delegate.onWipeSucceeded(); + } + }; + storeWorkQueue.execute(wipeRunnable); + } + + @Override + public void abort() { + passwordsProvider.release(); + super.abort(); + } + + @Override + public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException { + passwordsProvider.release(); + super.finish(delegate); + } + + public void deleteGUID(String guid) throws RemoteException { + final String[] args = new String[] { guid }; + + int deleted = passwordsProvider.delete(BrowserContractHelpers.PASSWORDS_CONTENT_URI, WHERE_GUID_IS, args) + + passwordsProvider.delete(BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI, WHERE_DELETED_GUID_IS, args); + if (deleted == 1) { + return; + } + Logger.warn(LOG_TAG, "Unexpectedly deleted " + deleted + " rows for guid " + guid); + } + + /** + * Insert record and return the record with its updated androidId set. + * + * @param record the record to insert. + * @return updated record. + * @throws RemoteException + */ + public PasswordRecord insert(PasswordRecord record) throws RemoteException { + record.timePasswordChanged = now(); + // TODO: are these necessary for Fennec autocomplete? + // record.timesUsed = 1; + // record.timeLastUsed = now(); + ContentValues cv = getContentValues(record); + Uri insertedUri = passwordsProvider.insert(BrowserContractHelpers.PASSWORDS_CONTENT_URI, cv); + if (insertedUri == null) { + throw new RemoteException(); // Not much to be done here, save throw. + } + record.androidID = ContentUris.parseId(insertedUri); + return record; + } + + public Record replace(Record origRecord, Record newRecord) throws RemoteException { + PasswordRecord newPasswordRecord = (PasswordRecord) newRecord; + PasswordRecord origPasswordRecord = (PasswordRecord) origRecord; + propagateTimes(newPasswordRecord, origPasswordRecord); + ContentValues cv = getContentValues(newPasswordRecord); + + final String[] args = new String[] { origRecord.guid }; + + if (origRecord.deleted) { + // Purge from deleted table. + deleteGUID(origRecord.guid); + insert(newPasswordRecord); + } else { + int updated = context.getContentResolver().update(BrowserContractHelpers.PASSWORDS_CONTENT_URI, cv, WHERE_GUID_IS, args); + if (updated != 1) { + Logger.warn(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + origPasswordRecord.guid); + } + } + + return newRecord; + } + + // When replacing a record, propagate the times. + private static void propagateTimes(PasswordRecord toRecord, PasswordRecord fromRecord) { + toRecord.timePasswordChanged = now(); + toRecord.timeCreated = fromRecord.timeCreated; + toRecord.timeLastUsed = fromRecord.timeLastUsed; + toRecord.timesUsed = fromRecord.timesUsed; + } + + private static String[] getAllColumns() { + return BrowserContractHelpers.PasswordColumns; + } + + private static String[] getAllDeletedColumns() { + return BrowserContractHelpers.DeletedColumns; + } + + /** + * Constructs the DB query string for entry age for deleted records. + * + * @param timestamp + * @return String DB query string for dates to fetch. + */ + private static String dateModifiedWhereDeleted(long timestamp) { + return DeletedColumns.TIME_DELETED + " >= " + Long.toString(timestamp); + } + + /** + * Constructs the DB query string for entry age for (undeleted) records. + * + * @param timestamp + * @return String DB query string for dates to fetch. + */ + private static String dateModifiedWhere(long timestamp) { + return Passwords.TIME_PASSWORD_CHANGED + " >= " + Long.toString(timestamp); + } + + + /** + * Fetch from the cursor with the given parameters, invoking + * delegate callbacks and closing the cursor. + * Returns true on success, false if failure was signaled. + * + * @param cursor + fetch* cursor. + * @param deleted + * true if using deleted table, false when using data table. + * @param delegate + * FetchRecordsDelegate to process records. + */ + private static boolean fetchAndCloseCursorDeleted(final Cursor cursor, + final boolean deleted, + final RecordFilter filter, + final RepositorySessionFetchRecordsDelegate delegate) { + if (cursor == null) { + return true; + } + + try { + while (cursor.moveToNext()) { + Record r = deleted ? deletedPasswordRecordFromCursor(cursor) : passwordRecordFromCursor(cursor); + if (r != null) { + if (filter == null || !filter.excludeRecord(r)) { + Logger.debug(LOG_TAG, "Processing record " + r.guid); + delegate.onFetchedRecord(r); + } else { + Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid); + } + } + } + } catch (Exception e) { + Logger.error(LOG_TAG, "Exception in fetch."); + delegate.onFetchFailed(e, null); + return false; + } finally { + cursor.close(); + } + + return true; + } + + private PasswordRecord retrieveByGUID(String guid) throws NullCursorException, RemoteException { + final String[] guidArg = new String[] { guid }; + + // Check data table. + final Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".store", BrowserContractHelpers.PasswordColumns, WHERE_GUID_IS, guidArg, null); + try { + if (data.moveToFirst()) { + return passwordRecordFromCursor(data); + } + } finally { + data.close(); + } + + // Check deleted table. + final Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".retrieveByGuid", BrowserContractHelpers.DeletedColumns, WHERE_DELETED_GUID_IS, guidArg, null); + try { + if (deleted.moveToFirst()) { + return deletedPasswordRecordFromCursor(deleted); + } + } finally { + deleted.close(); + } + + return null; + } + + private static final String WHERE_RECORD_DATA = + Passwords.HOSTNAME + " = ? AND " + + Passwords.HTTP_REALM + " = ? AND " + + Passwords.FORM_SUBMIT_URL + " = ? AND " + + Passwords.USERNAME_FIELD + " = ? AND " + + Passwords.PASSWORD_FIELD + " = ?"; + + private PasswordRecord findExistingRecord(PasswordRecord record) throws NullCursorException, RemoteException { + PasswordRecord foundRecord = null; + Cursor cursor = null; + // Only check the data table. + // We can't encrypt username directly for query, so run a more general query and then filter. + final String[] whereArgs = new String[] { + record.hostname, + record.httpRealm, + record.formSubmitURL, + record.usernameField, + record.passwordField + }; + + try { + cursor = passwordsHelper.safeQuery(passwordsProvider, ".findRecord", getAllColumns(), WHERE_RECORD_DATA, whereArgs, null); + while (cursor.moveToNext()) { + foundRecord = passwordRecordFromCursor(cursor); + + // We don't directly query for username because the + // username/password values are encrypted in the db. + // We don't have the keys for encrypting our query, + // so we run a more general query and then filter + // the returned records for a matching username. + Logger.pii(LOG_TAG, "Checking incoming [" + record.encryptedUsername + "] to [" + foundRecord.encryptedUsername + "]"); + if (record.encryptedUsername.equals(foundRecord.encryptedUsername)) { + Logger.trace(LOG_TAG, "Found matching record: " + foundRecord.guid); + return foundRecord; + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + Logger.debug(LOG_TAG, "No matching records, returning null."); + return null; + } + + private void storeRecordDeletion(Record record) { + try { + deleteGUID(record.guid); + } catch (RemoteException e) { + Logger.error(LOG_TAG, "RemoteException in password delete."); + delegate.onRecordStoreFailed(e, record.guid); + return; + } + delegate.onRecordStoreSucceeded(record.guid); + } + + /** + * Make a PasswordRecord from a Cursor. + * @param cur + * Cursor from query. + * @param deleted + * true if creating a deleted Record, false if otherwise. + * @return + * PasswordRecord populated from Cursor. + */ + private static PasswordRecord passwordRecordFromCursor(Cursor cur) { + if (cur.isAfterLast()) { + return null; + } + String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.GUID); + long lastModified = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_PASSWORD_CHANGED); + + PasswordRecord rec = new PasswordRecord(guid, COLLECTION, lastModified, false); + rec.id = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ID); + rec.hostname = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.HOSTNAME); + rec.httpRealm = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.HTTP_REALM); + rec.formSubmitURL = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.FORM_SUBMIT_URL); + rec.usernameField = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.USERNAME_FIELD); + rec.passwordField = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.PASSWORD_FIELD); + rec.encType = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ENC_TYPE); + + // TODO decryption of username/password here (Bug 711636) + rec.encryptedUsername = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ENCRYPTED_USERNAME); + rec.encryptedPassword = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ENCRYPTED_PASSWORD); + + rec.timeCreated = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_CREATED); + rec.timeLastUsed = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_LAST_USED); + rec.timePasswordChanged = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_PASSWORD_CHANGED); + rec.timesUsed = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIMES_USED); + return rec; + } + + private static PasswordRecord deletedPasswordRecordFromCursor(Cursor cur) { + if (cur.isAfterLast()) { + return null; + } + String guid = RepoUtils.getStringFromCursor(cur, DeletedColumns.GUID); + long lastModified = RepoUtils.getLongFromCursor(cur, DeletedColumns.TIME_DELETED); + PasswordRecord rec = new PasswordRecord(guid, COLLECTION, lastModified, true); + rec.androidID = RepoUtils.getLongFromCursor(cur, DeletedColumns.ID); + return rec; + } + + private static ContentValues getContentValues(Record record) { + PasswordRecord rec = (PasswordRecord) record; + + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.Passwords.GUID, rec.guid); + cv.put(BrowserContract.Passwords.HOSTNAME, rec.hostname); + cv.put(BrowserContract.Passwords.HTTP_REALM, rec.httpRealm); + cv.put(BrowserContract.Passwords.FORM_SUBMIT_URL, rec.formSubmitURL); + cv.put(BrowserContract.Passwords.USERNAME_FIELD, rec.usernameField); + cv.put(BrowserContract.Passwords.PASSWORD_FIELD, rec.passwordField); + + // TODO Do encryption of username/password here. Bug 711636 + cv.put(BrowserContract.Passwords.ENC_TYPE, rec.encType); + cv.put(BrowserContract.Passwords.ENCRYPTED_USERNAME, rec.encryptedUsername); + cv.put(BrowserContract.Passwords.ENCRYPTED_PASSWORD, rec.encryptedPassword); + + cv.put(BrowserContract.Passwords.TIME_CREATED, rec.timeCreated); + cv.put(BrowserContract.Passwords.TIME_LAST_USED, rec.timeLastUsed); + cv.put(BrowserContract.Passwords.TIME_PASSWORD_CHANGED, rec.timePasswordChanged); + cv.put(BrowserContract.Passwords.TIMES_USED, rec.timesUsed); + return cv; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java new file mode 100644 index 000000000..9c29953f8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java @@ -0,0 +1,290 @@ +/* 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.repositories.android; + +import android.content.ContentProviderClient; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.RemoteException; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.domain.ClientRecord; +import org.mozilla.gecko.sync.repositories.domain.HistoryRecord; + +import java.io.IOException; + +public class RepoUtils { + + private static final String LOG_TAG = "RepoUtils"; + + /** + * A helper class for monotonous SQL querying. Does timing and logging, + * offers a utility to throw on a null cursor. + * + * @author rnewman + * + */ + public static class QueryHelper { + private final Context context; + private final Uri uri; + private final String tag; + + public QueryHelper(Context context, Uri uri, String tag) { + this.context = context; + this.uri = uri; + this.tag = tag; + } + + // For ContentProvider queries. + public Cursor safeQuery(String label, String[] projection, + String selection, String[] selectionArgs, String sortOrder) throws NullCursorException { + long queryStart = android.os.SystemClock.uptimeMillis(); + Cursor c = context.getContentResolver().query(uri, projection, selection, selectionArgs, sortOrder); + return checkAndLogCursor(label, queryStart, c); + } + + public Cursor safeQuery(String[] projection, String selection, String[] selectionArgs, String sortOrder) throws NullCursorException { + return this.safeQuery(null, projection, selection, selectionArgs, sortOrder); + } + + // For ContentProviderClient queries. + public Cursor safeQuery(ContentProviderClient client, String label, String[] projection, + String selection, String[] selectionArgs, String sortOrder) throws NullCursorException, RemoteException { + long queryStart = android.os.SystemClock.uptimeMillis(); + Cursor c = client.query(uri, projection, selection, selectionArgs, sortOrder); + return checkAndLogCursor(label, queryStart, c); + } + + // For SQLiteOpenHelper queries. + public Cursor safeQuery(SQLiteDatabase db, String label, String table, String[] columns, + String selection, String[] selectionArgs, + String groupBy, String having, String orderBy, String limit) throws NullCursorException { + long queryStart = android.os.SystemClock.uptimeMillis(); + Cursor c = db.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit); + return checkAndLogCursor(label, queryStart, c); + } + + public Cursor safeQuery(SQLiteDatabase db, String label, String table, String[] columns, + String selection, String[] selectionArgs) throws NullCursorException { + return safeQuery(db, label, table, columns, selection, selectionArgs, null, null, null, null); + } + + private Cursor checkAndLogCursor(String label, long queryStart, Cursor c) throws NullCursorException { + long queryEnd = android.os.SystemClock.uptimeMillis(); + String logLabel = (label == null) ? tag : (tag + label); + RepoUtils.queryTimeLogger(logLabel, queryStart, queryEnd); + return checkNullCursor(logLabel, c); + } + + public Cursor checkNullCursor(String logLabel, Cursor cursor) throws NullCursorException { + if (cursor == null) { + Logger.error(tag, "Got null cursor exception in " + logLabel); + throw new NullCursorException(null); + } + return cursor; + } + } + + /** + * This method exists because the behavior of <code>cur.getString()</code> is undefined + * when the value in the database is <code>NULL</code>. + * This method will return <code>null</code> in that case. + */ + public static String optStringFromCursor(final Cursor cur, final String colId) { + final int col = cur.getColumnIndex(colId); + if (cur.isNull(col)) { + return null; + } + return cur.getString(col); + } + + /** + * The behavior of this method when the value in the database is <code>NULL</code> is + * determined by the implementation of the {@link Cursor}. + */ + public static String getStringFromCursor(final Cursor cur, final String colId) { + // TODO: getColumnIndexOrThrow? + // TODO: don't look up columns by name! + return cur.getString(cur.getColumnIndex(colId)); + } + + public static long getLongFromCursor(Cursor cur, String colId) { + return cur.getLong(cur.getColumnIndex(colId)); + } + + public static int getIntFromCursor(Cursor cur, String colId) { + return cur.getInt(cur.getColumnIndex(colId)); + } + + public static JSONArray getJSONArrayFromCursor(Cursor cur, String colId) { + String jsonArrayAsString = getStringFromCursor(cur, colId); + if (jsonArrayAsString == null) { + return new JSONArray(); + } + try { + return ExtendedJSONObject.parseJSONArray(getStringFromCursor(cur, colId)); + } catch (NonArrayJSONException e) { + Logger.error(LOG_TAG, "JSON parsing error for " + colId, e); + return null; + } catch (IOException e) { + Logger.error(LOG_TAG, "JSON parsing error for " + colId, e); + return null; + } + } + + /** + * Return true if the provided URI is non-empty and acceptable to Fennec + * (i.e., not an undesirable scheme). + * + * This code is pilfered from Fennec, which pilfered from Places. + */ + public static boolean isValidHistoryURI(String uri) { + if (uri == null || uri.length() == 0) { + return false; + } + + // First, check the most common cases (HTTP, HTTPS) to avoid most of the work. + if (uri.startsWith("http:") || uri.startsWith("https:")) { + return true; + } + + String scheme = Uri.parse(uri).getScheme(); + if (scheme == null) { + return false; + } + + // Now check for all bad things. + if (scheme.equals("about") || + scheme.equals("imap") || + scheme.equals("news") || + scheme.equals("mailbox") || + scheme.equals("moz-anno") || + scheme.equals("view-source") || + scheme.equals("chrome") || + scheme.equals("resource") || + scheme.equals("data") || + scheme.equals("wyciwyg") || + scheme.equals("javascript")) { + return false; + } + + return true; + } + + /** + * Create a HistoryRecord object from a cursor row. + * + * @return a HistoryRecord, or null if this row would produce + * an invalid record (e.g., with a null URI or no visits). + */ + public static HistoryRecord historyFromMirrorCursor(Cursor cur) { + final String guid = getStringFromCursor(cur, BrowserContract.SyncColumns.GUID); + if (guid == null) { + Logger.debug(LOG_TAG, "Skipping history record with null GUID."); + return null; + } + + final String historyURI = getStringFromCursor(cur, BrowserContract.History.URL); + if (!isValidHistoryURI(historyURI)) { + Logger.debug(LOG_TAG, "Skipping history record " + guid + " with unwanted/invalid URI " + historyURI); + return null; + } + + final long visitCount = getLongFromCursor(cur, BrowserContract.History.VISITS); + if (visitCount <= 0) { + Logger.debug(LOG_TAG, "Skipping history record " + guid + " with <= 0 visit count."); + return null; + } + + final String collection = "history"; + final long lastModified = getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED); + final boolean deleted = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1; + + final HistoryRecord rec = new HistoryRecord(guid, collection, lastModified, deleted); + + rec.androidID = getLongFromCursor(cur, BrowserContract.History._ID); + rec.fennecDateVisited = getLongFromCursor(cur, BrowserContract.History.DATE_LAST_VISITED); + rec.fennecVisitCount = visitCount; + rec.histURI = historyURI; + rec.title = getStringFromCursor(cur, BrowserContract.History.TITLE); + + return logHistory(rec); + } + + private static HistoryRecord logHistory(HistoryRecord rec) { + try { + Logger.debug(LOG_TAG, "Returning history record " + rec.guid + " (" + rec.androidID + ")"); + Logger.debug(LOG_TAG, "> Visited: " + rec.fennecDateVisited); + Logger.debug(LOG_TAG, "> Visits: " + rec.fennecVisitCount); + if (Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(LOG_TAG, "> Title: " + rec.title); + Logger.pii(LOG_TAG, "> URI: " + rec.histURI); + } + } catch (Exception e) { + Logger.debug(LOG_TAG, "Exception logging history record " + rec, e); + } + return rec; + } + + public static void logClient(ClientRecord rec) { + if (Logger.shouldLogVerbose(LOG_TAG)) { + Logger.trace(LOG_TAG, "Returning client record " + rec.guid + " (" + rec.androidID + ")"); + Logger.trace(LOG_TAG, "Client Name: " + rec.name); + Logger.trace(LOG_TAG, "Client Type: " + rec.type); + Logger.trace(LOG_TAG, "Last Modified: " + rec.lastModified); + Logger.trace(LOG_TAG, "Deleted: " + rec.deleted); + } + } + + public static void queryTimeLogger(String methodCallingQuery, long queryStart, long queryEnd) { + long elapsedTime = queryEnd - queryStart; + Logger.debug(LOG_TAG, "Query timer: " + methodCallingQuery + " took " + elapsedTime + "ms."); + } + + public static boolean stringsEqual(String a, String b) { + // Check for nulls + if (a == b) return true; + if (a == null && b != null) return false; + if (a != null && b == null) return false; + + return a.equals(b); + } + + public static String computeSQLLongInClause(long[] items, String field) { + final StringBuilder builder = new StringBuilder(field); + builder.append(" IN ("); + int i = 0; + for (; i < items.length - 1; ++i) { + builder.append(items[i]); + builder.append(", "); + } + if (i < items.length) { + builder.append(items[i]); + } + builder.append(")"); + return builder.toString(); + } + + public static String computeSQLInClause(int items, String field) { + final StringBuilder builder = new StringBuilder(field); + builder.append(" IN ("); + int i = 0; + for (; i < items - 1; ++i) { + builder.append("?, "); + } + if (i < items) { + builder.append("?"); + } + builder.append(")"); + return builder.toString(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java new file mode 100644 index 000000000..9ba784759 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java @@ -0,0 +1,130 @@ +/* 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.repositories.android; + +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.support.annotation.NonNull; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.db.BrowserContract.Visits; + +/** + * This class is used by History Sync code (see <code>AndroidBrowserHistoryDataAccessor</code> and <code>AndroidBrowserHistoryRepositorySession</code>, + * and provides utility functions for working with history visits. Primarily we're either inserting visits + * into local database based on data received from Sync, or we're preparing local visits for upload into Sync. + */ +public class VisitsHelper { + public static final boolean DEFAULT_IS_LOCAL_VALUE = false; + public static final String SYNC_TYPE_KEY = "type"; + public static final String SYNC_DATE_KEY = "date"; + + /** + * Returns a list of ContentValues of visits ready for insertion for a provided History GUID. + * Visits must have data and type. See <code>getVisitContentValues</code>. + * + * @param guid History GUID to use when inserting visit records + * @param visits <code>JSONArray</code> list of (date, type) tuples for visits + * @return visits ready for insertion + */ + public static ContentValues[] getVisitsContentValues(@NonNull String guid, @NonNull JSONArray visits) { + final ContentValues[] visitsToStore = new ContentValues[visits.size()]; + final int visitCount = visits.size(); + + if (visitCount == 0) { + return visitsToStore; + } + + for (int i = 0; i < visitCount; i++) { + visitsToStore[i] = getVisitContentValues( + guid, (JSONObject) visits.get(i), DEFAULT_IS_LOCAL_VALUE); + } + return visitsToStore; + } + + /** + * Maps up to <code>limit</code> visits for a given history GUID to an array of JSONObjects with "date" and "type" keys + * + * @param contentClient <code>ContentProviderClient</code> to use for querying Visits table + * @param guid History GUID for which to return visits + * @param limit Will return at most this number of visits + * @return <code>JSONArray</code> of all visits found for given History GUID + */ + public static JSONArray getRecentHistoryVisitsForGUID(@NonNull ContentProviderClient contentClient, + @NonNull String guid, int limit) throws RemoteException { + final JSONArray visits = new JSONArray(); + + final Cursor cursor = contentClient.query( + visitsUriWithLimit(limit), + new String[] {Visits.VISIT_TYPE, Visits.DATE_VISITED}, + Visits.HISTORY_GUID + " = ?", + new String[] {guid}, null); + if (cursor == null) { + return visits; + } + try { + if (!cursor.moveToFirst()) { + return visits; + } + + final int dateVisitedCol = cursor.getColumnIndexOrThrow(Visits.DATE_VISITED); + final int visitTypeCol = cursor.getColumnIndexOrThrow(Visits.VISIT_TYPE); + + while (!cursor.isAfterLast()) { + insertTupleIntoVisitsUnchecked(visits, + cursor.getLong(visitTypeCol), + cursor.getLong(dateVisitedCol) + ); + cursor.moveToNext(); + } + } finally { + cursor.close(); + } + + return visits; + } + + /** + * Constructs <code>ContentValues</code> object for a visit based on passed in parameters. + * + * @param visit <code>JSONObject</code> containing visit type and visit date keys for the visit + * @param guid History GUID with with to associate this visit + * @param isLocal Whether or not to mark this visit as local + * @return <code>ContentValues</code> with all visit values necessary for database insertion + * @throws IllegalArgumentException if visit object is missing date or type keys + */ + public static ContentValues getVisitContentValues(@NonNull String guid, @NonNull JSONObject visit, boolean isLocal) { + if (!visit.containsKey(SYNC_DATE_KEY) || !visit.containsKey(SYNC_TYPE_KEY)) { + throw new IllegalArgumentException("Visit missing required keys"); + } + + final ContentValues cv = new ContentValues(); + cv.put(Visits.HISTORY_GUID, guid); + cv.put(Visits.IS_LOCAL, isLocal ? Visits.VISIT_IS_LOCAL : Visits.VISIT_IS_REMOTE); + cv.put(Visits.VISIT_TYPE, (Long) visit.get(SYNC_TYPE_KEY)); + cv.put(Visits.DATE_VISITED, (Long) visit.get(SYNC_DATE_KEY)); + + return cv; + } + + @SuppressWarnings("unchecked") + private static void insertTupleIntoVisitsUnchecked(@NonNull final JSONArray visits, @NonNull Long type, @NonNull Long date) { + final JSONObject visit = new JSONObject(); + visit.put(SYNC_TYPE_KEY, type); + visit.put(SYNC_DATE_KEY, date); + visits.add(visit); + } + + private static Uri visitsUriWithLimit(int limit) { + return BrowserContractHelpers.VISITS_CONTENT_URI + .buildUpon() + .appendQueryParameter("limit", Integer.toString(limit)) + .build(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java new file mode 100644 index 000000000..f292600e4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java @@ -0,0 +1,41 @@ +/* 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.repositories.delegates; + +import org.mozilla.gecko.sync.ThreadPool; +import org.mozilla.gecko.sync.repositories.RepositorySession; + +public abstract class DeferrableRepositorySessionCreationDelegate implements RepositorySessionCreationDelegate { + @Override + public RepositorySessionCreationDelegate deferredCreationDelegate() { + final RepositorySessionCreationDelegate self = this; + return new RepositorySessionCreationDelegate() { + + // TODO: rewrite to use ExecutorService. + @Override + public void onSessionCreated(final RepositorySession session) { + ThreadPool.run(new Runnable() { + @Override + public void run() { + self.onSessionCreated(session); + }}); + } + + @Override + public void onSessionCreateFailed(final Exception ex) { + ThreadPool.run(new Runnable() { + @Override + public void run() { + self.onSessionCreateFailed(ex); + }}); + } + + @Override + public RepositorySessionCreationDelegate deferredCreationDelegate() { + return this; + } + }; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java new file mode 100644 index 000000000..1ccdcce19 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java @@ -0,0 +1,46 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.repositories.RepositorySession; + +public class DeferredRepositorySessionBeginDelegate implements RepositorySessionBeginDelegate { + private final RepositorySessionBeginDelegate inner; + private final ExecutorService executor; + public DeferredRepositorySessionBeginDelegate(final RepositorySessionBeginDelegate inner, final ExecutorService executor) { + this.inner = inner; + this.executor = executor; + } + + @Override + public void onBeginSucceeded(final RepositorySession session) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onBeginSucceeded(session); + } + }); + } + + @Override + public void onBeginFailed(final Exception ex) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onBeginFailed(ex); + } + }); + } + + @Override + public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService newExecutor) { + if (newExecutor == executor) { + return this; + } + throw new IllegalArgumentException("Can't re-defer this delegate."); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java new file mode 100644 index 000000000..1178d9b5b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java @@ -0,0 +1,56 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +public class DeferredRepositorySessionFetchRecordsDelegate implements RepositorySessionFetchRecordsDelegate { + private final RepositorySessionFetchRecordsDelegate inner; + private final ExecutorService executor; + public DeferredRepositorySessionFetchRecordsDelegate(final RepositorySessionFetchRecordsDelegate inner, final ExecutorService executor) { + this.inner = inner; + this.executor = executor; + } + + @Override + public void onFetchedRecord(final Record record) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onFetchedRecord(record); + } + }); + } + + @Override + public void onFetchFailed(final Exception ex, final Record record) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onFetchFailed(ex, record); + } + }); + } + + @Override + public void onFetchCompleted(final long fetchEnd) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onFetchCompleted(fetchEnd); + } + }); + } + + @Override + public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService newExecutor) { + if (newExecutor == executor) { + return this; + } + throw new IllegalArgumentException("Can't re-defer this delegate."); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java new file mode 100644 index 000000000..dbe7e4327 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java @@ -0,0 +1,51 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; + +public class DeferredRepositorySessionFinishDelegate implements + RepositorySessionFinishDelegate { + protected final ExecutorService executor; + protected final RepositorySessionFinishDelegate inner; + + public DeferredRepositorySessionFinishDelegate(RepositorySessionFinishDelegate inner, + ExecutorService executor) { + this.executor = executor; + this.inner = inner; + } + + @Override + public void onFinishSucceeded(final RepositorySession session, + final RepositorySessionBundle bundle) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onFinishSucceeded(session, bundle); + } + }); + } + + @Override + public void onFinishFailed(final Exception ex) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onFinishFailed(ex); + } + }); + } + + @Override + public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService newExecutor) { + if (newExecutor == executor) { + return this; + } + throw new IllegalArgumentException("Can't re-defer this delegate."); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java new file mode 100644 index 000000000..2f659c733 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java @@ -0,0 +1,57 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +public class DeferredRepositorySessionStoreDelegate implements + RepositorySessionStoreDelegate { + protected final RepositorySessionStoreDelegate inner; + protected final ExecutorService executor; + + public DeferredRepositorySessionStoreDelegate( + RepositorySessionStoreDelegate inner, ExecutorService executor) { + this.inner = inner; + this.executor = executor; + } + + @Override + public void onRecordStoreSucceeded(final String guid) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onRecordStoreSucceeded(guid); + } + }); + } + + @Override + public void onRecordStoreFailed(final Exception ex, final String guid) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onRecordStoreFailed(ex, guid); + } + }); + } + + @Override + public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService newExecutor) { + if (newExecutor == executor) { + return this; + } + throw new IllegalArgumentException("Can't re-defer this delegate."); + } + + @Override + public void onStoreCompleted(final long storeEnd) { + executor.execute(new Runnable() { + @Override + public void run() { + inner.onStoreCompleted(storeEnd); + } + }); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java new file mode 100644 index 000000000..f5853647f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java @@ -0,0 +1,23 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.repositories.RepositorySession; + +/** + * One of these two methods is guaranteed to be called after session.begin() is + * invoked (possibly during the invocation). The callback will be invoked prior + * to any other RepositorySession callbacks. + * + * @author rnewman + * + */ +public interface RepositorySessionBeginDelegate { + public void onBeginFailed(Exception ex); + public void onBeginSucceeded(RepositorySession session); + public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java new file mode 100644 index 000000000..139c561a0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java @@ -0,0 +1,12 @@ +/* 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.repositories.delegates; + +import org.mozilla.gecko.sync.repositories.Repository; + +public interface RepositorySessionCleanDelegate { + public void onCleaned(Repository repo); + public void onCleanFailed(Repository repo, Exception ex); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java new file mode 100644 index 000000000..6ad4991c3 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java @@ -0,0 +1,15 @@ +/* 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.repositories.delegates; + +import org.mozilla.gecko.sync.repositories.RepositorySession; + +// Used to provide the sessionCallback and storeCallback +// mechanism to repository instances. +public interface RepositorySessionCreationDelegate { + public void onSessionCreateFailed(Exception ex); + public void onSessionCreated(RepositorySession session); + public RepositorySessionCreationDelegate deferredCreationDelegate(); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java new file mode 100644 index 000000000..589a093dc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java @@ -0,0 +1,27 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +public interface RepositorySessionFetchRecordsDelegate { + public void onFetchFailed(Exception ex, Record record); + public void onFetchedRecord(Record record); + + /** + * Called when all records in this fetch have been returned. + * + * @param fetchEnd + * A millisecond-resolution timestamp indicating the *remote* timestamp + * at the end of the range of records. Usually this is the timestamp at + * which the request was received. + * E.g., the (normalized) value of the X-Weave-Timestamp header. + */ + public void onFetchCompleted(final long fetchEnd); + + public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java new file mode 100644 index 000000000..40296dd4f --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java @@ -0,0 +1,16 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; + +public interface RepositorySessionFinishDelegate { + public void onFinishFailed(Exception ex); + public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle); + public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java new file mode 100644 index 000000000..4f82768f1 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java @@ -0,0 +1,10 @@ +/* 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.repositories.delegates; + +public interface RepositorySessionGuidsSinceDelegate { + public void onGuidsSinceFailed(Exception ex); + public void onGuidsSinceSucceeded(String[] guids); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java new file mode 100644 index 000000000..01e44c3ae --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java @@ -0,0 +1,23 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +/** + * These methods *must* be invoked asynchronously. Use deferredStoreDelegate if you + * need help doing this. + * + * @author rnewman + * + */ +public interface RepositorySessionStoreDelegate { + public void onRecordStoreFailed(Exception ex, String recordGuid); + + // Called with a GUID when store has succeeded. + public void onRecordStoreSucceeded(String guid); + public void onStoreCompleted(long storeEnd); + public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java new file mode 100644 index 000000000..cc8830729 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java @@ -0,0 +1,13 @@ +/* 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.repositories.delegates; + +import java.util.concurrent.ExecutorService; + +public interface RepositorySessionWipeDelegate { + public void onWipeFailed(Exception ex); + public void onWipeSucceeded(); + public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java new file mode 100644 index 000000000..27b8e7151 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java @@ -0,0 +1,488 @@ +/* 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.repositories.domain; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Map; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +/** + * Covers the fields used by all bookmark objects. + * @author rnewman + * + */ +public class BookmarkRecord extends Record { + public static final String PLACES_URI_PREFIX = "places:"; + + private static final String LOG_TAG = "BookmarkRecord"; + + public static final String COLLECTION_NAME = "bookmarks"; + public static final long BOOKMARKS_TTL = -1; // Never ttl bookmarks. + + public BookmarkRecord(String guid, String collection, long lastModified, boolean deleted) { + super(guid, collection, lastModified, deleted); + this.ttl = BOOKMARKS_TTL; + } + public BookmarkRecord(String guid, String collection, long lastModified) { + this(guid, collection, lastModified, false); + } + public BookmarkRecord(String guid, String collection) { + this(guid, collection, 0, false); + } + public BookmarkRecord(String guid) { + this(guid, COLLECTION_NAME, 0, false); + } + public BookmarkRecord() { + this(Utils.generateGuid(), COLLECTION_NAME, 0, false); + } + + // Note: redundant accessors are evil. We're all grownups; let's just use + // public fields. + public String title; + public String bookmarkURI; + public String description; + public String keyword; + public String parentID; + public String parentName; + public long androidParentID; + public String type; + public long androidPosition; + + public JSONArray children; + public JSONArray tags; + + @Override + public String toString() { + return "#<Bookmark " + guid + " (" + androidID + "), parent " + + parentID + "/" + androidParentID + "/" + parentName + ">"; + } + + // Oh God, this is terribly thread-unsafe. These record objects should be immutable. + @SuppressWarnings("unchecked") + protected JSONArray copyChildren() { + if (this.children == null) { + return null; + } + JSONArray children = new JSONArray(); + children.addAll(this.children); + return children; + } + + @SuppressWarnings("unchecked") + protected JSONArray copyTags() { + if (this.tags == null) { + return null; + } + JSONArray tags = new JSONArray(); + tags.addAll(this.tags); + return tags; + } + + @Override + public Record copyWithIDs(String guid, long androidID) { + BookmarkRecord out = new BookmarkRecord(guid, this.collection, this.lastModified, this.deleted); + out.androidID = androidID; + out.sortIndex = this.sortIndex; + out.ttl = this.ttl; + + // Copy BookmarkRecord fields. + out.title = this.title; + out.bookmarkURI = this.bookmarkURI; + out.description = this.description; + out.keyword = this.keyword; + out.parentID = this.parentID; + out.parentName = this.parentName; + out.androidParentID = this.androidParentID; + out.type = this.type; + out.androidPosition = this.androidPosition; + + out.children = this.copyChildren(); + out.tags = this.copyTags(); + + return out; + } + + public boolean isBookmark() { + if (type == null) { + return false; + } + return type.equals("bookmark"); + } + + public boolean isFolder() { + if (type == null) { + return false; + } + return type.equals("folder"); + } + + public boolean isLivemark() { + if (type == null) { + return false; + } + return type.equals("livemark"); + } + + public boolean isSeparator() { + if (type == null) { + return false; + } + return type.equals("separator"); + } + + public boolean isMicrosummary() { + if (type == null) { + return false; + } + return type.equals("microsummary"); + } + + public boolean isQuery() { + if (type == null) { + return false; + } + return type.equals("query"); + } + + /** + * Return true if this record should have the Sync fields + * of a bookmark, microsummary, or query. + */ + private boolean isBookmarkIsh() { + if (type == null) { + return false; + } + return type.equals("bookmark") || + type.equals("microsummary") || + type.equals("query"); + } + + @Override + protected void initFromPayload(ExtendedJSONObject payload) { + this.type = payload.getString("type"); + this.title = payload.getString("title"); + this.description = payload.getString("description"); + this.parentID = payload.getString("parentid"); + this.parentName = payload.getString("parentName"); + + if (isFolder()) { + try { + this.children = payload.getArray("children"); + } catch (NonArrayJSONException e) { + Logger.error(LOG_TAG, "Got non-array children in bookmark record " + this.guid, e); + // Let's see if we can recover later by using the parentid pointers. + this.children = new JSONArray(); + } + return; + } + + final String bmkUri = payload.getString("bmkUri"); + + // bookmark, microsummary, query. + if (isBookmarkIsh()) { + this.keyword = payload.getString("keyword"); + try { + this.tags = payload.getArray("tags"); + } catch (NonArrayJSONException e) { + Logger.warn(LOG_TAG, "Got non-array tags in bookmark record " + this.guid, e); + this.tags = new JSONArray(); + } + } + + if (isBookmark()) { + this.bookmarkURI = bmkUri; + return; + } + + if (isLivemark()) { + String siteUri = payload.getString("siteUri"); + String feedUri = payload.getString("feedUri"); + this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri, + "siteUri", siteUri, + "feedUri", feedUri); + return; + } + if (isQuery()) { + String queryId = payload.getString("queryId"); + String folderName = payload.getString("folderName"); + this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri, + "queryId", queryId, + "folderName", folderName); + return; + } + if (isMicrosummary()) { + String generatorUri = payload.getString("generatorUri"); + String staticTitle = payload.getString("staticTitle"); + this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri, + "generatorUri", generatorUri, + "staticTitle", staticTitle); + return; + } + if (isSeparator()) { + Object p = payload.get("pos"); + if (p instanceof Long) { + this.androidPosition = (Long) p; + } else if (p instanceof String) { + try { + this.androidPosition = Long.parseLong((String) p, 10); + } catch (NumberFormatException e) { + return; + } + } else { + Logger.warn(LOG_TAG, "Unsupported position value " + p); + return; + } + String pos = String.valueOf(this.androidPosition); + this.bookmarkURI = encodeUnsupportedTypeURI(null, "pos", pos, null, null); + return; + } + } + + @Override + protected void populatePayload(ExtendedJSONObject payload) { + putPayload(payload, "type", this.type); + putPayload(payload, "title", this.title); + putPayload(payload, "description", this.description); + putPayload(payload, "parentid", this.parentID); + putPayload(payload, "parentName", this.parentName); + putPayload(payload, "keyword", this.keyword); + + if (isFolder()) { + payload.put("children", this.children); + return; + } + + // bookmark, microsummary, query. + if (isBookmarkIsh()) { + if (isBookmark()) { + payload.put("bmkUri", bookmarkURI); + } + + if (isQuery()) { + Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); + putPayload(payload, "queryId", parts.get("queryId"), true); + putPayload(payload, "folderName", parts.get("folderName"), true); + putPayload(payload, "bmkUri", parts.get("uri")); + return; + } + + if (this.tags != null) { + payload.put("tags", this.tags); + } + + putPayload(payload, "keyword", this.keyword); + return; + } + + if (isLivemark()) { + Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); + putPayload(payload, "siteUri", parts.get("siteUri")); + putPayload(payload, "feedUri", parts.get("feedUri")); + return; + } + if (isMicrosummary()) { + Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); + putPayload(payload, "generatorUri", parts.get("generatorUri")); + putPayload(payload, "staticTitle", parts.get("staticTitle")); + return; + } + if (isSeparator()) { + Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); + String pos = parts.get("pos"); + if (pos == null) { + return; + } + try { + payload.put("pos", Long.parseLong(pos, 10)); + } catch (NumberFormatException e) { + return; + } + return; + } + } + + private void trace(String s) { + Logger.trace(LOG_TAG, s); + } + + @Override + public boolean equalPayloads(Object o) { + trace("Calling BookmarkRecord.equalPayloads."); + if (!(o instanceof BookmarkRecord)) { + return false; + } + + BookmarkRecord other = (BookmarkRecord) o; + if (!super.equalPayloads(other)) { + return false; + } + + if (!RepoUtils.stringsEqual(this.type, other.type)) { + return false; + } + + // Check children. + if (isFolder() && (this.children != other.children)) { + trace("BookmarkRecord.equals: this folder: " + this.title + ", " + this.guid); + trace("BookmarkRecord.equals: other: " + other.title + ", " + other.guid); + if (this.children == null && + other.children != null) { + trace("Records differ: one children array is null."); + return false; + } + if (this.children != null && + other.children == null) { + trace("Records differ: one children array is null."); + return false; + } + if (this.children.size() != other.children.size()) { + trace("Records differ: children arrays differ in size (" + + this.children.size() + " vs. " + other.children.size() + ")."); + return false; + } + + for (int i = 0; i < this.children.size(); i++) { + String child = (String) this.children.get(i); + if (!other.children.contains(child)) { + trace("Records differ: child " + child + " not found."); + return false; + } + } + } + + trace("Checking strings."); + return RepoUtils.stringsEqual(this.title, other.title) + && RepoUtils.stringsEqual(this.bookmarkURI, other.bookmarkURI) + && RepoUtils.stringsEqual(this.parentID, other.parentID) + && RepoUtils.stringsEqual(this.parentName, other.parentName) + && RepoUtils.stringsEqual(this.description, other.description) + && RepoUtils.stringsEqual(this.keyword, other.keyword) + && jsonArrayStringsEqual(this.tags, other.tags); + } + + // TODO: two records can be congruent if their child lists are different. + @Override + public boolean congruentWith(Object o) { + return this.equalPayloads(o) && + super.congruentWith(o); + } + + // Converts two JSONArrays to strings and checks if they are the same. + // This is only useful for stuff like tags where we aren't actually + // touching the data there (and therefore ordering won't change) + private boolean jsonArrayStringsEqual(JSONArray a, JSONArray b) { + // Check for nulls + if (a == b) return true; + if (a == null && b != null) return false; + if (a != null && b == null) return false; + return RepoUtils.stringsEqual(a.toJSONString(), b.toJSONString()); + } + + /** + * URL-encode the provided string. If the input is null, + * the empty string is returned. + * + * @param in the string to encode. + * @return a URL-encoded version of the input. + */ + protected static String encode(String in) { + if (in == null) { + return ""; + } + try { + return URLEncoder.encode(in, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Will never occur. + return null; + } + } + + /** + * Take the provided URI and two parameters, constructing a URI like + * + * places:uri=$uri&p1=$p1&p2=$p2 + * + * null values in either parameter or value result in the parameter being omitted. + */ + protected static String encodeUnsupportedTypeURI(String originalURI, String p1, String v1, String p2, String v2) { + StringBuilder b = new StringBuilder(PLACES_URI_PREFIX); + boolean previous = false; + if (originalURI != null) { + b.append("uri="); + b.append(encode(originalURI)); + previous = true; + } + if (p1 != null && v1 != null) { + if (previous) { + b.append("&"); + } + b.append(p1); + b.append("="); + b.append(encode(v1)); + previous = true; + } + if (p2 != null && v2 != null) { + if (previous) { + b.append("&"); + } + b.append(p2); + b.append("="); + b.append(encode(v2)); + previous = true; + } + return b.toString(); + } +} + + +/* +// Bookmark: +{cleartext: + {id: "l7p2xqOTMMXw", + type: "bookmark", + title: "Your Flight Status", + parentName: "mobile", + bmkUri: "http: //www.flightstats.com/go/Mobile/flightStatusByFlightProcess.do;jsessionid=13A6C8DCC9592AF141A43349040262CE.web3: 8009?utm_medium=cpc&utm_campaign=co-op&utm_source=airlineInformationAndStatus&id=212492593", + tags: [], + keyword: null, + description: null, + loadInSidebar: false, + parentid: "mobile"}, + data: {payload: {ciphertext: null}, + id: "l7p2xqOTMMXw", + sortindex: 107}, + collection: "bookmarks"} + +// Folder: +{cleartext: + {id: "mobile", + type: "folder", + parentName: "", + title: "mobile", + description: null, + children: ["1ROdlTuIoddD", "3Z_bMIHPSZQ8", "4mSDUuOo2iVB", "8aEdE9IIrJVr", + "9DzPTmkkZRDb", "Qwwb99HtVKsD", "s8tM36aGPKbq", "JMTi61hOO3JV", + "JQUDk0wSvYip", "LmVH-J1r3HLz", "NhgQlC5ykYGW", "OVanevUUaqO2", + "OtQVX0PMiWQj", "_GP5cF595iie", "fkRssjXSZDL3", "k7K_NwIA1Ya0", + "raox_QGzvqh1", "vXYL-xHjK06k", "QKHKUN6Dm-xv", "pmN2dYWT2MJ_", + "EVeO_J1SQiwL", "7N-qkepS7bec", "NIGa3ha-HVOE", "2Phv1I25wbuH", + "TTSIAH1fV0VE", "WOmZ8PfH39Da", "gDTXNg4m1AJZ", "ayI30OZslHbO", + "zSEs4O3n6CzQ", "oWTDR0gO2aWf", "wWHUoFaInXi9", "F7QTuVJDpsTM", + "FIboggegplk-", "G4HWrT5nfRYS", "MHA7y9bupDdv", "T_Ldzmj0Ttte", + "U9eYu3SxsE_U", "bk463Kl9IO_m", "brUfrqJjFNSR", "ccpawfWsD-bY", + "l7p2xqOTMMXw", "o-nSDKtXYln7"], + parentid: "places"}, + data: {payload: {ciphertext: null}, + id: "mobile", + sortindex: 1000000}, + collection: "bookmarks"} +*/ diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java new file mode 100644 index 000000000..edf7b288c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java @@ -0,0 +1,25 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.repositories.RecordFactory; + +/** + * Turns CryptoRecords into BookmarkRecords. + * + * @author rnewman + * + */ +public class BookmarkRecordFactory extends RecordFactory { + + @Override + public Record createRecord(Record record) { + BookmarkRecord r = new BookmarkRecord(); + r.initFromEnvelope((CryptoRecord) record); + return r; + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java new file mode 100644 index 000000000..0c513a4a0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java @@ -0,0 +1,231 @@ +/* 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.repositories.domain; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +public class ClientRecord extends Record { + private static final String LOG_TAG = "ClientRecord"; + + public static final String CLIENT_TYPE = "mobile"; + public static final String COLLECTION_NAME = "clients"; + public static final long CLIENTS_TTL = 21 * 24 * 60 * 60; // 21 days in seconds. + public static final String DEFAULT_CLIENT_NAME = "Default Name"; + + public static final String PROTOCOL_LEGACY_SYNC = "1.1"; + public static final String PROTOCOL_FXA_SYNC = "1.5"; + + /** + * Each of these fields is 'owned' by the client it represents. For example, + * the "version" field is the Firefox version of that client; some time after + * that client upgrades, it'll upload a new record with its new version. + * + * The only exception is for commands. When a command is sent to a client, the + * sender will download its current record, append the command to the + * "commands" array, and reupload the record. After processing, the recipient + * will reupload its record with an empty commands array. + * + * Note that the version, then, will remain the version of the recipient, as + * with the other descriptive fields. + */ + public String name = ClientRecord.DEFAULT_CLIENT_NAME; + public String type = ClientRecord.CLIENT_TYPE; + public String version = null; // Free-form string, optional. + public JSONArray commands; + public JSONArray protocols; + + // Optional fields. + // See <https://github.com/mozilla-services/docs/blob/master/source/sync/objectformats.rst#user-content-clients> + // for full formats. + // If a value isn't known, the field is omitted. + public String formfactor; // "phone", "largetablet", "smalltablet", "desktop", "laptop", "tv". + public String os; // One of "Android", "Darwin", "WINNT", "Linux", "iOS", "Firefox OS". + public String application; // Display name, E.g., "Firefox Beta" + public String appPackage; // E.g., "org.mozilla.firefox_beta" + public String device; // E.g., "HTC One" + public String fxaDeviceId; // E.g., "525b624eaaf1e40d21ec8997c3116ad8" + + public ClientRecord(String guid, String collection, long lastModified, boolean deleted) { + super(guid, collection, lastModified, deleted); + this.ttl = CLIENTS_TTL; + } + + public ClientRecord(String guid, String collection, long lastModified) { + this(guid, collection, lastModified, false); + } + + public ClientRecord(String guid, String collection) { + this(guid, collection, 0, false); + } + + public ClientRecord(String guid) { + this(guid, COLLECTION_NAME, 0, false); + } + + public ClientRecord() { + this(Utils.generateGuid(), COLLECTION_NAME, 0, false); + } + + @Override + protected void initFromPayload(ExtendedJSONObject payload) { + this.name = (String) payload.get("name"); + this.type = (String) payload.get("type"); + try { + this.version = (String) payload.get("version"); + } catch (Exception e) { + // Oh well. + } + + try { + commands = payload.getArray("commands"); + } catch (NonArrayJSONException e) { + Logger.debug(LOG_TAG, "Got non-array commands in client record " + guid, e); + commands = null; + } + + try { + protocols = payload.getArray("protocols"); + } catch (NonArrayJSONException e) { + Logger.debug(LOG_TAG, "Got non-array protocols in client record " + guid, e); + protocols = null; + } + + if (payload.containsKey("formfactor")) { + this.formfactor = payload.getString("formfactor"); + } + + if (payload.containsKey("os")) { + this.os = payload.getString("os"); + } + + if (payload.containsKey("application")) { + this.application = payload.getString("application"); + } + + if (payload.containsKey("appPackage")) { + this.appPackage = payload.getString("appPackage"); + } + + if (payload.containsKey("device")) { + this.device = payload.getString("device"); + } + + if (payload.containsKey("fxaDeviceId")) { + this.fxaDeviceId = payload.getString("fxaDeviceId"); + } + } + + @Override + protected void populatePayload(ExtendedJSONObject payload) { + putPayload(payload, "id", this.guid); + putPayload(payload, "name", this.name); + putPayload(payload, "type", this.type); + putPayload(payload, "version", this.version); + + if (this.commands != null) { + payload.put("commands", this.commands); + } + + if (this.protocols != null) { + payload.put("protocols", this.protocols); + } + + if (this.formfactor != null) { + payload.put("formfactor", this.formfactor); + } + + if (this.os != null) { + payload.put("os", this.os); + } + + if (this.application != null) { + payload.put("application", this.application); + } + + if (this.appPackage != null) { + payload.put("appPackage", this.appPackage); + } + + if (this.device != null) { + payload.put("device", this.device); + } + + if (this.fxaDeviceId != null) { + payload.put("fxaDeviceId", this.fxaDeviceId); + } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ClientRecord) || !super.equals(o)) { + return false; + } + + return this.equalPayloads(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + @Override + public boolean equalPayloads(Object o) { + if (!(o instanceof ClientRecord) || !super.equalPayloads(o)) { + return false; + } + + // Don't compare versions, protocols, or other optional fields, no matter how much we might want to. + // They're not required by the spec. + ClientRecord other = (ClientRecord) o; + if (!RepoUtils.stringsEqual(other.name, this.name) || + !RepoUtils.stringsEqual(other.type, this.type)) { + return false; + } + return true; + } + + @Override + public Record copyWithIDs(String guid, long androidID) { + ClientRecord out = new ClientRecord(guid, this.collection, this.lastModified, this.deleted); + out.androidID = androidID; + out.sortIndex = this.sortIndex; + out.ttl = this.ttl; + + out.name = this.name; + out.type = this.type; + out.version = this.version; + out.protocols = this.protocols; + + out.formfactor = this.formfactor; + out.os = this.os; + out.application = this.application; + out.appPackage = this.appPackage; + out.device = this.device; + out.fxaDeviceId = this.fxaDeviceId; + + return out; + } + +/* +Example record: + +{id:"relf31w7B4F1", + name:"marina_mac", + type:"mobile" + commands:[{"args":["bookmarks"],"command":"wipeEngine"}, + {"args":["forms"],"command":"wipeEngine"}, + {"args":["history"],"command":"wipeEngine"}, + {"args":["passwords"],"command":"wipeEngine"}, + {"args":["prefs"],"command":"wipeEngine"}, + {"args":["tabs"],"command":"wipeEngine"}, + {"args":["addons"],"command":"wipeEngine"}]} +*/ +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java new file mode 100644 index 000000000..897d2859c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java @@ -0,0 +1,17 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.repositories.RecordFactory; + +public class ClientRecordFactory extends RecordFactory { + @Override + public Record createRecord(Record record) { + ClientRecord r = new ClientRecord(); + r.initFromEnvelope((CryptoRecord) record); + return r; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java new file mode 100644 index 000000000..e7ca70cb4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java @@ -0,0 +1,139 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +/** + * A FormHistoryRecord represents a saved form element. + * + * I map a <code>fieldName</code> string to a <code>value</code> string. + * + * @see "<a href='http://dxr.mozilla.org/services-central/source/services-central/services/sync/modules/engines/forms.js'>http://dxr.mozilla.org/services-central/source/services-central/services/sync/modules/engines/forms.js</a>." + */ +public class FormHistoryRecord extends Record { + private static final String LOG_TAG = "FormHistoryRecord"; + + public static final String COLLECTION_NAME = "forms"; + private static final String PAYLOAD_NAME = "name"; + private static final String PAYLOAD_VALUE = "value"; + public static final long FORMS_TTL = 3 * 365 * 24 * 60 * 60; // Three years in seconds. + + /** + * The name of the saved form field. + */ + public String fieldName; + + /** + * The value of the saved form field. + */ + public String fieldValue; + + public FormHistoryRecord(String guid, String collection, long lastModified, boolean deleted) { + super(guid, collection, lastModified, deleted); + this.ttl = FORMS_TTL; + } + + public FormHistoryRecord(String guid, String collection, long lastModified) { + this(guid, collection, lastModified, false); + } + + public FormHistoryRecord(String guid, String collection) { + this(guid, collection, 0, false); + } + + public FormHistoryRecord(String guid) { + this(guid, COLLECTION_NAME, 0, false); + } + + public FormHistoryRecord() { + this(Utils.generateGuid(), COLLECTION_NAME, 0, false); + } + + @Override + public Record copyWithIDs(String guid, long androidID) { + FormHistoryRecord out = new FormHistoryRecord(guid, this.collection, this.lastModified, this.deleted); + out.androidID = androidID; + out.sortIndex = this.sortIndex; + + // Copy FormHistoryRecord fields. + out.fieldName = this.fieldName; + out.fieldValue = this.fieldValue; + + return out; + } + + @Override + public void populatePayload(ExtendedJSONObject payload) { + putPayload(payload, PAYLOAD_NAME, this.fieldName); + putPayload(payload, PAYLOAD_VALUE, this.fieldValue); + } + + @Override + public void initFromPayload(ExtendedJSONObject payload) { + this.fieldName = payload.getString(PAYLOAD_NAME); + this.fieldValue = payload.getString(PAYLOAD_VALUE); + } + + /** + * We consider two form history records to be congruent if they represent the + * same form element regardless of times used. + */ + @Override + public boolean congruentWith(Object o) { + if (!(o instanceof FormHistoryRecord)) { + return false; + } + FormHistoryRecord other = (FormHistoryRecord) o; + if (!super.congruentWith(other)) { + return false; + } + return RepoUtils.stringsEqual(this.fieldName, other.fieldName) && + RepoUtils.stringsEqual(this.fieldValue, other.fieldValue); + } + + @Override + public boolean equalPayloads(Object o) { + if (!(o instanceof FormHistoryRecord)) { + Logger.debug(LOG_TAG, "Not a FormHistoryRecord: " + o.getClass()); + return false; + } + FormHistoryRecord other = (FormHistoryRecord) o; + if (!super.equalPayloads(other)) { + Logger.debug(LOG_TAG, "super.equalPayloads returned false."); + return false; + } + + if (this.deleted) { + // FormHistoryRecords are equal if they are both deleted (which + // they are, since super.equalPayloads is true) and have the + // same GUID. + if (other.deleted) { + return RepoUtils.stringsEqual(this.guid, other.guid); + } + return false; + } + + return RepoUtils.stringsEqual(this.fieldName, other.fieldName) && + RepoUtils.stringsEqual(this.fieldValue, other.fieldValue); + } + + public FormHistoryRecord log(String logTag) { + try { + Logger.debug(logTag, "Returning form history record " + guid + " (" + androidID + ")"); + Logger.debug(logTag, "> Last modified: " + lastModified); + if (Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(logTag, "> Field name: " + fieldName); + Logger.pii(logTag, "> Field value: " + fieldValue); + } + } catch (Exception e) { + Logger.debug(logTag, "Exception logging form history record " + this, e); + } + return this; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java new file mode 100644 index 000000000..94eae13a7 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java @@ -0,0 +1,217 @@ +/* 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.repositories.domain; + +import java.util.HashMap; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +/** + * Visits are in microsecond precision. + * + * @author rnewman + * + */ +public class HistoryRecord extends Record { + private static final String LOG_TAG = "HistoryRecord"; + + public static final String COLLECTION_NAME = "history"; + public static final long HISTORY_TTL = 60 * 24 * 60 * 60; // 60 days in seconds. + + public HistoryRecord(String guid, String collection, long lastModified, boolean deleted) { + super(guid, collection, lastModified, deleted); + this.ttl = HISTORY_TTL; + } + public HistoryRecord(String guid, String collection, long lastModified) { + this(guid, collection, lastModified, false); + } + public HistoryRecord(String guid, String collection) { + this(guid, collection, 0, false); + } + public HistoryRecord(String guid) { + this(guid, COLLECTION_NAME, 0, false); + } + public HistoryRecord() { + this(Utils.generateGuid(), COLLECTION_NAME, 0, false); + } + + public String title; + public String histURI; + public JSONArray visits; + public long fennecDateVisited; + public long fennecVisitCount; + + @SuppressWarnings("unchecked") + private JSONArray copyVisits() { + if (this.visits == null) { + return null; + } + JSONArray out = new JSONArray(); + out.addAll(this.visits); + return out; + } + + @Override + public Record copyWithIDs(String guid, long androidID) { + HistoryRecord out = new HistoryRecord(guid, this.collection, this.lastModified, this.deleted); + out.androidID = androidID; + out.sortIndex = this.sortIndex; + out.ttl = this.ttl; + + // Copy HistoryRecord fields. + out.title = this.title; + out.histURI = this.histURI; + out.fennecDateVisited = this.fennecDateVisited; + out.fennecVisitCount = this.fennecVisitCount; + out.visits = this.copyVisits(); + + return out; + } + + @Override + protected void populatePayload(ExtendedJSONObject payload) { + putPayload(payload, "id", this.guid); + putPayload(payload, "title", this.title); + putPayload(payload, "histUri", this.histURI); // TODO: encoding? + payload.put("visits", this.visits); + } + + @Override + protected void initFromPayload(ExtendedJSONObject payload) { + this.histURI = (String) payload.get("histUri"); + this.title = (String) payload.get("title"); + try { + this.visits = payload.getArray("visits"); + } catch (NonArrayJSONException e) { + Logger.error(LOG_TAG, "Got non-array visits in history record " + this.guid, e); + this.visits = new JSONArray(); + } + } + + /** + * We consider two history records to be congruent if they represent the + * same history record regardless of visits. Titles are allowed to differ, + * but the URI must be the same. + */ + @Override + public boolean congruentWith(Object o) { + if (!(o instanceof HistoryRecord)) { + return false; + } + HistoryRecord other = (HistoryRecord) o; + if (!super.congruentWith(other)) { + return false; + } + return RepoUtils.stringsEqual(this.histURI, other.histURI); + } + + @Override + public boolean equalPayloads(Object o) { + if (!(o instanceof HistoryRecord)) { + Logger.debug(LOG_TAG, "Not a HistoryRecord: " + o.getClass()); + return false; + } + HistoryRecord other = (HistoryRecord) o; + if (!super.equalPayloads(other)) { + Logger.debug(LOG_TAG, "super.equalPayloads returned false."); + return false; + } + return RepoUtils.stringsEqual(this.title, other.title) && + RepoUtils.stringsEqual(this.histURI, other.histURI) && + checkVisitsEquals(other); + } + + @Override + public boolean equalAndroidIDs(Record other) { + return super.equalAndroidIDs(other) && + this.equalFennecVisits(other); + } + + private boolean equalFennecVisits(Record other) { + if (!(other instanceof HistoryRecord)) { + return false; + } + HistoryRecord h = (HistoryRecord) other; + return this.fennecDateVisited == h.fennecDateVisited && + this.fennecVisitCount == h.fennecVisitCount; + } + + private boolean checkVisitsEquals(HistoryRecord other) { + Logger.debug(LOG_TAG, "Checking visits."); + if (Logger.LOG_PERSONAL_INFORMATION) { + // Don't JSON-encode unless we're logging. + Logger.pii(LOG_TAG, ">> Mine: " + ((this.visits == null) ? "null" : this.visits.toJSONString())); + Logger.pii(LOG_TAG, ">> Theirs: " + ((other.visits == null) ? "null" : other.visits.toJSONString())); + } + + // Handle nulls. + if (this.visits == other.visits) { + return true; + } + + // Now they can't both be null. + int aSize = this.visits == null ? 0 : this.visits.size(); + int bSize = other.visits == null ? 0 : other.visits.size(); + + if (aSize != bSize) { + return false; + } + + // Now neither of them can be null. + + // TODO: do this by maintaining visits as a sorted array. + HashMap<Long, Long> otherVisits = new HashMap<Long, Long>(); + for (int i = 0; i < bSize; i++) { + JSONObject visit = (JSONObject) other.visits.get(i); + otherVisits.put((Long) visit.get("date"), (Long) visit.get("type")); + } + + for (int i = 0; i < aSize; i++) { + JSONObject visit = (JSONObject) this.visits.get(i); + if (!otherVisits.containsKey(visit.get("date"))) { + return false; + } + Long otherDate = (Long) visit.get("date"); + Long otherType = otherVisits.get(otherDate); + if (otherType == null) { + return false; + } + if (!otherType.equals((Long) visit.get("type"))) { + return false; + } + } + + return true; + } + +// +// Example record (note microsecond resolution): +// +// {id:"--DUvUomABNq", +// histUri:"https://bugzilla.mozilla.org/show_bug.cgi?id=697634", +// title:"697634 \u2013 xpcshell test failures on 10.7", +// visits:[{date:1320087601465600, type:2}, +// {date:1320084970724990, type:1}, +// {date:1320084847035717, type:1}, +// {date:1319764134412287, type:1}, +// {date:1319757917982518, type:1}, +// {date:1319751664627351, type:1}, +// {date:1319681421072326, type:1}, +// {date:1319681306455594, type:1}, +// {date:1319678117125234, type:1}, +// {date:1319677508862901, type:1}] +// } +// +//"type" is a transition type: +// +//https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsINavHistoryService#Transition_type_constants + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java new file mode 100644 index 000000000..ac2c6a1dc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java @@ -0,0 +1,25 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.repositories.RecordFactory; + +/** + * Turns CryptoRecords into HistoryRecords. + * + * @author rnewman + * + */ +public class HistoryRecordFactory extends RecordFactory { + + @Override + public Record createRecord(Record record) { + HistoryRecord r = new HistoryRecord(); + r.initFromEnvelope((CryptoRecord) record); + return r; + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java new file mode 100644 index 000000000..b2de60f3c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java @@ -0,0 +1,205 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; + +public class PasswordRecord extends Record { + private static final String LOG_TAG = "PasswordRecord"; + + public static final String COLLECTION_NAME = "passwords"; + public static long PASSWORDS_TTL = -1; // Never expire passwords. + + // Payload strings. + public static final String PAYLOAD_HOSTNAME = "hostname"; + public static final String PAYLOAD_FORM_SUBMIT_URL = "formSubmitURL"; + public static final String PAYLOAD_HTTP_REALM = "httpRealm"; + public static final String PAYLOAD_USERNAME = "username"; + public static final String PAYLOAD_PASSWORD = "password"; + public static final String PAYLOAD_USERNAME_FIELD = "usernameField"; + public static final String PAYLOAD_PASSWORD_FIELD = "passwordField"; + + public PasswordRecord(String guid, String collection, long lastModified, boolean deleted) { + super(guid, collection, lastModified, deleted); + this.ttl = PASSWORDS_TTL; + } + public PasswordRecord(String guid, String collection, long lastModified) { + this(guid, collection, lastModified, false); + } + public PasswordRecord(String guid, String collection) { + this(guid, collection, 0, false); + } + public PasswordRecord(String guid) { + this(guid, COLLECTION_NAME, 0, false); + } + public PasswordRecord() { + this(Utils.generateGuid(), COLLECTION_NAME, 0, false); + } + + public String id; + public String hostname; + public String formSubmitURL; + public String httpRealm; + // TODO these are encrypted in the passwords content provider, + // need to figure out what we need to do here. + public String usernameField; + public String passwordField; + public String encryptedUsername; + public String encryptedPassword; + public String encType; + + public long timeCreated; + public long timeLastUsed; + public long timePasswordChanged; + public long timesUsed; + + + @Override + public Record copyWithIDs(String guid, long androidID) { + PasswordRecord out = new PasswordRecord(guid, this.collection, this.lastModified, this.deleted); + out.androidID = androidID; + out.sortIndex = this.sortIndex; + out.ttl = this.ttl; + + // Copy PasswordRecord fields. + out.id = this.id; + out.hostname = this.hostname; + out.formSubmitURL = this.formSubmitURL; + out.httpRealm = this.httpRealm; + + out.usernameField = this.usernameField; + out.passwordField = this.passwordField; + out.encryptedUsername = this.encryptedUsername; + out.encryptedPassword = this.encryptedPassword; + out.encType = this.encType; + + out.timeCreated = this.timeCreated; + out.timeLastUsed = this.timeLastUsed; + out.timePasswordChanged = this.timePasswordChanged; + out.timesUsed = this.timesUsed; + + return out; + } + + @Override + public void initFromPayload(ExtendedJSONObject payload) { + this.hostname = payload.getString(PAYLOAD_HOSTNAME); + this.formSubmitURL = payload.getString(PAYLOAD_FORM_SUBMIT_URL); + this.httpRealm = payload.getString(PAYLOAD_HTTP_REALM); + this.encryptedUsername = payload.getString(PAYLOAD_USERNAME); + this.encryptedPassword = payload.getString(PAYLOAD_PASSWORD); + this.usernameField = payload.getString(PAYLOAD_USERNAME_FIELD); + this.passwordField = payload.getString(PAYLOAD_PASSWORD_FIELD); + } + + @Override + public void populatePayload(ExtendedJSONObject payload) { + putPayload(payload, PAYLOAD_HOSTNAME, this.hostname); + putPayload(payload, PAYLOAD_FORM_SUBMIT_URL, this.formSubmitURL); + putPayload(payload, PAYLOAD_HTTP_REALM, this.httpRealm); + putPayload(payload, PAYLOAD_USERNAME, this.encryptedUsername); + putPayload(payload, PAYLOAD_PASSWORD, this.encryptedPassword); + putPayload(payload, PAYLOAD_USERNAME_FIELD, this.usernameField); + putPayload(payload, PAYLOAD_PASSWORD_FIELD, this.passwordField); + } + + @Override + public boolean congruentWith(Object o) { + if (!(o instanceof PasswordRecord)) { + return false; + } + PasswordRecord other = (PasswordRecord) o; + if (!super.congruentWith(other)) { + return false; + } + return RepoUtils.stringsEqual(this.hostname, other.hostname) + && RepoUtils.stringsEqual(this.formSubmitURL, other.formSubmitURL) + // Bug 738347 - SQLiteBridge does not check for nulls in ContentValues. + // && RepoUtils.stringsEqual(this.httpRealm, other.httpRealm) + // && RepoUtils.stringsEqual(this.encType, other.encType) + && RepoUtils.stringsEqual(this.usernameField, other.usernameField) + && RepoUtils.stringsEqual(this.passwordField, other.passwordField) + && RepoUtils.stringsEqual(this.encryptedUsername, other.encryptedUsername) + && RepoUtils.stringsEqual(this.encryptedPassword, other.encryptedPassword); + } + + @Override + public boolean equalPayloads(Object o) { + if (!(o instanceof PasswordRecord)) { + return false; + } + + PasswordRecord other = (PasswordRecord) o; + Logger.debug("PasswordRecord", "thisRecord:" + this.toString()); + Logger.debug("PasswordRecord", "otherRecord:" + o.toString()); + + if (this.deleted) { + if (other.deleted) { + // Deleted records are equal if their guids match. + return RepoUtils.stringsEqual(this.guid, other.guid); + } + // One record is deleted, the other is not. Not equal. + return false; + } + + if (!super.equalPayloads(other)) { + Logger.debug(LOG_TAG, "super.equalPayloads returned false."); + return false; + } + + return RepoUtils.stringsEqual(this.hostname, other.hostname) + && RepoUtils.stringsEqual(this.formSubmitURL, other.formSubmitURL) + // Bug 738347 - SQLiteBridge does not check for nulls in ContentValues. + // && RepoUtils.stringsEqual(this.httpRealm, other.httpRealm) + // && RepoUtils.stringsEqual(this.encType, other.encType) + && RepoUtils.stringsEqual(this.usernameField, other.usernameField) + && RepoUtils.stringsEqual(this.passwordField, other.passwordField) + && RepoUtils.stringsEqual(this.encryptedUsername, other.encryptedUsername) + && RepoUtils.stringsEqual(this.encryptedPassword, other.encryptedPassword); + // Desktop sync never sets timeCreated so this isn't relevant for sync records. + } + + @Override + public String toString() { + return "PasswordRecord {" + + "lastModified: " + this.lastModified + ", " + + "hostname null?: " + (this.hostname == null) + ", " + + "formSubmitURL null?: " + (this.formSubmitURL == null) + ", " + + "httpRealm null?: " + (this.httpRealm == null) + ", " + + "usernameField null?: " + (this.usernameField == null) + ", " + + "passwordField null?: " + (this.passwordField == null) + ", " + + "encryptedUsername null?: " + (this.encryptedUsername == null) + ", " + + "encryptedPassword null?: " + (this.encryptedPassword == null) + ", " + + "encType: " + this.encType + ", " + + "timeCreated: " + this.timeCreated + ", " + + "timeLastUsed: " + this.timeLastUsed + ", " + + "timePasswordChanged: " + this.timePasswordChanged + ", " + + "timesUsed: " + this.timesUsed; + } + + /** + * A PasswordRecord is considered valid if it abides by the database + * constraints of the PasswordsProvider (moz_logins). + * + * See toolkit/components/passwordmgr/storage-mozStorage.js for the + * definitions: + * + * http://hg.mozilla.org/mozilla-central/file/00955d61cc94/toolkit/components/passwordmgr/storage-mozStorage.js#l98 + */ + public boolean isValid() { + if (this.deleted) { + return true; + } + + return this.hostname != null && + this.encryptedUsername != null && + this.encryptedPassword != null && + this.usernameField != null && + this.passwordField != null; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java new file mode 100644 index 000000000..fc7ef916d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java @@ -0,0 +1,19 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.domain.PasswordRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; + +public class PasswordRecordFactory extends RecordFactory { + @Override + public Record createRecord(Record record) { + PasswordRecord r = new PasswordRecord(); + r.initFromEnvelope((CryptoRecord) record); + return r; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java new file mode 100644 index 000000000..145704c1c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java @@ -0,0 +1,308 @@ +/* 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.repositories.domain; + +import java.io.UnsupportedEncodingException; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +/** + * Record is the abstract base class for all entries that Sync processes: + * bookmarks, passwords, history, and such. + * + * A Record can be initialized from or serialized to a CryptoRecord for + * submission to an encrypted store. + * + * Records should be considered to be conventionally immutable: modifications + * should be completed before the new record object escapes its constructing + * scope. Note that this is a critically important part of equality. As Rich + * Hickey notes: + * + * … the only things you can really compare for equality are immutable things, + * because if you compare two things for equality that are mutable, and ever + * say true, and they're ever not the same thing, you are wrong. Or you will + * become wrong at some point in the future. + * + * Records have a layered definition of equality. Two records can be said to be + * "equal" if: + * + * * They have the same GUID and collection. Two crypto/keys records are in some + * way "the same". + * This is `equalIdentifiers`. + * + * * Their most significant fields are the same. That is to say, they share a + * GUID, a collection, deletion, and domain-specific fields. Two copies of + * crypto/keys, neither deleted, with the same encrypted data but different + * modified times and sortIndex are in a stronger way "the same". + * This is `equalPayloads`. + * + * * Their most significant fields are the same, and their local fields (e.g., + * the androidID to which we have decided that this record maps) are congruent. + * A record with the same androidID, or one whose androidID has not been set, + * can be considered "the same". + * This concept can be extended by Record subclasses. The key point is that + * reconciling should be applied to the contents of these records. For example, + * two history records with the same URI and GUID, but different visit arrays, + * can be said to be congruent. + * This is `congruentWith`. + * + * * They are strictly identical. Every field that is persisted, including + * lastModified and androidID, is equal. + * This is `equals`. + * + * Different parts of the codebase have use for different layers of this + * comparison hierarchy. For instance, lastModified times change every time a + * record is stored; a store followed by a retrieval will return a Record that + * shares its most significant fields with the input, but has a later + * lastModified time and might not yet have values set for others. Reconciling + * will thus ignore the modification time of a record. + * + * @author rnewman + * + */ +public abstract class Record { + + public String guid; + public String collection; + public long lastModified; + public boolean deleted; + public long androidID; + /** + * An integer indicating the relative importance of this item in the collection. + * <p> + * Default is 0. + */ + public long sortIndex; + /** + * The number of seconds to keep this record. After that time this item will + * no longer be returned in response to any request, and it may be pruned from + * the database. + * <p> + * Negative values mean never forget this record. + * <p> + * Default is 1 year. + */ + public long ttl; + + public Record(String guid, String collection, long lastModified, boolean deleted) { + this.guid = guid; + this.collection = collection; + this.lastModified = lastModified; + this.deleted = deleted; + this.sortIndex = 0; + this.ttl = 365 * 24 * 60 * 60; // Seconds. + this.androidID = -1; + } + + /** + * Return true iff the input is a Record and has the same + * collection and guid as this object. + */ + public boolean equalIdentifiers(Object o) { + if (!(o instanceof Record)) { + return false; + } + + Record other = (Record) o; + if (this.guid == null) { + if (other.guid != null) { + return false; + } + } else { + if (!this.guid.equals(other.guid)) { + return false; + } + } + if (this.collection == null) { + if (other.collection != null) { + return false; + } + } else { + if (!this.collection.equals(other.collection)) { + return false; + } + } + return true; + } + + /** + * @param o + * The object to which this object should be compared. + * @return + * true iff the input is a Record which is substantially the + * same as this object. + */ + public boolean equalPayloads(Object o) { + if (!this.equalIdentifiers(o)) { + return false; + } + Record other = (Record) o; + return this.deleted == other.deleted; + } + + /** + * + * + * @param o + * The object to which this object should be compared. + * @return + * true iff the input is a Record which is substantially the + * same as this object, considering the ability and desire to + * reconcile the two objects if possible. + */ + public boolean congruentWith(Object o) { + if (!this.equalIdentifiers(o)) { + return false; + } + Record other = (Record) o; + return congruentAndroidIDs(other) && + (this.deleted == other.deleted); + } + + public boolean congruentAndroidIDs(Record other) { + // We treat -1 as "unset", and treat this as + // congruent with any other value. + if (this.androidID != -1 && + other.androidID != -1 && + this.androidID != other.androidID) { + return false; + } + return true; + } + + /** + * Return true iff the input is both equal in terms of payload, + * and also shares transient values such as timestamps. + */ + @Override + public boolean equals(Object o) { + if (!(o instanceof Record)) { + return false; + } + + Record other = (Record) o; + return equalTimestamps(other) && + equalSortIndices(other) && + equalAndroidIDs(other) && + equalPayloads(o); + } + + public boolean equalAndroidIDs(Record other) { + return this.androidID == other.androidID; + } + + public boolean equalSortIndices(Record other) { + return this.sortIndex == other.sortIndex; + } + + public boolean equalTimestamps(Object o) { + if (!(o instanceof Record)) { + return false; + } + return ((Record) o).lastModified == this.lastModified; + } + + protected abstract void populatePayload(ExtendedJSONObject payload); + protected abstract void initFromPayload(ExtendedJSONObject payload); + + public void initFromEnvelope(CryptoRecord envelope) { + ExtendedJSONObject p = envelope.payload; + this.guid = envelope.guid; + checkGUIDs(p); + + this.collection = envelope.collection; + this.lastModified = envelope.lastModified; + + final Object del = p.get("deleted"); + if (del instanceof Boolean) { + this.deleted = (Boolean) del; + } else { + this.initFromPayload(p); + } + + } + + public CryptoRecord getEnvelope() { + CryptoRecord rec = new CryptoRecord(this); + ExtendedJSONObject payload = new ExtendedJSONObject(); + payload.put("id", this.guid); + + if (this.deleted) { + payload.put("deleted", true); + } else { + populatePayload(payload); + } + rec.payload = payload; + return rec; + } + + @SuppressWarnings("static-method") + public String toJSONString() { + throw new RuntimeException("Cannot JSONify non-CryptoRecord Records."); + } + + public byte[] toJSONBytes() { + try { + return this.toJSONString().getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + // Can't happen. + return null; + } + } + + /** + * Utility for safely populating an output CryptoRecord. + * + * @param rec + * @param key + * @param value + */ + @SuppressWarnings("static-method") + protected void putPayload(CryptoRecord rec, String key, String value) { + if (value == null) { + return; + } + rec.payload.put(key, value); + } + + protected void putPayload(ExtendedJSONObject payload, String key, String value) { + this.putPayload(payload, key, value, false); + } + + @SuppressWarnings("static-method") + protected void putPayload(ExtendedJSONObject payload, String key, String value, boolean excludeEmpty) { + if (value == null) { + return; + } + if (excludeEmpty && value.equals("")) { + return; + } + payload.put(key, value); + } + + protected void checkGUIDs(ExtendedJSONObject payload) { + String payloadGUID = (String) payload.get("id"); + if (this.guid == null || + payloadGUID == null) { + String detailMessage = "Inconsistency: either envelope or payload GUID missing."; + throw new IllegalStateException(detailMessage); + } + if (!this.guid.equals(payloadGUID)) { + String detailMessage = "Inconsistency: record has envelope ID " + this.guid + ", payload ID " + payloadGUID; + throw new IllegalStateException(detailMessage); + } + } + + /** + * Oh for persistent data structures. + * + * @param guid + * @param androidID + * @return + * An identical copy of this record with the provided two values. + */ + public abstract Record copyWithIDs(String guid, long androidID); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java new file mode 100644 index 000000000..0d8fe90b2 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java @@ -0,0 +1,14 @@ +/* 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.repositories.domain; + + +public class RecordParseException extends Exception { + private static final long serialVersionUID = -5145494854722254491L; + + public RecordParseException(String detailMessage) { + super(detailMessage); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java new file mode 100644 index 000000000..eb3a4f6d0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java @@ -0,0 +1,153 @@ +/* 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.repositories.domain; + +import java.util.ArrayList; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.db.Tab; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.Utils; + +import android.content.ContentValues; + +/** + * Represents a client's collection of tabs. + * + * @author rnewman + * + */ +public class TabsRecord extends Record { + public static final String LOG_TAG = "TabsRecord"; + + public static final String COLLECTION_NAME = "tabs"; + public static final long TABS_TTL = 7 * 24 * 60 * 60; // 7 days in seconds. + + public TabsRecord(String guid, String collection, long lastModified, boolean deleted) { + super(guid, collection, lastModified, deleted); + this.ttl = TABS_TTL; + } + public TabsRecord(String guid, String collection, long lastModified) { + this(guid, collection, lastModified, false); + } + public TabsRecord(String guid, String collection) { + this(guid, collection, 0, false); + } + public TabsRecord(String guid) { + this(guid, COLLECTION_NAME, 0, false); + } + public TabsRecord() { + this(Utils.generateGuid(), COLLECTION_NAME, 0, false); + } + + public String clientName; + public ArrayList<Tab> tabs; + + @Override + public void initFromPayload(ExtendedJSONObject payload) { + clientName = (String) payload.get("clientName"); + try { + tabs = tabsFrom(payload.getArray("tabs")); + } catch (NonArrayJSONException e) { + // Oh well. + tabs = new ArrayList<Tab>(); + } + } + + @SuppressWarnings("unchecked") + protected static JSONArray tabsToJSON(ArrayList<Tab> tabs) { + JSONArray out = new JSONArray(); + for (Tab tab : tabs) { + out.add(tabToJSONObject(tab)); + } + return out; + } + + protected static ArrayList<Tab> tabsFrom(JSONArray in) { + ArrayList<Tab> tabs = new ArrayList<Tab>(in.size()); + for (Object o : in) { + if (o instanceof JSONObject) { + try { + tabs.add(TabsRecord.tabFromJSONObject((JSONObject) o)); + } catch (NonArrayJSONException e) { + Logger.warn(LOG_TAG, "urlHistory is not an array for this tab.", e); + } + } + } + return tabs; + } + + @Override + public void populatePayload(ExtendedJSONObject payload) { + putPayload(payload, "id", this.guid); + putPayload(payload, "clientName", this.clientName); + payload.put("tabs", tabsToJSON(this.tabs)); + } + + @Override + public Record copyWithIDs(String guid, long androidID) { + TabsRecord out = new TabsRecord(guid, this.collection, this.lastModified, this.deleted); + out.androidID = androidID; + out.sortIndex = this.sortIndex; + out.ttl = this.ttl; + + out.clientName = this.clientName; + out.tabs = new ArrayList<Tab>(this.tabs); + + return out; + } + + public ContentValues getClientsContentValues() { + ContentValues cv = new ContentValues(); + cv.put(BrowserContract.Clients.GUID, this.guid); + cv.put(BrowserContract.Clients.NAME, this.clientName); + cv.put(BrowserContract.Clients.LAST_MODIFIED, this.lastModified); + return cv; + } + + public ContentValues[] getTabsContentValues() { + int c = tabs.size(); + ContentValues[] out = new ContentValues[c]; + for (int i = 0; i < c; i++) { + out[i] = tabs.get(i).toContentValues(this.guid, i); + } + return out; + } + + public static Tab tabFromJSONObject(JSONObject o) throws NonArrayJSONException { + ExtendedJSONObject obj = new ExtendedJSONObject(o); + String title = obj.getString("title"); + String icon = obj.getString("icon"); + JSONArray history = obj.getArray("urlHistory"); + + // Last used is inexplicably a string in seconds. Most of the time. + long lastUsed = 0; + Object lU = obj.get("lastUsed"); + if (lU instanceof Number) { + lastUsed = ((Long) lU) * 1000L; + } else if (lU instanceof String) { + try { + lastUsed = Long.parseLong((String) lU, 10) * 1000L; + } catch (NumberFormatException e) { + Logger.debug(TabsRecord.LOG_TAG, "Invalid number format in lastUsed: " + lU); + } + } + return new Tab(title, icon, history, lastUsed); + } + + @SuppressWarnings("unchecked") + public static JSONObject tabToJSONObject(Tab tab) { + JSONObject o = new JSONObject(); + o.put("title", tab.title); + o.put("icon", tab.icon); + o.put("urlHistory", tab.history); + o.put("lastUsed", tab.lastUsed / 1000); + return o; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java new file mode 100644 index 000000000..9504434d8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java @@ -0,0 +1,17 @@ +/* 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.repositories.domain; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.repositories.RecordFactory; + +public class TabsRecordFactory extends RecordFactory { + @Override + public Record createRecord(Record record) { + TabsRecord r = new TabsRecord(); + r.initFromEnvelope((CryptoRecord) record); + return r; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java new file mode 100644 index 000000000..2d3d4fd32 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java @@ -0,0 +1,14 @@ +/* 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.repositories.domain; + +public class VersionConstants { + public static final int BOOKMARKS_ENGINE_VERSION = 2; + public static final int CLIENTS_ENGINE_VERSION = 1; + public static final int FORMS_ENGINE_VERSION = 1; + public static final int HISTORY_ENGINE_VERSION = 1; + public static final int PASSWORDS_ENGINE_VERSION = 1; + public static final int TABS_ENGINE_VERSION = 1; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java new file mode 100644 index 000000000..5c3037e4d --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java @@ -0,0 +1,310 @@ +/* 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.repositories.downloaders; + +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.DelayedWorkTracker; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest; +import org.mozilla.gecko.sync.net.SyncStorageResponse; +import org.mozilla.gecko.sync.repositories.Server11Repository; +import org.mozilla.gecko.sync.repositories.Server11RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Batching Downloader, which implements batching protocol as supported by Sync 1.5. + * + * Downloader's batching behaviour is configured via two parameters, obtained from the repository: + * - Per-batch limit, which specified how many records may be fetched in an individual GET request. + * - Total limit, which controls number of batch GET requests we will make. + * + * + * Batching is implemented via specifying a 'limit' GET parameter, and looking for an 'offset' token + * in the response. If offset token is present, this indicates that there are more records than what + * we've received so far, and we perform an additional fetch. Batching stops when either we hit a total + * limit, or offset token is no longer present (indicating that we're done). + * + * For unlimited repositories (such as passwords), both of these value will be -1. Downloader will not + * specify a limit parameter in this case, and the response will contain every record available and no + * offset token, thus fully completing in one go. + * + * In between batches, we maintain a Last-Modified timestamp, based off the value return in the header + * of the first response. Every response will have a Last-Modified header, indicating when the collection + * was modified last. We pass along this header in our subsequent requests in a X-If-Unmodified-Since + * header. Server will ensure that our collection did not change while we are batching, if it did it will + * fail our fetch with a 412 (Consequent Modification) error. Additionally, we perform the same checks + * locally. + */ +public class BatchingDownloader { + public static final String LOG_TAG = "BatchingDownloader"; + + protected final Server11Repository repository; + private final Server11RepositorySession repositorySession; + private final DelayedWorkTracker workTracker = new DelayedWorkTracker(); + // Used to track outstanding requests, so that we can abort them as needed. + @VisibleForTesting + protected final Set<SyncStorageCollectionRequest> pending = Collections.synchronizedSet(new HashSet<SyncStorageCollectionRequest>()); + /* @GuardedBy("this") */ private String lastModified; + /* @GuardedBy("this") */ private long numRecords = 0; + + public BatchingDownloader(final Server11Repository repository, final Server11RepositorySession repositorySession) { + this.repository = repository; + this.repositorySession = repositorySession; + } + + @VisibleForTesting + protected static String flattenIDs(String[] guids) { + // Consider using Utils.toDelimitedString if and when the signature changes + // to Collection<String> guids. + if (guids.length == 0) { + return ""; + } + if (guids.length == 1) { + return guids[0]; + } + // Assuming 12-char GUIDs. There should be a -1 in there, but we accumulate one comma too many. + StringBuilder b = new StringBuilder(guids.length * 12 + guids.length); + for (String guid : guids) { + b.append(guid); + b.append(","); + } + return b.substring(0, b.length() - 1); + } + + @VisibleForTesting + protected void fetchWithParameters(long newer, + long batchLimit, + boolean full, + String sort, + String ids, + SyncStorageCollectionRequest request, + RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) + throws URISyntaxException, UnsupportedEncodingException { + if (batchLimit > repository.getDefaultTotalLimit()) { + throw new IllegalArgumentException("Batch limit should not be greater than total limit"); + } + + request.delegate = new BatchingDownloaderDelegate(this, fetchRecordsDelegate, request, + newer, batchLimit, full, sort, ids); + this.pending.add(request); + request.get(); + } + + @VisibleForTesting + @Nullable + protected String encodeParam(String param) throws UnsupportedEncodingException { + if (param != null) { + return URLEncoder.encode(param, "UTF-8"); + } + return null; + } + + @VisibleForTesting + protected SyncStorageCollectionRequest makeSyncStorageCollectionRequest(long newer, + long batchLimit, + boolean full, + String sort, + String ids, + String offset) + throws URISyntaxException, UnsupportedEncodingException { + URI collectionURI = repository.collectionURI(full, newer, batchLimit, sort, ids, encodeParam(offset)); + Logger.debug(LOG_TAG, collectionURI.toString()); + + return new SyncStorageCollectionRequest(collectionURI); + } + + public void fetchSince(long timestamp, RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) { + this.fetchSince(timestamp, null, fetchRecordsDelegate); + } + + private void fetchSince(long timestamp, String offset, + RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) { + long batchLimit = repository.getDefaultBatchLimit(); + String sort = repository.getDefaultSort(); + + try { + SyncStorageCollectionRequest request = makeSyncStorageCollectionRequest(timestamp, + batchLimit, true, sort, null, offset); + this.fetchWithParameters(timestamp, batchLimit, true, sort, null, request, fetchRecordsDelegate); + } catch (URISyntaxException | UnsupportedEncodingException e) { + fetchRecordsDelegate.onFetchFailed(e, null); + } + } + + public void fetch(String[] guids, RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) { + String ids = flattenIDs(guids); + String index = "index"; + + try { + SyncStorageCollectionRequest request = makeSyncStorageCollectionRequest( + -1, -1, true, index, ids, null); + this.fetchWithParameters(-1, -1, true, index, ids, request, fetchRecordsDelegate); + } catch (URISyntaxException | UnsupportedEncodingException e) { + fetchRecordsDelegate.onFetchFailed(e, null); + } + } + + public Server11Repository getServerRepository() { + return this.repository; + } + + public void onFetchCompleted(SyncStorageResponse response, + final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate, + final SyncStorageCollectionRequest request, long newer, + long limit, boolean full, String sort, String ids) { + removeRequestFromPending(request); + + // When we process our first request, we get back a X-Last-Modified header indicating when collection was modified last. + // We pass it to the server with every subsequent request (if we need to make more) as the X-If-Unmodified-Since header, + // and server is supposed to ensure that this pre-condition is met, and fail our request with a 412 error code otherwise. + // So, if all of this happens, these checks should never fail. + // However, we also track this header in client side, and can defensively validate against it here as well. + final String currentLastModifiedTimestamp = response.lastModified(); + Logger.debug(LOG_TAG, "Last modified timestamp " + currentLastModifiedTimestamp); + + // Sanity check. We also did a null check in delegate before passing it into here. + if (currentLastModifiedTimestamp == null) { + this.abort(fetchRecordsDelegate, "Last modified timestamp is missing"); + return; + } + + final boolean lastModifiedChanged; + synchronized (this) { + if (this.lastModified == null) { + // First time seeing last modified timestamp. + this.lastModified = currentLastModifiedTimestamp; + } + lastModifiedChanged = !this.lastModified.equals(currentLastModifiedTimestamp); + } + + if (lastModifiedChanged) { + this.abort(fetchRecordsDelegate, "Last modified timestamp has changed unexpectedly"); + return; + } + + final boolean hasNotReachedLimit; + synchronized (this) { + this.numRecords += response.weaveRecords(); + hasNotReachedLimit = this.numRecords < repository.getDefaultTotalLimit(); + } + + final String offset = response.weaveOffset(); + final SyncStorageCollectionRequest newRequest; + try { + newRequest = makeSyncStorageCollectionRequest(newer, + limit, full, sort, ids, offset); + } catch (final URISyntaxException | UnsupportedEncodingException e) { + this.workTracker.delayWorkItem(new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Delayed onFetchCompleted running."); + fetchRecordsDelegate.onFetchFailed(e, null); + } + }); + return; + } + + if (offset != null && hasNotReachedLimit) { + try { + this.fetchWithParameters(newer, limit, full, sort, ids, newRequest, fetchRecordsDelegate); + } catch (final URISyntaxException | UnsupportedEncodingException e) { + this.workTracker.delayWorkItem(new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Delayed onFetchCompleted running."); + fetchRecordsDelegate.onFetchFailed(e, null); + } + }); + } + return; + } + + final long normalizedTimestamp = response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED); + Logger.debug(LOG_TAG, "Fetch completed. Timestamp is " + normalizedTimestamp); + + this.workTracker.delayWorkItem(new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Delayed onFetchCompleted running."); + fetchRecordsDelegate.onFetchCompleted(normalizedTimestamp); + } + }); + } + + public void onFetchFailed(final Exception ex, + final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate, + final SyncStorageCollectionRequest request) { + removeRequestFromPending(request); + this.workTracker.delayWorkItem(new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Running onFetchFailed."); + fetchRecordsDelegate.onFetchFailed(ex, null); + } + }); + } + + public void onFetchedRecord(CryptoRecord record, + RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) { + this.workTracker.incrementOutstanding(); + try { + fetchRecordsDelegate.onFetchedRecord(record); + } catch (Exception ex) { + Logger.warn(LOG_TAG, "Got exception calling onFetchedRecord with WBO.", ex); + throw new RuntimeException(ex); + } finally { + this.workTracker.decrementOutstanding(); + } + } + + private void removeRequestFromPending(SyncStorageCollectionRequest request) { + if (request == null) { + return; + } + this.pending.remove(request); + } + + @VisibleForTesting + protected void abortRequests() { + this.repositorySession.abort(); + synchronized (this.pending) { + for (SyncStorageCollectionRequest request : this.pending) { + request.abort(); + } + this.pending.clear(); + } + } + + @Nullable + protected synchronized String getLastModified() { + return this.lastModified; + } + + private void abort(final RepositorySessionFetchRecordsDelegate delegate, final String msg) { + Logger.error(LOG_TAG, msg); + this.abortRequests(); + this.workTracker.delayWorkItem(new Runnable() { + @Override + public void run() { + Logger.debug(LOG_TAG, "Delayed onFetchCompleted running."); + delegate.onFetchFailed( + new IllegalStateException(msg), + null); + } + }); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java new file mode 100644 index 000000000..eb9f76d6b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java @@ -0,0 +1,91 @@ +/* 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.repositories.downloaders; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest; +import org.mozilla.gecko.sync.net.SyncStorageResponse; +import org.mozilla.gecko.sync.net.WBOCollectionRequestDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; + +/** + * Delegate that gets passed into fetch methods to handle server response from fetch. + */ +public class BatchingDownloaderDelegate extends WBOCollectionRequestDelegate { + public static final String LOG_TAG = "BatchingDownloaderDelegate"; + + private BatchingDownloader downloader; + private RepositorySessionFetchRecordsDelegate fetchRecordsDelegate; + public SyncStorageCollectionRequest request; + // Used to pass back to BatchDownloader to start another fetch with these parameters if needed. + private long newer; + private long batchLimit; + private boolean full; + private String sort; + private String ids; + + public BatchingDownloaderDelegate(final BatchingDownloader downloader, + final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate, + final SyncStorageCollectionRequest request, long newer, + long batchLimit, boolean full, String sort, String ids) { + this.downloader = downloader; + this.fetchRecordsDelegate = fetchRecordsDelegate; + this.request = request; + this.newer = newer; + this.batchLimit = batchLimit; + this.full = full; + this.sort = sort; + this.ids = ids; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return this.downloader.getServerRepository().getAuthHeaderProvider(); + } + + @Override + public String ifUnmodifiedSince() { + return this.downloader.getLastModified(); + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + Logger.debug(LOG_TAG, "Fetch done."); + if (response.lastModified() != null) { + this.downloader.onFetchCompleted(response, this.fetchRecordsDelegate, this.request, + this.newer, this.batchLimit, this.full, this.sort, this.ids); + return; + } + this.downloader.onFetchFailed( + new IllegalStateException("Missing last modified header from response"), + this.fetchRecordsDelegate, + this.request); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + this.handleRequestError(new HTTPFailureException(response)); + } + + @Override + public void handleRequestError(final Exception ex) { + Logger.warn(LOG_TAG, "Got request error.", ex); + this.downloader.onFetchFailed(ex, this.fetchRecordsDelegate, this.request); + } + + @Override + public void handleWBO(CryptoRecord record) { + this.downloader.onFetchedRecord(record, this.fetchRecordsDelegate); + } + + @Override + public KeyBundle keyBundle() { + return null; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java new file mode 100644 index 000000000..951588586 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java @@ -0,0 +1,165 @@ +/* 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.repositories.uploaders; + +import android.support.annotation.CheckResult; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.mozilla.gecko.background.common.log.Logger; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader.TokenModifiedException; +import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader.LastModifiedChangedUnexpectedly; +import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader.LastModifiedDidNotChange; + +/** + * Keeps track of token, Last-Modified value and GUIDs of succeeded records. + */ +/* @ThreadSafe */ +public class BatchMeta extends BufferSizeTracker { + private static final String LOG_TAG = "BatchMeta"; + + // Will be set once first payload upload succeeds. We don't expect this to change until we + // commit the batch, and which point it must change. + /* @GuardedBy("this") */ private Long lastModified; + + // Will be set once first payload upload succeeds. We don't expect this to ever change until + // a commit succeeds, at which point this gets set to null. + /* @GuardedBy("this") */ private String token; + + /* @GuardedBy("accessLock") */ private boolean isUnlimited = false; + + // Accessed by synchronously running threads. + /* @GuardedBy("accessLock") */ private final List<String> successRecordGuids = new ArrayList<>(); + + /* @GuardedBy("accessLock") */ private boolean needsCommit = false; + + protected final Long collectionLastModified; + + public BatchMeta(@NonNull Object payloadLock, long maxBytes, long maxRecords, @Nullable Long collectionLastModified) { + super(payloadLock, maxBytes, maxRecords); + this.collectionLastModified = collectionLastModified; + } + + protected void setIsUnlimited(boolean isUnlimited) { + synchronized (accessLock) { + this.isUnlimited = isUnlimited; + } + } + + @Override + protected boolean canFit(long recordDeltaByteCount) { + synchronized (accessLock) { + return isUnlimited || super.canFit(recordDeltaByteCount); + } + } + + @Override + @CheckResult + protected boolean addAndEstimateIfFull(long recordDeltaByteCount) { + synchronized (accessLock) { + needsCommit = true; + boolean isFull = super.addAndEstimateIfFull(recordDeltaByteCount); + return !isUnlimited && isFull; + } + } + + protected boolean needToCommit() { + synchronized (accessLock) { + return needsCommit; + } + } + + protected synchronized String getToken() { + return token; + } + + protected synchronized void setToken(final String newToken, boolean isCommit) throws TokenModifiedException { + // Set token once in a batching mode. + // In a non-batching mode, this.token and newToken will be null, and this is a no-op. + if (token == null) { + token = newToken; + return; + } + + // Sanity checks. + if (isCommit) { + // We expect token to be null when commit payload succeeds. + if (newToken != null) { + throw new TokenModifiedException(); + } else { + token = null; + } + return; + } + + // We expect new token to always equal current token for non-commit payloads. + if (!token.equals(newToken)) { + throw new TokenModifiedException(); + } + } + + protected synchronized Long getLastModified() { + if (lastModified == null) { + return collectionLastModified; + } + return lastModified; + } + + protected synchronized void setLastModified(final Long newLastModified, final boolean expectedToChange) throws LastModifiedChangedUnexpectedly, LastModifiedDidNotChange { + if (lastModified == null) { + lastModified = newLastModified; + return; + } + + if (!expectedToChange && !lastModified.equals(newLastModified)) { + Logger.debug(LOG_TAG, "Last-Modified timestamp changed when we didn't expect it"); + throw new LastModifiedChangedUnexpectedly(); + + } else if (expectedToChange && lastModified.equals(newLastModified)) { + Logger.debug(LOG_TAG, "Last-Modified timestamp did not change when we expected it to"); + throw new LastModifiedDidNotChange(); + + } else { + lastModified = newLastModified; + } + } + + protected ArrayList<String> getSuccessRecordGuids() { + synchronized (accessLock) { + return new ArrayList<>(this.successRecordGuids); + } + } + + protected void recordSucceeded(final String recordGuid) { + // Sanity check. + if (recordGuid == null) { + throw new IllegalStateException(); + } + + synchronized (accessLock) { + successRecordGuids.add(recordGuid); + } + } + + @Override + protected boolean canFitRecordByteDelta(long byteDelta, long recordCount, long byteCount) { + return isUnlimited || super.canFitRecordByteDelta(byteDelta, recordCount, byteCount); + } + + @Override + protected void reset() { + synchronized (accessLock) { + super.reset(); + token = null; + lastModified = null; + successRecordGuids.clear(); + needsCommit = false; + } + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java new file mode 100644 index 000000000..26efbd136 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java @@ -0,0 +1,344 @@ +/* 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.repositories.uploaders; + +import android.net.Uri; +import android.support.annotation.VisibleForTesting; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.InfoConfiguration; +import org.mozilla.gecko.sync.Server11RecordPostFailedException; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageResponse; +import org.mozilla.gecko.sync.repositories.Server11RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; + +import java.util.ArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Uploader which implements batching introduced in Sync 1.5. + * + * Batch vs payload terminology: + * - batch is comprised of a series of payloads, which are all committed at the same time. + * -- identified via a "batch token", which is returned after first payload for the batch has been uploaded. + * - payload is a collection of records which are uploaded together. Associated with a batch. + * -- last payload, identified via commit=true, commits the batch. + * + * Limits for how many records can fit into a payload and into a batch are defined in the passed-in + * InfoConfiguration object. + * + * If we can't fit everything we'd like to upload into one batch (according to max-total-* limits), + * then we commit that batch, and start a new one. There are no explicit limits on total number of + * batches we might use, although at some point we'll start to run into storage limit errors from the API. + * + * Once we go past using one batch this uploader is no longer "atomic". Partial state is exposed + * to other clients after our first batch is committed and before our last batch is committed. + * However, our per-batch limits are high, X-I-U-S mechanics help protect downloading clients + * (as long as they implement X-I-U-S) with 412 error codes in case of interleaving upload and download, + * and most mobile clients will not be uploading large-enough amounts of data (especially structured + * data, such as bookmarks). + * + * Last-Modified header returned with the first batch payload POST success is maintained for a batch, + * to guard against concurrent-modification errors (different uploader commits before we're done). + * + * Non-batching mode notes: + * We also support Sync servers which don't enable batching for uploads. In this case, we respect + * payload limits for individual uploads, and every upload is considered a commit. Batching limits + * do not apply, and batch token is irrelevant. + * We do keep track of Last-Modified and send along X-I-U-S with our uploads, to protect against + * concurrent modifications by other clients. + */ +public class BatchingUploader { + private static final String LOG_TAG = "BatchingUploader"; + + private final Uri collectionUri; + + private volatile boolean recordUploadFailed = false; + + private final BatchMeta batchMeta; + private final Payload payload; + + // Accessed by synchronously running threads, OK to not synchronize and just make it volatile. + private volatile Boolean inBatchingMode; + + // Used to ensure we have thread-safe access to the following: + // - byte and record counts in both Payload and BatchMeta objects + // - buffers in the Payload object + private final Object payloadLock = new Object(); + + protected Executor workQueue; + protected final RepositorySessionStoreDelegate sessionStoreDelegate; + protected final Server11RepositorySession repositorySession; + + protected AtomicLong uploadTimestamp = new AtomicLong(0); + + protected static final int PER_RECORD_OVERHEAD_BYTE_COUNT = RecordUploadRunnable.RECORD_SEPARATOR.length; + protected static final int PER_PAYLOAD_OVERHEAD_BYTE_COUNT = RecordUploadRunnable.RECORDS_END.length; + + // Sanity check. RECORD_SEPARATOR and RECORD_START are assumed to be of the same length. + static { + if (RecordUploadRunnable.RECORD_SEPARATOR.length != RecordUploadRunnable.RECORDS_START.length) { + throw new IllegalStateException("Separator and start tokens must be of the same length"); + } + } + + public BatchingUploader(final Server11RepositorySession repositorySession, final Executor workQueue, final RepositorySessionStoreDelegate sessionStoreDelegate) { + this.repositorySession = repositorySession; + this.workQueue = workQueue; + this.sessionStoreDelegate = sessionStoreDelegate; + this.collectionUri = Uri.parse(repositorySession.getServerRepository().collectionURI().toString()); + + InfoConfiguration config = repositorySession.getServerRepository().getInfoConfiguration(); + this.batchMeta = new BatchMeta( + payloadLock, config.maxTotalBytes, config.maxTotalRecords, + repositorySession.getServerRepository().getCollectionLastModified() + ); + this.payload = new Payload(payloadLock, config.maxPostBytes, config.maxPostRecords); + } + + public void process(final Record record) { + final String guid = record.guid; + final byte[] recordBytes = record.toJSONBytes(); + final long recordDeltaByteCount = recordBytes.length + PER_RECORD_OVERHEAD_BYTE_COUNT; + + Logger.debug(LOG_TAG, "Processing a record with guid: " + guid); + + // We can't upload individual records which exceed our payload byte limit. + if ((recordDeltaByteCount + PER_PAYLOAD_OVERHEAD_BYTE_COUNT) > payload.maxBytes) { + sessionStoreDelegate.onRecordStoreFailed(new RecordTooLargeToUpload(), guid); + return; + } + + synchronized (payloadLock) { + final boolean canFitRecordIntoBatch = batchMeta.canFit(recordDeltaByteCount); + final boolean canFitRecordIntoPayload = payload.canFit(recordDeltaByteCount); + + // Record fits! + if (canFitRecordIntoBatch && canFitRecordIntoPayload) { + Logger.debug(LOG_TAG, "Record fits into the current batch and payload"); + addAndFlushIfNecessary(recordDeltaByteCount, recordBytes, guid); + + // Payload won't fit the record. + } else if (canFitRecordIntoBatch) { + Logger.debug(LOG_TAG, "Current payload won't fit incoming record, uploading payload."); + flush(false, false); + + Logger.debug(LOG_TAG, "Recording the incoming record into a new payload"); + + // Keep track of the overflow record. + addAndFlushIfNecessary(recordDeltaByteCount, recordBytes, guid); + + // Batch won't fit the record. + } else { + Logger.debug(LOG_TAG, "Current batch won't fit incoming record, committing batch."); + flush(true, false); + + Logger.debug(LOG_TAG, "Recording the incoming record into a new batch"); + batchMeta.reset(); + + // Keep track of the overflow record. + addAndFlushIfNecessary(recordDeltaByteCount, recordBytes, guid); + } + } + } + + // Convenience function used from the process method; caller must hold a payloadLock. + private void addAndFlushIfNecessary(long byteCount, byte[] recordBytes, String guid) { + boolean isPayloadFull = payload.addAndEstimateIfFull(byteCount, recordBytes, guid); + boolean isBatchFull = batchMeta.addAndEstimateIfFull(byteCount); + + // Preemptive commit batch or upload a payload if they're estimated to be full. + if (isBatchFull) { + flush(true, false); + batchMeta.reset(); + } else if (isPayloadFull) { + flush(false, false); + } + } + + public void noMoreRecordsToUpload() { + Logger.debug(LOG_TAG, "Received 'no more records to upload' signal."); + + // Run this after the last payload succeeds, so that we know for sure if we're in a batching + // mode and need to commit with a potentially empty payload. + workQueue.execute(new Runnable() { + @Override + public void run() { + commitIfNecessaryAfterLastPayload(); + } + }); + } + + @VisibleForTesting + protected void commitIfNecessaryAfterLastPayload() { + // Must be called after last payload upload finishes. + synchronized (payload) { + // If we have any pending records in the Payload, flush them! + if (!payload.isEmpty()) { + flush(true, true); + + // If we have an empty payload but need to commit the batch in the batching mode, flush! + } else if (batchMeta.needToCommit() && Boolean.TRUE.equals(inBatchingMode)) { + flush(true, true); + + // Otherwise, we're done. + } else { + finished(uploadTimestamp); + } + } + } + + /** + * We've been told by our upload delegate that a payload succeeded. + * Depending on the type of payload and batch mode status, inform our delegate of progress. + * + * @param response success response to our commit post + * @param isCommit was this a commit upload? + * @param isLastPayload was this a very last payload we'll upload? + */ + public void payloadSucceeded(final SyncStorageResponse response, final boolean isCommit, final boolean isLastPayload) { + // Sanity check. + if (inBatchingMode == null) { + throw new IllegalStateException("Can't process payload success until we know if we're in a batching mode"); + } + + // We consider records to have been committed if we're not in a batching mode or this was a commit. + // If records have been committed, notify our store delegate. + if (!inBatchingMode || isCommit) { + for (String guid : batchMeta.getSuccessRecordGuids()) { + sessionStoreDelegate.onRecordStoreSucceeded(guid); + } + } + + // If this was our very last commit, we're done storing records. + // Get Last-Modified timestamp from the response, and pass it upstream. + if (isLastPayload) { + finished(response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED)); + } + } + + public void lastPayloadFailed() { + finished(uploadTimestamp); + } + + private void finished(long lastModifiedTimestamp) { + bumpTimestampTo(uploadTimestamp, lastModifiedTimestamp); + finished(uploadTimestamp); + } + + private void finished(AtomicLong lastModifiedTimestamp) { + repositorySession.storeDone(lastModifiedTimestamp.get()); + } + + public BatchMeta getCurrentBatch() { + return batchMeta; + } + + public void setInBatchingMode(boolean inBatchingMode) { + this.inBatchingMode = inBatchingMode; + + // If we know for sure that we're not in a batching mode, + // consider our batch to be of unlimited size. + this.batchMeta.setIsUnlimited(!inBatchingMode); + } + + public Boolean getInBatchingMode() { + return inBatchingMode; + } + + public void setLastModified(final Long lastModified, final boolean isCommit) throws BatchingUploaderException { + // Sanity check. + if (inBatchingMode == null) { + throw new IllegalStateException("Can't process Last-Modified before we know we're in a batching mode."); + } + + // In non-batching mode, every time we receive a Last-Modified timestamp, we expect it to change + // since records are "committed" (become visible to other clients) on every payload. + // In batching mode, we only expect Last-Modified to change when we commit a batch. + batchMeta.setLastModified(lastModified, isCommit || !inBatchingMode); + } + + public void recordSucceeded(final String recordGuid) { + Logger.debug(LOG_TAG, "Record store succeeded: " + recordGuid); + batchMeta.recordSucceeded(recordGuid); + } + + public void recordFailed(final String recordGuid) { + recordFailed(new Server11RecordPostFailedException(), recordGuid); + } + + public void recordFailed(final Exception e, final String recordGuid) { + Logger.debug(LOG_TAG, "Record store failed for guid " + recordGuid + " with exception: " + e.toString()); + recordUploadFailed = true; + sessionStoreDelegate.onRecordStoreFailed(e, recordGuid); + } + + public Server11RepositorySession getRepositorySession() { + return repositorySession; + } + + private static void bumpTimestampTo(final AtomicLong current, long newValue) { + while (true) { + long existing = current.get(); + if (existing > newValue) { + return; + } + if (current.compareAndSet(existing, newValue)) { + return; + } + } + } + + private void flush(final boolean isCommit, final boolean isLastPayload) { + final ArrayList<byte[]> outgoing; + final ArrayList<String> outgoingGuids; + final long byteCount; + + // Even though payload object itself is thread-safe, we want to ensure we get these altogether + // as a "unit". Another approach would be to create a wrapper object for these values, but this works. + synchronized (payloadLock) { + outgoing = payload.getRecordsBuffer(); + outgoingGuids = payload.getRecordGuidsBuffer(); + byteCount = payload.getByteCount(); + } + + workQueue.execute(new RecordUploadRunnable( + new BatchingAtomicUploaderMayUploadProvider(), + collectionUri, + batchMeta, + new PayloadUploadDelegate(this, outgoingGuids, isCommit, isLastPayload), + outgoing, + byteCount, + isCommit + )); + + payload.reset(); + } + + private class BatchingAtomicUploaderMayUploadProvider implements MayUploadProvider { + public boolean mayUpload() { + return !recordUploadFailed; + } + } + + public static class BatchingUploaderException extends Exception { + private static final long serialVersionUID = 1L; + } + public static class RecordTooLargeToUpload extends BatchingUploaderException { + private static final long serialVersionUID = 1L; + } + public static class LastModifiedDidNotChange extends BatchingUploaderException { + private static final long serialVersionUID = 1L; + } + public static class LastModifiedChangedUnexpectedly extends BatchingUploaderException { + private static final long serialVersionUID = 1L; + } + public static class TokenModifiedException extends BatchingUploaderException { + private static final long serialVersionUID = 1L; + }; +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java new file mode 100644 index 000000000..7f4c305f3 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java @@ -0,0 +1,103 @@ +/* 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.repositories.uploaders; + +import android.support.annotation.CallSuper; +import android.support.annotation.CheckResult; + +/** + * Implements functionality shared by BatchMeta and Payload objects, namely: + * - keeping track of byte and record counts + * - incrementing those counts when records are added + * - checking if a record can fit + */ +/* @ThreadSafe */ +public abstract class BufferSizeTracker { + protected final Object accessLock; + + /* @GuardedBy("accessLock") */ private long byteCount = BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT; + /* @GuardedBy("accessLock") */ private long recordCount = 0; + /* @GuardedBy("accessLock") */ protected Long smallestRecordByteCount; + + protected final long maxBytes; + protected final long maxRecords; + + public BufferSizeTracker(Object accessLock, long maxBytes, long maxRecords) { + this.accessLock = accessLock; + this.maxBytes = maxBytes; + this.maxRecords = maxRecords; + } + + @CallSuper + protected boolean canFit(long recordDeltaByteCount) { + synchronized (accessLock) { + return canFitRecordByteDelta(recordDeltaByteCount, recordCount, byteCount); + } + } + + protected boolean isEmpty() { + synchronized (accessLock) { + return recordCount == 0; + } + } + + /** + * Adds a record and returns a boolean indicating whether batch is estimated to be full afterwards. + */ + @CheckResult + protected boolean addAndEstimateIfFull(long recordDeltaByteCount) { + synchronized (accessLock) { + // Sanity check. Calling this method when buffer won't fit the record is an error. + if (!canFitRecordByteDelta(recordDeltaByteCount, recordCount, byteCount)) { + throw new IllegalStateException("Buffer size exceeded"); + } + + byteCount += recordDeltaByteCount; + recordCount += 1; + + if (smallestRecordByteCount == null || smallestRecordByteCount > recordDeltaByteCount) { + smallestRecordByteCount = recordDeltaByteCount; + } + + // See if we're full or nearly full after adding a record. + // We're halving smallestRecordByteCount because we're erring + // on the side of "can hopefully fit". We're trying to upload as soon as we know we + // should, but we also need to be mindful of minimizing total number of uploads we make. + return !canFitRecordByteDelta(smallestRecordByteCount / 2, recordCount, byteCount); + } + } + + protected long getByteCount() { + synchronized (accessLock) { + // Ensure we account for payload overhead twice when the batch is empty. + // Payload overhead is either RECORDS_START ("[") or RECORDS_END ("]"), + // and for an empty payload we need account for both ("[]"). + if (recordCount == 0) { + return byteCount + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT; + } + return byteCount; + } + } + + protected long getRecordCount() { + synchronized (accessLock) { + return recordCount; + } + } + + @CallSuper + protected void reset() { + synchronized (accessLock) { + byteCount = BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT; + recordCount = 0; + } + } + + @CallSuper + protected boolean canFitRecordByteDelta(long byteDelta, long recordCount, long byteCount) { + return recordCount < maxRecords + && (byteCount + byteDelta) <= maxBytes; + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java new file mode 100644 index 000000000..a1994cf62 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java @@ -0,0 +1,9 @@ +/* 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.repositories.uploaders; + +public interface MayUploadProvider { + boolean mayUpload(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java new file mode 100644 index 000000000..1ed9b5798 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java @@ -0,0 +1,66 @@ +/* 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.repositories.uploaders; + +import android.support.annotation.CheckResult; + +import java.util.ArrayList; + +/** + * Owns per-payload record byte and recordGuid buffers. + */ +/* @ThreadSafe */ +public class Payload extends BufferSizeTracker { + // Data of outbound records. + /* @GuardedBy("accessLock") */ private final ArrayList<byte[]> recordsBuffer = new ArrayList<>(); + + // GUIDs of outbound records. Used to fail entire payloads. + /* @GuardedBy("accessLock") */ private final ArrayList<String> recordGuidsBuffer = new ArrayList<>(); + + public Payload(Object payloadLock, long maxBytes, long maxRecords) { + super(payloadLock, maxBytes, maxRecords); + } + + @Override + protected boolean addAndEstimateIfFull(long recordDelta) { + throw new UnsupportedOperationException(); + } + + @CheckResult + protected boolean addAndEstimateIfFull(long recordDelta, byte[] recordBytes, String guid) { + synchronized (accessLock) { + recordsBuffer.add(recordBytes); + recordGuidsBuffer.add(guid); + return super.addAndEstimateIfFull(recordDelta); + } + } + + @Override + protected void reset() { + synchronized (accessLock) { + super.reset(); + recordsBuffer.clear(); + recordGuidsBuffer.clear(); + } + } + + protected ArrayList<byte[]> getRecordsBuffer() { + synchronized (accessLock) { + return new ArrayList<>(recordsBuffer); + } + } + + protected ArrayList<String> getRecordGuidsBuffer() { + synchronized (accessLock) { + return new ArrayList<>(recordGuidsBuffer); + } + } + + protected boolean isEmpty() { + synchronized (accessLock) { + return recordsBuffer.isEmpty(); + } + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java new file mode 100644 index 000000000..e8bbb7df6 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java @@ -0,0 +1,185 @@ +/* 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.repositories.uploaders; + +import org.json.simple.JSONArray; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +import java.util.ArrayList; + +public class PayloadUploadDelegate implements SyncStorageRequestDelegate { + private static final String LOG_TAG = "PayloadUploadDelegate"; + + private static final String KEY_BATCH = "batch"; + + private final BatchingUploader uploader; + private ArrayList<String> postedRecordGuids; + private final boolean isCommit; + private final boolean isLastPayload; + + public PayloadUploadDelegate(BatchingUploader uploader, ArrayList<String> postedRecordGuids, boolean isCommit, boolean isLastPayload) { + this.uploader = uploader; + this.postedRecordGuids = postedRecordGuids; + this.isCommit = isCommit; + this.isLastPayload = isLastPayload; + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return uploader.getRepositorySession().getServerRepository().getAuthHeaderProvider(); + } + + @Override + public String ifUnmodifiedSince() { + final Long lastModified = uploader.getCurrentBatch().getLastModified(); + if (lastModified == null) { + return null; + } + return Utils.millisecondsToDecimalSecondsString(lastModified); + } + + @Override + public void handleRequestSuccess(final SyncStorageResponse response) { + // First, do some sanity checking. + if (response.getStatusCode() != 200 && response.getStatusCode() != 202) { + handleRequestError( + new IllegalStateException("handleRequestSuccess received a non-200/202 response: " + response.getStatusCode()) + ); + return; + } + + // We always expect to see a Last-Modified header. It's returned with every success response. + if (!response.httpResponse().containsHeader(SyncResponse.X_LAST_MODIFIED)) { + handleRequestError( + new IllegalStateException("Response did not have a Last-Modified header") + ); + return; + } + + // We expect to be able to parse the response as a JSON object. + final ExtendedJSONObject body; + try { + body = response.jsonObjectBody(); // jsonObjectBody() throws or returns non-null. + } catch (Exception e) { + Logger.error(LOG_TAG, "Got exception parsing POST success body.", e); + this.handleRequestError(e); + return; + } + + // If we got a 200, it could be either a non-batching result, or a batch commit. + // - if we're in a batching mode, we expect this to be a commit. + // If we got a 202, we expect there to be a token present in the response + if (response.getStatusCode() == 200 && uploader.getCurrentBatch().getToken() != null) { + if (uploader.getInBatchingMode() && !isCommit) { + handleRequestError( + new IllegalStateException("Got 200 OK in batching mode, but this was not a commit payload") + ); + return; + } + } else if (response.getStatusCode() == 202) { + if (!body.containsKey(KEY_BATCH)) { + handleRequestError( + new IllegalStateException("Batch response did not have a batch ID") + ); + return; + } + } + + // With sanity checks out of the way, can now safely say if we're in a batching mode or not. + // We only do this once per session. + if (uploader.getInBatchingMode() == null) { + uploader.setInBatchingMode(body.containsKey(KEY_BATCH)); + } + + // Tell current batch about the token we've received. + // Throws if token changed after being set once, or if we got a non-null token after a commit. + try { + uploader.getCurrentBatch().setToken(body.getString(KEY_BATCH), isCommit); + } catch (BatchingUploader.BatchingUploaderException e) { + handleRequestError(e); + return; + } + + // Will throw if Last-Modified changed when it shouldn't have. + try { + uploader.setLastModified( + response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED), + isCommit); + } catch (BatchingUploader.BatchingUploaderException e) { + handleRequestError(e); + return; + } + + // All looks good up to this point, let's process success and failed arrays. + JSONArray success; + try { + success = body.getArray("success"); + } catch (NonArrayJSONException e) { + handleRequestError(e); + return; + } + + if (success != null && !success.isEmpty()) { + Logger.trace(LOG_TAG, "Successful records: " + success.toString()); + for (Object o : success) { + try { + uploader.recordSucceeded((String) o); + } catch (ClassCastException e) { + Logger.error(LOG_TAG, "Got exception parsing POST success guid.", e); + // Not much to be done. + } + } + } + // GC + success = null; + + ExtendedJSONObject failed; + try { + failed = body.getObject("failed"); + } catch (NonObjectJSONException e) { + handleRequestError(e); + return; + } + + if (failed != null && !failed.object.isEmpty()) { + Logger.debug(LOG_TAG, "Failed records: " + failed.object.toString()); + for (String guid : failed.keySet()) { + uploader.recordFailed(guid); + } + } + // GC + failed = null; + + // And we're done! Let uploader finish up. + uploader.payloadSucceeded(response, isCommit, isLastPayload); + } + + @Override + public void handleRequestFailure(final SyncStorageResponse response) { + this.handleRequestError(new HTTPFailureException(response)); + } + + @Override + public void handleRequestError(Exception e) { + for (String guid : postedRecordGuids) { + uploader.recordFailed(e, guid); + } + // GC + postedRecordGuids = null; + + if (isLastPayload) { + uploader.lastPayloadFailed(); + } + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java new file mode 100644 index 000000000..ce2955102 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java @@ -0,0 +1,176 @@ +/* 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.repositories.uploaders; + +import android.net.Uri; +import android.support.annotation.VisibleForTesting; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.Server11PreviousPostFailedException; +import org.mozilla.gecko.sync.net.SyncStorageRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; + +import ch.boye.httpclientandroidlib.entity.ContentProducer; +import ch.boye.httpclientandroidlib.entity.EntityTemplate; + +/** + * Responsible for creating and posting a <code>SyncStorageRequest</code> request object. + */ +public class RecordUploadRunnable implements Runnable { + public final String LOG_TAG = "RecordUploadRunnable"; + + public final static byte[] RECORDS_START = { 91 }; // [ in UTF-8 + public final static byte[] RECORD_SEPARATOR = { 44 }; // , in UTF-8 + public final static byte[] RECORDS_END = { 93 }; // ] in UTF-8 + + private static final String QUERY_PARAM_BATCH = "batch"; + private static final String QUERY_PARAM_TRUE = "true"; + private static final String QUERY_PARAM_BATCH_COMMIT = "commit"; + + private final MayUploadProvider mayUploadProvider; + private final SyncStorageRequestDelegate uploadDelegate; + + private final ArrayList<byte[]> outgoing; + private final long byteCount; + + // Used to construct POST URI during run(). + @VisibleForTesting + public final boolean isCommit; + private final Uri collectionUri; + private final BatchMeta batchMeta; + + public RecordUploadRunnable(MayUploadProvider mayUploadProvider, + Uri collectionUri, + BatchMeta batchMeta, + SyncStorageRequestDelegate uploadDelegate, + ArrayList<byte[]> outgoing, + long byteCount, + boolean isCommit) { + this.mayUploadProvider = mayUploadProvider; + this.uploadDelegate = uploadDelegate; + this.outgoing = outgoing; + this.byteCount = byteCount; + this.batchMeta = batchMeta; + this.collectionUri = collectionUri; + this.isCommit = isCommit; + } + + public static class ByteArraysContentProducer implements ContentProducer { + ArrayList<byte[]> outgoing; + public ByteArraysContentProducer(ArrayList<byte[]> arrays) { + outgoing = arrays; + } + + @Override + public void writeTo(OutputStream outstream) throws IOException { + int count = outgoing.size(); + outstream.write(RECORDS_START); + if (count > 0) { + outstream.write(outgoing.get(0)); + for (int i = 1; i < count; ++i) { + outstream.write(RECORD_SEPARATOR); + outstream.write(outgoing.get(i)); + } + } + outstream.write(RECORDS_END); + } + + public static long outgoingBytesCount(ArrayList<byte[]> outgoing) { + final long numberOfRecords = outgoing.size(); + + // Account for start and end tokens. + long count = RECORDS_START.length + RECORDS_END.length; + + // Account for all the records. + for (int i = 0; i < numberOfRecords; i++) { + count += outgoing.get(i).length; + } + + // Account for a separator between the records. + // There's one less separator than there are records. + if (numberOfRecords > 1) { + count += RECORD_SEPARATOR.length * (numberOfRecords - 1); + } + + return count; + } + } + + public static class ByteArraysEntity extends EntityTemplate { + private final long count; + public ByteArraysEntity(ArrayList<byte[]> arrays, long totalBytes) { + super(new ByteArraysContentProducer(arrays)); + this.count = totalBytes; + this.setContentType("application/json"); + // charset is set in BaseResource. + + // Sanity check our byte counts. + long realByteCount = ByteArraysContentProducer.outgoingBytesCount(arrays); + if (realByteCount != totalBytes) { + throw new IllegalStateException("Mismatched byte counts. Received " + totalBytes + " while real byte count is " + realByteCount); + } + } + + @Override + public long getContentLength() { + return count; + } + + @Override + public boolean isRepeatable() { + return true; + } + } + + @Override + public void run() { + if (!mayUploadProvider.mayUpload()) { + Logger.info(LOG_TAG, "Told not to proceed by the uploader. Cancelling upload, failing records."); + uploadDelegate.handleRequestError(new Server11PreviousPostFailedException()); + return; + } + + Logger.trace(LOG_TAG, "Running upload task. Outgoing records: " + outgoing.size()); + + // We don't want the task queue to proceed until this request completes. + // Fortunately, BaseResource is currently synchronous. + // If that ever changes, you'll need to block here. + + final URI postURI = buildPostURI(isCommit, batchMeta, collectionUri); + final SyncStorageRequest request = new SyncStorageRequest(postURI); + request.delegate = uploadDelegate; + + ByteArraysEntity body = new ByteArraysEntity(outgoing, byteCount); + request.post(body); + } + + @VisibleForTesting + public static URI buildPostURI(boolean isCommit, BatchMeta batchMeta, Uri collectionUri) { + final Uri.Builder uriBuilder = collectionUri.buildUpon(); + final String batchToken = batchMeta.getToken(); + + if (batchToken != null) { + uriBuilder.appendQueryParameter(QUERY_PARAM_BATCH, batchToken); + } else { + uriBuilder.appendQueryParameter(QUERY_PARAM_BATCH, QUERY_PARAM_TRUE); + } + + if (isCommit) { + uriBuilder.appendQueryParameter(QUERY_PARAM_BATCH_COMMIT, QUERY_PARAM_TRUE); + } + + try { + return new URI(uriBuilder.build().toString()); + } catch (URISyntaxException e) { + throw new IllegalStateException("Failed to construct a collection URI", e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java new file mode 100644 index 000000000..66e6768b4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java @@ -0,0 +1,29 @@ +/* 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.setup; + +public class Constants { + public static final String DEFAULT_PROFILE = "default"; + + /** + * Key in sync extras bundle specifying stages to sync this sync session. + * <p> + * Corresponding value should be a String JSON-encoding an object, the keys of + * which are the stage names to sync. For example: + * <code>"{ \"stageToSync\": 0 }"</code>. + */ + public static final String EXTRAS_KEY_STAGES_TO_SYNC = "sync"; + + /** + * Key in sync extras bundle specifying stages to skip this sync session. + * <p> + * Corresponding value should be a String JSON-encoding an object, the keys of + * which are the stage names to skip. For example: + * <code>"{ \"stageToSkip\": 0 }"</code>. + */ + public static final String EXTRAS_KEY_STAGES_TO_SKIP = "skip"; + + public static final String JSON_KEY_ACCOUNT = "account"; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java new file mode 100644 index 000000000..ac0fd58d0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java @@ -0,0 +1,9 @@ +/* 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.setup; + +public class InvalidSyncKeyException extends Exception { + private static final long serialVersionUID = -6504925951580479894L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java new file mode 100644 index 000000000..6542e1b00 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java @@ -0,0 +1,34 @@ +/* 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.setup.activities; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.db.BrowserContract; + +public class ActivityUtils { + /** + * Open a URL in Fennec, if one is provided; or just open Fennec. + * + * @param context Android context. + * @param url to visit, or null to just open Fennec. + */ + public static void openURLInFennec(final Context context, final String url) { + Intent intent; + if (url != null) { + intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(url)); + } else { + intent = new Intent(Intent.ACTION_MAIN); + } + intent.setClassName(GlobalConstants.BROWSER_INTENT_PACKAGE, GlobalConstants.BROWSER_INTENT_CLASS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, true); + context.startActivity(intent); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java new file mode 100644 index 000000000..8411d2a62 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java @@ -0,0 +1,161 @@ +/* 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.setup.activities; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class WebURLFinder { + /** + * These regular expressions are taken from Android's Patterns.java. + * We brought them in to standardize URL matching across Android versions, instead of relying + * on Android version-dependent built-ins that can vary across Android versions. + * The original code can be found here: + * http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/util/Patterns.java + * + */ + public static final String GOOD_IRI_CHAR = "a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF"; + public static final String GOOD_GTLD_CHAR = "a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF"; + public static final String IRI = "[" + GOOD_IRI_CHAR + "]([" + GOOD_IRI_CHAR + "\\-]{0,61}[" + GOOD_IRI_CHAR + "]){0,1}"; + public static final String GTLD = "[" + GOOD_GTLD_CHAR + "]{2,63}"; + public static final String HOST_NAME = "(" + IRI + "\\.)+" + GTLD; + public static final Pattern IP_ADDRESS = Pattern.compile("((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" + + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" + + "|[1-9][0-9]|[0-9]))"); + public static final Pattern DOMAIN_NAME = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")"); + public static final Pattern WEB_URL = Pattern.compile("((?:(http|https|Http|Https|rtsp|Rtsp):\\/\\/(?:(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" + + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?" + + "(?:" + DOMAIN_NAME + ")" + + "(?:\\:\\d{1,5})?)" + + "(\\/(?:(?:[" + GOOD_IRI_CHAR + "\\;\\/\\?\\:\\@\\&\\=\\#\\~" + + "\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])|(?:\\%[a-fA-F0-9]{2}))*)?" + + "(?:\\b|$)"); + + public final List<String> candidates; + + public WebURLFinder(String string) { + if (string == null) { + throw new IllegalArgumentException("string must not be null"); + } + + this.candidates = candidateWebURLs(string); + } + + public WebURLFinder(List<String> strings) { + if (strings == null) { + throw new IllegalArgumentException("strings must not be null"); + } + + this.candidates = candidateWebURLs(strings); + } + + /** + * Check if string is a Web URL. + * <p> + * A Web URL is a URI that is not a <code>file:</code> or + * <code>javascript:</code> scheme. + * + * @param string + * to check. + * @return <code>true</code> if <code>string</code> is a Web URL. + */ + public static boolean isWebURL(String string) { + try { + new URI(string); + } catch (Exception e) { + return false; + } + + if (android.webkit.URLUtil.isFileUrl(string) || + android.webkit.URLUtil.isJavaScriptUrl(string)) { + return false; + } + + return true; + } + + /** + * Return best Web URL. + * <p> + * "Best" means a Web URL with a scheme, and failing that, a Web URL without a + * scheme. + * + * @return a Web URL or <code>null</code>. + */ + public String bestWebURL() { + String firstWebURLWithScheme = firstWebURLWithScheme(); + if (firstWebURLWithScheme != null) { + return firstWebURLWithScheme; + } + + return firstWebURLWithoutScheme(); + } + + protected static List<String> candidateWebURLs(Collection<String> strings) { + List<String> candidates = new ArrayList<String>(); + + for (String string : strings) { + if (string == null) { + continue; + } + + candidates.addAll(candidateWebURLs(string)); + } + + return candidates; + } + + protected static List<String> candidateWebURLs(String string) { + Matcher matcher = WEB_URL.matcher(string); + List<String> matches = new LinkedList<String>(); + + while (matcher.find()) { + // Remove URLs with bad schemes. + if (!isWebURL(matcher.group())) { + continue; + } + + // Remove parts of email addresses. + if (matcher.start() > 0 && (string.charAt(matcher.start() - 1) == '@')) { + continue; + } + + matches.add(matcher.group()); + } + + return matches; + } + + protected String firstWebURLWithScheme() { + for (String match : candidates) { + try { + if (new URI(match).getScheme() != null) { + return match; + } + } catch (URISyntaxException e) { + // Ignore: on to the next. + continue; + } + } + + return null; + } + + protected String firstWebURLWithoutScheme() { + if (!candidates.isEmpty()) { + return candidates.get(0); + } + + return null; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java new file mode 100644 index 000000000..c910216eb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java @@ -0,0 +1,26 @@ +/* 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.stage; + + +/** + * This is simply a stage that is not responsible for synchronizing repositories. + */ +public abstract class AbstractNonRepositorySyncStage extends AbstractSessionManagingSyncStage { + @Override + protected void resetLocal() { + // Do nothing. + } + + @Override + protected void wipeLocal() { + // Do nothing. + } + + @Override + public Integer getStorageVersion() { + return null; // Never include these engines in any meta/global records. + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java new file mode 100644 index 000000000..6592c3baa --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java @@ -0,0 +1,43 @@ +/* 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.stage; + +import org.mozilla.gecko.sync.GlobalSession; + +/** + * A global sync stage that manages a <code>GlobalSession</code> instance. This + * class is intended to be temporary: it should disappear as work to make + * data-driven syncs progresses. + * <p> + * This class is inherently <b>thread-unsafe</b>: if <code>session</code> is + * mutated after being set, all sorts of bad things could occur. At the time of + * writing, every <code>GlobalSyncStage</code> created is executed (wiped, + * reset) with the same <code>GlobalSession</code> argument. + */ +public abstract class AbstractSessionManagingSyncStage implements GlobalSyncStage { + protected GlobalSession session; + + protected abstract void execute() throws NoSuchStageException; + protected abstract void resetLocal(); + protected abstract void wipeLocal() throws Exception; + + @Override + public void resetLocal(GlobalSession session) { + this.session = session; + resetLocal(); + } + + @Override + public void wipeLocal(GlobalSession session) throws Exception { + this.session = session; + wipeLocal(); + } + + @Override + public void execute(GlobalSession session) throws NoSuchStageException { + this.session = session; + execute(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java new file mode 100644 index 000000000..10e209230 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java @@ -0,0 +1,80 @@ +/* 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.stage; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.sync.JSONRecordFetcher; +import org.mozilla.gecko.sync.MetaGlobalException; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.android.AndroidBrowserBookmarksRepository; +import org.mozilla.gecko.sync.repositories.domain.BookmarkRecordFactory; +import org.mozilla.gecko.sync.repositories.domain.VersionConstants; + +public class AndroidBrowserBookmarksServerSyncStage extends ServerSyncStage { + protected static final String LOG_TAG = "BookmarksStage"; + + // Eventually this kind of sync stage will be data-driven, + // and all this hard-coding can go away. + private static final String BOOKMARKS_SORT = "index"; + // Sanity limit. Batch and total limit are the same for now, and will be adjusted + // once buffer and high water mark are in place. See Bug 730142. + private static final long BOOKMARKS_BATCH_LIMIT = 5000; + private static final long BOOKMARKS_TOTAL_LIMIT = 5000; + + @Override + protected String getCollection() { + return "bookmarks"; + } + + @Override + protected String getEngineName() { + return "bookmarks"; + } + + @Override + public Integer getStorageVersion() { + return VersionConstants.BOOKMARKS_ENGINE_VERSION; + } + + @Override + protected Repository getRemoteRepository() throws URISyntaxException { + // If this is a first sync, we need to check server counts to make sure that we aren't + // going to screw up. SafeConstrainedServer11Repository does this. See Bug 814331. + AuthHeaderProvider authHeaderProvider = session.getAuthHeaderProvider(); + final JSONRecordFetcher countsFetcher = new JSONRecordFetcher(session.config.infoCollectionCountsURL(), authHeaderProvider); + String collection = getCollection(); + return new SafeConstrainedServer11Repository( + collection, + session.config.storageURL(), + session.getAuthHeaderProvider(), + session.config.infoCollections, + session.config.infoConfiguration, + BOOKMARKS_BATCH_LIMIT, + BOOKMARKS_TOTAL_LIMIT, + BOOKMARKS_SORT, + countsFetcher); + } + + @Override + protected Repository getLocalRepository() { + return new AndroidBrowserBookmarksRepository(); + } + + @Override + protected RecordFactory getRecordFactory() { + return new BookmarkRecordFactory(); + } + + @Override + protected boolean isEnabled() throws MetaGlobalException { + if (session == null || session.getContext() == null) { + return false; + } + return super.isEnabled(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java new file mode 100644 index 000000000..947a10898 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java @@ -0,0 +1,74 @@ +/* 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.stage; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.sync.MetaGlobalException; +import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.android.AndroidBrowserHistoryRepository; +import org.mozilla.gecko.sync.repositories.domain.HistoryRecordFactory; +import org.mozilla.gecko.sync.repositories.domain.VersionConstants; + +public class AndroidBrowserHistoryServerSyncStage extends ServerSyncStage { + protected static final String LOG_TAG = "HistoryStage"; + + // Eventually this kind of sync stage will be data-driven, + // and all this hard-coding can go away. + private static final String HISTORY_SORT = "index"; + // Sanity limit. Batch and total limit are the same for now, and will be adjusted + // once buffer and high water mark are in place. See Bug 730142. + private static final long HISTORY_BATCH_LIMIT = 250; + private static final long HISTORY_TOTAL_LIMIT = 250; + + @Override + protected String getCollection() { + return "history"; + } + + @Override + protected String getEngineName() { + return "history"; + } + + @Override + public Integer getStorageVersion() { + return VersionConstants.HISTORY_ENGINE_VERSION; + } + + @Override + protected Repository getLocalRepository() { + return new AndroidBrowserHistoryRepository(); + } + + @Override + protected Repository getRemoteRepository() throws URISyntaxException { + String collection = getCollection(); + return new ConstrainedServer11Repository( + collection, + session.config.storageURL(), + session.getAuthHeaderProvider(), + session.config.infoCollections, + session.config.infoConfiguration, + HISTORY_BATCH_LIMIT, + HISTORY_TOTAL_LIMIT, + HISTORY_SORT); + } + + @Override + protected RecordFactory getRecordFactory() { + return new HistoryRecordFactory(); + } + + @Override + protected boolean isEnabled() throws MetaGlobalException { + if (session == null || session.getContext() == null) { + return false; + } + return super.isEnabled(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java new file mode 100644 index 000000000..b33f83ad1 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java @@ -0,0 +1,13 @@ +/* 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.stage; + + +public class CheckPreconditionsStage extends AbstractNonRepositorySyncStage { + @Override + public void execute() throws NoSuchStageException { + session.advance(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java new file mode 100644 index 000000000..7ec776324 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java @@ -0,0 +1,16 @@ +/* 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.stage; + + + +public class CompletedStage extends AbstractNonRepositorySyncStage { + @Override + public void execute() throws NoSuchStageException { + // TODO: Update tracking timestamps, close connections, etc. + // TODO: call clean() on each Repository in the sync constellation. + session.completeSync(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java new file mode 100644 index 000000000..5031cf770 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java @@ -0,0 +1,192 @@ +/* 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.stage; + +import java.net.URISyntaxException; +import java.util.HashSet; +import java.util.Set; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.CollectionKeys; +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.InfoCollections; +import org.mozilla.gecko.sync.NoCollectionKeysSetException; +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.net.SyncStorageRecordRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +public class EnsureCrypto5KeysStage +extends AbstractNonRepositorySyncStage +implements SyncStorageRequestDelegate { + + private static final String LOG_TAG = "EnsureC5KeysStage"; + private static final String CRYPTO_COLLECTION = "crypto"; + protected boolean retrying = false; + + @Override + public void execute() throws NoSuchStageException { + InfoCollections infoCollections = session.config.infoCollections; + if (infoCollections == null) { + session.abort(null, "No info/collections set in EnsureCrypto5KeysStage."); + return; + } + + PersistedCrypto5Keys pck = session.config.persistedCryptoKeys(); + long lastModified = pck.lastModified(); + if (retrying || !infoCollections.updateNeeded(CRYPTO_COLLECTION, lastModified)) { + // Try to use our local collection keys for this session. + Logger.debug(LOG_TAG, "Trying to use persisted collection keys for this session."); + CollectionKeys keys = pck.keys(); + if (keys != null) { + Logger.trace(LOG_TAG, "Using persisted collection keys for this session."); + session.config.setCollectionKeys(keys); + session.advance(); + return; + } + Logger.trace(LOG_TAG, "Failed to use persisted collection keys for this session."); + } + + // We need an update: fetch fresh keys. + Logger.debug(LOG_TAG, "Fetching fresh collection keys for this session."); + try { + SyncStorageRecordRequest request = new SyncStorageRecordRequest(session.wboURI(CRYPTO_COLLECTION, "keys")); + request.delegate = this; + request.get(); + } catch (URISyntaxException e) { + session.abort(e, "Invalid URI."); + } + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return session.getAuthHeaderProvider(); + } + + @Override + public String ifUnmodifiedSince() { + // TODO: last key time! + return null; + } + + protected void setAndPersist(PersistedCrypto5Keys pck, CollectionKeys keys, long timestamp) { + session.config.setCollectionKeys(keys); + pck.persistKeys(keys); + pck.persistLastModified(timestamp); + } + + /** + * Return collections where either the individual key has changed, or if the + * new default key is not the same as the old default key, where the + * collection is using the default key. + */ + protected Set<String> collectionsToUpdate(CollectionKeys oldKeys, CollectionKeys newKeys) { + // These keys have explicitly changed; they definitely need updating. + Set<String> changedKeys = new HashSet<String>(CollectionKeys.differences(oldKeys, newKeys)); + + boolean defaultKeyChanged = true; // Most pessimistic is to assume default key has changed. + KeyBundle newDefaultKeyBundle = null; + try { + KeyBundle oldDefaultKeyBundle = oldKeys.defaultKeyBundle(); + newDefaultKeyBundle = newKeys.defaultKeyBundle(); + defaultKeyChanged = !oldDefaultKeyBundle.equals(newDefaultKeyBundle); + } catch (NoCollectionKeysSetException e) { + Logger.warn(LOG_TAG, "NoCollectionKeysSetException in EnsureCrypto5KeysStage.", e); + } + + if (newDefaultKeyBundle == null) { + Logger.trace(LOG_TAG, "New default key not provided; returning changed individual keys."); + return changedKeys; + } + + if (!defaultKeyChanged) { + Logger.trace(LOG_TAG, "New default key is the same as old default key; returning changed individual keys."); + return changedKeys; + } + + // New keys have a different default/sync key; check known collections against the default key. + Logger.debug(LOG_TAG, "New default key is not the same as old default key."); + for (Stage stage : Stage.getNamedStages()) { + String name = stage.getRepositoryName(); + if (!newKeys.keyBundleForCollectionIsNotDefault(name)) { + // Default key has changed, so this collection has changed. + changedKeys.add(name); + } + } + + return changedKeys; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + // Take the timestamp from the response since it is later than the timestamp from info/collections. + long responseTimestamp = response.normalizedWeaveTimestamp(); + CollectionKeys keys = new CollectionKeys(); + try { + ExtendedJSONObject body = response.jsonObjectBody(); + if (Logger.LOG_PERSONAL_INFORMATION) { + Logger.pii(LOG_TAG, "Fetched keys: " + body.toJSONString()); + } + keys.setKeyPairsFromWBO(CryptoRecord.fromJSONRecord(body), session.config.syncKeyBundle); + } catch (Exception e) { + session.abort(e, "Invalid keys WBO."); + return; + } + + PersistedCrypto5Keys pck = session.config.persistedCryptoKeys(); + if (!pck.persistedKeysExist()) { + // New keys, and no old keys! Persist keys and server timestamp. + Logger.trace(LOG_TAG, "Setting fetched keys for this session; persisting fetched keys and last modified."); + setAndPersist(pck, keys, responseTimestamp); + session.advance(); + return; + } + + // New keys, but we had old keys. Check for differences. + CollectionKeys oldKeys = pck.keys(); + Set<String> changedCollections = collectionsToUpdate(oldKeys, keys); + if (!changedCollections.isEmpty()) { + // New keys, different from old keys. + Logger.trace(LOG_TAG, "Fetched keys are not the same as persisted keys; " + + "setting fetched keys for this session before resetting changed engines."); + setAndPersist(pck, keys, responseTimestamp); + session.resetStagesByName(changedCollections); + session.abort(null, "crypto/keys changed on server."); + return; + } + + // New keys don't differ from old keys; persist timestamp and move on. + Logger.trace(LOG_TAG, "Fetched keys are the same as persisted keys; persisting only last modified."); + session.config.setCollectionKeys(oldKeys); + pck.persistLastModified(response.normalizedWeaveTimestamp()); + session.advance(); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + if (retrying) { + // Should happen very rarely -- this means we uploaded our crypto/keys + // successfully, but failed to re-download. + session.handleHTTPError(response, "Failure while re-downloading already uploaded keys."); + return; + } + + int statusCode = response.getStatusCode(); + if (statusCode == 404) { + Logger.info(LOG_TAG, "Got 404 fetching keys. Fresh starting since keys are missing on server."); + session.freshStart(); + return; + } + session.handleHTTPError(response, "Failure fetching keys: got response status code " + statusCode); + } + + @Override + public void handleRequestError(Exception ex) { + session.abort(ex, "Failure fetching keys."); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java new file mode 100644 index 000000000..40a474ef4 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java @@ -0,0 +1,40 @@ +/* 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.stage; + +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository; +import org.mozilla.gecko.sync.repositories.domain.TabsRecordFactory; +import org.mozilla.gecko.sync.repositories.domain.VersionConstants; + +public class FennecTabsServerSyncStage extends ServerSyncStage { + private static final String COLLECTION = "tabs"; + + @Override + protected String getCollection() { + return COLLECTION; + } + + @Override + protected String getEngineName() { + return COLLECTION; + } + + @Override + public Integer getStorageVersion() { + return VersionConstants.TABS_ENGINE_VERSION; + } + + @Override + protected Repository getLocalRepository() { + return new FennecTabsRepository(session.getClientsDelegate()); + } + + @Override + protected RecordFactory getRecordFactory() { + return new TabsRecordFactory(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java new file mode 100644 index 000000000..088321d5b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java @@ -0,0 +1,44 @@ +/* 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.stage; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.InfoCollections; +import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +public class FetchInfoCollectionsStage extends AbstractNonRepositorySyncStage { + public class StageInfoCollectionsDelegate implements JSONRecordFetchDelegate { + + @Override + public void handleSuccess(ExtendedJSONObject global) { + session.config.infoCollections = new InfoCollections(global); + session.advance(); + } + + @Override + public void handleFailure(SyncStorageResponse response) { + session.handleHTTPError(response, "Failure fetching info/collections."); + } + + @Override + public void handleError(Exception e) { + session.abort(e, "Failure fetching info/collections."); + } + + } + + @Override + public void execute() throws NoSuchStageException { + try { + session.fetchInfoCollections(new StageInfoCollectionsDelegate()); + } catch (URISyntaxException e) { + session.abort(e, "Invalid URI."); + } + } + +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java new file mode 100644 index 000000000..7f53c2739 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java @@ -0,0 +1,59 @@ +/* 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.stage; + +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.InfoConfiguration; +import org.mozilla.gecko.sync.JSONRecordFetcher; +import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +/** + * Fetches configuration data from info/configurations endpoint. + */ +public class FetchInfoConfigurationStage extends AbstractNonRepositorySyncStage { + private final String configurationURL; + private final AuthHeaderProvider authHeaderProvider; + + public FetchInfoConfigurationStage(final String configurationURL, final AuthHeaderProvider authHeaderProvider) { + super(); + this.configurationURL = configurationURL; + this.authHeaderProvider = authHeaderProvider; + } + + public class StageInfoConfigurationDelegate implements JSONRecordFetchDelegate { + @Override + public void handleSuccess(final ExtendedJSONObject result) { + session.config.infoConfiguration = new InfoConfiguration(result); + session.advance(); + } + + @Override + public void handleFailure(final SyncStorageResponse response) { + // Handle all non-404 failures upstream. + if (response.getStatusCode() != 404) { + session.handleHTTPError(response, "Failure fetching info/configuration"); + return; + } + + // End-point might not be available (404) if server is running an older version. + // We will use default config values in this case. + session.config.infoConfiguration = new InfoConfiguration(); + session.advance(); + } + + @Override + public void handleError(final Exception e) { + session.abort(e, "Failure fetching info/configuration"); + } + } + @Override + public void execute() { + final StageInfoConfigurationDelegate delegate = new StageInfoConfigurationDelegate(); + final JSONRecordFetcher fetcher = new JSONRecordFetcher(configurationURL, authHeaderProvider); + fetcher.fetch(delegate); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java new file mode 100644 index 000000000..b4407b26b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java @@ -0,0 +1,79 @@ +/* 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.stage; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.GlobalSession; +import org.mozilla.gecko.sync.InfoCollections; +import org.mozilla.gecko.sync.MetaGlobal; +import org.mozilla.gecko.sync.PersistedMetaGlobal; +import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; + +public class FetchMetaGlobalStage extends AbstractNonRepositorySyncStage { + private static final String LOG_TAG = "FetchMetaGlobalStage"; + private static final String META_COLLECTION = "meta"; + + public class StageMetaGlobalDelegate implements MetaGlobalDelegate { + + private final GlobalSession session; + public StageMetaGlobalDelegate(GlobalSession session) { + this.session = session; + } + + @Override + public void handleSuccess(MetaGlobal global, SyncStorageResponse response) { + Logger.trace(LOG_TAG, "Persisting fetched meta/global and last modified."); + PersistedMetaGlobal pmg = session.config.persistedMetaGlobal(); + pmg.persistMetaGlobal(global); + // Take the timestamp from the response since it is later than the timestamp from info/collections. + pmg.persistLastModified(response.normalizedWeaveTimestamp()); + + session.processMetaGlobal(global); + } + + @Override + public void handleFailure(SyncStorageResponse response) { + session.handleHTTPError(response, "Failure fetching meta/global."); + } + + @Override + public void handleError(Exception e) { + session.abort(e, "Failure fetching meta/global."); + } + + @Override + public void handleMissing(MetaGlobal global, SyncStorageResponse response) { + session.processMissingMetaGlobal(global); + } + } + + @Override + public void execute() throws NoSuchStageException { + InfoCollections infoCollections = session.config.infoCollections; + if (infoCollections == null) { + session.abort(null, "No info/collections set in FetchMetaGlobalStage."); + return; + } + + long lastModified = session.config.persistedMetaGlobal().lastModified(); + if (!infoCollections.updateNeeded(META_COLLECTION, lastModified)) { + // Try to use our local collection keys for this session. + Logger.info(LOG_TAG, "Trying to use persisted meta/global for this session."); + MetaGlobal global = session.config.persistedMetaGlobal().metaGlobal(session.config.metaURL(), session.getAuthHeaderProvider()); + if (global != null) { + Logger.info(LOG_TAG, "Using persisted meta/global for this session."); + session.processMetaGlobal(global); // Calls session.advance(). + return; + } + Logger.info(LOG_TAG, "Failed to use persisted meta/global for this session."); + } + + // We need an update: fetch or upload meta/global as necessary. + Logger.info(LOG_TAG, "Fetching fresh meta/global for this session."); + MetaGlobal global = new MetaGlobal(session.config.metaURL(), session.getAuthHeaderProvider()); + global.fetch(new StageMetaGlobalDelegate(session)); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java new file mode 100644 index 000000000..0a5d974b8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java @@ -0,0 +1,76 @@ +/* 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.stage; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.android.FormHistoryRepositorySession; +import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord; +import org.mozilla.gecko.sync.repositories.domain.Record; +import org.mozilla.gecko.sync.repositories.domain.VersionConstants; + +public class FormHistoryServerSyncStage extends ServerSyncStage { + + // Eventually this kind of sync stage will be data-driven, + // and all this hard-coding can go away. + private static final String FORM_HISTORY_SORT = "index"; + // Sanity limit. Batch and total limit are the same for now, and will be adjusted + // once buffer and high water mark are in place. See Bug 730142. + private static final long FORM_HISTORY_BATCH_LIMIT = 5000; + private static final long FORM_HISTORY_TOTAL_LIMIT = 5000; + + @Override + protected String getCollection() { + return "forms"; + } + + @Override + protected String getEngineName() { + return "forms"; + } + + @Override + public Integer getStorageVersion() { + return VersionConstants.FORMS_ENGINE_VERSION; + } + + @Override + protected Repository getRemoteRepository() throws URISyntaxException { + String collection = getCollection(); + return new ConstrainedServer11Repository( + collection, + session.config.storageURL(), + session.getAuthHeaderProvider(), + session.config.infoCollections, + session.config.infoConfiguration, + FORM_HISTORY_BATCH_LIMIT, + FORM_HISTORY_TOTAL_LIMIT, + FORM_HISTORY_SORT); + } + + @Override + protected Repository getLocalRepository() { + return new FormHistoryRepositorySession.FormHistoryRepository(); + } + + public class FormHistoryRecordFactory extends RecordFactory { + + @Override + public Record createRecord(Record record) { + FormHistoryRecord r = new FormHistoryRecord(); + r.initFromEnvelope((CryptoRecord) record); + return r; + } + } + + @Override + protected RecordFactory getRecordFactory() { + return new FormHistoryRecordFactory(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java new file mode 100644 index 000000000..6dee71f90 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java @@ -0,0 +1,93 @@ +/* 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.stage; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import org.mozilla.gecko.sync.GlobalSession; + + +public interface GlobalSyncStage { + public static enum Stage { + idle, // Start state. + checkPreconditions, // Preparation of the basics. TODO: clear status + fetchInfoCollections, // Take a look at timestamps. + fetchInfoConfiguration, // Fetch server upload limits + fetchMetaGlobal, + ensureKeysStage, + /* + ensureSpecialRecords, + updateEngineTimestamps, + */ + syncClientsEngine(SyncClientsEngineStage.STAGE_NAME), + /* + processFirstSyncPref, + processClientCommands, + updateEnabledEngines, + */ + syncTabs("tabs"), + syncPasswords("passwords"), + syncBookmarks("bookmarks"), + syncHistory("history"), + syncFormHistory("forms"), + + uploadMetaGlobal, + completed; + + // Maintain a mapping from names ("bookmarks") to Stage enumerations (syncBookmarks). + private static final Map<String, Stage> named = new HashMap<String, Stage>(); + static { + for (Stage s : EnumSet.allOf(Stage.class)) { + if (s.getRepositoryName() != null) { + named.put(s.getRepositoryName(), s); + } + } + } + + public static Stage byName(final String name) { + if (name == null) { + return null; + } + return named.get(name); + } + + /** + * @return an immutable collection of Stages. + */ + public static Collection<Stage> getNamedStages() { + return Collections.unmodifiableCollection(named.values()); + } + + // Each Stage tracks its repositoryName. + private final String repositoryName; + public String getRepositoryName() { + return repositoryName; + } + + private Stage() { + this.repositoryName = null; + } + + private Stage(final String name) { + this.repositoryName = name; + } + } + + public void execute(GlobalSession session) throws NoSuchStageException; + public void resetLocal(GlobalSession session); + public void wipeLocal(GlobalSession session) throws Exception; + + /** + * What storage version number this engine supports. + * <p> + * Used to generate a fresh meta/global record for upload. + * @return a version number or <code>null</code> to never include this engine in a fresh meta/global record. + */ + public Integer getStorageVersion(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java new file mode 100644 index 000000000..14c9bb43e --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java @@ -0,0 +1,13 @@ +/* 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.stage; + +public class NoSuchStageException extends Exception { + private static final long serialVersionUID = 8338484472880746971L; + GlobalSyncStage.Stage stage; + public NoSuchStageException(GlobalSyncStage.Stage stage) { + this.stage = stage; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java new file mode 100644 index 000000000..c781ce2cc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java @@ -0,0 +1,38 @@ +/* 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.stage; + +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.android.PasswordsRepositorySession; +import org.mozilla.gecko.sync.repositories.domain.PasswordRecordFactory; +import org.mozilla.gecko.sync.repositories.domain.VersionConstants; + +public class PasswordsServerSyncStage extends ServerSyncStage { + @Override + protected String getCollection() { + return "passwords"; + } + + @Override + protected String getEngineName() { + return "passwords"; + } + + @Override + public Integer getStorageVersion() { + return VersionConstants.PASSWORDS_ENGINE_VERSION; + } + + @Override + protected Repository getLocalRepository() { + return new PasswordsRepositorySession.PasswordsRepository(); + } + + @Override + protected RecordFactory getRecordFactory() { + return new PasswordRecordFactory(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java new file mode 100644 index 000000000..733c887f0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java @@ -0,0 +1,110 @@ +/* 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.stage; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.InfoCollections; +import org.mozilla.gecko.sync.InfoConfiguration; +import org.mozilla.gecko.sync.InfoCounts; +import org.mozilla.gecko.sync.JSONRecordFetcher; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.Server11RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; + +import android.content.Context; + +/** + * This is a constrained repository -- one which fetches a limited number + * of records -- that additionally refuses to sync if the limit will + * be exceeded on a first sync by the number of records on the server. + * + * You must pass an {@link InfoCounts} instance, which will be interrogated + * in the event of a first sync. + * + * "First sync" means that our sync timestamp is not greater than zero. + */ +public class SafeConstrainedServer11Repository extends ConstrainedServer11Repository { + + // This can be lazily evaluated if we need it. + private final JSONRecordFetcher countFetcher; + + public SafeConstrainedServer11Repository(String collection, + String storageURL, + AuthHeaderProvider authHeaderProvider, + InfoCollections infoCollections, + InfoConfiguration infoConfiguration, + long batchLimit, + long totalLimit, + String sort, + JSONRecordFetcher countFetcher) + throws URISyntaxException { + super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration, + batchLimit, totalLimit, sort); + if (countFetcher == null) { + throw new IllegalArgumentException("countFetcher must not be null"); + } + this.countFetcher = countFetcher; + } + + @Override + public void createSession(RepositorySessionCreationDelegate delegate, + Context context) { + delegate.onSessionCreated(new CountCheckingServer11RepositorySession(this, this.getDefaultBatchLimit())); + } + + public class CountCheckingServer11RepositorySession extends Server11RepositorySession { + private static final String LOG_TAG = "CountCheckingServer11RepositorySession"; + + /** + * The session will report no data available if this is a first sync + * and the server has more data available than this limit. + */ + private final long fetchLimit; + + public CountCheckingServer11RepositorySession(Repository repository, long fetchLimit) { + super(repository); + this.fetchLimit = fetchLimit; + } + + @Override + public boolean shouldSkip() { + // If this is a first sync, verify that we aren't going to blow through our limit. + final long lastSyncTimestamp = getLastSyncTimestamp(); + if (lastSyncTimestamp > 0) { + Logger.info(LOG_TAG, "Collection " + collection + " has already had a first sync: " + + "timestamp is " + lastSyncTimestamp + "; " + + "ignoring any updated counts and syncing as usual."); + } else { + Logger.info(LOG_TAG, "Collection " + collection + " is starting a first sync; checking counts."); + + final InfoCounts counts; + try { + // This'll probably be the same object, but best to obey the API. + counts = new InfoCounts(countFetcher.fetchBlocking()); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Skipping " + collection + " until we can fetch counts.", e); + return true; + } + + Integer c = counts.getCount(collection); + if (c == null) { + Logger.info(LOG_TAG, "Fetched counts does not include collection " + collection + "; syncing as usual."); + return false; + } + + Logger.info(LOG_TAG, "First sync for " + collection + ": " + c + " items."); + if (c > fetchLimit) { + Logger.warn(LOG_TAG, "Too many items to sync safely. Skipping."); + return true; + } + } + return super.shouldSkip(); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java new file mode 100644 index 000000000..733e69da5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java @@ -0,0 +1,627 @@ +/* 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.stage; + +import android.content.Context; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.EngineSettings; +import org.mozilla.gecko.sync.GlobalSession; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.MetaGlobalException; +import org.mozilla.gecko.sync.NoCollectionKeysSetException; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.SynchronizerConfiguration; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.delegates.WipeServerDelegate; +import org.mozilla.gecko.sync.middleware.Crypto5MiddlewareRepository; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.SyncStorageRequest; +import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; +import org.mozilla.gecko.sync.net.SyncStorageResponse; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.RecordFactory; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; +import org.mozilla.gecko.sync.repositories.Server11Repository; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; +import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer; +import org.mozilla.gecko.sync.synchronizer.Synchronizer; +import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate; +import org.mozilla.gecko.sync.synchronizer.SynchronizerSession; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** + * Fetch from a server collection into a local repository, encrypting + * and decrypting along the way. + * + * @author rnewman + * + */ +public abstract class ServerSyncStage extends AbstractSessionManagingSyncStage implements SynchronizerDelegate { + + protected static final String LOG_TAG = "ServerSyncStage"; + + protected long stageStartTimestamp = -1; + protected long stageCompleteTimestamp = -1; + + /** + * Override these in your subclasses. + * + * @return true if this stage should be executed. + * @throws MetaGlobalException + */ + protected boolean isEnabled() throws MetaGlobalException { + EngineSettings engineSettings = null; + try { + engineSettings = getEngineSettings(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Unable to get engine settings for " + this + ": fetching config failed.", e); + // Fall through; null engineSettings will pass below. + } + + // We can be disabled by the server's meta/global record, or malformed in the server's meta/global record, + // or by the user manually in Sync Settings. + // We catch the subclasses of MetaGlobalException to trigger various resets and wipes in execute(). + boolean enabledInMetaGlobal = session.isEngineRemotelyEnabled(this.getEngineName(), engineSettings); + + // Check for manual changes to engines by the user. + checkAndUpdateUserSelectedEngines(enabledInMetaGlobal); + + // Check for changes on the server. + if (!enabledInMetaGlobal) { + Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled by server meta/global."); + return false; + } + + // We can also be disabled just for this sync. + boolean enabledThisSync = session.isEngineLocallyEnabled(this.getEngineName()); // For ServerSyncStage, stage name == engine name. + if (!enabledThisSync) { + Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled just for this sync."); + } + return enabledThisSync; + } + + /** + * Compares meta/global engine state to user selected engines from Sync + * Settings and throws an exception if they don't match and meta/global needs + * to be updated. + * + * @param enabledInMetaGlobal + * boolean of engine sync state in meta/global + * @throws MetaGlobalException + * if engine sync state has been changed in Sync Settings, with new + * engine sync state. + */ + protected void checkAndUpdateUserSelectedEngines(boolean enabledInMetaGlobal) throws MetaGlobalException { + Map<String, Boolean> selectedEngines = session.config.userSelectedEngines; + String thisEngine = this.getEngineName(); + + if (selectedEngines != null && selectedEngines.containsKey(thisEngine)) { + boolean enabledInSelection = selectedEngines.get(thisEngine); + if (enabledInMetaGlobal != enabledInSelection) { + // Engine enable state has been changed by the user. + Logger.debug(LOG_TAG, "Engine state has been changed by user. Throwing exception."); + throw new MetaGlobalException.MetaGlobalEngineStateChangedException(enabledInSelection); + } + } + } + + protected EngineSettings getEngineSettings() throws NonObjectJSONException, IOException { + Integer version = getStorageVersion(); + if (version == null) { + Logger.warn(LOG_TAG, "null storage version for " + this + "; using version 0."); + version = 0; + } + + SynchronizerConfiguration config = this.getConfig(); + if (config == null) { + return new EngineSettings(null, version); + } + return new EngineSettings(config.syncID, version); + } + + protected abstract String getCollection(); + protected abstract String getEngineName(); + protected abstract Repository getLocalRepository(); + protected abstract RecordFactory getRecordFactory(); + + // Override this in subclasses. + protected Repository getRemoteRepository() throws URISyntaxException { + String collection = getCollection(); + return new Server11Repository(collection, + session.config.storageURL(), + session.getAuthHeaderProvider(), + session.config.infoCollections, + session.config.infoConfiguration); + } + + /** + * Return a Crypto5Middleware-wrapped Server11Repository. + * + * @throws NoCollectionKeysSetException + * @throws URISyntaxException + */ + protected Repository wrappedServerRepo() throws NoCollectionKeysSetException, URISyntaxException { + String collection = this.getCollection(); + KeyBundle collectionKey = session.keyBundleForCollection(collection); + Crypto5MiddlewareRepository cryptoRepo = new Crypto5MiddlewareRepository(getRemoteRepository(), collectionKey); + cryptoRepo.recordFactory = getRecordFactory(); + return cryptoRepo; + } + + protected String bundlePrefix() { + return this.getCollection() + "."; + } + + protected SynchronizerConfiguration getConfig() throws NonObjectJSONException, IOException { + return new SynchronizerConfiguration(session.config.getBranch(bundlePrefix())); + } + + protected void persistConfig(SynchronizerConfiguration synchronizerConfiguration) { + synchronizerConfiguration.persist(session.config.getBranch(bundlePrefix())); + } + + public Synchronizer getConfiguredSynchronizer(GlobalSession session) throws NoCollectionKeysSetException, URISyntaxException, NonObjectJSONException, IOException { + Repository remote = wrappedServerRepo(); + + Synchronizer synchronizer = new ServerLocalSynchronizer(); + synchronizer.repositoryA = remote; + synchronizer.repositoryB = this.getLocalRepository(); + synchronizer.load(getConfig()); + + return synchronizer; + } + + /** + * Reset timestamps. + */ + @Override + protected void resetLocal() { + resetLocalWithSyncID(null); + } + + /** + * Reset timestamps and possibly set syncID. + * @param syncID if non-null, new syncID to persist. + */ + protected void resetLocalWithSyncID(String syncID) { + // Clear both timestamps. + SynchronizerConfiguration config; + try { + config = this.getConfig(); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Unable to reset " + this + ": fetching config failed.", e); + return; + } + + if (syncID != null) { + config.syncID = syncID; + Logger.info(LOG_TAG, "Setting syncID for " + this + " to '" + syncID + "'."); + } + config.localBundle.setTimestamp(0L); + config.remoteBundle.setTimestamp(0L); + persistConfig(config); + Logger.info(LOG_TAG, "Reset timestamps for " + this); + } + + // Not thread-safe. Use with caution. + private class WipeWaiter { + public boolean sessionSucceeded = true; + public boolean wipeSucceeded = true; + public Exception error; + + public void notify(Exception e, boolean sessionSucceeded) { + this.sessionSucceeded = sessionSucceeded; + this.wipeSucceeded = false; + this.error = e; + this.notify(); + } + } + + /** + * Synchronously wipe this stage by instantiating a local repository session + * and wiping that. + * <p> + * Logs and re-throws an exception on failure. + */ + @Override + protected void wipeLocal() throws Exception { + // Reset, then clear data. + this.resetLocal(); + + final WipeWaiter monitor = new WipeWaiter(); + final Context context = session.getContext(); + final Repository r = this.getLocalRepository(); + + final Runnable doWipe = new Runnable() { + @Override + public void run() { + r.createSession(new RepositorySessionCreationDelegate() { + + @Override + public void onSessionCreated(final RepositorySession session) { + try { + session.begin(new RepositorySessionBeginDelegate() { + + @Override + public void onBeginSucceeded(final RepositorySession session) { + session.wipe(new RepositorySessionWipeDelegate() { + @Override + public void onWipeSucceeded() { + try { + session.finish(new RepositorySessionFinishDelegate() { + + @Override + public void onFinishSucceeded(RepositorySession session, + RepositorySessionBundle bundle) { + // Hurrah. + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void onFinishFailed(Exception ex) { + // Assume that no finish => no wipe. + synchronized (monitor) { + monitor.notify(ex, true); + } + } + + @Override + public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) { + return this; + } + }); + } catch (InactiveSessionException e) { + // Cannot happen. Call for safety. + synchronized (monitor) { + monitor.notify(e, true); + } + } + } + + @Override + public void onWipeFailed(Exception ex) { + session.abort(); + synchronized (monitor) { + monitor.notify(ex, true); + } + } + + @Override + public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor) { + return this; + } + }); + } + + @Override + public void onBeginFailed(Exception ex) { + session.abort(); + synchronized (monitor) { + monitor.notify(ex, true); + } + } + + @Override + public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) { + return this; + } + }); + } catch (InvalidSessionTransitionException e) { + session.abort(); + synchronized (monitor) { + monitor.notify(e, true); + } + } + } + + @Override + public void onSessionCreateFailed(Exception ex) { + synchronized (monitor) { + monitor.notify(ex, false); + } + } + + @Override + public RepositorySessionCreationDelegate deferredCreationDelegate() { + return this; + } + }, context); + } + }; + + final Thread wiping = new Thread(doWipe); + synchronized (monitor) { + wiping.start(); + try { + monitor.wait(); + } catch (InterruptedException e) { + Logger.error(LOG_TAG, "Wipe interrupted."); + } + } + + if (!monitor.sessionSucceeded) { + Logger.error(LOG_TAG, "Failed to create session for wipe."); + throw monitor.error; + } + + if (!monitor.wipeSucceeded) { + Logger.error(LOG_TAG, "Failed to wipe session."); + throw monitor.error; + } + + Logger.info(LOG_TAG, "Wiping stage complete."); + } + + /** + * Asynchronously wipe collection on server. + */ + protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) { + SyncStorageRequest request; + + try { + request = new SyncStorageRequest(session.config.collectionURI(getCollection())); + } catch (URISyntaxException ex) { + Logger.warn(LOG_TAG, "Invalid URI in wipeServer."); + wipeDelegate.onWipeFailed(ex); + return; + } + + request.delegate = new SyncStorageRequestDelegate() { + + @Override + public String ifUnmodifiedSince() { + return null; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + BaseResource.consumeEntity(response); + resetLocal(); + wipeDelegate.onWiped(response.normalizedWeaveTimestamp()); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + Logger.warn(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer."); + // Process HTTP failures here to pick up backoffs, etc. + session.interpretHTTPFailure(response.httpResponse()); + BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response. + wipeDelegate.onWipeFailed(new HTTPFailureException(response)); + } + + @Override + public void handleRequestError(Exception ex) { + Logger.warn(LOG_TAG, "Got exception in wipeServer.", ex); + wipeDelegate.onWipeFailed(ex); + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return authHeaderProvider; + } + }; + + request.delete(); + } + + /** + * Synchronously wipe the server. + * <p> + * Logs and re-throws an exception on failure. + */ + public void wipeServer(final GlobalSession session) throws Exception { + this.session = session; + + final WipeWaiter monitor = new WipeWaiter(); + + final Runnable doWipe = new Runnable() { + @Override + public void run() { + wipeServer(session.getAuthHeaderProvider(), new WipeServerDelegate() { + @Override + public void onWiped(long timestamp) { + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void onWipeFailed(Exception e) { + synchronized (monitor) { + monitor.notify(e, false); + } + } + }); + } + }; + + final Thread wiping = new Thread(doWipe); + synchronized (monitor) { + wiping.start(); + try { + monitor.wait(); + } catch (InterruptedException e) { + Logger.error(LOG_TAG, "Server wipe interrupted."); + } + } + + if (!monitor.wipeSucceeded) { + Logger.error(LOG_TAG, "Failed to wipe server."); + throw monitor.error; + } + + Logger.info(LOG_TAG, "Wiping server complete."); + } + + @Override + public void execute() throws NoSuchStageException { + final String name = getEngineName(); + Logger.debug(LOG_TAG, "Starting execute for " + name); + + stageStartTimestamp = System.currentTimeMillis(); + + try { + if (!this.isEnabled()) { + Logger.info(LOG_TAG, "Skipping stage " + name + "."); + session.advance(); + return; + } + } catch (MetaGlobalException.MetaGlobalMalformedSyncIDException e) { + // Bad engine syncID. This should never happen. Wipe the server. + try { + session.recordForMetaGlobalUpdate(name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion())); + Logger.info(LOG_TAG, "Wiping server because malformed engine sync ID was found in meta/global."); + wipeServer(session); + Logger.info(LOG_TAG, "Wiped server after malformed engine sync ID found in meta/global."); + } catch (Exception ex) { + session.abort(ex, "Failed to wipe server after malformed engine sync ID found in meta/global."); + } + } catch (MetaGlobalException.MetaGlobalMalformedVersionException e) { + // Bad engine version. This should never happen. Wipe the server. + try { + session.recordForMetaGlobalUpdate(name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion())); + Logger.info(LOG_TAG, "Wiping server because malformed engine version was found in meta/global."); + wipeServer(session); + Logger.info(LOG_TAG, "Wiped server after malformed engine version found in meta/global."); + } catch (Exception ex) { + session.abort(ex, "Failed to wipe server after malformed engine version found in meta/global."); + } + } catch (MetaGlobalException.MetaGlobalStaleClientSyncIDException e) { + // Our syncID is wrong. Reset client and take the server syncID. + Logger.warn(LOG_TAG, "Remote engine syncID different from local engine syncID:" + + " resetting local engine and assuming remote engine syncID."); + this.resetLocalWithSyncID(e.serverSyncID); + } catch (MetaGlobalException.MetaGlobalEngineStateChangedException e) { + boolean isEnabled = e.isEnabled; + if (!isEnabled) { + // Engine has been disabled; update meta/global with engine removal for upload. + session.removeEngineFromMetaGlobal(name); + session.config.declinedEngineNames.add(name); + } else { + session.config.declinedEngineNames.remove(name); + // Add engine with new syncID to meta/global for upload. + String newSyncID = Utils.generateGuid(); + session.recordForMetaGlobalUpdate(name, new EngineSettings(newSyncID, this.getStorageVersion())); + // Update SynchronizerConfiguration w/ new engine syncID. + this.resetLocalWithSyncID(newSyncID); + } + try { + // Engine sync status has changed. Wipe server. + Logger.warn(LOG_TAG, "Wiping server because engine sync state changed."); + wipeServer(session); + Logger.warn(LOG_TAG, "Wiped server because engine sync state changed."); + } catch (Exception ex) { + session.abort(ex, "Failed to wipe server after engine sync state changed"); + } + if (!isEnabled) { + Logger.warn(LOG_TAG, "Stage has been disabled. Advancing to next stage."); + session.advance(); + return; + } + } catch (MetaGlobalException e) { + session.abort(e, "Inappropriate meta/global; refusing to execute " + name + " stage."); + return; + } + + Synchronizer synchronizer; + try { + synchronizer = this.getConfiguredSynchronizer(session); + } catch (NoCollectionKeysSetException e) { + session.abort(e, "No CollectionKeys."); + return; + } catch (URISyntaxException e) { + session.abort(e, "Invalid URI syntax for server repository."); + return; + } catch (NonObjectJSONException | IOException e) { + session.abort(e, "Invalid persisted JSON for config."); + return; + } + + Logger.debug(LOG_TAG, "Invoking synchronizer."); + synchronizer.synchronize(session.getContext(), this); + Logger.debug(LOG_TAG, "Reached end of execute."); + } + + /** + * Express the duration taken by this stage as a String, like "0.56 seconds". + * + * @return formatted string. + */ + protected String getStageDurationString() { + return Utils.formatDuration(stageStartTimestamp, stageCompleteTimestamp); + } + + /** + * We synced this engine! Persist timestamps and advance the session. + * + * @param synchronizer the <code>Synchronizer</code> that succeeded. + */ + @Override + public void onSynchronized(Synchronizer synchronizer) { + stageCompleteTimestamp = System.currentTimeMillis(); + Logger.debug(LOG_TAG, "onSynchronized."); + + SynchronizerConfiguration newConfig = synchronizer.save(); + if (newConfig != null) { + persistConfig(newConfig); + } else { + Logger.warn(LOG_TAG, "Didn't get configuration from synchronizer after success."); + } + + final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession(); + int inboundCount = synchronizerSession.getInboundCount(); + int outboundCount = synchronizerSession.getOutboundCount(); + Logger.info(LOG_TAG, "Stage " + getEngineName() + + " received " + inboundCount + " and sent " + outboundCount + + " records in " + getStageDurationString() + "."); + Logger.info(LOG_TAG, "Advancing session."); + session.advance(); + } + + /** + * We failed to sync this engine! Do not persist timestamps (which means that + * the next sync will include this sync's data), but do advance the session + * (if we didn't get a Retry-After header). + * + * @param synchronizer the <code>Synchronizer</code> that failed. + */ + @Override + public void onSynchronizeFailed(Synchronizer synchronizer, + Exception lastException, String reason) { + stageCompleteTimestamp = System.currentTimeMillis(); + Logger.warn(LOG_TAG, "Synchronize failed: " + reason, lastException); + + // This failure could be due to a 503 or a 401 and it could have headers. + // Interrogate the headers but only abort the global session if Retry-After header is set. + if (lastException instanceof HTTPFailureException) { + SyncStorageResponse response = ((HTTPFailureException)lastException).response; + if (response.retryAfterInSeconds() > 0) { + session.handleHTTPError(response, reason); // Calls session.abort(). + return; + } else { + session.interpretHTTPFailure(response.httpResponse()); // Does not call session.abort(). + } + } + + Logger.info(LOG_TAG, "Advancing session even though stage failed (took " + getStageDurationString() + + "). Timestamps not persisted."); + session.advance(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java new file mode 100644 index 000000000..04d3e7ce2 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java @@ -0,0 +1,691 @@ +/* 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.stage; + +import android.accounts.Account; +import android.content.Context; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.Log; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountClient; +import org.mozilla.gecko.background.fxa.FxAccountClient20; +import org.mozilla.gecko.background.fxa.FxAccountClientException; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.sync.CommandProcessor; +import org.mozilla.gecko.sync.CommandProcessor.Command; +import org.mozilla.gecko.sync.CryptoRecord; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.HTTPFailureException; +import org.mozilla.gecko.sync.NoCollectionKeysSetException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.crypto.CryptoException; +import org.mozilla.gecko.sync.crypto.KeyBundle; +import org.mozilla.gecko.sync.delegates.ClientsDataDelegate; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest; +import org.mozilla.gecko.sync.net.SyncStorageRecordRequest; +import org.mozilla.gecko.sync.net.SyncStorageResponse; +import org.mozilla.gecko.sync.net.WBOCollectionRequestDelegate; +import org.mozilla.gecko.sync.net.WBORequestDelegate; +import org.mozilla.gecko.sync.repositories.NullCursorException; +import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; +import org.mozilla.gecko.sync.repositories.domain.ClientRecord; +import org.mozilla.gecko.sync.repositories.domain.ClientRecordFactory; +import org.mozilla.gecko.sync.repositories.domain.VersionConstants; + +import ch.boye.httpclientandroidlib.HttpStatus; + +public class SyncClientsEngineStage extends AbstractSessionManagingSyncStage { + private static final String LOG_TAG = "SyncClientsEngineStage"; + + public static final String COLLECTION_NAME = "clients"; + public static final String STAGE_NAME = COLLECTION_NAME; + public static final int CLIENTS_TTL_REFRESH = 604800000; // 7 days in milliseconds. + public static final int MAX_UPLOAD_FAILURE_COUNT = 5; + public static final long NOTIFY_TAB_SENT_TTL_SECS = TimeUnit.SECONDS.convert(1L, TimeUnit.HOURS); // 1 hour + + protected final ClientRecordFactory factory = new ClientRecordFactory(); + protected ClientUploadDelegate clientUploadDelegate; + protected ClientDownloadDelegate clientDownloadDelegate; + + // Be sure to use this safely via getClientsDatabaseAccessor/closeDataAccessor. + protected ClientsDatabaseAccessor db; + + protected volatile boolean shouldWipe; + protected volatile boolean shouldUploadLocalRecord; // Set if, e.g., we received commands or need to refresh our version. + protected final AtomicInteger uploadAttemptsCount = new AtomicInteger(); + protected final List<ClientRecord> modifiedClientsToUpload = new ArrayList<ClientRecord>(); + + protected int getClientsCount() { + return getClientsDatabaseAccessor().clientsCount(); + } + + protected synchronized ClientsDatabaseAccessor getClientsDatabaseAccessor() { + if (db == null) { + db = new ClientsDatabaseAccessor(session.getContext()); + } + return db; + } + + protected synchronized void closeDataAccessor() { + if (db == null) { + return; + } + db.close(); + db = null; + } + + /** + * The following two delegates, ClientDownloadDelegate and ClientUploadDelegate + * are both triggered in a chain, starting when execute() calls + * downloadClientRecords(). + * + * Client records are downloaded using a get() request. Upon success of the + * get() request, the local client record is uploaded. + * + * @author Marina Samuel + * + */ + public class ClientDownloadDelegate extends WBOCollectionRequestDelegate { + + // We use this on each WBO, so lift it out. + final ClientsDataDelegate clientsDelegate = session.getClientsDelegate(); + boolean localAccountGUIDDownloaded = false; + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return session.getAuthHeaderProvider(); + } + + @Override + public String ifUnmodifiedSince() { + // TODO last client download time? + return null; + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + + // Hang onto the server's last modified timestamp to use + // in X-If-Unmodified-Since for upload. + session.config.persistServerClientsTimestamp(response.normalizedWeaveTimestamp()); + BaseResource.consumeEntity(response); + + // Wipe the clients table if it still hasn't been wiped but needs to be. + wipeAndStore(null); + + // If we successfully downloaded all records but ours was not one of them + // then reset the timestamp. + if (!localAccountGUIDDownloaded) { + Logger.info(LOG_TAG, "Local client GUID does not exist on the server. Upload timestamp will be reset."); + session.config.persistServerClientRecordTimestamp(0); + } + localAccountGUIDDownloaded = false; + + final int clientsCount; + try { + clientsCount = getClientsCount(); + } finally { + // Close the database to clear cached readableDatabase/writableDatabase + // after we've completed our last transaction (db.store()). + closeDataAccessor(); + } + + Logger.debug(LOG_TAG, "Database contains " + clientsCount + " clients."); + Logger.debug(LOG_TAG, "Server response asserts " + response.weaveRecords() + " records."); + + // TODO: persist the response timestamp to know whether to download next time (Bug 726055). + clientUploadDelegate = new ClientUploadDelegate(); + clientsDelegate.setClientsCount(clientsCount); + + // If we upload remote records, checkAndUpload() will be called upon + // upload success in the delegate. Otherwise call checkAndUpload() now. + if (modifiedClientsToUpload.size() > 0) { + // modifiedClientsToUpload is cleared in uploadRemoteRecords, save what we need here + final List<String> devicesToNotify = new ArrayList<>(); + for (ClientRecord record : modifiedClientsToUpload) { + if (!TextUtils.isEmpty(record.fxaDeviceId)) { + devicesToNotify.add(record.fxaDeviceId); + } + } + + // This method is synchronous, there's no risk of notifying the clients + // before we actually uploaded the records + uploadRemoteRecords(); + + // Notify the clients who got their record written + notifyClients(devicesToNotify); + + return; + } + checkAndUpload(); + } + + private void notifyClients(final List<String> devicesToNotify) { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final Context context = session.getContext(); + final Account account = FirefoxAccounts.getFirefoxAccount(context); + if (account == null) { + Log.e(LOG_TAG, "Can't notify other clients: no account"); + return; + } + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + final ExtendedJSONObject payload = createNotifyDevicesPayload(); + + final byte[] sessionToken; + try { + sessionToken = fxAccount.getSessionToken(); + } catch (AndroidFxAccount.InvalidFxAState invalidFxAState) { + Log.e(LOG_TAG, "Could not get session token", invalidFxAState); + return; + } + + // API doc : https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountdevicesnotify + final FxAccountClient fxAccountClient = new FxAccountClient20(fxAccount.getAccountServerURI(), executor); + fxAccountClient.notifyDevices(sessionToken, devicesToNotify, payload, NOTIFY_TAB_SENT_TTL_SECS, new FxAccountClient20.RequestDelegate<ExtendedJSONObject>() { + @Override + public void handleError(Exception e) { + Log.e(LOG_TAG, "Error while notifying devices", e); + } + + @Override + public void handleFailure(FxAccountClientException.FxAccountClientRemoteException e) { + Log.e(LOG_TAG, "Error while notifying devices", e); + } + + @Override + public void handleSuccess(ExtendedJSONObject result) { + Log.i(LOG_TAG, devicesToNotify.size() + " devices notified"); + } + }); + } + + @NonNull + @SuppressWarnings("unchecked") + private ExtendedJSONObject createNotifyDevicesPayload() { + final ExtendedJSONObject payload = new ExtendedJSONObject(); + payload.put("version", 1); + payload.put("command", "sync:collection_changed"); + final ExtendedJSONObject data = new ExtendedJSONObject(); + final JSONArray collections = new JSONArray(); + collections.add("clients"); + data.put("collections", collections); + payload.put("data", data); + return payload; + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + BaseResource.consumeEntity(response); // We don't need the response at all, and any exception handling shouldn't need the response body. + localAccountGUIDDownloaded = false; + + try { + Logger.info(LOG_TAG, "Client upload failed. Aborting sync."); + session.abort(new HTTPFailureException(response), "Client download failed."); + } finally { + // Close the database upon failure. + closeDataAccessor(); + } + } + + @Override + public void handleRequestError(Exception ex) { + localAccountGUIDDownloaded = false; + try { + Logger.info(LOG_TAG, "Client upload error. Aborting sync."); + session.abort(ex, "Failure fetching client record."); + } finally { + // Close the database upon error. + closeDataAccessor(); + } + } + + @Override + public void handleWBO(CryptoRecord record) { + ClientRecord r; + try { + r = (ClientRecord) factory.createRecord(record.decrypt()); + if (clientsDelegate.isLocalGUID(r.guid)) { + Logger.info(LOG_TAG, "Local client GUID exists on server and was downloaded."); + localAccountGUIDDownloaded = true; + handleDownloadedLocalRecord(r); + } else { + // Only need to store record if it isn't our local one. + wipeAndStore(r); + addCommands(r); + } + RepoUtils.logClient(r); + } catch (Exception e) { + session.abort(e, "Exception handling client WBO."); + return; + } + } + + @Override + public KeyBundle keyBundle() { + try { + return session.keyBundleForCollection(COLLECTION_NAME); + } catch (NoCollectionKeysSetException e) { + return null; + } + } + } + + public class ClientUploadDelegate extends WBORequestDelegate { + protected static final String LOG_TAG = "ClientUploadDelegate"; + public Long currentlyUploadingRecordTimestamp; + public boolean currentlyUploadingLocalRecord; + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return session.getAuthHeaderProvider(); + } + + private void setUploadDetails(boolean isLocalRecord) { + // Use the timestamp for the whole collection per Sync storage 1.1 spec. + currentlyUploadingRecordTimestamp = session.config.getPersistedServerClientsTimestamp(); + currentlyUploadingLocalRecord = isLocalRecord; + } + + @Override + public String ifUnmodifiedSince() { + Long timestampInMilliseconds = currentlyUploadingRecordTimestamp; + + // It's the first upload so we don't care about X-If-Unmodified-Since. + if (timestampInMilliseconds <= 0) { + return null; + } + + return Utils.millisecondsToDecimalSecondsString(timestampInMilliseconds); + } + + @Override + public void handleRequestSuccess(SyncStorageResponse response) { + Logger.debug(LOG_TAG, "Upload succeeded."); + uploadAttemptsCount.set(0); + + // X-Weave-Timestamp is the modified time of uploaded records. + // Always persist this. + final long responseTimestamp = response.normalizedWeaveTimestamp(); + Logger.trace(LOG_TAG, "Timestamp from header is: " + responseTimestamp); + + if (responseTimestamp == -1) { + final String message = "Response did not contain a valid timestamp."; + session.abort(new RuntimeException(message), message); + return; + } + + BaseResource.consumeEntity(response); + session.config.persistServerClientsTimestamp(responseTimestamp); + + // If we're not uploading our record, we're done here; just + // clean up and finish. + if (!currentlyUploadingLocalRecord) { + // TODO: check failed uploads in body. + clearRecordsToUpload(); + checkAndUpload(); + return; + } + + // If we're processing our record, we have a little more cleanup + // to do. + shouldUploadLocalRecord = false; + session.config.persistServerClientRecordTimestamp(responseTimestamp); + session.advance(); + } + + @Override + public void handleRequestFailure(SyncStorageResponse response) { + int statusCode = response.getStatusCode(); + + // If upload failed because of `ifUnmodifiedSince` then there are new + // commands uploaded to our record. We must download and process them first. + if (!shouldUploadLocalRecord || + statusCode == HttpStatus.SC_PRECONDITION_FAILED || + uploadAttemptsCount.incrementAndGet() > MAX_UPLOAD_FAILURE_COUNT) { + + Logger.debug(LOG_TAG, "Client upload failed. Aborting sync."); + if (!currentlyUploadingLocalRecord) { + modifiedClientsToUpload.clear(); // These will be redownloaded. + } + BaseResource.consumeEntity(response); // The exception thrown should need the response body. + session.abort(new HTTPFailureException(response), "Client upload failed."); + return; + } + Logger.trace(LOG_TAG, "Retrying upload…"); + // Preconditions: + // shouldUploadLocalRecord == true && + // statusCode != 412 && + // uploadAttemptCount < MAX_UPLOAD_FAILURE_COUNT + checkAndUpload(); + } + + @Override + public void handleRequestError(Exception ex) { + Logger.info(LOG_TAG, "Client upload error. Aborting sync."); + session.abort(ex, "Client upload failed."); + } + + @Override + public KeyBundle keyBundle() { + try { + return session.keyBundleForCollection(COLLECTION_NAME); + } catch (NoCollectionKeysSetException e) { + return null; + } + } + } + + @Override + public void execute() throws NoSuchStageException { + // We can be disabled just for this sync. + boolean enabledThisSync = session.isEngineLocallyEnabled(STAGE_NAME); + if (!enabledThisSync) { + // These log messages look best when they match the messages in ServerSyncStage. + Logger.debug(LOG_TAG, "Stage " + STAGE_NAME + " disabled just for this sync."); + Logger.info(LOG_TAG, "Skipping stage " + STAGE_NAME + "."); + session.advance(); + return; + } + + if (shouldDownload()) { + downloadClientRecords(); // Will kick off upload, too… + } else { + // Upload if necessary. + } + } + + @Override + protected void resetLocal() { + // Clear timestamps and local data. + session.config.persistServerClientRecordTimestamp(0L); // TODO: roll these into one. + session.config.persistServerClientsTimestamp(0L); + + session.getClientsDelegate().setClientsCount(0); + try { + getClientsDatabaseAccessor().wipeDB(); + } finally { + closeDataAccessor(); + } + } + + @Override + protected void wipeLocal() throws Exception { + // Nothing more to do. + this.resetLocal(); + } + + @Override + public Integer getStorageVersion() { + return VersionConstants.CLIENTS_ENGINE_VERSION; + } + + protected String getLocalClientVersion() { + return AppConstants.MOZ_APP_VERSION; + } + + @SuppressWarnings("unchecked") + protected JSONArray getLocalClientProtocols() { + final JSONArray protocols = new JSONArray(); + protocols.add(ClientRecord.PROTOCOL_LEGACY_SYNC); + protocols.add(ClientRecord.PROTOCOL_FXA_SYNC); + return protocols; + } + + protected ClientRecord newLocalClientRecord(ClientsDataDelegate delegate) { + final String ourGUID = delegate.getAccountGUID(); + final String ourName = delegate.getClientName(); + + ClientRecord r = new ClientRecord(ourGUID); + r.name = ourName; + r.version = getLocalClientVersion(); + r.protocols = getLocalClientProtocols(); + + r.os = "Android"; + r.application = AppConstants.MOZ_APP_DISPLAYNAME; + r.appPackage = AppConstants.ANDROID_PACKAGE_NAME; + r.device = android.os.Build.MODEL; + r.formfactor = delegate.getFormFactor(); + + Context context = session.getContext(); + final Account account = FirefoxAccounts.getFirefoxAccount(context); + if (account != null) { + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); + final String deviceId = fxAccount.getDeviceId(); + if (!TextUtils.isEmpty(deviceId)) { + r.fxaDeviceId = deviceId; + } + } + + return r; + } + + // TODO: Bug 726055 - More considered handling of when to sync. + protected boolean shouldDownload() { + // Ask info/collections whether a download is needed. + return true; + } + + protected boolean shouldUpload() { + if (shouldUploadLocalRecord) { + return true; + } + + long lastUpload = session.config.getPersistedServerClientRecordTimestamp(); // Defaults to 0. + if (lastUpload == 0) { + return true; + } + + if (session.getClientsDelegate().getLastModifiedTimestamp() > lastUpload) { + // Something's changed locally since we last uploaded. + return true; + } + + // Note the opportunity for clock drift problems here. + // TODO: if we track download times, we can use the timestamp of most + // recent download response instead of the current time. + long now = System.currentTimeMillis(); + long age = now - lastUpload; + return age >= CLIENTS_TTL_REFRESH; + } + + protected void handleDownloadedLocalRecord(ClientRecord r) { + session.config.persistServerClientRecordTimestamp(r.lastModified); + + if (!getLocalClientVersion().equals(r.version) || + !getLocalClientProtocols().equals(r.protocols)) { + shouldUploadLocalRecord = true; + } + processCommands(r.commands); + } + + protected void processCommands(JSONArray commands) { + if (commands == null || + commands.size() == 0) { + return; + } + + shouldUploadLocalRecord = true; + CommandProcessor processor = CommandProcessor.getProcessor(); + + for (Object o : commands) { + processor.processCommand(session, new ExtendedJSONObject((JSONObject) o)); + } + } + + @SuppressWarnings("unchecked") + protected void addCommands(ClientRecord record) throws NullCursorException { + Logger.trace(LOG_TAG, "Adding commands to " + record.guid); + List<Command> commands = db.fetchCommandsForClient(record.guid); + + if (commands == null || commands.size() == 0) { + Logger.trace(LOG_TAG, "No commands to add."); + return; + } + + for (Command command : commands) { + JSONObject jsonCommand = command.asJSONObject(); + if (record.commands == null) { + record.commands = new JSONArray(); + } + record.commands.add(jsonCommand); + } + modifiedClientsToUpload.add(record); + } + + @SuppressWarnings("unchecked") + protected void uploadRemoteRecords() { + Logger.trace(LOG_TAG, "In uploadRemoteRecords. Uploading " + modifiedClientsToUpload.size() + " records" ); + + for (ClientRecord r : modifiedClientsToUpload) { + Logger.trace(LOG_TAG, ">> Uploading record " + r.guid + ": " + r.name); + } + + if (modifiedClientsToUpload.size() == 1) { + ClientRecord record = modifiedClientsToUpload.get(0); + Logger.debug(LOG_TAG, "Only 1 remote record to upload."); + Logger.debug(LOG_TAG, "Record last modified: " + record.lastModified); + CryptoRecord cryptoRecord = encryptClientRecord(record); + if (cryptoRecord != null) { + clientUploadDelegate.setUploadDetails(false); + this.uploadClientRecord(cryptoRecord); + } + return; + } + + JSONArray cryptoRecords = new JSONArray(); + for (ClientRecord record : modifiedClientsToUpload) { + Logger.trace(LOG_TAG, "Record " + record.guid + " is being uploaded" ); + + CryptoRecord cryptoRecord = encryptClientRecord(record); + cryptoRecords.add(cryptoRecord.toJSONObject()); + } + Logger.debug(LOG_TAG, "Uploading records: " + cryptoRecords.size()); + clientUploadDelegate.setUploadDetails(false); + this.uploadClientRecords(cryptoRecords); + } + + protected void checkAndUpload() { + if (!shouldUpload()) { + Logger.debug(LOG_TAG, "Not uploading client record."); + session.advance(); + return; + } + + final ClientRecord localClient = newLocalClientRecord(session.getClientsDelegate()); + clientUploadDelegate.setUploadDetails(true); + CryptoRecord cryptoRecord = encryptClientRecord(localClient); + if (cryptoRecord != null) { + this.uploadClientRecord(cryptoRecord); + } + } + + protected CryptoRecord encryptClientRecord(ClientRecord recordToUpload) { + // Generate CryptoRecord from ClientRecord to upload. + final String encryptionFailure = "Couldn't encrypt new client record."; + + try { + CryptoRecord cryptoRecord = recordToUpload.getEnvelope(); + cryptoRecord.keyBundle = clientUploadDelegate.keyBundle(); + if (cryptoRecord.keyBundle == null) { + session.abort(new NoCollectionKeysSetException(), "No collection keys set."); + return null; + } + return cryptoRecord.encrypt(); + } catch (UnsupportedEncodingException e) { + session.abort(e, encryptionFailure + " Unsupported encoding."); + } catch (CryptoException e) { + session.abort(e, encryptionFailure); + } + return null; + } + + public void clearRecordsToUpload() { + try { + getClientsDatabaseAccessor().wipeCommandsTable(); + modifiedClientsToUpload.clear(); + } finally { + closeDataAccessor(); + } + } + + protected void downloadClientRecords() { + shouldWipe = true; + clientDownloadDelegate = makeClientDownloadDelegate(); + + try { + final URI getURI = session.config.collectionURI(COLLECTION_NAME, true); + final SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(getURI); + request.delegate = clientDownloadDelegate; + + Logger.trace(LOG_TAG, "Downloading client records."); + request.get(); + } catch (URISyntaxException e) { + session.abort(e, "Invalid URI."); + } + } + + protected void uploadClientRecords(JSONArray records) { + Logger.trace(LOG_TAG, "Uploading " + records.size() + " client records."); + try { + final URI postURI = session.config.collectionURI(COLLECTION_NAME, false); + final SyncStorageRecordRequest request = new SyncStorageRecordRequest(postURI); + request.delegate = clientUploadDelegate; + request.post(records); + } catch (URISyntaxException e) { + session.abort(e, "Invalid URI."); + } catch (Exception e) { + session.abort(e, "Unable to parse body."); + } + } + + /** + * Upload a client record via HTTP POST to the parent collection. + */ + protected void uploadClientRecord(CryptoRecord record) { + Logger.debug(LOG_TAG, "Uploading client record " + record.guid); + try { + final URI postURI = session.config.collectionURI(COLLECTION_NAME); + final SyncStorageRecordRequest request = new SyncStorageRecordRequest(postURI); + request.delegate = clientUploadDelegate; + request.post(record); + } catch (URISyntaxException e) { + session.abort(e, "Invalid URI."); + } + } + + protected ClientDownloadDelegate makeClientDownloadDelegate() { + return new ClientDownloadDelegate(); + } + + protected void wipeAndStore(ClientRecord record) { + final ClientsDatabaseAccessor db = getClientsDatabaseAccessor(); + if (shouldWipe) { + db.wipeClientsTable(); + shouldWipe = false; + } + if (record != null) { + db.store(record); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java new file mode 100644 index 000000000..77846c212 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java @@ -0,0 +1,18 @@ +/* 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.stage; + + +public class UploadMetaGlobalStage extends AbstractNonRepositorySyncStage { + public static final String LOG_TAG = "UploadMGStage"; + + @Override + public void execute() throws NoSuchStageException { + if (session.hasUpdatedMetaGlobal()) { + session.uploadUpdatedMetaGlobal(); + } + session.advance(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java new file mode 100644 index 000000000..9b1ef3e85 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java @@ -0,0 +1,122 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.domain.Record; + +/** + * Consume records from a queue inside a RecordsChannel, as fast as we can. + * TODO: rewrite this in terms of an ExecutorService and a CompletionService. + * See Bug 713483. + * + * @author rnewman + * + */ +class ConcurrentRecordConsumer extends RecordConsumer { + private static final String LOG_TAG = "CRecordConsumer"; + + /** + * When this is true and all records have been processed, the consumer + * will notify its delegate. + */ + protected boolean allRecordsQueued = false; + private long counter = 0; + + public ConcurrentRecordConsumer(RecordsConsumerDelegate delegate) { + this.delegate = delegate; + } + + private final Object monitor = new Object(); + @Override + public void doNotify() { + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void queueFilled() { + Logger.debug(LOG_TAG, "Queue filled."); + synchronized (monitor) { + this.allRecordsQueued = true; + monitor.notify(); + } + } + + @Override + public void halt() { + synchronized (monitor) { + this.stopImmediately = true; + monitor.notify(); + } + } + + private final Object countMonitor = new Object(); + @Override + public void stored() { + Logger.trace(LOG_TAG, "Record stored. Notifying."); + synchronized (countMonitor) { + counter++; + } + } + + private void consumerIsDone() { + Logger.debug(LOG_TAG, "Consumer is done. Processed " + counter + ((counter == 1) ? " record." : " records.")); + delegate.consumerIsDone(!allRecordsQueued); + } + + @Override + public void run() { + Record record; + + while (true) { + // The queue is concurrent-safe. + while ((record = delegate.getQueue().poll()) != null) { + synchronized (monitor) { + Logger.trace(LOG_TAG, "run() took monitor."); + if (stopImmediately) { + Logger.debug(LOG_TAG, "Stopping immediately. Clearing queue."); + delegate.getQueue().clear(); + Logger.debug(LOG_TAG, "Notifying consumer."); + consumerIsDone(); + return; + } + Logger.debug(LOG_TAG, "run() dropped monitor."); + } + + Logger.trace(LOG_TAG, "Storing record with guid " + record.guid + "."); + try { + delegate.store(record); + } catch (Exception e) { + // TODO: Bug 709371: track records that failed to apply. + Logger.error(LOG_TAG, "Caught error in store.", e); + } + Logger.trace(LOG_TAG, "Done with record."); + } + synchronized (monitor) { + Logger.trace(LOG_TAG, "run() took monitor."); + + if (allRecordsQueued) { + Logger.debug(LOG_TAG, "Done with records and no more to come. Notifying consumerIsDone."); + consumerIsDone(); + return; + } + if (stopImmediately) { + Logger.debug(LOG_TAG, "Done with records and told to stop immediately. Notifying consumerIsDone."); + consumerIsDone(); + return; + } + try { + Logger.debug(LOG_TAG, "Not told to stop but no records. Waiting."); + monitor.wait(10000); + } catch (InterruptedException e) { + // TODO + } + Logger.trace(LOG_TAG, "run() dropped monitor."); + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java new file mode 100644 index 000000000..35e57d9c2 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java @@ -0,0 +1,26 @@ +/* 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.synchronizer; + +public abstract class RecordConsumer implements Runnable { + + public abstract void stored(); + + /** + * There are no more store items to arrive at the delegate. + * When you're done, take care of finishing up. + */ + public abstract void queueFilled(); + public abstract void halt(); + + public abstract void doNotify(); + + protected boolean stopImmediately = false; + protected RecordsConsumerDelegate delegate; + + public RecordConsumer() { + super(); + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java new file mode 100644 index 000000000..f929cdc75 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java @@ -0,0 +1,292 @@ +/* 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.synchronizer; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.ThreadPool; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.NoStoreDelegateException; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.delegates.DeferredRepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.DeferredRepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; +import org.mozilla.gecko.sync.repositories.domain.Record; + +/** + * Pulls records from `source`, applying them to `sink`. + * Notifies its delegate of errors and completion. + * + * All stores (initiated by a fetch) must have been completed before storeDone + * is invoked on the sink. This is to avoid the existing stored items being + * considered as the total set, with onStoreCompleted being called when they're + * done: + * + * store(A) store(B) + * store(C) storeDone() + * store(A) finishes. Store job begins. + * store(C) finishes. Store job begins. + * storeDone() finishes. + * Storing of A complete. + * Storing of C complete. + * We're done! Call onStoreCompleted. + * store(B) finishes... uh oh. + * + * In other words, storeDone must be gated on the synchronous invocation of every store. + * + * Similarly, we require that every store callback have returned before onStoreCompleted is invoked. + * + * This whole set of guarantees should be achievable thusly: + * + * * The fetch process must run in a single thread, and invoke store() + * synchronously. After processing every incoming record, storeDone is called, + * setting a flag. + * If the fetch cannot be implicitly queued, it must be explicitly queued. + * In this implementation, we assume that fetch callbacks are strictly ordered in this way. + * + * * The store process must be (implicitly or explicitly) queued. When the + * queue empties, the consumer checks the storeDone flag. If it's set, and the + * queue is exhausted, invoke onStoreCompleted. + * + * RecordsChannel exists to enforce this ordering of operations. + * + * @author rnewman + * + */ +public class RecordsChannel implements + RepositorySessionFetchRecordsDelegate, + RepositorySessionStoreDelegate, + RecordsConsumerDelegate, + RepositorySessionBeginDelegate { + + private static final String LOG_TAG = "RecordsChannel"; + public RepositorySession source; + public RepositorySession sink; + private final RecordsChannelDelegate delegate; + private long fetchEnd = -1; + + protected final AtomicInteger numFetched = new AtomicInteger(); + protected final AtomicInteger numFetchFailed = new AtomicInteger(); + protected final AtomicInteger numStored = new AtomicInteger(); + protected final AtomicInteger numStoreFailed = new AtomicInteger(); + + public RecordsChannel(RepositorySession source, RepositorySession sink, RecordsChannelDelegate delegate) { + this.source = source; + this.sink = sink; + this.delegate = delegate; + } + + /* + * We push fetched records into a queue. + * A separate thread is waiting for us to notify it of work to do. + * When we tell it to stop, it'll stop. We do that when the fetch + * is completed. + * When it stops, we tell the sink that there are no more records, + * and wait for the sink to tell us that storing is done. + * Then we notify our delegate of completion. + */ + private RecordConsumer consumer; + private boolean waitingForQueueDone = false; + private final ConcurrentLinkedQueue<Record> toProcess = new ConcurrentLinkedQueue<Record>(); + + @Override + public ConcurrentLinkedQueue<Record> getQueue() { + return toProcess; + } + + protected boolean isReady() { + return source.isActive() && sink.isActive(); + } + + /** + * Get the number of records fetched so far. + * + * @return number of fetches. + */ + public int getFetchCount() { + return numFetched.get(); + } + + /** + * Get the number of fetch failures recorded so far. + * + * @return number of fetch failures. + */ + public int getFetchFailureCount() { + return numFetchFailed.get(); + } + + /** + * Get the number of store attempts (successful or not) so far. + * + * @return number of stores attempted. + */ + public int getStoreCount() { + return numStored.get(); + } + + /** + * Get the number of store failures recorded so far. + * + * @return number of store failures. + */ + public int getStoreFailureCount() { + return numStoreFailed.get(); + } + + /** + * Start records flowing through the channel. + */ + public void flow() { + if (!isReady()) { + RepositorySession failed = source; + if (source.isActive()) { + failed = sink; + } + this.delegate.onFlowBeginFailed(this, new SessionNotBegunException(failed)); + return; + } + + if (!source.dataAvailable()) { + Logger.info(LOG_TAG, "No data available: short-circuiting flow from source " + source); + long now = System.currentTimeMillis(); + this.delegate.onFlowCompleted(this, now, now); + return; + } + + sink.setStoreDelegate(this); + numFetched.set(0); + numFetchFailed.set(0); + numStored.set(0); + numStoreFailed.set(0); + // Start a consumer thread. + this.consumer = new ConcurrentRecordConsumer(this); + ThreadPool.run(this.consumer); + waitingForQueueDone = true; + source.fetchSince(source.getLastSyncTimestamp(), this); + } + + /** + * Begin both sessions, invoking flow() when done. + * @throws InvalidSessionTransitionException + */ + public void beginAndFlow() throws InvalidSessionTransitionException { + Logger.trace(LOG_TAG, "Beginning source."); + source.begin(this); + } + + @Override + public void store(Record record) { + numStored.incrementAndGet(); + try { + sink.store(record); + } catch (NoStoreDelegateException e) { + Logger.error(LOG_TAG, "Got NoStoreDelegateException in RecordsChannel.store(). This should not occur. Aborting.", e); + delegate.onFlowStoreFailed(this, e, record.guid); + } + } + + @Override + public void onFetchFailed(Exception ex, Record record) { + Logger.warn(LOG_TAG, "onFetchFailed. Calling for immediate stop.", ex); + numFetchFailed.incrementAndGet(); + this.consumer.halt(); + delegate.onFlowFetchFailed(this, ex); + } + + @Override + public void onFetchedRecord(Record record) { + numFetched.incrementAndGet(); + this.toProcess.add(record); + this.consumer.doNotify(); + } + + @Override + public void onFetchCompleted(final long fetchEnd) { + Logger.trace(LOG_TAG, "onFetchCompleted. Stopping consumer once stores are done."); + Logger.trace(LOG_TAG, "Fetch timestamp is " + fetchEnd); + this.fetchEnd = fetchEnd; + this.consumer.queueFilled(); + } + + @Override + public void onRecordStoreFailed(Exception ex, String recordGuid) { + Logger.trace(LOG_TAG, "Failed to store record with guid " + recordGuid); + numStoreFailed.incrementAndGet(); + this.consumer.stored(); + delegate.onFlowStoreFailed(this, ex, recordGuid); + // TODO: abort? + } + + @Override + public void onRecordStoreSucceeded(String guid) { + Logger.trace(LOG_TAG, "Stored record with guid " + guid); + this.consumer.stored(); + } + + + @Override + public void consumerIsDone(boolean allRecordsQueued) { + Logger.trace(LOG_TAG, "Consumer is done. Are we waiting for it? " + waitingForQueueDone); + if (waitingForQueueDone) { + waitingForQueueDone = false; + this.sink.storeDone(); // Now we'll be waiting for onStoreCompleted. + } + } + + @Override + public void onStoreCompleted(long storeEnd) { + Logger.trace(LOG_TAG, "onStoreCompleted. Notifying delegate of onFlowCompleted. " + + "Fetch end is " + fetchEnd + ", store end is " + storeEnd); + // TODO: synchronize on consumer callback? + delegate.onFlowCompleted(this, fetchEnd, storeEnd); + } + + @Override + public void onBeginFailed(Exception ex) { + delegate.onFlowBeginFailed(this, ex); + } + + @Override + public void onBeginSucceeded(RepositorySession session) { + if (session == source) { + Logger.trace(LOG_TAG, "Source session began. Beginning sink session."); + try { + sink.begin(this); + } catch (InvalidSessionTransitionException e) { + onBeginFailed(e); + return; + } + } + if (session == sink) { + Logger.trace(LOG_TAG, "Sink session began. Beginning flow."); + this.flow(); + return; + } + + // TODO: error! + } + + @Override + public RepositorySessionStoreDelegate deferredStoreDelegate(final ExecutorService executor) { + return new DeferredRepositorySessionStoreDelegate(this, executor); + } + + @Override + public RepositorySessionBeginDelegate deferredBeginDelegate(final ExecutorService executor) { + return new DeferredRepositorySessionBeginDelegate(this, executor); + } + + @Override + public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) { + // Lie outright. We know that all of our fetch methods are safe. + return this; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java new file mode 100644 index 000000000..8daeb7ad5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java @@ -0,0 +1,13 @@ +/* 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.synchronizer; + +public interface RecordsChannelDelegate { + public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd); + public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex); + public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex); + public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid); + public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java new file mode 100644 index 000000000..a00abf848 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java @@ -0,0 +1,23 @@ +/* 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.synchronizer; + +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.mozilla.gecko.sync.repositories.domain.Record; + +interface RecordsConsumerDelegate { + public abstract ConcurrentLinkedQueue<Record> getQueue(); + + /** + * Called when no more items will be processed. + * If forced is true, the consumer is terminating because it was told to halt; + * not all items will necessarily have been processed. + * If forced is false, the consumer has invoked store and received an onStoreCompleted callback. + * @param forced + */ + public abstract void consumerIsDone(boolean forced); + public abstract void store(Record record); +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java new file mode 100644 index 000000000..6ee44ea2b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java @@ -0,0 +1,131 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.domain.Record; + +/** + * Consume records from a queue inside a RecordsChannel, storing them serially. + * @author rnewman + * + */ +class SerialRecordConsumer extends RecordConsumer { + private static final String LOG_TAG = "SerialRecordConsumer"; + protected boolean stopEventually = false; + private volatile long counter = 0; + + public SerialRecordConsumer(RecordsConsumerDelegate delegate) { + this.delegate = delegate; + } + + private final Object monitor = new Object(); + @Override + public void doNotify() { + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void queueFilled() { + Logger.debug(LOG_TAG, "Queue filled."); + synchronized (monitor) { + this.stopEventually = true; + monitor.notify(); + } + } + + @Override + public void halt() { + Logger.debug(LOG_TAG, "Halting."); + synchronized (monitor) { + this.stopEventually = true; + this.stopImmediately = true; + monitor.notify(); + } + } + + private final Object storeSerializer = new Object(); + @Override + public void stored() { + Logger.debug(LOG_TAG, "Record stored. Notifying."); + synchronized (storeSerializer) { + Logger.debug(LOG_TAG, "stored() took storeSerializer."); + counter++; + storeSerializer.notify(); + Logger.debug(LOG_TAG, "stored() dropped storeSerializer."); + } + } + private void storeSerially(Record record) { + Logger.debug(LOG_TAG, "New record to store."); + synchronized (storeSerializer) { + Logger.debug(LOG_TAG, "storeSerially() took storeSerializer."); + Logger.debug(LOG_TAG, "Storing..."); + try { + this.delegate.store(record); + } catch (Exception e) { + Logger.warn(LOG_TAG, "Got exception in store. Not waiting.", e); + return; // So we don't block for a stored() that never comes. + } + try { + Logger.debug(LOG_TAG, "Waiting..."); + storeSerializer.wait(); + } catch (InterruptedException e) { + // TODO + } + Logger.debug(LOG_TAG, "storeSerially() dropped storeSerializer."); + } + } + + private void consumerIsDone() { + long counterNow = this.counter; + Logger.info(LOG_TAG, "Consumer is done. Processed " + counterNow + ((counterNow == 1) ? " record." : " records.")); + delegate.consumerIsDone(stopImmediately); + } + + @Override + public void run() { + while (true) { + synchronized (monitor) { + Logger.debug(LOG_TAG, "run() took monitor."); + if (stopImmediately) { + Logger.debug(LOG_TAG, "Stopping immediately. Clearing queue."); + delegate.getQueue().clear(); + Logger.debug(LOG_TAG, "Notifying consumer."); + consumerIsDone(); + return; + } + Logger.debug(LOG_TAG, "run() dropped monitor."); + } + // The queue is concurrent-safe. + while (!delegate.getQueue().isEmpty()) { + Logger.debug(LOG_TAG, "Grabbing record..."); + Record record = delegate.getQueue().remove(); + // Block here, allowing us to process records + // serially. + Logger.debug(LOG_TAG, "Invoking storeSerially..."); + this.storeSerially(record); + Logger.debug(LOG_TAG, "Done with record."); + } + synchronized (monitor) { + Logger.debug(LOG_TAG, "run() took monitor."); + + if (stopEventually) { + Logger.debug(LOG_TAG, "Done with records and told to stop. Notifying consumer."); + consumerIsDone(); + return; + } + try { + Logger.debug(LOG_TAG, "Not told to stop but no records. Waiting."); + monitor.wait(10000); + } catch (InterruptedException e) { + // TODO + } + Logger.debug(LOG_TAG, "run() dropped monitor."); + } + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java new file mode 100644 index 000000000..ac4f48789 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java @@ -0,0 +1,18 @@ +/* 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.synchronizer; + +/** + * A <code>SynchronizerSession</code> designed to be used between a remote + * server and a local repository. + * <p> + * See <code>ServerLocalSynchronizerSession</code> for error handling details. + */ +public class ServerLocalSynchronizer extends Synchronizer { + @Override + public SynchronizerSession newSynchronizerSession() { + return new ServerLocalSynchronizerSession(this, this); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java new file mode 100644 index 000000000..dc9eb01a0 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java @@ -0,0 +1,78 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.FetchFailedException; +import org.mozilla.gecko.sync.repositories.StoreFailedException; + +/** + * A <code>SynchronizerSession</code> designed to be used between a remote + * server and a local repository. + * <p> + * Handles failure cases as follows (in the order they will occur during a sync): + * <ul> + * <li>Remote fetch failures abort.</li> + * <li>Local store failures are ignored.</li> + * <li>Local fetch failures abort.</li> + * <li>Remote store failures abort.</li> + * </ul> + */ +public class ServerLocalSynchronizerSession extends SynchronizerSession { + protected static final String LOG_TAG = "ServLocSynchronizerSess"; + + public ServerLocalSynchronizerSession(Synchronizer synchronizer, SynchronizerSessionDelegate delegate) { + super(synchronizer, delegate); + } + + @Override + public void onFirstFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) { + // Fetch failures always abort. + int numRemoteFetchFailed = recordsChannel.getFetchFailureCount(); + if (numRemoteFetchFailed > 0) { + final String message = "Got " + numRemoteFetchFailed + " failures fetching remote records!"; + Logger.warn(LOG_TAG, message + " Aborting session."); + delegate.onSynchronizeFailed(this, new FetchFailedException(), message); + return; + } + Logger.trace(LOG_TAG, "No failures fetching remote records."); + + // Local store failures are ignored. + int numLocalStoreFailed = recordsChannel.getStoreFailureCount(); + if (numLocalStoreFailed > 0) { + final String message = "Got " + numLocalStoreFailed + " failures storing local records!"; + Logger.warn(LOG_TAG, message + " Ignoring local store failures and continuing synchronizer session."); + } else { + Logger.trace(LOG_TAG, "No failures storing local records."); + } + + super.onFirstFlowCompleted(recordsChannel, fetchEnd, storeEnd); + } + + @Override + public void onSecondFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) { + // Fetch failures always abort. + int numLocalFetchFailed = recordsChannel.getFetchFailureCount(); + if (numLocalFetchFailed > 0) { + final String message = "Got " + numLocalFetchFailed + " failures fetching local records!"; + Logger.warn(LOG_TAG, message + " Aborting session."); + delegate.onSynchronizeFailed(this, new FetchFailedException(), message); + return; + } + Logger.trace(LOG_TAG, "No failures fetching local records."); + + // Remote store failures abort! + int numRemoteStoreFailed = recordsChannel.getStoreFailureCount(); + if (numRemoteStoreFailed > 0) { + final String message = "Got " + numRemoteStoreFailed + " failures storing remote records!"; + Logger.warn(LOG_TAG, message + " Aborting session."); + delegate.onSynchronizeFailed(this, new StoreFailedException(), message); + return; + } + Logger.trace(LOG_TAG, "No failures storing remote records."); + + super.onSecondFlowCompleted(recordsChannel, fetchEnd, storeEnd); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java new file mode 100644 index 000000000..20c7fcd56 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java @@ -0,0 +1,19 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.sync.SyncException; +import org.mozilla.gecko.sync.repositories.RepositorySession; + +public class SessionNotBegunException extends SyncException { + + public RepositorySession failed; + + public SessionNotBegunException(RepositorySession failed) { + this.failed = failed; + } + + private static final long serialVersionUID = -4565241449897072841L; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java new file mode 100644 index 000000000..cc15b35a9 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java @@ -0,0 +1,105 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.SynchronizerConfiguration; +import org.mozilla.gecko.sync.repositories.Repository; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; + +import android.content.Context; + +/** + * I perform a sync. + * + * Initialize me by calling `load` with a SynchronizerConfiguration. + * + * Start synchronizing by calling `synchronize` with a SynchronizerDelegate. I + * provide coarse-grained feedback by calling my delegate's callback methods. + * + * I always call exactly one of my delegate's `onSynchronized` or + * `onSynchronizeFailed` callback methods. In addition, I call + * `onSynchronizeAborted` before `onSynchronizeFailed` when I encounter a fetch, + * store, or session error while synchronizing. + * + * After synchronizing, call `save` to get back a SynchronizerConfiguration with + * updated bundle information. + */ +public class Synchronizer implements SynchronizerSessionDelegate { + public static final String LOG_TAG = "SyncDelSDelegate"; + + protected String configSyncID; // Used to pass syncID from load() back into save(). + + protected SynchronizerDelegate synchronizerDelegate; + + protected SynchronizerSession session = null; + + public SynchronizerSession getSynchronizerSession() { + return session; + } + + @Override + public void onInitialized(SynchronizerSession session) { + session.synchronize(); + } + + @Override + public void onSynchronized(SynchronizerSession synchronizerSession) { + Logger.debug(LOG_TAG, "Got onSynchronized."); + Logger.debug(LOG_TAG, "Notifying SynchronizerDelegate."); + this.synchronizerDelegate.onSynchronized(synchronizerSession.getSynchronizer()); + } + + @Override + public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) { + Logger.debug(LOG_TAG, "Got onSynchronizeSkipped."); + Logger.debug(LOG_TAG, "Notifying SynchronizerDelegate as if on success."); + this.synchronizerDelegate.onSynchronized(synchronizerSession.getSynchronizer()); + } + + @Override + public void onSynchronizeFailed(SynchronizerSession session, + Exception lastException, String reason) { + this.synchronizerDelegate.onSynchronizeFailed(session.getSynchronizer(), lastException, reason); + } + + public Repository repositoryA; + public Repository repositoryB; + public RepositorySessionBundle bundleA; + public RepositorySessionBundle bundleB; + + /** + * Fetch a synchronizer session appropriate for this <code>Synchronizer</code> + */ + protected SynchronizerSession newSynchronizerSession() { + return new SynchronizerSession(this, this); + } + + /** + * Start synchronizing, calling delegate's callback methods. + */ + public void synchronize(Context context, SynchronizerDelegate delegate) { + this.synchronizerDelegate = delegate; + this.session = newSynchronizerSession(); + this.session.init(context, bundleA, bundleB); + } + + public SynchronizerConfiguration save() { + return new SynchronizerConfiguration(configSyncID, bundleA, bundleB); + } + + /** + * Set my repository session bundles from a SynchronizerConfiguration. + * + * This method is not thread-safe. + * + * @param config + */ + public void load(SynchronizerConfiguration config) { + bundleA = config.remoteBundle; + bundleB = config.localBundle; + configSyncID = config.syncID; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java new file mode 100644 index 000000000..a290188ab --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java @@ -0,0 +1,10 @@ +/* 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.synchronizer; + +public interface SynchronizerDelegate { + public void onSynchronized(Synchronizer synchronizer); + public void onSynchronizeFailed(Synchronizer synchronizer, Exception lastException, String reason); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java new file mode 100644 index 000000000..c4d244b4c --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java @@ -0,0 +1,425 @@ +/* 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.synchronizer; + + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.repositories.InactiveSessionException; +import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; +import org.mozilla.gecko.sync.repositories.RepositorySession; +import org.mozilla.gecko.sync.repositories.RepositorySessionBundle; +import org.mozilla.gecko.sync.repositories.delegates.DeferrableRepositorySessionCreationDelegate; +import org.mozilla.gecko.sync.repositories.delegates.DeferredRepositorySessionFinishDelegate; +import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; + +import android.content.Context; + +/** + * I coordinate the moving parts of a sync started by + * {@link Synchronizer#synchronize}. + * + * I flow records twice: first from A to B, and then from B to A. I provide + * fine-grained feedback by calling my delegate's callback methods. + * + * Initialize me by creating me with a Synchronizer and a + * SynchronizerSessionDelegate. Kick things off by calling `init` with two + * RepositorySessionBundles, and then call `synchronize` in your `onInitialized` + * callback. + * + * I always call exactly one of my delegate's `onInitialized` or + * `onSessionError` callback methods from `init`. + * + * I call my delegate's `onSynchronizeSkipped` callback method if there is no + * data to be synchronized in `synchronize`. + * + * In addition, I call `onFetchError`, `onStoreError`, and `onSessionError` when + * I encounter a fetch, store, or session error while synchronizing. + * + * Typically my delegate will call `abort` in its error callbacks, which will + * call my delegate's `onSynchronizeAborted` method and halt the sync. + * + * I always call exactly one of my delegate's `onSynchronized` or + * `onSynchronizeFailed` callback methods if I have not seen an error. + */ +public class SynchronizerSession +extends DeferrableRepositorySessionCreationDelegate +implements RecordsChannelDelegate, + RepositorySessionFinishDelegate { + + protected static final String LOG_TAG = "SynchronizerSession"; + protected Synchronizer synchronizer; + protected SynchronizerSessionDelegate delegate; + protected Context context; + + /* + * Computed during init. + */ + private RepositorySession sessionA; + private RepositorySession sessionB; + private RepositorySessionBundle bundleA; + private RepositorySessionBundle bundleB; + + // Bug 726054: just like desktop, we track our last interaction with the server, + // not the last record timestamp that we fetched. This ensures that we don't re- + // download the records we just uploaded, at the cost of skipping any records + // that a concurrently syncing client has uploaded. + private long pendingATimestamp = -1; + private long pendingBTimestamp = -1; + private long storeEndATimestamp = -1; + private long storeEndBTimestamp = -1; + private boolean flowAToBCompleted = false; + private boolean flowBToACompleted = false; + + protected final AtomicInteger numInboundRecords = new AtomicInteger(-1); + protected final AtomicInteger numOutboundRecords = new AtomicInteger(-1); + + /* + * Public API: constructor, init, synchronize. + */ + public SynchronizerSession(Synchronizer synchronizer, SynchronizerSessionDelegate delegate) { + this.setSynchronizer(synchronizer); + this.delegate = delegate; + } + + public Synchronizer getSynchronizer() { + return synchronizer; + } + + public void setSynchronizer(Synchronizer synchronizer) { + this.synchronizer = synchronizer; + } + + public void init(Context context, RepositorySessionBundle bundleA, RepositorySessionBundle bundleB) { + this.context = context; + this.bundleA = bundleA; + this.bundleB = bundleB; + // Begin sessionA and sessionB, call onInitialized in callbacks. + this.getSynchronizer().repositoryA.createSession(this, context); + } + + /** + * Get the number of records fetched from the first repository (usually the + * server, hence inbound). + * <p> + * Valid only after first flow has completed. + * + * @return number of records, or -1 if not valid. + */ + public int getInboundCount() { + return numInboundRecords.get(); + } + + /** + * Get the number of records fetched from the second repository (usually the + * local store, hence outbound). + * <p> + * Valid only after second flow has completed. + * + * @return number of records, or -1 if not valid. + */ + public int getOutboundCount() { + return numOutboundRecords.get(); + } + + // These are accessed by `abort` and `synchronize`, both of which are synchronized. + // Guarded by `this`. + protected RecordsChannel channelAToB; + protected RecordsChannel channelBToA; + + /** + * Please don't call this until you've been notified with onInitialized. + */ + public synchronized void synchronize() { + numInboundRecords.set(-1); + numOutboundRecords.set(-1); + + // First thing: decide whether we should. + if (sessionA.shouldSkip() || + sessionB.shouldSkip()) { + Logger.info(LOG_TAG, "Session requested skip. Short-circuiting sync."); + sessionA.abort(); + sessionB.abort(); + this.delegate.onSynchronizeSkipped(this); + return; + } + + final SynchronizerSession session = this; + + // TODO: failed record handling. + + // This is the *second* record channel to flow. + // I, SynchronizerSession, am the delegate for the *second* flow. + channelBToA = new RecordsChannel(this.sessionB, this.sessionA, this); + + // This is the delegate for the *first* flow. + RecordsChannelDelegate channelAToBDelegate = new RecordsChannelDelegate() { + @Override + public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) { + session.onFirstFlowCompleted(recordsChannel, fetchEnd, storeEnd); + } + + @Override + public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex) { + Logger.warn(LOG_TAG, "First RecordsChannel onFlowBeginFailed. Logging session error.", ex); + session.delegate.onSynchronizeFailed(session, ex, "Failed to begin first flow."); + } + + @Override + public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex) { + Logger.warn(LOG_TAG, "First RecordsChannel onFlowFetchFailed. Logging remote fetch error.", ex); + } + + @Override + public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid) { + Logger.warn(LOG_TAG, "First RecordsChannel onFlowStoreFailed. Logging local store error.", ex); + } + + @Override + public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex) { + Logger.warn(LOG_TAG, "First RecordsChannel onFlowFinishedFailed. Logging session error.", ex); + session.delegate.onSynchronizeFailed(session, ex, "Failed to finish first flow."); + } + }; + + // This is the *first* channel to flow. + channelAToB = new RecordsChannel(this.sessionA, this.sessionB, channelAToBDelegate); + + Logger.trace(LOG_TAG, "Starting A to B flow. Channel is " + channelAToB); + try { + channelAToB.beginAndFlow(); + } catch (InvalidSessionTransitionException e) { + onFlowBeginFailed(channelAToB, e); + } + } + + /** + * Called after the first flow completes. + * <p> + * By default, any fetch and store failures are ignored. + * @param recordsChannel the <code>RecordsChannel</code> (for error testing). + * @param fetchEnd timestamp when fetches completed. + * @param storeEnd timestamp when stores completed. + */ + public void onFirstFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) { + Logger.trace(LOG_TAG, "First RecordsChannel onFlowCompleted."); + Logger.debug(LOG_TAG, "Fetch end is " + fetchEnd + ". Store end is " + storeEnd + ". Starting next."); + pendingATimestamp = fetchEnd; + storeEndBTimestamp = storeEnd; + numInboundRecords.set(recordsChannel.getFetchCount()); + flowAToBCompleted = true; + channelBToA.flow(); + } + + /** + * Called after the second flow completes. + * <p> + * By default, any fetch and store failures are ignored. + * @param recordsChannel the <code>RecordsChannel</code> (for error testing). + * @param fetchEnd timestamp when fetches completed. + * @param storeEnd timestamp when stores completed. + */ + public void onSecondFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) { + Logger.trace(LOG_TAG, "Second RecordsChannel onFlowCompleted."); + Logger.debug(LOG_TAG, "Fetch end is " + fetchEnd + ". Store end is " + storeEnd + ". Finishing."); + + pendingBTimestamp = fetchEnd; + storeEndATimestamp = storeEnd; + numOutboundRecords.set(recordsChannel.getFetchCount()); + flowBToACompleted = true; + + // Finish the two sessions. + try { + this.sessionA.finish(this); + } catch (InactiveSessionException e) { + this.onFinishFailed(e); + return; + } + } + + @Override + public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) { + onSecondFlowCompleted(recordsChannel, fetchEnd, storeEnd); + } + + @Override + public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex) { + Logger.warn(LOG_TAG, "Second RecordsChannel onFlowBeginFailed. Logging session error.", ex); + this.delegate.onSynchronizeFailed(this, ex, "Failed to begin second flow."); + } + + @Override + public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex) { + Logger.warn(LOG_TAG, "Second RecordsChannel onFlowFetchFailed. Logging local fetch error.", ex); + } + + @Override + public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid) { + Logger.warn(LOG_TAG, "Second RecordsChannel onFlowStoreFailed. Logging remote store error.", ex); + } + + @Override + public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex) { + Logger.warn(LOG_TAG, "Second RecordsChannel onFlowFinishedFailed. Logging session error.", ex); + this.delegate.onSynchronizeFailed(this, ex, "Failed to finish second flow."); + } + + /* + * RepositorySessionCreationDelegate methods. + */ + + /** + * I could be called twice: once for sessionA and once for sessionB. + * + * I try to clean up sessionA if it is not null, since the creation of + * sessionB must have failed. + */ + @Override + public void onSessionCreateFailed(Exception ex) { + // Attempt to finish the first session, if the second is the one that failed. + if (this.sessionA != null) { + try { + // We no longer need a reference to our context. + this.context = null; + this.sessionA.finish(this); + } catch (Exception e) { + // Never mind; best-effort finish. + } + } + // We no longer need a reference to our context. + this.context = null; + this.delegate.onSynchronizeFailed(this, ex, "Failed to create session"); + } + + /** + * I should be called twice: first for sessionA and second for sessionB. + * + * If I am called for sessionB, I call my delegate's `onInitialized` callback + * method because my repository sessions are correctly initialized. + */ + // TODO: some of this "finish and clean up" code can be refactored out. + @Override + public void onSessionCreated(RepositorySession session) { + if (session == null || + this.sessionA == session) { + // TODO: clean up sessionA. + this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(session), "Failed to create session."); + return; + } + if (this.sessionA == null) { + this.sessionA = session; + + // Unbundle. + try { + this.sessionA.unbundle(this.bundleA); + } catch (Exception e) { + this.delegate.onSynchronizeFailed(this, new UnbundleError(e, sessionA), "Failed to unbundle first session."); + // TODO: abort + return; + } + this.getSynchronizer().repositoryB.createSession(this, this.context); + return; + } + if (this.sessionB == null) { + this.sessionB = session; + // We no longer need a reference to our context. + this.context = null; + + // Unbundle. We unbundled sessionA when that session was created. + try { + this.sessionB.unbundle(this.bundleB); + } catch (Exception e) { + this.delegate.onSynchronizeFailed(this, new UnbundleError(e, sessionA), "Failed to unbundle second session."); + return; + } + + this.delegate.onInitialized(this); + return; + } + // TODO: need a way to make sure we don't call any more delegate methods. + this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(session), "Failed to create session."); + } + + /* + * RepositorySessionFinishDelegate methods. + */ + + /** + * I could be called twice: once for sessionA and once for sessionB. + * + * If sessionB couldn't be created, I don't fail again. + */ + @Override + public void onFinishFailed(Exception ex) { + if (this.sessionB == null) { + // Ah, it was a problem cleaning up. Never mind. + Logger.warn(LOG_TAG, "Got exception cleaning up first after second session creation failed.", ex); + return; + } + String session = (this.sessionA == null) ? "B" : "A"; + this.delegate.onSynchronizeFailed(this, ex, "Finish of session " + session + " failed."); + } + + /** + * I should be called twice: first for sessionA and second for sessionB. + * + * If I am called for sessionA, I try to finish sessionB. + * + * If I am called for sessionB, I call my delegate's `onSynchronized` callback + * method because my flows should have completed. + */ + @Override + public void onFinishSucceeded(RepositorySession session, + RepositorySessionBundle bundle) { + Logger.debug(LOG_TAG, "onFinishSucceeded. Flows? " + flowAToBCompleted + ", " + flowBToACompleted); + + if (session == sessionA) { + if (flowAToBCompleted) { + Logger.debug(LOG_TAG, "onFinishSucceeded: bumping session A's timestamp to " + pendingATimestamp + " or " + storeEndATimestamp); + bundle.bumpTimestamp(Math.max(pendingATimestamp, storeEndATimestamp)); + this.synchronizer.bundleA = bundle; + } else { + // Should not happen! + this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(sessionA), "Failed to finish first session."); + return; + } + if (this.sessionB != null) { + Logger.trace(LOG_TAG, "Finishing session B."); + // On to the next. + try { + this.sessionB.finish(this); + } catch (InactiveSessionException e) { + this.onFinishFailed(e); + return; + } + } + } else if (session == sessionB) { + if (flowBToACompleted) { + Logger.debug(LOG_TAG, "onFinishSucceeded: bumping session B's timestamp to " + pendingBTimestamp + " or " + storeEndBTimestamp); + bundle.bumpTimestamp(Math.max(pendingBTimestamp, storeEndBTimestamp)); + this.synchronizer.bundleB = bundle; + Logger.trace(LOG_TAG, "Notifying delegate.onSynchronized."); + this.delegate.onSynchronized(this); + } else { + // Should not happen! + this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(sessionB), "Failed to finish second session."); + return; + } + } else { + // TODO: hurrrrrr... + } + + if (this.sessionB == null) { + this.sessionA = null; // We're done. + } + } + + @Override + public RepositorySessionFinishDelegate deferredFinishDelegate(final ExecutorService executor) { + return new DeferredRepositorySessionFinishDelegate(this, executor); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java new file mode 100644 index 000000000..1d55274e8 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java @@ -0,0 +1,13 @@ +/* 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.synchronizer; + +public interface SynchronizerSessionDelegate { + public void onInitialized(SynchronizerSession session); + + public void onSynchronized(SynchronizerSession session); + public void onSynchronizeFailed(SynchronizerSession session, Exception lastException, String reason); + public void onSynchronizeSkipped(SynchronizerSession synchronizerSession); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java new file mode 100644 index 000000000..fea779636 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java @@ -0,0 +1,19 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.sync.SyncException; +import org.mozilla.gecko.sync.repositories.RepositorySession; + +public class UnbundleError extends SyncException { + private static final long serialVersionUID = -8709503281041697522L; + + public RepositorySession failedSession; + + public UnbundleError(Exception e, RepositorySession session) { + super(e); + this.failedSession = session; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java new file mode 100644 index 000000000..0237b884b --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java @@ -0,0 +1,26 @@ +/* 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.synchronizer; + +import org.mozilla.gecko.sync.SyncException; +import org.mozilla.gecko.sync.repositories.RepositorySession; + +/** + * An exception class that indicates that a session was passed + * to a begin callback and wasn't expected. + * + * This shouldn't occur. + * + * @author rnewman + * + */ +public class UnexpectedSessionException extends SyncException { + private static final long serialVersionUID = 949010933527484721L; + public RepositorySession session; + + public UnexpectedSessionException(RepositorySession session) { + this.session = session; + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java new file mode 100644 index 000000000..e3e134fe5 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java @@ -0,0 +1,56 @@ +/* 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.telemetry; + +public class TelemetryContract { + /** + * We are a Sync 1.1 (legacy) client, and we downloaded a migration sentinel. + */ + public static final String SYNC11_MIGRATION_SENTINELS_SEEN = "FENNEC_SYNC11_MIGRATION_SENTINELS_SEEN"; + + /** + * We are a Sync 1.1 (legacy) client and we have downloaded a migration + * sentinel, but there was an error creating a Firefox Account from that + * sentinel. + * <p> + * We have logged the error and are ignoring that sentinel. + */ + public static final String SYNC11_MIGRATIONS_FAILED = "FENNEC_SYNC11_MIGRATIONS_FAILED"; + + /** + * We are a Sync 1.1 (legacy) client and we have downloaded a migration + * sentinel, and there was no reported error creating a Firefox Account from + * that sentinel. + * <p> + * We have created a Firefox Account corresponding to the sentinel and have + * queued the existing Old Sync account for removal. + */ + public static final String SYNC11_MIGRATIONS_SUCCEEDED = "FENNEC_SYNC11_MIGRATIONS_SUCCEEDED"; + + /** + * We are (now) a Sync 1.5 (Firefox Accounts-based) client that migrated from + * Sync 1.1. We have presented the user the "complete upgrade" notification. + * <p> + * We will offer every time a sync is triggered, including when a notification + * is already pending. + */ + public static final String SYNC11_MIGRATION_NOTIFICATIONS_OFFERED = "FENNEC_SYNC11_MIGRATION_NOTIFICATIONS_OFFERED"; + + /** + * We are (now) a Sync 1.5 (Firefox Accounts-based) client that migrated from + * Sync 1.1. We have presented the user the "complete upgrade" notification + * and they have successfully completed the upgrade process by entering their + * Firefox Account credentials. + */ + public static final String SYNC11_MIGRATIONS_COMPLETED = "FENNEC_SYNC11_MIGRATIONS_COMPLETED"; + + public static final String SYNC_STARTED = "FENNEC_SYNC_NUMBER_OF_SYNCS_STARTED"; + + public static final String SYNC_COMPLETED = "FENNEC_SYNC_NUMBER_OF_SYNCS_COMPLETED"; + + public static final String SYNC_FAILED = "FENNEC_SYNC_NUMBER_OF_SYNCS_FAILED"; + + public static final String SYNC_FAILED_BACKOFF = "FENNEC_SYNC_NUMBER_OF_SYNCS_FAILED_BACKOFF"; +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java new file mode 100644 index 000000000..9ee014dcb --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java @@ -0,0 +1,330 @@ +/* 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.tokenserver; + +import java.io.IOException; +import java.net.URI; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +import org.json.simple.JSONObject; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.SkewHandler; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonArrayJSONException; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException; +import org.mozilla.gecko.sync.net.AuthHeaderProvider; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider; +import org.mozilla.gecko.sync.net.SyncResponse; +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerConditionsRequiredException; +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerInvalidCredentialsException; +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedRequestException; +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedResponseException; +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerUnknownServiceException; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import ch.boye.httpclientandroidlib.message.BasicHeader; + +/** + * HTTP client for interacting with the Mozilla Services Token Server API v1.0, + * as documented at + * <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>. + * <p> + * A token server accepts some authorization credential and returns a different + * authorization credential. Usually, it used to exchange a public-key + * authorization token that is expensive to validate for a symmetric-key + * authorization that is cheap to validate. For example, we might exchange a + * BrowserID assertion for a HAWK id and key pair. + */ +public class TokenServerClient { + protected static final String LOG_TAG = "TokenServerClient"; + + public static final String JSON_KEY_API_ENDPOINT = "api_endpoint"; + public static final String JSON_KEY_CONDITION_URLS = "condition_urls"; + public static final String JSON_KEY_DURATION = "duration"; + public static final String JSON_KEY_ERRORS = "errors"; + public static final String JSON_KEY_ID = "id"; + public static final String JSON_KEY_KEY = "key"; + public static final String JSON_KEY_UID = "uid"; + + public static final String HEADER_CONDITIONS_ACCEPTED = "X-Conditions-Accepted"; + public static final String HEADER_CLIENT_STATE = "X-Client-State"; + + protected final Executor executor; + protected final URI uri; + + public TokenServerClient(URI uri, Executor executor) { + if (uri == null) { + throw new IllegalArgumentException("uri must not be null"); + } + if (executor == null) { + throw new IllegalArgumentException("executor must not be null"); + } + this.uri = uri; + this.executor = executor; + } + + protected void invokeHandleSuccess(final TokenServerClientDelegate delegate, final TokenServerToken token) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleSuccess(token); + } + }); + } + + protected void invokeHandleFailure(final TokenServerClientDelegate delegate, final TokenServerException e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleFailure(e); + } + }); + } + + /** + * Notify the delegate that some kind of backoff header (X-Backoff, + * X-Weave-Backoff, Retry-After) was received and should be acted upon. + * + * This method is non-terminal, and will be followed by a separate + * <code>invoke*</code> call. + * + * @param delegate + * the delegate to inform. + * @param backoffSeconds + * the number of seconds for which the system should wait before + * making another token server request to this server. + */ + protected void notifyBackoff(final TokenServerClientDelegate delegate, final int backoffSeconds) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleBackoff(backoffSeconds); + } + }); + } + + protected void invokeHandleError(final TokenServerClientDelegate delegate, final Exception e) { + executor.execute(new Runnable() { + @Override + public void run() { + delegate.handleError(e); + } + }); + } + + public TokenServerToken processResponse(SyncResponse res) throws TokenServerException { + int statusCode = res.getStatusCode(); + + Logger.debug(LOG_TAG, "Got token response with status code " + statusCode + "."); + + // Responses should *always* be JSON, even in the case of 4xx and 5xx + // errors. If we don't see JSON, the server is likely very unhappy. + final Header contentType = res.getContentType(); + if (contentType == null) { + throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type."); + } + + final String type = contentType.getValue(); + if (!type.equals("application/json") && + !type.startsWith("application/json;")) { + Logger.warn(LOG_TAG, "Got non-JSON response with Content-Type " + + contentType + ". Misconfigured server?"); + throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type."); + } + + // Responses should *always* be a valid JSON object. + // It turns out that right now they're not always, but that's a server bug... + ExtendedJSONObject result; + try { + result = res.jsonObjectBody(); + } catch (Exception e) { + Logger.debug(LOG_TAG, "Malformed token response.", e); + throw new TokenServerMalformedResponseException(null, e); + } + + // The service shouldn't have any 3xx, so we don't need to handle those. + if (res.getStatusCode() != 200) { + // We should have a (Cornice) error report in the JSON. We log that to + // help with debugging. + List<ExtendedJSONObject> errorList = new ArrayList<ExtendedJSONObject>(); + + if (result.containsKey(JSON_KEY_ERRORS)) { + try { + for (Object error : result.getArray(JSON_KEY_ERRORS)) { + Logger.warn(LOG_TAG, "" + error); + + if (error instanceof JSONObject) { + errorList.add(new ExtendedJSONObject((JSONObject) error)); + } + } + } catch (NonArrayJSONException e) { + Logger.warn(LOG_TAG, "Got non-JSON array '" + JSON_KEY_ERRORS + "'.", e); + } + } + + if (statusCode == 400) { + throw new TokenServerMalformedRequestException(errorList, result.toJSONString()); + } + + if (statusCode == 401) { + throw new TokenServerInvalidCredentialsException(errorList, result.toJSONString()); + } + + // 403 should represent a "condition acceptance needed" response. + // + // The extra validation of "urls" is important. We don't want to signal + // conditions required unless we are absolutely sure that is what the + // server is asking for. + if (statusCode == 403) { + // Bug 792674 and Bug 783598: make this testing simpler. For now, we + // check that errors is an array, and take any condition_urls from the + // first element. + + try { + if (errorList == null || errorList.isEmpty()) { + throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields."); + } + + ExtendedJSONObject error = errorList.get(0); + + ExtendedJSONObject condition_urls = error.getObject(JSON_KEY_CONDITION_URLS); + if (condition_urls != null) { + throw new TokenServerConditionsRequiredException(condition_urls); + } + } catch (NonObjectJSONException e) { + Logger.warn(LOG_TAG, "Got non-JSON error object."); + } + + throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields."); + } + + if (statusCode == 404) { + throw new TokenServerUnknownServiceException(errorList); + } + + // We shouldn't ever get here... + throw new TokenServerException(errorList); + } + + try { + result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_ID, JSON_KEY_KEY, JSON_KEY_API_ENDPOINT }, String.class); + result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_UID }, Long.class); + } catch (BadRequiredFieldJSONException e ) { + throw new TokenServerMalformedResponseException(null, e); + } + + Logger.debug(LOG_TAG, "Successful token response: " + result.getString(JSON_KEY_ID)); + + return new TokenServerToken(result.getString(JSON_KEY_ID), + result.getString(JSON_KEY_KEY), + result.get(JSON_KEY_UID).toString(), + result.getString(JSON_KEY_API_ENDPOINT)); + } + + public static class TokenFetchResourceDelegate extends BaseResourceDelegate { + private final TokenServerClient client; + private final TokenServerClientDelegate delegate; + private final String assertion; + private final String clientState; + private final BaseResource resource; + private final boolean conditionsAccepted; + + public TokenFetchResourceDelegate(TokenServerClient client, + BaseResource resource, + TokenServerClientDelegate delegate, + String assertion, String clientState, + boolean conditionsAccepted) { + super(resource); + this.client = client; + this.delegate = delegate; + this.assertion = assertion; + this.clientState = clientState; + this.resource = resource; + this.conditionsAccepted = conditionsAccepted; + } + + @Override + public String getUserAgent() { + return delegate.getUserAgent(); + } + + @Override + public void handleHttpResponse(HttpResponse response) { + // Skew. + SkewHandler skewHandler = SkewHandler.getSkewHandlerForResource(resource); + skewHandler.updateSkew(response, System.currentTimeMillis()); + + // Extract backoff regardless of whether this was an error response, and + // Retry-After for 503 responses. The error will be handled elsewhere.) + SyncResponse res = new SyncResponse(response); + final boolean includeRetryAfter = res.getStatusCode() == 503; + int backoffInSeconds = res.totalBackoffInSeconds(includeRetryAfter); + if (backoffInSeconds > -1) { + client.notifyBackoff(delegate, backoffInSeconds); + } + + try { + TokenServerToken token = client.processResponse(res); + client.invokeHandleSuccess(delegate, token); + } catch (TokenServerException e) { + client.invokeHandleFailure(delegate, e); + } + } + + @Override + public void handleTransportException(GeneralSecurityException e) { + client.invokeHandleError(delegate, e); + } + + @Override + public void handleHttpProtocolException(ClientProtocolException e) { + client.invokeHandleError(delegate, e); + } + + @Override + public void handleHttpIOException(IOException e) { + client.invokeHandleError(delegate, e); + } + + @Override + public AuthHeaderProvider getAuthHeaderProvider() { + return new BrowserIDAuthHeaderProvider(assertion); + } + + @Override + public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { + String host = request.getURI().getHost(); + request.setHeader(new BasicHeader(HttpHeaders.HOST, host)); + if (clientState != null) { + request.setHeader(new BasicHeader(HEADER_CLIENT_STATE, clientState)); + } + if (conditionsAccepted) { + request.addHeader(HEADER_CONDITIONS_ACCEPTED, "1"); + } + } + } + + public void getTokenFromBrowserIDAssertion(final String assertion, + final boolean conditionsAccepted, + final String clientState, + final TokenServerClientDelegate delegate) { + final BaseResource resource = new BaseResource(this.uri); + resource.delegate = new TokenFetchResourceDelegate(this, resource, delegate, + assertion, clientState, + conditionsAccepted); + resource.get(); + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java new file mode 100644 index 000000000..e1dfe2422 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java @@ -0,0 +1,19 @@ +/* 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.tokenserver; + + +public interface TokenServerClientDelegate { + void handleSuccess(TokenServerToken token); + void handleFailure(TokenServerException e); + void handleError(Exception e); + + /** + * Might be called multiple times, in addition to the other terminating handler methods. + */ + void handleBackoff(int backoffSeconds); + + public String getUserAgent(); +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java new file mode 100644 index 000000000..099e51867 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java @@ -0,0 +1,89 @@ +/* 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.tokenserver; + +import java.util.List; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +public class TokenServerException extends Exception { + private static final long serialVersionUID = 7185692034925819696L; + + public final List<ExtendedJSONObject> errors; + + public TokenServerException(List<ExtendedJSONObject> errors) { + super(); + this.errors = errors; + } + + public TokenServerException(List<ExtendedJSONObject> errors, String string) { + super(string); + this.errors = errors; + } + + public TokenServerException(List<ExtendedJSONObject> errors, Throwable e) { + super(e); + this.errors = errors; + } + + public static class TokenServerConditionsRequiredException extends TokenServerException { + private static final long serialVersionUID = 7578072663150608399L; + + public final ExtendedJSONObject conditionUrls; + + public TokenServerConditionsRequiredException(ExtendedJSONObject urls) { + super(null); + this.conditionUrls = urls; + } + } + + public static class TokenServerInvalidCredentialsException extends TokenServerException { + private static final long serialVersionUID = 7578072663150608398L; + + public TokenServerInvalidCredentialsException(List<ExtendedJSONObject> errors) { + super(errors); + } + + public TokenServerInvalidCredentialsException(List<ExtendedJSONObject> errors, String message) { + super(errors, message); + } + } + + public static class TokenServerUnknownServiceException extends TokenServerException { + private static final long serialVersionUID = 7578072663150608397L; + + public TokenServerUnknownServiceException(List<ExtendedJSONObject> errors) { + super(errors); + } + + public TokenServerUnknownServiceException(List<ExtendedJSONObject> errors, String message) { + super(errors, message); + } + } + + public static class TokenServerMalformedRequestException extends TokenServerException { + private static final long serialVersionUID = 7578072663150608396L; + + public TokenServerMalformedRequestException(List<ExtendedJSONObject> errors) { + super(errors); + } + + public TokenServerMalformedRequestException(List<ExtendedJSONObject> errors, String message) { + super(errors, message); + } + } + + public static class TokenServerMalformedResponseException extends TokenServerException { + private static final long serialVersionUID = 7578072663150608395L; + + public TokenServerMalformedResponseException(List<ExtendedJSONObject> errors, String message) { + super(errors, message); + } + + public TokenServerMalformedResponseException(List<ExtendedJSONObject> errors, Throwable e) { + super(errors, e); + } + } +} diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java new file mode 100644 index 000000000..916586cdc --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java @@ -0,0 +1,19 @@ +/* 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.tokenserver; + +public class TokenServerToken { + public final String id; + public final String key; + public final String uid; + public final String endpoint; + + public TokenServerToken(String id, String key, String uid, String endpoint) { + this.id = id; + this.key = key; + this.uid = uid; + this.endpoint = endpoint; + } +}
\ No newline at end of file diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java b/mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java new file mode 100644 index 000000000..ebb50f765 --- /dev/null +++ b/mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java @@ -0,0 +1,339 @@ +/* + * This software is provided 'as-is', without any express or implied + * warranty. In no event will Google be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, as long as the origin is not misrepresented. + */ + +package org.mozilla.gecko.util; + +import android.os.Build; +import android.os.Process; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.SecureRandomSpi; +import java.security.Security; + +/** + * Fixes for the output of the default PRNG having low entropy. + * + * The fixes need to be applied via {@link #apply()} before any use of Java + * Cryptography Architecture primitives. A good place to invoke them is in the + * application's {@code onCreate}. + */ +public final class PRNGFixes { + private static final long serialVersionUID = -687331492884005033L; + + private static final int VERSION_CODE_JELLY_BEAN = 16; + private static final int VERSION_CODE_JELLY_BEAN_MR2 = 18; + private static final byte[] BUILD_FINGERPRINT_AND_DEVICE_SERIAL = + getBuildFingerprintAndDeviceSerial(); + + /** Hidden constructor to prevent instantiation. */ + private PRNGFixes() {} + + /** + * Applies all fixes. + * + * @throws SecurityException if a fix is needed but could not be applied. + */ + public static void apply() { + applyOpenSSLFix(); + installLinuxPRNGSecureRandom(); + } + + /** + * Applies the fix for OpenSSL PRNG having low entropy. Does nothing if the + * fix is not needed. + * + * @throws SecurityException if the fix is needed but could not be applied. + */ + private static void applyOpenSSLFix() throws SecurityException { + if ((Build.VERSION.SDK_INT < VERSION_CODE_JELLY_BEAN) + || (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2)) { + // No need to apply the fix + return; + } + + try { + // Mix in the device- and invocation-specific seed. + Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_seed", byte[].class) + .invoke(null, generateSeed()); + + // Mix output of Linux PRNG into OpenSSL's PRNG + int bytesRead = (Integer) Class.forName( + "org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_load_file", String.class, long.class) + .invoke(null, "/dev/urandom", 1024); + if (bytesRead != 1024) { + throw new IOException( + "Unexpected number of bytes read from Linux PRNG: " + + bytesRead); + } + } catch (Exception e) { + throw new SecurityException("Failed to seed OpenSSL PRNG", e); + } + } + + /** + * Installs a Linux PRNG-backed {@code SecureRandom} implementation as the + * default. Does nothing if the implementation is already the default or if + * there is not need to install the implementation. + * + * @throws SecurityException if the fix is needed but could not be applied. + */ + private static void installLinuxPRNGSecureRandom() + throws SecurityException { + if (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2) { + // No need to apply the fix + return; + } + + // Install a Linux PRNG-based SecureRandom implementation as the + // default, if not yet installed. + Provider[] secureRandomProviders = + Security.getProviders("SecureRandom.SHA1PRNG"); + if ((secureRandomProviders == null) + || (secureRandomProviders.length < 1) + || (!LinuxPRNGSecureRandomProvider.class.equals( + secureRandomProviders[0].getClass()))) { + Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1); + } + + // Assert that new SecureRandom() and + // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed + // by the Linux PRNG-based SecureRandom implementation. + SecureRandom rng1 = new SecureRandom(); + if (!LinuxPRNGSecureRandomProvider.class.equals( + rng1.getProvider().getClass())) { + throw new SecurityException( + "new SecureRandom() backed by wrong Provider: " + + rng1.getProvider().getClass()); + } + + SecureRandom rng2; + try { + rng2 = SecureRandom.getInstance("SHA1PRNG"); + } catch (NoSuchAlgorithmException e) { + throw new SecurityException("SHA1PRNG not available", e); + } + if (!LinuxPRNGSecureRandomProvider.class.equals( + rng2.getProvider().getClass())) { + throw new SecurityException( + "SecureRandom.getInstance(\"SHA1PRNG\") backed by wrong" + + " Provider: " + rng2.getProvider().getClass()); + } + } + + /** + * {@code Provider} of {@code SecureRandom} engines which pass through + * all requests to the Linux PRNG. + */ + private static class LinuxPRNGSecureRandomProvider extends Provider { + private static final long serialVersionUID = -686731492884005033L; + + public LinuxPRNGSecureRandomProvider() { + super("LinuxPRNG", + 1.0, + "A Linux-specific random number provider that uses" + + " /dev/urandom"); + // Although /dev/urandom is not a SHA-1 PRNG, some apps + // explicitly request a SHA1PRNG SecureRandom and we thus need to + // prevent them from getting the default implementation whose output + // may have low entropy. + put("SecureRandom.SHA1PRNG", LinuxPRNGSecureRandom.class.getName()); + put("SecureRandom.SHA1PRNG ImplementedIn", "Software"); + } + } + + /** + * {@link SecureRandomSpi} which passes all requests to the Linux PRNG + * ({@code /dev/urandom}). + */ + public static class LinuxPRNGSecureRandom extends SecureRandomSpi { + private static final long serialVersionUID = -696231492884005033L; + + /* + * IMPLEMENTATION NOTE: Requests to generate bytes and to mix in a seed + * are passed through to the Linux PRNG (/dev/urandom). Instances of + * this class seed themselves by mixing in the current time, PID, UID, + * build fingerprint, and hardware serial number (where available) into + * Linux PRNG. + * + * Concurrency: Read requests to the underlying Linux PRNG are + * serialized (on sLock) to ensure that multiple threads do not get + * duplicated PRNG output. + */ + + private static final File URANDOM_FILE = new File("/dev/urandom"); + + private static final Object sLock = new Object(); + + /** + * Input stream for reading from Linux PRNG or {@code null} if not yet + * opened. + * + * @GuardedBy("sLock") + */ + private static DataInputStream sUrandomIn; + + /** + * Output stream for writing to Linux PRNG or {@code null} if not yet + * opened. + * + * @GuardedBy("sLock") + */ + private static OutputStream sUrandomOut; + + /** + * Whether this engine instance has been seeded. This is needed because + * each instance needs to seed itself if the client does not explicitly + * seed it. + */ + private boolean mSeeded; + + @Override + protected void engineSetSeed(byte[] bytes) { + try { + OutputStream out; + synchronized (sLock) { + out = getUrandomOutputStream(); + } + out.write(bytes); + out.flush(); + } catch (IOException e) { + // On a small fraction of devices /dev/urandom is not writable. + // Log and ignore. + Log.w(PRNGFixes.class.getSimpleName(), + "Failed to mix seed into " + URANDOM_FILE); + } finally { + mSeeded = true; + } + } + + @Override + protected void engineNextBytes(byte[] bytes) { + if (!mSeeded) { + // Mix in the device- and invocation-specific seed. + engineSetSeed(generateSeed()); + } + + try { + DataInputStream in; + synchronized (sLock) { + in = getUrandomInputStream(); + } + synchronized (in) { + in.readFully(bytes); + } + } catch (IOException e) { + throw new SecurityException( + "Failed to read from " + URANDOM_FILE, e); + } + } + + @Override + protected byte[] engineGenerateSeed(int size) { + byte[] seed = new byte[size]; + engineNextBytes(seed); + return seed; + } + + private DataInputStream getUrandomInputStream() { + synchronized (sLock) { + if (sUrandomIn == null) { + // NOTE: Consider inserting a BufferedInputStream between + // DataInputStream and FileInputStream if you need higher + // PRNG output performance and can live with future PRNG + // output being pulled into this process prematurely. + try { + sUrandomIn = new DataInputStream( + new FileInputStream(URANDOM_FILE)); + } catch (IOException e) { + throw new SecurityException("Failed to open " + + URANDOM_FILE + " for reading", e); + } + } + return sUrandomIn; + } + } + + private OutputStream getUrandomOutputStream() throws IOException { + synchronized (sLock) { + if (sUrandomOut == null) { + sUrandomOut = new FileOutputStream(URANDOM_FILE); + } + return sUrandomOut; + } + } + } + + /** + * Generates a device- and invocation-specific seed to be mixed into the + * Linux PRNG. + */ + private static byte[] generateSeed() { + try { + ByteArrayOutputStream seedBuffer = new ByteArrayOutputStream(); + DataOutputStream seedBufferOut = + new DataOutputStream(seedBuffer); + seedBufferOut.writeLong(System.currentTimeMillis()); + seedBufferOut.writeLong(System.nanoTime()); + seedBufferOut.writeInt(Process.myPid()); + seedBufferOut.writeInt(Process.myUid()); + seedBufferOut.write(BUILD_FINGERPRINT_AND_DEVICE_SERIAL); + seedBufferOut.close(); + return seedBuffer.toByteArray(); + } catch (IOException e) { + throw new SecurityException("Failed to generate seed", e); + } + } + + /** + * Gets the hardware serial number of this device. + * + * @return serial number or {@code null} if not available. + */ + private static String getDeviceSerialNumber() { + // We're using the Reflection API because Build.SERIAL is only available + // since API Level 9 (Gingerbread, Android 2.3). + try { + return (String) Build.class.getField("SERIAL").get(null); + } catch (Exception ignored) { + return null; + } + } + + private static byte[] getBuildFingerprintAndDeviceSerial() { + StringBuilder result = new StringBuilder(); + String fingerprint = Build.FINGERPRINT; + if (fingerprint != null) { + result.append(fingerprint); + } + String serial = getDeviceSerialNumber(); + if (serial != null) { + result.append(serial); + } + try { + return result.toString().getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 encoding not supported"); + } + } +} diff --git a/mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.png b/mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.png Binary files differnew file mode 100644 index 000000000..3a2cbc4bf --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.png diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.png Binary files differnew file mode 100644 index 000000000..caa6ed246 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.png diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.png Binary files differnew file mode 100644 index 000000000..abf87f16c --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.png diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.png Binary files differnew file mode 100644 index 000000000..869dbf402 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.png diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.png Binary files differnew file mode 100644 index 000000000..4b25152b2 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.png diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.png Binary files differnew file mode 100644 index 000000000..e9401797d --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.png diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_promo.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_promo.png Binary files differnew file mode 100644 index 000000000..ea2150508 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_promo.png diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.png b/mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.png Binary files differnew file mode 100644 index 000000000..f9bf849fa --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.png diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.png Binary files differnew file mode 100644 index 000000000..30d5b5c09 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.png diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.png Binary files differnew file mode 100644 index 000000000..1b5b00a75 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.png diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.png Binary files differnew file mode 100644 index 000000000..2c3f45d4a --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.png diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.png Binary files differnew file mode 100644 index 000000000..60fd77c8a --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.png diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.png Binary files differnew file mode 100644 index 000000000..63f1a55ad --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.png diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.png b/mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.png Binary files differnew file mode 100644 index 000000000..7555bc9d6 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.png diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.png Binary files differnew file mode 100644 index 000000000..16d127882 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.png diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.png Binary files differnew file mode 100644 index 000000000..9bb9a55c2 --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.png diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.png Binary files differnew file mode 100644 index 000000000..c3fe0ec1d --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.png diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.png Binary files differnew file mode 100644 index 000000000..400ddf65b --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.png diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.png Binary files differnew file mode 100644 index 000000000..a688b0d7b --- /dev/null +++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.png diff --git a/mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml b/mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml new file mode 100644 index 000000000..acaafc7c2 --- /dev/null +++ b/mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2010, 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. +*/ +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:layout_height="fill_parent" + android:layout_width="fill_parent" + android:background="@android:color/transparent"> + + <ListView android:id="@android:id/list" + android:layout_width="fill_parent" + android:layout_height="0px" + android:layout_weight="1" + android:paddingTop="0dip" + android:paddingBottom="@dimen/preference_fragment_padding_bottom" + android:paddingLeft="@dimen/preference_fragment_padding_side" + android:paddingRight="@dimen/preference_fragment_padding_side" + android:scrollbarStyle="@integer/preference_fragment_scrollbarStyle" + android:clipToPadding="false" + android:drawSelectorOnTop="false" + android:cacheColorHint="@android:color/transparent" + android:scrollbarAlwaysDrawVerticalTrack="true" /> + +</LinearLayout> diff --git a/mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml b/mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml new file mode 100644 index 000000000..4a507cddd --- /dev/null +++ b/mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:background="@color/fxaccount_error_preference_backgroundcolor" + android:gravity="center_vertical" + android:minHeight="?android:attr/listPreferredItemHeight" + android:paddingRight="?android:attr/scrollbarSize" > + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:gravity="center" + android:minWidth="0dp" + android:orientation="horizontal" > + + <ImageView + android:id="@+android:id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:minWidth="48dip" + android:padding="10dip" /> + </LinearLayout> + + <RelativeLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginBottom="6dip" + android:layout_marginLeft="15dip" + android:layout_marginRight="6dip" + android:layout_marginTop="6dip" + android:layout_weight="1" > + + <TextView + android:id="@+android:id/title" + style="@style/FxAccountTextItem" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:gravity="center_vertical" > + </TextView> + </RelativeLayout> + + <!-- We ignore summary and widget_frame, but they still need to be present. We set them to be gone. --> + + <TextView + android:id="@+android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:maxLines="4" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?android:attr/textColorSecondary" + android:visibility="gone" /> + + <!-- Preference should place its actual preference widget here. --> + + <LinearLayout + android:id="@+android:id/widget_frame" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:gravity="center" + android:orientation="vertical" + android:visibility="gone" /> + +</LinearLayout> diff --git a/mobile/android/services/src/main/res/layout/homescreen_prompt.xml b/mobile/android/services/src/main/res/layout/homescreen_prompt.xml new file mode 100644 index 000000000..26d04ad17 --- /dev/null +++ b/mobile/android/services/src/main/res/layout/homescreen_prompt.xml @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!-- 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/. --> + +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false"> + + <RelativeLayout + android:id="@+id/container" + android:layout_width="@dimen/overlay_prompt_container_width" + android:layout_height="wrap_content" + android:layout_gravity="bottom|center" + android:background="@android:color/white" + android:clickable="true" + android:orientation="vertical"> + + <ImageView + android:id="@+id/close" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_alignParentRight="true" + android:layout_marginLeft="10dp" + android:layout_marginRight="30dp" + android:layout_marginTop="30dp" + android:ellipsize="end" + android:maxLines="2" + android:padding="6dp" + android:src="@drawable/tab_close_active" /> + + <TextView + android:id="@+id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="6dp" + android:layout_marginLeft="30dp" + android:layout_marginTop="30dp" + android:layout_toLeftOf="@id/close" + android:fontFamily="sans-serif-light" + android:textColor="@color/text_and_tabs_tray_grey" + android:textSize="20sp" + tools:text="The Pokedex" /> + + <TextView + android:id="@+id/host" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@id/title" + android:layout_marginBottom="20dp" + android:layout_marginLeft="30dp" + android:layout_marginRight="30dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="@color/placeholder_grey" + android:textSize="16sp" + tools:text="pokedex.org" /> + + <ImageView + android:id="@+id/icon" + android:layout_width="50dp" + android:layout_height="50dp" + android:layout_below="@id/host" + android:layout_marginBottom="20dp" + android:layout_marginLeft="30dp" + android:src="@drawable/icon" /> + + <Button + android:id="@+id/add" + style="@style/Widget.BaseButton" + android:layout_width="wrap_content" + android:layout_height="50dp" + android:layout_alignParentRight="true" + android:layout_below="@id/host" + android:layout_marginBottom="20dp" + android:layout_marginLeft="100dp" + android:layout_marginRight="30dp" + android:background="@drawable/button_background_action_orange_round" + android:paddingLeft="16dp" + android:paddingRight="16dp" + android:text="@string/promotion_add_to_homescreen" + android:maxLines="2" + android:ellipsize="end" + android:textColor="@android:color/white" + android:textSize="16sp" /> + + </RelativeLayout> +</merge> diff --git a/mobile/android/services/src/main/res/layout/simple_helper_ui.xml b/mobile/android/services/src/main/res/layout/simple_helper_ui.xml new file mode 100644 index 000000000..f549d5c31 --- /dev/null +++ b/mobile/android/services/src/main/res/layout/simple_helper_ui.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!-- 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/. --> + +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false"> + + <LinearLayout + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@android:color/white" + android:layout_gravity="bottom|center" + android:clickable="true" + android:orientation="vertical"> + + <ImageView + android:id="@+id/image" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="40dp" + android:layout_marginBottom="40dp" + android:scaleType="fitCenter" + android:layout_gravity="center" + android:adjustViewBounds="true"/> + + <TextView + android:id="@+id/title" + android:layout_width="@dimen/firstrun_content_width" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:gravity="center" + android:textAppearance="@style/TextAppearance.FirstrunLight.Main"/> + + + <TextView + android:id="@+id/message" + android:layout_width="@dimen/firstrun_content_width" + android:layout_height="wrap_content" + android:paddingTop="20dp" + android:paddingBottom="30dp" + android:layout_gravity="center" + android:gravity="center" + android:textAppearance="@style/TextAppearance.FirstrunRegular.Body" + android:singleLine="false"/> + + <Button + android:id="@+id/button" + style="@style/Widget.Firstrun.Button" + android:background="@drawable/button_background_action_orange_round" + android:layout_gravity="center" + android:layout_marginBottom="30dp"/> + + </LinearLayout> +</merge> diff --git a/mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml b/mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml new file mode 100644 index 000000000..16f72a7ca --- /dev/null +++ b/mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android" > + <item + android:id="@+id/enable_debug_mode" + android:checkable="true" + android:checked="false" + android:title="@string/fxaccount_enable_debug_mode" /> +</menu> diff --git a/mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml b/mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml new file mode 100644 index 000000000..5c0a23db5 --- /dev/null +++ b/mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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/. +--> + +<resources xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- FxAccountStatusActivity ActionBar --> + <style name="ActionBar.FxAccountStatusActivity"> + <item name="android:displayOptions">showHome|homeAsUp|showTitle</item> + </style> + + <style name="FxAccountTheme" parent="Gecko.Preferences" /> + + <style name="FxAccountTheme.FxAccountStatusActivity" parent="Gecko.Preferences"> + <item name="android:actionBarStyle">@style/ActionBar.FxAccountStatusActivity</item> + </style> + +</resources> diff --git a/mobile/android/services/src/main/res/values/fxaccount_colors.xml b/mobile/android/services/src/main/res/values/fxaccount_colors.xml new file mode 100644 index 000000000..f7140faff --- /dev/null +++ b/mobile/android/services/src/main/res/values/fxaccount_colors.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<resources xmlns:android="http://schemas.android.com/apk/res/android"> + <color name="fxaccount_textColor">#424f59</color> + <color name="fxaccount_error_preference_backgroundcolor">#fad4d2</color> +</resources> diff --git a/mobile/android/services/src/main/res/values/fxaccount_dimens.xml b/mobile/android/services/src/main/res/values/fxaccount_dimens.xml new file mode 100644 index 000000000..d1d44585d --- /dev/null +++ b/mobile/android/services/src/main/res/values/fxaccount_dimens.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<resources> + <!-- Preference fragment padding, bottom --> + <dimen name="preference_fragment_padding_bottom">0dp</dimen> + <!-- Preference fragment padding, sides --> + <dimen name="preference_fragment_padding_side">16dp</dimen> + + <integer name="preference_fragment_scrollbarStyle">0x02000000</integer> <!-- outsideOverlay --> + + <!-- Profile avatar image height. --> + <dimen name="fxaccount_profile_image_height">48dp</dimen> + <!-- Profile avatar image width. --> + <dimen name="fxaccount_profile_image_width">48dp</dimen> +</resources> diff --git a/mobile/android/services/src/main/res/values/fxaccount_styles.xml b/mobile/android/services/src/main/res/values/fxaccount_styles.xml new file mode 100644 index 000000000..d74efac91 --- /dev/null +++ b/mobile/android/services/src/main/res/values/fxaccount_styles.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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/. +--> + +<resources xmlns:android="http://schemas.android.com/apk/res/android"> + + <style name="FxAccountTheme" parent="Gecko.Preferences" /> + + <style name="FxAccountTheme.FxAccountStatusActivity" parent="@style/FxAccountTheme"> + <item name="android:windowNoTitle">false</item> + </style> + + <style name="FxAccountTextItem" parent="@android:style/TextAppearance.Medium"> + <item name="android:textColor">@color/fxaccount_textColor</item> + <item name="android:layout_width">fill_parent</item> + <item name="android:layout_height">wrap_content</item> + <item name="android:gravity">center_horizontal</item> + <item name="android:textSize">14sp</item> + <item name="android:layout_marginBottom">10dp</item> + <item name="android:layout_marginLeft">10dp</item> + <item name="android:layout_marginRight">10dp</item> + </style> + +</resources> diff --git a/mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml b/mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml new file mode 100644 index 000000000..7b004e209 --- /dev/null +++ b/mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android" + android:accountType="@string/moz_android_shared_fxaccount_type" + android:icon="@drawable/icon" + android:smallIcon="@drawable/icon" + android:label="@string/fxaccount_label" + android:accountPreferences="@xml/fxaccount_options" /> diff --git a/mobile/android/services/src/main/res/xml/fxaccount_options.xml b/mobile/android/services/src/main/res/xml/fxaccount_options.xml new file mode 100644 index 000000000..449fc0545 --- /dev/null +++ b/mobile/android/services/src/main/res/xml/fxaccount_options.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> + <PreferenceCategory + android:title="@string/fxaccount_options_title" /> + <PreferenceScreen + android:key="options" + android:title="@string/fxaccount_options_configure_title"> + <intent + android:action="android.intent.action.MAIN" + android:targetPackage="@string/android_package_name_for_ui" + android:targetClass="org.mozilla.gecko.fxa.activities.FxAccountStatusActivity"> + </intent> + </PreferenceScreen> +</PreferenceScreen> diff --git a/mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml b/mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml new file mode 100644 index 000000000..570e362cc --- /dev/null +++ b/mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml @@ -0,0 +1,142 @@ +<?xml version="1.0" encoding="utf-8"?> +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:gecko="http://schemas.android.com/apk/res-auto" + android:key="status_screen"> + + <PreferenceCategory + android:key="signed_in_as_category" + android:title="@string/fxaccount_status_signed_in_as" > + <Preference + android:editable="false" + android:key="profile" + android:icon="@drawable/sync_avatar_default" + android:persistent="false" + android:title="" /> + <Preference + android:editable="false" + android:key="manage_account" + android:persistent="false" + android:title="@string/fxaccount_status_manage_account" /> + <Preference + android:editable="false" + android:key="auth_server" + android:persistent="false" + android:title="@string/fxaccount_status_auth_server" /> + </PreferenceCategory> + <PreferenceCategory + android:key="sync_category" + android:title="@string/fxaccount_status_sync" > + <Preference + android:editable="false" + android:icon="@drawable/fxaccount_sync_error" + android:key="needs_credentials" + android:layout="@layout/fxaccount_status_error_preference" + android:persistent="false" + android:title="@string/fxaccount_status_needs_credentials" /> + <Preference + android:editable="false" + android:icon="@drawable/fxaccount_sync_error" + android:key="needs_upgrade" + android:layout="@layout/fxaccount_status_error_preference" + android:persistent="false" + android:title="@string/fxaccount_status_needs_upgrade" /> + <Preference + android:editable="false" + android:icon="@drawable/fxaccount_sync_error" + android:key="needs_verification" + android:layout="@layout/fxaccount_status_error_preference" + android:persistent="false" + android:title="@string/fxaccount_status_needs_verification" /> + <Preference + android:editable="false" + android:icon="@drawable/fxaccount_sync_error" + android:key="needs_master_sync_automatically_enabled" + android:layout="@layout/fxaccount_status_error_preference" + android:persistent="false" + android:title="@string/fxaccount_status_needs_master_sync_automatically_enabled" /> + <Preference + android:editable="false" + android:icon="@drawable/fxaccount_sync_error" + android:key="needs_finish_migrating" + android:layout="@layout/fxaccount_status_error_preference" + android:persistent="false" + android:title="@string/fxaccount_status_needs_finish_migrating" /> + + <Preference + android:editable="false" + android:key="sync_now" + android:defaultValue="" + android:persistent="false" + android:title="@string/fxaccount_status_sync_now" + android:summary="" /> + + <CheckBoxPreference + android:key="bookmarks" + android:persistent="false" + android:title="@string/fxaccount_status_bookmarks" /> + <CheckBoxPreference + android:key="history" + android:persistent="false" + android:title="@string/fxaccount_status_history" /> + <CheckBoxPreference + android:key="tabs" + android:persistent="false" + android:title="@string/fxaccount_status_tabs" /> + <CheckBoxPreference + android:key="passwords" + android:persistent="false" + android:title="@string/fxaccount_status_passwords" /> + + <EditTextPreference + android:singleLine="true" + android:key="device_name" + android:persistent="false" + android:title="@string/fxaccount_status_device_name" /> + + <Preference + android:editable="false" + android:key="sync_server" + android:persistent="false" + android:title="@string/fxaccount_status_sync_server" /> + <org.mozilla.gecko.fxa.activities.CustomColorPreference + android:editable="false" + android:key="remove_account" + android:persistent="false" + gecko:titleColor="@color/rejection_red" + android:title="@string/fxaccount_remove_account" /> + <Preference + android:editable="false" + android:key="more" + android:persistent="false" + android:title="@string/fxaccount_status_more" /> + + </PreferenceCategory> + <PreferenceCategory + android:key="legal_category" + android:title="@string/fxaccount_status_legal" > + <Preference + android:editable="false" + android:key="linktos" + android:persistent="false" + android:title="@string/fxaccount_status_linktos" /> + <Preference + android:editable="false" + android:key="linkprivacy" + android:persistent="false" + android:title="@string/fxaccount_status_linkprivacy" /> + </PreferenceCategory> + <PreferenceCategory + android:key="debug_category" > + <Preference android:key="debug_refresh" /> + <Preference android:key="debug_dump" /> + <Preference android:key="debug_force_sync" /> + <Preference android:key="debug_invalidate_certificate" /> + <Preference android:key="debug_forget_certificate" /> + <Preference android:key="debug_require_password" /> + <Preference android:key="debug_require_upgrade" /> + <Preference android:key="debug_migrated_from_sync11" /> + <Preference android:key="debug_make_account_stage" /> + <Preference android:key="debug_make_account_default" /> + </PreferenceCategory> + +</PreferenceScreen> diff --git a/mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml b/mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml new file mode 100644 index 000000000..761920667 --- /dev/null +++ b/mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android" + android:accountType="@string/moz_android_shared_fxaccount_type" + android:contentAuthority="@string/content_authority_db_browser" + android:isAlwaysSyncable="true" + android:supportsUploading="true" + android:userVisible="true" +/> |