Bug 721271 - Post-0.3 Android Sync Aurora uplift. a=akeybl
authorRichard Newman <rnewman@mozilla.com>
Wed, 25 Jan 2012 20:29:23 -0800
changeset 84221 d786e7c31177ae6f2d27267d759cea71ed28ad74
parent 84220 b314edef7cbfa1b4f4b8dbf5193567e83dc47c29
child 84222 b310cea4258ce9680a9948de8103843a63083859
push id775
push userrnewman@mozilla.com
push dateThu, 26 Jan 2012 04:30:14 +0000
treeherdermozilla-aurora@b310cea4258c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersakeybl
bugs721271
milestone11.0a2
Bug 721271 - Post-0.3 Android Sync Aurora uplift. a=akeybl
mobile/android/base/locales/en-US/sync_strings.dtd
mobile/android/base/resources/drawable/pin_background.xml
mobile/android/base/resources/layout/sync_account.xml
mobile/android/base/resources/layout/sync_setup.xml
mobile/android/base/resources/layout/sync_setup_failure.xml
mobile/android/base/resources/layout/sync_setup_jpake_waiting.xml
mobile/android/base/resources/layout/sync_setup_nointernet.xml
mobile/android/base/resources/layout/sync_setup_pair.xml
mobile/android/base/resources/layout/sync_setup_success.xml
mobile/android/base/resources/values/sync_styles.xml
mobile/android/base/resources/xml/sync_authenticator.xml
mobile/android/base/resources/xml/sync_options.xml
mobile/android/base/resources/xml/sync_syncadapter.xml.in
mobile/android/base/sync/CryptoRecord.java
mobile/android/base/sync/ExtendedJSONObject.java
mobile/android/base/sync/GlobalSession.java
mobile/android/base/sync/HTTPFailureException.java
mobile/android/base/sync/InfoCollections.java
mobile/android/base/sync/MetaGlobal.java
mobile/android/base/sync/NonArrayJSONException.java
mobile/android/base/sync/NonObjectJSONException.java
mobile/android/base/sync/PrefsSource.java
mobile/android/base/sync/SyncConfiguration.java
mobile/android/base/sync/SynchronizerConfiguration.java
mobile/android/base/sync/UnexpectedJSONException.java
mobile/android/base/sync/Utils.java
mobile/android/base/sync/crypto/Cryptographer.java
mobile/android/base/sync/crypto/HKDF.java
mobile/android/base/sync/crypto/KeyBundle.java
mobile/android/base/sync/crypto/Utils.java
mobile/android/base/sync/cryptographer/SyncCryptographer.java
mobile/android/base/sync/delegates/GlobalSessionCallback.java
mobile/android/base/sync/delegates/MetaGlobalDelegate.java
mobile/android/base/sync/jpake/JPakeClient.java
mobile/android/base/sync/jpake/JPakeCrypto.java
mobile/android/base/sync/jpake/JPakeRequest.java
mobile/android/base/sync/middleware/Crypto5MiddlewareRepositorySession.java
mobile/android/base/sync/net/BaseResource.java
mobile/android/base/sync/net/SyncResponse.java
mobile/android/base/sync/net/SyncStorageRequest.java
mobile/android/base/sync/net/SyncStorageResponse.java
mobile/android/base/sync/net/TLSSocketFactory.java
mobile/android/base/sync/repositories/NoStoreDelegateException.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/repositories/android/AndroidBrowserBookmarksDataAccessor.java
mobile/android/base/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java
mobile/android/base/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
mobile/android/base/sync/repositories/android/AndroidBrowserHistoryDataExtender.java
mobile/android/base/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
mobile/android/base/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java
mobile/android/base/sync/repositories/android/AndroidBrowserRepositorySession.java
mobile/android/base/sync/repositories/android/RepoUtils.java
mobile/android/base/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java
mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java
mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java
mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java
mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java
mobile/android/base/sync/repositories/delegates/RepositorySessionBeginDelegate.java
mobile/android/base/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java
mobile/android/base/sync/repositories/delegates/RepositorySessionFinishDelegate.java
mobile/android/base/sync/repositories/delegates/RepositorySessionStoreDelegate.java
mobile/android/base/sync/repositories/delegates/RepositorySessionWipeDelegate.java
mobile/android/base/sync/repositories/domain/BookmarkRecord.java
mobile/android/base/sync/repositories/domain/HistoryRecord.java
mobile/android/base/sync/repositories/domain/PasswordRecord.java
mobile/android/base/sync/repositories/domain/Record.java
mobile/android/base/sync/setup/Constants.java
mobile/android/base/sync/setup/SyncAuthenticatorService.java
mobile/android/base/sync/setup/activities/AccountActivity.java
mobile/android/base/sync/setup/activities/SetupFailureActivity.java
mobile/android/base/sync/setup/activities/SetupSuccessActivity.java
mobile/android/base/sync/setup/activities/SetupSyncActivity.java
mobile/android/base/sync/setup/activities/SetupWaitingActivity.java
mobile/android/base/sync/stage/EnsureClusterURLStage.java
mobile/android/base/sync/stage/FetchMetaGlobalStage.java
mobile/android/base/sync/stage/ServerSyncStage.java
mobile/android/base/sync/syncadapter/SyncAdapter.java
mobile/android/base/sync/synchronizer/ConcurrentRecordConsumer.java
mobile/android/base/sync/synchronizer/RecordConsumer.java
mobile/android/base/sync/synchronizer/RecordsChannel.java
mobile/android/base/sync/synchronizer/RecordsConsumerDelegate.java
mobile/android/base/sync/synchronizer/SerialRecordConsumer.java
mobile/android/base/sync/synchronizer/Synchronizer.java
mobile/android/base/sync/synchronizer/SynchronizerSession.java
mobile/android/base/sync/synchronizer/SynchronizerSessionDelegate.java
mobile/android/sync/android-drawable-resources.mn
mobile/android/sync/android-layout-resources.mn
mobile/android/sync/android-xml-resources.mn
mobile/android/sync/java-sources.mn
mobile/android/sync/java-third-party-sources.mn
mobile/android/sync/manifests/SyncAndroidManifest_activities.xml.in
mobile/android/sync/manifests/SyncAndroidManifest_permissions.xml.in
mobile/android/sync/strings.xml.in
--- a/mobile/android/base/locales/en-US/sync_strings.dtd
+++ b/mobile/android/base/locales/en-US/sync_strings.dtd
@@ -1,24 +1,29 @@
 <!-- Don't localize these. They're here until they have
      a better place to live. -->
 <!ENTITY syncBrand.fullName.label "Firefox Sync">
 <!ENTITY syncBrand.shortName.label "Sync">
 
 <!-- Main titles. -->
 <!ENTITY sync.app.name.label '&syncBrand.fullName.label;'>
 <!ENTITY sync.title.connect.label 'Connect to &syncBrand.shortName.label;'>
+<!ENTITY sync.title.adddevice.label 'Add a &syncBrand.fullName.label; Account'>
 <!ENTITY sync.title.pair.label 'Pair a Device'>
 
 <!-- J-PAKE Key Screen -->
 <!ENTITY sync.subtitle.connect.label 'To activate your new device, select “Set up &syncBrand.shortName.label;” on the device.'>
+<!ENTITY sync.subtitle.header.label 'Enter this code on your computer'>
+<!ENTITY sync.subtitle.connectlocation.label 'Select “&sync.title.pair.label;” in the &syncBrand.shortName.label; section of your desktop Firefox options.'>
 <!ENTITY sync.subtitle.pair.label 'To activate, select “Pair a device” on your other device.'>
 <!ENTITY sync.pin.default.label '...\n...\n...\n'>
-<!ENTITY sync.link.show.label '<a href="https://support.mozilla.com/kb/add-a-device-to-firefox-sync">Show me how</a>'>
-<!ENTITY sync.link.nodevice.label '<a href="#">I don\&apos;t have the device with me…</a>'>
+<!ENTITY sync.pin.oneline.label '...'>
+<!ENTITY sync.link.show.label 'Show me how.'>
+<!ENTITY sync.link.advancedsetup.label 'Advanced setup...'>
+<!ENTITY sync.link.nodevice.label 'I don\&apos;t have the device with me…'>
 
 <!-- J-PAKE Waiting Screen -->
 <!ENTITY sync.jpake.subtitle.waiting.label 'Waiting for other device…'>
 
 <!-- Account Login Screen -->
 <!ENTITY sync.subtitle.account.label 'Enter your &syncBrand.fullName.label; account information'>
 <!ENTITY sync.input.username.label 'Account Name'>
 <!ENTITY sync.input.password.label 'Password'>
@@ -26,30 +31,42 @@
 <!ENTITY sync.checkbox.server.label 'Use custom server'>
 <!ENTITY sync.input.server.label 'Server URL'>
   
 <!-- Setup Fail -->
 <!ENTITY sync.title.fail.label 'Cannot Set Up &syncBrand.shortName.label;'>
 <!ENTITY sync.subtitle.fail.label '&syncBrand.fullName.label; could not connect to the server. Would you like to try again?'>
 <!ENTITY sync.button.tryagain.label 'Try again'>
 <!ENTITY sync.button.manual.label 'Manual Setup'>
+<!ENTITY sync.subtitle.nointernet.label 'No internet connection available.'>
   
 <!-- Setup Success -->
 <!ENTITY sync.title.success.label 'Setup Complete'>
 <!ENTITY sync.subtitle.success.label 'Your data is now being downloaded in the background. You can go to Settings to manage your account.'>
 <!ENTITY sync.settings.label 'Settings'>
 <!ENTITY sync.subtitle.manage.label 'Your &syncBrand.fullName.label; account is already set up. Go to Settings to manage your account.'>
-  
+
+<!-- Pair Device -->
+<!ENTITY sync.pair.tryagain.label 'Please try again.'>
+
+<!-- Firefox SyncAdapter Settings Screen -->
+<!ENTITY sync.settings.options.label 'Options'>
+<!ENTITY sync.summary.pair.label 'Link another device to your &syncBrand.shortName.label; account'>
+
 <!-- Common text -->
 <!ENTITY sync.button.cancel.label 'Cancel'>
 <!ENTITY sync.button.connect.label 'Connect'>
+<!ENTITY sync.button.ok.label 'OK'>
   
 <!-- Account strings -->
 <!ENTITY sync.account.label.label '&syncBrand.fullName.label;'>
   
 <!-- Bookmark folder strings -->
 <!ENTITY bookmarks.folder.menu.label 'Bookmarks Menu'>
 <!ENTITY bookmarks.folder.places.label ''>
 <!ENTITY bookmarks.folder.tags.label 'Tags'>
 <!ENTITY bookmarks.folder.toolbar.label 'Bookmarks Toolbar'>
 <!ENTITY bookmarks.folder.unfiled.label 'Unsorted Bookmarks'>
 <!ENTITY bookmarks.folder.desktop.label 'Desktop Bookmarks'>
 <!ENTITY bookmarks.folder.mobile.label 'Mobile Bookmarks'>
+
+<!-- Notification strings -->
+<!ENTITY sync.notification.oneaccount.label 'Only one &syncBrand.fullName.label; account is supported.'>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/drawable/pin_background.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+  <solid android:color="#000000" />
+  <stroke android:width="1dp" android:color="#FFFFFF" />
+  <padding
+    android:left="1dp"
+    android:top="1dp"
+    android:right="1dp"
+    android:bottom="1dp" />
+</shape>
\ No newline at end of file
--- a/mobile/android/base/resources/layout/sync_account.xml
+++ b/mobile/android/base/resources/layout/sync_account.xml
@@ -1,54 +1,76 @@
 <?xml version="1.0" encoding="utf-8"?>
-<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
-  style="@style/SyncTextFrame" >
-
-  <TextView
-    style="@style/SyncTextTitle"
-    android:text="@string/sync_title_connect" />
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
+  <LinearLayout
+    android:id="@+id/account_top"
+    style="@style/SyncTop">
 
-  <View
-    android:layout_height="2dp"
-    android:background="#FFFFFF" />
+    <ImageView
+      style="@style/SyncTopIcon" />
+    <TextView
+      style="@style/SyncTextTitle"
+      android:text="@string/sync_title_connect" />
+  </LinearLayout>
 
-  <TextView
-    style="@style/SyncTextItem"
-    android:text="@string/sync_subtitle_account" />
+  <ScrollView
+    android:id="@+id/account_content"
+    style="@style/SyncLayout"
+    android:layout_below="@id/account_top"
+    android:layout_above="@+id/account_bottom">
+
+	<LinearLayout
+      style="@style/SyncLayout.Vertical"
+      android:padding="20dp" >
 
-  <EditText android:id="@+id/username"
-    style="@style/SyncEditItem"
-    android:hint="@string/sync_input_username" />
-
-  <EditText android:id="@+id/password"
-    style="@style/SyncEditItem"
-    android:password="true"
-    android:hint="@string/sync_input_password" />
+	  <TextView
+	    style="@style/SyncTextItem"
+	    android:text="@string/sync_subtitle_account"
+	    android:layout_marginBottom="10dp" />
+	
+	  <EditText android:id="@+id/usernameInput"
+	    style="@style/SyncEditItem"
+	    android:hint="@string/sync_input_username" />
+	
+	  <EditText android:id="@+id/passwordInput"
+	    style="@style/SyncEditItem"
+	    android:password="true"
+	    android:hint="@string/sync_input_password" />
+	
+	  <EditText android:id="@+id/keyInput"
+	    style="@style/SyncEditItem"
+	    android:hint="@string/sync_input_key" />
+	
+	  <CheckBox android:id="@+id/checkbox_server"
+	    android:layout_width="wrap_content"
+	    android:layout_height="wrap_content"
+	    android:layout_gravity="left"
+	    android:imeOptions="actionDone"
+	    android:text="@string/sync_checkbox_server" />
+	
+	  <EditText android:id="@+id/serverInput"
+	    style="@style/SyncEditItem"
+	    android:visibility="gone"
+	    android:hint="@string/sync_input_server" />
 
-  <EditText android:id="@+id/key"
-    style="@style/SyncEditItem"
-    android:hint="@string/sync_input_key" />
-
-  <CheckBox android:id="@+id/checkbox_server"
-    android:text="@string/sync_checkbox_server" />
-
-  <EditText android:id="@+id/server"
-    style="@style/SyncEditItem"
-    android:hint="@string/sync_input_server" />
+	</LinearLayout>
+  </ScrollView>
 
   <LinearLayout
-    android:layout_width="fill_parent"
-    android:layout_height="fill_parent"
+    android:id="@id/account_bottom"
+    style="@style/SyncBottom"
     android:orientation="horizontal" >
 
     <Button
-      style="@style/SyncButtonCommon"
+      style="@style/SyncButton"
       android:onClick="cancelClickHandler"
       android:text="@string/sync_button_cancel" />
 
     <Button
-      style="@style/SyncButtonCommon"
+      style="@style/SyncButton"
+      android:id="@+id/accountConnectButton"
       android:onClick="connectClickHandler"
+      android:clickable="false"
+      android:enabled="false"
       android:text="@string/sync_button_connect" />
-
   </LinearLayout>
-
-</TableLayout>
+</RelativeLayout>
\ No newline at end of file
--- a/mobile/android/base/resources/layout/sync_setup.xml
+++ b/mobile/android/base/resources/layout/sync_setup.xml
@@ -1,58 +1,87 @@
 <?xml version="1.0" encoding="utf-8"?>
-<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
-  style="@style/SyncTextFrame" >
-
-  <TextView android:id="@+id/setup_title"
-    style="@style/SyncTextTitle"
-    android:text="@string/sync_title_connect" />
-
-  <View
-    android:layout_width="wrap_content"
-    android:layout_height="2dp"
-    android:background="#FFFFFF" />
-
-  <TextView
-      android:id="@+id/setup_subtitle"
-      style="@style/SyncTextItem"
-      android:layout_marginTop="10dp"
-      android:layout_marginBottom="10dp"
-      android:text="@string/sync_subtitle_pair" />
-
-  <TextView
-    style="@style/SyncTextItem"
-    android:autoLink="web"
-    android:clickable="true"
-    android:text="@string/sync_link_show" />
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
 
   <LinearLayout
-    style="@style/SyncTextItem"
-    android:orientation="vertical" >
-
-    <TextView android:id="@+id/text_pin"
-      style="@style/SyncTextItem"
-      android:text="@string/sync_pin_default"
-      android:textSize="40dp" />
-
+    android:id="@+id/setup_top"
+    style="@style/SyncTop" >
+    <ImageView
+      style="@style/SyncTopIcon" />
+    <TextView
+      android:id="@+id/setup_title"
+      style="@style/SyncTextTitle"
+      android:text="@string/sync_title_connect" />
   </LinearLayout>
 
-  <TextView android:id="@+id/link_nodevice"
-    style="@style/SyncTextItem"
-    android:clickable="true"
-    android:onClick="manualClickHandler"
-    android:text="@string/sync_link_nodevice" />
+  <ScrollView
+    style="@style/SyncLayout"
+    android:fillViewport="true"
+    android:layout_below="@id/setup_top"
+    android:layout_above="@+id/setup_bottom" >
+
+    <RelativeLayout
+        style="@style/SyncLayout.Vertical"
+        android:layout_height="fill_parent"
+        android:padding="15dp" >
+
+      <TextView
+        android:id="@+id/setup_header"
+        style="@style/SyncTextItem"
+        android:gravity="left"
+        android:layout_marginTop="15dp"
+        android:text="@string/sync_subtitle_header"
+        android:textStyle="bold" />
+      <TextView
+        android:id="@+id/setup_subtitle"
+        style="@style/SyncTextItem"
+        android:layout_below="@id/setup_header"
+        android:gravity="left"
+        android:layout_marginTop="2dp"
+        android:text="@string/sync_subtitle_connect" />
 
+      <TextView
+        android:id="@+id/setup_showme"
+        style="@style/SyncLinkItem"
+        android:layout_below="@id/setup_subtitle"
+        android:layout_marginTop="2dp"
+        android:onClick="showClickHandler"
+        android:text="@string/sync_link_show" />
+
+      <LinearLayout
+        style="@style/SyncLayout"
+        android:layout_below="@id/setup_showme"
+        android:paddingTop="30dp"
+        android:paddingLeft="-15dp"
+        android:orientation="vertical" >
+        <TextView
+          android:id="@+id/text_pin1"
+          style="@style/SyncPinText" />
+        <TextView
+          android:id="@+id/text_pin2"
+          style="@style/SyncPinText" />
+        <TextView
+          android:id="@+id/text_pin3"
+          style="@style/SyncPinText" />
+      </LinearLayout>
+
+      <TextView
+        android:id="@+id/link_nodevice"
+        style="@style/SyncLinkItem"
+        android:layout_alignParentBottom="true"
+        android:gravity="center"
+        android:paddingBottom="5dp"
+        android:onClick="manualClickHandler"
+        android:text="@string/sync_link_advancedsetup" />
+    </RelativeLayout>
+  </ScrollView>
   <LinearLayout
-    android:layout_width="wrap_content"
-    android:layout_height="wrap_content"
-    android:gravity="center"
+    android:id="@id/setup_bottom"
+    style="@style/SyncBottom"
     android:orientation="horizontal" >
-
-      <Button
-          style="@style/SyncButtonCommon"
-          android:layout_marginTop="10dp"
-          android:onClick="cancelClickHandler"
-          android:text="@string/sync_button_cancel" />
-
+    <Button
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:onClick="cancelClickHandler"
+      android:text="@string/sync_button_cancel" />
   </LinearLayout>
-
-</TableLayout>
+</RelativeLayout>
\ No newline at end of file
--- a/mobile/android/base/resources/layout/sync_setup_failure.xml
+++ b/mobile/android/base/resources/layout/sync_setup_failure.xml
@@ -1,42 +1,45 @@
 <?xml version="1.0" encoding="utf-8"?>
-<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    style="@style/SyncTextFrame" >
-    <TextView
-      style="@style/SyncTextTitle"
-      android:text="@string/sync_title_fail" />
-    <View 
-      android:layout_width="fill_parent"
-      android:layout_height="2dp"
-      android:background="#FFFFFF"/>
-    <TextView
-      style="@style/SyncTextItem"
-      android:text="@string/sync_subtitle_fail" />
-
-    <LinearLayout
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:orientation="vertical" >
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
+  <LinearLayout
+    android:id="@+id/failure_top"
+    style="@style/SyncTop" >
+  <ImageView
+    style="@style/SyncTopIcon" />
+  <TextView
+    style="@style/SyncTextTitle"
+    android:text="@string/sync_title_fail" />
+  </LinearLayout>
 
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal" >
-
-            <Button
-                style="@style/SyncButtonCommon"
-                android:text="@string/sync_button_tryagain"
-                android:onClick="tryAgainClickHandler" />
+  <TextView
+    style="@style/SyncTextItem"
+    android:layout_below="@id/failure_top"
+    android:layout_above="@+id/failure_bottom"
+    android:padding="20dp"
+    android:text="@string/sync_subtitle_fail" />
+  <LinearLayout
+    android:id="@id/failure_bottom"
+    style="@style/SyncBottom"
+    android:orientation="horizontal" >
 
-            <Button
-                style="@style/SyncButtonCommon"
-                android:onClick="manualClickHandler"
-                android:text="@string/sync_button_manual" />
-        </LinearLayout>
+    <Button
+      android:layout_width="fill_parent"
+      android:layout_height="wrap_content"
+      android:layout_weight="1"
+      android:onClick="tryAgainClickHandler"
+      android:text="@string/sync_button_tryagain" />
 
-        <Button
-            style="@style/SyncButtonCommon"
-            android:onClick="cancelClickHandler"
-            android:text="@string/sync_button_cancel" />
-    </LinearLayout>
-
-</TableLayout>
+    <Button
+      android:layout_width="fill_parent"
+      android:layout_height="wrap_content"
+      android:layout_weight="1"
+      android:onClick="manualClickHandler"
+      android:text="@string/sync_button_manual" />
+     <Button
+      android:layout_width="fill_parent"
+      android:layout_height="wrap_content"
+      android:layout_weight="1"
+      android:onClick="cancelClickHandler"
+      android:text="@string/sync_button_cancel" />
+  </LinearLayout>
+</RelativeLayout>
--- a/mobile/android/base/resources/layout/sync_setup_jpake_waiting.xml
+++ b/mobile/android/base/resources/layout/sync_setup_jpake_waiting.xml
@@ -1,23 +1,37 @@
 <?xml version="1.0" encoding="utf-8"?>
-<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    style="@style/SyncTextFrame" >
-    <TextView
-        style="@style/SyncTextTitle"
-        android:text="@string/sync_title_connect" />
-    <View
-        android:layout_width="fill_parent"
-        android:layout_height="2dp"
-        android:background="#FFFFFF" />
-    <ProgressBar
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        style="@android:style/Widget.ProgressBar.Horizontal"
-        android:indeterminateOnly="true" />
-    <TextView
-        style="@style/SyncTextItem"
-        android:text="@string/sync_jpake_subtitle_waiting" />
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
+  <LinearLayout
+    android:id="@+id/waiting_top"
+    style="@style/SyncTop" >
+  <ImageView
+    style="@style/SyncTopIcon" />
+  <TextView
+    style="@style/SyncTextTitle"
+    android:text="@string/sync_title_connect" />
+  </LinearLayout>
+
+  <ProgressBar
+    android:id="@+id/waiting_content1"
+    style="@android:style/Widget.ProgressBar.Horizontal"
+    android:layout_width="fill_parent"
+    android:layout_height="wrap_content"
+    android:layout_below="@id/waiting_top"
+    android:indeterminateOnly="true"
+    android:layout_marginTop="40dp"
+    android:layout_marginLeft="15dp"
+    android:layout_marginRight="15dp"
+    android:layout_marginBottom="15dp"/>
+  <TextView
+    style="@style/SyncTextItem"
+    android:gravity="center"
+    android:layout_below="@id/waiting_content1"
+    android:text="@string/sync_jpake_subtitle_waiting" />
+  <LinearLayout
+    style="@style/SyncBottom">
     <Button
-        style="@style/SyncButtonCommon"
-        android:onClick="cancelClickHandler"
-        android:text="@string/sync_button_cancel" />
-</TableLayout>
+      style="@style/SyncButton"
+      android:onClick="cancelClickHandler"
+      android:text="@string/sync_button_cancel" />
+  </LinearLayout>
+</RelativeLayout>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/sync_setup_nointernet.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
+  <LinearLayout
+    android:id="@+id/internet_top"
+    style="@style/SyncTop">
+    <ImageView
+      style="@style/SyncTopIcon" />
+    <TextView
+      style="@style/SyncTextTitle"
+      android:text="@string/sync_title_fail" />
+  </LinearLayout>
+  <TextView
+    style="@style/SyncTextItem"
+    android:layout_below="@id/internet_top"
+    android:layout_marginTop="20dp"
+    android:gravity="center"
+    android:text="@string/sync_subtitle_nointernet" />
+  <LinearLayout
+    style="@style/SyncBottom" >
+    <Button
+      style="@style/SyncButton"
+      android:onClick="cancelClickHandler"
+      android:text="@string/sync_button_ok" />
+  </LinearLayout>
+</RelativeLayout>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/sync_setup_pair.xml
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
+  <LinearLayout
+    android:id="@+id/pair_top"
+    style="@style/SyncTop" >
+    <ImageView
+      style="@style/SyncTopIcon" />
+    <TextView
+      android:id="@+id/setup_title"
+      style="@style/SyncTextTitle"
+      android:text="@string/sync_title_pair" />
+  </LinearLayout>
+  <ScrollView
+    style="@style/SyncLayout"
+    android:layout_below="@id/pair_top"
+    android:layout_above="@+id/pair_bottom" >
+
+    <LinearLayout
+      style="@style/SyncLayout.Vertical"
+      android:gravity="center"
+      android:padding="10dp" >
+
+      <TextView
+        android:id="@+id/setup_subtitle"
+        style="@style/SyncTextItem"
+        android:layout_marginTop="20dp"
+        android:layout_marginBottom="10dp"
+        android:text="@string/sync_subtitle_connect" />
+
+      <TextView
+        style="@style/SyncLinkItem"
+        android:layout_marginBottom="10dp"
+        android:onClick="showClickHandler"
+        android:text="@string/sync_link_show" />
+
+      <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:gravity="center"
+        android:orientation="vertical" >
+
+        <EditText
+          android:id="@+id/pair_row1"
+          style="@style/SyncEditPin" />
+        <EditText
+          android:id="@+id/pair_row2"
+          style="@style/SyncEditPin" />
+        <EditText
+          android:id="@+id/pair_row3"
+          style="@style/SyncEditPin"
+          android:imeOptions="actionDone" />
+      </LinearLayout>
+
+      <LinearLayout
+        android:id="@+id/pair_error"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:gravity="center"
+        android:orientation="horizontal"
+        android:visibility="invisible" >
+
+        <TextView
+          style="@style/SyncTextItem"
+          android:layout_margin="10dp"
+          android:text="@string/sync_pair_tryagain"
+          android:textSize="10dp" />
+      </LinearLayout>
+    </LinearLayout>
+  </ScrollView>
+  <LinearLayout
+    android:id="@id/pair_bottom"
+    style="@style/SyncBottom"
+    android:orientation="horizontal" >
+
+    <Button
+      style="@style/SyncButton"
+      android:onClick="cancelClickHandler"
+      android:text="@string/sync_button_cancel" />
+
+    <Button
+      android:id="@+id/pair_button_connect"
+      style="@style/SyncButton"
+      android:onClick="connectClickHandler"
+      android:clickable="false"
+      android:enabled="false"
+      android:text="@string/sync_button_connect" />
+  </LinearLayout>
+</RelativeLayout>
--- a/mobile/android/base/resources/layout/sync_setup_success.xml
+++ b/mobile/android/base/resources/layout/sync_setup_success.xml
@@ -1,25 +1,29 @@
 <?xml version="1.0" encoding="utf-8"?>
-<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    style="@style/SyncTextFrame" >
-    <TextView
-        style="@style/SyncTextTitle"
-        android:text="@string/sync_title_success" />
-    <View
-        android:layout_width="fill_parent"
-        android:layout_height="2dp"
-        android:background="#FFFFFF" />
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  style="@style/SyncLayout" >
 
+  <LinearLayout
+    android:id="@+id/success_top"
+    style="@style/SyncTop" >
+    <ImageView
+      style="@style/SyncTopIcon"/>
     <TextView
-        android:id="@+id/setup_success_subtitle"
-        android:layout_width="fill_parent"
-        android:layout_height="wrap_content"
-        android:gravity="center"
-        android:padding="20dp"
-        android:text="@string/sync_subtitle_success" />
+      style="@style/SyncTextTitle"
+      android:text="@string/sync_title_success" />
+  </LinearLayout>
+  <TextView
+    android:id="@+id/setup_success_subtitle"
+	style="@style/SyncTextItem"
+	android:gravity="left"
+    android:padding="20dp"
+    android:layout_below="@id/success_top"
+	android:text="@string/sync_subtitle_success" />
+
+  <LinearLayout
+    style="@style/SyncBottom" >
     <Button
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:onClick="settingsClickHandler"
-        android:text="@string/sync_settings" />
-</TableLayout>
+      style="@style/SyncButton"
+      android:onClick="settingsClickHandler"
+      android:text="@string/sync_settings" />
+  </LinearLayout>
+</RelativeLayout>
\ No newline at end of file
--- a/mobile/android/base/resources/values/sync_styles.xml
+++ b/mobile/android/base/resources/values/sync_styles.xml
@@ -1,34 +1,94 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
+  <style name="SyncLayout">
+    <item name="android:layout_width">fill_parent</item>
+    <item name="android:layout_height">fill_parent</item>
+  </style>
+  <style name="SyncLayout.Vertical" parent="@style/SyncLayout">
+    <item name="android:orientation">vertical</item>
+  </style>
+  <style name="SyncLayout.Horizontal" parent="@style/SyncLayout">
+    <item name="android:orientation">horizontal</item>
+  </style>
+
   <!-- TextView Styles -->
   <style name="SyncTextFrame" parent="@android:style/TextAppearance">
-    <item name="android:layout_width">wrap_content</item>
+    <item name="android:layout_width">fill_parent</item>
     <item name="android:layout_height">fill_parent</item>
+    <item name="android:layout_gravity">center</item>
     <item name="android:padding">20dp</item>
-    <item name="android:layout_gravity">center</item>
-    <item name="android:background">#82818A</item>
     <item name="android:orientation">vertical</item>
   </style>
   <style name="SyncTextItem" parent="@android:style/TextAppearance.Medium">
-    <item name="android:gravity">center</item>
-    <item name="android:layout_gravity">center_vertical</item>
+    <item name="android:layout_width">fill_parent</item>
     <item name="android:layout_height">wrap_content</item>
-    <item name="android:layout_width">fill_parent</item>
     <item name="android:textSize">15dp</item>
   </style>
+  <style name="SyncLinkItem" parent="SyncTextItem">
+    <item name="android:clickable">true</item>
+    <item name="android:textColor">#ACC4D5</item>
+  </style>
   <style name="SyncTextTitle" parent="@style/SyncTextItem">
+    <item name="android:layout_gravity">center_vertical</item>
+    <item name="android:paddingLeft">4dp</item>
+    <item name="android:gravity">left</item>
     <item name="android:textSize">20dp</item>
-    <item name="android:gravity">center</item>
-    <item name="android:paddingBottom">10dp</item>
   </style>
   <!-- EditView Styles -->
   <style name="SyncEditItem" parent="@android:style/Widget.EditText">
+    <item name="android:layout_width">fill_parent</item>
     <item name="android:layout_height">wrap_content</item>
-    <item name="android:layout_width">fill_parent</item>
     <item name="android:singleLine">true</item>
   </style>
-  <style name="SyncButtonCommon">
+  <style name="SyncEditPin" parent="@style/SyncEditItem">
+    <item name="android:layout_width">wrap_content</item>
+    <item name="android:gravity">center_horizontal</item>
+    <item name="android:ems">4</item>
+    <item name="android:maxLength">4</item>
+    <item name="android:imeOptions">actionNext</item>
+  </style>
+
+  <!-- Theme Styles -->
+  <style name="SyncTheme" parent="@android:style/Theme.NoTitleBar"/>
+
+  <style name="SyncTop">
+    <item name="android:layout_width">fill_parent</item>
+    <item name="android:layout_height">wrap_content</item>
+    <item name="android:gravity">left</item>
+    <item name="android:orientation">horizontal</item>
+    <item name="android:layout_alignParentTop">true</item>
+    <item name="android:background">@android:drawable/bottom_bar</item>
+  </style>
+    <style name="SyncBottom">
+    <item name="android:layout_width">fill_parent</item>
     <item name="android:layout_height">wrap_content</item>
+    <item name="android:gravity">center</item>
+    <item name="android:layout_alignParentBottom">true</item>
+    <item name="android:background">@android:drawable/bottom_bar</item>
+  </style>
+  <style name="SyncButton" parent="@android:style/Widget.Button">
+    <item name="android:layout_width">fill_parent</item>
+    <item name="android:layout_height">wrap_content</item>
+    <item name="android:layout_weight">1</item>
+  </style>
+
+  <!-- Text Display Styles -->
+  <style name="SyncPinText" parent="@android:style/Widget.TextView">
+    <item name="android:layout_width">130sp</item>
+    <item name="android:layout_height">wrap_content</item>
+    <item name="android:layout_gravity">center</item>
+    <item name="android:layout_marginBottom">5dp</item>
+    <item name="android:gravity">center</item>
+    <item name="android:background">@drawable/pin_background</item>
+    <item name="android:textColor">#FFFFFF</item>
+    <item name="android:textSize">35sp</item>
+    <item name="android:text">@string/sync_pin_default</item>
+  </style>
+  <style name="SyncTopIcon">
+    <item name="android:src">@drawable/sync_ic_launcher</item>
     <item name="android:layout_width">wrap_content</item>
+    <item name="android:layout_height">wrap_content</item>
+    <item name="android:paddingTop">2dp</item>
+    <item name="android:paddingLeft">4dp</item>
   </style>
 </resources>
--- a/mobile/android/base/resources/xml/sync_authenticator.xml
+++ b/mobile/android/base/resources/xml/sync_authenticator.xml
@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
-    android:accountType="org.mozilla.firefox.sync"
+    android:accountType="org.mozilla.firefox_sync"
     android:icon="@drawable/sync_icon"
     android:smallIcon="@drawable/sync_icon"
