Bug 788688 - Allow setting client's device name in FxAccountStatusActivity. r=rnewman
authorNick Alexander <nalexander@mozilla.com>
Wed, 04 Jun 2014 16:37:25 -0700
changeset 207093 ceb42698005edbc985eb6361ec8790766abe1e91
parent 206893 3c54b7fe8941da0dc6859029cba1dbeb251be5e3
child 207094 18925acdcbbfffcda69b2c6adcccca69902ad986
push id494
push userraliiev@mozilla.com
push dateMon, 25 Aug 2014 18:42:16 +0000
treeherdermozilla-release@a3cc3e46b571 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs788688
milestone32.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 788688 - Allow setting client's device name in FxAccountStatusActivity. r=rnewman ======== https://github.com/mozilla-services/android-sync/commit/8c7b2531420892398d6b86eef52b0b472908e53e Author: Nick Alexander <nalexander@mozilla.com> Date: Tue Jun 3 15:48:40 2014 -0700 Bug 788688 - Review comment: Include timestamp in setClientName. ======== https://github.com/mozilla-services/android-sync/commit/b53b9092c20a317b0ebb4eef0d3618c12d4c6407 Author: Nick Alexander <nalexander@mozilla.com> Date: Thu May 29 16:05:25 2014 -0700 Bug 788688 - Post: PII client data to ease debugging. ======== https://github.com/mozilla-services/android-sync/commit/fd59f3c984b4a6c480cf045ca810e298ced826d5 Author: Nick Alexander <nalexander@mozilla.com> Date: Wed Jun 4 15:01:12 2014 -0700 Bug 788688 - Part 4: Work around Android DialogPreference caching bug. ======== https://github.com/mozilla-services/android-sync/commit/87d10bc16aaf59d463e187bb5879728152367cfc Author: Nick Alexander <nalexander@mozilla.com> Date: Thu May 29 14:44:24 2014 -0700 Bug 788688 - Part 3: Add "Device name" pref to Status activity. In the edge case where what the user has entered (empty text) and what is persisted (default client name) differ, Android does not update the contents of the dialog's EditText correctly. Removing and re-creating all preferences is the only way I found to work around this; that's in the next commit. ======== https://github.com/mozilla-services/android-sync/commit/7af72f6c2ff9099b957cc562cc3f71ee3cb1f49b Author: Nick Alexander <nalexander@mozilla.com> Date: Thu May 29 16:05:59 2014 -0700 Bug 788688 - Part 2: Upload clients and tabs records when client name changes. ======== https://github.com/mozilla-services/android-sync/commit/0e99eae1b5fe5e79dd3a8030bfca4aef64e22703 Author: Nick Alexander <nalexander@mozilla.com> Date: Thu May 29 15:28:36 2014 -0700 Bug 788688 - Part 1: Add setClientName with timestamp to ClientsDataDelegate. ======== https://github.com/mozilla-services/android-sync/commit/1999e263dbee7340c1f2ad312813561520805700 Author: Nick Alexander <nalexander@mozilla.com> Date: Thu May 29 14:10:40 2014 -0700 Bug 788688 - Pre: Clean some imports.
mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
mobile/android/base/fxa/activities/FxAccountStatusFragment.java
mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
mobile/android/base/locales/en-US/sync_strings.dtd
mobile/android/base/resources/xml/fxaccount_status_prefscreen.xml
mobile/android/base/sync/SharedPreferencesClientsDataDelegate.java
mobile/android/base/sync/SyncConfiguration.java
mobile/android/base/sync/delegates/ClientsDataDelegate.java
mobile/android/base/sync/repositories/android/FennecTabsRepository.java
mobile/android/base/sync/setup/activities/SendTabActivity.java
mobile/android/base/sync/stage/FennecTabsServerSyncStage.java
mobile/android/base/sync/stage/SyncClientsEngineStage.java
mobile/android/services/strings.xml.in
mobile/android/tests/background/junit3/src/db/TestFennecTabsRepositorySession.java
mobile/android/tests/background/junit3/src/testhelpers/MockClientsDataDelegate.java
--- a/mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
@@ -1,23 +1,21 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.fxa.activities;
 
 import java.util.Locale;
 
-import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountAgeLockoutHelper;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
-import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
 import org.mozilla.gecko.sync.setup.activities.LocaleAware;
 
 import android.accounts.AccountAuthenticatorActivity;
 import android.content.Intent;
 import android.os.Bundle;
 import android.os.SystemClock;
 import android.view.View;
