diff --git a/mobile/android/base/background/fxa/FxAccountAgeLockoutHelper.java b/mobile/android/base/background/fxa/FxAccountAgeLockoutHelper.java index 6f726c0a2238..4b4ac22f3c33 100644 --- a/mobile/android/base/background/fxa/FxAccountAgeLockoutHelper.java +++ b/mobile/android/base/background/fxa/FxAccountAgeLockoutHelper.java @@ -4,8 +4,10 @@ package org.mozilla.gecko.background.fxa; +import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Calendar; +import java.util.Locale; import org.mozilla.gecko.fxa.FxAccountConstants; @@ -46,31 +48,69 @@ public class FxAccountAgeLockoutHelper { } /** - * Return true if the age of somebody born in yearOfBirth is - * definitely old enough to create an account. + * Return true if the given year is the magic year. *

- * This errs towards locking out users who might be old enough, but are not - * definitely old enough. + * The magic year is the calendar year when the user is the minimum age + * older. That is, for part of the magic year the user is younger than the age + * limit and for part of the magic year the user is older than the age limit. * * @param yearOfBirth - * @return true if somebody born in yearOfBirth is definitely old - * enough. + * @return true if yearOfBirth is the magic year. */ - public static boolean passesAgeCheck(int yearOfBirth) { - int thisYear = Calendar.getInstance().get(Calendar.YEAR); - int approximateAge = thisYear - yearOfBirth; - boolean oldEnough = approximateAge >= FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT; + public static boolean isMagicYear(int yearOfBirth) { + final Calendar cal = Calendar.getInstance(); + final int thisYear = cal.get(Calendar.YEAR); + return (thisYear - yearOfBirth) == FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT; + } + + /** + * Return true if the age of somebody born in + * dayOfBirth/zeroBasedMonthOfBirth/yearOfBirth is old enough to + * create an account. + * + * @param dayOfBirth + * @param zeroBasedMonthOfBirth + * @param yearOfBirth + * @return true if somebody born in + * dayOfBirth/zeroBasedMonthOfBirth/yearOfBirth is old enough. + */ + public static boolean passesAgeCheck(final int dayOfBirth, final int zeroBasedMonthOfBirth, final int yearOfBirth) { + final Calendar latestBirthday = Calendar.getInstance(); + final int y = latestBirthday.get(Calendar.YEAR); + final int m = latestBirthday.get(Calendar.MONTH); + final int d = latestBirthday.get(Calendar.DAY_OF_MONTH); + latestBirthday.clear(); + latestBirthday.set(y - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT, m, d); + + // Go back one second, so that the exact same birthday and latestBirthday satisfy birthday <= latestBirthday. + latestBirthday.add(Calendar.SECOND, 1); + + final Calendar birthday = Calendar.getInstance(); + birthday.clear(); + birthday.set(yearOfBirth, zeroBasedMonthOfBirth, dayOfBirth); + + boolean oldEnough = birthday.before(latestBirthday); + if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { - FxAccountUtils.pii(LOG_TAG, "Age check " + (oldEnough ? "passes" : "fails") + - ": age is " + approximateAge + " = " + thisYear + " - " + yearOfBirth); + final StringBuilder message = new StringBuilder(); + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd", Locale.getDefault()); + message.append("Age check "); + message.append(oldEnough ? "passes" : "fails"); + message.append(": birthday is "); + message.append(sdf.format(birthday.getTime())); + message.append("; latest birthday is "); + message.append(sdf.format(latestBirthday.getTime())); + message.append(" (Y/M/D)."); + FxAccountUtils.pii(LOG_TAG, message.toString()); } + return oldEnough; } /** * Custom function for UI use only. */ - public static boolean passesAgeCheck(String yearText, String[] yearItems) { + public static boolean passesAgeCheck(int dayOfBirth, int zeroBaseMonthOfBirth, String yearText, String[] yearItems) { if (yearText == null) { throw new IllegalArgumentException("yearText must not be null"); } @@ -91,6 +131,7 @@ public class FxAccountAgeLockoutHelper { FxAccountUtils.pii(LOG_TAG, "Passed age check: year text was found in item list but was not a number."); return true; } - return passesAgeCheck(yearOfBirth); + + return passesAgeCheck(dayOfBirth, zeroBaseMonthOfBirth, yearOfBirth); } } diff --git a/mobile/android/base/fxa/FxAccountConstants.java b/mobile/android/base/fxa/FxAccountConstants.java index 5ced80f7227a..a13c18110883 100644 --- a/mobile/android/base/fxa/FxAccountConstants.java +++ b/mobile/android/base/fxa/FxAccountConstants.java @@ -16,8 +16,8 @@ public class FxAccountConstants { public static final String STAGE_AUTH_SERVER_ENDPOINT = "https://api-accounts.stage.mozaws.net/v1"; public static final String STAGE_TOKEN_SERVER_ENDPOINT = "https://token.stage.mozaws.net/1.0/sync/1.5"; - // You must be at least 14 years old to create a Firefox Account. - public static final int MINIMUM_AGE_TO_CREATE_AN_ACCOUNT = 14; + // You must be at least 13 years old, on the day of creation, to create a Firefox Account. + public static final int MINIMUM_AGE_TO_CREATE_AN_ACCOUNT = 13; // You must wait 15 minutes after failing an age check before trying to create a different account. public static final long MINIMUM_TIME_TO_WAIT_AFTER_AGE_CHECK_FAILED_IN_MILLISECONDS = 15 * 60 * 1000; diff --git a/mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java b/mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java index b878b4673e7a..f13a3e3512d1 100644 --- a/mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java +++ b/mobile/android/base/fxa/activities/FxAccountAbstractSetupActivity.java @@ -63,6 +63,8 @@ abstract public class FxAccountAbstractSetupActivity extends FxAccountAbstractAc public static final String EXTRA_PASSWORD = "password"; public static final String EXTRA_PASSWORD_SHOWN = "password_shown"; public static final String EXTRA_YEAR = "year"; + public static final String EXTRA_MONTH = "month"; + public static final String EXTRA_DAY = "day"; public static final String EXTRA_EXTRAS = "extras"; public static final String JSON_KEY_AUTH = "auth"; diff --git a/mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java b/mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java index 10416368043e..4a9f1a2e2200 100644 --- a/mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java +++ b/mobile/android/base/fxa/activities/FxAccountCreateAccountActivity.java @@ -7,6 +7,7 @@ package org.mozilla.gecko.fxa.activities; import java.util.Calendar; import java.util.HashMap; import java.util.LinkedList; +import java.util.Locale; import java.util.Map; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -52,8 +53,13 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi private static final int CHILD_REQUEST_CODE = 2; protected String[] yearItems; + protected String[] monthItems; + protected String[] dayItems; protected EditText yearEdit; + protected EditText monthEdit; + protected EditText dayEdit; protected CheckBox chooseCheckBox; + protected View monthDaycombo; protected Map selectedEngines; @@ -71,6 +77,9 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi passwordEdit = (EditText) ensureFindViewById(null, R.id.password, "password edit"); showPasswordButton = (Button) ensureFindViewById(null, R.id.show_password, "show password button"); yearEdit = (EditText) ensureFindViewById(null, R.id.year_edit, "year edit"); + monthEdit = (EditText) ensureFindViewById(null, R.id.month_edit, "month edit"); + dayEdit = (EditText) ensureFindViewById(null, R.id.day_edit, "day edit"); + monthDaycombo = ensureFindViewById(null, R.id.month_day_combo, "month day combo"); remoteErrorTextView = (TextView) ensureFindViewById(null, R.id.remote_error, "remote error text view"); button = (Button) ensureFindViewById(null, R.id.button, "create account button"); progressBar = (ProgressBar) ensureFindViewById(null, R.id.progress, "progress bar"); @@ -84,6 +93,7 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi createShowPasswordButton(); linkifyPolicy(); createChooseCheckBox(); + initializeMonthAndDayValues(); View signInInsteadLink = ensureFindViewById(null, R.id.sign_in_instead_link, "sign in instead link"); signInInsteadLink.setOnClickListener(new OnClickListener() { @@ -97,11 +107,23 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi updateFromIntentExtras(); } + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + updateMonthAndDayFromBundle(savedInstanceState); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + updateBundleWithMonthAndDay(outState); + } + @Override protected Bundle makeExtrasBundle(String email, String password) { final Bundle extras = super.makeExtrasBundle(email, password); - final String year = yearEdit.getText().toString(); - extras.putString(EXTRA_YEAR, year); + extras.putString(EXTRA_YEAR, yearEdit.getText().toString()); + updateBundleWithMonthAndDay(extras); return extras; } @@ -111,9 +133,40 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi if (getIntent() != null) { yearEdit.setText(getIntent().getStringExtra(EXTRA_YEAR)); + updateMonthAndDayFromBundle(getIntent().getExtras() != null ? getIntent().getExtras() : new Bundle()); } } + private void updateBundleWithMonthAndDay(final Bundle bundle) { + if (monthEdit.getTag() != null) { + bundle.putInt(EXTRA_MONTH, (Integer) monthEdit.getTag()); + } + if (dayEdit.getTag() != null) { + bundle.putInt(EXTRA_DAY, (Integer) dayEdit.getTag()); + } + } + + private void updateMonthAndDayFromBundle(final Bundle extras) { + final Integer zeroBasedMonthIndex = (Integer) extras.get(EXTRA_MONTH); + final Integer oneBasedDayIndex = (Integer) extras.get(EXTRA_DAY); + maybeEnableMonthAndDayButtons(); + + if (zeroBasedMonthIndex != null) { + monthEdit.setText(monthItems[zeroBasedMonthIndex]); + monthEdit.setTag(Integer.valueOf(zeroBasedMonthIndex)); + createDayEdit(zeroBasedMonthIndex); + + if (oneBasedDayIndex != null && dayItems != null) { + dayEdit.setText(dayItems[oneBasedDayIndex - 1]); + dayEdit.setTag(Integer.valueOf(oneBasedDayIndex)); + } + } else { + monthEdit.setText(""); + dayEdit.setText(""); + } + updateButtonState(); + } + @Override protected void showClientRemoteException(final FxAccountClientRemoteException e) { if (!e.isAccountAlreadyExists()) { @@ -186,6 +239,7 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi @Override public void onClick(DialogInterface dialog, int which) { yearEdit.setText(yearItems[which]); + maybeEnableMonthAndDayButtons(); updateButtonState(); } }; @@ -200,6 +254,114 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi }); } + private void initializeMonthAndDayValues() { + // Hide Month and day pickers + monthDaycombo.setVisibility(View.GONE); + dayEdit.setEnabled(false); + + // Populate month names. + final Calendar calendar = Calendar.getInstance(); + final Map monthNamesMap = calendar.getDisplayNames(Calendar.MONTH, Calendar.LONG, Locale.getDefault()); + monthItems = new String[monthNamesMap.size()]; + for (Map.Entry entry : monthNamesMap.entrySet()) { + monthItems[entry.getValue()] = entry.getKey(); + } + createMonthEdit(); + } + + protected void createMonthEdit() { + monthEdit.setText(""); + monthEdit.setTag(null); + monthEdit.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + android.content.DialogInterface.OnClickListener listener = new Dialog.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + monthEdit.setText(monthItems[which]); + monthEdit.setTag(Integer.valueOf(which)); + createDayEdit(which); + updateButtonState(); + } + }; + final AlertDialog dialog = new AlertDialog.Builder(FxAccountCreateAccountActivity.this) + .setTitle(R.string.fxaccount_create_account_month_of_birth) + .setItems(monthItems, listener) + .setIcon(R.drawable.icon) + .create(); + dialog.show(); + } + }); + } + + protected void createDayEdit(final int monthIndex) { + dayEdit.setText(""); + dayEdit.setTag(null); + dayEdit.setEnabled(true); + + String yearText = yearEdit.getText().toString(); + Integer birthYear; + try { + birthYear = Integer.parseInt(yearText); + } catch (NumberFormatException e) { + // Ideal this should never happen. + Logger.debug(LOG_TAG, "Exception while parsing year value" + e); + return; + } + + Calendar c = Calendar.getInstance(); + c.set(birthYear, monthIndex, 1); + LinkedList days = new LinkedList(); + for (int i = c.getActualMinimum(Calendar.DAY_OF_MONTH); i <= c.getActualMaximum(Calendar.DAY_OF_MONTH); i++) { + days.add(Integer.toString(i)); + } + dayItems = days.toArray(new String[days.size()]); + + dayEdit.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + android.content.DialogInterface.OnClickListener listener = new Dialog.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dayEdit.setText(dayItems[which]); + dayEdit.setTag(Integer.valueOf(which + 1)); // Days are 1-based. + updateButtonState(); + } + }; + final AlertDialog dialog = new AlertDialog.Builder(FxAccountCreateAccountActivity.this) + .setTitle(R.string.fxaccount_create_account_day_of_birth) + .setItems(dayItems, listener) + .setIcon(R.drawable.icon) + .create(); + dialog.show(); + } + }); + } + + private void maybeEnableMonthAndDayButtons() { + Integer yearOfBirth = null; + try { + yearOfBirth = Integer.valueOf(yearEdit.getText().toString(), 10); + } catch (NumberFormatException e) { + Logger.debug(LOG_TAG, "Year text is not a number; assuming year is a range and that user is old enough."); + } + + // Check if the selected year is the magic year. + if (yearOfBirth == null || !FxAccountAgeLockoutHelper.isMagicYear(yearOfBirth)) { + // Year/Dec/31 is the latest birthday in the selected year, corresponding + // to the youngest person. + monthEdit.setTag(Integer.valueOf(11)); + dayEdit.setTag(Integer.valueOf(31)); + return; + } + + // Show month and date field. + yearEdit.setVisibility(View.GONE); + monthDaycombo.setVisibility(View.VISIBLE); + monthEdit.setTag(null); + dayEdit.setTag(null); + } + public void createAccount(String email, String password, Map engines) { String serverURI = getAuthServerEndpoint(); PasswordStretcher passwordStretcher = makePasswordStretcher(password); @@ -230,7 +392,9 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi @Override protected boolean shouldButtonBeEnabled() { return super.shouldButtonBeEnabled() && - (yearEdit.length() > 0); + (yearEdit.length() > 0) && + (monthEdit.getTag() != null) && + (dayEdit.getTag() != null); } protected void createCreateAccountButton() { @@ -242,11 +406,13 @@ public class FxAccountCreateAccountActivity extends FxAccountAbstractSetupActivi } final String email = emailEdit.getText().toString(); final String password = passwordEdit.getText().toString(); + final int dayOfBirth = (Integer) dayEdit.getTag(); + final int zeroBasedMonthOfBirth = (Integer) monthEdit.getTag(); // Only include selected engines if the user currently has the option checked. final Map engines = chooseCheckBox.isChecked() ? selectedEngines : null; - if (FxAccountAgeLockoutHelper.passesAgeCheck(yearEdit.getText().toString(), yearItems)) { + if (FxAccountAgeLockoutHelper.passesAgeCheck(dayOfBirth, zeroBasedMonthOfBirth, yearEdit.getText().toString(), yearItems)) { FxAccountUtils.pii(LOG_TAG, "Passed age check."); createAccount(email, password, engines); } else { diff --git a/mobile/android/base/locales/en-US/sync_strings.dtd b/mobile/android/base/locales/en-US/sync_strings.dtd index cca0b7ece3a4..429968a79a90 100644 --- a/mobile/android/base/locales/en-US/sync_strings.dtd +++ b/mobile/android/base/locales/en-US/sync_strings.dtd @@ -150,6 +150,8 @@ etc). The account remains a "Firefox Account". --> + + diff --git a/mobile/android/base/resources/layout/fxaccount_create_account.xml b/mobile/android/base/resources/layout/fxaccount_create_account.xml index 77530e13b5e5..4718640012ea 100644 --- a/mobile/android/base/resources/layout/fxaccount_create_account.xml +++ b/mobile/android/base/resources/layout/fxaccount_create_account.xml @@ -40,6 +40,35 @@ android:hint="@string/fxaccount_create_account_year_of_birth" android:inputType="none" /> + + + + + + + &fxaccount_create_account_header2; &fxaccount_create_account_password_length_restriction; +&fxaccount_create_account_day_of_birth; +&fxaccount_create_account_month_of_birth; &fxaccount_create_account_year_of_birth; &fxaccount_create_account_policy_text2;