-    android:label="@string/sync_account_label" />
+    android:label="@string/sync_account_label"
+    android:accountPreferences="@xml/sync_options" />
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/xml/sync_options.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+  <PreferenceCategory
+    android:title="@string/sync_settings_options" />
+  <PreferenceScreen
+    android:key="sync_options"
+    android:title="@string/sync_title_pair"
+    android:summary="@string/sync_settings_summary_pair">
+    <intent
+      android:action="android.intent.action.MAIN"
+      android:targetPackage="org.mozilla.gecko"
+      android:targetClass="org.mozilla.gecko.sync.setup.activities.SetupSyncActivity">
+      <extra
+        android:name="isSetup"
+        android:value="false" />
+    </intent>
+  </PreferenceScreen>
+</PreferenceScreen>
\ No newline at end of file
--- a/mobile/android/base/resources/xml/sync_syncadapter.xml.in
+++ b/mobile/android/base/resources/xml/sync_syncadapter.xml.in
@@ -1,8 +1,8 @@
 #filter substitution
 <?xml version="1.0" encoding="utf-8"?>
 <sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
     android:contentAuthority="@ANDROID_PACKAGE_NAME@.db.browser"
-    android:accountType="org.mozilla.firefox.sync"
+    android:accountType="org.mozilla.firefox_sync"
     android:supportsUploading="true"
     android:userVisible="true"
 />
--- a/mobile/android/base/sync/CryptoRecord.java
+++ b/mobile/android/base/sync/CryptoRecord.java
@@ -35,27 +35,26 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync;
 
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 
-import org.mozilla.apache.commons.codec.binary.Base64;
 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.crypto.CryptoException;
 import org.mozilla.gecko.sync.crypto.CryptoInfo;
 import org.mozilla.gecko.sync.crypto.Cryptographer;
 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.crypto.Utils;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 /**
  * A Sync crypto record has:
  *
  * * a collection of fields which are not encrypted (id and collection)
  * * a set of metadata fields (index, modified, ttl)
  * * a payload, which is encrypted and decrypted on request.
@@ -70,16 +69,18 @@ import org.mozilla.gecko.sync.repositori
  *
  */
 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_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),
@@ -137,30 +138,39 @@ public class CryptoRecord extends Record
    * and turn it into a CryptoRecord object.
    *
    * @param jsonRecord
    * @return
    * @throws NonObjectJSONException
    * @throws ParseException
    * @throws IOException
    */
-  public static CryptoRecord fromJSONRecord(String jsonRecord) throws ParseException, NonObjectJSONException, IOException {
-    return CryptoRecord.fromJSONRecord(CryptoRecord.parseUTF8AsJSONObject(jsonRecord.getBytes("UTF-8")));
+  public static CryptoRecord fromJSONRecord(String jsonRecord)
+      throws ParseException, NonObjectJSONException, IOException {
+    byte[] bytes = jsonRecord.getBytes("UTF-8");
+    ExtendedJSONObject object = CryptoRecord.parseUTF8AsJSONObject(bytes);
+
+    return CryptoRecord.fromJSONRecord(object);
   }
 
   // TODO: defensive programming.
-  public static CryptoRecord fromJSONRecord(ExtendedJSONObject jsonRecord) throws IOException, ParseException, NonObjectJSONException {
-    String id = (String) jsonRecord.get(KEY_ID);
-    String collection = (String) jsonRecord.get(KEY_COLLECTION);
+  public static CryptoRecord fromJSONRecord(ExtendedJSONObject jsonRecord)
+      throws IOException, ParseException, NonObjectJSONException {
+    String id                  = (String) jsonRecord.get(KEY_ID);
+    String collection          = (String) jsonRecord.get(KEY_COLLECTION);
     ExtendedJSONObject payload = jsonRecord.getJSONObject(KEY_PAYLOAD);
     CryptoRecord record = new CryptoRecord(payload);
-    record.guid = id;
-    record.collection = collection;
-
-    // TODO: lastModified?
+    record.guid         = id;
+    record.collection   = collection;
+    if (jsonRecord.containsKey(KEY_MODIFIED)) {
+      record.lastModified = jsonRecord.getTimestamp(KEY_MODIFIED);
+    }
+    if (jsonRecord.containsKey(KEY_SORTINDEX )) {
+      record.sortIndex = jsonRecord.getLong(KEY_SORTINDEX);
+    }
     // TODO: deleted?
     return record;
   }
 
   public void setKeyBundle(KeyBundle bundle) {
     this.keyBundle = bundle;
   }
 
@@ -225,9 +235,14 @@ public class CryptoRecord extends Record
 
   // 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);
     return o.object;
   }
+
+  @Override
+  public String toJSONString() {
+    return toJSONObject().toJSONString();
+  }
 }
--- a/mobile/android/base/sync/ExtendedJSONObject.java
+++ b/mobile/android/base/sync/ExtendedJSONObject.java
@@ -40,39 +40,48 @@ package org.mozilla.gecko.sync;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.io.StringReader;
 import java.util.Map;
 import java.util.Map.Entry;
 
+import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.json.simple.parser.JSONParser;
 import org.json.simple.parser.ParseException;
 
 /**
  * Extend JSONObject to do little things, like, y'know, accessing members.
  *
  * @author rnewman
  *
  */
 public class ExtendedJSONObject {
 
   public JSONObject object;
 
-  public static Object parse(InputStreamReader reader) throws IOException, ParseException {
-    Object parseOutput = new JSONParser().parse(reader);
+  private static Object processParseOutput(Object parseOutput) {
     if (parseOutput instanceof JSONObject) {
       return new ExtendedJSONObject((JSONObject) parseOutput);
     } else {
       return parseOutput;
     }
   }
 
+  public static Object parse(String string) throws IOException, ParseException {
+    return processParseOutput(new JSONParser().parse(string));
+  }
+
+  public static Object parse(InputStreamReader reader) throws IOException, ParseException {
+    return processParseOutput(new JSONParser().parse(reader));
+
+  }
+
   public static Object parse(InputStream stream) throws IOException, ParseException {
     InputStreamReader reader = new InputStreamReader(stream, "UTF-8");
     return ExtendedJSONObject.parse(reader);
   }
 
   /**
    * Helper method to get a JSONObject from a String. Input: String containing
    * JSON. Output: Extracted JSONObject. Throws: Exception if JSON is invalid.
@@ -114,16 +123,41 @@ public class ExtendedJSONObject {
   // Passthrough methods.
   public Object get(String key) {
     return this.object.get(key);
   }
   public Long getLong(String key) {
     return (Long) this.get(key);
   }
 
+  /**
+   * Return a server timestamp value as milliseconds since epoch.
+   * @param string
+   * @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).doubleValue() * 1000;
+      return new Double(millis).longValue();
+    }
+    if (val instanceof Float) {
+      double millis = ((Float) val).doubleValue() * 1000;
+      return new Double(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();
   }
 
@@ -134,16 +168,19 @@ public class ExtendedJSONObject {
   public void put(String key, Object value) {
     @SuppressWarnings("unchecked")
     Map<Object, Object> map = this.object;
     map.put(key, value);
   }
 
   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(o);
   }
@@ -169,9 +206,20 @@ public class ExtendedJSONObject {
     String val = (String) this.object.get(key);
     return ExtendedJSONObject.parseJSONObject(val);
   }
 
   @SuppressWarnings("unchecked")
   public Iterable<Entry<String, Object>> entryIterable() {
     return this.object.entrySet();
   }
+
+  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(o);
+  }
 }
--- a/mobile/android/base/sync/GlobalSession.java
+++ b/mobile/android/base/sync/GlobalSession.java
@@ -66,24 +66,31 @@ import org.mozilla.gecko.sync.stage.Comp
 import org.mozilla.gecko.sync.stage.EnsureClusterURLStage;
 import org.mozilla.gecko.sync.stage.EnsureKeysStage;
 import org.mozilla.gecko.sync.stage.FetchInfoCollectionsStage;
 import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
 import org.mozilla.gecko.sync.stage.NoSuchStageException;
 
+import ch.boye.httpclientandroidlib.HttpResponse;
+
 import android.content.Context;
+import android.content.SharedPreferences;
 import android.os.Bundle;
 import android.util.Log;
 
-public class GlobalSession implements CredentialsSource {
+public class GlobalSession implements CredentialsSource, PrefsSource {
+  private static final String LOG_TAG = "GlobalSession";
+
   public static final String API_VERSION   = "1.1";
   public static final long STORAGE_VERSION = 5;
-  private static final String LOG_TAG = "GlobalSession";
+
+  private static final String HEADER_RETRY_AFTER     = "retry-after";
+  private static final String HEADER_X_WEAVE_BACKOFF = "x-weave-backoff";
 
   public SyncConfiguration config = null;
 
   protected Map<Stage, GlobalSyncStage> stages;
   public Stage currentState = Stage.idle;
 
   private GlobalSessionCallback callback;
   private Context context;
@@ -134,16 +141,17 @@ public class GlobalSession implements Cr
     }
     return false;
   }
 
   public GlobalSession(String userAPI,
                        String serverURL,
                        String username,
                        String password,
+                       String prefsPath,
                        KeyBundle syncKeyBundle,
                        GlobalSessionCallback callback,
                        Context context,
                        Bundle persisted)
                            throws SyncConfigurationException, IllegalArgumentException, IOException, ParseException, NonObjectJSONException {
     if (callback == null) {
       throw new IllegalArgumentException("Must provide a callback to GlobalSession constructor.");
     }
@@ -161,26 +169,26 @@ public class GlobalSession implements Cr
     }
 
     if (syncKeyBundle == null ||
         syncKeyBundle.getEncryptionKey() == null ||
         syncKeyBundle.getHMACKey() == null) {
       throw new SyncConfigurationException();
     }
 
-    // TODO: use persisted.
-    config = new SyncConfiguration();
+    this.callback        = callback;
+    this.context         = context;
+
+    config = new SyncConfiguration(prefsPath, this);
     config.userAPI       = userAPI;
     config.serverURL     = serverURI;
     config.username      = username;
     config.password      = password;
     config.syncKeyBundle = syncKeyBundle;
-
-    this.callback        = callback;
-    this.context         = context;
+    // clusterURL and syncID are set through `persisted`, or fetched from the server.
 
     // TODO: populate saved configurations. We'll amend these after processing meta/global.
     this.synchronizerConfigurations = new SynchronizerConfigurations(persisted);
     prepareStages();
   }
 
   protected void prepareStages() {
     stages = new HashMap<Stage, GlobalSyncStage>();
@@ -245,16 +253,25 @@ public class GlobalSession implements Cr
     return config.syncID;
   }
 
   private String generateSyncID() {
     config.syncID = Utils.generateGuid();
     return config.syncID;
   }
 
+  /*
+   * PrefsSource methods.
+   */
+  @Override
+  public SharedPreferences getPrefs(String name, int mode) {
+    return this.getContext().getSharedPreferences(name, mode);
+  }
+
+  @Override
   public Context getContext() {
     return this.context;
   }
 
   /**
    * Begin a sync.
    *
    * The caller is responsible for:
@@ -274,17 +291,20 @@ public class GlobalSession implements Cr
   }
 
   /**
    * Stop this sync and start again.
    * @throws AlreadySyncingException
    */
   protected void restart() throws AlreadySyncingException {
     this.currentState = GlobalSyncStage.Stage.idle;
-    // TODO: respect backoff.
+    if (callback.shouldBackOff()) {
+      this.callback.handleAborted(this, "Told to back off.");
+      return;
+    }
     this.start();
   }
 
   public void completeSync() {
     this.currentState = GlobalSyncStage.Stage.idle;
     this.callback.handleSuccess(this);
   }
 
@@ -292,19 +312,42 @@ public class GlobalSession implements Cr
     Log.w(LOG_TAG, "Aborting sync: " + reason, e);
     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.
     Log.w(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 retryAfter = 0;
+    long weaveBackoff = 0;
+    if (response.containsHeader(HEADER_RETRY_AFTER)) {
+      // Handles non-decimals just fine.
+      String headerValue = response.getFirstHeader(HEADER_RETRY_AFTER).getValue();
+      retryAfter = Utils.decimalSecondsToMilliseconds(headerValue);
+    }
+    if (response.containsHeader(HEADER_X_WEAVE_BACKOFF)) {
+      // Handles non-decimals just fine.
+      String headerValue = response.getFirstHeader(HEADER_X_WEAVE_BACKOFF).getValue();
+      weaveBackoff = Utils.decimalSecondsToMilliseconds(headerValue);
+    }
+    long backoff = Math.max(retryAfter, weaveBackoff);
+    if (backoff > 0) {
+      callback.requestBackoff(backoff);
+    }
+  }
 
 
   public void fetchMetaGlobal(MetaGlobalDelegate callback) throws URISyntaxException {
     if (this.config.metaGlobal == null) {
       this.config.metaGlobal = new MetaGlobal(config.metaURL(), credentials());
     }
     this.config.metaGlobal.fetch(callback);
   }
@@ -314,17 +357,17 @@ public class GlobalSession implements Cr
       this.config.infoCollections = new InfoCollections(config.infoURL(), credentials());
     }
     this.config.infoCollections.fetch(callback);
   }
 
   public void uploadKeys(CryptoRecord keysRecord,
                          final KeyUploadDelegate keyUploadDelegate) {
     SyncStorageRecordRequest request;
-    final GlobalSession globalSession = this;
+    final GlobalSession self = this;
     try {
       request = new SyncStorageRecordRequest(this.config.keysURI());
     } catch (URISyntaxException e) {
       keyUploadDelegate.onKeyUploadFailed(e);
       return;
     }
 
     request.delegate = new SyncStorageRequestDelegate() {
@@ -336,27 +379,28 @@ public class GlobalSession implements Cr
 
       @Override
       public void handleRequestSuccess(SyncStorageResponse response) {
         keyUploadDelegate.onKeysUploaded();
       }
 
       @Override
       public void handleRequestFailure(SyncStorageResponse response) {
+        self.interpretHTTPFailure(response.httpResponse());
         keyUploadDelegate.onKeyUploadFailed(new HTTPFailureException(response));
       }
 
       @Override
       public void handleRequestError(Exception ex) {
         keyUploadDelegate.onKeyUploadFailed(ex);
       }
 
       @Override
       public String credentials() {
-        return globalSession.credentials();
+        return self.credentials();
       }
     };
 
     keysRecord.setKeyBundle(config.syncKeyBundle);
     try {
       keysRecord.encrypt();
     } catch (UnsupportedEncodingException e) {
       keyUploadDelegate.onKeyUploadFailed(e);
@@ -395,16 +439,17 @@ public class GlobalSession implements Cr
       // Sync ID has changed. Reset timestamps and fetch new keys.
       resetClient();
       if (config.collectionKeys != null) {
         config.collectionKeys.clear();
       }
       config.syncID = remoteSyncID;
       // TODO TODO TODO
     }
+    config.persistToPrefs();
     advance();
   }
 
   public void processMissingMetaGlobal(MetaGlobal global) {
     freshStart();
   }
 
   /**
@@ -417,16 +462,17 @@ public class GlobalSession implements Cr
       @Override
       public void onFreshStartFailed(Exception e) {
         globalSession.abort(e, "Fresh start failed.");
       }
 
       @Override
       public void onFreshStart() {
         try {
+          globalSession.config.persistToPrefs();
           globalSession.restart();
         } catch (Exception e) {
           Log.w(LOG_TAG, "Got exception when restarting sync after freshStart.", e);
           globalSession.abort(e, "Got exception after freshStart.");
         }
       }
     });
   }
@@ -441,29 +487,30 @@ public class GlobalSession implements Cr
     final String credentials = session.credentials();
 
     wipeServer(session, new WipeServerDelegate() {
 
       @Override
       public void onWiped(long timestamp) {
         session.resetClient();
         session.config.collectionKeys.clear();      // TODO: make sure we clear our keys timestamp.
+        session.config.persistToPrefs();
 
         MetaGlobal mg = new MetaGlobal(metaURL, credentials);
         mg.setSyncID(newSyncID);
         mg.setStorageVersion(STORAGE_VERSION);
 
         // 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 global) {
+          public void handleSuccess(MetaGlobal global, SyncStorageResponse response) {
             session.config.metaGlobal = global;
             Log.i(LOG_TAG, "New meta/global uploaded with sync ID " + newSyncID);
 
             // Generate and upload new keys.
             try {
               session.uploadKeys(CollectionKeys.generateCollectionKeys().asCryptoRecord(), new KeyUploadDelegate() {
                 @Override
                 public void onKeysUploaded() {
@@ -482,55 +529,56 @@ public class GlobalSession implements Cr
               freshStartDelegate.onFreshStartFailed(e);
             } catch (CryptoException e) {
               Log.e(LOG_TAG, "Got exception generating new keys.", e);
               freshStartDelegate.onFreshStartFailed(e);
             }
           }
 
           @Override
-          public void handleMissing(MetaGlobal global) {
+          public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
             // Shouldn't happen.
             Log.w(LOG_TAG, "Got 'missing' response uploading new meta/global.");
             freshStartDelegate.onFreshStartFailed(new Exception("meta/global missing"));
           }
 
           @Override
           public void handleFailure(SyncStorageResponse response) {
             // TODO: respect backoffs etc.
             Log.w(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) {
             Log.w(LOG_TAG, "Got error uploading new meta/global.", e);
             freshStartDelegate.onFreshStartFailed(e);
           }
 
           @Override
           public MetaGlobalDelegate deferred() {
             final MetaGlobalDelegate self = this;
             return new MetaGlobalDelegate() {
 
               @Override
-              public void handleSuccess(final MetaGlobal global) {
+              public void handleSuccess(final MetaGlobal global, final SyncStorageResponse response) {
                 ThreadPool.run(new Runnable() {
                   @Override
                   public void run() {
-                    self.handleSuccess(global);
+                    self.handleSuccess(global, response);
                   }});
               }
 
               @Override
-              public void handleMissing(final MetaGlobal global) {
+              public void handleMissing(final MetaGlobal global, final SyncStorageResponse response) {
                 ThreadPool.run(new Runnable() {
                   @Override
                   public void run() {
-                    self.handleMissing(global);
+                    self.handleMissing(global, response);
                   }});
               }
 
               @Override
               public void handleFailure(final SyncStorageResponse response) {
                 ThreadPool.run(new Runnable() {
                   @Override
                   public void run() {
@@ -562,16 +610,18 @@ public class GlobalSession implements Cr
         freshStartDelegate.onFreshStartFailed(e);
       }
     });
 
   }
 
   private void wipeServer(final CredentialsSource credentials, final WipeServerDelegate wipeDelegate) {
     SyncStorageRequest request;
+    final GlobalSession self = this;
+
     try {
       request = new SyncStorageRequest(config.storageURL(false));
     } catch (URISyntaxException ex) {
       Log.w(LOG_TAG, "Invalid URI in wipeServer.");
       wipeDelegate.onWipeFailed(ex);
       return;
     }
 
@@ -585,17 +635,18 @@ public class GlobalSession implements Cr
       @Override
       public void handleRequestSuccess(SyncStorageResponse response) {
         wipeDelegate.onWiped(response.normalizedWeaveTimestamp());
       }
 
       @Override
       public void handleRequestFailure(SyncStorageResponse response) {
         Log.w(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer.");
-        // TODO: process HTTP failures here to pick up backoffs etc.
+        // Process HTTP failures here to pick up backoffs, etc.
+        self.interpretHTTPFailure(response.httpResponse());
         wipeDelegate.onWipeFailed(new HTTPFailureException(response));
       }
 
       @Override
       public void handleRequestError(Exception ex) {
         Log.w(LOG_TAG, "Got exception in wipeServer.", ex);
         wipeDelegate.onWipeFailed(ex);
       }
@@ -603,18 +654,23 @@ public class GlobalSession implements Cr
       @Override
       public String credentials() {
         return credentials.credentials();
       }
     };
     request.delete();
   }
 
+  /**
+   * Reset our state. Clear our sync ID, reset each engine, drop any
+   * cached records.
+   */
   private void resetClient() {
-    // TODO Auto-generated method stub
+    // TODO: futz with config?!
+    // TODO: engines?!
 
   }
 
   /**
    * Suggest that your Sync client needs to be upgraded to work
    * with this server.
    */
   public void requiresUpgrade() {
--- a/mobile/android/base/sync/HTTPFailureException.java
+++ b/mobile/android/base/sync/HTTPFailureException.java
@@ -45,16 +45,28 @@ public class HTTPFailureException extend
   private static final long serialVersionUID = -5415864029780770619L;
   public SyncStorageResponse response;
 
   public HTTPFailureException(SyncStorageResponse response) {
     this.response = response;
   }
 
   @Override
+  public String toString() {
+    String errorMessage = "[unknown error message]";
+    try {
+      errorMessage = this.response.getErrorMessage();
+    } catch (Exception e) {
+      // Oh well.
+    }
+    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:
--- a/mobile/android/base/sync/InfoCollections.java
+++ b/mobile/android/base/sync/InfoCollections.java
@@ -35,16 +35,18 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync;
 
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.util.HashMap;
+import java.util.Map.Entry;
+import java.util.Set;
 
 import org.json.simple.parser.ParseException;
 import org.mozilla.gecko.sync.delegates.InfoCollectionsDelegate;
 import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
 import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
 import org.mozilla.gecko.sync.net.SyncStorageResponse;
 
 import android.util.Log;
@@ -54,30 +56,30 @@ public class InfoCollections implements 
   protected String infoURL;
   protected String credentials;
 
   // Fetched objects.
   protected SyncStorageResponse response;
   private ExtendedJSONObject    record;
 
   // Fields.
-  private HashMap<String, Double> timestamps;
+  // Rather than storing decimal/double timestamps, as provided by the
+  // server, we convert immediately to milliseconds since epoch.
+  private HashMap<String, Long> timestamps;
 
-  public HashMap<String, Double> getTimestamps() {
+  public HashMap<String, Long> getTimestamps() {
     if (!this.wasSuccessful()) {
       throw new IllegalStateException("No record fetched.");
     }
     return this.timestamps;
   }
 
-  // TODO
-//  public Iterable<String> changedCollections(HashMap<String, Long> formerTimestamps) {
-    // for (Entry<String, Long> oldEntry : formerTimestamps.entrySet()) {
-    //}
-//  }
+  public Long getTimestamp(String collection) {
+    return this.getTimestamps().get(collection);
+  }
 
   public boolean wasSuccessful() {
     return this.response.wasSuccessful() &&
            this.timestamps != null;
   }
 
   // Temporary location to store our callback.
   private InfoCollectionsDelegate callback;
@@ -93,19 +95,25 @@ public class InfoCollections implements 
       this.doFetch();
       return;
     }
     callback.handleSuccess(this);
   }
 
   private void doFetch() {
     try {
-      SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.infoURL);
+      final SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.infoURL);
       r.delegate = this;
-      r.get();
+      // TODO: it might be nice to make Resource include its
+      // own thread pool, and automatically run asynchronously.
+      ThreadPool.run(new Runnable() {
+        @Override
+        public void run() {
+          r.get();
+        }});
     } catch (URISyntaxException e) {
       callback.handleError(e);
     }
   }
 
   public SyncStorageResponse getResponse() {
     return this.response;
   }
@@ -121,18 +129,38 @@ public class InfoCollections implements 
     this.record = record;
   }
 
   @SuppressWarnings("unchecked")
   private void unpack(SyncStorageResponse response) throws IllegalStateException, IOException, ParseException, NonObjectJSONException {
     this.response = response;
     this.setRecord(response.jsonObjectBody());
     Log.i(LOG_TAG, "info/collections is " + this.record.toJSONString());
-    HashMap<String, Double> map = new HashMap<String, Double>();
-    map.putAll((HashMap<String, Double>) this.record.object);
+    HashMap<String, Long> map = new HashMap<String, Long>();
+
+    Set<Entry<String, Object>> entrySet = this.record.object.entrySet();
+    for (Entry<String, Object> entry : entrySet) {
+      // These objects are most likely going to be Doubles. Regardless, we
+      // want to get them in a more sane time format.
+      String key = entry.getKey();
+      Object value = entry.getValue();
+      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;
+      }
+      Log.w(LOG_TAG, "Skipping info/collections entry for " + key);
+    }
     this.timestamps = map;
   }
 
   // SyncStorageRequestDelegate methods for fetching.
   public String credentials() {
     return this.credentials;
   }
 
--- a/mobile/android/base/sync/MetaGlobal.java
+++ b/mobile/android/base/sync/MetaGlobal.java
@@ -51,18 +51,17 @@ import android.util.Log;
 public class MetaGlobal implements SyncStorageRequestDelegate {
   private static final String LOG_TAG = "MetaGlobal";
   protected String metaURL;
   protected String credentials;
 
   public boolean isModified;
   protected boolean isNew;
 
-  // Fetched objects.
-  protected SyncStorageResponse response;
+  // Fetched object.
   private CryptoRecord record;
 
   // Fields.
   protected ExtendedJSONObject  engines;
   protected Long                storageVersion;
   protected String              syncID;
 
   // Temporary location to store our callback.
@@ -72,39 +71,31 @@ public class MetaGlobal implements SyncS
   private boolean isUploading;
 
   public MetaGlobal(String metaURL, String credentials) {
     this.metaURL     = metaURL;
     this.credentials = credentials;
   }
 
   public void fetch(MetaGlobalDelegate callback) {
-    if (this.response == null) {
-      this.callback = callback;
-      this.doFetch();
-      return;
-    }
-    callback.deferred().handleSuccess(this);
+    this.callback = callback;
+    this.doFetch();
   }
 
   private void doFetch() {
     try {
       this.isUploading = false;
       SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.metaURL);
       r.delegate = this;
       r.deferGet();
     } catch (URISyntaxException e) {
       callback.handleError(e);
     }
   }
 
-  public SyncStorageResponse getResponse() {
-    return this.response;
-  }
-
   public void upload(MetaGlobalDelegate callback) {
     try {
       this.isUploading = true;
       SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.metaURL);
 
       // TODO: PUT! Body!
       r.delegate = this;
       r.deferPut(null);
@@ -120,17 +111,16 @@ public class MetaGlobal implements SyncS
     return this.record;
   }
 
   protected void setRecord(ExtendedJSONObject obj) throws IOException, ParseException, NonObjectJSONException {
     this.record = CryptoRecord.fromJSONRecord(obj);
   }
 
   private void unpack(SyncStorageResponse response) throws IllegalStateException, IOException, ParseException, NonObjectJSONException {
-    this.response = response;
     this.setRecord(response.jsonObjectBody());
     Log.i(LOG_TAG, "meta/global is " + record.payload.toJSONString());
     this.isModified = false;
     this.storageVersion = (Long) record.payload.get("storageVersion");
     this.engines  = record.payload.getObject("engines");
     this.syncID = (String) record.payload.get("syncID");
   }
 