--- a/mobile/android/base/fxa/activities/FxAccountStatusFragment.java
+++ b/mobile/android/base/fxa/activities/FxAccountStatusFragment.java
@@ -13,38 +13,44 @@ import org.mozilla.gecko.background.comm
 import org.mozilla.gecko.background.preferences.PreferenceFragment;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.login.Married;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.sync.FxAccountSyncStatusHelper;
 import org.mozilla.gecko.fxa.tasks.FxAccountCodeResender;
+import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
 import org.mozilla.gecko.sync.SyncConfiguration;
 
 import android.accounts.Account;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
 import android.os.Bundle;
 import android.os.Handler;
 import android.preference.CheckBoxPreference;
+import android.preference.EditTextPreference;
 import android.preference.Preference;
+import android.preference.Preference.OnPreferenceChangeListener;
 import android.preference.Preference.OnPreferenceClickListener;
 import android.preference.PreferenceCategory;
 import android.preference.PreferenceScreen;
+import android.text.TextUtils;
 
 /**
  * A fragment that displays the status of an AndroidFxAccount.
  * <p>
  * The owning activity is responsible for providing an AndroidFxAccount at
  * appropriate times.
  */
-public class FxAccountStatusFragment extends PreferenceFragment implements OnPreferenceClickListener {
+public class FxAccountStatusFragment
+    extends PreferenceFragment
+    implements OnPreferenceClickListener, OnPreferenceChangeListener {
   private static final String LOG_TAG = FxAccountStatusFragment.class.getSimpleName();
 
   // When a checkbox is toggled, wait 5 seconds (for other checkbox actions)
   // before trying to sync. Should we kill off the fragment before the sync
   // request happens, that's okay: the runnable will run if the UI thread is
   // still around to service it, and since we're not updating any UI, we'll just
   // schedule the sync as usual. See also comment below about garbage
   // collection.
@@ -60,17 +66,22 @@ public class FxAccountStatusFragment ext
 
   protected PreferenceCategory syncCategory;
 
   protected CheckBoxPreference bookmarksPreference;
   protected CheckBoxPreference historyPreference;
   protected CheckBoxPreference tabsPreference;
   protected CheckBoxPreference passwordsPreference;
 
+  protected EditTextPreference deviceNamePreference;
+
   protected volatile AndroidFxAccount fxAccount;
+  // The contract is: when fxAccount is non-null, then clientsDataDelegate is
+  // non-null.  If violated then an IllegalStateException is thrown.
+  protected volatile SharedPreferencesClientsDataDelegate clientsDataDelegate;
 
   // Used to post delayed sync requests.
   protected Handler handler;
 
   // Member variable so that re-posting pushes back the already posted instance.
   // This Runnable references the fxAccount above, but it is not specific to a
   // single account. (That is, it does not capture a single account instance.)
   protected Runnable requestSyncRunnable;
@@ -83,16 +94,20 @@ public class FxAccountStatusFragment ext
       throw new IllegalStateException("Could not find preference with key: " + key);
     }
     return preference;
   }
 
   @Override
   public void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
+    addPreferences();
+  }
+
+  protected void addPreferences() {
     addPreferencesFromResource(R.xml.fxaccount_status_prefscreen);
 
     emailPreference = ensureFindPreference("email");
 
     needsPasswordPreference = ensureFindPreference("needs_credentials");
     needsUpgradePreference = ensureFindPreference("needs_upgrade");
     needsVerificationPreference = ensureFindPreference("needs_verification");
     needsMasterSyncAutomaticallyEnabledPreference = ensureFindPreference("needs_master_sync_automatically_enabled");
@@ -114,16 +129,19 @@ public class FxAccountStatusFragment ext
     needsPasswordPreference.setOnPreferenceClickListener(this);
     needsVerificationPreference.setOnPreferenceClickListener(this);
     needsAccountEnabledPreference.setOnPreferenceClickListener(this);
 
     bookmarksPreference.setOnPreferenceClickListener(this);
     historyPreference.setOnPreferenceClickListener(this);
     tabsPreference.setOnPreferenceClickListener(this);
     passwordsPreference.setOnPreferenceClickListener(this);
+
+    deviceNamePreference = (EditTextPreference) ensureFindPreference("device_name");
+    deviceNamePreference.setOnPreferenceChangeListener(this);
   }
 
   /**
    * We intentionally don't refresh here. Our owning activity is responsible for
    * providing an AndroidFxAccount to our refresh method in its onResume method.
    */
   @Override
   public void onResume() {
@@ -172,16 +190,18 @@ public class FxAccountStatusFragment ext
     return false;
   }
 
   protected void setCheckboxesEnabled(boolean enabled) {
     bookmarksPreference.setEnabled(enabled);
     historyPreference.setEnabled(enabled);
     tabsPreference.setEnabled(enabled);
     passwordsPreference.setEnabled(enabled);
+    // Since we can't sync, we can't update our remote client record.
+    deviceNamePreference.setEnabled(enabled);
   }
 
   /**
    * Show at most one error preference, hiding all others.
    *
    * @param errorPreferenceToShow
    *          single error preference to show; if null, hide all error preferences
    */
