Bug 1080242 - Surface 'Account locked' status. r=rnewman
authorNick Alexander <nalexander@mozilla.com>
Tue, 25 Nov 2014 17:43:04 -0800
changeset 243922 55313b1290fcd87d41530827b6054261c6ce840d
parent 243921 3397a68199cc4cd46fdb217f00dd2237807a5683
child 243923 6dededc47e3d31c6707f56303dedc87039356d9e
push id4489
push userraliiev@mozilla.com
push dateMon, 23 Feb 2015 15:17:55 +0000
treeherdermozilla-beta@fd7c3dc24146 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1080242
milestone37.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 1080242 - Surface 'Account locked' status. r=rnewman
mobile/android/base/android-services.mozbuild
mobile/android/base/background/fxa/FxAccountClient.java
mobile/android/base/background/fxa/FxAccountClient10.java
mobile/android/base/background/fxa/FxAccountClientException.java
mobile/android/base/background/fxa/FxAccountRemoteError.java
mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java
mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java
mobile/android/base/fxa/tasks/FxAccountUnlockCodeResender.java
mobile/android/base/locales/en-US/sync_strings.dtd
mobile/android/base/sync/Utils.java
mobile/android/services/strings.xml.in
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -878,16 +878,17 @@ sync_java_files = [
     'fxa/sync/FxAccountSyncAdapter.java',
     'fxa/sync/FxAccountSyncService.java',
     'fxa/sync/FxAccountSyncStatusHelper.java',
     'fxa/sync/SchedulePolicy.java',
     'fxa/tasks/FxAccountCodeResender.java',
     'fxa/tasks/FxAccountCreateAccountTask.java',
     'fxa/tasks/FxAccountSetupTask.java',
     'fxa/tasks/FxAccountSignInTask.java',
+    'fxa/tasks/FxAccountUnlockCodeResender.java',
     'sync/AlreadySyncingException.java',
     'sync/BackoffHandler.java',
     'sync/BadRequiredFieldJSONException.java',
     'sync/CollectionKeys.java',
     'sync/CommandProcessor.java',
     'sync/CommandRunner.java',
     'sync/config/AccountPickler.java',
     'sync/config/activities/SelectEnginesActivity.java',
--- a/mobile/android/base/background/fxa/FxAccountClient.java
+++ b/mobile/android/base/background/fxa/FxAccountClient.java
@@ -12,9 +12,10 @@ import org.mozilla.gecko.sync.ExtendedJS
 
 public interface FxAccountClient {
   public void createAccountAndGetKeys(final byte[] emailUTF8, final PasswordStretcher passwordStretcher, final RequestDelegate<LoginResponse> delegate);
   public void loginAndGetKeys(final byte[] emailUTF8, final PasswordStretcher passwordStretcher, final RequestDelegate<LoginResponse> requestDelegate);
   public void status(byte[] sessionToken, RequestDelegate<StatusResponse> requestDelegate);
   public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate);
   public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate);
   public void resendCode(byte[] sessionToken, RequestDelegate<Void> delegate);
+  public void resendUnlockCode(byte[] emailUTF8, RequestDelegate<Void> delegate);
 }
--- a/mobile/android/base/background/fxa/FxAccountClient10.java
+++ b/mobile/android/base/background/fxa/FxAccountClient10.java
@@ -765,9 +765,51 @@ public class FxAccountClient10 {
         } catch (Exception e) {
           delegate.handleError(e);
           return;
         }
       }
     };
     post(resource, new JSONObject(), delegate);
   }