@@ -175,40 +165,39 @@ public class MetaGlobal implements SyncS
       this.handleUploadSuccess(response);
     } else {
       this.handleDownloadSuccess(response);
     }
   }
 
   private void handleUploadSuccess(SyncStorageResponse response) {
     this.isModified = false;
-    this.callback.handleSuccess(this);
+    this.callback.handleSuccess(this, response);
     this.callback = null;
   }
 
   private void handleDownloadSuccess(SyncStorageResponse response) {
     if (response.wasSuccessful()) {
       try {
         this.unpack(response);
-        this.callback.handleSuccess(this);
+        this.callback.handleSuccess(this, response);
         this.callback = null;
       } catch (Exception e) {
         this.callback.handleError(e);
         this.callback = null;
       }
       return;
     }
     this.callback.handleFailure(response);
     this.callback = null;
   }
 
   public void handleRequestFailure(SyncStorageResponse response) {
     if (response.getStatusCode() == 404) {
-      this.response = response;
-      this.callback.handleMissing(this);
+      this.callback.handleMissing(this, response);
       this.callback = null;
       return;
     }
     this.callback.handleFailure(response);
     this.callback = null;
   }
 
   public void handleRequestError(Exception e) {
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/NonArrayJSONException.java
@@ -0,0 +1,46 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.sync;
+
+public class NonArrayJSONException extends UnexpectedJSONException {
+  private static final long serialVersionUID = 5582918057432365749L;
+
+  public NonArrayJSONException(Object object) {
+    super(object);
+  }
+}
--- a/mobile/android/base/sync/NonObjectJSONException.java
+++ b/mobile/android/base/sync/NonObjectJSONException.java
@@ -14,17 +14,17 @@
  * The Original Code is Android Sync Client.
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
- * Richard Newman <rnewman@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -32,15 +32,15 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync;
 
-public class NonObjectJSONException extends Exception {
-  private static final long serialVersionUID = 435366246452253073L;
-  Object obj;
+public class NonObjectJSONException extends UnexpectedJSONException {
+  private static final long serialVersionUID = 2214238763035650087L;
+
   public NonObjectJSONException(Object object) {
-    obj = object;
+    super(object);
   }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/PrefsSource.java
@@ -0,0 +1,66 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.sync;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * Implement PrefsSource to allow other components to fetch a SharedPreferences
+ * instance via a Context that you provide.
+ *
+ * This allows components to use SharedPreferences without being tightly
+ * coupled to an Activity.
+ *
+ * @author rnewman
+ *
+ */
+public interface PrefsSource {
+  public Context getContext();
+
+  /**
+   * Return a SharedPreferences instance.
+   * @param name
+   *        A String, used to identify a preferences 'branch'. Must not be null.
+   * @param mode
+   *        A bitmask mode, as described in http://developer.android.com/reference/android/content/Context.html#getSharedPreferences%28java.lang.String,%20int%29.
+   * @return
+   *        A new or existing SharedPreferences instance.
+   */
+  public SharedPreferences getPrefs(String name, int mode);
+}
--- a/mobile/android/base/sync/SyncConfiguration.java
+++ b/mobile/android/base/sync/SyncConfiguration.java
@@ -34,41 +34,262 @@
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync;
 
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.Map;
+import java.util.Set;
 
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
 import android.util.Log;
 
 public class SyncConfiguration implements CredentialsSource {
+
+  public class EditorBranch implements Editor {
+
+    private String prefix;
+    private Editor editor;
+
+    public EditorBranch(SyncConfiguration config, String prefix) {
+      if (!prefix.endsWith(".")) {
+        throw new IllegalArgumentException("No trailing period in prefix.");
+      }
+      this.prefix = prefix;
+      this.editor = config.getEditor();
+    }
+
+    public void apply() {
+      // Android <=r8 SharedPreferences.Editor does not contain apply() for overriding.
+      this.editor.commit();
+    }
+
+    @Override
+    public Editor clear() {
+      this.editor = this.editor.clear();
+      return this;
+    }
+
+    @Override
+    public boolean commit() {
+      return this.editor.commit();
+    }
+
+    @Override
+    public Editor putBoolean(String key, boolean value) {
+      this.editor = this.editor.putBoolean(prefix + key, value);
+      return this;
+    }
+
+    @Override
+    public Editor putFloat(String key, float value) {
+      this.editor = this.editor.putFloat(prefix + key, value);
+      return this;
+    }
+
+    @Override
+    public Editor putInt(String key, int value) {
+      this.editor = this.editor.putInt(prefix + key, value);
+      return this;
+    }
+
+    @Override
+    public Editor putLong(String key, long value) {
+      this.editor = this.editor.putLong(prefix + key, value);
+      return this;
+    }
+
+    @Override
+    public Editor putString(String key, String value) {
+      this.editor = this.editor.putString(prefix + key, value);
+      return this;
+    }
+
+    // Not marking as Override, because Android <= 10 doesn't have
+    // putStringSet. Neither can we implement it.
+    public Editor putStringSet(String key, Set<String> value) {
+      throw new RuntimeException("putStringSet not available.");
+    }
+
+    @Override
+    public Editor remove(String key) {
+      this.editor = this.editor.remove(prefix + key);
+      return this;
+    }
+
+  }
+
+  /**
+   * A wrapper around a portion of the SharedPreferences space.
+   *
+   * @author rnewman
+   *
+   */
+  public class ConfigurationBranch implements SharedPreferences {
+
+    private SyncConfiguration config;
+    private String prefix;                // Including trailing period.
+
+    public ConfigurationBranch(SyncConfiguration syncConfiguration,
+        String prefix) {
+      if (!prefix.endsWith(".")) {
+        throw new IllegalArgumentException("No trailing period in prefix.");
+      }
+      this.config = syncConfiguration;
+      this.prefix = prefix;
+    }
+
+    @Override
+    public boolean contains(String key) {
+      return config.getPrefs().contains(prefix + key);
+    }
+
+    @Override
+    public Editor edit() {
+      return new EditorBranch(config, prefix);
+    }
+
+    @Override
+    public Map<String, ?> getAll() {
+      // Not implemented. TODO
+      return null;
+    }
+
+    @Override
+    public boolean getBoolean(String key, boolean defValue) {
+      return config.getPrefs().getBoolean(prefix + key, defValue);
+    }
+
+    @Override
+    public float getFloat(String key, float defValue) {
+      return config.getPrefs().getFloat(prefix + key, defValue);
+    }
+
+    @Override
+    public int getInt(String key, int defValue) {
+      return config.getPrefs().getInt(prefix + key, defValue);
+    }
+
+    @Override
+    public long getLong(String key, long defValue) {
+      return config.getPrefs().getLong(prefix + key, defValue);
+    }
+
+    @Override
+    public String getString(String key, String defValue) {
+      return config.getPrefs().getString(prefix + key, defValue);
+    }
+
+    // Not marking as Override, because Android <= 10 doesn't have
+    // getStringSet. Neither can we implement it.
+    public Set<String> getStringSet(String key, Set<String> defValue) {
+      throw new RuntimeException("getStringSet not available.");
+    }
+
+    @Override
+    public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
+      config.getPrefs().registerOnSharedPreferenceChangeListener(listener);
+    }
+
+    @Override
+    public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
+      config.getPrefs().unregisterOnSharedPreferenceChangeListener(listener);
+    }
+  }
+
   public static final String DEFAULT_USER_API = "https://auth.services.mozilla.com/user/1.0/";
 
   private static final String LOG_TAG = "SyncConfiguration";
 
   // These must be set in GlobalSession's constructor.
   public String          userAPI;
   public URI             serverURL;
-  public URI             clusterURL;
+  protected URI          clusterURL;
   public String          username;
   public KeyBundle       syncKeyBundle;
 
   public CollectionKeys  collectionKeys;
   public InfoCollections infoCollections;
   public MetaGlobal      metaGlobal;
   public String          password;
   public String          syncID;
 
+  // Fields that maintain a reference to a SharedPreferences instance, used for
+  // persistence.
+  // Behavior is undefined if the PrefsSource is switched out in flight.
+  public String          prefsPath;
+  public PrefsSource     prefsSource;
 
-  public SyncConfiguration() {
+  /**
+   * Create a new SyncConfiguration instance. Pass in a PrefsSource to
+   * provide access to preferences.
+   *
+   * @param prefsPath
+   * @param context
+   */
+  public SyncConfiguration(String prefsPath, PrefsSource prefsSource) {
+    this.prefsPath   = prefsPath;
+    this.prefsSource = prefsSource;
+    this.loadFromPrefs(getPrefs());
+  }
+
+  public SharedPreferences getPrefs() {
+    Log.d(LOG_TAG, "Returning prefs for " + prefsPath);
+    return prefsSource.getPrefs(prefsPath, Utils.SHARED_PREFERENCES_MODE);
+  }
+
+  /**
+   * Return a convenient accessor for part of prefs.
+   * @param prefix
+   * @return
+   */
+  public ConfigurationBranch getBranch(String prefix) {
+    return new ConfigurationBranch(this, prefix);
+  }
+
+  public void loadFromPrefs(SharedPreferences prefs) {
+
+    if (prefs.contains("clusterURL")) {
+      String u = prefs.getString("clusterURL", null);
+      try {
+        clusterURL = new URI(u);
+        Log.i(LOG_TAG, "Set clusterURL from bundle: " + u);
+      } catch (URISyntaxException e) {
+        Log.w(LOG_TAG, "Ignoring bundle clusterURL (" + u + "): invalid URI.", e);
+      }
+    }
+    if (prefs.contains("syncID")) {
+      syncID = prefs.getString("syncID", null);
+      Log.i(LOG_TAG, "Set syncID from bundle: " + syncID);
+    }
+    // TODO: MetaGlobal, password, infoCollections, collectionKeys.
+  }
+
+  public void persistToPrefs() {
+    this.persistToPrefs(this.getPrefs());
+  }
+
+  public void persistToPrefs(SharedPreferences prefs) {
+    Editor edit = prefs.edit();
+    if (clusterURL == null) {
+      edit.remove("clusterURL");
+    } else {
+      edit.putString("clusterURL", clusterURL.toASCIIString());
+    }
+    if (syncID != null) {
+      edit.putString("syncID", syncID);
+    }
+    edit.commit();
+    // TODO: keys.
   }
 
   @Override
   public String credentials() {
     return username + ":" + password;
   }
 
   @Override
@@ -131,26 +352,62 @@ public class SyncConfiguration implement
   public URI wboURI(String collection, String id) throws URISyntaxException {
     return new URI(storageURL(true) + 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 setAndPersistClusterURL(URI u, SharedPreferences prefs) {
+    boolean shouldPersist = (prefs != null) && (clusterURL == null);
+
+    Log.d(LOG_TAG, "Setting cluster URL to " + u.toASCIIString() +
+                   (shouldPersist ? ". Persisting." : ". Not persisting."));
+    clusterURL = u;
+    if (shouldPersist) {
+      Editor edit = prefs.edit();
+      edit.putString("clusterURL", clusterURL.toASCIIString());
+      edit.commit();
+    }
+  }
+
   public void setClusterURL(URI u) {
+    setClusterURL(u, this.getPrefs());
+  }
+
+  public void setClusterURL(URI u, SharedPreferences prefs) {
     if (u == null) {
       Log.w(LOG_TAG, "Refusing to set cluster URL to null.");
       return;
     }
     URI uri = u.normalize();
     if (uri.toASCIIString().endsWith("/")) {
-      this.clusterURL = u;
+      setAndPersistClusterURL(u, prefs);
       return;
     }
-    this.clusterURL = uri.resolve("/");
-    Log.i(LOG_TAG, "Set cluster URL to " + this.clusterURL.toASCIIString() + ", given input " + u.toASCIIString());
+    setAndPersistClusterURL(uri.resolve("/"), prefs);
+    Log.i(LOG_TAG, "Set cluster URL to " + clusterURL.toASCIIString() + ", given input " + u.toASCIIString());
   }
 
   public void setClusterURL(String url) throws URISyntaxException {
     this.setClusterURL(new URI(url));
   }
+
+  /**
+   * Used for direct management of related prefs.
+   * @return
+   */
+  public Editor getEditor() {
+    return this.getPrefs().edit();
+  }
 }
--- a/mobile/android/base/sync/SynchronizerConfiguration.java
+++ b/mobile/android/base/sync/SynchronizerConfiguration.java
@@ -32,30 +32,78 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync;
 
+import java.io.IOException;
+
+import org.json.simple.parser.ParseException;
+import org.mozilla.gecko.sync.SyncConfiguration.ConfigurationBranch;
 import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
 
+import android.content.SharedPreferences.Editor;
+import android.util.Log;
+
 public class SynchronizerConfiguration {
+  private static final String LOG_TAG = "SynchronizerConfiguration";
 
   public String syncID;
   public RepositorySessionBundle remoteBundle;
   public RepositorySessionBundle localBundle;
 
+  public SynchronizerConfiguration(ConfigurationBranch config) throws NonObjectJSONException, IOException, ParseException {
+    this.load(config);
+  }
+
   public SynchronizerConfiguration(String syncID, RepositorySessionBundle remoteBundle, RepositorySessionBundle localBundle) {
     this.syncID       = syncID;
     this.remoteBundle = remoteBundle;
     this.localBundle  = localBundle;
   }
 
   public String[] toStringValues() {
     String[] out = new String[3];
     out[0] = syncID;
     out[1] = remoteBundle.toJSONString();
     out[2] = localBundle.toJSONString();
     return out;
   }
+
+  // This should get partly shuffled back into SyncConfiguration, I think.
+  public void load(ConfigurationBranch config) throws NonObjectJSONException, IOException, ParseException {
+    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;
+    Log.i(LOG_TAG, "Initialized SynchronizerConfiguration. syncID: " + syncID + ", remoteBundle: " + remoteBundle + ", localBundle: " + localBundle);
+  }
+
+  public void persist(ConfigurationBranch 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();
+  }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/UnexpectedJSONException.java
@@ -0,0 +1,47 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.sync;
+
+public class UnexpectedJSONException extends Exception {
+  private static final long serialVersionUID = 4797570033096443169L;
+
+  public Object obj;
+  public UnexpectedJSONException(Object object) {
+    obj = object;
+  }
+}
--- a/mobile/android/base/sync/Utils.java
+++ b/mobile/android/base/sync/Utils.java
@@ -33,26 +33,224 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync;
 
+import java.io.UnsupportedEncodingException;
+import java.math.BigDecimal;
+import java.security.NoSuchAlgorithmException;
+import java.util.HashMap;
 import java.util.Random;
 
+import org.mozilla.apache.commons.codec.binary.Base32;
 import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.crypto.Cryptographer;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
 
 public class Utils {
 
+  private static final String LOG_TAG = "Utils";
+
+  // See <http://developer.android.com/reference/android/content/Context.html#getSharedPreferences%28java.lang.String,%20int%29>
+  public static final int SHARED_PREFERENCES_MODE = 0;
+
+
+  // We don't really have a trace logger, so use this to toggle
+  // some debug logging.
+  // This is awful. I'm so sorry.
+  public static boolean ENABLE_TRACE_LOGGING = true;
+
+  // If true, log to System.out as well as using Android's Log.* calls.
+  public static boolean LOG_TO_STDOUT = false;
+  public static void logToStdout(String... s) {
+    if (LOG_TO_STDOUT) {
+      for (String string : s) {
+        System.out.print(string);
+      }
+      System.out.println("");
+    }
+  }
+
   public static String generateGuid() {
     byte[] encodedBytes = Base64.encodeBase64(generateRandomBytes(9), false);
     return new String(encodedBytes).replace("+", "-").replace("/", "_");
   }
 
   private static byte[] generateRandomBytes(int length) {
     byte[] bytes = new byte[length];
     Random random = new Random(System.nanoTime());
     random.nextBytes(bytes);
     return bytes;
   }
+
+  /*
+   * Helper to convert Byte Array to a Hex String
+   * Input: byte[] array
+   * Output: Hex string
+   */
+  public static String byte2hex(byte[] b) {
+  
+      // String Buffer can be used instead
+      String hs = "";
+      String stmp = "";
+  
+      for (int n = 0; n < b.length; n++) {
+          stmp = (java.lang.Integer.toHexString(b[n] & 0XFF));
+  
+          if (stmp.length() == 1) {
+              hs = hs + "0" + stmp;
+          } else {
+              hs = hs + stmp;
+          }
+  
+          if (n < b.length - 1) {
+              hs = hs + "";
+          }
+      }
+  
+      return hs;
+  }
+
+  /*
+   * Helper for array concatenation.
+   * Input: At least two byte[]
+   * Output: A concatenated version of them
+   */
+  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"));
+  }
+
+  /*
+   * Decode a friendly base32 string.
+   */
+  public static byte[] decodeFriendlyBase32(String base32) {
+      Base32 converter = new Base32();
+      return converter.decode(base32.replace('8', 'l').replace('9', 'o')
+              .toUpperCase());
+  }
+
+  /*
+   * Helper to convert Hex String to Byte Array
+   * Input: Hex string
+   * Output: byte[] version of hex string
+   */
+  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 new BigDecimal(ms).movePointLeft(3).toString();
+  }
+
+  // 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 (long)(decimal * 1000);
+  }
+
+
+  public static String getPrefsPath(String username, String serverURL)
+    throws NoSuchAlgorithmException, UnsupportedEncodingException {
+    return "sync.prefs." + Cryptographer.sha1Base32(serverURL + ":" + username);
+  }
+
+  public static SharedPreferences getSharedPreferences(Context context, String username, String serverURL) throws NoSuchAlgorithmException, UnsupportedEncodingException {
+    String prefsPath = getPrefsPath(username, serverURL);
+    Log.d(LOG_TAG, "Shared preferences: " + prefsPath);
+    return context.getSharedPreferences(prefsPath, SHARED_PREFERENCES_MODE);
+  }
+
+  /**
+   * Populate null slots in the provided array from keys in the provided Map.
+   * Set values in the map to be the new indices.
+   *
+   * @param dest
+   * @param source
+   * @throws Exception
+   */
+  public static void fillArraySpaces(String[] dest, HashMap<String, Long> source) throws Exception {
+    int i = 0;
+    int c = dest.length;
+    int needed = source.size();
+    if (needed == 0) {
+      return;
+    }
+    if (needed > c) {
+      throw new Exception("Need " + needed + " array spaces, have no more than " + c);
+    }
+    for (String key : source.keySet()) {
+      while (i < c) {
+        if (dest[i] == null) {
+          // Great!
+          dest[i] = key;
+          source.put(key, (long) i);
+          break;
+        }
+        ++i;
+      }
+    }
+    if (i >= c) {
+      throw new Exception("Could not fill array spaces.");
+    }
+  }
 }
--- a/mobile/android/base/sync/crypto/Cryptographer.java
+++ b/mobile/android/base/sync/crypto/Cryptographer.java
@@ -50,16 +50,17 @@ import javax.crypto.IllegalBlockSizeExce
 import javax.crypto.KeyGenerator;
 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.Base32;
 import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.Utils;
 
 /*
  * Implements the basic required cryptography options.
  */
 public class Cryptographer {
 
   private static final String TRANSFORMATION     = "AES/CBC/PKCS5Padding";
   private static final String KEY_ALGORITHM_SPEC = "AES";
--- a/mobile/android/base/sync/crypto/HKDF.java
+++ b/mobile/android/base/sync/crypto/HKDF.java
@@ -35,21 +35,22 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.crypto;
 
 import java.security.InvalidKeyException;
 import java.security.Key;
 import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
 
 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 {
 
@@ -91,17 +92,19 @@ public class HKDF {
 
         int iterations = (int) Math.ceil(((double)len) / ((double)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);
         }
 
-        return Arrays.copyOfRange(T, 0, len);
+        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) {
--- a/mobile/android/base/sync/crypto/KeyBundle.java
+++ b/mobile/android/base/sync/crypto/KeyBundle.java
@@ -39,16 +39,17 @@
 package org.mozilla.gecko.sync.crypto;
 
 import java.io.UnsupportedEncodingException;
 import java.security.NoSuchAlgorithmException;
 
 import javax.crypto.Mac;
 
 import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.Utils;
 
 public class KeyBundle {
 
     private byte[] encryptionKey;
     private byte[] hmacKey;
 
     // These are the same for every sync key bundle.
     private static final byte[] EMPTY_BYTES      = {};
deleted file mode 100644
--- a/mobile/android/base/sync/crypto/Utils.java
+++ /dev/null
@@ -1,140 +0,0 @@
-/* ***** BEGIN LICENSE BLOCK *****
- * Version: MPL 1.1/GPL 2.0/LGPL 2.1
- *
- * The contents of this file are subject to the Mozilla Public License Version
- * 1.1 (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.mozilla.org/MPL/
- *
- * Software distributed under the License is distributed on an "AS IS" basis,
- * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- * for the specific language governing rights and limitations under the
- * License.
- *
- * The Original Code is Android Sync Client.
- *
- * The Initial Developer of the Original Code is
- * the Mozilla Foundation.
- * Portions created by the Initial Developer are Copyright (C) 2011
- * the Initial Developer. All Rights Reserved.
- *
- * Contributor(s):
- * Jason Voll
- *
- * Alternatively, the contents of this file may be used under the terms of
- * either the GNU General Public License Version 2 or later (the "GPL"), or
- * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
- * in which case the provisions of the GPL or the LGPL are applicable instead
- * of those above. If you wish to allow use of your version of this file only
- * under the terms of either the GPL or the LGPL, and not to allow others to
- * use your version of this file under the terms of the MPL, indicate your
- * decision by deleting the provisions above and replace them with the notice
- * and other provisions required by the GPL or the LGPL. If you do not delete
- * the provisions above, a recipient may use your version of this file under
- * the terms of any one of the MPL, the GPL or the LGPL.
- *
- * ***** END LICENSE BLOCK ***** */
-
-package org.mozilla.gecko.sync.crypto;
-
-import java.io.UnsupportedEncodingException;
-import java.util.Arrays;
-
-import org.mozilla.apache.commons.codec.binary.Base32;
-import org.mozilla.apache.commons.codec.binary.Base64;
-
-public class Utils {
-
-    /*
-     * Helper to convert Hex String to Byte Array
-     * Input: Hex string
-     * Output: byte[] version of hex string
-     */
-    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;
-    }
-
-    /*
-     * Helper to convert Byte Array to a Hex String
-     * Input: byte[] array
-     * Output: Hex string
-     */
-    public static String byte2hex(byte[] b) {
-
-        // String Buffer can be used instead
-        String hs = "";
-        String stmp = "";
-
-        for (int n = 0; n < b.length; n++) {
-            stmp = (java.lang.Integer.toHexString(b[n] & 0XFF));
-
-            if (stmp.length() == 1) {
-                hs = hs + "0" + stmp;
-            } else {
-                hs = hs + stmp;
-            }
-
-            if (n < b.length - 1) {
-                hs = hs + "";
-            }
-        }
-
-        return hs;
-    }
-
-    /*
-     * Helper for array concatenation.
-     * Input: At least two byte[]
-     * Output: A concatenated version of them
-     */
-    public static byte[] concatAll(byte[] first, byte[]... rest) {
-        int totalLength = first.length;
-        for (byte[] array : rest) {
-            totalLength += array.length;
-        }
-
-        byte[] result = Arrays.copyOf(first, totalLength);
-        int offset = first.length;
-
-        for (byte[] array : rest) {
-            System.arraycopy(array, 0, result, offset, array.length);
-            offset += array.length;
-        }
-        return result;
-    }
-
-    /*
-     * Decode a friendly base32 string.
-     */
-    public static byte[] decodeFriendlyBase32(String base32) {
-        Base32 converter = new Base32();
-        return converter.decode(base32.replace('8', 'l').replace('9', 'o')
-                .toUpperCase());
-    }
-
-    /**
-     * 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"));
-    }
-}
--- a/mobile/android/base/sync/cryptographer/SyncCryptographer.java
+++ b/mobile/android/base/sync/cryptographer/SyncCryptographer.java
@@ -41,26 +41,26 @@ package org.mozilla.gecko.sync.cryptogra
 import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.io.StringReader;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
 
-import org.mozilla.apache.commons.codec.binary.Base64;
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.json.simple.parser.JSONParser;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.CryptoException;
 import org.mozilla.gecko.sync.crypto.CryptoInfo;
 import org.mozilla.gecko.sync.crypto.Cryptographer;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
-import org.mozilla.gecko.sync.crypto.Utils;
-import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.cryptographer.CryptoStatusBundle.CryptoStatus;
 
 /*
  * This class acts as a wrapper for the Cryptographer class.
  * The goal here is to take care of all JSON parsing and BaseXX
  * encoding/decoding so that the Cryptographer class doesn't need
  * to know anything about the format of the original data. It also
  * enables classes to get crypto services based on a WBO, rather than
--- a/mobile/android/base/sync/delegates/GlobalSessionCallback.java
+++ b/mobile/android/base/sync/delegates/GlobalSessionCallback.java
@@ -35,14 +35,23 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.delegates;
 
 import org.mozilla.gecko.sync.GlobalSession;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
 
+public interface GlobalSessionCallback {
 
-public interface GlobalSessionCallback {
+  /**
+   * Request that no further syncs occur within the next `backoff` milliseconds.
+   * @param backoff a duration in milliseconds.
+   */
+  void requestBackoff(long backoff);
+
+  void handleAborted(GlobalSession globalSession, String reason);
   void handleError(GlobalSession globalSession, Exception ex);
   void handleSuccess(GlobalSession globalSession);
   void handleStageCompleted(Stage currentState, GlobalSession globalSession);
+
+  boolean shouldBackOff();
 }
--- a/mobile/android/base/sync/delegates/MetaGlobalDelegate.java
+++ b/mobile/android/base/sync/delegates/MetaGlobalDelegate.java
@@ -36,14 +36,14 @@
  * ***** END LICENSE BLOCK ***** */
 
 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);
-  public void handleMissing(MetaGlobal global);
+  public void handleSuccess(MetaGlobal global, SyncStorageResponse response);
+  public void handleMissing(MetaGlobal global, SyncStorageResponse response);
   public void handleFailure(SyncStorageResponse response);
   public void handleError(Exception e);
   public MetaGlobalDelegate deferred();
 }
--- a/mobile/android/base/sync/jpake/JPakeClient.java
+++ b/mobile/android/base/sync/jpake/JPakeClient.java
@@ -43,29 +43,29 @@ import java.io.StringReader;
 import java.io.UnsupportedEncodingException;
 import java.math.BigInteger;
 import java.net.URISyntaxException;
 import java.security.GeneralSecurityException;
 import java.util.Random;
 import java.util.Timer;
 import java.util.TimerTask;
 
-import org.mozilla.apache.commons.codec.binary.Base64;
 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.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.ThreadPool;
+import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.CryptoException;
 import org.mozilla.gecko.sync.crypto.CryptoInfo;
 import org.mozilla.gecko.sync.crypto.Cryptographer;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.crypto.NoKeyBundleException;
-import org.mozilla.gecko.sync.crypto.Utils;
-import org.mozilla.gecko.sync.ExtendedJSONObject;
-import org.mozilla.gecko.sync.NonObjectJSONException;
-import org.mozilla.gecko.sync.ThreadPool;
 import org.mozilla.gecko.sync.net.ResourceDelegate;
 import org.mozilla.gecko.sync.net.SyncResourceDelegate;
 import org.mozilla.gecko.sync.setup.Constants;
 import org.mozilla.gecko.sync.setup.activities.SetupSyncActivity;
 
 import android.util.Log;
 import ch.boye.httpclientandroidlib.Header;
 import ch.boye.httpclientandroidlib.HttpResponse;
@@ -73,17 +73,17 @@ import ch.boye.httpclientandroidlib.clie
 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
 import ch.boye.httpclientandroidlib.entity.StringEntity;
 import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
 import ch.boye.httpclientandroidlib.message.BasicHeader;
 
 public class JPakeClient implements JPakeRequestDelegate {
   private static String       LOG_TAG                 = "JPakeClient";
 
-  // J-Pake constants.
+  // J-PAKE constants.
   private final static int    REQUEST_TIMEOUT         = 60 * 1000;         // 1
                                                                             // minute
   private final static int    SOCKET_TIMEOUT          = 5 * 60 * 1000;     // 5
                                                                             // minutes
   private final static int    KEYEXCHANGE_VERSION     = 3;
 
   private final static String JPAKE_SIGNERID_SENDER   = "sender";
   private final static String JPAKE_SIGNERID_RECEIVER = "receiver";
@@ -93,116 +93,84 @@ public class JPakeClient implements JPak
 
   private final static int    MAX_TRIES               = 10;
   private final static int    MAX_TRIES_FIRST_MSG     = 300;
   private final static int    MAX_TRIES_LAST_MSG      = 300;
 
   // UI Controller.
   private SetupSyncActivity   ssActivity;
 
-  // J-Pake session values.
+  // J-PAKE session values.
   private String              clientId;
   private String              secret;
 
   private String              myEtag;
   private String              mySignerId;
   private String              theirEtag;
   private String              theirSignerId;
+  private String              jpakeServer;
 
-  // J-Pake state.
+  // J-PAKE state.
+  private boolean             pairWithPin;
+  private boolean             paired                  = false;
   private boolean             finished                = false;
   private State               state                   = State.GET_CHANNEL;
-  private State               nextPhase;
+  private State               stateContext;
   private String              error;
   private int                 pollTries               = 0;
-  private boolean             paired                  = false;
 
-  private String              jpakeServer;
+  // J-PAKE values.
   private int                 jpakePollInterval;
   private int                 jpakeMaxTries;
   private String              channel;
   private String              channelUrl;
 
-  // J-Pake delayed-task scheduler. Used for timing.
+  // J-PAKE delayed-task scheduler. Used for timing.
   private Timer               timerScheduler;
 
-  // J-Pake session data.
+  // J-PAKE session data.
   private KeyBundle           myKeyBundle;
 
   private ExtendedJSONObject  jOutgoing;
   private ExtendedJSONObject  jIncoming;
 
-  private JSONObject          newData;
-  private String              jOutData;
-
   private JPakeParty          jParty;
   private JPakeNumGenerator   numGen;
 
   public JPakeClient(SetupSyncActivity activity) {
     ssActivity = activity;
 
     // Set JPAKE params from prefs
     // TODO: remove hardcoding
     jpakeServer = "https://setup.services.mozilla.com/";
     jpakePollInterval = 1 * 1000; // 1 second
+    jpakeMaxTries = MAX_TRIES;
+
+    if (!jpakeServer.endsWith("/")) {
+      jpakeServer += "/";
+    }
 
     timerScheduler = new Timer();
 
     setClientId();
   }
 
-  private static void runOnThread(Runnable run) {
-    ThreadPool.run(run);
-  }
-
-  public enum State {
-    GET_CHANNEL, STEP_ONE_GET, STEP_TWO_GET, PUT, ABORT, ENCRYPT_PUT, REPORT_FAILURE, KEY_VERIFY;
-  }
-
-  public class GetStepTimerTask extends TimerTask {
-
-    private JPakeRequest request;
-
-    public GetStepTimerTask(JPakeRequest request) {
-      this.request = request;
-    }
-
-    @Override
-    public void run() {
-      request.get();
-    }
-
-  }
-  /*
-   * Helper method to schedule a GET request with some delay.
-   */
-
-  private void scheduleGetRequest(int delay) {
-    JPakeRequest getRequest = null;
-    try {
-      getRequest = new JPakeRequest(channelUrl, makeRequestResourceDelegate());
-    } catch (URISyntaxException e) {
-      e.printStackTrace();
-      abort(Constants.JPAKE_ERROR_CHANNEL);
-      return;
-    }
-
-    GetStepTimerTask getStepTimerTask = new GetStepTimerTask(getRequest);
-    timerScheduler.schedule(getStepTimerTask, delay);
-  }
-
   /**
+   * (Receiver Only)
+   *
    * Initiate pairing and receive data without providing a PIN. The PIN will be
    * generated and passed on to the controller to be displayed to the user.
    *
    * Starts JPAKE protocol.
    */
   public void receiveNoPin() {
     mySignerId = JPAKE_SIGNERID_RECEIVER;
     theirSignerId = JPAKE_SIGNERID_SENDER;
+    pairWithPin = false;
+
     // TODO: fetch from prefs
     jpakeMaxTries = MAX_TRIES_FIRST_MSG;
 
     jParty = new JPakeParty(mySignerId);
     numGen = new JPakeNumGeneratorRandom();
 
     final JPakeClient self = this;
     runOnThread(new Runnable() {
@@ -210,16 +178,821 @@ public class JPakeClient implements JPak
       public void run() {
         self.createSecret();
         self.getChannel();
       }
     });
   }
 
   /**
+   * (Sender Only)
+   *
+   * Pairing using PIN provided on other device. Functionality available only
+   * when a Sync account has already been set up.
+   *
+   * @param pin
+   *          12-character string containing PIN entered by the user.
+   * @param expectDelay
+   *          Flag that indicates that a significant delay between pairing and
+   *          sending shoudl be expected. v2 and earlier of the protocol did not
+   *          allow for this, and the pairing to a v2 or earlier client will be
+   *          aborted if this flag is 'true'
+   */
+  public void pairWithPin(String pin, boolean expectDelay) {
+    mySignerId = JPAKE_SIGNERID_SENDER;
+    theirSignerId = JPAKE_SIGNERID_RECEIVER;
+    pairWithPin = true;
+
+    // Extract secret and server channel.
+    secret = pin.substring(0, JPAKE_LENGTH_SECRET);
+    channel = pin.substring(JPAKE_LENGTH_SECRET);
+    channelUrl = jpakeServer + channel;
+
+    jParty = new JPakeParty(mySignerId);
+    numGen = new JPakeNumGeneratorRandom();
+
+    final JPakeClient self = this;
+    runOnThread(new Runnable() {
+      @Override
+      public void run() {
+        // Make initial GET request for first round data.
+        self.state = State.SNDR_STEP_ZERO;
+        scheduleGetRequest(jpakePollInterval);
+      }
+    });
+  }
+
+  private static void runOnThread(Runnable run) {
+    ThreadPool.run(run);
+  }
+
+  public enum State {
+    GET_CHANNEL, SNDR_STEP_ZERO, SNDR_STEP_ONE, SNDR_STEP_TWO, RCVR_STEP_ONE, RCVR_STEP_TWO, PUT, ABORT, ENCRYPT_PUT, REPORT_FAILURE, VERIFY_KEY, VERIFY_PAIRING;
+  }
+
+  /* Main functionality Steps */
+
+  /*
+   * (Receiver Only) Request channel for J-PAKE from server.
+   */
+  private void getChannel() {
+    Log.d(LOG_TAG, "Getting channel.");
+    if (finished)
+      return;
+
+    JPakeRequest channelRequest = null;
+    try {
+      channelRequest = new JPakeRequest(jpakeServer + "new_channel",
+          makeRequestResourceDelegate());
+    } catch (URISyntaxException e) {
+      e.printStackTrace();
+      abort(Constants.JPAKE_ERROR_CHANNEL);
+      return;
+    }
+    channelRequest.get();
+  }
+
+  /*
+   * Helper for sending a PUT request to server, with data taken from shared
+   * jOutgoing JSONObject.
+   */
+  private void putStep() {
+    Log.d(LOG_TAG, "Uploading message.");
+    runOnThread(new Runnable() {
+      @Override
+      public void run() {
+        JPakeRequest putRequest = null;
+        try {
+          putRequest = new JPakeRequest(channelUrl,
+              makeRequestResourceDelegate());
+        } catch (URISyntaxException e) {
+          e.printStackTrace();
+          abort(Constants.JPAKE_ERROR_CHANNEL);
+          return;
+        }
+        try {
+          putRequest.put(jsonEntity(jOutgoing.object));
+        } catch (UnsupportedEncodingException e) {
+          e.printStackTrace();
+        }
+        Log.d(LOG_TAG, "outgoing: " + jOutgoing.toJSONString());
+      }
+    });
+  }
+
+  /*
+   * Step One of J-PAKE protocol.
+   */
+  private void computeStepOne() {
+    Log.d(LOG_TAG, "Computing round 1.");
+
+    JPakeCrypto.round1(jParty, numGen);
+
+    // Set outgoing message.
+    ExtendedJSONObject jOne = new ExtendedJSONObject();
+    jOne.put(Constants.ZKP_KEY_GX1,
+        BigIntegerHelper.toEvenLengthHex(jParty.gx1));
+    jOne.put(Constants.ZKP_KEY_GX2,
+        BigIntegerHelper.toEvenLengthHex(jParty.gx2));
+
+    Zkp zkp1 = jParty.zkp1;
+    Zkp zkp2 = jParty.zkp2;
+    ExtendedJSONObject jZkp1 = makeJZkp(zkp1.gr, zkp1.b, mySignerId);
+    ExtendedJSONObject jZkp2 = makeJZkp(zkp2.gr, zkp2.b, mySignerId);
+
+    jOne.put(Constants.ZKP_KEY_ZKP_X1, jZkp1);
+    jOne.put(Constants.ZKP_KEY_ZKP_X2, jZkp2);
+
+    jOutgoing = new ExtendedJSONObject();
+    jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "1");
+    jOutgoing.put(Constants.JSON_KEY_PAYLOAD, jOne);
+    jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
+    Log.d(LOG_TAG, "Sending: " + jOutgoing.toJSONString());
+
+    // Store context to determine next step after PUT request.
+    stateContext = pairWithPin ? State.SNDR_STEP_ONE : State.RCVR_STEP_ONE;
+    state = State.PUT;
+    putStep();
+  }
+
+  /*
+   * Step Two of J-PAKE protocol.
+   *
+   * Verifies message computed by other party in their Step One. Creates Step
+   * Two message to be sent.
+   */
+  private void computeStepTwo() {
+    Log.d(LOG_TAG, "Computing round 2.");
+
+    // Check incoming message sender.
+    if (!jIncoming.get(Constants.JSON_KEY_TYPE).equals(theirSignerId + "1")) {
+      Log.e(LOG_TAG, "Invalid round 1 message: " + jIncoming.toJSONString());
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+      return;
+    }
+
+    // Check incoming message fields.
+    ExtendedJSONObject iPayload = null;
+    try {
+      iPayload = jIncoming.getObject(Constants.JSON_KEY_PAYLOAD);
+      if (iPayload == null
+          || iPayload.getObject(Constants.ZKP_KEY_ZKP_X1) == null
+          || !theirSignerId.equals(iPayload.getObject(Constants.ZKP_KEY_ZKP_X1)
+              .get(Constants.ZKP_KEY_ID))
+          || iPayload.getObject(Constants.ZKP_KEY_ZKP_X2) == null
+          || !theirSignerId.equals(iPayload.getObject(Constants.ZKP_KEY_ZKP_X2)
+              .get(Constants.ZKP_KEY_ID))) {
+        Log.e(LOG_TAG, "Invalid round 1 message: " + jIncoming.toJSONString());
+        abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+        return;
+      }
+    } catch (NonObjectJSONException e) {
+      e.printStackTrace();
+    }
+
+    // Extract message fields.
+    jParty.gx3 = new BigInteger((String) iPayload.get(Constants.ZKP_KEY_GX1),
+        16);
+    jParty.gx4 = new BigInteger((String) iPayload.get(Constants.ZKP_KEY_GX2),
+        16);
+
+    ExtendedJSONObject zkpPayload3 = null;
+    ExtendedJSONObject zkpPayload4 = null;
+    try {
+      zkpPayload3 = iPayload.getObject(Constants.ZKP_KEY_ZKP_X1);
+      zkpPayload4 = iPayload.getObject(Constants.ZKP_KEY_ZKP_X2);
+      if (zkpPayload3 == null || zkpPayload4 == null) {
+        Log.e(LOG_TAG, "Invalid round 1 zkpPayload message");
+        abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+        return;
+      }
+    } catch (NonObjectJSONException e) {
+      e.printStackTrace();
+    }
+
+    // Extract ZKPs.
+    String zkp3_gr = (String) zkpPayload3.get(Constants.ZKP_KEY_GR);
+    String zkp3_b = (String) zkpPayload3.get(Constants.ZKP_KEY_B);
+    String zkp3_id = (String) zkpPayload3.get(Constants.ZKP_KEY_ID);
+
+    String zkp4_gr = (String) zkpPayload4.get(Constants.ZKP_KEY_GR);
+    String zkp4_b = (String) zkpPayload4.get(Constants.ZKP_KEY_B);
+    String zkp4_id = (String) zkpPayload4.get(Constants.ZKP_KEY_ID);
+
+    jParty.zkp3 = new Zkp(new BigInteger(zkp3_gr, 16), new BigInteger(zkp3_b,
+        16), zkp3_id);
+    jParty.zkp4 = new Zkp(new BigInteger(zkp4_gr, 16), new BigInteger(zkp4_b,
+        16), zkp4_id);
+
+    // Jpake round 2
+    try {
+      JPakeCrypto.round2(secret, jParty, numGen);
+    } catch (Gx4IsOneException e) {
+      Log.e(LOG_TAG, "gx4 cannot equal 1.");
+      abort(Constants.JPAKE_ERROR_INTERNAL);
+    } catch (IncorrectZkpException e) {
+      Log.e(LOG_TAG, "ZKP mismatch");
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+    }
+
+    // Make outgoing payload.
+    Zkp zkpA = jParty.thisZkpA;
+    ExtendedJSONObject oPayload = new ExtendedJSONObject();
+    ExtendedJSONObject jZkpA = makeJZkp(zkpA.gr, zkpA.b, zkpA.id);
+    oPayload.put(Constants.ZKP_KEY_A,
+        BigIntegerHelper.toEvenLengthHex(jParty.thisA));
+    oPayload.put(Constants.ZKP_KEY_ZKP_A, jZkpA);
+
+    // Make outgoing message.
+    jOutgoing = new ExtendedJSONObject();
+    jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "2");
+    jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
+    jOutgoing.put(Constants.JSON_KEY_PAYLOAD, oPayload);
+
+    // Different behavior depending on sender or receiver.
+    if (pairWithPin) {
+      state = State.SNDR_STEP_TWO;
+      stateContext = State.PUT;
+      scheduleGetRequest(jpakePollInterval);
+    } else {
+      stateContext = State.RCVR_STEP_TWO;
+      state = State.PUT;
+      putStep();
+    }
+  }
+
+  /*
+   * Final Step of J-PAKE protocol.
+   *
+   * Verifies message computed by other party in Step Two. Creates or fetches
+   * encrypted message for verification of successful key exchange.
+   */
+  private void computeFinal() {
+    Log.d(LOG_TAG, "Computing final round.");
+    // Check incoming message type.
+    if (!jIncoming.get(Constants.JSON_KEY_TYPE).equals(theirSignerId + "2")) {
+      Log.e(LOG_TAG, "Invalid round 2 message: " + jIncoming.toJSONString());
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+      return;
+    }
+
+    // Check incoming message fields.
+    ExtendedJSONObject iPayload = null;
+    try {
+      iPayload = jIncoming.getObject(Constants.JSON_KEY_PAYLOAD);
+      if (iPayload == null
+          || iPayload.getObject(Constants.ZKP_KEY_ZKP_A) == null
+          || !theirSignerId.equals(iPayload.getObject(Constants.ZKP_KEY_ZKP_A)
+              .get(Constants.ZKP_KEY_ID))) {
+        Log.e(LOG_TAG, "Invalid round 2 message: " + jIncoming.toJSONString());
+        abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+        return;
+      }
+    } catch (NonObjectJSONException e) {
+      e.printStackTrace();
+    }
+    // Extract fields.
+    jParty.otherA = new BigInteger((String) iPayload.get(Constants.ZKP_KEY_A),
+        16);
+
+    ExtendedJSONObject zkpPayload = null;
+    try {
+      zkpPayload = iPayload.getObject(Constants.ZKP_KEY_ZKP_A);
+    } catch (NonObjectJSONException e) {
+      e.printStackTrace();
+    }
+    // Extract ZKP.
+    String gr = (String) zkpPayload.get(Constants.ZKP_KEY_GR);
+    String b = (String) zkpPayload.get(Constants.ZKP_KEY_B);
+    String id = (String) zkpPayload.get(Constants.ZKP_KEY_ID);
+
+    jParty.otherZkpA = new Zkp(new BigInteger(gr, 16), new BigInteger(b, 16),
+        id);
+
+    myKeyBundle = null;
+    try {
+      myKeyBundle = JPakeCrypto.finalRound(secret, jParty);
+    } catch (IncorrectZkpException e) {
+      Log.e(LOG_TAG, "ZKP mismatch");
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+      e.printStackTrace();
+    }
+
+    if (pairWithPin) { // Wait for other device to send verification of keys.
+      Log.d(LOG_TAG, "get: verifyPairing");
+      this.state = State.VERIFY_PAIRING;
+      scheduleGetRequest(jpakePollInterval);
+    } else { // Prepare and send verification of keys.
+      jOutgoing = computeKeyVerification(myKeyBundle);
+      stateContext = State.VERIFY_KEY;
+      this.state = State.PUT;
+      putStep();
+    }
+  }
+
+  /*
+   * (Receiver Only) Helper method to compute verification message from JPake
+   * key bundle, to be sent to other device for verification.
+   */
+  public ExtendedJSONObject computeKeyVerification(KeyBundle keyBundle) {
+    Log.d(LOG_TAG, "Encrypting key verification value.");
+    // KeyBundle not null
+    ExtendedJSONObject jPayload = null;
+    try {
+      jPayload = encryptPayload(JPAKE_VERIFY_VALUE, keyBundle);
+    } catch (UnsupportedEncodingException e) {
+      Log.e(LOG_TAG, "Failed to encrypt key verification value.", e);
+      abort(Constants.JPAKE_ERROR_INTERNAL);
+    } catch (CryptoException e) {
+      Log.e(LOG_TAG, "Failed to encrypt key verification value.", e);
+      abort(Constants.JPAKE_ERROR_INTERNAL);
+    }
+    Log.d(
+        LOG_TAG,
+        "enc key64: "
+            + new String(Base64.encodeBase64(keyBundle.getEncryptionKey())));
+    Log.e(LOG_TAG,
+        "hmac64: " + new String(Base64.encodeBase64(keyBundle.getHMACKey())));
+
+    ExtendedJSONObject result = new ExtendedJSONObject();
+    result.put(Constants.JSON_KEY_TYPE, mySignerId + "3");
+    result.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
+    result.put(Constants.JSON_KEY_PAYLOAD, jPayload.object);
+    return result;
+  }
+
+  /*
+   * (Sender Only) Helper method to check the verification message sent by the
+   * other device against self-derived key.
+   */
+  private boolean verifyPairing(ExtendedJSONObject verificationObject,
+      KeyBundle keyBundle) throws CryptoException, IOException, ParseException,
+      NonObjectJSONException {
+    if (!verificationObject.get(Constants.JSON_KEY_TYPE).equals(
+        theirSignerId + "3")) {
+      Log.e(LOG_TAG,
+          "Invalid round 3 message: " + verificationObject.toJSONString());
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+      return false;
+    }
+    ExtendedJSONObject payload = verificationObject
+        .getObject(Constants.JSON_KEY_PAYLOAD);
+    String theirCiphertext = (String) payload
+        .get(Constants.JSON_KEY_CIPHERTEXT);
+    String iv = (String) payload.get(Constants.JSON_KEY_IV);
+    boolean correctPairing = verifyCiphertext(theirCiphertext, iv, keyBundle);
+    return correctPairing;
+  }
+
+  /*
+   * Helper function to verify an incoming ciphertext and IV against derived
+   * keyBundle.
+   */
+  public boolean verifyCiphertext(String theirCiphertext, String iv,
+      KeyBundle keyBundle) throws UnsupportedEncodingException, CryptoException {
+    byte[] cleartextBytes = JPAKE_VERIFY_VALUE.getBytes("UTF-8");
+    CryptoInfo info = new CryptoInfo(cleartextBytes, keyBundle);
+    info.setIV(Base64.decodeBase64(iv));
+
+    Cryptographer.encrypt(info);
+    String myCiphertext = new String(Base64.encodeBase64(info.getMessage()));
+    return myCiphertext.equals(theirCiphertext);
+  }
+
+  /*
+   * (Sender Only)
+   *
+   * Called from controller, with Sync credentials to be encrypted and sent.
+   */
+  public void sendAndComplete(JSONObject jObj)
+      throws JPakeNoActivePairingException {
+    if (paired && !finished) {
+      String outData = jObj.toJSONString();
+      state = State.ENCRYPT_PUT;
+      encryptData(myKeyBundle, outData);
+      putStep();
+    } else {
+      Log.e(LOG_TAG, "Can't send data, no active pairing!");
+      throw new JPakeNoActivePairingException();
+    }
+  }
+
+  /*
+   * (Sender Only)
+   *
+   * Encrypt payload and package into jOutgoing for sending with a PUT request.
+   *
+   * @param keyBundle Encryption keys derived during J-PAKE.
+   * @param payload   Credentials data to be encrypted.
+   */
+  private void encryptData(KeyBundle keyBundle, String payload) {
+    Log.d(LOG_TAG, "Encrypting data.");
+    ExtendedJSONObject jPayload = null;
+    try {
+      jPayload = encryptPayload(payload, keyBundle);
+    } catch (UnsupportedEncodingException e) {
+      Log.e(LOG_TAG, "Failed to encrypt data.", e);
+      abort(Constants.JPAKE_ERROR_INTERNAL);
+    } catch (CryptoException e) {
+      Log.e(LOG_TAG, "Failed to encrypt data.", e);
+      abort(Constants.JPAKE_ERROR_INTERNAL);
+    }
+    jOutgoing = new ExtendedJSONObject();
+    jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "3");
+    jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
+    jOutgoing.put(Constants.JSON_KEY_PAYLOAD, jPayload.object);
+  }
+
+  /*
+   * (Receiver Only)
+   *
+   * Decrypt jIncoming message from other device and extract credentials to be stored.
+   *
+   * @param keyBundle
+   */
+  private void decryptData(KeyBundle keyBundle) {
+    Log.d(LOG_TAG, "Verifying their key");
+    if (!(theirSignerId + "3").equals((String) jIncoming
+        .get(Constants.JSON_KEY_TYPE))) {
+      try {
+        Log.e(LOG_TAG, "Invalid round 3 data: " + jsonEntity(jIncoming.object));
+      } catch (UnsupportedEncodingException e) {
+        e.printStackTrace();
+      }
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+    }
+
+    // Decrypt payload and verify HMAC.
+    ExtendedJSONObject iPayload = null;
+    try {
+      iPayload = jIncoming.getObject(Constants.JSON_KEY_PAYLOAD);
+    } catch (NonObjectJSONException e1) {
+      Log.e(LOG_TAG, "Invalid round 3 data.", e1);
+      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
+    }
+    Log.d(LOG_TAG, "Decrypting data.");
+    String cleartext = null;
+    try {
+      cleartext = new String(decryptPayload(iPayload, keyBundle), "UTF-8");
+    } catch (UnsupportedEncodingException e1) {
+      Log.e(LOG_TAG, "Failed to decrypt data.", e1);
+      abort(Constants.JPAKE_ERROR_INTERNAL);
+    } catch (CryptoException e1) {
+      Log.e(LOG_TAG, "Failed to decrypt data.", e1);
+      abort(Constants.JPAKE_ERROR_KEYMISMATCH);
+    }
+    JSONObject jCreds = null;
+    try {
+      jCreds = getJSONObject(cleartext);
+    } catch (Exception e) {
+      Log.e(LOG_TAG, "Invalid data: " + cleartext);
+      abort(Constants.JPAKE_ERROR_INVALID);
+      return;
+    }
+    complete(jCreds);
+  }
+
+  /*
+   * Complete Sync setup and return credentials to controller to be stored, if receiver.
+   *
+   * @param jCreds Credentials to be stored by controller. May be null if device is sender.
+   */
+  private void complete(JSONObject jCreds) {
+    Log.d(LOG_TAG, "Exchange complete.");
+    finished = true;
+    ssActivity.onComplete(jCreds);
+  }
+
+  /* JpakeRequestDelegate methods */
+  @Override
+  public void onRequestFailure(HttpResponse res) {
+    JPakeResponse response = new JPakeResponse(res);
+    switch (this.state) {
+    case GET_CHANNEL:
+      Log.e(LOG_TAG, "getChannel failure: " + response.getStatusCode());
+      abort(Constants.JPAKE_ERROR_CHANNEL);
+      break;
+    case VERIFY_PAIRING:
+    case VERIFY_KEY:
+    case SNDR_STEP_ZERO:
+    case SNDR_STEP_ONE:
+    case SNDR_STEP_TWO:
+    case RCVR_STEP_ONE:
+    case RCVR_STEP_TWO:
+      int statusCode = response.getStatusCode();
+      switch (statusCode) {
+      case 304:
+        Log.d(LOG_TAG, "Channel hasn't been updated yet. Will try again later");
+        if (pollTries >= jpakeMaxTries) {
+          Log.e(LOG_TAG, "Tried for " + pollTries + " times, maxTries " + jpakeMaxTries + ", aborting");
+          abort(Constants.JPAKE_ERROR_TIMEOUT);
+          return;
+        }
+        pollTries += 1;
+        if (!finished) {
+          scheduleGetRequest(jpakePollInterval);
+        }
+        return;
+      case 404:
+        Log.e(LOG_TAG, "No data found in channel.");
+        abort(Constants.JPAKE_ERROR_NODATA);
+        break;
+      case 412: // "Precondition failed"
+        Log.d(LOG_TAG, "Message already replaced on server by other party.");
+        onRequestSuccess(res);
+        break;
+      default:
+        Log.e(LOG_TAG, "Could not retrieve data. Server responded with HTTP "
+            + statusCode);
+        abort(Constants.JPAKE_ERROR_SERVER);
+        return;
+      }
+      pollTries = 0;
+      break;
+    case PUT:
+      Log.e(LOG_TAG, "Could not upload data. Server responded with HTTP "
+          + response.getStatusCode());
+      abort(Constants.JPAKE_ERROR_SERVER);
+      break;
+    case ABORT:
+      Log.e(LOG_TAG, "Abort: request failure.");
+      break;
+    case REPORT_FAILURE:
+      Log.e(LOG_TAG, "ReportFailure: failure. Server responded with HTTP "
+          + response.getStatusCode());
+      break;
+    default:
+      Log.e(LOG_TAG, "Unhandled request failure " + response.getStatusCode());
+    }
+  }
+
+  @Override
+  public void onRequestError(Exception e) {
+    abort(Constants.JPAKE_ERROR_NETWORK);
+  }
+
+  @Override
+  public void onRequestSuccess(HttpResponse res) {
+    if (finished)
+      return;
+    JPakeResponse response = new JPakeResponse(res);
+    Header[] etagHeaders;
+    switch (this.state) {
+    case GET_CHANNEL:
+      Object body = null;
+      try {
+        body = response.jsonBody();
+      } catch (IllegalStateException e1) {
+        e1.printStackTrace();
+      } catch (IOException e1) {
+        e1.printStackTrace();
+      } catch (ParseException e1) {
+        abort(Constants.JPAKE_ERROR_CHANNEL);
+      }
+      String channel = body instanceof String ? (String) body : null;
+      if (channel == null) { // should be string
+        abort(Constants.JPAKE_ERROR_CHANNEL);
+        return;
+      }
+      channelUrl = jpakeServer + channel;
+      Log.d(LOG_TAG, "using channel " + channel);
+
+      ssActivity.displayPin(secret + channel);
+
+      // Set up next step.
+      this.state = State.RCVR_STEP_ONE;
+      computeStepOne();
+      break;
+
+    // Results from GET request. Continue flow depending on case.
+    case RCVR_STEP_ONE:
+      ssActivity.onPairingStart();
+      jpakeMaxTries = MAX_TRIES;
+      // fall through
+    case RCVR_STEP_TWO:
+    case SNDR_STEP_ZERO:
+    case SNDR_STEP_ONE:
+    case SNDR_STEP_TWO:
+    case VERIFY_KEY:
+    case VERIFY_PAIRING:
+      etagHeaders = response.httpResponse().getHeaders("etag");
+      if (etagHeaders == null) {
+        try {
+          Log.e(LOG_TAG,
+              "Server did not supply ETag for message: " + response.body());
+          abort(Constants.JPAKE_ERROR_SERVER);
+        } catch (IllegalStateException e) {
+          e.printStackTrace();
+        } catch (IOException e) {
+          e.printStackTrace();
+        }
+        return;
+      }
+
+      theirEtag = etagHeaders[0].toString();
+      try {
+        jIncoming = response.jsonObjectBody();
+      } catch (IllegalStateException e) {
+        e.printStackTrace();
+      } catch (IOException e) {
+        e.printStackTrace();
+      } catch (ParseException e) {
+        abort(Constants.JPAKE_ERROR_INVALID);
+        return;
+      } catch (NonObjectJSONException e) {
+        abort(Constants.JPAKE_ERROR_INVALID);
+        return;
+      }
+      Log.d(LOG_TAG, "incoming message: " + jIncoming.toJSONString());
+
+      if (this.state == State.SNDR_STEP_ZERO) {
+        computeStepOne();
+      } else if (this.state == State.RCVR_STEP_ONE
+          || this.state == State.SNDR_STEP_ONE) {
+        computeStepTwo();
+      } else if (this.state == State.SNDR_STEP_TWO) {
+        stateContext = state;
+        state = State.PUT;
+        putStep();
+      } else if (this.state == State.RCVR_STEP_TWO) {
+        computeFinal();
+      } else if (this.state == State.VERIFY_KEY) {
+        decryptData(myKeyBundle);
+      } else if (this.state == State.VERIFY_PAIRING) {
+        try {
+          if (verifyPairing(jIncoming, myKeyBundle)) {
+            paired = true;
+            ssActivity.onPaired();
+          } else {
+            abort(Constants.JPAKE_ERROR_KEYMISMATCH);
+          }
+        } catch (NonObjectJSONException e) {
+          e.printStackTrace();
+        } catch (CryptoException e) {
+          e.printStackTrace();
+        } catch (IOException e) {
+          e.printStackTrace();
+        } catch (ParseException e) {
+          e.printStackTrace();
+        }
+      }
+      break;
+
+    case PUT:
+      etagHeaders = response.httpResponse().getHeaders("etag");
+      myEtag = response.httpResponse().getHeaders("etag")[0].getValue();
+
+      state = stateContext;
+      if (state == State.VERIFY_KEY) {
+        jpakeMaxTries = MAX_TRIES_LAST_MSG;
+        ssActivity.onPaired();
+      }
+      if (state == State.SNDR_STEP_ONE) {
+        computeStepTwo();
+        return; // No need to wait for response from PUT request.
+      }
+      if (state == State.SNDR_STEP_TWO) {
+        computeFinal();
+        return; // No need to wait for response from PUT request.
+      }
+
+      // Pause twice the poll interval.
+      scheduleGetRequest(2 * jpakePollInterval);
+      Log.i(LOG_TAG, "scheduling 2xPollInterval for " + state.name());
+      break;
+
+    case ENCRYPT_PUT:
+      complete(null); // No need to pass along credentials, they are already
+                      // stored on device.
+      break;
+
+    case ABORT:
+      Log.e(LOG_TAG, "Key exchange successfully aborted.");
+      break;
+    default:
+      Log.e(LOG_TAG, "Unhandled response success.");
+    }
+  }
+
+  /* ResourceDelegate that handles Resource responses */
+  public ResourceDelegate makeRequestResourceDelegate() {
+    return new JpakeRequestResourceDelegate(this);
+  }
+
+  public class JpakeRequestResourceDelegate implements ResourceDelegate {
+
+    private JPakeRequestDelegate requestDelegate;
+
+    public JpakeRequestResourceDelegate(JPakeRequestDelegate delegate) {
+      this.requestDelegate = delegate;
+    }
+
+    @Override
+    public String getCredentials() {
+      // Jpake setup has no credentials
+      return null;
+    }
+
+    @Override
+    public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+      request.setHeader(new BasicHeader("X-KeyExchange-Id", clientId));
+
+      switch (state) {
+      case REPORT_FAILURE:
+        // optional: set report cid to delete channel
+
+      case ABORT:
+        request.setHeader(new BasicHeader("X-KeyExchange-Cid", channel));
+        break;
+
+      case PUT:
+        if (myEtag == null && state == State.RCVR_STEP_ONE) {
+          request.setHeader(new BasicHeader("If-None-Match", "*"));
+        }
+        // fall through
+      case VERIFY_KEY:
+      case VERIFY_PAIRING:
+      case RCVR_STEP_ONE:
+      case RCVR_STEP_TWO:
+        if (myEtag != null) {
+          request.setHeader(new BasicHeader("If-None-Match", myEtag));
+        }
+        break;
+      }
+    }
+
+    @Override
+    public void handleHttpResponse(HttpResponse response) {
+      // TODO: maybe use of http requests is wasteful?
+      if (isSuccess(response)) {
+        this.requestDelegate.onRequestSuccess(response);
+      } else {
+        this.requestDelegate.onRequestFailure(response);
+      }
+      SyncResourceDelegate.consumeEntity(response.getEntity());
+    }
+
+    @Override
+    public void handleHttpProtocolException(ClientProtocolException e) {
+      Log.e(LOG_TAG, "Got HTTP protocol exception.", e);
+      this.requestDelegate.onRequestError(e);
+    }
+
+    @Override
+    public void handleTransportException(GeneralSecurityException e) {
+      Log.e(LOG_TAG, "Got HTTP transport exception.", e);
+      this.requestDelegate.onRequestError(e);
+    }
+
+    @Override
+    public void handleHttpIOException(IOException e) {
+      // TODO: abort or pass on.
+      Log.e(LOG_TAG, "HttpIOException", e);
+      switch (state) {
+      case GET_CHANNEL:
+        Log.e(LOG_TAG, "Failed on GetChannel.", e);
+        break;
+
+      case RCVR_STEP_ONE:
+      case RCVR_STEP_TWO:
+        Log.e(LOG_TAG, "Failed on GET", e);
+        break;
+      case PUT:
+        Log.e(LOG_TAG, "Failed on PUT.", e);
+        break;
+
+      case REPORT_FAILURE:
+        Log.e(LOG_TAG, "Report failed: " + error);
+        break;
+      default:
+        Log.e(LOG_TAG, "Unhandled HTTP I/O exception.", e);
+      }
+      abort(Constants.JPAKE_ERROR_NETWORK);
+    }
+
+    @Override
+    public int connectionTimeout() {
+      return REQUEST_TIMEOUT;
+    }
+
+    @Override
+    public int socketTimeout() {
+      return SOCKET_TIMEOUT;
+    }
+
+    private int getStatusCode(HttpResponse response) {
+      return response.getStatusLine().getStatusCode();
+    }
+
+    private boolean isSuccess(HttpResponse response) {
+      return getStatusCode(response) == 200;
+    }
+  }
+
+  /**
    * Abort the current pairing. The channel on the server will be deleted if the
    * abort wasn't due to a network or server error. The controller's 'onAbort()'
    * method is notified in all cases.
    *
    * @param error
    *          [can be null] Error constant indicating the reason for the abort.
    *          Defaults to user abort
    */