@@ -289,16 +309,24 @@ public class FxAccountStatusFragment ext
    *
    * @param fxAccount new instance.
    */
   public void refresh(AndroidFxAccount fxAccount) {
     if (fxAccount == null) {
       throw new IllegalArgumentException("fxAccount must not be null");
     }
     this.fxAccount = fxAccount;
+    try {
+      this.clientsDataDelegate = new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs());
+    } catch (Exception e) {
+      Logger.error(LOG_TAG, "Got exception fetching Sync prefs associated to Firefox Account; aborting.", e);
+      // Something is terribly wrong; best to get a stack trace rather than
+      // continue with a null clients delegate.
+      throw new IllegalStateException(e);
+    }
 
     handler = new Handler(); // Attached to current (assumed to be UI) thread.
 
     // Runnable is not specific to one Firefox Account. This runnable will keep
     // a reference to this fragment alive, but we expect posted runnables to be
     // serviced very quickly, so this is not an issue.
     requestSyncRunnable = new RequestSyncRunnable();
 
@@ -314,16 +342,27 @@ public class FxAccountStatusFragment ext
   }
 
   @Override
   public void onPause() {
     super.onPause();
     FxAccountSyncStatusHelper.getInstance().stopObserving(syncStatusDelegate);
   }
 
+  protected void hardRefresh() {
+    // This is the only way to guarantee that the EditText dialogs created by
+    // EditTextPreferences are re-created. This works around the issue described
+    // at http://androiddev.orkitra.com/?p=112079.
+    final PreferenceScreen statusScreen = (PreferenceScreen) ensureFindPreference("status_screen");
+    statusScreen.removeAll();
+    addPreferences();
+
+    refresh();
+  }
+
   protected void refresh() {
     // refresh is called from our onResume, which can happen before the owning
     // Activity tells us about an account (via our public
     // refresh(AndroidFxAccount) method).
     if (fxAccount == null) {
       throw new IllegalArgumentException("fxAccount must not be null");
     }
 
@@ -367,16 +406,20 @@ public class FxAccountStatusFragment ext
       if (!masterSyncAutomatically) {
         showNeedsMasterSyncAutomaticallyEnabled();
         return;
       }
     } finally {
       // No matter our state, we should update the checkboxes.
       updateSelectedEngines();
     }
