Bug 836790 - Don't HTTP GET remote changes if info/collections says there are none. r=rnewman
authorNick Alexander <nalexander@mozilla.com>
Fri, 28 Mar 2014 14:32:48 -0700
changeset 176077 ee38720b1ae7058703467e6604db528fc1bbdec2
parent 176076 822b3855dc4b98b00f62e65765be088d479f3949
child 176078 1bf54f05992734e1db7f7a872e5d3700466bf651
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersrnewman
bugs836790
milestone31.0a1
Bug 836790 - Don't HTTP GET remote changes if info/collections says there are none. r=rnewman
mobile/android/base/background/healthreport/upload/AndroidSubmissionClient.java
mobile/android/base/sync/InfoCollections.java
mobile/android/base/sync/middleware/MiddlewareRepositorySession.java
mobile/android/base/sync/repositories/ConstrainedServer11Repository.java
mobile/android/base/sync/repositories/RepositorySession.java
mobile/android/base/sync/repositories/Server11Repository.java
mobile/android/base/sync/repositories/Server11RepositorySession.java
mobile/android/base/sync/setup/activities/WebViewActivity.java
mobile/android/base/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
mobile/android/base/sync/stage/AndroidBrowserHistoryServerSyncStage.java
mobile/android/base/sync/stage/EnsureClusterURLStage.java
mobile/android/base/sync/stage/FormHistoryServerSyncStage.java
mobile/android/base/sync/stage/SafeConstrainedServer11Repository.java
mobile/android/base/sync/stage/ServerSyncStage.java
mobile/android/base/sync/synchronizer/RecordsChannel.java
mobile/android/base/sync/synchronizer/SynchronizerSession.java
--- a/mobile/android/base/background/healthreport/upload/AndroidSubmissionClient.java
+++ b/mobile/android/base/background/healthreport/upload/AndroidSubmissionClient.java
@@ -396,17 +396,17 @@ public class AndroidSubmissionClient imp
         super(storage);
       }
 
       @Override
       public JSONObject generateDocument(long since, long lastPingTime,
           String generationProfilePath) throws JSONException {
         final JSONObject document;
         // If the given profilePath matches the one we cached for the tracker, use the cached env.
-        if (generationProfilePath == profilePath) {
+        if (profilePath != null && profilePath.equals(generationProfilePath)) {
           final Environment environment = getCurrentEnvironment();
           document = super.generateDocument(since, lastPingTime, environment);
         } else {
           document = super.generateDocument(since, lastPingTime, generationProfilePath);
         }
 
         if (document == null) {
           incrementUploadClientFailureCount();
--- a/mobile/android/base/sync/InfoCollections.java
+++ b/mobile/android/base/sync/InfoCollections.java
@@ -3,17 +3,16 @@
  * 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;
 
 /**
  * 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.
  */
@@ -23,28 +22,27 @@ public class 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;
 
-  @SuppressWarnings("unchecked")
+  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>();
 
-    Set<Entry<String, Object>> entrySet = record.object.entrySet();
-
-    String key;
-    Object value;
-    for (Entry<String, Object> entry : entrySet) {
-      key = entry.getKey();
-      value = entry.getValue();
+    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) {
--- a/mobile/android/base/sync/middleware/MiddlewareRepositorySession.java
+++ b/mobile/android/base/sync/middleware/MiddlewareRepositorySession.java
@@ -156,9 +156,29 @@ public abstract class MiddlewareReposito
   public void storeDone() {
     inner.storeDone();
   }
 
   @Override
   public void storeDone(long storeEnd) {
     inner.storeDone(storeEnd);
   }
-}
\ No newline at end of file
+
+  @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();
+  }
+}
--- a/mobile/android/base/sync/repositories/ConstrainedServer11Repository.java
+++ b/mobile/android/base/sync/repositories/ConstrainedServer11Repository.java
@@ -1,31 +1,32 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy 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.net.AuthHeaderProvider;
 
 /**
  * A kind of Server11Repository that supports explicit setting of limit and sort on operations.
  *
  * @author rnewman
  *
  */
 public class ConstrainedServer11Repository extends Server11Repository {
 
   private String sort = null;
   private long limit  = -1;
 
-  public ConstrainedServer11Repository(String collection, String storageURL, AuthHeaderProvider authHeaderProvider, long limit, String sort) throws URISyntaxException {
-    super(collection, storageURL, authHeaderProvider);
+  public ConstrainedServer11Repository(String collection, String storageURL, AuthHeaderProvider authHeaderProvider, InfoCollections infoCollections, long limit, String sort) throws URISyntaxException {
+    super(collection, storageURL, authHeaderProvider, infoCollections);
     this.limit = limit;
     this.sort  = sort;
   }
 
   @Override
   protected String getDefaultSort() {
     return sort;
   }
--- a/mobile/android/base/sync/repositories/RepositorySession.java
+++ b/mobile/android/base/sync/repositories/RepositorySession.java
@@ -65,17 +65,21 @@ public abstract class RepositorySession 
   /**
    * 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.
-  public long lastSyncTimestamp;
+  private long lastSyncTimestamp = 0;
+
+  public long getLastSyncTimestamp() {
+    return lastSyncTimestamp;
+  }
 
   public static long now() {
     return System.currentTimeMillis();
   }
 
   public RepositorySession(Repository repository) {
     this.repository = repository;
   }
@@ -137,20 +141,16 @@ public abstract class RepositorySession 
         delegate.onStoreCompleted(end);
       }
     };
     storeWorkQueue.execute(command);
   }
 
   public abstract void wipe(RepositorySessionWipeDelegate delegate);
 
-  public void unbundle(RepositorySessionBundle bundle) {
-    this.lastSyncTimestamp = bundle == null ? 0 : bundle.getTimestamp();
-  }
-
   /**
    * 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()) {
@@ -169,44 +169,45 @@ public abstract class RepositorySession 
    * @param delegate
    * @throws InvalidSessionTransitionException
    */
   public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
     sharedBegin();
     delegate.deferredBeginDelegate(delegateQueue).onBeginSucceeded(this);
   }
 
-  protected RepositorySessionBundle getBundle() {
-    return this.getBundle(null);
+  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(RepositorySessionBundle optional) {
+  protected RepositorySessionBundle getBundle() {
     // Why don't we just persist the old bundle?
-    RepositorySessionBundle bundle = (optional == null) ? new RepositorySessionBundle(this.lastSyncTimestamp) : optional;
-    Logger.debug(LOG_TAG, "Setting bundle timestamp to " + this.lastSyncTimestamp + ".");
+    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(null));
+    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.
@@ -228,17 +229,17 @@ public abstract class RepositorySession 
    * 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(null));
+      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();
--- a/mobile/android/base/sync/repositories/Server11Repository.java
+++ b/mobile/android/base/sync/repositories/Server11Repository.java
@@ -3,45 +3,58 @@
  * 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.Utils;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
 
 import android.content.Context;
 
 /**
  * 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;
 
   /**
    * 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.
+   * @param authHeaderProvider to use in requests; may be null.
+   * @param infoCollections instance; must not be null.
    * @throws URISyntaxException
    */
-  public Server11Repository(String collection, String storageURL, AuthHeaderProvider authHeaderProvider) throws URISyntaxException {
+  public Server11Repository(String collection, String storageURL, AuthHeaderProvider authHeaderProvider, InfoCollections infoCollections) 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;
   }
 
   @Override
   public void createSession(RepositorySessionCreationDelegate delegate,
                             Context context) {
     delegate.onSessionCreated(new Server11RepositorySession(this));
   }
 
@@ -97,9 +110,13 @@ public class Server11Repository extends 
   @SuppressWarnings("static-method")
   protected String getDefaultSort() {
     return null;
   }
 
   public AuthHeaderProvider getAuthHeaderProvider() {
     return authHeaderProvider;
   }
+
+  public boolean updateNeeded(long lastSyncTimestamp) {
+    return infoCollections.updateNeeded(collection, lastSyncTimestamp);
+  }
 }
--- a/mobile/android/base/sync/repositories/Server11RepositorySession.java
+++ b/mobile/android/base/sync/repositories/Server11RepositorySession.java
@@ -406,16 +406,17 @@ public class Server11RepositorySession e
    * <code>true</code> if a record upload has failed this session.
    * <p>
    * This is only set in begin and possibly by <code>RecordUploadRunnable</code>.
    * Since those are executed serially, we can use an unsynchronized
    * volatile boolean here.
    */
   protected volatile boolean recordUploadFailed;
 
+  @Override
   public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
     recordUploadFailed = false;
     super.begin(delegate);
   }
 
   /**
    * Make an HTTP request, and convert HTTP request delegate callbacks into
    * store callbacks within the context of this RepositorySession.
@@ -603,9 +604,14 @@ public class Server11RepositorySession e
 
       // 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.
       ByteArraysEntity body = getBodyEntity();
       request.post(body);
     }
   }
+
+  @Override
+  public boolean dataAvailable() {
+    return serverRepository.updateNeeded(getLastSyncTimestamp());
+  }
 }
--- a/mobile/android/base/sync/setup/activities/WebViewActivity.java
+++ b/mobile/android/base/sync/setup/activities/WebViewActivity.java
@@ -35,16 +35,17 @@ public class WebViewActivity extends Syn
       finish();
       return;
     }
 
     WebView wv = (WebView) findViewById(R.id.web_engine);
     // Add a progress bar.
     final Activity activity = this;
     wv.setWebChromeClient(new WebChromeClient() {
+      @Override
       public void onProgressChanged(WebView view, int progress) {
         // Activities and WebViews measure progress with different scales.
         // The progress meter will automatically disappear when we reach 100%
         activity.setProgress(progress * 100);
       }
     });
     wv.setWebViewClient(new WebViewClient() {
       // Handle url loading in this WebView, instead of asking the ActivityManager.
--- a/mobile/android/base/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
+++ b/mobile/android/base/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
@@ -41,22 +41,23 @@ public class AndroidBrowserBookmarksServ
   @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(),
-                                                 BOOKMARKS_REQUEST_LIMIT,
-                                                 BOOKMARKS_SORT,
-                                                 countsFetcher);
+        collection,
+        session.config.storageURL(),
+        session.getAuthHeaderProvider(),
+        session.config.infoCollections,
+        BOOKMARKS_REQUEST_LIMIT,
+        BOOKMARKS_SORT,
+        countsFetcher);
   }
 
   @Override
   protected Repository getLocalRepository() {
     return new AndroidBrowserBookmarksRepository();
   }
 
   @Override
--- a/mobile/android/base/sync/stage/AndroidBrowserHistoryServerSyncStage.java
+++ b/mobile/android/base/sync/stage/AndroidBrowserHistoryServerSyncStage.java
@@ -44,16 +44,17 @@ public class AndroidBrowserHistoryServer
 
   @Override
   protected Repository getRemoteRepository() throws URISyntaxException {
     String collection = getCollection();
     return new ConstrainedServer11Repository(
                                              collection,
                                              session.config.storageURL(),
                                              session.getAuthHeaderProvider(),
+                                             session.config.infoCollections,
                                              HISTORY_REQUEST_LIMIT,
                                              HISTORY_SORT);
   }
 
   @Override
   protected RecordFactory getRecordFactory() {
     return new HistoryRecordFactory();
   }
--- a/mobile/android/base/sync/stage/EnsureClusterURLStage.java
+++ b/mobile/android/base/sync/stage/EnsureClusterURLStage.java
@@ -207,22 +207,17 @@ public class EnsureClusterURLStage exten
           callback.informNodeAuthenticationFailed(session, url);
           session.abort(new NodeAuthenticationException(), "User password has changed.");
           return;
         }
 
         callback.informNodeAssigned(session, oldClusterURL, url); // No matter what, we're getting a new node/weave clusterURL.
         session.config.setClusterURL(url);
 
-        ThreadPool.run(new Runnable() {
-          @Override
-          public void run() {
-            session.advance();
-          }
-        });
+        session.advance();
       }
 
       @Override
       public void handleThrottled() {
         session.abort(new NullClusterURLException(), "Got 'null' cluster URL. Aborting.");
       }
 
       @Override
--- a/mobile/android/base/sync/stage/FormHistoryServerSyncStage.java
+++ b/mobile/android/base/sync/stage/FormHistoryServerSyncStage.java
@@ -36,21 +36,22 @@ public class FormHistoryServerSyncStage 
   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(),
-                                             FORM_HISTORY_REQUEST_LIMIT,
-                                             FORM_HISTORY_SORT);
+        collection,
+        session.config.storageURL(),
+        session.getAuthHeaderProvider(),
+        session.config.infoCollections,
+        FORM_HISTORY_REQUEST_LIMIT,
+        FORM_HISTORY_SORT);
   }
 
   @Override
   protected Repository getLocalRepository() {
     return new FormHistoryRepositorySession.FormHistoryRepository();
   }
 
   public class FormHistoryRecordFactory extends RecordFactory {
--- a/mobile/android/base/sync/stage/SafeConstrainedServer11Repository.java
+++ b/mobile/android/base/sync/stage/SafeConstrainedServer11Repository.java
@@ -2,16 +2,17 @@
  * License, v. 2.0. If a copy 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.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;
 
@@ -30,58 +31,71 @@ import android.content.Context;
 public class SafeConstrainedServer11Repository extends ConstrainedServer11Repository {
 
   // This can be lazily evaluated if we need it.
   private JSONRecordFetcher countFetcher;
 
   public SafeConstrainedServer11Repository(String collection,
                                            String storageURL,
                                            AuthHeaderProvider authHeaderProvider,
+                                           InfoCollections infoCollections,
                                            long limit,
                                            String sort,
                                            JSONRecordFetcher countFetcher)
     throws URISyntaxException {
-    super(collection, storageURL, authHeaderProvider, limit, sort);
+    super(collection, storageURL, authHeaderProvider, infoCollections, limit, 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.getDefaultFetchLimit()));
   }
 
   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 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.
-      if (this.lastSyncTimestamp <= 0) {
+      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.intValue() + " items.");
         if (c.intValue() > fetchLimit) {
           Logger.warn(LOG_TAG, "Too many items to sync safely. Skipping.");
           return true;
         }
--- a/mobile/android/base/sync/stage/ServerSyncStage.java
+++ b/mobile/android/base/sync/stage/ServerSyncStage.java
@@ -143,17 +143,18 @@ public abstract class ServerSyncStage ex
   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.getAuthHeaderProvider(),
+                                  session.config.infoCollections);
   }
 
   /**
    * Return a Crypto5Middleware-wrapped Server11Repository.
    *
    * @throws NoCollectionKeysSetException
    * @throws URISyntaxException
    */
--- a/mobile/android/base/sync/synchronizer/RecordsChannel.java
+++ b/mobile/android/base/sync/synchronizer/RecordsChannel.java
@@ -65,29 +65,27 @@ public class RecordsChannel implements
   RepositorySessionStoreDelegate,
   RecordsConsumerDelegate,
   RepositorySessionBeginDelegate {
 
   private static final String LOG_TAG = "RecordsChannel";
   public RepositorySession source;
   public RepositorySession sink;
   private RecordsChannelDelegate delegate;
-  private long timestamp;
   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;
-    this.timestamp = source.lastSyncTimestamp;
   }
 
   /*
    * 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,
@@ -150,31 +148,39 @@ public class RecordsChannel implements
     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(timestamp, this);
+    source.fetchSince(source.getLastSyncTimestamp(), this);
   }
 
   /**
    * Begin both sessions, invoking flow() when done.
-   * @throws InvalidSessionTransitionException 
+   * @throws InvalidSessionTransitionException
    */
   public void beginAndFlow() throws InvalidSessionTransitionException {
     Logger.trace(LOG_TAG, "Beginning source.");
     source.begin(this);
   }
 
   @Override
   public void store(Record record) {
--- a/mobile/android/base/sync/synchronizer/SynchronizerSession.java
+++ b/mobile/android/base/sync/synchronizer/SynchronizerSession.java
@@ -143,25 +143,16 @@ implements RecordsChannelDelegate,
         sessionB.shouldSkip()) {
       Logger.info(LOG_TAG, "Session requested skip. Short-circuiting sync.");
       sessionA.abort();
       sessionB.abort();
       this.delegate.onSynchronizeSkipped(this);
       return;
     }
 
-    if (!sessionA.dataAvailable() &&
-        !sessionB.dataAvailable()) {
-      Logger.info(LOG_TAG, "Neither session reports data available. 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);