@@ -292,37 +1065,70 @@ public class JPakeClient implements JPak
   private void createSecret() {
     // 0-9a-z without 1,l,o,0
     String key = "23456789abcdefghijkmnpqrstuvwxyz";
     int keylen = key.length();
 
     byte[] rBytes = generateRandomBytes(JPAKE_LENGTH_SECRET);
     StringBuilder secret = new StringBuilder();
     for (byte b : rBytes) {
-      secret.append(key.charAt((int) (Math.abs(b) * keylen / 256)));
+      secret.append(key.charAt(Math.abs(b) * keylen / 256));
     }
     this.secret = secret.toString();
   }
 
   /*
    * Helper for turning a JSON object into a payload.
    *
-   * @param body
-   *
-   * @return
-   *
+   * @param body JSONObject body to be converted to StringEntity.
+   * @return StringEntity representation of JSONObject.
    * @throws UnsupportedEncodingException
    */
   protected StringEntity jsonEntity(JSONObject body)
       throws UnsupportedEncodingException {
     StringEntity e = new StringEntity(body.toJSONString(), "UTF-8");
     e.setContentType("application/json");
     return e;
   }
 
+  /**
+   * TimerTask for use with delayed GET requests.
+   *
+   */
+  public class GetStepTimerTask extends TimerTask {
+    private JPakeRequest request;
+
+    public GetStepTimerTask(JPakeRequest request) {
+      this.request = request;
+    }
+
+    @Override
+    public void run() {
+      request.get();
+    }
+  }
+
+  /*
+   * Helper method to schedule a GET request with some delay.
+   */
+
+  private void scheduleGetRequest(int delay) {
+    JPakeRequest getRequest = null;
+    try {
+      getRequest = new JPakeRequest(channelUrl, makeRequestResourceDelegate());
+    } catch (URISyntaxException e) {
+      e.printStackTrace();
+      abort(Constants.JPAKE_ERROR_CHANNEL);
+      return;
+    }
+
+    GetStepTimerTask getStepTimerTask = new GetStepTimerTask(getRequest);
+    timerScheduler.schedule(getStepTimerTask, delay);
+  }
+
   /*
    * Helper method to get a JSONObject from a String. Input: String containing
    * JSON. Output: Extracted JSONObject. Throws: Exception if JSON is invalid.
    */
   private JSONObject getJSONObject(String jsonString) throws Exception {
     Reader in = new StringReader(jsonString);
     try {
       return (JSONObject) new JSONParser().parse(in);
@@ -359,18 +1165,17 @@ public class JPakeClient implements JPak
    *
    * Input: JSONObject containing a valid payload (cipherText, IV, HMAC),
    * KeyBundle with keys for decryption. Output: byte[] clearText
    *
    * @throws CryptoException
    * @throws UnsupportedEncodingException
    */
   public byte[] decryptPayload(ExtendedJSONObject payload, KeyBundle keybundle)
-                                                                               throws CryptoException,
-                                                                               UnsupportedEncodingException {
+      throws CryptoException, UnsupportedEncodingException {
     byte[] ciphertext = Utils.decodeBase64((String) payload
         .get(Constants.JSON_KEY_CIPHERTEXT));
     byte[] iv = Utils.decodeBase64((String) payload.get(Constants.JSON_KEY_IV));
     byte[] hmac = Utils.hex2Byte((String) payload.get(Constants.JSON_KEY_HMAC));
     byte[] plainbytes = Cryptographer.decrypt(new CryptoInfo(ciphertext, iv,
         hmac, keybundle));
     return plainbytes;
   }
@@ -400,657 +1205,9 @@ public class JPakeClient implements JPak
     payload.put(Constants.JSON_KEY_CIPHERTEXT, message);
     payload.put(Constants.JSON_KEY_IV, iv);
     if (this.state == State.ENCRYPT_PUT) {
       String hmac = Utils.byte2hex(info.getHMAC());
       payload.put(Constants.JSON_KEY_HMAC, hmac);
     }
     return payload;
   }
-
-  /* Main functionality Steps */
-  private void getChannel() {
-    Log.d(LOG_TAG, "Getting channel.");
-    if (finished)
-      return;
-
-    JPakeRequest channelRequest = null;
-    try {
-      channelRequest = new JPakeRequest(jpakeServer + "new_channel",
-          makeRequestResourceDelegate());
-    } catch (URISyntaxException e) {
-      e.printStackTrace();
-      abort(Constants.JPAKE_ERROR_CHANNEL);
-      return;
-    }
-    channelRequest.get();
-  }
-
-  private void putStep() {
-    Log.d(LOG_TAG, "Uploading message.");
-    runOnThread(new Runnable() {
-      @Override
-      public void run() {
-        JPakeRequest putRequest = null;
-        try {
-          putRequest = new JPakeRequest(channelUrl,
-              makeRequestResourceDelegate());
-        } catch (URISyntaxException e) {
-          e.printStackTrace();
-          abort(Constants.JPAKE_ERROR_CHANNEL);
-          return;
-        }
-        try {
-          putRequest.put(jsonEntity(jOutgoing.object));
-        } catch (UnsupportedEncodingException e) {
-          e.printStackTrace();
-        }
-        Log.d(LOG_TAG, "outgoing: " + jOutgoing.toJSONString());
-      }
-    });
-  }
-
-  private void computeStepOne() {
-    Log.d(LOG_TAG, "Computing round 1.");
-
-    JPakeCrypto.round1(jParty, numGen);
-
-    // Set outgoing message.
-    ExtendedJSONObject jOne = new ExtendedJSONObject();
-    jOne.put(Constants.ZKP_KEY_GX1,
-        BigIntegerHelper.toEvenLengthHex(jParty.gx1));
-    jOne.put(Constants.ZKP_KEY_GX2,
-        BigIntegerHelper.toEvenLengthHex(jParty.gx2));
-
-    Zkp zkp1 = jParty.zkp1;
-    Zkp zkp2 = jParty.zkp2;
-    ExtendedJSONObject jZkp1 = makeJZkp(zkp1.gr, zkp1.b, mySignerId);
-    ExtendedJSONObject jZkp2 = makeJZkp(zkp2.gr, zkp2.b, mySignerId);
-
-    jOne.put(Constants.ZKP_KEY_ZKP_X1, jZkp1);
-    jOne.put(Constants.ZKP_KEY_ZKP_X2, jZkp2);
-
-    jOutgoing = new ExtendedJSONObject();
-    jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "1");
-    jOutgoing.put(Constants.JSON_KEY_PAYLOAD, jOne);
-    jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
-    Log.d(LOG_TAG, "Sending: " + jOutgoing.toJSONString());
-
-    nextPhase = State.STEP_ONE_GET;
-    state = State.PUT;
-    putStep();
-  }
-
-  private void computeStepTwo() {
-    Log.d(LOG_TAG, "Computing round 2.");
-
-    // Check incoming message sender.
-    if (!jIncoming.get(Constants.JSON_KEY_TYPE).equals(theirSignerId + "1")) {
-      Log.e(LOG_TAG, "Invalid round 1 message: " + jIncoming.toJSONString());
-      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-      return;
-    }
-
-    // Check incoming message fields.
-    ExtendedJSONObject iPayload = null;
-    try {
-      iPayload = jIncoming.getObject(Constants.JSON_KEY_PAYLOAD);
-      if (iPayload == null
-          || iPayload.getObject(Constants.ZKP_KEY_ZKP_X1) == null
-          || !theirSignerId.equals(iPayload.getObject(Constants.ZKP_KEY_ZKP_X1)
-              .get(Constants.ZKP_KEY_ID))
-          || iPayload.getObject(Constants.ZKP_KEY_ZKP_X2) == null
-          || !theirSignerId.equals(iPayload.getObject(Constants.ZKP_KEY_ZKP_X2)
-              .get(Constants.ZKP_KEY_ID))) {
-        Log.e(LOG_TAG, "Invalid round 1 message: " + jIncoming.toJSONString());
-        abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-        return;
-      }
-    } catch (NonObjectJSONException e) {
-      e.printStackTrace();
-    }
-
-    // Extract message fields.
-    jParty.gx3 = new BigInteger((String) iPayload.get(Constants.ZKP_KEY_GX1),
-        16);
-    jParty.gx4 = new BigInteger((String) iPayload.get(Constants.ZKP_KEY_GX2),
-        16);
-
-    ExtendedJSONObject zkpPayload3 = null;
-    ExtendedJSONObject zkpPayload4 = null;
-    try {
-      zkpPayload3 = iPayload.getObject(Constants.ZKP_KEY_ZKP_X1);
-      zkpPayload4 = iPayload.getObject(Constants.ZKP_KEY_ZKP_X2);
-      if (zkpPayload3 == null || zkpPayload4 == null) {
-        Log.e(LOG_TAG, "Invalid round 1 zkpPayload message");
-        abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-        return;
-      }
-    } catch (NonObjectJSONException e) {
-      e.printStackTrace();
-    }
-
-    // Extract ZKPs.
-    String zkp3_gr = (String) zkpPayload3.get(Constants.ZKP_KEY_GR);
-    String zkp3_b = (String) zkpPayload3.get(Constants.ZKP_KEY_B);
-    String zkp3_id = (String) zkpPayload3.get(Constants.ZKP_KEY_ID);
-
-    String zkp4_gr = (String) zkpPayload4.get(Constants.ZKP_KEY_GR);
-    String zkp4_b = (String) zkpPayload4.get(Constants.ZKP_KEY_B);
-    String zkp4_id = (String) zkpPayload4.get(Constants.ZKP_KEY_ID);
-
-    jParty.zkp3 = new Zkp(new BigInteger(zkp3_gr, 16), new BigInteger(zkp3_b,
-        16), zkp3_id);
-    jParty.zkp4 = new Zkp(new BigInteger(zkp4_gr, 16), new BigInteger(zkp4_b,
-        16), zkp4_id);
-
-    // Jpake round 2
-    try {
-      JPakeCrypto.round2(secret, jParty, numGen);
-    } catch (Gx4IsOneException e) {
-      e.printStackTrace();
-    } catch (IncorrectZkpException e) {
-      Log.e(LOG_TAG, "ZKP mismatch");
-      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-    }
-
-    // Make outgoing payload.
-    Zkp zkpA = jParty.thisZkpA;
-    ExtendedJSONObject oPayload = new ExtendedJSONObject();
-    ExtendedJSONObject jZkpA = makeJZkp(zkpA.gr, zkpA.b, zkpA.id);
-    oPayload.put(Constants.ZKP_KEY_A,
-        BigIntegerHelper.toEvenLengthHex(jParty.thisA));
-    oPayload.put(Constants.ZKP_KEY_ZKP_A, jZkpA);
-
-    // Make outgoing message.
-    jOutgoing = new ExtendedJSONObject();
-    jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "2");
-    jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
-    jOutgoing.put(Constants.JSON_KEY_PAYLOAD, oPayload);
-
-    nextPhase = State.STEP_TWO_GET;
-    state = State.PUT;
-    putStep();
-  }
-
-  private void computeFinal() {
-    Log.d(LOG_TAG, "Computing final round.");
-    // Check incoming message type.
-    if (!jIncoming.get(Constants.JSON_KEY_TYPE).equals(theirSignerId + "2")) {
-      Log.e(LOG_TAG, "Invalid round 2 message: " + jIncoming.toJSONString());
-      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-      return;
-    }
-
-    // Check incoming message fields.
-    ExtendedJSONObject iPayload = null;
-    try {
-      iPayload = jIncoming.getObject(Constants.JSON_KEY_PAYLOAD);
-      if (iPayload == null
-          || iPayload.getObject(Constants.ZKP_KEY_ZKP_A) == null
-          || !theirSignerId.equals(iPayload.getObject(Constants.ZKP_KEY_ZKP_A)
-              .get(Constants.ZKP_KEY_ID))) {
-        Log.e(LOG_TAG, "Invalid round 2 message: " + jIncoming.toJSONString());
-        abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-        return;
-      }
-    } catch (NonObjectJSONException e) {
-      e.printStackTrace();
-    }
-    // Extract fields.
-    jParty.otherA = new BigInteger((String) iPayload.get(Constants.ZKP_KEY_A),
-        16);
-
-    ExtendedJSONObject zkpPayload = null;
-    try {
-      zkpPayload = iPayload.getObject(Constants.ZKP_KEY_ZKP_A);
-    } catch (NonObjectJSONException e) {
-      e.printStackTrace();
-    }
-    // Extract ZKP.
-    String gr = (String) zkpPayload.get(Constants.ZKP_KEY_GR);
-    String b = (String) zkpPayload.get(Constants.ZKP_KEY_B);
-    String id = (String) zkpPayload.get(Constants.ZKP_KEY_ID);
-
-    jParty.otherZkpA = new Zkp(new BigInteger(gr, 16), new BigInteger(b, 16),
-        id);
-
-    myKeyBundle = null;
-    try {
-      myKeyBundle = JPakeCrypto.finalRound(secret, jParty);
-    } catch (IncorrectZkpException e) {
-      Log.e(LOG_TAG, "ZKP mismatch");
-      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-      e.printStackTrace();
-    }
-    jOutgoing = computeKeyVerification(myKeyBundle);
-    nextPhase = State.KEY_VERIFY;
-    this.state = State.PUT;
-    putStep();
-  }
-
-  /*
-   * Helper method to compute verification message from JPake key bundle.
-   */
-  public ExtendedJSONObject computeKeyVerification(KeyBundle keyBundle) {
-    Log.d(LOG_TAG, "Encrypting key verification value.");
-    // KeyBundle not null
-    ExtendedJSONObject jPayload = null;
-    try {
-      jPayload = encryptPayload(JPAKE_VERIFY_VALUE, keyBundle);
-    } catch (UnsupportedEncodingException e) {
-      e.printStackTrace();
-    } catch (CryptoException e) {
-      e.printStackTrace();
-    }
-    Log.d(
-        LOG_TAG,
-        "enc key64: "
-            + new String(Base64.encodeBase64(keyBundle.getEncryptionKey())));
-    Log.e(LOG_TAG,
-        "hmac64: " + new String(Base64.encodeBase64(keyBundle.getHMACKey())));
-
-    ExtendedJSONObject result = new ExtendedJSONObject();
-    result.put(Constants.JSON_KEY_TYPE, mySignerId + "3");
-    result.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
-    result.put(Constants.JSON_KEY_PAYLOAD, jPayload.object);
-    return result;
-  }
-
-  /*
-   * (Unused) helper method to check the verification message sent by the other
-   * device against self-derived key.
-   *
-   * (Used when pairing through entering a PIN generated by another device.)
-   */
-  private boolean verifyPairing(ExtendedJSONObject verificationObject,
-      KeyBundle keyBundle) throws CryptoException, IOException, ParseException,
-      NonObjectJSONException {
-    if (!jIncoming.get(Constants.JSON_KEY_TYPE).equals(theirSignerId + "3")) {
-      Log.e(LOG_TAG,
-          "Invalid round 3 message: " + verificationObject.toJSONString());
-      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-      return false;
-    }
-    ExtendedJSONObject payload = verificationObject
-        .getObject(Constants.JSON_KEY_PAYLOAD);
-    String theirCiphertext = (String) payload
-        .get(Constants.JSON_KEY_CIPHERTEXT);
-    String iv = (String) payload.get(Constants.JSON_KEY_IV);
-    boolean correctPairing = verifyCiphertext(theirCiphertext, iv, keyBundle);
-    return correctPairing;
-  }
-
-  /*
-   * Helper function to verify an incoming ciphertext and IV against derived
-   * keyBundle.
-   *
-   * TODO: make less of a mess. (although not actually used except in testing
-   * right now.)
-   */
-  public boolean verifyCiphertext(String theirCiphertext, String iv,
-      KeyBundle keyBundle) throws UnsupportedEncodingException, CryptoException {
-    byte[] cleartextBytes = JPAKE_VERIFY_VALUE.getBytes("UTF-8");
-    CryptoInfo info = new CryptoInfo(cleartextBytes, keyBundle);
-    info.setIV(Base64.decodeBase64(iv));
-
-    Cryptographer.encrypt(info);
-    String myCiphertext = new String(Base64.encodeBase64(info.getMessage()));
-    return myCiphertext.equals(theirCiphertext);
-  }
-
-  /*
-   *
-   */
-  public void sendAndComplete(JSONObject jObj)
-      throws JPakeNoActivePairingException {
-    if (!paired || finished) {
-      Log.e(LOG_TAG, "Can't send data, no active pairing!");
-      throw new JPakeNoActivePairingException();
-    }
-
-    jOutData = jObj.toJSONString();
-    encryptData(myKeyBundle);
-  }
-
-  private void encryptData(KeyBundle keyBundle) {
-    Log.d(LOG_TAG, "Encrypting data.");
-    state = State.ENCRYPT_PUT;
-    ExtendedJSONObject jPayload = null;
-    try {
-      jPayload = encryptPayload(jOutData, keyBundle);
-    } catch (UnsupportedEncodingException e) {
-      e.printStackTrace();
-    } catch (CryptoException e) {
-      e.printStackTrace();
-    }
-    jOutgoing = new ExtendedJSONObject();
-    jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "3");
-    jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION);
-    jOutgoing.put(Constants.JSON_KEY_PAYLOAD, jPayload.object);
-
-    putStep();
-  }
-
-  private void decryptData(KeyBundle keyBundle) {
-    Log.d(LOG_TAG, "Verifying their key");
-    if (!(theirSignerId + "3").equals((String) jIncoming
-        .get(Constants.JSON_KEY_TYPE))) {
-      try {
-        Log.e(LOG_TAG, "Invalid round 3 data: " + jsonEntity(jIncoming.object));
-      } catch (UnsupportedEncodingException e) {
-        e.printStackTrace();
-      }
-      abort(Constants.JPAKE_ERROR_WRONGMESSAGE);
-    }
-
-    // Decrypt payload and verify HMAC.
-    ExtendedJSONObject iPayload = null;
-    try {
-      iPayload = jIncoming.getObject(Constants.JSON_KEY_PAYLOAD);
-    } catch (NonObjectJSONException e1) {
-      e1.printStackTrace();
-    }
-    Log.d(LOG_TAG, "Decrypting data.");
-    String cleartext = null;
-    try {
-      cleartext = new String(decryptPayload(iPayload, keyBundle), "UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      // TODO Auto-generated catch block
-      e1.printStackTrace();
-    } catch (CryptoException e1) {
-      // TODO Auto-generated catch block
-      e1.printStackTrace();
-    }
-    try {
-      newData = getJSONObject(cleartext);
-    } catch (Exception e) {
-      e.printStackTrace();
-      Log.e(LOG_TAG, "Invalid data: " + cleartext);
-      abort(Constants.JPAKE_ERROR_INVALID);
-      return;
-    }
-    complete();
-  }
-
-  private void complete() {
-    Log.d(LOG_TAG, "Exchange complete.");
-    finished = true;
-    ssActivity.onComplete(newData);
-  }
-
-  /* JpakeRequestDelegate methods */
-  @Override
-  public void onRequestFailure(HttpResponse res) {
-    JPakeResponse response = new JPakeResponse(res);
-    switch (this.state) {
-    case GET_CHANNEL:
-      Log.e(LOG_TAG, "getChannel failure: " + response.getStatusCode());
-      abort(Constants.JPAKE_ERROR_CHANNEL);
-      break;
-    case KEY_VERIFY:
-    case STEP_ONE_GET:
-    case STEP_TWO_GET:
-      int statusCode = response.getStatusCode();
-      switch (statusCode) {
-      case 304:
-        Log.d(LOG_TAG, "Channel hasn't been updated yet. Will try again later");
-        if (pollTries >= jpakeMaxTries) {
-          Log.e(LOG_TAG, "Tried for " + pollTries + " times, aborting");
-          abort(Constants.JPAKE_ERROR_TIMEOUT);
-          return;
-        }
-        pollTries += 1;
-        if (!finished) {
-          scheduleGetRequest(jpakePollInterval);
-        }
-        return;
-      case 404:
-        Log.e(LOG_TAG, "No data found in channel.");
-        abort(Constants.JPAKE_ERROR_NODATA);
-        break;
-      case 412: // "Precondition failed"
-        Log.d(LOG_TAG, "Message already replaced on server by other party.");
-        onRequestSuccess(res);
-        break;
-      default:
-        Log.e(LOG_TAG, "Could not retrieve data. Server responded with HTTP "
-            + statusCode);
-        abort(Constants.JPAKE_ERROR_SERVER);
-        return;
-      }
-      pollTries = 0;
-      break;
-    case PUT:
-      Log.e(LOG_TAG, "Could not upload data. Server responded with HTTP "
-          + response.getStatusCode());
-      abort(Constants.JPAKE_ERROR_SERVER);
-      break;
-    case ABORT:
-      Log.e(LOG_TAG, "Abort: request failure.");
-      break;
-    case REPORT_FAILURE:
-      Log.e(LOG_TAG, "ReportFailure: failure. Server responded with HTTP "
-          + response.getStatusCode());
-      break;
-    default:
-      Log.e(LOG_TAG, "Unhandled request failure " + response.getStatusCode());
-    }
-  }
-
-  @Override
-  public void onRequestError(Exception e) {
-    // TODO Auto-generated method stub
-  }
-
-  @Override
-  public void onRequestSuccess(HttpResponse res) {
-    if (finished)
-      return;
-    JPakeResponse response = new JPakeResponse(res);
-    Header[] etagHeaders;
-    switch (this.state) {
-    case GET_CHANNEL:
-      Object body = null;
-      try {
-        body = response.jsonBody();
-      } catch (IllegalStateException e1) {
-        e1.printStackTrace();
-      } catch (IOException e1) {
-        e1.printStackTrace();
-      } catch (ParseException e1) {
-        e1.printStackTrace();
-      }
-      String channel = body instanceof String ? (String) body : null;
-      if (channel == null) { // should be string
-        abort(Constants.JPAKE_ERROR_INVALID);
-        return;
-      }
-      channelUrl = jpakeServer + channel;
-      Log.d(LOG_TAG, "using channel " + channel);
-
-      ssActivity.displayPin(secret + channel);
-
-      // Set up next step.
-      this.state = State.STEP_ONE_GET;
-      computeStepOne();
-      break;
-
-    case STEP_ONE_GET:
-      ssActivity.onPairingStart();
-      jpakeMaxTries = MAX_TRIES;
-      // fall through
-    case KEY_VERIFY:
-      jpakeMaxTries = MAX_TRIES_LAST_MSG;
-    case STEP_TWO_GET:
-      etagHeaders = response.httpResponse().getHeaders("etag");
-      if (etagHeaders == null) {
-        try {
-          Log.e(LOG_TAG,
-              "Server did not supply ETag for message: " + response.body());
-          abort(Constants.JPAKE_ERROR_SERVER);
-        } catch (IllegalStateException e) {
-          e.printStackTrace();
-        } catch (IOException e) {
-          e.printStackTrace();
-        }
-        return;
-      }
-
-      theirEtag = etagHeaders[0].toString();
-      try {
-        jIncoming = response.jsonObjectBody();
-      } catch (IllegalStateException e) {
-        e.printStackTrace();
-      } catch (IOException e) {
-        e.printStackTrace();
-      } catch (ParseException e) {
-        e.printStackTrace();
-      } catch (NonObjectJSONException e) {
-        e.printStackTrace();
-      }
-      Log.d(LOG_TAG, "incoming message: " + jIncoming.toJSONString());
-      if (this.state == State.STEP_ONE_GET) {
-        computeStepTwo();
-      } else if (this.state == State.STEP_TWO_GET) {
-        computeFinal();
-      } else if (this.state == State.KEY_VERIFY) {
-        decryptData(myKeyBundle);
-      }
-      break;
-
-    case PUT:
-      etagHeaders = response.httpResponse().getHeaders("etag");
-      myEtag = response.httpResponse().getHeaders("etag")[0].getValue();
-
-      // Pause twice the poll interval.
-      state = nextPhase;
-      if (state == State.KEY_VERIFY) {
-        ssActivity.onPaired();
-      }
-      scheduleGetRequest(2 * jpakePollInterval);
-      Log.i(LOG_TAG, "scheduling 2xPollInterval for " + state.name());
-      break;
-
-    case ENCRYPT_PUT:
-      complete();
-      break;
-
-    case ABORT:
-      Log.e(LOG_TAG, "Key exchange successfully aborted.");
-      break;
-    default:
-      Log.e(LOG_TAG, "Unhandled response success.");
-    }
-  }
-
-  /* ResourceDelegate that handles Resource responses */
-  public ResourceDelegate makeRequestResourceDelegate() {
-    return new JpakeRequestResourceDelegate(this);
-  }
-
-  public class JpakeRequestResourceDelegate implements ResourceDelegate {
-
-    private JPakeRequestDelegate requestDelegate;
-
-    public JpakeRequestResourceDelegate(JPakeRequestDelegate delegate) {
-      this.requestDelegate = delegate;
-    }
-
-    @Override
-    public String getCredentials() {
-      // Jpake setup has no credentials
-      return null;
-    }
-
-    @Override
-    public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
-      request.setHeader(new BasicHeader("X-KeyExchange-Id", clientId));
-
-      switch (state) {
-      case REPORT_FAILURE:
-        // optional: set report cid to delete channel
-
-      case ABORT:
-        request.setHeader(new BasicHeader("X-KeyExchange-Cid", channel));
-        break;
-
-      case PUT:
-        if (myEtag == null) {
-          request.setHeader(new BasicHeader("If-None-Match", "*"));
-        }
-        // fall through
-      case KEY_VERIFY:
-      case STEP_ONE_GET:
-      case STEP_TWO_GET:
-        if (myEtag != null) {
-          request.setHeader(new BasicHeader("If-None-Match", myEtag));
-        }
-        break;
-      }
-    }
-
-    @Override
-    public void handleHttpResponse(HttpResponse response) {
-      // TODO: maybe use of http requests is wasteful?
-      if (isSuccess(response)) {
-        this.requestDelegate.onRequestSuccess(response);
-      } else {
-        this.requestDelegate.onRequestFailure(response);
-      }
-      SyncResourceDelegate.consumeEntity(response.getEntity());
-    }
-
-    @Override
-    public void handleHttpProtocolException(ClientProtocolException e) {
-      Log.e(LOG_TAG, "Got HTTP protocol exception.", e);
-      this.requestDelegate.onRequestError(e);
-    }
-
-    @Override
-    public void handleTransportException(GeneralSecurityException e) {
-      Log.e(LOG_TAG, "Got HTTP transport exception.", e);
-      this.requestDelegate.onRequestError(e);
-    }
-
-    @Override
-    public void handleHttpIOException(IOException e) {
-      // TODO: pass this on!
-      Log.e(LOG_TAG, "HttpIOException", e);
-      switch (state) {
-      case GET_CHANNEL:
-        Log.e(LOG_TAG, "Failed on GetChannel.", e);
-        break;
-
-      case STEP_ONE_GET:
-      case STEP_TWO_GET:
-        break;
-      case PUT:
-        break;
-
-      case REPORT_FAILURE:
-        Log.e(LOG_TAG, "Report failed: " + error);
-        break;
-
-      default:
-        Log.e(LOG_TAG, "Unhandled HTTP I/O exception.", e);
-      }
-    }
-
-    @Override
-    public int connectionTimeout() {
-      return REQUEST_TIMEOUT;
-    }
-
-    @Override
-    public int socketTimeout() {
-      return SOCKET_TIMEOUT;
-    }
-
-    private int getStatusCode(HttpResponse response) {
-      return response.getStatusLine().getStatusCode();
-    }
-
-    private boolean isSuccess(HttpResponse response) {
-      return getStatusCode(response) == 200;
-    }
-  }
 }