+
+    final String clientName = clientsDataDelegate.getClientName();
+    deviceNamePreference.setSummary(clientName);
+    deviceNamePreference.setText(clientName);
   }
 
   /**
    * Query shared prefs for the current engine state, and update the UI
    * accordingly.
    * <p>
    * In future, we might want this to be on a background thread, or implemented
    * as a Loader.
@@ -566,9 +609,27 @@ public class FxAccountStatusFragment ext
         "debug_require_password",
         "debug_require_upgrade" };
     for (String debugKey : debugKeys) {
       final Preference button = ensureFindPreference(debugKey);
       button.setTitle(debugKey); // Not very friendly, but this is for debugging only!
       button.setOnPreferenceClickListener(listener);
     }
   }
+
+  @Override
+  public boolean onPreferenceChange(Preference preference, Object newValue) {
+    if (preference == deviceNamePreference) {
+      String newClientName = (String) newValue;
+      if (TextUtils.isEmpty(newClientName)) {
+        newClientName = clientsDataDelegate.getDefaultClientName();
+      }
+      final long now = System.currentTimeMillis();
+      clientsDataDelegate.setClientName(newClientName, now);
+      requestDelayedSync(); // Try to update our remote client record.
+      hardRefresh(); // Updates the value displayed to the user, among other things.
+      return true;
+    }
+
+    // For everything else, accept the change.
+    return true;
+  }
 }
--- a/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
+++ b/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
@@ -352,17 +352,21 @@ public class FxAccountSyncAdapter extend
         }
 
         // Invalidate the previous backoff, because our storage host has changed,
         // or we never had one at all, or we're OK to sync.
         storageBackoffHandler.setEarliestNextRequest(0L);
 
         FxAccountGlobalSession globalSession = null;
         try {
-          ClientsDataDelegate clientsDataDelegate = new SharedPreferencesClientsDataDelegate(sharedPrefs);
+          final ClientsDataDelegate clientsDataDelegate = new SharedPreferencesClientsDataDelegate(sharedPrefs);
+          if (FxAccountConstants.LOG_PERSONAL_INFORMATION) {
+            FxAccountConstants.pii(LOG_TAG, "Client device name is: '" + clientsDataDelegate.getClientName() + "'.");
+            FxAccountConstants.pii(LOG_TAG, "Client device data last modified: " + clientsDataDelegate.getLastModifiedTimestamp());
+          }
 
           // We compute skew over time using SkewHandler. This yields an unchanging
           // skew adjustment that the HawkAuthHeaderProvider uses to adjust its
           // timestamps. Eventually we might want this to adapt within the scope of a
           // global session.
           final SkewHandler storageServerSkewHandler = SkewHandler.getSkewHandlerForHostname(storageHostname);
           final long storageServerSkew = storageServerSkewHandler.getSkewInSeconds();
           // We expect Sync to upload large sets of records. Calculating the
--- a/mobile/android/base/locales/en-US/sync_strings.dtd
+++ b/mobile/android/base/locales/en-US/sync_strings.dtd
@@ -170,16 +170,17 @@
 <!ENTITY fxaccount_account_verified_description2 'Your data will begin syncing momentarily.'>
 
 <!ENTITY fxaccount_update_credentials_header 'Sign in'>
 <!ENTITY fxaccount_update_credentials_button_label 'Sign in'>
 <!ENTITY fxaccount_update_credentials_unknown_error 'Could not sign in'>
 
 <!ENTITY fxaccount_status_header2 'Firefox Account'>
 <!ENTITY fxaccount_status_signed_in_as 'Signed in as'>
+<!ENTITY fxaccount_status_device_name 'Device name'>
 <!ENTITY fxaccount_status_sync '&syncBrand.shortName.label;'>
 <!ENTITY fxaccount_status_sync_enabled '&syncBrand.shortName.label;: enabled'>
 <!ENTITY fxaccount_status_needs_verification2 'Your account needs to be verified. Tap to resend verification email.'>
 <!ENTITY fxaccount_status_needs_credentials 'Cannot connect. Tap to sign in.'>
 <!ENTITY fxaccount_status_needs_upgrade 'You need to upgrade &brandShortName; to sign in.'>
 <!ENTITY fxaccount_status_needs_master_sync_automatically_enabled '&syncBrand.shortName.label; is set up, but not syncing automatically. Toggle “Auto-sync data” in Android Settings &gt; Data Usage.'>
 <!ENTITY fxaccount_status_needs_account_enabled '&syncBrand.shortName.label; is set up, but not syncing automatically. Tap to start syncing.'>
 <!ENTITY fxaccount_status_bookmarks 'Bookmarks'>
--- a/mobile/android/base/resources/xml/fxaccount_status_prefscreen.xml
+++ b/mobile/android/base/resources/xml/fxaccount_status_prefscreen.xml
@@ -61,16 +61,22 @@
         <CheckBoxPreference
             android:key="tabs"
             android:persistent="false"
             android:title="@string/fxaccount_status_tabs" />
         <CheckBoxPreference
             android:key="passwords"
             android:persistent="false"
             android:title="@string/fxaccount_status_passwords" />
+
+        <EditTextPreference
+            android:singleLine="true"
+            android:key="device_name"
+            android:persistent="false"
+            android:title="@string/fxaccount_status_device_name" />
     </PreferenceCategory>
     <PreferenceCategory
         android:key="legal_category"
         android:title="@string/fxaccount_status_legal" >
         <Preference android:title="@string/fxaccount_status_linktos" >
             <intent
                 android:action="android.intent.action.VIEW"
                 android:data="@string/fxaccount_link_tos"
--- a/mobile/android/base/sync/SharedPreferencesClientsDataDelegate.java
+++ b/mobile/android/base/sync/SharedPreferencesClientsDataDelegate.java
@@ -25,22 +25,43 @@ public class SharedPreferencesClientsDat
     String accountGUID = sharedPreferences.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null);
     if (accountGUID == null) {
       accountGUID = Utils.generateGuid();
       sharedPreferences.edit().putString(SyncConfiguration.PREF_ACCOUNT_GUID, accountGUID).commit();
     }
     return accountGUID;
   }
 
+  /**
+   * Set client name.
+   *
+   * @param clientName to change to.
+   */
+  @Override
+  public synchronized void setClientName(String clientName, long now) {
+    sharedPreferences
+      .edit()
+      .putString(SyncConfiguration.PREF_CLIENT_NAME, clientName)
+      .putLong(SyncConfiguration.PREF_CLIENT_DATA_TIMESTAMP, now)
+      .commit();
+  }
+
+  @Override
+  public String getDefaultClientName() {
+    // Bug 1019719: localize this string!
+    return GlobalConstants.MOZ_APP_DISPLAYNAME + " on " + android.os.Build.MODEL;
+  }
+
   @Override
   public synchronized String getClientName() {
     String clientName = sharedPreferences.getString(SyncConfiguration.PREF_CLIENT_NAME, null);
     if (clientName == null) {
-      clientName = GlobalConstants.MOZ_APP_DISPLAYNAME + " on " + android.os.Build.MODEL;
-      sharedPreferences.edit().putString(SyncConfiguration.PREF_CLIENT_NAME, clientName).commit();
+      clientName = getDefaultClientName();
+      long now = System.currentTimeMillis();
+      setClientName(clientName, now);
     }
     return clientName;
   }
 
   @Override
   public synchronized void setClientsCount(int clientsCount) {
     sharedPreferences.edit().putLong(SyncConfiguration.PREF_NUM_CLIENTS, (long) clientsCount).commit();
   }