+
+  /**
+   * Request a fresh unlock code be sent to the account email.
+   * <p>
+   * Since the account can be locked before the device can connect to it, the
+   * only reasonable identifier is the account email. Since the account is
+   * locked out, this request is un-authenticated.
+   *
+   * @param emailUTF8
+   *          identifying account.
+   * @param delegate
+   *          to invoke callbacks.
+   */
+  @SuppressWarnings("unchecked")
+  public void resendUnlockCode(final byte[] emailUTF8, final RequestDelegate<Void> delegate) {
+    final BaseResource resource;
+    final JSONObject body = new JSONObject();
+    try {
+      resource = new BaseResource(new URI(serverURI + "account/unlock/resend_code"));
+      body.put("email", new String(emailUTF8, "UTF-8"));
+    } catch (URISyntaxException e) {
+      invokeHandleError(delegate, e);
+      return;
+    } catch (UnsupportedEncodingException e) {
+      invokeHandleError(delegate, e);
+      return;
+    }
+
+    resource.delegate = new ResourceDelegate<Void>(resource, delegate) {
+      @Override
+      public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+        try {
+          delegate.handleSuccess(null);
+          return;
+        } catch (Exception e) {
+          delegate.handleError(e);
+          return;
+        }
+      }
+    };
+    post(resource, body, delegate);
+  }
 }
--- a/mobile/android/base/background/fxa/FxAccountClientException.java
+++ b/mobile/android/base/background/fxa/FxAccountClientException.java
@@ -91,31 +91,37 @@ public class FxAccountClientException ex
     public boolean isServerUnavailable() {
       return apiErrorNumber == FxAccountRemoteError.SERVICE_TEMPORARILY_UNAVAILABLE_DUE_TO_HIGH_LOAD;
     }
 
     public boolean isBadEmailCase() {
       return apiErrorNumber == FxAccountRemoteError.INCORRECT_EMAIL_CASE;
     }
 