--- a/mobile/android/base/sync/jpake/JPakeCrypto.java
+++ b/mobile/android/base/sync/jpake/JPakeCrypto.java
@@ -14,17 +14,18 @@
  * The Original Code is Android Sync Client.
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
- *  Chenxia Liu <liuche@mozilla.com>
+ *   Chenxia Liu <liuche@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -47,59 +48,59 @@ import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
 
 import org.mozilla.gecko.sync.crypto.HKDF;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 
 import android.util.Log;
 
 public class JPakeCrypto {
-  private static final String    LOG_TAG = "JpakeCrypto";
+  private static final String LOG_TAG = "JPakeCrypto";
 
   /*
-   * Primes P and Q, and generator G - from original Mozilla jpake
+   * Primes P and Q, and generator G - from original Mozilla J-PAKE
    * implementation.
    */
-  public static final BigInteger P       = new BigInteger(
-                                             "90066455B5CFC38F9CAA4A48B4281F292C260FEEF01FD61037E56258A7795A1C"
-                                                 + "7AD46076982CE6BB956936C6AB4DCFE05E6784586940CA544B9B2140E1EB523F"
-                                                 + "009D20A7E7880E4E5BFA690F1B9004A27811CD9904AF70420EEFD6EA11EF7DA1"
-                                                 + "29F58835FF56B89FAA637BC9AC2EFAAB903402229F491D8D3485261CD068699B"
-                                                 + "6BA58A1DDBBEF6DB51E8FE34E8A78E542D7BA351C21EA8D8F1D29F5D5D159394"
-                                                 + "87E27F4416B0CA632C59EFD1B1EB66511A5A0FBF615B766C5862D0BD8A3FE7A0"
-                                                 + "E0DA0FB2FE1FCB19E8F9996A8EA0FCCDE538175238FC8B0EE6F29AF7F642773E"
-                                                 + "BE8CD5402415A01451A840476B2FCEB0E388D30D4B376C37FE401C2A2C2F941D"
-                                                 + "AD179C540C1C8CE030D460C4D983BE9AB0B20F69144C1AE13F9383EA1C08504F"
-                                                 + "B0BF321503EFE43488310DD8DC77EC5B8349B8BFE97C2C560EA878DE87C11E3D"
-                                                 + "597F1FEA742D73EEC7F37BE43949EF1A0D15C3F3E3FC0A8335617055AC91328E"
-                                                 + "C22B50FC15B941D3D1624CD88BC25F3E941FDDC6200689581BFEC416B4B2CB73",
-                                             16);
+  public static final BigInteger P = new BigInteger(
+      "90066455B5CFC38F9CAA4A48B4281F292C260FEEF01FD61037E56258A7795A1C" +
+      "7AD46076982CE6BB956936C6AB4DCFE05E6784586940CA544B9B2140E1EB523F" +
+      "009D20A7E7880E4E5BFA690F1B9004A27811CD9904AF70420EEFD6EA11EF7DA1" +
+      "29F58835FF56B89FAA637BC9AC2EFAAB903402229F491D8D3485261CD068699B" +
+      "6BA58A1DDBBEF6DB51E8FE34E8A78E542D7BA351C21EA8D8F1D29F5D5D159394" +
+      "87E27F4416B0CA632C59EFD1B1EB66511A5A0FBF615B766C5862D0BD8A3FE7A0" +
+      "E0DA0FB2FE1FCB19E8F9996A8EA0FCCDE538175238FC8B0EE6F29AF7F642773E" +
+      "BE8CD5402415A01451A840476B2FCEB0E388D30D4B376C37FE401C2A2C2F941D" +
+      "AD179C540C1C8CE030D460C4D983BE9AB0B20F69144C1AE13F9383EA1C08504F" +
+      "B0BF321503EFE43488310DD8DC77EC5B8349B8BFE97C2C560EA878DE87C11E3D" +
+      "597F1FEA742D73EEC7F37BE43949EF1A0D15C3F3E3FC0A8335617055AC91328E" +
+      "C22B50FC15B941D3D1624CD88BC25F3E941FDDC6200689581BFEC416B4B2CB73",
+      16);
 
-  public static final BigInteger Q       = new BigInteger(
-                                             "CFA0478A54717B08CE64805B76E5B14249A77A4838469DF7F7DC987EFCCFB11D",
-                                             16);
+  public static final BigInteger Q = new BigInteger(
+      "CFA0478A54717B08CE64805B76E5B14249A77A4838469DF7F7DC987EFCCFB11D",
+      16);
 
-  public static final BigInteger G       = new BigInteger(
-                                             "5E5CBA992E0A680D885EB903AEA78E4A45A469103D448EDE3B7ACCC54D521E37"
-                                                 + "F84A4BDD5B06B0970CC2D2BBB715F7B82846F9A0C393914C792E6A923E2117AB"
-                                                 + "805276A975AADB5261D91673EA9AAFFEECBFA6183DFCB5D3B7332AA19275AFA1"
-                                                 + "F8EC0B60FB6F66CC23AE4870791D5982AAD1AA9485FD8F4A60126FEB2CF05DB8"
-                                                 + "A7F0F09B3397F3937F2E90B9E5B9C9B6EFEF642BC48351C46FB171B9BFA9EF17"
-                                                 + "A961CE96C7E7A7CC3D3D03DFAD1078BA21DA425198F07D2481622BCE45969D9C"
-                                                 + "4D6063D72AB7A0F08B2F49A7CC6AF335E08C4720E31476B67299E231F8BD90B3"
-                                                 + "9AC3AE3BE0C6B6CACEF8289A2E2873D58E51E029CAFBD55E6841489AB66B5B4B"
-                                                 + "9BA6E2F784660896AFF387D92844CCB8B69475496DE19DA2E58259B090489AC8"
-                                                 + "E62363CDF82CFD8EF2A427ABCD65750B506F56DDE3B988567A88126B914D7828"
-                                                 + "E2B63A6D7ED0747EC59E0E0A23CE7D8A74C1D2C2A7AFB6A29799620F00E11C33"
-                                                 + "787F7DED3B30E1A22D09F1FBDA1ABBBFBF25CAE05A13F812E34563F99410E73B",
-                                             16);
+  public static final BigInteger G = new BigInteger(
+      "5E5CBA992E0A680D885EB903AEA78E4A45A469103D448EDE3B7ACCC54D521E37" +
+      "F84A4BDD5B06B0970CC2D2BBB715F7B82846F9A0C393914C792E6A923E2117AB" +
+      "805276A975AADB5261D91673EA9AAFFEECBFA6183DFCB5D3B7332AA19275AFA1" +
+      "F8EC0B60FB6F66CC23AE4870791D5982AAD1AA9485FD8F4A60126FEB2CF05DB8" +
+      "A7F0F09B3397F3937F2E90B9E5B9C9B6EFEF642BC48351C46FB171B9BFA9EF17" +
+      "A961CE96C7E7A7CC3D3D03DFAD1078BA21DA425198F07D2481622BCE45969D9C" +
+      "4D6063D72AB7A0F08B2F49A7CC6AF335E08C4720E31476B67299E231F8BD90B3" +
+      "9AC3AE3BE0C6B6CACEF8289A2E2873D58E51E029CAFBD55E6841489AB66B5B4B" +
+      "9BA6E2F784660896AFF387D92844CCB8B69475496DE19DA2E58259B090489AC8" +
+      "E62363CDF82CFD8EF2A427ABCD65750B506F56DDE3B988567A88126B914D7828" +
+      "E2B63A6D7ED0747EC59E0E0A23CE7D8A74C1D2C2A7AFB6A29799620F00E11C33" +
+      "787F7DED3B30E1A22D09F1FBDA1ABBBFBF25CAE05A13F812E34563F99410E73B",
+      16);
 
   /**
    *
-   * Round 1 of JPAKE protocol.
+   * Round 1 of J-PAKE protocol.
    * Generate x1, x2, and ZKP for other party.
    *
    * @param mySignerId
    * @param valuesOut
    */
   public static void round1(JPakeParty jp, JPakeNumGenerator gen) {
     // Randomly select x1 from [0,q), x2 from [1,q).
     BigInteger x1 = gen.generateFromRange(Q); // [0, q)
@@ -113,36 +114,36 @@ public class JPakeCrypto {
     jp.gx2 = gx2;
 
     // Generate and store zero knowledge proofs.
     jp.zkp1 = createZkp(G, x1, gx1, jp.signerId, gen);
     jp.zkp2 = createZkp(G, x2, gx2, jp.signerId, gen);
   }
 
   /**
-   * Round 2 of JPAKE protocol.
+   * Round 2 of J-PAKE protocol.
    * Generate A and ZKP for A.
    * Verify ZKP from other party. Does not check for replay ZKP.
    *
    * @param mySignerId
    * @param valuesOut
    * @param secret
    * @param gx3
    * @param gx4
    * @param zkp3
    * @param zkp4
    * @throws Gx4IsOneException
    * @throws IncorrectZkpException
    */
   public static void round2(String secret, JPakeParty jp,
       JPakeNumGenerator gen) throws Gx4IsOneException, IncorrectZkpException {
 
-    Log.d(LOG_TAG, "round2 started");
+    Log.d(LOG_TAG, "round2 started.");
 
-    if (jp.gx4 == BigInteger.ONE) {
+    if (BigInteger.ONE.compareTo(jp.gx4) == 0) {
       throw new Gx4IsOneException();
     }
 
     // Check ZKP.
     checkZkp(G, jp.gx3, jp.zkp3);
     checkZkp(G, jp.gx4, jp.zkp4);
 
     // Compute a = g^[(x1+x3+x4)*(x2*secret)].
@@ -154,57 +155,56 @@ public class JPakeCrypto {
       // TODO Auto-generated catch block
       e.printStackTrace();
     }
 
     BigInteger a  = y1.modPow(y2, P);
     jp.thisZkpA = createZkp(y1, y2, a, jp.signerId, gen);
     jp.thisA = a;
 
-    Log.d(LOG_TAG, "round2 finished");
+    Log.d(LOG_TAG, "round2 finished.");
   }
 
   /**
-   * Final round of JPAKE protocol.
+   * Final round of J-PAKE protocol.
    *
    * @param b
    * @param zkp
    * @param secret
    *
    * @return KeyBundle
    * @throws IncorrectZkpException
    */
   public static KeyBundle finalRound(String secret, JPakeParty jp)
       throws IncorrectZkpException {
-    Log.d(LOG_TAG, "final round started");
+    Log.d(LOG_TAG, "Final round started.");
     BigInteger gb = jp.gx1.multiply(jp.gx2).mod(P).multiply(jp.gx3)
         .mod(P);
     checkZkp(gb, jp.otherA, jp.otherZkpA);
 
     // Calculate shared key g^(x1+x3)*x2*x4*secret, which is equivalent to
     // (B/g^(x2*x4*s))^x2 = (B*(g^x4)^x2^s^-1)^2.
     BigInteger k = jp.gx4.modPow(jp.x2.multiply(new BigInteger(secret.getBytes())).negate().mod(Q), P).multiply(jp.otherA)
         .modPow(jp.x2, P);
 
     byte[] enc = new byte[32];
     byte[] hmac = new byte[32];
     generateKeyAndHmac(k, enc, hmac);
 
-    Log.d(LOG_TAG, "final round finished; returning key");
+    Log.d(LOG_TAG, "Final round finished; returning key.");
     return new KeyBundle(enc, hmac);
   }
 
   // TODO Replace this function with the one in the  crypto library
   private static byte[] HMACSHA256(byte[] data, byte[] key) {
     byte[] result = null;
     try {
       Mac hmacSha256;
       hmacSha256 = Mac.getInstance("HmacSHA256");
-      SecretKeySpec secret_key = new SecretKeySpec(key,
-          "HmacSHA256");
+      SecretKeySpec secret_key = new SecretKeySpec(key, "HmacSHA256");
       hmacSha256.init(secret_key);
       result = hmacSha256.doFinal(data);
     } catch (GeneralSecurityException e) {
       Log.d(LOG_TAG, e.toString());
     }
     return result;
   }
 
@@ -238,38 +238,41 @@ public class JPakeCrypto {
   private static void checkZkp(BigInteger g, BigInteger gx, Zkp zkp)
       throws IncorrectZkpException {
 
     BigInteger h = computeBHash(g, zkp.gr, gx, zkp.id);
 
     // Check parameters of zkp, and compare to computed hash. These shouldn't
     // fail.
     if (gx.compareTo(BigInteger.ZERO) < 1) {// g^x > 1
-      Log.e(LOG_TAG, "g^x > 1 fails");
+      Log.e(LOG_TAG, "g^x > 1 fails.");
+      throw new IncorrectZkpException();
+    }
+    if (gx.compareTo(P.subtract(BigInteger.ONE)) > -1) { // g^x < p-1
+      Log.e(LOG_TAG, "g^x < p-1 fails.");
       throw new IncorrectZkpException();
-    } else if (gx.compareTo(P.subtract(BigInteger.ONE)) > -1) { // g^x < p-1
-      Log.e(LOG_TAG, "g^x < p-1 fails");
+    }
+    if (gx.modPow(Q, P).compareTo(BigInteger.ONE) != 0) {
+      Log.e(LOG_TAG, "g^x^q % p = 1 fails.");
       throw new IncorrectZkpException();
-    } else if (gx.modPow(Q, P).compareTo(BigInteger.ONE) != 0) {
-      Log.e(LOG_TAG, "g^x^q % p = 1 fails");
-    } else if (zkp.gr.compareTo(g.modPow(zkp.b, P).multiply(gx.modPow(h, P)).mod(P)) != 0) {
+    }
+    if (zkp.gr.compareTo(g.modPow(zkp.b, P).multiply(gx.modPow(h, P)).mod(P)) != 0) {
       // b = r-h*x ==> g^r = g^b*g^x^(h)
       Log.i(LOG_TAG, "gb*g(xh) = "
           + g.modPow(zkp.b, P).multiply(gx.modPow(h, P)).mod(P).toString(16));
       Log.d(LOG_TAG, "gr = " + zkp.gr.toString(16));
       Log.d(LOG_TAG, "b = " + zkp.b.toString(16));
       Log.d(LOG_TAG, "g^b = " + g.modPow(zkp.b, P).toString(16));
       Log.d(LOG_TAG, "g^(xh) = " + gx.modPow(h, P).toString(16));
       Log.d(LOG_TAG, "gx = " + gx.toString(16));
       Log.d(LOG_TAG, "h = " + h.toString(16));
-      Log.e(LOG_TAG, "zkp calculation incorrect");
+      Log.e(LOG_TAG, "zkp calculation incorrect.");
       throw new IncorrectZkpException();
-    } else {
-      Log.d(LOG_TAG, "*** ZKP SUCCESS ***");
     }
+    Log.d(LOG_TAG, "*** ZKP SUCCESS ***");
   }
 
   /*
    * Use SHA-256 to compute a BigInteger hash of g, gr, gx values with
    * mySignerId to prevent replay. Does not make a twos-complement BigInteger
    * form hash.
    */
   private static BigInteger computeBHash(BigInteger g, BigInteger gr, BigInteger gx,
--- a/mobile/android/base/sync/jpake/JPakeRequest.java
+++ b/mobile/android/base/sync/jpake/JPakeRequest.java
@@ -43,17 +43,17 @@ import java.net.URISyntaxException;
 import org.mozilla.gecko.sync.net.BaseResource;
 import org.mozilla.gecko.sync.net.Resource;
 import org.mozilla.gecko.sync.net.ResourceDelegate;
 
 import android.util.Log;
 import ch.boye.httpclientandroidlib.HttpEntity;
 
 public class JPakeRequest implements Resource {
-  private static String LOG_TAG = "JPAKE_REQUEST";
+  private static String LOG_TAG = "JPakeRequest";
 
   private BaseResource resource;
   public JPakeRequestDelegate delegate;
 
   public JPakeRequest(String uri, ResourceDelegate delegate) throws URISyntaxException {
     this(new URI(uri), delegate);
   }
 
--- a/mobile/android/base/sync/middleware/Crypto5MiddlewareRepositorySession.java
+++ b/mobile/android/base/sync/middleware/Crypto5MiddlewareRepositorySession.java
@@ -33,20 +33,22 @@
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.middleware;
 
 import java.io.UnsupportedEncodingException;
+import java.util.concurrent.ExecutorService;
 
 import org.mozilla.gecko.sync.crypto.CryptoException;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.CryptoRecord;
+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.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;
 
@@ -154,16 +156,23 @@ public class Crypto5MiddlewareRepository
       }
       this.onFetchCompleted(end);
     }
 
     @Override
     public void onFetchCompleted(long end) {
       next.onFetchCompleted(end);
     }
+
+    @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);
   }
@@ -188,29 +197,44 @@ public class Crypto5MiddlewareRepository
   }
 
   @Override
   public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
     inner.fetchAll(makeUnwrappingDelegate(delegate));
   }
 
   @Override
-  public void store(Record record, RepositorySessionStoreDelegate delegate) {
+  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.getPayload();
     rec.keyBundle = this.keyBundle;
     try {
       rec.encrypt();
     } catch (UnsupportedEncodingException e) {
-      delegate.onStoreFailed(e);
+      delegate.onRecordStoreFailed(e);
       return;
     } catch (CryptoException e) {
-      delegate.onStoreFailed(e);
+      delegate.onRecordStoreFailed(e);
       return;
     }
-    // TODO: it remains to be seen how this will work.
-    inner.store(rec, delegate);
+    // Allow the inner session to do delegate handling.
+    inner.store(rec);
   }
 
   @Override
   public void wipe(RepositorySessionWipeDelegate delegate) {
     inner.wipe(delegate);
   }
+
+  @Override
+  public void storeDone() {
+    inner.storeDone();
+  }
 }
--- a/mobile/android/base/sync/net/BaseResource.java
+++ b/mobile/android/base/sync/net/BaseResource.java
@@ -14,17 +14,17 @@
  * The Original Code is Android Sync Client.
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
- *  Richard Newman <rnewman@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -45,16 +45,17 @@ import java.security.NoSuchAlgorithmExce
 import java.security.SecureRandom;
 
 import javax.net.ssl.SSLContext;
 
 import android.util.Log;
 import ch.boye.httpclientandroidlib.Header;
 import ch.boye.httpclientandroidlib.HttpEntity;
 import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpVersion;
 import ch.boye.httpclientandroidlib.auth.Credentials;
 import ch.boye.httpclientandroidlib.auth.UsernamePasswordCredentials;
 import ch.boye.httpclientandroidlib.client.ClientProtocolException;
 import ch.boye.httpclientandroidlib.client.methods.HttpDelete;
 import ch.boye.httpclientandroidlib.client.methods.HttpGet;
 import ch.boye.httpclientandroidlib.client.methods.HttpPost;
 import ch.boye.httpclientandroidlib.client.methods.HttpPut;
 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