@@ -49,9 +70,14 @@ public class SharedPreferencesClientsDat
   public boolean isLocalGUID(String guid) {
     return getAccountGUID().equals(guid);
   }
 
   @Override
   public synchronized int getClientsCount() {
     return (int) sharedPreferences.getLong(SyncConfiguration.PREF_NUM_CLIENTS, 0);
   }
+
+  @Override
+  public long getLastModifiedTimestamp() {
+    return sharedPreferences.getLong(SyncConfiguration.PREF_CLIENT_DATA_TIMESTAMP, 0);
+  }
 }
--- a/mobile/android/base/sync/SyncConfiguration.java
+++ b/mobile/android/base/sync/SyncConfiguration.java
@@ -253,16 +253,17 @@ public class SyncConfiguration {
   public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC = "userSelectedEngines";
   public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP = "userSelectedEnginesTimestamp";
 
   public static final String PREF_CLUSTER_URL_IS_STALE = "clusterurlisstale";
 
   public static final String PREF_ACCOUNT_GUID = "account.guid";
   public static final String PREF_CLIENT_NAME = "account.clientName";
   public static final String PREF_NUM_CLIENTS = "account.numClients";
+  public static final String PREF_CLIENT_DATA_TIMESTAMP = "account.clientDataTimestamp";
 
   private static final String API_VERSION = "1.5";
 
   public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs) {
     this.username = username;
     this.authHeaderProvider = authHeaderProvider;
     this.prefs = prefs;
     this.loadFromPrefs(prefs);
--- a/mobile/android/base/sync/delegates/ClientsDataDelegate.java
+++ b/mobile/android/base/sync/delegates/ClientsDataDelegate.java
@@ -1,13 +1,27 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.delegates;
 
 public interface ClientsDataDelegate {
   public String getAccountGUID();
+  public String getDefaultClientName();
+  public void setClientName(String clientName, long now);
   public String getClientName();
   public void setClientsCount(int clientsCount);
   public int getClientsCount();
   public boolean isLocalGUID(String guid);
+
+  /**
+   * The last time the client's data was modified in a way that should be
+   * reflected remotely.
+   * <p>
+   * Changing the client's name should be reflected remotely, while changing the
+   * clients count should not (since that data is only used to inform local
+   * policy.)
+   *
+   * @return timestamp in milliseconds.
+   */
+  public long getLastModifiedTimestamp();
 }
--- a/mobile/android/base/sync/repositories/android/FennecTabsRepository.java
+++ b/mobile/android/base/sync/repositories/android/FennecTabsRepository.java
@@ -4,16 +4,17 @@
 
 package org.mozilla.gecko.sync.repositories.android;
 
 import java.util.ArrayList;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.db.Tab;
 import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.repositories.InactiveSessionException;
 import org.mozilla.gecko.sync.repositories.NoContentProviderException;
 import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
 import org.mozilla.gecko.sync.repositories.Repository;
 import org.mozilla.gecko.sync.repositories.RepositorySession;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
@@ -25,22 +26,20 @@ import org.mozilla.gecko.sync.repositori
 import android.content.ContentProviderClient;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.RemoteException;
 
 public class FennecTabsRepository extends Repository {
-  protected final String localClientName;
-  protected final String localClientGuid;
+  protected final ClientsDataDelegate clientsDataDelegate;
 
-  public FennecTabsRepository(final String localClientName, final String localClientGuid) {
-    this.localClientName = localClientName;
-    this.localClientGuid = localClientGuid;
+  public FennecTabsRepository(ClientsDataDelegate clientsDataDelegate) {
+    this.clientsDataDelegate = clientsDataDelegate;
   }
 
   /**
    * Note that — unlike most repositories — this will only fetch Fennec's tabs,
    * and only store tabs from other clients.
    *
    * It will never retrieve tabs from other clients, or store tabs for Fennec,
    * unless you use {@link #fetch(String[], RepositorySessionFetchRecordsDelegate)}
@@ -139,24 +138,27 @@ public class FennecTabsRepository extend
       final String localClientSelection = localClientSelection();
       final String[] localClientSelectionArgs = localClientSelectionArgs();
 
       final Runnable command = new Runnable() {
         @Override
         public void run() {
           // We fetch all local tabs (since the record must contain them all)
           // but only process the record if the timestamp is sufficiently
-          // recent.
+          // recent, or if the client data has been modified.
           try {
             final Cursor cursor = tabsHelper.safeQuery(tabsProvider, ".fetchSince()", null,
                 localClientSelection, localClientSelectionArgs, positionAscending);
             try {
+              final String localClientGuid = clientsDataDelegate.getAccountGUID();
+              final String localClientName = clientsDataDelegate.getClientName();
               final TabsRecord tabsRecord = FennecTabsRepository.tabsRecordFromCursor(cursor, localClientGuid, localClientName);
 
-              if (tabsRecord.lastModified >= timestamp) {
+              if (tabsRecord.lastModified >= timestamp ||
+                  clientsDataDelegate.getLastModifiedTimestamp() >= timestamp) {
                 delegate.onFetchedRecord(tabsRecord);
               }
             } finally {
               cursor.close();
             }
           } catch (Exception e) {
             delegate.onFetchFailed(e, null);
             return;
--- a/mobile/android/base/sync/setup/activities/SendTabActivity.java
+++ b/mobile/android/base/sync/setup/activities/SendTabActivity.java
@@ -20,22 +20,19 @@ import org.mozilla.gecko.fxa.authenticat
 import org.mozilla.gecko.fxa.login.State.Action;
 import org.mozilla.gecko.sync.CommandProcessor;
 import org.mozilla.gecko.sync.CommandRunner;
 import org.mozilla.gecko.sync.GlobalSession;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.SyncConstants;
 import org.mozilla.gecko.sync.repositories.NullCursorException;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
-import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
-import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository.FennecTabsRepositorySession;
 import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
 import org.mozilla.gecko.sync.setup.SyncAccounts;
 import org.mozilla.gecko.sync.setup.activities.LocaleAware.LocaleAwareActivity;
-import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
 import org.mozilla.gecko.sync.syncadapter.SyncAdapter;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.app.Activity;
 import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
--- a/mobile/android/base/sync/stage/FennecTabsServerSyncStage.java
+++ b/mobile/android/base/sync/stage/FennecTabsServerSyncStage.java
@@ -1,15 +1,14 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.stage;
 
-import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.repositories.RecordFactory;
 import org.mozilla.gecko.sync.repositories.Repository;
 import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
 import org.mozilla.gecko.sync.repositories.domain.TabsRecordFactory;
 import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
 
 public class FennecTabsServerSyncStage extends ServerSyncStage {
   private static final String COLLECTION = "tabs";
@@ -26,17 +25,16 @@ public class FennecTabsServerSyncStage e
 
   @Override
   public Integer getStorageVersion() {
     return VersionConstants.TABS_ENGINE_VERSION;
   }
 
   @Override
   protected Repository getLocalRepository() {
-    final ClientsDataDelegate clientsDelegate = session.getClientsDelegate();
-    return new FennecTabsRepository(clientsDelegate.getClientName(), clientsDelegate.getAccountGUID());
+    return new FennecTabsRepository(session.getClientsDelegate());
   }
 
   @Override
   protected RecordFactory getRecordFactory() {
     return new TabsRecordFactory();
   }
 }
--- a/mobile/android/base/sync/stage/SyncClientsEngineStage.java
+++ b/mobile/android/base/sync/stage/SyncClientsEngineStage.java
@@ -392,16 +392,21 @@ public class SyncClientsEngineStage exte
       return true;
     }
 
     long lastUpload = session.config.getPersistedServerClientRecordTimestamp();   // Defaults to 0.
     if (lastUpload == 0) {
       return true;
     }
 
+    if (session.getClientsDelegate().getLastModifiedTimestamp() > lastUpload) {
+      // Something's changed locally since we last uploaded.
+      return true;
+    }
+
     // Note the opportunity for clock drift problems here.
     // TODO: if we track download times, we can use the timestamp of most
     // recent download response instead of the current time.
     long now = System.currentTimeMillis();
     long age = now - lastUpload;
     return age >= CLIENTS_TTL_REFRESH;
   }
 
--- a/mobile/android/services/strings.xml.in
+++ b/mobile/android/services/strings.xml.in
@@ -164,16 +164,17 @@
 
 <string name="fxaccount_update_credentials_header">&fxaccount_update_credentials_header;</string>
 <string name="fxaccount_update_credentials_button_label">&fxaccount_update_credentials_button_label;</string>
 <string name="fxaccount_update_credentials_unknown_error">&fxaccount_update_credentials_unknown_error;</string>
 
 <string name="fxaccount_status_activity_label">&syncBrand.shortName.label;</string>
 <string name="fxaccount_status_header">&fxaccount_status_header2;</string>
 <string name="fxaccount_status_signed_in_as">&fxaccount_status_signed_in_as;</string>
+<string name="fxaccount_status_device_name">&fxaccount_status_device_name;</string>
 <string name="fxaccount_status_sync">&fxaccount_status_sync;</string>
 <string name="fxaccount_status_sync_enabled">&fxaccount_status_sync_enabled;</string>
 <string name="fxaccount_status_needs_verification">&fxaccount_status_needs_verification2;</string>
 <string name="fxaccount_status_needs_credentials">&fxaccount_status_needs_credentials;</string>
 <string name="fxaccount_status_needs_upgrade">&fxaccount_status_needs_upgrade;</string>
 <string name="fxaccount_status_needs_master_sync_automatically_enabled">&fxaccount_status_needs_master_sync_automatically_enabled;</string>
 <string name="fxaccount_status_needs_account_enabled">&fxaccount_status_needs_account_enabled;</string>
 <string name="fxaccount_status_bookmarks">&fxaccount_status_bookmarks;</string>
--- a/mobile/android/tests/background/junit3/src/db/TestFennecTabsRepositorySession.java
+++ b/mobile/android/tests/background/junit3/src/db/TestFennecTabsRepositorySession.java
@@ -2,16 +2,17 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.background.db;
 
 import org.json.simple.JSONArray;
 import org.mozilla.gecko.background.helpers.AndroidSyncTestCase;
 import org.mozilla.gecko.background.sync.helpers.ExpectFetchDelegate;
 import org.mozilla.gecko.background.sync.helpers.SessionTestHelper;
+import org.mozilla.gecko.background.testhelpers.MockClientsDataDelegate;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.sync.repositories.NoContentProviderException;
 import org.mozilla.gecko.sync.repositories.RepositorySession;
 import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
 import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
 import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository.FennecTabsRepositorySession;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
 import org.mozilla.gecko.sync.repositories.domain.Record;
@@ -19,18 +20,19 @@ import org.mozilla.gecko.sync.repositori
 
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.Cursor;
 import android.os.RemoteException;
 
 public class TestFennecTabsRepositorySession extends AndroidSyncTestCase {
-  public static final String TEST_CLIENT_GUID = "test guid"; // Real GUIDs never contain spaces.
-  public static final String TEST_CLIENT_NAME = "test client name";
+  public static final MockClientsDataDelegate clientsDataDelegate = new MockClientsDataDelegate();
+  public static final String TEST_CLIENT_GUID = clientsDataDelegate.getAccountGUID();
+  public static final String TEST_CLIENT_NAME = clientsDataDelegate.getClientName();
 
   // Override these to test against data that is not live.
   public static final String TEST_TABS_CLIENT_GUID_IS_LOCAL_SELECTION = BrowserContract.Tabs.CLIENT_GUID + " IS ?";
   public static final String[] TEST_TABS_CLIENT_GUID_IS_LOCAL_SELECTION_ARGS = new String[] { TEST_CLIENT_GUID };
 
   protected ContentProviderClient tabsClient = null;
 
   protected ContentProviderClient getTabsClient() {
@@ -67,17 +69,17 @@ public class TestFennecTabsRepositorySes
     }
   }
 
   protected FennecTabsRepository getRepository() {
     /**
      * Override this chain in order to avoid our test code having to create two
      * sessions all the time.
      */
-    return new FennecTabsRepository(TEST_CLIENT_NAME, TEST_CLIENT_GUID) {
+    return new FennecTabsRepository(clientsDataDelegate) {
       @Override
       public void createSession(RepositorySessionCreationDelegate delegate,
                                 Context context) {
         try {
           final FennecTabsRepositorySession session = new FennecTabsRepositorySession(this, context) {
             @Override
             protected synchronized void trackGUID(String guid) {
             }
@@ -192,14 +194,25 @@ public class TestFennecTabsRepositorySes
   public void testFetchSince() throws NoContentProviderException, RemoteException {
     final TabsRecord tabsRecord = insertTestTabsAndExtractTabsRecord();
 
     final FennecTabsRepositorySession session = createAndBeginSession();
 
     // Not all tabs are modified after this, but the record should contain them all.
     performWait(fetchSinceRunnable(session, 1000, new Record[] { tabsRecord }));
 
-    // No tabs are modified after this, so we shouldn't get a record at all.
-    performWait(fetchSinceRunnable(session, 4000, new Record[] { }));
+    // No tabs are modified after this, but our client name has changed in the interim.
+    performWait(fetchSinceRunnable(session, 4000, new Record[] { tabsRecord }));
+
+    // No tabs are modified after this, and our client name hasn't changed, so
+    // we shouldn't get a record at all. Note: this runs after our static
+    // initializer that sets the client data timestamp.
+    final long now = System.currentTimeMillis();
+    performWait(fetchSinceRunnable(session, now, new Record[] { }));
+
+    // No tabs are modified after this, but our client name has changed, so
+    // again we get a record.
+    clientsDataDelegate.setClientName("new client name", System.currentTimeMillis());
+    performWait(fetchSinceRunnable(session, now, new Record[] { tabsRecord }));
 
     session.abort();
   }
 }
--- a/mobile/android/tests/background/junit3/src/testhelpers/MockClientsDataDelegate.java
+++ b/mobile/android/tests/background/junit3/src/testhelpers/MockClientsDataDelegate.java
@@ -5,40 +5,57 @@ package org.mozilla.gecko.background.tes
 
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 
 public class MockClientsDataDelegate implements ClientsDataDelegate {
   private String accountGUID;
   private String clientName;
   private int clientsCount;
+  private long clientDataTimestamp = 0;
 
   @Override
   public synchronized String getAccountGUID() {
     if (accountGUID == null) {
       accountGUID = Utils.generateGuid();
     }
     return accountGUID;
   }
 
   @Override
+  public synchronized String getDefaultClientName() {
+    return "Default client";
+  }
+
+  @Override
+  public synchronized void setClientName(String clientName, long now) {
+    this.clientName = clientName;
+    this.clientDataTimestamp = now;
+  }
+
+  @Override
   public synchronized String getClientName() {
     if (clientName == null) {
-      clientName = "Default Name";
+      setClientName(getDefaultClientName(), System.currentTimeMillis());
     }
     return clientName;
   }
 
   @Override
   public synchronized void setClientsCount(int clientsCount) {
     this.clientsCount = clientsCount;
   }
 
   @Override
   public synchronized int getClientsCount() {
     return clientsCount;
   }
 
   @Override
-  public boolean isLocalGUID(String guid) {
+  public synchronized boolean isLocalGUID(String guid) {
     return getAccountGUID().equals(guid);
   }
-}
\ No newline at end of file
+
+  @Override
+  public synchronized long getLastModifiedTimestamp() {
+    return clientDataTimestamp;
+  }
+}