+    public boolean isAccountLocked() {
+      return apiErrorNumber == FxAccountRemoteError.ACCOUNT_LOCKED;
+    }
+
     public int getErrorMessageStringResource() {
       if (isUpgradeRequired()) {
         return R.string.fxaccount_remote_error_UPGRADE_REQUIRED;
       } else if (isAccountAlreadyExists()) {
         return R.string.fxaccount_remote_error_ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS;
       } else if (isAccountDoesNotExist()) {
         return R.string.fxaccount_remote_error_ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST;
       } else if (isBadPassword()) {
         return R.string.fxaccount_remote_error_INCORRECT_PASSWORD;
       } else if (isUnverified()) {
         return R.string.fxaccount_remote_error_ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT;
       } else if (isTooManyRequests()) {
         return R.string.fxaccount_remote_error_CLIENT_HAS_SENT_TOO_MANY_REQUESTS;
       } else if (isServerUnavailable()) {
         return R.string.fxaccount_remote_error_SERVICE_TEMPORARILY_UNAVAILABLE_TO_DUE_HIGH_LOAD;
+      } else if (isAccountLocked()) {
+        return R.string.fxaccount_remote_error_ACCOUNT_LOCKED;
       } else {
         return R.string.fxaccount_remote_error_UNKNOWN_ERROR;
       }
     }
   }
 
   public static class FxAccountClientMalformedResponseException extends FxAccountClientRemoteException {
     private static final long serialVersionUID = 2209313149952001098L;
--- a/mobile/android/base/background/fxa/FxAccountRemoteError.java
+++ b/mobile/android/base/background/fxa/FxAccountRemoteError.java
@@ -11,21 +11,21 @@ public interface FxAccountRemoteError {
   public static final int ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT = 104;
   public static final int INVALID_VERIFICATION_CODE = 105;
   public static final int REQUEST_BODY_WAS_NOT_VALID_JSON = 106;
   public static final int REQUEST_BODY_CONTAINS_INVALID_PARAMETERS = 107;
   public static final int REQUEST_BODY_MISSING_REQUIRED_PARAMETERS = 108;
   public static final int INVALID_REQUEST_SIGNATURE = 109;
   public static final int INVALID_AUTHENTICATION_TOKEN = 110;
   public static final int INVALID_AUTHENTICATION_TIMESTAMP = 111;
-  public static final int INVALID_AUTHENTICATION_NONCE = 115;
   public static final int CONTENT_LENGTH_HEADER_WAS_NOT_PROVIDED = 112;
   public static final int REQUEST_BODY_TOO_LARGE = 113;
   public static final int CLIENT_HAS_SENT_TOO_MANY_REQUESTS = 114;
   public static final int INVALID_NONCE_IN_REQUEST_SIGNATURE = 115;
   public static final int ENDPOINT_IS_NO_LONGER_SUPPORTED = 116;
   public static final int INCORRECT_LOGIN_METHOD_FOR_THIS_ACCOUNT = 117;
   public static final int INCORRECT_KEY_RETRIEVAL_METHOD_FOR_THIS_ACCOUNT = 118;
   public static final int INCORRECT_API_VERSION_FOR_THIS_ACCOUNT = 119;
   public static final int INCORRECT_EMAIL_CASE = 120;
+  public static final int ACCOUNT_LOCKED = 121;
   public static final int SERVICE_TEMPORARILY_UNAVAILABLE_DUE_TO_HIGH_LOAD = 201;
   public static final int UNKNOWN_ERROR = 999;
 }
--- a/mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java
@@ -1,15 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.fxa.activities;
 
 import java.io.IOException;
+import java.io.UnsupportedEncodingException;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountClient10.RequestDelegate;
@@ -18,31 +19,36 @@ import org.mozilla.gecko.background.fxa.
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.fxa.PasswordStretcher;
 import org.mozilla.gecko.background.fxa.QuickPasswordStretcher;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.login.Engaged;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.tasks.FxAccountSetupTask.ProgressDisplay;
+import org.mozilla.gecko.fxa.tasks.FxAccountUnlockCodeResender;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.setup.Constants;
 import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.content.Context;
 import android.content.Intent;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.text.Editable;
+import android.text.Spannable;
 import android.text.TextWatcher;
+import android.text.method.LinkMovementMethod;
 import android.text.method.PasswordTransformationMethod;
 import android.text.method.SingleLineTransformationMethod;
+import android.text.style.ClickableSpan;
 import android.util.Patterns;
 import android.view.KeyEvent;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.view.View.OnFocusChangeListener;
 import android.widget.ArrayAdapter;
 import android.widget.AutoCompleteTextView;
 import android.widget.Button;
@@ -149,17 +155,45 @@ abstract public class FxAccountAbstractS
     } else {
       remoteErrorTextView.setText(defaultResourceId);
     }
     Logger.warn(LOG_TAG, "Got exception; showing error message: " + remoteErrorTextView.getText().toString(), e);
     remoteErrorTextView.setVisibility(View.VISIBLE);
   }
 
   protected void showClientRemoteException(final FxAccountClientRemoteException e) {
-    remoteErrorTextView.setText(e.getErrorMessageStringResource());
+    if (!e.isAccountLocked()) {
+      remoteErrorTextView.setText(e.getErrorMessageStringResource());
+      return;
+    }
+
+    // This horrible bit of special-casing is because we want this error message
+    // to contain a clickable, extra chunk of text, but we don't want to pollute
+    // the exception class with Android specifics.
+    final int messageId = e.getErrorMessageStringResource();
+    final int clickableId = R.string.fxaccount_resend_unlock_code_button_label;
+    final Spannable span = Utils.interpolateClickableSpan(this, messageId, clickableId, new ClickableSpan() {
+      @Override
+      public void onClick(View widget) {
+        // It would be best to capture the email address sent to the server
+        // and use it here, but this will do for now. If the user modifies
+        // the email address entered, the error text is hidden, so sending a
+        // changed email address would be the result of an unusual race.
+        final String email = emailEdit.getText().toString();
+        byte[] emailUTF8 = null;
+        try {
+          emailUTF8 = email.getBytes("UTF-8");
+        } catch (UnsupportedEncodingException e) {
+          // It's okay, we'll fail in the code resender.
+        }
+        FxAccountUnlockCodeResender.resendUnlockCode(FxAccountAbstractSetupActivity.this, getAuthServerEndpoint(), emailUTF8);
+      }
+    });
+    remoteErrorTextView.setMovementMethod(LinkMovementMethod.getInstance());
+    remoteErrorTextView.setText(span);
   }
 
   protected void addListeners() {
     TextChangedListener textChangedListener = new TextChangedListener();
     EditorActionListener editorActionListener = new EditorActionListener();
     FocusChangeListener focusChangeListener = new FocusChangeListener();
 
     emailEdit.addTextChangedListener(textChangedListener);
--- a/mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java
@@ -17,25 +17,25 @@ import org.mozilla.gecko.background.fxa.
 import org.mozilla.gecko.background.fxa.FxAccountClient;
 import org.mozilla.gecko.background.fxa.FxAccountClient10.RequestDelegate;
 import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.LoginResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.fxa.PasswordStretcher;
 import org.mozilla.gecko.fxa.tasks.FxAccountCreateAccountTask;
+import org.mozilla.gecko.sync.Utils;
 
 import android.app.AlertDialog;
 import android.app.Dialog;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.os.Bundle;
 import android.os.SystemClock;
 import android.text.Spannable;
-import android.text.Spanned;
 import android.text.method.LinkMovementMethod;
 import android.text.style.ClickableSpan;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.widget.AutoCompleteTextView;
 import android.widget.Button;
 import android.widget.CheckBox;
 import android.widget.EditText;
@@ -119,36 +119,33 @@ public class FxAccountCreateAccountActiv
     if (!e.isAccountAlreadyExists()) {
       super.showClientRemoteException(e);
       return;
     }
 
     // This horrible bit of special-casing is because we want this error message to
     // contain a clickable, extra chunk of text, but we don't want to pollute
     // the exception class with Android specifics.
-    final String clickablePart = getString(R.string.fxaccount_sign_in_button_label);
-    final String message = getString(e.getErrorMessageStringResource(), clickablePart);
-    final int clickableStart = message.lastIndexOf(clickablePart);
-    final int clickableEnd = clickableStart + clickablePart.length();
+    final int messageId = e.getErrorMessageStringResource();
+    final int clickableId = R.string.fxaccount_sign_in_button_label;
 
-    final Spannable span = Spannable.Factory.getInstance().newSpannable(message);
-    span.setSpan(new ClickableSpan() {
+    final Spannable span = Utils.interpolateClickableSpan(this, messageId, clickableId, new ClickableSpan() {
       @Override
       public void onClick(View widget) {
         // Pass through the email address that already existed.
         String email = e.body.getString("email");
         if (email == null) {
             email = emailEdit.getText().toString();
         }
         final String password = passwordEdit.getText().toString();
 
         final Bundle extras = makeExtrasBundle(email, password);
         startActivityInstead(FxAccountSignInActivity.class, CHILD_REQUEST_CODE, extras);
       }
-    }, clickableStart, clickableEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+    });
     remoteErrorTextView.setMovementMethod(LinkMovementMethod.getInstance());
     remoteErrorTextView.setText(span);
   }
 
   /**
    * We might have switched to the SignIn activity; if that activity
    * succeeds, feed its result back to the authenticator.
    */
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/fxa/tasks/FxAccountUnlockCodeResender.java
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.tasks;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient10.RequestDelegate;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.widget.Toast;
+
+/**
+ * A helper class that provides a simple interface for requesting a Firefox
+ * Account unlock account email be (re)-sent.
+ */
+public class FxAccountUnlockCodeResender {
+  private static final String LOG_TAG = FxAccountUnlockCodeResender.class.getSimpleName();
+
+  private static class FxAccountUnlockCodeTask extends FxAccountSetupTask<Void> {
+    protected static final String LOG_TAG = FxAccountUnlockCodeTask.class.getSimpleName();
+
+    protected final byte[] emailUTF8;
+
+    public FxAccountUnlockCodeTask(Context context, byte[] emailUTF8, FxAccountClient client, RequestDelegate<Void> delegate) {
+      super(context, null, client, delegate);
+      this.emailUTF8 = emailUTF8;
+    }
+
+    @Override
+    protected InnerRequestDelegate<Void> doInBackground(Void... arg0) {
+      try {
+        client.resendUnlockCode(emailUTF8, innerDelegate);
+        latch.await();
+        return innerDelegate;
+      } catch (Exception e) {
+        Logger.error(LOG_TAG, "Got exception signing in.", e);
+        delegate.handleError(e);
+      }
+      return null;
+    }
+  }
+
+  private static class ResendUnlockCodeDelegate implements RequestDelegate<Void> {
+    public final Context context;
+
+    public ResendUnlockCodeDelegate(Context context) {
+      this.context = context;
+    }
+
+    @Override
+    public void handleError(Exception e) {
+      Logger.warn(LOG_TAG, "Got exception requesting fresh unlock code; ignoring.", e);
+      Toast.makeText(context, R.string.fxaccount_unlock_code_not_sent, Toast.LENGTH_LONG).show();
+    }
+
+    @Override
+    public void handleFailure(FxAccountClientRemoteException e) {
+      handleError(e);
+    }
+
+    @Override
+    public void handleSuccess(Void result) {
+      Toast.makeText(context, R.string.fxaccount_unlock_code_sent, Toast.LENGTH_SHORT).show();
+    }
+  }
+
+  /**
+   * Resends the account unlock email, and displays an appropriate toast on both
+   * send success and failure. Note that because the underlying implementation
+   * uses {@link AsyncTask}, the provided context must be UI-capable and this
+   * method called from the UI thread.
+   *
+   * Note that it may actually be possible to run this (and the
+   * {@link AsyncTask}) method from a background thread - but this hasn't been
+   * tested.
+   *
+   * @param context
+   *          A UI-capable Android context.
+   * @param authServerURI
+   *          to send request to.
+   * @param emailUTF8
+   *          bytes of email address identifying account; null indicates a local failure.
+   */
+  public static void resendUnlockCode(Context context, String authServerURI, byte[] emailUTF8) {
+    RequestDelegate<Void> delegate = new ResendUnlockCodeDelegate(context);
+
+    if (emailUTF8 == null) {
+      delegate.handleError(new IllegalArgumentException("emailUTF8 must not be null"));
+      return;
+    }
+
+    final Executor executor = Executors.newSingleThreadExecutor();
+    final FxAccountClient client = new FxAccountClient20(authServerURI, executor);
+    new FxAccountUnlockCodeTask(context, emailUTF8, client, delegate).execute();
+  }
+}
--- a/mobile/android/base/locales/en-US/sync_strings.dtd
+++ b/mobile/android/base/locales/en-US/sync_strings.dtd
@@ -165,16 +165,19 @@
 
 <!ENTITY fxaccount_confirm_account_header 'Confirm your account'>
 <!-- Localization note: &formatS; is the Firefox Account's email address. -->
 <!ENTITY fxaccount_confirm_account_verification_link 'A verification link has been sent to &formatS;'>
 <!ENTITY fxaccount_confirm_account_resend_email 'Resend email'>
 <!ENTITY fxaccount_confirm_account_change_email 'Forget this email address?'>
 <!ENTITY fxaccount_confirm_account_verification_link_sent2 'Verification email sent'>
 <!ENTITY fxaccount_confirm_account_verification_link_not_sent2 'Couldn\&apos;t send verification email'>
+<!ENTITY fxaccount_resend_unlock_code_button_label 'Resend unlock email'>
+<!ENTITY fxaccount_unlock_code_sent 'Account unlock email sent'>
+<!ENTITY fxaccount_unlock_code_not_sent 'Couldn\&apos;t send account unlock email'>
 
 <!ENTITY fxaccount_sign_in_sub_header 'Sign in'>
 <!ENTITY fxaccount_sign_in_button_label 'Sign in'>
 <!ENTITY fxaccount_sign_in_forgot_password 'Forgot password?'>
 <!ENTITY fxaccount_sign_in_create_account_instead 'Create an account'>
 <!ENTITY fxaccount_sign_in_unknown_error 'Could not sign in'>
 
 <!ENTITY fxaccount_account_verified_sub_header 'Account verified'>
@@ -247,16 +250,17 @@
 <!ENTITY fxaccount_remote_error_ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS_2 'Account already exists. &formatS1;'>
 <!ENTITY fxaccount_remote_error_ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST 'Invalid email or password'>
 <!ENTITY fxaccount_remote_error_INCORRECT_PASSWORD 'Invalid email or password'>
 <!ENTITY fxaccount_remote_error_ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT 'Account is not verified'>
 <!ENTITY fxaccount_remote_error_CLIENT_HAS_SENT_TOO_MANY_REQUESTS 'Server busy, try again soon'>
 <!ENTITY fxaccount_remote_error_SERVICE_TEMPORARILY_UNAVAILABLE_TO_DUE_HIGH_LOAD 'Server busy, try again soon'>
 <!ENTITY fxaccount_remote_error_UNKNOWN_ERROR 'There was a problem'>
 <!ENTITY fxaccount_remote_error_COULD_NOT_CONNECT 'Cannot connect to network'>
+<!ENTITY fxaccount_remote_error_ACCOUNT_LOCKED 'Account is locked. &formatS1;'>
 
 <!ENTITY fxaccount_sync_sign_in_error_notification_title2 '&syncBrand.shortName.label; is not connected'>
 <!-- Localization note: the format string below will be replaced
      with the Firefox Account's email address. -->
 <!ENTITY fxaccount_sync_sign_in_error_notification_text2 'Tap to sign in as &formatS;'>
 
 <!ENTITY fxaccount_sync_finish_migrating_notification_title 'Finish upgrading &syncBrand.shortName.label;?'>
 <!-- Localization note: the format string below will be replaced
--- a/mobile/android/base/sync/Utils.java
+++ b/mobile/android/base/sync/Utils.java
@@ -30,16 +30,19 @@ import org.mozilla.apache.commons.codec.
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.nativecode.NativeCrypto;
 import org.mozilla.gecko.sync.setup.Constants;
 
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.os.Bundle;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
 
 public class Utils {
 
   private static final String LOG_TAG = "Utils";
 
   private static final SecureRandom sharedSecureRandom = new SecureRandom();
 
   // See <http://developer.android.com/reference/android/content/Context.html#getSharedPreferences%28java.lang.String,%20int%29>
@@ -609,9 +612,31 @@ public class Utils {
     }
 
     String country = locale.getCountry();    // Can be an empty string.
     if (country.equals("")) {
       return language;
     }
     return language + "-" + country;
   }
+
+  /**
+   * Make a span with a clickable chunk of text interpolated in.
+   *
+   * @param context Android context.
+   * @param messageId of string containing clickable chunk.
+   * @param clickableId of string to make clickable.
+   * @param clickableSpan to activate on click.
+   * @return Spannable.
+   */
+  public static Spannable interpolateClickableSpan(Context context, int messageId, int clickableId, ClickableSpan clickableSpan) {
+    // This horrible bit of special-casing is because we want this error message to
+    // contain a clickable, extra chunk of text, but we don't want to pollute
+    // the exception class with Android specifics.
+    final String clickablePart = context.getString(clickableId);
+    final String message = context.getString(messageId, clickablePart);
+    final int clickableStart = message.lastIndexOf(clickablePart);
+    final int clickableEnd = clickableStart + clickablePart.length();
+    final Spannable span = Spannable.Factory.getInstance().newSpannable(message);
+    span.setSpan(clickableSpan, clickableStart, clickableEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+    return span;
+  }
 }
--- a/mobile/android/services/strings.xml.in
+++ b/mobile/android/services/strings.xml.in
@@ -152,16 +152,19 @@
 <string name="fxaccount_account_create_not_allowed_learn_more">&fxaccount_account_create_not_allowed_learn_more;</string>
 
 <string name="fxaccount_confirm_account_header">&fxaccount_confirm_account_header;</string>
 <string name="fxaccount_confirm_account_verification_link">&fxaccount_confirm_account_verification_link;</string>
 <string name="fxaccount_confirm_account_resend_email">&fxaccount_confirm_account_resend_email;</string>
 <string name="fxaccount_confirm_account_change_email">&fxaccount_confirm_account_change_email;</string>
 <string name="fxaccount_confirm_account_verification_link_sent">&fxaccount_confirm_account_verification_link_sent2;</string>
 <string name="fxaccount_confirm_account_verification_link_not_sent">&fxaccount_confirm_account_verification_link_not_sent2;</string>
+<string name="fxaccount_resend_unlock_code_button_label">&fxaccount_resend_unlock_code_button_label;</string>
+<string name="fxaccount_unlock_code_sent">&fxaccount_unlock_code_sent;</string>
+<string name="fxaccount_unlock_code_not_sent">&fxaccount_unlock_code_not_sent;</string>
 
 <string name="fxaccount_sign_in_sub_header">&fxaccount_sign_in_sub_header;</string>
 <string name="fxaccount_sign_in_button_label">&fxaccount_sign_in_button_label;</string>
 <string name="fxaccount_sign_in_forgot_password">&fxaccount_sign_in_forgot_password;</string>
 <string name="fxaccount_sign_in_create_account_instead">&fxaccount_sign_in_create_account_instead;</string>
 <string name="fxaccount_sign_in_unknown_error">&fxaccount_sign_in_unknown_error;</string>
 
 <string name="fxaccount_account_verified_sub_header">&fxaccount_account_verified_sub_header;</string>
@@ -213,16 +216,17 @@
 <string name="fxaccount_remote_error_ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS">&fxaccount_remote_error_ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS_2;</string>
 <string name="fxaccount_remote_error_ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST">&fxaccount_remote_error_ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST;</string>
 <string name="fxaccount_remote_error_INCORRECT_PASSWORD">&fxaccount_remote_error_INCORRECT_PASSWORD;</string>
 <string name="fxaccount_remote_error_ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT">&fxaccount_remote_error_ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT;</string>
 <string name="fxaccount_remote_error_CLIENT_HAS_SENT_TOO_MANY_REQUESTS">&fxaccount_remote_error_CLIENT_HAS_SENT_TOO_MANY_REQUESTS;</string>
 <string name="fxaccount_remote_error_SERVICE_TEMPORARILY_UNAVAILABLE_TO_DUE_HIGH_LOAD">&fxaccount_remote_error_SERVICE_TEMPORARILY_UNAVAILABLE_TO_DUE_HIGH_LOAD;</string>
 <string name="fxaccount_remote_error_UNKNOWN_ERROR">&fxaccount_remote_error_UNKNOWN_ERROR;</string>
 <string name="fxaccount_remote_error_COULD_NOT_CONNECT">&fxaccount_remote_error_COULD_NOT_CONNECT;</string>
+<string name="fxaccount_remote_error_ACCOUNT_LOCKED">&fxaccount_remote_error_ACCOUNT_LOCKED;</string>
 
 <string name="fxaccount_sync_sign_in_error_notification_title">&fxaccount_sync_sign_in_error_notification_title2;</string>
 <string name="fxaccount_sync_sign_in_error_notification_text">&fxaccount_sync_sign_in_error_notification_text2;</string>
 
 <!-- Remove Account -->
 <string name="fxaccount_remove_account_dialog_title">&fxaccount_remove_account_dialog_title;</string>
 <string name="fxaccount_remove_account_dialog_message">&fxaccount_remove_account_dialog_message;</string>
 <string name="fxaccount_remove_account_toast">&fxaccount_remove_account_toast;</string>