@@ -65,38 +66,65 @@ import ch.boye.httpclientandroidlib.conn
 import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry;
 import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory;
 import ch.boye.httpclientandroidlib.impl.auth.BasicScheme;
 import ch.boye.httpclientandroidlib.impl.client.AbstractHttpClient;
 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;
 
 /**
  * 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.
  */
 public class BaseResource implements Resource {
+  public static boolean rewriteLocalhost = true;
+
   private static final String LOG_TAG = "BaseResource";
   protected URI uri;
   protected BasicHttpContext context;
   protected DefaultHttpClient client;
   public    ResourceDelegate delegate;
   protected HttpRequestBase request;
+  public String charset = "utf-8";
 
   public BaseResource(String uri) throws URISyntaxException {
-	  this(new URI(uri));
+    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) {
-    this.uri = uri;
+
+  public BaseResource(URI uri, boolean rewrite) {
+    if (rewrite && uri.getHost().equals("localhost")) {
+      // Rewrite localhost URIs to refer to the special Android emulator loopback passthrough interface.
+      Log.d(LOG_TAG, "Rewriting " + uri + " to point to 10.0.2.2.");
+      try {
+        this.uri = new URI(uri.getScheme(), uri.getUserInfo(), "10.0.2.2", uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment());
+      } catch (URISyntaxException e) {
+        Log.e(LOG_TAG, "Got error rewriting URI for Android emulator.", e);
+      }
+    } else {
+      this.uri = uri;
+    }
+  }
+
+  public URI getURI() {
+    return this.uri;
   }
 
   /**
    * Apply the provided credentials string to the provided request.
    * @param credentials
    *        A string, "user:pass".
    * @param client
    * @param request
@@ -124,16 +152,18 @@ public class BaseResource implements Res
     String credentials = delegate.getCredentials();
     if (credentials != null) {
       BaseResource.applyCredentials(credentials, client, request, context);
     }
 
     HttpParams params = client.getParams();
     HttpConnectionParams.setConnectionTimeout(params, delegate.connectionTimeout());
     HttpConnectionParams.setSoTimeout(params, delegate.socketTimeout());
+    HttpProtocolParams.setContentCharset(params, charset);
+    HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
     delegate.addHeaders(request, client);
   }
 
   private static Object connManagerMonitor = new Object();
   private static ClientConnectionManager connManager;
 
   /**
    * This method exists for test code.
@@ -149,18 +179,17 @@ public class BaseResource implements Res
 
   public static ClientConnectionManager enableTLSConnectionManager() throws KeyManagementException, NoSuchAlgorithmException  {
     SSLContext sslContext = SSLContext.getInstance("TLS");
     sslContext.init(null, null, new SecureRandom());
     SSLSocketFactory sf = new TLSSocketFactory(sslContext);
     SchemeRegistry schemeRegistry = new SchemeRegistry();
     schemeRegistry.register(new Scheme("https", 443, sf));
     schemeRegistry.register(new Scheme("http", 80, new PlainSocketFactory()));
-    ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(
-        schemeRegistry);
+    ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(schemeRegistry);
     connManager = cm;
     return cm;
   }
 
   public static ClientConnectionManager getConnectionManager() throws KeyManagementException, NoSuchAlgorithmException
                                                          {
     // TODO: shutdown.
     synchronized (connManagerMonitor) {
--- a/mobile/android/base/sync/net/SyncResponse.java
+++ b/mobile/android/base/sync/net/SyncResponse.java
@@ -35,22 +35,22 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.net;
 
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
-import java.math.BigDecimal;
 import java.util.Scanner;
 
 import org.json.simple.parser.ParseException;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
 
 import ch.boye.httpclientandroidlib.Header;
 import ch.boye.httpclientandroidlib.HttpEntity;
 import ch.boye.httpclientandroidlib.HttpResponse;
 
 public class SyncResponse {
 
   protected HttpResponse response;
@@ -70,33 +70,42 @@ public class SyncResponse {
   public int getStatusCode() {
     return this.response.getStatusLine().getStatusCode();
   }
 
   public boolean wasSuccessful() {
     return this.getStatusCode() == 200;
   }
 
+  private String body = null;
   public String body() throws IllegalStateException, IOException {
+    if (body != null) {
+      return body;
+    }
     InputStreamReader is = new InputStreamReader(this.response.getEntity().getContent());
     // Oh, Java, you are so evil.
-    return new Scanner(is).useDelimiter("\\A").next();
+    body = new Scanner(is).useDelimiter("\\A").next();
+    return body;
   }
 
   /**
    * Return the body as an Object.
    *
    * @return null if there is no body, or an Object if it successfully parses.
    *         The return value will be an ExtendedJSONObject if it's a JSON object.
    * @throws IllegalStateException
    * @throws IOException
    * @throws ParseException
    */
   public Object jsonBody() throws IllegalStateException, IOException,
                           ParseException {
+    if (body != null) {
+      // Do it from the cached String.
+      ExtendedJSONObject.parse(body);
+    }
     HttpEntity entity = this.response.getEntity();
     if (entity == null) {
       return null;
     }
     InputStream content = entity.getContent();
     return ExtendedJSONObject.parse(content);
   }
 
@@ -128,41 +137,32 @@ public class SyncResponse {
   public int retryAfter() throws NumberFormatException {
     return this.getIntegerHeader("retry-after");
   }
 
   public int weaveBackoff() throws NumberFormatException {
     return this.getIntegerHeader("x-weave-backoff");
   }
 
-  // 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;
-    }
-  }
-
   /**
    * The timestamp returned from a Sync server is a decimal number 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 normalizedWeaveTimestamp() {
     String h = "x-weave-timestamp";
     if (!this.hasHeader(h)) {
       return -1;
     }
 
-    return decimalSecondsToMilliseconds(this.response.getFirstHeader(h).getValue());
+    return Utils.decimalSecondsToMilliseconds(this.response.getFirstHeader(h).getValue());
   }
 
   public int weaveRecords() throws NumberFormatException {
     return this.getIntegerHeader("x-weave-records");
   }
 
   public int weaveQuotaRemaining() throws NumberFormatException {
     return this.getIntegerHeader("x-weave-quota-remaining");
--- a/mobile/android/base/sync/net/SyncStorageRequest.java
+++ b/mobile/android/base/sync/net/SyncStorageRequest.java
@@ -36,26 +36,69 @@
  * ***** END LICENSE BLOCK ***** */
 
 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 android.util.Log;
 
 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;
 import ch.boye.httpclientandroidlib.params.CoreProtocolPNames;
 
 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));
   }
 
@@ -67,35 +110,43 @@ public class SyncStorageRequest implemen
     this.resourceDelegate = this.makeResourceDelegate(this);
     this.resource.delegate = this.resourceDelegate;
   }
 
   /**
    * A ResourceDelegate that mediates between Resource-level notifications and the SyncStorageRequest.
    */
   public class SyncStorageResourceDelegate extends SyncResourceDelegate {
+    private static final String LOG_TAG = "SyncStorageResourceDelegate";
     protected SyncStorageRequest request;
 
     SyncStorageResourceDelegate(SyncStorageRequest request) {
       super(request);
       this.request = request;
     }
 
     @Override
     public String getCredentials() {
       return this.request.delegate.credentials();
     }
 
     @Override
     public void handleHttpResponse(HttpResponse response) {
+      Log.d(LOG_TAG, "SyncStorageResourceDelegate handling response: " + response.getStatusLine() + ".");
       SyncStorageRequestDelegate d = this.request.delegate;
       SyncStorageResponse res = new SyncStorageResponse(response);
       if (res.wasSuccessful()) {
         d.handleRequestSuccess(res);
       } else {
+        Log.w(LOG_TAG, "HTTP request failed.");
+        try {
+          Log.w(LOG_TAG, "HTTP response body: " + res.getErrorMessage());
+        } catch (Exception e) {
+          Log.e(LOG_TAG, "Can't fetch HTTP response body.", e);
+        }
         d.handleRequestFailure(res);
       }
     }
 
     @Override
     public void handleHttpProtocolException(ClientProtocolException e) {
       this.request.delegate.handleRequestError(e);
     }
@@ -117,17 +168,17 @@ public class SyncStorageRequest implemen
       // Clients can use their delegate interface to specify X-Weave-If-Unmodified-Since.
       String ifUnmodifiedSince = this.request.delegate.ifUnmodifiedSince();
       if (ifUnmodifiedSince != null) {
         request.setHeader("x-weave-if-unmodified-since", ifUnmodifiedSince);
       }
     }
   }
 
-  public static String USER_AGENT = "Firefox AndroidSync 0.1";
+  public static String USER_AGENT = "Firefox AndroidSync 0.3";
   protected SyncResourceDelegate resourceDelegate;
   public SyncStorageRequestDelegate delegate;
   protected BaseResource resource;
 
   public SyncStorageRequest() {
     super();
   }
 
--- a/mobile/android/base/sync/net/SyncStorageResponse.java
+++ b/mobile/android/base/sync/net/SyncStorageResponse.java
@@ -33,58 +33,70 @@
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.net;
 
 
+import java.io.IOException;
+import java.util.HashMap;
+
+import android.util.Log;
+
 import ch.boye.httpclientandroidlib.HttpResponse;
 
 public class SyncStorageResponse extends SyncResponse {
-  // Server responses on which we want to switch.
-  static final int SERVER_RESPONSE_OVER_QUOTA = 14;
+
+  private static final String LOG_TAG = "SyncStorageResponse";
+  public static HashMap<String, String> SERVER_ERROR_MESSAGES;
+  static {
+    HashMap<String, String> errors = new HashMap<String, String>();
 
-  // Higher-level interpretations of response contents.
-  public enum Reason {
-    SUCCESS,
-    OVER_QUOTA,
-    UNAUTHORIZED_OR_REASSIGNED,
-    SERVICE_UNAVAILABLE,
-    BAD_REQUEST,
-    UNKNOWN
+    // 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("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) {
+    Log.d(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) {
     this.response = res;
   }
 
-  /**
-   * Return the high-level definition of the status of this request.
-   * @return
-   */
-  public Reason reason() {
-    switch (this.response.getStatusLine().getStatusCode()) {
-    case 200:
-      return Reason.SUCCESS;
-    case 400:
-      try {
-        Object body = this.jsonBody();
-        if (body instanceof Number) {
-          if (((Number) body).intValue() == SERVER_RESPONSE_OVER_QUOTA) {
-            return Reason.OVER_QUOTA;
-          }
-        }
-      } catch (Exception e) {
-      }
-      return Reason.BAD_REQUEST;
-    case 401:
-      return Reason.UNAUTHORIZED_OR_REASSIGNED;
-    case 503:
-      return Reason.SERVICE_UNAVAILABLE;
-    }
-    return Reason.UNKNOWN;
+  public String getErrorMessage() throws IllegalStateException, IOException {
+    return SyncStorageResponse.getServerErrorMessage(this.body().trim());
   }
 
   // TODO: Content-Type and Content-Length validation.
 
 }
--- a/mobile/android/base/sync/net/TLSSocketFactory.java
+++ b/mobile/android/base/sync/net/TLSSocketFactory.java
@@ -38,29 +38,69 @@
 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 android.util.Log;
+
 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";
+  private static final String[] DEFAULT_CIPHER_SUITES = new String[] {
+    "SSL_RSA_WITH_RC4_128_SHA",        // "RC4_SHA"
+  };
+  private static final String[] DEFAULT_PROTOCOLS = new String[] {
+    "SSLv3",
+    "TLSv1"
+  };
+
+  // Guarded by `this`.
+  private static String[] cipherSuites = 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();
+      Log.d(LOG_TAG, "Setting enabled cipher suites failed: " + e.getMessage());
+      Log.d(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(new String[] { "SSLv3", "TLSv1" });
-    socket.setEnabledCipherSuites(new String[] { "SSL_RSA_WITH_RC4_128_SHA" });
+    socket.setEnabledProtocols(DEFAULT_PROTOCOLS);
+    setEnabledCipherSuites(socket);
     return socket;
   }
 
   @Override
   public boolean isSecure(Socket sock) throws IllegalArgumentException {
     return true;
   }
 }
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/repositories/NoStoreDelegateException.java
@@ -0,0 +1,44 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class NoStoreDelegateException extends SyncException {
+  private static final long serialVersionUID = 6631689468978422074L;
+}
--- a/mobile/android/base/sync/repositories/RepositorySession.java
+++ b/mobile/android/base/sync/repositories/RepositorySession.java
@@ -33,16 +33,19 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories;
 
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
 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;
 
@@ -68,33 +71,87 @@ public abstract class RepositorySession 
     ACTIVE,
     ABORTED,
     DONE
   }
 
   private static final String LOG_TAG = "RepositorySession";
   protected 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.
   public long 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);
   public abstract void fetchAll(RepositorySessionFetchRecordsDelegate delegate);
-  public abstract void store(Record record, RepositorySessionStoreDelegate 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;
+  }
+
+  /*
+   * 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) {
+    Log.d(LOG_TAG, "Setting store delegate to " + delegate);
+    this.delegate = delegate;
+  }
+  public abstract void store(Record record) throws NoStoreDelegateException;
+
+  public void storeDone() {
+    Log.d(LOG_TAG, "Scheduling onStoreCompleted for after storing is done.");
+    Runnable command = new Runnable() {
+      @Override
+      public void run() {
+        delegate.onStoreCompleted();
+      }
+    };
+    storeWorkQueue.execute(command);
+  }
+
   public abstract void wipe(RepositorySessionWipeDelegate delegate);
 
   public void unbundle(RepositorySessionBundle bundle) {
     this.lastSyncTimestamp = 0;
     if (bundle == null) {
       return;
     }
     if (bundle.containsKey("timestamp")) {
@@ -123,20 +180,19 @@ public abstract class RepositorySession 
       error("Tried to begin() an already active or finished session");
       throw new InvalidSessionTransitionException(null);
     }
   }
 
   public void begin(RepositorySessionBeginDelegate delegate) {
     try {
       sharedBegin();
-      delegate.deferredBeginDelegate().onBeginSucceeded(this);
-
+      delegate.deferredBeginDelegate(delegateQueue).onBeginSucceeded(this);
     } catch (Exception e) {
-      delegate.deferredBeginDelegate().onBeginFailed(e);
+      delegate.deferredBeginDelegate(delegateQueue).onBeginFailed(e);
     }
   }
 
   protected RepositorySessionBundle getBundle() {
     return this.getBundle(null);
   }
 
   /**
@@ -163,30 +219,36 @@ public abstract class RepositorySession 
   /**
    * 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.
    *
    * @param delegate
    */
   public void abort(RepositorySessionFinishDelegate delegate) {
     this.status = SessionStatus.DONE;    // TODO: ABORTED?
-    delegate.deferredFinishDelegate().onFinishSucceeded(this, this.getBundle(null));
+    delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle(null));
   }
 
-  public void finish(RepositorySessionFinishDelegate delegate) {
+  public void finish(final RepositorySessionFinishDelegate delegate) {
     if (this.status == SessionStatus.ACTIVE) {
       this.status = SessionStatus.DONE;
-      delegate.deferredFinishDelegate().onFinishSucceeded(this, this.getBundle(null));
+      delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle(null));
     } else {
       Log.e(LOG_TAG, "Tried to finish() an unstarted or already finished session");
-      delegate.deferredFinishDelegate().onFinishFailed(new InvalidSessionTransitionException(null));
+      Exception e = new InvalidSessionTransitionException(null);
+      delegate.deferredFinishDelegate(delegateQueue).onFinishFailed(e);
     }
+    Log.i(LOG_TAG, "Shutting down work queues.");
+ //   storeWorkQueue.shutdown();
+ //   delegateQueue.shutdown();
   }
 
   public boolean isActive() {
     return status == SessionStatus.ACTIVE;
   }
 
   public void abort() {
     // TODO: do something here.
     status = SessionStatus.ABORTED;
+    storeWorkQueue.shutdown();
+    delegateQueue.shutdown();
   }
 }
--- a/mobile/android/base/sync/repositories/Server11Repository.java
+++ b/mobile/android/base/sync/repositories/Server11Repository.java
@@ -36,72 +36,82 @@
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories;
 
 import java.net.URI;
 import java.net.URISyntaxException;
 
 import org.mozilla.gecko.sync.CredentialsSource;
+import org.mozilla.gecko.sync.Utils;
 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 {
 
   private String serverURI;
   private String username;
   private String collection;
   private String collectionPath;
+  private URI collectionPathURI;
   public CredentialsSource credentialsSource;
   public static final String VERSION_PATH_FRAGMENT = "1.1/";
 
   /**
    *
    * @param serverURI
    *        URI of the Sync 1.1 server (string)
    * @param username
    *        Username on the server (string)
    * @param collection
    *        Name of the collection (string)
+   * @throws URISyntaxException
    */
-  public Server11Repository(String serverURI, String username, String collection, CredentialsSource credentialsSource) {
+  public Server11Repository(String serverURI, String username, String collection, CredentialsSource credentialsSource) throws URISyntaxException {
     this.serverURI  = serverURI;
     this.username   = username;
     this.collection = collection;
 
     this.collectionPath = this.serverURI + VERSION_PATH_FRAGMENT + this.username + "/storage/" + this.collection;
+    this.collectionPathURI = new URI(this.collectionPath);
     this.credentialsSource = credentialsSource;
   }
 
   @Override
   public void createSession(RepositorySessionCreationDelegate delegate,
                             Context context) {
     delegate.onSessionCreated(new Server11RepositorySession(this));
   }
 
+  public URI collectionURI() {
+    return this.collectionPathURI;
+  }
+
   public URI collectionURI(boolean full, long newer, String ids) throws URISyntaxException {
     // Do it this way to make it easier to add more params later.
     // It's pretty ugly, I'll grant.
     // I can't believe Java doesn't have a good way to do this.
     boolean anyParams = full;
     String  uriParams = "";
     if (anyParams) {
       StringBuilder params = new StringBuilder("?");
       if (full) {
         params.append("full=1");
       }
       if (newer >= 0) {
-        params.append((full ? "&newer=" : "newer=") + newer);
+        // Translate local millisecond timestamps into server decimal seconds.
+        String newerString = Utils.millisecondsToDecimalSecondsString(newer);
+        params.append((full ? "&newer=" : "newer=") + newerString);
       }
       if (ids != null) {
         params.append(((full || newer >= 0) ? "&ids=" : "ids=") + ids);
       }
       uriParams = params.toString();
     }
     String uri = this.collectionPath + uriParams;
     return new URI(uri);
--- a/mobile/android/base/sync/repositories/Server11RepositorySession.java
+++ b/mobile/android/base/sync/repositories/Server11RepositorySession.java
@@ -32,40 +32,69 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories;
 
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.ArrayList;
 import java.util.Date;
 
-import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.json.simple.JSONArray;
 import org.mozilla.gecko.sync.CryptoRecord;
 import org.mozilla.gecko.sync.DelayedWorkTracker;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.UnexpectedJSONException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
+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.net.WBOCollectionRequestDelegate;
 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 android.util.Log;
+import ch.boye.httpclientandroidlib.entity.ContentProducer;
+import ch.boye.httpclientandroidlib.entity.EntityTemplate;
 
 public class Server11RepositorySession extends RepositorySession {
+  private static byte[] recordsStart;
+  private static byte[] recordSeparator;
+  private static byte[] recordsEnd;
+
+  static {
+    try {
+      recordsStart    = "[\n".getBytes("UTF-8");
+      recordSeparator = ",\n".getBytes("UTF-8");
+      recordsEnd      = "\n]\n".getBytes("UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      // These won't fail.
+    }
+  }
 
   public static final String LOG_TAG = "Server11RepositorySession";
 
+  private static final int UPLOAD_BYTE_THRESHOLD = 1024 * 1024;    // 1MB.
+  private static final int UPLOAD_ITEM_THRESHOLD = 50;
+  private static final int PER_RECORD_OVERHEAD   = 2;              // Comma, newline.
+  // {}, newlines, but we get to skip one record overhead.
+  private static final int PER_BATCH_OVERHEAD    = 5 - PER_RECORD_OVERHEAD;
+
   /**
    * Convert HTTP request delegate callbacks into fetch callbacks within the
    * context of this RepositorySession.
    *
    * @author rnewman
    *
    */
   public class RequestFetchDelegateAdapter extends WBOCollectionRequestDelegate {
@@ -150,16 +179,17 @@ public class Server11RepositorySession e
 
     // TODO: this implies that we've screwed up our inheritance chain somehow.
     @Override
     public KeyBundle keyBundle() {
       return null;
     }
   }
 
+
   Server11Repository serverRepository;
   public Server11RepositorySession(Repository repository) {
     super(repository);
     serverRepository = (Server11Repository) repository;
   }
 
   private String flattenIDs(String[] guids) {
     if (guids.length == 0) {
@@ -178,20 +208,20 @@ public class Server11RepositorySession e
 
   @Override
   public void guidsSince(long timestamp,
                          RepositorySessionGuidsSinceDelegate delegate) {
     // TODO Auto-generated method stub
 
   }
 
-  private void fetchWithParameters(long newer,
-                                   boolean full,
-                                   String ids,
-                                   SyncStorageRequestDelegate delegate) throws URISyntaxException {
+  protected void fetchWithParameters(long newer,
+                                     boolean full,
+                                     String ids,
+                                     SyncStorageRequestDelegate delegate) throws URISyntaxException {
 
     URI collectionURI = serverRepository.collectionURI(full, newer, ids);
     SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(collectionURI);
     request.delegate = delegate;
     request.get();
   }
 
   @Override
@@ -217,17 +247,214 @@ public class Server11RepositorySession e
       String ids = flattenIDs(guids);
       this.fetchWithParameters(-1, true, ids, new RequestFetchDelegateAdapter(delegate));
     } catch (URISyntaxException e) {
       delegate.onFetchFailed(e, null);
     }
   }
 
   @Override
-  public void store(Record record, RepositorySessionStoreDelegate delegate) {
-    // TODO: implement store.
+  public void wipe(RepositorySessionWipeDelegate delegate) {
+    // TODO: implement wipe.
+  }
+
+  protected Object recordsBufferMonitor = new Object();
+  protected ArrayList<byte[]> recordsBuffer = new ArrayList<byte[]>();
+  protected int byteCount = PER_BATCH_OVERHEAD;
+
+  @Override
+  public void store(Record record) throws NoStoreDelegateException {
+    if (delegate == null) {
+      throw new NoStoreDelegateException();
+    }
+    this.enqueue(record);
+  }
+
+  /**
+   * Batch incoming records until some reasonable threshold (e.g., 50),
+   * some size limit is hit (probably way less than 3MB!), or storeDone
+   * is received.
+   * @param record
+   */
+  protected void enqueue(Record record) {
+    // JSONify and store the bytes, rather than the record.
+    byte[] json = record.toJSONBytes();
+    int delta   = json.length;
+    synchronized (recordsBufferMonitor) {
+      if ((delta + byteCount     > UPLOAD_BYTE_THRESHOLD) ||
+          (recordsBuffer.size() >= UPLOAD_ITEM_THRESHOLD)) {
+
+        // POST the existing contents, then enqueue.
+        flush();
+      }
+      recordsBuffer.add(json);
+      byteCount += PER_RECORD_OVERHEAD + delta;
+    }
+  }
+
+  // Asynchronously upload records.
+  // Must be locked!
+  protected void flush() {
+    if (recordsBuffer.size() > 0) {
+      final ArrayList<byte[]> outgoing = recordsBuffer;
+      RepositorySessionStoreDelegate uploadDelegate = this.delegate;
+      storeWorkQueue.execute(new RecordUploadRunnable(uploadDelegate, outgoing, byteCount));
+
+      recordsBuffer = new ArrayList<byte[]>();
+      byteCount = PER_BATCH_OVERHEAD;
+    }
   }
 
   @Override
-  public void wipe(RepositorySessionWipeDelegate delegate) {
-    // TODO: implement wipe.
+  public void storeDone() {
+    synchronized (recordsBufferMonitor) {
+      flush();
+      super.storeDone();
+    }
+  }
+
+  /**
+   * Make an HTTP request, and convert HTTP request delegate callbacks into
+   * store callbacks within the context of this RepositorySession.
+   *
+   * @author rnewman
+   *
+   */
+  protected class RecordUploadRunnable implements Runnable, SyncStorageRequestDelegate {
+
+    public final String LOG_TAG = "RecordUploadRunnable";
+    private ArrayList<byte[]> outgoing;
+    private long byteCount;
+
+    public RecordUploadRunnable(RepositorySessionStoreDelegate storeDelegate,
+                                ArrayList<byte[]> outgoing,
+                                long byteCount) {
+      Log.i(LOG_TAG, "Preparing RecordUploadRunnable for " +
+                     outgoing.size() + " records (" +
+                     byteCount + " bytes).");
+      this.outgoing  = outgoing;
+      this.byteCount = byteCount;
+    }
+
+    @Override
+    public String credentials() {
+      return serverRepository.credentialsSource.credentials();
+    }
+
+    @Override
+    public String ifUnmodifiedSince() {
+      return null;
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse response) {
+      Log.i(LOG_TAG, "POST of " + outgoing.size() + " records done.");
+
+      ExtendedJSONObject body;
+      try {
+        body = response.jsonObjectBody();
+      } catch (Exception e) {
+        Log.e(LOG_TAG, "Got exception parsing POST success body.", e);
+        // TODO
+        return;
+      }
+      long modified = body.getTimestamp("modified");
+      Log.i(LOG_TAG, "POST request success. Modified timestamp: " + modified);
+
+      try {
+        JSONArray          success = body.getArray("success");
+        ExtendedJSONObject failed  = body.getObject("failed");
+        if ((success != null) &&
+            (success.size() > 0)) {
+          Log.d(LOG_TAG, "Successful records: " + success.toString());
+          // TODO: how do we notify without the whole record?
+        }
+        if ((failed != null) &&
+            (failed.object.size() > 0)) {
+          Log.d(LOG_TAG, "Failed records: " + failed.object.toString());
+          // TODO: notify.
+        }
+      } catch (UnexpectedJSONException e) {
+        Log.e(LOG_TAG, "Got exception processing success/failed in POST success body.", e);
+        // TODO
+        return;
+      }
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      // TODO: ensure that delegate methods don't get called more than once.
+      // TODO: call session.interpretHTTPFailure.
+      this.handleRequestError(new HTTPFailureException(response));
+    }
+
+    @Override
+    public void handleRequestError(final Exception ex) {
+      Log.i(LOG_TAG, "Got request error: " + ex, ex);
+      delegate.onRecordStoreFailed(ex);
+    }
+
+    public 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(recordsStart);
+        outstream.write(outgoing.get(0));
+        for (int i = 1; i < count; ++i) {
+          outstream.write(recordSeparator);
+          outstream.write(outgoing.get(i));
+        }
+        outstream.write(recordsEnd);
+      }
+    }
+
+    public class ByteArraysEntity extends EntityTemplate {
+      private 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.
+      }
+
+      @Override
+      public long getContentLength() {
+        return count;
+      }
+
+      @Override
+      public boolean isRepeatable() {
+        return true;
+      }
+    }
+
+    public ByteArraysEntity getBodyEntity() {
+      ByteArraysEntity body = new ByteArraysEntity(outgoing, byteCount);
+      return body;
+    }
+
+    @Override
+    public void run() {
+      if (outgoing == null ||
+          outgoing.size() == 0) {
+        Log.i(LOG_TAG, "No items: RecordUploadRunnable returning immediately.");
+        return;
+      }
+
+      URI u = serverRepository.collectionURI();
+      SyncStorageRequest request = new SyncStorageRequest(u);
+
+      request.delegate = this;
+
+      // 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);
+    }
   }
 }
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java
@@ -15,16 +15,17 @@
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
  *   Jason Voll <jvoll@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -32,142 +33,161 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.android;
 
+import java.util.HashMap;
+
 import org.json.simple.JSONArray;
 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.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
+import android.util.Log;
 
 public class AndroidBrowserBookmarksDataAccessor extends AndroidBrowserRepositoryDataAccessor {
 
+  private static final String LOG_TAG = "AndroidBrowserBookmarksDataAccessor";
+
+  /*
+   * Fragments of SQL to make our lives easier.
+   */
+  private static final String BOOKMARK_IS_FOLDER = BrowserContract.Bookmarks.IS_FOLDER + " = 1";
+  private static final String GUID_NOT_TAGS_OR_PLACES = BrowserContract.SyncColumns.GUID + " NOT IN ('" +
+                     BrowserContract.Bookmarks.TAGS_FOLDER_GUID + "', '" +
+                     BrowserContract.Bookmarks.PLACES_FOLDER_GUID + "')";
+
   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 BrowserContract.Bookmarks.CONTENT_URI;
   }
 
   protected Cursor getGuidsIDsForFolders() throws NullCursorException {
-    String where = BrowserContract.Bookmarks.IS_FOLDER + "=1";
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(), null, where, null, null);
-    queryEnd = System.currentTimeMillis();
-    RepoUtils.queryTimeLogger("AndroidBrowserBookmarksDataAccessor.getGuidsIDsForFolders", queryStart, queryEnd);
-    if (cur == null) {
-      throw new NullCursorException(null);
-    }
-    return cur;
+    // Exclude "places" and "tags", in case they've ended up in the DB.
+    String where = BOOKMARK_IS_FOLDER + " AND " + GUID_NOT_TAGS_OR_PLACES;
+    return queryHelper.safeQuery(".getGuidsIDsForFolders", null, where, null, null);
   }
 
   protected void updateParentAndPosition(String guid, long newParentId, long position) {
     ContentValues cv = new ContentValues();
     cv.put(BrowserContract.Bookmarks.PARENT, newParentId);
     cv.put(BrowserContract.Bookmarks.POSITION, position);
     updateByGuid(guid, cv);
   } 
   
   /*
-   * Verify that all special guids are present and that they aren't set to deleted.
-   * Inser them if they aren't there.
+   * 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 {
     Cursor cur = fetch(RepoUtils.SPECIAL_GUIDS);
-    cur.moveToFirst();
-    int count = 0;
-    boolean containsMobileFolder = false;
-    long mobileRoot = 0;
-    while (!cur.isAfterLast()) {
-      String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
-      if (guid.equals("mobile")) {
-        containsMobileFolder = true;
-        mobileRoot = RepoUtils.getLongFromCursor(cur, BrowserContract.CommonColumns._ID);
+    long mobileRoot  = 0;
+    long desktopRoot = 0;
+
+    // Map from GUID to whether deleted. Non-presence implies just that.
+    HashMap<String, Boolean> statuses = new HashMap<String, Boolean>(RepoUtils.SPECIAL_GUIDS.length);
+    try {
+      if (cur.moveToFirst()) {
+        while (!cur.isAfterLast()) {
+          String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
+          if (guid.equals("mobile")) {
+            mobileRoot = RepoUtils.getLongFromCursor(cur, BrowserContract.CommonColumns._ID);
+          }
+          if (guid.equals("desktop")) {
+            desktopRoot = 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();
+        }
       }
-      count++;
-      
-      // Make sure none of these folders are marked as deleted
-      if (RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1) {
-        ContentValues cv = new ContentValues();
-        cv.put(BrowserContract.SyncColumns.IS_DELETED, 0);
-        updateByGuid(guid, cv);
-      }
-      cur.moveToNext();
+    } finally {
+      cur.close();
     }
-    cur.close();
-    
-    // Insert them if missing
-    if (count != RepoUtils.SPECIAL_GUIDS.length) {
-      if (!containsMobileFolder) {
-        mobileRoot = insertSpecialFolder("mobile", 0);
+
+    // Insert or undelete them if missing.
+    for (String guid : RepoUtils.SPECIAL_GUIDS) {
+      if (statuses.containsKey(guid)) {
+        if (statuses.get(guid)) {
+          // Undelete.
+          Log.i(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("mobile")) {
+          Log.i(LOG_TAG, "No mobile folder. Inserting one.");
+          mobileRoot = insertSpecialFolder("mobile", 0);
+        } else if (guid.equals("places")) {
+          desktopRoot = insertSpecialFolder("places", mobileRoot);
+        } else {
+          // unfiled, menu, toolbar.
+          insertSpecialFolder(guid, desktopRoot);
+        }
       }
-      long desktop = insertSpecialFolder("places", mobileRoot);
-      insertSpecialFolder("unfiled", desktop);
-      insertSpecialFolder("menu", desktop);
-      insertSpecialFolder("toolbar", desktop);
     }
   }
 
   private long insertSpecialFolder(String guid, long parentId) {
-      BookmarkRecord record = new BookmarkRecord(guid);
-      record.title = RepoUtils.SPECIAL_GUIDS_MAP.get(guid);
-      record.type = "folder";
-      record.androidParentID = parentId;
-      return(RepoUtils.getAndroidIdFromUri(insert(record)));
+    BookmarkRecord record = new BookmarkRecord(guid);
+    record.title = RepoUtils.SPECIAL_GUIDS_MAP.get(guid);
+    record.type = "folder";
+    record.androidParentID = parentId;
+    return(RepoUtils.getAndroidIdFromUri(insert(record)));
   }
 
   @Override
   protected ContentValues getContentValues(Record record) {
     ContentValues cv = new ContentValues();
     BookmarkRecord rec = (BookmarkRecord) record;
-    cv.put("guid",          rec.guid);
+    cv.put(BrowserContract.SyncColumns.GUID,      rec.guid);
     cv.put(BrowserContract.Bookmarks.TITLE,       rec.title);
     cv.put(BrowserContract.Bookmarks.URL,         rec.bookmarkURI);
-    cv.put(BrowserContract.Bookmarks.DESCRIPTION,         rec.description);
+    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);
+    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: Only bookmark and folder types should make it this far,
-    // other types should be filtered out and droppped
-    cv.put(BrowserContract.Bookmarks.IS_FOLDER, rec.type.equalsIgnoreCase(TYPE_FOLDER) ? 1 : 0);
+    // Only bookmark and folder types should make it this far.
+    // Other types should be filtered out and dropped.
+    cv.put(BrowserContract.Bookmarks.IS_FOLDER,   rec.type.equalsIgnoreCase(TYPE_FOLDER) ? 1 : 0);
 
     cv.put("modified", rec.lastModified);
     return cv;
   }
   
   // Returns a cursor with any records that list the given androidID as a parent
   public Cursor getChildren(long androidID) throws NullCursorException {
-    String where = BrowserContract.Bookmarks.PARENT + "=" + androidID;
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(), getAllColumns(), where, null, null);
-    queryEnd = System.currentTimeMillis();
-    RepoUtils.queryTimeLogger("AndroidBrowserBookmarksDataAccessor.getChildren", queryStart, queryEnd);
-    if (cur == null) {
-      throw new NullCursorException(null);
-    }
-    return cur;
+    String where = BrowserContract.Bookmarks.PARENT + " = ?";
+    String[] args = new String[] { String.valueOf(androidID) };
+    return queryHelper.safeQuery(".getChildren", getAllColumns(), where, args, null);
   }
   
   @Override
   protected String[] getAllColumns() {
     return BrowserContract.Bookmarks.BookmarkColumns;
   }
-
 }
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java
@@ -36,16 +36,17 @@
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.android;
 
 import java.util.ArrayList;
 import java.util.HashMap;
 
 import org.json.simple.JSONArray;
+import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.repositories.BookmarkNeedsReparentingException;
 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.domain.BookmarkRecord;
@@ -53,89 +54,200 @@ import org.mozilla.gecko.sync.repositori
 
 import android.content.Context;
 import android.database.Cursor;
 import android.util.Log;
 
 
 public class AndroidBrowserBookmarksRepositorySession extends AndroidBrowserRepositorySession {
 
+  // TODO: synchronization for these.
   private HashMap<String, Long> guidToID = new HashMap<String, Long>();
   private HashMap<Long, String> idToGuid = new HashMap<Long, String>();
+
   private HashMap<String, ArrayList<String>> missingParentToChildren = new HashMap<String, ArrayList<String>>();
   private HashMap<String, JSONArray> parentToChildArray = new HashMap<String, JSONArray>();
   private AndroidBrowserBookmarksDataAccessor dataAccessor;
   private int needsReparenting = 0;
 
+  private static void trace(String string) {
+    if (Utils.ENABLE_TRACE_LOGGING) {
+      Log.d(LOG_TAG, string);
+    }
+  }
+
+  /**
+   * Return true if the provided record GUID should be skipped
+   * in child lists or fetch results.
+   *
+   * @param recordGUID
+   * @return
+   */
+  public static boolean forbiddenGUID(String recordGUID) {
+    return recordGUID == null ||
+           "places".equals(recordGUID) ||
+           "tags".equals(recordGUID);
+  }
+
   public AndroidBrowserBookmarksRepositorySession(Repository repository, Context context) {
     super(repository);
     RepoUtils.initialize(context);
     dbHelper = new AndroidBrowserBookmarksDataAccessor(context);
     dataAccessor = (AndroidBrowserBookmarksDataAccessor) dbHelper;
   }
 
+  private boolean rowIsFolder(Cursor cur) {
+    return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.IS_FOLDER) == 1;
+  }
+
+  private String getGUIDForID(long androidID) {
+    String guid = idToGuid.get(androidID);
+    trace("  " + androidID + " => " + guid);
+    return guid;
+  }
+
+  private String getGUID(Cursor cur) {
+    return RepoUtils.getStringFromCursor(cur, "guid");
+  }
+
+  private long getParentID(Cursor cur) {
+    return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.PARENT);
+  }
+
+  private String getParentName(String parentGUID) throws ParentNotFoundException, NullCursorException {
+    if (parentGUID == null) {
+      return "";
+    }
+    if (RepoUtils.SPECIAL_GUIDS_MAP.containsKey(parentGUID)) {
+      return RepoUtils.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 {
+        Log.e(LOG_TAG, "Couldn't find record with guid '" + parentGUID + "' when looking for parent name.");
+        throw new ParentNotFoundException(null);
+      }
+    } finally {
+      name.close();
+    }
+    return parentName;
+  }
+
   @SuppressWarnings("unchecked")
+  private JSONArray getChildren(long androidID) throws NullCursorException {
+    trace("Calling getChildren for androidID " + androidID);
+    JSONArray childArray = new JSONArray();
+    Cursor children = dataAccessor.getChildren(androidID);
+    try {
+      if (!children.moveToFirst()) {
+        trace("No children: empty cursor.");
+        return childArray;
+      }
+
+      int count = children.getCount();
+      String[] kids = new String[count];
+      trace("Expecting " + count + " children.");
+
+      // Track badly positioned records.
+      // TODO: use a mechanism here that preserves ordering.
+      HashMap<String, Long> broken = new HashMap<String, Long>();
+
+      // Get children into array in correct order.
+      while (!children.isAfterLast()) {
+        String childGuid = getGUID(children);
+        trace("  Child GUID: " + childGuid);
+        int childPosition = (int) RepoUtils.getLongFromCursor(children, BrowserContract.Bookmarks.POSITION);
+        trace("  Child position: " + childPosition);
+        if (childPosition >= count) {
+          Log.w(LOG_TAG, "Child position " + childPosition + " greater than expected children " + count);
+          broken.put(childGuid, 0L);
+        } else {
+          String existing = kids[childPosition];
+          if (existing != null) {
+            Log.w(LOG_TAG, "Child position " + childPosition + " already occupied! (" +
+                childGuid + ", " + existing + ")");
+            broken.put(childGuid, 0L);
+          } else {
+            kids[childPosition] = childGuid;
+          }
+        }
+        children.moveToNext();
+      }
+
+      try {
+        Utils.fillArraySpaces(kids, broken);
+      } catch (Exception e) {
+        Log.e(LOG_TAG, "Unable to reposition children to yield a valid sequence. Data loss may result.", e);
+      }
+      // TODO: now use 'broken' to edit the records on disk.
+
+      // Collect into a more friendly data structure.
+      for (int i = 0; i < count; ++i) {
+        String kid = kids[i];
+        if (forbiddenGUID(kid)) {
+          continue;
+        }
+        childArray.add(kid);
+      }
+      if (Utils.ENABLE_TRACE_LOGGING) {
+        Log.d(LOG_TAG, "Output child array: " + childArray.toJSONString());
+      }
+    } finally {
+      children.close();
+    }
+    return childArray;
+  }
+
   @Override
   protected Record recordFromMirrorCursor(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    long androidParentId = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.PARENT);
-    String guid = idToGuid.get(androidParentId);
+    String recordGUID = getGUID(cur);
+    Log.d(LOG_TAG, "Record from mirror cursor: " + recordGUID);
+
+    if (forbiddenGUID(recordGUID)) {
+      Log.d(LOG_TAG, "Ignoring " + recordGUID + " record in recordFromMirrorCursor.");
+      return null;
+    }
 
-    if (guid == null) {
-      // if the parent has been stored and somehow has a null guid, throw an error
-      if (idToGuid.containsKey(androidParentId)) {
-        Log.e(LOG_TAG, "Have the parent android id for the record but the parent's guid wasn't found");
+    long androidParentID     = getParentID(cur);
+    String androidParentGUID = getGUIDForID(androidParentID);
+
+    if (androidParentGUID == null) {
+      Log.d(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 (idToGuid.containsKey(androidParentID)) {
+        Log.e(LOG_TAG, "Have the parent android ID for the record but the parent's GUID wasn't found.");
         throw new NoGuidForIdException(null);
-      } else {
-        return RepoUtils.bookmarkFromMirrorCursor(cur, "", "", null);
       }
     }
-    
-    // Get parent name
-    String parentName = "";
-    Cursor name = dataAccessor.fetch(new String[] { guid });
-    name.moveToFirst();
-    if (!name.isAfterLast()) {
-      parentName = RepoUtils.getStringFromCursor(name, BrowserContract.Bookmarks.TITLE);
-    }
-    else {
-      Log.e(LOG_TAG, "Couldn't find record with guid " + guid + " while looking for parent name");
-      throw new ParentNotFoundException(null);
-    }
-    name.close();
-    
-    // If record is a folder, build out the children array
-    long isFolder = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.IS_FOLDER);
+
+    // If record is a folder, build out the children array.
+    JSONArray childArray = getChildArrayForCursor(cur, recordGUID);
+    String parentName = getParentName(androidParentGUID);
+    return RepoUtils.bookmarkFromMirrorCursor(cur, androidParentGUID, parentName, childArray);
+  }
+
+  protected JSONArray getChildArrayForCursor(Cursor cur, String recordGUID) throws NullCursorException {
     JSONArray childArray = null;
-    if (isFolder == 1) {
-      long androidID = guidToID.get(RepoUtils.getStringFromCursor(cur, "guid"));
-      Cursor children = dataAccessor.getChildren(androidID);
-      children.moveToFirst();
-      int count = 0;
-      
-      // Get children into array in correct order
-      while(!children.isAfterLast()) {
-        count++;
-        children.moveToNext();
-      }
-      children.moveToFirst();
-      String[] kids = new String[count];
-      while(!children.isAfterLast()) {
-        if (childArray == null) childArray = new JSONArray();
-        String childGuid = RepoUtils.getStringFromCursor(children, "guid");
-        int childPosition = (int) RepoUtils.getLongFromCursor(children, BrowserContract.Bookmarks.POSITION);
-        kids[childPosition] = childGuid;
-        children.moveToNext();
-      }
-      children.close();
-      for(int i = 0; i < kids.length; i++) {
-        childArray.add(kids[i]);
-      }
-      
+    boolean isFolder = rowIsFolder(cur);
+    Log.d(LOG_TAG, "Record " + recordGUID + " is a " + (isFolder ? "folder." : "bookmark."));
+    if (isFolder) {
+      long androidID = guidToID.get(recordGUID);
+      childArray = getChildren(androidID);
     }
-    return RepoUtils.bookmarkFromMirrorCursor(cur, guid, parentName, childArray);
+    if (childArray != null) {
+      Log.d(LOG_TAG, "Fetched " + childArray.size() + " children for " + recordGUID);
+    }
+    return childArray;
   }
 
   @Override
   protected boolean checkRecordType(Record record) {
     BookmarkRecord bmk = (BookmarkRecord) record;
     if (bmk.type.equalsIgnoreCase(AndroidBrowserBookmarksDataAccessor.TYPE_BOOKMARK) ||
         bmk.type.equalsIgnoreCase(AndroidBrowserBookmarksDataAccessor.TYPE_FOLDER)) {
       return true;
@@ -145,52 +257,66 @@ public class AndroidBrowserBookmarksRepo
   }
   
   @Override
   public void begin(RepositorySessionBeginDelegate delegate) {
     // Check for the existence of special folders
     // and insert them if they don't exist.
     Cursor cur;
     try {
+      Log.d(LOG_TAG, "Check and build special GUIDs.");
       dataAccessor.checkAndBuildSpecialGuids();
       cur = dataAccessor.getGuidsIDsForFolders();
+      Log.d(LOG_TAG, "Got GUIDs for folders.");
+    } catch (android.database.sqlite.SQLiteConstraintException e) {
+      Log.e(LOG_TAG, "Got sqlite constraint exception working with Fennec bookmark DB.", e);
+      delegate.onBeginFailed(e);
+      return;
     } catch (NullCursorException 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
-    cur.moveToFirst();
-    while(!cur.isAfterLast()) {
-      String guid = RepoUtils.getStringFromCursor(cur, "guid");
-      long id = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
-      guidToID.put(guid, id);
-      idToGuid.put(id, guid);
-      cur.moveToNext();
+    // hairy stuff. Here's the setup for it.
+
+    Log.d(LOG_TAG, "Preparing folder ID mappings.");
+    idToGuid.put(0L, "places");       // Fake our root.
+    try {
+      cur.moveToFirst();
+      while (!cur.isAfterLast()) {
+        String guid = getGUID(cur);
+        long id = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
+        guidToID.put(guid, id);
+        idToGuid.put(id, guid);
+        Log.d(LOG_TAG, "GUID " + guid + " maps to " + id);
+        cur.moveToNext();
+      }
+    } finally {
+      cur.close();
     }
-    cur.close();
-    
+    Log.d(LOG_TAG, "Done with initial setup of bookmarks session.");
     super.begin(delegate);
   }
 
   @Override
   public void finish(RepositorySessionFinishDelegate delegate) {
     // Override finish to do this check; make sure all records
     // needing re-parenting have been re-parented.
     if (needsReparenting != 0) {
       Log.e(LOG_TAG, "Finish called but " + needsReparenting +
-          " bookmark(s) have been placed in unsorted bookmarks and not been reparented.");
-      delegate.onFinishFailed(new BookmarkNeedsReparentingException(null));
-    } else {
-      super.finish(delegate);
+            " 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);
   };
 
   // TODO this code is yucky, cleanup or comment or something
   @SuppressWarnings("unchecked")
   @Override
   protected long insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
     BookmarkRecord bmk = (BookmarkRecord) record;
     
@@ -214,37 +340,48 @@ public class AndroidBrowserBookmarksRepo
       } else {
         children = new ArrayList<String>();
       }
       children.add(bmk.guid);
       needsReparenting++;
       missingParentToChildren.put(bmk.parentID, children);
     }
 
+    if (bmk.isFolder()) {
+      Log.d(LOG_TAG, "Inserting folder " + bmk.guid + ", " + bmk.title +
+                     " with parent " + bmk.androidParentID +
+                     " (" + bmk.parentID + ", " + bmk.parentName +
+                     ", " + bmk.pos + ")");
+    } else {
+      Log.d(LOG_TAG, "Inserting bookmark " + bmk.guid + ", " + bmk.title + ", " +
+                     bmk.bookmarkURI + " with parent " + bmk.androidParentID +
+                     " (" + bmk.parentID + ", " + bmk.parentName +
+                     ", " + bmk.pos + ")");
+    }
     long id = RepoUtils.getAndroidIdFromUri(dbHelper.insert(bmk));
+    Log.d(LOG_TAG, "Inserted as " + id);
+
     putRecordToGuidMap(buildRecordString(bmk), bmk.guid);
     bmk.androidID = id;
 
     // If record is folder, update maps and re-parent children if necessary
-    if(bmk.type.equalsIgnoreCase(AndroidBrowserBookmarksDataAccessor.TYPE_FOLDER)) {
+    if (bmk.type.equalsIgnoreCase(AndroidBrowserBookmarksDataAccessor.TYPE_FOLDER)) {
       guidToID.put(bmk.guid, id);
       idToGuid.put(id, bmk.guid);
 
       JSONArray childArray = bmk.children;
 
       // Re-parent.
-      if(missingParentToChildren.containsKey(bmk.guid)) {
+      if (missingParentToChildren.containsKey(bmk.guid)) {
         for (String child : missingParentToChildren.get(bmk.guid)) {
           long position;
-          if (bmk.children.contains(child)) {
-            position = childArray.indexOf(child);
-          } else {
+          if (!bmk.children.contains(child)) {
             childArray.add(child);
-            position = childArray.indexOf(child);
           }
+          position = childArray.indexOf(child);
           dataAccessor.updateParentAndPosition(child, id, position);
           needsReparenting--;
         }
         missingParentToChildren.remove(bmk.guid);
       }
       parentToChildArray.put(bmk.guid, childArray);
     }
     return id;
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
@@ -40,16 +40,17 @@ package org.mozilla.gecko.sync.repositor
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 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;
+import android.util.Log;
 
 public class AndroidBrowserHistoryDataAccessor extends AndroidBrowserRepositoryDataAccessor {
 
   private AndroidBrowserHistoryDataExtender dataExtender;
 
   public AndroidBrowserHistoryDataAccessor(Context context) {
     super(context);
     dataExtender = new AndroidBrowserHistoryDataExtender(context);
@@ -63,46 +64,51 @@ public class AndroidBrowserHistoryDataAc
   protected Uri getUri() {
     return BrowserContract.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.DATE_MODIFIED,        rec.lastModified);
-    cv.put(BrowserContract.History.TITLE,           rec.title);
-    cv.put(BrowserContract.History.URL,        rec.histURI);
+    cv.put(BrowserContract.History.GUID,          rec.guid);
+    cv.put(BrowserContract.History.DATE_MODIFIED, rec.lastModified);
+    cv.put(BrowserContract.History.TITLE,         rec.title);
+    cv.put(BrowserContract.History.URL,           rec.histURI);
     if (rec.visits != null) {
-      JSONArray visits = (JSONArray) rec.visits;
+      JSONArray visits = rec.visits;
       long mostRecent = 0;
       for (int i = 0; i < visits.size(); i++) {
         JSONObject visit = (JSONObject) visits.get(i);
         long visitDate = (Long) visit.get(AndroidBrowserHistoryRepositorySession.KEY_DATE);
         if (visitDate > mostRecent) {
           mostRecent = visitDate;
         }
       }
-      cv.put(BrowserContract.History.DATE_LAST_VISITED,    mostRecent);
+      // Fennec stores milliseconds. The rest of Sync works in microseconds.
+      cv.put(BrowserContract.History.DATE_LAST_VISITED, mostRecent / 1000);
+      cv.put(BrowserContract.History.VISITS, Long.toString(visits.size()));
     }
     return cv;
   }
 
   @Override
   protected String[] getAllColumns() {
     return BrowserContract.History.HistoryColumns;
   }
   
   @Override
   public Uri insert(Record record) {
     HistoryRecord rec = (HistoryRecord) record;
+    Log.d(LOG_TAG, "Storing visits for " + record.guid);
     dataExtender.store(record.guid, rec.visits);
+    Log.d(LOG_TAG, "Storing record " + record.guid);
     return super.insert(record);
   }  
   
   @Override
   protected void delete(String guid) {
-    context.getContentResolver().delete(getUri(), BrowserContract.SyncColumns.GUID + " = '" + guid + "'", null);
+    Log.d(LOG_TAG, "Deleting record " + guid);
+    super.delete(guid);
     dataExtender.delete(guid);
   }
 
 }
\ No newline at end of file
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserHistoryDataExtender.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserHistoryDataExtender.java
@@ -1,29 +1,67 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jason Voll <jvoll@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
 package org.mozilla.gecko.sync.repositories.android;
 
 import org.json.simple.JSONArray;
 import org.mozilla.gecko.sync.repositories.NullCursorException;
 
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteOpenHelper;
 import android.util.Log;
 
 public class AndroidBrowserHistoryDataExtender extends SQLiteOpenHelper {
 
   public static final String TAG = "AndroidBrowserHistoryDataExtender";
   
-  // Database Specifications
+  // Database Specifications.
   protected static final String DB_NAME = "history_extension_database";
   protected static final int SCHEMA_VERSION = 1;
 
-  // History Table
+  // History Table.
   public static final String TBL_HISTORY_EXT = "HistoryExtension";
   public static final String COL_GUID = "guid";
   public static final String COL_VISITS = "visits";
   
   protected AndroidBrowserHistoryDataExtender(Context context) {
     super(context, DB_NAME, null, SCHEMA_VERSION);
   }
 
@@ -70,55 +108,59 @@ public class AndroidBrowserHistoryDataEx
       AndroidBrowserHistoryDataExtender.writableDatabase.close();
       AndroidBrowserHistoryDataExtender.writableDatabase = null;
     }
     super.close();
   }
 
   @Override
   public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
-    // For now we'll just drop and recreate the tables
+    // For now we'll just drop and recreate the tables.
     db.execSQL("DROP TABLE IF EXISTS " + TBL_HISTORY_EXT);
     onCreate(db);
   }
 
   public void wipe() {
     SQLiteDatabase db = this.getCachedWritableDatabase();
     onUpgrade(db, SCHEMA_VERSION, SCHEMA_VERSION);
   }
 
-  // If a record with given guid exists, we'll delete it
-  // and store the updated version
+  // If a record with given GUID exists, we'll delete it
+  // and store the updated version.
   public long store(String guid, JSONArray visits) {
     SQLiteDatabase db = this.getCachedReadableDatabase();
     
     // delete if exists
     delete(guid);
     
     // insert new
     ContentValues cv = new ContentValues();
     cv.put(COL_GUID, guid);
-    if (visits == null) cv.put(COL_VISITS, new JSONArray().toJSONString());
-    else cv.put(COL_VISITS, visits.toJSONString());
+    if (visits == null) {
+      cv.put(COL_VISITS, "[]");
+    } else {
+      cv.put(COL_VISITS, visits.toJSONString());
+    }
     long rowId = db.insert(TBL_HISTORY_EXT, null, cv);
     Log.i(TAG, "Inserted history extension record into row: " + rowId);
     return rowId;
   }
   
   public Cursor fetch(String guid) throws NullCursorException {
     SQLiteDatabase db = this.getCachedReadableDatabase();
     long queryStart = System.currentTimeMillis();
-    Cursor cur = db.query(TBL_HISTORY_EXT, new String[] { COL_GUID, COL_VISITS },
-        COL_GUID + " = '" + guid + "'", null, null, null, null);
+    Cursor cur = db.query(TBL_HISTORY_EXT,
+                          new String[] { COL_GUID, COL_VISITS },
+                          COL_GUID + " = '" + guid + "'",
+                          null, null, null, null);
     RepoUtils.queryTimeLogger("AndroidBrowserHistoryDataExtender.fetch(guid)", queryStart, System.currentTimeMillis());
     if (cur == null) {
       Log.e(TAG, "Got a null cursor while doing fetch for guid " + guid + " on history extension table");
       throw new NullCursorException(null);
     }
     return cur;
   }
   
   public void delete(String guid) {
     SQLiteDatabase db = this.getCachedWritableDatabase();
     db.delete(TBL_HISTORY_EXT, COL_GUID + " = '" + guid + "'", null);
   }
-
 }
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
@@ -14,17 +14,18 @@
  * The Original Code is Android Sync Client.
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
- * Jason Voll <jvoll@mozilla.com>
+ *   Jason Voll <jvoll@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -34,25 +35,24 @@
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.android;
 
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
-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.domain.HistoryRecord;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 import android.content.Context;
 import android.database.Cursor;
+import android.util.Log;
 
 public class AndroidBrowserHistoryRepositorySession extends AndroidBrowserRepositorySession {
   
   public static final String KEY_DATE = "date";
   public static final String KEY_TYPE = "type";
   public static final long DEFAULT_VISIT_TYPE = 1;
 
   public AndroidBrowserHistoryRepositorySession(Repository repository, Context context) {
@@ -65,65 +65,74 @@ public class AndroidBrowserHistoryReposi
     return RepoUtils.historyFromMirrorCursor(cur);
   }
 
   @Override
   protected String buildRecordString(Record record) {
     HistoryRecord hist = (HistoryRecord) record;
     return hist.title + hist.histURI;
   }
-  
-  @Override
-  protected Record[] doFetch(String[] guids) throws NoGuidForIdException,
-      NullCursorException, ParentNotFoundException {
-    return addVisitsToRecords(super.doFetch(guids));
-  }
-  
+
   @Override
-  protected Record[] doFetchSince(long since) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    return addVisitsToRecords(super.doFetchSince(since));
+  protected Record transformRecord(Record record) throws NullCursorException {
+    return addVisitsToRecord(record);
   }
-  
-  @Override
-  protected Record[] doFetchAll() throws NullCursorException, NoGuidForIdException, ParentNotFoundException {
-    return addVisitsToRecords(super.doFetchAll());
-  }
-  
+
   @SuppressWarnings("unchecked")
-  private Record[] addVisitsToRecords(Record[] records) throws NullCursorException {
-    AndroidBrowserHistoryDataExtender dataExtender = ((AndroidBrowserHistoryDataAccessor) dbHelper).getHistoryDataExtender();
-    for(int i = 0; i < records.length; i++) {
-      HistoryRecord hist = (HistoryRecord) records[i];
-      Cursor visits = dataExtender.fetch(hist.guid);
-      visits.moveToFirst();
-      JSONArray visitsArray = RepoUtils.getJSONArrayFromCursor(visits, AndroidBrowserHistoryDataExtender.COL_VISITS);
-      long missingRecords = hist.fennecVisitCount - visitsArray.size();
-      
-      // Add missingRecords -1 fake visits
-      if (missingRecords >= 1) {
-        
-        if (missingRecords > 1) {
-          for (int j = 0; j < missingRecords -1; j++) {
-            JSONObject fake = new JSONObject();
-            
-            // Set fake visit timestamp to be just previous to
-            // the real one we are about to add.
-            fake.put(KEY_DATE, (long) hist.fennecDateVisited - (1+j));
-            fake.put(KEY_TYPE, DEFAULT_VISIT_TYPE);
-            visitsArray.add(fake);
-          }
-        }
-        
-        // Add the 1 actual record we have
-        // (unfortunately we still have to fake the
-        // visit type since Fennec doesn't track that)
-        JSONObject real = new JSONObject();
-        real.put(KEY_DATE, hist.fennecDateVisited);
-        real.put(KEY_TYPE, DEFAULT_VISIT_TYPE);
-        visitsArray.add(real);
+  private void addVisit(JSONArray visits, long date, long visitType) {
+    JSONObject visit = new JSONObject();
+    visit.put(KEY_DATE, date);               // Microseconds since epoch.
+    visit.put(KEY_TYPE, visitType);
+    visits.add(visit);
+  }
+
+  private void addVisit(JSONArray visits, long date) {
+    addVisit(visits, date, DEFAULT_VISIT_TYPE);
+  }
+
+  private AndroidBrowserHistoryDataExtender getDataExtender() {
+    return ((AndroidBrowserHistoryDataAccessor) dbHelper).getHistoryDataExtender();
+  }
+
+  private JSONArray visitsForGUID(String guid) throws NullCursorException {
+    Log.d(LOG_TAG, "Fetching visits for GUID " + guid);
+    Cursor visits = getDataExtender().fetch(guid);
+    try {
+      if (!visits.moveToFirst()) {
+        // Cursor is empty.
+        return new JSONArray();
+      } else {
+        return RepoUtils.getJSONArrayFromCursor(visits, AndroidBrowserHistoryDataExtender.COL_VISITS);
       }
-      hist.visits = visitsArray;
-      records[i] = hist;
+    } finally {
+      visits.close();
     }
-    
-    return records;
+  }
+
+  private Record addVisitsToRecord(Record record) throws NullCursorException {
+    Log.d(LOG_TAG, "Adding visits for GUID " + record.guid);
+    HistoryRecord hist = (HistoryRecord) record;
+    JSONArray visitsArray = visitsForGUID(hist.guid);
+    long missingRecords = hist.fennecVisitCount - visitsArray.size();
+
+    // Note that Fennec visit times are milliseconds, and we are working
+    // in microseconds. This is the point at which we translate.
+
+    // Add (missingRecords - 1) fake visits...
+    if (missingRecords > 0) {
+      long fakes = missingRecords - 1;
+      for (int j = 0; j < fakes; j++) {
+        // Set fake visit timestamp to be just previous to
+        // the real one we are about to add.
+        // TODO: make these equidistant?
+        long fakeDate = (hist.fennecDateVisited - (1 + j)) * 1000;
+        addVisit(visitsArray, fakeDate);
+      }
+
+      // ... and the 1 actual record we have.
+      // We still have to fake the visit type: Fennec doesn't track that.
+      addVisit(visitsArray, hist.fennecDateVisited * 1000);
+    }
+
+    hist.visits = visitsArray;
+    return hist;
   }
 }
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java
@@ -14,17 +14,18 @@
  * The Original Code is Android Sync Client.
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
- * Jason Voll <jvoll@mozilla.com>
+ *   Jason Voll <jvoll@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -43,126 +44,155 @@ import org.mozilla.gecko.sync.repositori
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
 import android.util.Log;
 
 public abstract class AndroidBrowserRepositoryDataAccessor {
 
+  private static final String[] GUID_COLUMNS = new String[] { BrowserContract.SyncColumns.GUID };
   protected Context context;
   protected String LOG_TAG = "AndroidBrowserRepositoryDataAccessor";
+  private final RepoUtils.QueryHelper queryHelper;
 
   public AndroidBrowserRepositoryDataAccessor(Context context) {
     this.context = context;
+    this.queryHelper = new RepoUtils.QueryHelper(context, getUri(), LOG_TAG);
   }
 
   protected abstract String[] getAllColumns();
   protected abstract ContentValues getContentValues(Record record);
   protected abstract Uri getUri();
-  protected long queryStart = 0;
-  protected long queryEnd = 0;
-  
+
+  public String dateModifiedWhere(long timestamp) {
+    return BrowserContract.SyncColumns.DATE_MODIFIED + " >= " + Long.toString(timestamp);
+  }
+
   public void wipe() {
     Log.i(LOG_TAG, "wiping: " + getUri());
     String where = BrowserContract.SyncColumns.GUID + " NOT IN ('mobile')";
     context.getContentResolver().delete(getUri(), where, null);
   }
   
   public void purgeDeleted() throws NullCursorException {
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(),
-        new String[] { BrowserContract.SyncColumns.GUID },
-        BrowserContract.SyncColumns.IS_DELETED + "= 1", null, null);
-    queryEnd = System.currentTimeMillis();
-    RepoUtils.queryTimeLogger(LOG_TAG + ".purgeDeleted", queryStart, queryEnd);
-    if (cur == null) {
-      Log.e(LOG_TAG, "Got back a null cursor in AndroidBrowserRepositoryDataAccessor.purgeDeleted");
-      throw new NullCursorException(null);
+    String where = BrowserContract.SyncColumns.IS_DELETED + "= 1";
+    Cursor cur = queryHelper.safeQuery(".purgeDeleted", GUID_COLUMNS, where, null, null);
+
+    try {
+      if (!cur.moveToFirst()) {
+        return;
+      }
+      while (!cur.isAfterLast()) {
+        delete(RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID));
+        cur.moveToNext();
+      }
+    } finally {
+      cur.close();
     }
-    cur.moveToFirst();
-    while (!cur.isAfterLast()) {
-      delete(RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID));
-      cur.moveToNext();
-    }
-    cur.close();
   }
   
   protected void delete(String guid) {
-    context.getContentResolver().delete(getUri(), BrowserContract.SyncColumns.GUID + " = '" + guid + "'", null);
+    String where  = BrowserContract.SyncColumns.GUID + " = ?";
+    String[] args = new String[] { guid };
+
+    int deleted = context.getContentResolver().delete(getUri(), where, args);
+    if (deleted == 1) {
+      return;
+    }
+    Log.w(LOG_TAG, "Unexpectedly deleted " + deleted + " rows for guid " + guid);
   }
 
   public Uri insert(Record record) {
     ContentValues cv = getContentValues(record);
     return context.getContentResolver().insert(getUri(), cv);
   }
-  
+
+  /**
+   * Fetch all records.
+   * The caller is responsible for closing the cursor.
+   *
+   * @return A cursor. You *must* close this when you're done with it.
+   * @throws NullCursorException
+   */
   public Cursor fetchAll() throws NullCursorException {
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(),
-        getAllColumns(), null, null, null);
-    queryEnd = System.currentTimeMillis();
-    
-    RepoUtils.queryTimeLogger(LOG_TAG + ".fetchAll", queryStart, queryEnd);
-    if (cur == null) {
-      Log.e(LOG_TAG, "Got null cursor exception in AndroidBrowserRepositoryDataAccessor.fetchAll");
-      throw new NullCursorException(null);
-    }
-    return cur;
+    return queryHelper.safeQuery(".fetchAll", getAllColumns(), null, null, null);
   }
   
+  /**
+   * Fetch GUIDs for records modified since the provided timestamp.
+   * The caller is responsible for closing the cursor.
+   *
+   * @param timestamp
+   * @return A cursor. You *must* close this when you're done with it.
+   * @throws NullCursorException
+   */
   public Cursor getGUIDsSince(long timestamp) throws NullCursorException {
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(),
-        new String[] { BrowserContract.SyncColumns.GUID },
-        BrowserContract.SyncColumns.DATE_MODIFIED + " >= " +
-        Long.toString(timestamp), null, null);
-    queryEnd = System.currentTimeMillis();
-    RepoUtils.queryTimeLogger(LOG_TAG + ".getGUIDsSince", queryStart, queryEnd);
-    if (cur == null) {
-      Log.e(LOG_TAG, "Got null cursor exception in AndroidBrowserRepositoryDataAccessor.getGUIDsSince");
-      throw new NullCursorException(null);
-    }
-    return cur;
+    return queryHelper.safeQuery(".getGUIDsSince",
+        GUID_COLUMNS,
+        dateModifiedWhere(timestamp),
+        null, null);
   }
 
+  /**
+   * Fetch records modified since the provided timestamp.
+   * The caller is responsible for closing the cursor.
+   *
+   * @param timestamp
+   * @return A cursor. You *must* close this when you're done with it.
+   * @throws NullCursorException
+   */
   public Cursor fetchSince(long timestamp) throws NullCursorException {
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(),
+    return queryHelper.safeQuery(".fetchSince",
         getAllColumns(),
-        BrowserContract.SyncColumns.DATE_MODIFIED + " >= " +
-        Long.toString(timestamp), null, null);
-    queryEnd = System.currentTimeMillis();
-    RepoUtils.queryTimeLogger(LOG_TAG + ".fetchSince", queryStart, queryEnd);
-    if (cur == null) {
-      Log.e(LOG_TAG, "Got null cursor exception in AndroidBrowserRepositoryDataAccessor.fetchSince");
-      throw new NullCursorException(null);
-    }
-    return cur;
+        dateModifiedWhere(timestamp),
+        null, null);
   }
 
+  /**
+   * Fetch records for the provided GUIDs.
+   * The caller is responsible for closing the cursor.
+   *
+   * @param guids
+   * @return A cursor. You *must* close this when you're done with it.
+   * @throws NullCursorException
+   */
   public Cursor fetch(String guids[]) throws NullCursorException {
-    String where = "guid" + " in (";
-    for (String guid : guids) {
-      where = where + "'" + guid + "', ";
+    String where = computeSQLInClause(guids.length, "guid");
+    return queryHelper.safeQuery(".fetch", getAllColumns(), where, guids, null);
+  }
+
+  protected String computeSQLInClause(int items, String field) {
+    StringBuilder builder = new StringBuilder(field);
+    builder.append(" IN (");
+    int i = 0;
+    for (; i < items - 1; ++i) {
+      builder.append("?, ");
     }
-    where = (where.substring(0, where.length() -2) + ")");
-    queryStart = System.currentTimeMillis();
-    Cursor cur = context.getContentResolver().query(getUri(), getAllColumns(), where, null, null);
-    queryEnd = System.currentTimeMillis();
-    RepoUtils.queryTimeLogger(LOG_TAG + ".fetch", queryStart, queryEnd);
-    if (cur == null) {
-      Log.e(LOG_TAG, "Got null cursor exception in AndroidBrowserRepositoryDataAccessor.fetch");
-      throw new NullCursorException(null);
+    if (i < items) {
+      builder.append("?");
     }
-    return cur;
+    builder.append(")");
+    return builder.toString();
   }
 
   public void delete(Record record) {
-    context.getContentResolver().delete(getUri(),
-         BrowserContract.SyncColumns.GUID + " = '" + record.guid +"'", null);
+    String where  = BrowserContract.SyncColumns.GUID + " = ?";
+    String[] args = new String[] { record.guid };
+
+    int deleted = context.getContentResolver().delete(getUri(), where, args);
+    if (deleted == 1) {
+      return;
+    }
+    Log.w(LOG_TAG, "Unexpectedly deleted " + deleted + " rows for guid " + record.guid);
   }
 
   public void updateByGuid(String guid, ContentValues cv) {
-    context.getContentResolver().update(getUri(), cv,
-        BrowserContract.SyncColumns.GUID + " = '" + guid +"'", null);
+    String where  = BrowserContract.SyncColumns.GUID + " = ?";
+    String[] args = new String[] { guid };
+
+    int updated = context.getContentResolver().update(getUri(), cv, where, args);
+    if (updated == 1) {
+      return;
+    }
+    Log.w(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + guid);
   }
 }
--- a/mobile/android/base/sync/repositories/android/AndroidBrowserRepositorySession.java
+++ b/mobile/android/base/sync/repositories/android/AndroidBrowserRepositorySession.java
@@ -14,17 +14,18 @@
  * The Original Code is Android Sync Client.
  *
  * The Initial Developer of the Original Code is
  * the Mozilla Foundation.
  * Portions created by the Initial Developer are Copyright (C) 2011
  * the Initial Developer. All Rights Reserved.
  *
  * Contributor(s):
- * Jason Voll <jvoll@mozilla.com>
+ *   Jason Voll <jvoll@mozilla.com>
+ *   Richard Newman <rnewman@mozilla.com>
  *
  * Alternatively, the contents of this file may be used under the terms of
  * either the GNU General Public License Version 2 or later (the "GPL"), or
  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
  * in which case the provisions of the GPL or the LGPL are applicable instead
  * of those above. If you wish to allow use of your version of this file only
  * under the terms of either the GPL or the LGPL, and not to allow others to
  * use your version of this file under the terms of the MPL, indicate your
@@ -36,102 +37,155 @@
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.android;
 
 import java.util.ArrayList;
 import java.util.HashMap;
 
 import org.mozilla.gecko.sync.repositories.InactiveSessionException;
-import org.mozilla.gecko.sync.repositories.InvalidBookmarkTypeException;
 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.Repository;
 import org.mozilla.gecko.sync.repositories.RepositorySession;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
 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 android.database.Cursor;
 import android.util.Log;
 
+/**
+ * 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 RepositorySession {
 
   protected AndroidBrowserRepositoryDataAccessor dbHelper;
   protected static final String LOG_TAG = "AndroidBrowserRepositorySession";
   private HashMap<String, String> recordToGuid;
 
   public AndroidBrowserRepositorySession(Repository repository) {
     super(repository);
   }
 
+  /**
+   * Override this.
+   * Return null if this record should not be processed.
+   *
+   * @param cur
+   * @return
+   * @throws NoGuidForIdException
+   * @throws NullCursorException
+   * @throws ParentNotFoundException
+   */
+  protected abstract Record recordFromMirrorCursor(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException;
+
+  // Must be overriden by AndroidBookmarkRepositorySession.
+  protected boolean checkRecordType(Record record) {
+    return true;
+  }
+
+  /**
+   * Override in subclass to implement record extension.
+   * 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
+   */
+  protected Record transformRecord(Record record) throws NullCursorException {
+    return record;
+  }
+
   @Override
   public void begin(RepositorySessionBeginDelegate delegate) {
+    RepositorySessionBeginDelegate deferredDelegate = delegate.deferredBeginDelegate(delegateQueue);
     try {
       super.sharedBegin();
     } catch (InvalidSessionTransitionException e) {
-      delegate.onBeginFailed(e);
+      deferredDelegate.onBeginFailed(e);
       return;
     }
 
     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) {
       Log.e(LOG_TAG, "ProfileDatabaseException from begin. Fennec must be launched once until this error is fixed");
-      delegate.onBeginFailed(e);
+      deferredDelegate.onBeginFailed(e);
       return;
     } catch (NullCursorException e) {
-      delegate.onBeginFailed(e);
+      deferredDelegate.onBeginFailed(e);
       return;
     } catch (Exception e) {
-      delegate.onBeginFailed(e);
+      deferredDelegate.onBeginFailed(e);
       return;
     }
-    delegate.onBeginSucceeded(this);
+    deferredDelegate.onBeginSucceeded(this);
   }
 
   protected abstract String buildRecordString(Record record);
 
   protected void checkDatabase() throws ProfileDatabaseException, NullCursorException {
+    Log.i(LOG_TAG, "Checking database.");
     try {
-      dbHelper.fetch(new String[] { "none" });
+      dbHelper.fetch(new String[] { "none" }).close();
     } catch (NullPointerException e) {
       throw new ProfileDatabaseException(e);
     }
   }
 
-  // guids since method and thread
   @Override
   public void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate) {
-    GuidsSinceThread thread = new GuidsSinceThread(timestamp, delegate);
-    thread.start();
+    GuidsSinceRunnable command = new GuidsSinceRunnable(timestamp, delegate);
+    delegateQueue.execute(command);
   }
 
-  class GuidsSinceThread extends Thread {
+  class GuidsSinceRunnable implements Runnable {
 
-    private long                                   timestamp;
-    private RepositorySessionGuidsSinceDelegate    delegate;
+    private RepositorySessionGuidsSinceDelegate delegate;
+    private long                                timestamp;
 
-    public GuidsSinceThread(long timestamp,
-        RepositorySessionGuidsSinceDelegate delegate) {
+    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 {
@@ -139,301 +193,319 @@ public abstract class AndroidBrowserRepo
       } catch (NullCursorException e) {
         delegate.onGuidsSinceFailed(e);
         return;
       } catch (Exception e) {
         delegate.onGuidsSinceFailed(e);
         return;
       }
 
-      ArrayList<String> guids = new ArrayList<String>();
-      cur.moveToFirst();
-      while (!cur.isAfterLast()) {
-        guids.add(RepoUtils.getStringFromCursor(cur, "guid"));
-        cur.moveToNext();
+      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 {
+        Log.d(LOG_TAG, "Closing cursor after guidsSince.");
+        cur.close();
       }
-      cur.close();
 
       String guidsArray[] = new String[guids.size()];
       guids.toArray(guidsArray);
       delegate.onGuidsSinceSucceeded(guidsArray);
-
     }
   }
 
-  protected Record[] compileIntoRecordsArray(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    ArrayList<Record> records = new ArrayList<Record>();
-    cur.moveToFirst();
-    while (!cur.isAfterLast()) {
-      records.add(recordFromMirrorCursor(cur));
-      cur.moveToNext();
-    }
-    cur.close();
-
-    Record[] recordArray = new Record[records.size()];
-    records.toArray(recordArray);
-    return recordArray;
+  @Override
+  public void fetch(String[] guids,
+                    RepositorySessionFetchRecordsDelegate delegate) {
+    FetchRunnable command = new FetchRunnable(guids, now(), delegate);
+    delegateQueue.execute(command);
   }
 
-  protected abstract Record recordFromMirrorCursor(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException;
-  
-  
-
-  // Fetch since method and thread
-  @Override
-  public void fetchSince(long timestamp,
-                         RepositorySessionFetchRecordsDelegate delegate) {
-    FetchSinceThread thread = new FetchSinceThread(timestamp, now(), delegate);
-    thread.start();
-  }
+  abstract class FetchingRunnable implements Runnable {
+    protected RepositorySessionFetchRecordsDelegate delegate;
 
-  class FetchSinceThread extends Thread {
-
-    private long since;
-    private long end;
-    private RepositorySessionFetchRecordsDelegate delegate;
-
-    public FetchSinceThread(long since,
-                            long end,
-                            RepositorySessionFetchRecordsDelegate delegate) {
-      this.since    = since;
-      this.end      = end;
+    public FetchingRunnable(RepositorySessionFetchRecordsDelegate delegate) {
       this.delegate = delegate;
     }
 
-    public void run() {
-      if (!isActive()) {
-        delegate.onFetchFailed(new InactiveSessionException(null), null);
-        return;
-      }
-
+    protected void fetchFromCursor(Cursor cursor, long end) {
+      Log.d(LOG_TAG, "Fetch from cursor:");
       try {
-        delegate.onFetchSucceeded(doFetchSince(since), end);
-      } catch (NoGuidForIdException e) {
-        delegate.onFetchFailed(e, null);
-        return;
-      } catch (NullCursorException e) {
-        delegate.onFetchFailed(e, null);
-        return;
-      } catch (Exception e) {
-        delegate.onFetchFailed(e, null);
-        return;
+        try {
+          if (!cursor.moveToFirst()) {
+            delegate.onFetchCompleted(end);
+            return;
+          }
+          while (!cursor.isAfterLast()) {
+            Log.d(LOG_TAG, "... one more record.");
+            Record r = transformRecord(recordFromMirrorCursor(cursor));
+            if (r != null) {
+              delegate.onFetchedRecord(r);
+            }
+            cursor.moveToNext();
+          }
+          delegate.onFetchCompleted(end);
+        } catch (NoGuidForIdException e) {
+          Log.w(LOG_TAG, "No GUID for ID.", e);
+          delegate.onFetchFailed(e, null);
+        } catch (Exception e) {
+          Log.w(LOG_TAG, "Exception in fetchFromCursor.", e);
+          delegate.onFetchFailed(e, null);
+          return;
+        }
+      } finally {
+        Log.d(LOG_TAG, "Closing cursor after fetch.");
+        cursor.close();
       }
     }
   }
-  
-  protected Record[] doFetchSince(long since) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    return compileIntoRecordsArray(dbHelper.fetchSince(since));
-  }
 
-  // Fetch method and thread
-  @Override
-  public void fetch(String[] guids,
-                    RepositorySessionFetchRecordsDelegate delegate) {
-    FetchThread thread = new FetchThread(guids, now(), delegate);
-    thread.start();
-  }
-
-  class FetchThread extends Thread {
+  class FetchRunnable extends FetchingRunnable {
     private String[] guids;
     private long     end;
-    private RepositorySessionFetchRecordsDelegate delegate;
 
-    public FetchThread(String[] guids,
-                       long end,
-                       RepositorySessionFetchRecordsDelegate delegate) {
-      this.guids    = guids;
-      this.end      = end;
-      this.delegate = delegate;
+    public FetchRunnable(String[] guids,
+                         long end,
+                         RepositorySessionFetchRecordsDelegate delegate) {
+      super(delegate);
+      this.guids = guids;
+      this.end   = end;
     }
 
+    @Override
     public void run() {
       if (!isActive()) {
         delegate.onFetchFailed(new InactiveSessionException(null), null);
         return;
       }
 
       if (guids == null || guids.length < 1) {
         Log.e(LOG_TAG, "No guids sent to fetch");
         delegate.onFetchFailed(new InvalidRequestException(null), null);
-      } else {
-        try {
-          delegate.onFetchSucceeded(doFetch(guids), end);
-        } catch (NoGuidForIdException e) {
-          delegate.onFetchFailed(e, null);
-        } catch (NullCursorException e) {
-          delegate.onFetchFailed(e, null);
-        } catch (Exception e) {
+        return;
+      }
+
+      try {
+        Cursor cursor = dbHelper.fetch(guids);
+        this.fetchFromCursor(cursor, end);
+      } catch (NullCursorException e) {
         delegate.onFetchFailed(e, null);
-        return;
-        }
       }
     }
   }
 
-  protected Record[] doFetch(String[] guids) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    Cursor cur = dbHelper.fetch(guids);
-    return compileIntoRecordsArray(cur);
+  @Override
+  public void fetchSince(long timestamp,
+                         RepositorySessionFetchRecordsDelegate delegate) {
+    Log.i(LOG_TAG, "Running fetchSince(" + timestamp + ").");
+    FetchSinceRunnable command = new FetchSinceRunnable(timestamp, now(), delegate);
+    delegateQueue.execute(command);
   }
 
-  // Fetch all method and thread
-  @Override
-  public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
-    FetchAllThread thread = new FetchAllThread(now(), delegate);
-    thread.start();
-  }
+  class FetchSinceRunnable extends FetchingRunnable {
+    private long since;
+    private long end;
 
-  class FetchAllThread extends Thread {
-    private long end;
-    private RepositorySessionFetchRecordsDelegate delegate;
-
-    public FetchAllThread(long end, RepositorySessionFetchRecordsDelegate delegate) {
-      this.end      = end;
-      this.delegate = delegate;
+    public FetchSinceRunnable(long since,
+                              long end,
+                              RepositorySessionFetchRecordsDelegate delegate) {
+      super(delegate);
+      this.since = since;
+      this.end   = end;
     }
 
+    @Override
     public void run() {
       if (!isActive()) {
         delegate.onFetchFailed(new InactiveSessionException(null), null);
         return;
       }
 
       try {
-        delegate.onFetchSucceeded(doFetchAll(), end);
-      } catch (NoGuidForIdException e) {
-        delegate.onFetchFailed(e, null);
-        return;
+        Cursor cursor = dbHelper.fetchSince(since);
+        this.fetchFromCursor(cursor, end);
       } catch (NullCursorException e) {
         delegate.onFetchFailed(e, null);
         return;
-      } catch (Exception e) {
-        delegate.onFetchFailed(e, null);
-        return;
       }
     }
   }
-  
-  protected Record[] doFetchAll() throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    return compileIntoRecordsArray(dbHelper.fetchAll());
-  }
 
-  // Store method and thread
   @Override
-  public void store(Record record, RepositorySessionStoreDelegate delegate) {
-    StoreThread thread = new StoreThread(record, delegate);
-    thread.start();
+  public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
+    this.fetchSince(0, delegate);
   }
 
-  class StoreThread extends Thread {
-    private Record                         record;
-    private RepositorySessionStoreDelegate delegate;
-
-    public StoreThread(Record record, RepositorySessionStoreDelegate delegate) {
-      if (record == null) {
-        Log.e(LOG_TAG, "Record sent to store was null");
-        throw new IllegalArgumentException("record is null.");
-      }
-      this.record = record;
-      this.delegate = delegate;
+  @Override
+  public void store(final Record record) throws NoStoreDelegateException {
+    if (delegate == null) {
+      throw new NoStoreDelegateException();
+    }
+    if (record == null) {
+      Log.e(LOG_TAG, "Record sent to store was null");
+      throw new IllegalArgumentException("Null record passed to AndroidBrowserRepositorySession.store().");
     }
 
-    public void run() {
-      if (!isActive()) {
-        delegate.onStoreFailed(new InactiveSessionException(null));
-        return;
-      }
+    // Store Runnables *must* complete synchronously. It's OK, they
+    // run on a background thread.
+    Runnable command = new Runnable() {
+
+      @Override
+      public void run() {
+        if (!isActive()) {
+          delegate.onRecordStoreFailed(new InactiveSessionException(null));
+          return;
+        }
 
-      // Check that the record is a valid type
-      // TODO Currently for bookmarks we only take care of folders
-      // and bookmarks, all other types are ignored and thrown away
-      if (!checkRecordType(record)) {
-        delegate.onStoreFailed(new InvalidBookmarkTypeException(null));
-        return;
-      }
+        // Check that the record is a valid type
+        // TODO Currently for bookmarks we only take care of folders
+        // and bookmarks, all other types are ignored and thrown away
+        if (!checkRecordType(record)) {
+          Log.d(LOG_TAG, "Ignoring record " + record.guid + " due to unknown record type.");
 
-      Record existingRecord;
-      try {
-        existingRecord = findExistingRecord(this.record);
+          // Don't throw: we don't want to abort the entire sync when we get a livemark!
+          // delegate.onRecordStoreFailed(new InvalidBookmarkTypeException(null));
+          return;
+        }
 
-        // If the record is new and not deleted, store it
-        if (existingRecord == null && !record.deleted) {
-          record.androidID = insert(record);
-        } else if (existingRecord != null) {
+        // TODO:
+        // TODO: rnewman 2012-01-13: read and improve this code.
+        // TODO:
+        Record existingRecord;
+        try {
+          existingRecord = findExistingRecord(record);
 
-          dbHelper.delete(existingRecord);
-          // Or clause: We won't store a remotely deleted record ever, but if it is marked deleted
-          // and our existing record has a newer timestamp, we will restore the existing record
-          if (!record.deleted || (record.deleted && existingRecord.lastModified > record.lastModified)) {
-            // Record exists already, need to figure out what to store
-            Record store = reconcileRecords(existingRecord, record);
-            record.androidID = insert(store);
+          // If the record is new and not deleted, store it
+          if (existingRecord == null && !record.deleted) {
+            record.androidID = insert(record);
+          } else if (existingRecord != null) {
+
+            dbHelper.delete(existingRecord);
+            // Or clause: We won't store a remotely deleted record ever, but if it is marked deleted
+            // and our existing record has a newer timestamp, we will restore the existing record
+            if (!record.deleted || (record.deleted && existingRecord.lastModified > record.lastModified)) {
+              // Record exists already, need to figure out what to store
+              Record store = reconcileRecords(existingRecord, record);
+              record.androidID = insert(store);
+            }
           }
+        } catch (MultipleRecordsForGuidException e) {
+          Log.e(LOG_TAG, "Multiple records returned for given guid: " + record.guid);
+          delegate.onRecordStoreFailed(e);
+          return;
+        } catch (NoGuidForIdException e) {
+          Log.e(LOG_TAG, "Store failed for " + record.guid, e);
+          delegate.onRecordStoreFailed(e);
+          return;
+        } catch (NullCursorException e) {
+          Log.e(LOG_TAG, "Store failed for " + record.guid, e);
+          delegate.onRecordStoreFailed(e);
+          return;
+        } catch (Exception e) {
+          Log.e(LOG_TAG, "Store failed for " + record.guid, e);
+          delegate.onRecordStoreFailed(e);
+          return;
         }
-      } catch (MultipleRecordsForGuidException e) {
-        Log.e(LOG_TAG, "Multiple records returned for given guid: " + record.guid);
-        delegate.onStoreFailed(e);
-        return;
-      } catch (NoGuidForIdException e) {
-        delegate.onStoreFailed(e);
-        return;
-      } catch (NullCursorException e) {
-        delegate.onStoreFailed(e);
-        return;
-      } catch (Exception e) {
-        delegate.onStoreFailed(e);
-        return;
+
+        // Invoke callback with result.
+        delegate.onRecordStoreSucceeded(record);
       }
-
-      // Invoke callback with result.
-      delegate.onStoreSucceeded(record);
-    }
-
+    };
+    storeWorkQueue.execute(command);
   }
   
   protected long insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
     putRecordToGuidMap(buildRecordString(record), record.guid);
     return RepoUtils.getAndroidIdFromUri(dbHelper.insert(record));
   }
 
-  // Check if record already exists locally
+  protected Record recordForGUID(String guid) throws
+                                             NoGuidForIdException,
+                                             NullCursorException,
+                                             ParentNotFoundException,
+                                             MultipleRecordsForGuidException {
+    Cursor cursor = dbHelper.fetch(new String[] { guid });
+    try {
+      if (!cursor.moveToFirst()) {
+        return null;
+      }
+
+      Record r = recordFromMirrorCursor(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();
+    }
+  }
+
+  // Check if record already exists locally.
   protected Record findExistingRecord(Record record) throws MultipleRecordsForGuidException,
     NoGuidForIdException, NullCursorException, ParentNotFoundException {
-    Record[] records = doFetch(new String[] { record.guid });
-    if (records.length == 1) {
-      return records[0];
-    } else if (records.length > 1) {
-      throw (new MultipleRecordsForGuidException(null));
-    } else {
-      // Check to see if record exists but with a different guid
-      String recordString = buildRecordString(record);
-      String guid = getRecordToGuidMap().get(recordString);
-      if (guid != null) {
-        return doFetch(new String[] { guid })[0];
-      }
+
+    Log.d(LOG_TAG, "Finding existing record for GUID " + record.guid);
+    Record r = recordForGUID(record.guid);
+
+    // One result. (Multiple throws an exception.)
+    if (r != null) {
+      Log.d(LOG_TAG, "Found one by GUID.");
+      return r;
     }
+
+    // Empty result.
+    // Check to see if record exists but with a different guid.
+    String recordString = buildRecordString(record);
+    Log.d(LOG_TAG, "Searching with record string " + recordString);
+    String guid = getRecordToGuidMap().get(recordString);
+    if (guid != null) {
+      Log.d(LOG_TAG, "Found one. Returning computed record.");
+      return recordForGUID(guid);
+    }
+    Log.d(LOG_TAG, "findExistingRecord failed to find one for " + record.guid);
     return null;
   }
 
   public HashMap<String, String> getRecordToGuidMap() throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
     if (recordToGuid == null) {
       createRecordToGuidMap();
     }
     return recordToGuid;
   }
 
   private void createRecordToGuidMap() throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
     recordToGuid = new HashMap<String, String>();
     Cursor cur = dbHelper.fetchAll();
-    cur.moveToFirst();
-    while (!cur.isAfterLast()) {
-      Record record = recordFromMirrorCursor(cur);
-      recordToGuid.put(buildRecordString(record), record.guid);
-      cur.moveToNext();
+    try {
+      if (!cur.moveToFirst()) {
+        return;
+      }
+      while (!cur.isAfterLast()) {
+        Record record = recordFromMirrorCursor(cur);
+        if (record != null) {
+          recordToGuid.put(buildRecordString(record), record.guid);
+        }
+        cur.moveToNext();
+      }
+    } finally {
+      cur.close();
     }
-    cur.close();
   }
 
   public void putRecordToGuidMap(String guid, String recordString) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
     if (recordToGuid == null) {
       createRecordToGuidMap();
     }
     recordToGuid.put(guid, recordString);
   }
@@ -453,32 +525,27 @@ public abstract class AndroidBrowserRepo
     if (newer.guid != remote.guid) {
       newer.guid = remote.guid;
     }
     newer.androidID = local.androidID;
 
     return newer;
   }
 
-  // Must be overrriden by AndroidBookmarkRepositorySession
-  protected boolean checkRecordType(Record record) {
-    return true;
-  }
-
   // Wipe method and thread.
   @Override
   public void wipe(RepositorySessionWipeDelegate delegate) {
-    WipeThread thread = new WipeThread(delegate);
-    thread.start();
+    Runnable command = new WipeRunnable(delegate);
+    storeWorkQueue.execute(command);
   }
 
-  class WipeThread extends Thread {
+  class WipeRunnable implements Runnable {
     private RepositorySessionWipeDelegate delegate;
 
-    public WipeThread(RepositorySessionWipeDelegate delegate) {
+    public WipeRunnable(RepositorySessionWipeDelegate delegate) {
       this.delegate = delegate;
     }
 
     public void run() {
       if (!isActive()) {
         delegate.onWipeFailed(new InactiveSessionException(null));
         return;
       }
--- a/mobile/android/base/sync/repositories/android/RepoUtils.java
+++ b/mobile/android/base/sync/repositories/android/RepoUtils.java
@@ -33,63 +33,176 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.android;
 
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.Map;
 
 import org.json.simple.JSONArray;
 import org.json.simple.parser.JSONParser;
 import org.json.simple.parser.ParseException;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
 import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
 import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
 import org.mozilla.gecko.sync.repositories.domain.PasswordRecord;
 
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
 import android.util.Log;
 
 public class RepoUtils {
 
   private static final String LOG_TAG = "DBUtils";
+
+  /**
+   * An array of known-special GUIDs.
+   */
   public static String[] SPECIAL_GUIDS = new String[] {
-    "menu",
+    // Mobile and desktop places roots have to come first.
+    "mobile",
     "places",
     "toolbar",
-    "unfiled",
-    "mobile"
+    "menu",
+    "unfiled"
   };
-  
-  // Map of guids to their localized name strings
-  public static HashMap<String, String> SPECIAL_GUIDS_MAP;
+
+  /**
+   * = 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.
+   *
+   * Sync skips over `places` and `tags` when finding IDs.
+   *
+   * We need to consume records with these various guids, producing a local
+   * representation which we are able to stably map upstream.
+   *
+   * That is:
+   *
+   * * We should not upload a `places` record or a `tags` record.
+   * * We can stably _store_ menu/toolbar/unfiled/mobile as special GUIDs, and set
+     * their parent ID as appropriate on upload.
+   *
+   *
+   * = 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
+   * ----------  ----------  ----------
+   * mobile      ?           0
+   *
+  */
+  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;
   public static void initialize(Context context) {
     if (SPECIAL_GUIDS_MAP == null) {
-      SPECIAL_GUIDS_MAP = new HashMap<String, String>();
-      SPECIAL_GUIDS_MAP.put("menu",    context.getString(R.string.bookmarks_folder_menu));
-      SPECIAL_GUIDS_MAP.put("places",  context.getString(R.string.bookmarks_folder_places));
-      SPECIAL_GUIDS_MAP.put("toolbar", context.getString(R.string.bookmarks_folder_toolbar));
-      SPECIAL_GUIDS_MAP.put("unfiled", context.getString(R.string.bookmarks_folder_unfiled));
-      SPECIAL_GUIDS_MAP.put("mobile",  context.getString(R.string.bookmarks_folder_mobile));
+      HashMap<String, String> m = new HashMap<String, String>();
+      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));
+      m.put("mobile",  context.getString(R.string.bookmarks_folder_mobile));
+      SPECIAL_GUIDS_MAP = Collections.unmodifiableMap(m);
+    }
+  }
+
+  /**
+   * 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;
+    }
+
+    public Cursor query(String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+      return this.query(null, projection, selection, selectionArgs, sortOrder);
+    }
+
+    public Cursor query(String label, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+      String logLabel = (label == null) ? this.tag : this.tag + label;
+      long queryStart = android.os.SystemClock.uptimeMillis();
+      Cursor c = context.getContentResolver().query(uri, projection, selection, selectionArgs, sortOrder);
+      long queryEnd   = android.os.SystemClock.uptimeMillis();
+      RepoUtils.queryTimeLogger(logLabel, queryStart, queryEnd);
+      return c;
+    }
+
+    public Cursor safeQuery(String label, String[] projection, String selection, String[] selectionArgs, String sortOrder) throws NullCursorException {
+      Cursor c = this.query(label, projection, selection, selectionArgs, sortOrder);
+      if (c == null) {
+        Log.e(tag, "Got null cursor exception in " + tag + ((label == null) ? "" : label));
+        throw new NullCursorException(null);
+      }
+      return c;
     }
   }
 
   public static String getStringFromCursor(Cursor cur, 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 long 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 (JSONArray) new JSONParser().parse(getStringFromCursor(cur, colId));
     } catch (ParseException e) {
@@ -101,67 +214,127 @@ public class RepoUtils {
   // Returns android id from the URI that we get after inserting a
   // bookmark into the local Android store.
   public static long getAndroidIdFromUri(Uri uri) {
     String path = uri.getPath();
     int lastSlash = path.lastIndexOf('/');
     return Long.parseLong(path.substring(lastSlash + 1));
   }
 
-  //Create a BookmarkRecord object from a cursor on a row with a Moz Bookmark in it
+  public static BookmarkRecord computeParentFields(BookmarkRecord rec, String suggestedParentID, String suggestedParentName) {
+    final String guid = rec.guid;
+    if (guid == null) {
+      // Oh dear.
+      Log.e(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 = suggestedParentID;
+    } else {
+      Log.d(LOG_TAG, "Ignoring suggested parent ID " + suggestedParentID +
+                       " for " + guid + "; using " + realParent);
+    }
+
+    if (realParent == null) {
+      // Oh dear.
+      Log.e(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;
+  }
+
+  // Create a BookmarkRecord object from a cursor on a row containing a Fennec bookmark.
   public static BookmarkRecord bookmarkFromMirrorCursor(Cursor cur, String parentId, String parentName, JSONArray children) {
 
     String guid = getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
     String collection = "bookmarks";
     long lastModified = getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
-    boolean deleted = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1 ? true : false;
+    boolean deleted   = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1 ? true : false;
+    boolean isFolder  = getIntFromCursor(cur, BrowserContract.Bookmarks.IS_FOLDER) == 1;
     BookmarkRecord rec = new BookmarkRecord(guid, collection, lastModified, deleted);
 
     rec.title = getStringFromCursor(cur, BrowserContract.Bookmarks.TITLE);
     rec.bookmarkURI = getStringFromCursor(cur, BrowserContract.Bookmarks.URL);
     rec.description = getStringFromCursor(cur, BrowserContract.Bookmarks.DESCRIPTION);
     rec.tags = getJSONArrayFromCursor(cur, BrowserContract.Bookmarks.TAGS);
     rec.keyword = getStringFromCursor(cur, BrowserContract.Bookmarks.KEYWORD);
-    rec.type = cur.getInt(cur.getColumnIndex(BrowserContract.Bookmarks.IS_FOLDER)) == 0 ?
-      AndroidBrowserBookmarksDataAccessor.TYPE_BOOKMARK : AndroidBrowserBookmarksDataAccessor.TYPE_FOLDER;
+    rec.type = isFolder ? AndroidBrowserBookmarksDataAccessor.TYPE_FOLDER :
+                          AndroidBrowserBookmarksDataAccessor.TYPE_BOOKMARK;
 
     rec.androidID = getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
     rec.androidPosition = getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION);
     rec.children = children;
 
-    // Need to restore the parentId since it isn't stored in content provider
-    rec.parentID = parentId;
-    // Set parent name
-    // Always set the parent name for special folders back to default so stuff doesn't go crazy
-    if (SPECIAL_GUIDS_MAP.containsKey(rec.parentID)) {
-      rec.parentName = SPECIAL_GUIDS_MAP.get(rec.parentID);
-    } else {
-      rec.parentName = parentName;
+    // 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.
+    return logBookmark(computeParentFields(rec, parentId, parentName));
+  }
+
+  private static BookmarkRecord logBookmark(BookmarkRecord rec) {
+    try {
+      Log.d(LOG_TAG, "Returning bookmark record " + rec.guid + " (" + rec.androidID +
+          ", " + rec.parentName + ":" + rec.parentID + ")");
+      Log.d(LOG_TAG, "> Title:            " + rec.title);
+      Log.d(LOG_TAG, "> Type:             " + rec.type);
+      Log.d(LOG_TAG, "> URI:              " + rec.bookmarkURI);
+      Log.d(LOG_TAG, "> Android position: " + rec.androidPosition);
+      Log.d(LOG_TAG, "> Position:         " + rec.pos);
+      if (rec.isFolder()) {
+        Log.d(LOG_TAG, "FOLDER: Children are " + (rec.children == null ? "null" : rec.children.toJSONString()));
+      }
+    } catch (Exception e) {
+      Log.d(LOG_TAG, "Exception logging bookmark record " + rec, e);
     }
     return rec;
   }
 
   //Create a HistoryRecord object from a cursor on a row with a Moz History record in it
   public static HistoryRecord historyFromMirrorCursor(Cursor cur) {
 
     String guid = getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
     String collection = "history";
-    long lastModified = getLongFromCursor(cur,BrowserContract.SyncColumns.DATE_MODIFIED);
+    long lastModified = getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
     boolean deleted = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1 ? true : false;
     HistoryRecord rec = new HistoryRecord(guid, collection, lastModified, deleted);
 
     rec.title = getStringFromCursor(cur, BrowserContract.History.TITLE);
     rec.histURI = getStringFromCursor(cur, BrowserContract.History.URL);
     rec.androidID = getLongFromCursor(cur, BrowserContract.History._ID);
     rec.fennecDateVisited = getLongFromCursor(cur, BrowserContract.History.DATE_LAST_VISITED);
     rec.fennecVisitCount = getLongFromCursor(cur, BrowserContract.History.VISITS);
 
+    return logHistory(rec);
+  }
+
+  private static HistoryRecord logHistory(HistoryRecord rec) {
+    try {
+      Log.d(LOG_TAG, "Returning history record " + rec.guid + " (" + rec.androidID + ")");
+      Log.d(LOG_TAG, "> Title:            " + rec.title);
+      Log.d(LOG_TAG, "> URI:              " + rec.histURI);
+      Log.d(LOG_TAG, "> Visited:          " + rec.fennecDateVisited);
+      Log.d(LOG_TAG, "> Visits:           " + rec.fennecVisitCount);
+    } catch (Exception e) {
+      Log.d(LOG_TAG, "Exception logging bookmark record " + rec, e);
+    }
     return rec;
   }
-  
+
   public static PasswordRecord passwordFromMirrorCursor(Cursor cur) {
     
     String guid = getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
     String collection = "passwords";
     long lastModified = getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
     boolean deleted = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1 ? true : false;
     PasswordRecord rec = new PasswordRecord(guid, collection, lastModified, deleted);
     rec.hostname = getStringFromCursor(cur, BrowserContract.Passwords.HOSTNAME);
--- a/mobile/android/base/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java
+++ b/mobile/android/base/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java
@@ -41,16 +41,17 @@ 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);
           }});
       }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java
@@ -0,0 +1,79 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+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 RepositorySessionBeginDelegate inner;
+  private 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
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java
@@ -0,0 +1,100 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class DeferredRepositorySessionFetchRecordsDelegate implements RepositorySessionFetchRecordsDelegate {
+  private RepositorySessionFetchRecordsDelegate inner;
+  private 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 onFetchSucceeded(final Record[] records, final long end) {
+    executor.execute(new Runnable() {
+      @Override
+      public void run() {
+        inner.onFetchSucceeded(records, end);  
+      }
+    });       
+  }
+
+  @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 end) {
+    executor.execute(new Runnable() {
+      @Override
+      public void run() {
+        inner.onFetchCompleted(end);
+      }
+    });        
+  }
+
+  @Override
+  public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService newExecutor) {
+    if (newExecutor == executor) {
+      return this;
+    }
+    throw new IllegalArgumentException("Can't re-defer this delegate.");
+  }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java
@@ -0,0 +1,84 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+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
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java
@@ -0,0 +1,92 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Android Sync Client.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+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 Record record) {
+    executor.execute(new Runnable() {
+      @Override
+      public void run() {
+        inner.onRecordStoreSucceeded(record);
+      }
+    });
+  }
+
+  @Override
+  public void onRecordStoreFailed(final Exception ex) {
+    executor.execute(new Runnable() {
+      @Override
+      public void run() {
+        inner.onRecordStoreFailed(ex);
+      }
+    });
+  }
+
+  @Override
+  public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService newExecutor) {
+    if (newExecutor == executor) {
+      return this;
+    }
+    throw new IllegalArgumentException("Can't re-defer this delegate.");
+  }
+
+  @Override
+  public void onStoreCompleted() {
+    executor.execute(new Runnable() {
+      @Override
+      public void run() {
+        inner.onStoreCompleted();
+      }
+    });
+  }
+}
\ No newline at end of file
--- a/mobile/android/base/sync/repositories/delegates/RepositorySessionBeginDelegate.java
+++ b/mobile/android/base/sync/repositories/delegates/RepositorySessionBeginDelegate.java
@@ -32,23 +32,25 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 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();
+  public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor);
 }
--- a/mobile/android/base/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java
+++ b/mobile/android/base/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java
@@ -33,16 +33,18 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 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.
@@ -53,9 +55,11 @@ public interface RepositorySessionFetchR
    *        which the request was received.
    *        E.g., the (normalized) value of the X-Weave-Timestamp header.
    */
   public void onFetchCompleted(long end);
 
   // Shorthand for calling onFetchedRecord for each record in turn, then
   // calling onFetchCompleted.
   public void onFetchSucceeded(Record[] records, long end);
+
+  public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor);
 }
--- a/mobile/android/base/sync/repositories/delegates/RepositorySessionFinishDelegate.java
+++ b/mobile/android/base/sync/repositories/delegates/RepositorySessionFinishDelegate.java
@@ -32,16 +32,18 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 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();
+  public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor);
 }
--- a/mobile/android/base/sync/repositories/delegates/RepositorySessionStoreDelegate.java
+++ b/mobile/android/base/sync/repositories/delegates/RepositorySessionStoreDelegate.java
@@ -32,22 +32,25 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.delegates;
 
+import java.util.concurrent.ExecutorService;
+
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 /**
  * These methods *must* be invoked asynchronously. Use deferredStoreDelegate if you
  * need help doing this.
  *
  * @author rnewman
  *
  */
 public interface RepositorySessionStoreDelegate {
-  public void onStoreFailed(Exception ex);
-  public void onStoreSucceeded(Record record);
-  public RepositorySessionStoreDelegate deferredStoreDelegate();
+  public void onRecordStoreFailed(Exception ex);
+  public void onRecordStoreSucceeded(Record record);
+  public void onStoreCompleted();
+  public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor);
 }
--- a/mobile/android/base/sync/repositories/delegates/RepositorySessionWipeDelegate.java
+++ b/mobile/android/base/sync/repositories/delegates/RepositorySessionWipeDelegate.java
@@ -32,13 +32,15 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 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();
+  public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor);
 }
--- a/mobile/android/base/sync/repositories/domain/BookmarkRecord.java
+++ b/mobile/android/base/sync/repositories/domain/BookmarkRecord.java
@@ -36,25 +36,29 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.sync.repositories.domain;
 
 import org.json.simple.JSONArray;
 import org.mozilla.gecko.sync.CryptoRecord;
 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;
 
+import android.util.Log;
+
 /**
  * Covers the fields used by all bookmark objects.
  * @author rnewman
  *
  */
 public class BookmarkRecord extends Record {
+  private static final String LOG_TAG = "BookmarkRecord";
 
   public static final String COLLECTION_NAME = "bookmarks";
 
   public BookmarkRecord(String guid, String collection, long lastModified, boolean deleted) {
     super(guid, collection, lastModified, deleted);
   }
   public BookmarkRecord(String guid, String collection, long lastModified) {
     super(guid, collection, lastModified, false);
@@ -80,44 +84,61 @@ public class BookmarkRecord extends Reco
   public long    androidParentID;
   public String  type;
   public String  pos;
   public long    androidPosition;
 
   public JSONArray children;
   public JSONArray tags;
 
-  private static boolean getBooleanProperty(ExtendedJSONObject object, String property, boolean defaultValue) {
-    Object val = object.get(property);
-    if (val instanceof Boolean) {
-      return ((Boolean) val).booleanValue();
-    }
-    return defaultValue;
+  @Override
+  public String toString() {
+    return "#<Bookmark " + guid + " (" + androidID + "), parent " +
+           parentID + "/" + androidParentID + "/" + parentName + ">";
   }
 
   @Override