Bug 957894: Update account pickling code for Firefox Accounts. r=nalexander

This commit is contained in:
Michael Comella 2014-03-26 11:31:43 -07:00
parent 96d36654c2
commit a302af9ad4
17 changed files with 768 additions and 18 deletions

View File

@ -561,6 +561,7 @@ sync_java_files = [
'fxa/activities/FxAccountStatusFragment.java',
'fxa/activities/FxAccountUpdateCredentialsActivity.java',
'fxa/activities/FxAccountVerifiedAccountActivity.java',
'fxa/authenticator/AccountPickler.java',
'fxa/authenticator/AndroidFxAccount.java',
'fxa/authenticator/FxAccountAuthenticator.java',
'fxa/authenticator/FxAccountAuthenticatorService.java',
@ -578,6 +579,8 @@ sync_java_files = [
'fxa/login/State.java',
'fxa/login/StateFactory.java',
'fxa/login/TokensAndKeysState.java',
'fxa/receivers/FxAccountDeletedReceiver.java',
'fxa/receivers/FxAccountDeletedService.java',
'fxa/sync/FxAccountGlobalSession.java',
'fxa/sync/FxAccountNotificationManager.java',
'fxa/sync/FxAccountSchedulePolicy.java',

View File

@ -4,6 +4,14 @@
package org.mozilla.gecko.fxa;
import java.io.File;
import java.util.concurrent.CountDownLatch;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.fxa.authenticator.AccountPickler;
import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
import org.mozilla.gecko.sync.ThreadPool;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.Context;
@ -12,8 +20,10 @@ import android.content.Context;
* Simple public accessors for Firefox account objects.
*/
public class FirefoxAccounts {
private static final String LOG_TAG = FirefoxAccounts.class.getSimpleName();
/**
* Return true if at least one Firefox account exists.
* Returns true if a FirefoxAccount exists, false otherwise.
*
* @param context Android context.
* @return true if at least one Firefox account exists.
@ -24,12 +34,63 @@ public class FirefoxAccounts {
/**
* Return Firefox accounts.
* <p>
* If no accounts exist in the AccountManager, one may be created
* via a pickled FirefoxAccount, if available, and that account
* will be added to the AccountManager and returned.
* <p>
* Note that this can be called from any thread.
*
* @param context Android context.
* @return Firefox account objects.
*/
public static Account[] getFirefoxAccounts(final Context context) {
return AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
final Account[] accounts =
AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
if (accounts.length > 0) {
return accounts;
}
final Account pickledAccount = getPickledAccount(context);
return (pickledAccount != null) ? new Account[] {pickledAccount} : new Account[0];
}
private static Account getPickledAccount(final Context context) {
// To avoid a StrictMode violation for disk access, we call this from a background thread.
// We do this every time, so the caller doesn't have to care.
final CountDownLatch latch = new CountDownLatch(1);
final Account[] accounts = new Account[1];
ThreadPool.run(new Runnable() {
@Override
public void run() {
try {
final File file = context.getFileStreamPath(FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
if (!file.exists()) {
accounts[0] = null;
return;
}
// There is a small race window here: if the user creates a new Firefox account
// between our checks, this could erroneously report that no Firefox accounts
// exist.
final AndroidFxAccount fxAccount =
AccountPickler.unpickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
accounts[0] = fxAccount.getAndroidAccount();
} finally {
latch.countDown();
}
}
});
try {
latch.await(); // Wait for the background thread to return.
} catch (InterruptedException e) {
Logger.warn(LOG_TAG,
"Foreground thread unexpectedly interrupted while getting pickled account", e);
return null;
}
return accounts[0];
}
/**
@ -43,4 +104,4 @@ public class FirefoxAccounts {
}
return null;
}
}
}

View File

@ -34,4 +34,36 @@ public class FxAccountConstants {
public static final long MINIMUM_TIME_TO_WAIT_AFTER_AGE_CHECK_FAILED_IN_MILLISECONDS = 15 * 60 * 1000;
public static final String USER_AGENT = "Firefox-Android-FxAccounts/ (" + GlobalConstants.MOZ_APP_DISPLAYNAME + " " + GlobalConstants.MOZ_APP_VERSION + ")";
public static final String ACCOUNT_PICKLE_FILENAME = "fxa.account.json";
/**
* This action is broadcast when an Android Firefox Account is deleted.
* This allows each installed Firefox to delete any Firefox Account pickle
* file.
* <p>
* It is protected by signing-level permission PER_ACCOUNT_TYPE_PERMISSION and
* can be received only by Firefox channels sharing the same Android Firefox
* Account type.
* <p>
* See {@link org.mozilla.gecko.fxa.AndroidFxAccount#makeDeletedAccountIntent(android.content.Context, android.accounts.Account)}
* for contents of the intent.
*
* See bug 790931 for additional information in the context of Sync.
*/
public static final String ACCOUNT_DELETED_ACTION = "@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.accounts.ACCOUNT_DELETED_ACTION";
/**
* Version number of contents of SYNC_ACCOUNT_DELETED_ACTION intent.
*/
public static final long ACCOUNT_DELETED_INTENT_VERSION = 1;
public static final String ACCOUNT_DELETED_INTENT_VERSION_KEY = "account_deleted_intent_version";
public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_KEY = "account_deleted_intent_account";
/**
* This signing-level permission protects broadcast intents that should be
* received only by Firefox channels sharing the same Android Firefox Account type.
*/
public static final String PER_ACCOUNT_TYPE_PERMISSION = "@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE";
}

View File

@ -67,15 +67,15 @@ public class FxAccountGetStartedActivity extends AccountAuthenticatorActivity {
} else if (FirefoxAccounts.firefoxAccountsExist(this)) {
intent = new Intent(this, FxAccountStatusActivity.class);
}
if (intent != null) {
this.setAccountAuthenticatorResult(null);
setResult(RESULT_CANCELED);
// Per http://stackoverflow.com/a/8992365, this triggers a known bug with
// the soft keyboard not being shown for the started activity. Why, Android, why?
intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
startActivity(intent);
finish();
return;
this.startActivity(intent);
this.finish();
}
}

View File

@ -0,0 +1,275 @@
/* 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.authenticator;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.fxa.FxAccountConstants;
import org.mozilla.gecko.fxa.login.State;
import org.mozilla.gecko.fxa.login.State.StateLabel;
import org.mozilla.gecko.fxa.login.StateFactory;
import org.mozilla.gecko.sync.ExtendedJSONObject;
import org.mozilla.gecko.sync.NonObjectJSONException;
import org.mozilla.gecko.sync.Utils;
import android.content.Context;
/**
* Android deletes Account objects when the Authenticator that owns the Account
* disappears. This happens when an App is installed to the SD card and the SD
* card is un-mounted or the device is rebooted.
* <p>
* We work around this by pickling the current Firefox account data every sync
* and unpickling when we check if Firefox accounts exist (called from Fennec).
* <p>
* Android just doesn't support installing Apps that define long-lived Services
* and/or own Account types onto the SD card. The documentation says not to do
* it. There are hordes of developers who want to do it, and have tried to
* register for almost every "package installation changed" broadcast intent
* that Android supports. They all explicitly state that the package that has
* changed does *not* receive the broadcast intent, thereby preventing an App
* from re-establishing its state.
* <p>
* <a href="http://developer.android.com/guide/topics/data/install-location.html">Reference.</a>
* <p>
* <b>Quote</b>: Your AbstractThreadedSyncAdapter and all its sync functionality
* will not work until external storage is remounted.
* <p>
* <b>Quote</b>: Your running Service will be killed and will not be restarted
* when external storage is remounted. You can, however, register for the
* ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify
* your application when applications installed on external storage have become
* available to the system again. At which time, you can restart your Service.
* <p>
* Problem: <a href="http://code.google.com/p/android/issues/detail?id=8485">that intent doesn't work</a>!
* <p>
* See bug 768102 for more information in the context of Sync.
*/
public class AccountPickler {
public static final String LOG_TAG = AccountPickler.class.getSimpleName();
public static final long PICKLE_VERSION = 1;
private static final String KEY_PICKLE_VERSION = "pickle_version";
private static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp";
private static final String KEY_ACCOUNT_VERSION = "account_version";
private static final String KEY_ACCOUNT_TYPE = "account_type";
private static final String KEY_EMAIL = "email";
private static final String KEY_PROFILE = "profile";
private static final String KEY_IDP_SERVER_URI = "idpServerURI";
private static final String KEY_TOKEN_SERVER_URI = "tokenServerURI";
private static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled";
private static final String KEY_BUNDLE = "bundle";
/**
* Remove Firefox account persisted to disk.
*
* @param context Android context.
* @param filename name of persisted pickle file; must not contain path separators.
* @return <code>true</code> if given pickle existed and was successfully deleted.
*/
public static boolean deletePickle(final Context context, final String filename) {
return context.deleteFile(filename);
}
/**
* Persist Firefox account to disk as a JSON object.
*
* @param AndroidFxAccount the account to persist to disk
* @param filename name of file to persist to; must not contain path separators.
*/
public static void pickle(final AndroidFxAccount account, final String filename) {
final ExtendedJSONObject o = new ExtendedJSONObject();
o.put(KEY_PICKLE_VERSION, Long.valueOf(PICKLE_VERSION));
o.put(KEY_PICKLE_TIMESTAMP, Long.valueOf(System.currentTimeMillis()));
o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION);
o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE);
o.put(KEY_EMAIL, account.getEmail());
o.put(KEY_PROFILE, account.getProfile());
o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI());
o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI());
o.put(KEY_IS_SYNCING_ENABLED, account.isSyncingEnabled());
// TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs.
final ExtendedJSONObject bundle = account.unbundle();
if (bundle == null) {
Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting.");
return;
}
o.put(KEY_BUNDLE, bundle);
writeToDisk(account.context, filename, o);
}
private static void writeToDisk(final Context context, final String filename,
final ExtendedJSONObject pickle) {
try {
final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE);
try {
final PrintStream ps = new PrintStream(fos);
try {
ps.print(pickle.toJSONString());
Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() +
" account settings to " + filename + ".");
} finally {
ps.close();
}
} finally {
fos.close();
}
} catch (Exception e) {
Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename +
"; ignoring.", e);
}
}
/**
* Create Android account from saved JSON object. Assumes that an account does not exist.
*
* @param context
* Android context.
* @param filename
* name of file to read from; must not contain path separators.
* @return created Android account, or null on error.
*/
public static AndroidFxAccount unpickle(final Context context, final String filename) {
final String jsonString = Utils.readFile(context, filename);
if (jsonString == null) {
Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting.");
return null;
}
ExtendedJSONObject json = null;
try {
json = ExtendedJSONObject.parseJSONObject(jsonString);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e);
return null;
}
final UnpickleParams params;
try {
params = UnpickleParams.fromJSON(json);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e);
return null;
}
final AndroidFxAccount account;
try {
account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile,
params.idpServerURI, params.tokenServerURI, params.state, params.accountVersion,
params.isSyncingEnabled, true, params.bundle);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e);
return null;
}
if (account == null) {
Logger.warn(LOG_TAG, "Failed to add Android Account; aborting.");
return null;
}
Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP);
if (timestamp == null) {
Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring.");
timestamp = Long.valueOf(-1);
}
Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " +
params.pickleVersion + ", pickled at " + timestamp + ").");
return account;
}
private static class UnpickleParams {
private Long pickleVersion;
private int accountVersion;
private String email;
private String profile;
private String idpServerURI;
private String tokenServerURI;
private boolean isSyncingEnabled;
private ExtendedJSONObject bundle;
private State state;
private UnpickleParams() {
}
private static UnpickleParams fromJSON(final ExtendedJSONObject json)
throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
final UnpickleParams params = new UnpickleParams();
params.pickleVersion = json.getLong(KEY_PICKLE_VERSION);
if (params.pickleVersion == null) {
throw new IllegalStateException("Pickle version not found.");
}
switch (params.pickleVersion.intValue()) {
case 1:
params.unpickleV1(json);
break;
default:
throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + ".");
}
return params;
}
private void unpickleV1(final ExtendedJSONObject json)
throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException {
// Sanity check.
final String accountType = json.getString(KEY_ACCOUNT_TYPE);
if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) {
throw new IllegalStateException("Account type has changed from, " + accountType +
", to, " + FxAccountConstants.ACCOUNT_TYPE + ".");
}
this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION);
this.email = json.getString(KEY_EMAIL);
this.profile = json.getString(KEY_PROFILE);
this.idpServerURI = json.getString(KEY_IDP_SERVER_URI);
this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI);
this.isSyncingEnabled = json.getBoolean(KEY_IS_SYNCING_ENABLED);
this.bundle = json.getObject(KEY_BUNDLE);
if (bundle == null) {
throw new IllegalStateException("Pickle bundle is null.");
}
this.state = getState(bundle);
}
private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException,
NonObjectJSONException, NoSuchAlgorithmException {
// TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain
// old versions?
final StateLabel stateLabel = StateLabel.valueOf(
bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL));
final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE);
if (stateLabel == null) {
throw new IllegalStateException("stateLabel must not be null");
}
if (stateString == null) {
throw new IllegalStateException("stateString must not be null");
}
try {
return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString));
} catch (Exception e) {
throw new IllegalStateException("could not get state", e);
}
}
}
}

View File

@ -25,6 +25,7 @@ import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
@ -41,6 +42,7 @@ public class AndroidFxAccount {
public static final int CURRENT_PREFS_VERSION = 1;
// When updating the account, do not forget to update AccountPickler.
public static final int CURRENT_ACCOUNT_VERSION = 3;
public static final String ACCOUNT_KEY_ACCOUNT_VERSION = "version";
public static final String ACCOUNT_KEY_PROFILE = "profile";
@ -82,6 +84,18 @@ public class AndroidFxAccount {
this.accountManager = AccountManager.get(this.context);
}
/**
* Persist the Firefox account to disk as a JSON object. Note that this is a wrapper around
* {@link AccountPickler#pickle}, and is identical to calling it directly.
* <p>
* Note that pickling is different from bundling, which involves operations on a
* {@link android.os.Bundle Bundle} object of miscellaenous data associated with the account.
* See {@link #persistBundle} and {@link #unbundle} for more.
*/
public void pickle(final String filename) {
AccountPickler.pickle(this, filename);
}
public Account getAndroidAccount() {
return this.account;
}
@ -99,10 +113,18 @@ public class AndroidFxAccount {
}
}
/**
* Saves the given data as the internal bundle associated with this account.
* @param bundle to write to account.
*/
protected void persistBundle(ExtendedJSONObject bundle) {
accountManager.setUserData(account, ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString());
}
/**
* Retrieve the internal bundle associated with this account.
* @return bundle associated with account.
*/
protected ExtendedJSONObject unbundle() {
final int version = getAccountVersion();
if (version < CURRENT_ACCOUNT_VERSION) {
@ -275,6 +297,22 @@ public class AndroidFxAccount {
String tokenServerURI,
State state)
throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException {
return addAndroidAccount(context, email, profile, idpServerURI, tokenServerURI, state,
CURRENT_ACCOUNT_VERSION, true, false, null);
}
public static AndroidFxAccount addAndroidAccount(
Context context,
String email,
String profile,
String idpServerURI,
String tokenServerURI,
State state,
final int accountVersion,
final boolean syncEnabled,
final boolean fromPickle,
ExtendedJSONObject bundle)
throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException {
if (email == null) {
throw new IllegalArgumentException("email must not be null");
}
@ -288,6 +326,12 @@ public class AndroidFxAccount {
throw new IllegalArgumentException("state must not be null");
}
// TODO: Add migration code.
if (accountVersion != CURRENT_ACCOUNT_VERSION) {
throw new IllegalStateException("Could not create account of version " + accountVersion +
". Current version is " + CURRENT_ACCOUNT_VERSION + ".");
}
// Android has internal restrictions that require all values in this
// bundle to be strings. *sigh*
Bundle userdata = new Bundle();
@ -297,13 +341,15 @@ public class AndroidFxAccount {
userdata.putString(ACCOUNT_KEY_AUDIENCE, FxAccountUtils.getAudienceForURL(tokenServerURI));
userdata.putString(ACCOUNT_KEY_PROFILE, profile);
ExtendedJSONObject descriptor = new ExtendedJSONObject();
if (bundle == null) {
bundle = new ExtendedJSONObject();
// TODO: How to upgrade?
bundle.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION);
}
bundle.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name());
bundle.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString());
descriptor.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name());
descriptor.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString());
descriptor.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION);
userdata.putString(ACCOUNT_KEY_DESCRIPTOR, descriptor.toJSONString());
userdata.putString(ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString());
Account account = new Account(email, FxAccountConstants.ACCOUNT_TYPE);
AccountManager accountManager = AccountManager.get(context);
@ -316,8 +362,16 @@ public class AndroidFxAccount {
}
AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
fxAccount.clearSyncPrefs();
fxAccount.enableSyncing();
if (!fromPickle) {
fxAccount.clearSyncPrefs();
}
if (syncEnabled) {
fxAccount.enableSyncing();
} else {
fxAccount.disableSyncing();
}
return fxAccount;
}
@ -326,6 +380,19 @@ public class AndroidFxAccount {
getSyncPrefs().edit().clear().commit();
}
public boolean isSyncingEnabled() {
// TODO: Authority will be static in PR 426.
final int result = ContentResolver.getIsSyncable(account, BrowserContract.AUTHORITY);
if (result > 0) {
return true;
} else if (result == 0) {
return false;
} else {
// This should not happen.
throw new IllegalStateException("Sync enabled state unknown.");
}
}
public void enableSyncing() {
Logger.info(LOG_TAG, "Disabling sync for account named like " + Utils.obfuscateEmail(getEmail()));
for (String authority : new String[] { BrowserContract.AUTHORITY }) {
@ -403,4 +470,22 @@ public class AndroidFxAccount {
public String getEmail() {
return account.name;
}
/**
* Create an intent announcing that a Firefox account will be deleted.
*
* @param context
* Android context.
* @param account
* Android account being removed.
* @return <code>Intent</code> to broadcast.
*/
public static Intent makeDeletedAccountIntent(final Context context, final Account account) {
final Intent intent = new Intent(FxAccountConstants.ACCOUNT_DELETED_ACTION);
intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY,
Long.valueOf(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION));
intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name);
return intent;
}
}

View File

@ -98,4 +98,47 @@ public class FxAccountAuthenticator extends AbstractAccountAuthenticator {
return null;
}
/**
* If the account is going to be removed, broadcast an "account deleted"
* intent. This allows us to clean up the account.
* <p>
* It is preferable to receive Android's LOGIN_ACCOUNTS_CHANGED_ACTION broadcast
* than to create our own hacky broadcast here, but that doesn't include enough
* information about which Accounts changed to correctly identify whether a Sync
* account has been removed (when some Firefox channels are installed on the SD
* card). We can work around this by storing additional state but it's both messy
* and expensive because the broadcast is noisy.
* <p>
* Note that this is <b>not</b> called when an Android Account is blown away
* due to the SD card being unmounted.
*/
@Override
public Bundle getAccountRemovalAllowed(final AccountAuthenticatorResponse response, Account account)
throws NetworkErrorException {
Bundle result = super.getAccountRemovalAllowed(response, account);
if (result == null ||
!result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) ||
result.containsKey(AccountManager.KEY_INTENT)) {
return result;
}
final boolean removalAllowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
if (!removalAllowed) {
return result;
}
// Broadcast a message to all Firefox channels sharing this Android
// Account type telling that this Firefox account has been deleted.
//
// Broadcast intents protected with permissions are secure, so it's okay
// to include private information such as a password.
final Intent intent = AndroidFxAccount.makeDeletedAccountIntent(context, account);
Logger.info(LOG_TAG, "Account named " + account.name + " being removed; " +
"broadcasting secure intent " + intent.getAction() + ".");
context.sendBroadcast(intent, FxAccountConstants.PER_ACCOUNT_TYPE_PERMISSION);
return result;
}
}

View File

@ -0,0 +1,33 @@
/* 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.receivers;
import org.mozilla.gecko.background.common.log.Logger;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public class FxAccountDeletedReceiver extends BroadcastReceiver {
public static final String LOG_TAG = FxAccountDeletedReceiver.class.getSimpleName();
/**
* This receiver can be killed as soon as it returns, but we have things to do
* that can't be done on the main thread (network activity). Therefore we
* start a service to do our clean up work for us, with Android doing the
* heavy lifting for the service's lifecycle.
* <p>
* See <a href="http://developer.android.com/reference/android/content/BroadcastReceiver.html#ReceiverLifecycle">the Android documentation</a>
* for details.
*/
@Override
public void onReceive(final Context context, Intent broadcastIntent) {
Logger.debug(LOG_TAG, "FxAccount deleted broadcast received.");
Intent serviceIntent = new Intent(context, FxAccountDeletedService.class);
serviceIntent.putExtras(broadcastIntent);
context.startService(serviceIntent);
}
}

View File

@ -0,0 +1,65 @@
/* 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.receivers;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.fxa.FxAccountConstants;
import org.mozilla.gecko.sync.config.AccountPickler;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
/**
* A background service to clean up after a Firefox Account is deleted.
* <p>
* Note that we specifically handle deleting the pickle file using a Service and a
* BroadcastReceiver, rather than a background thread, to allow channels sharing a Firefox account
* to delete their respective pickle files (since, if one remains, the account will be restored
* when that channel is used).
*/
public class FxAccountDeletedService extends IntentService {
public static final String LOG_TAG = FxAccountDeletedService.class.getSimpleName();
public FxAccountDeletedService() {
super(LOG_TAG);
}
@Override
protected void onHandleIntent(final Intent intent) {
final Context context = this;
long intentVersion = intent.getLongExtra(
FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY, 0);
long expectedVersion = FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION;
if (intentVersion != expectedVersion) {
Logger.warn(LOG_TAG, "Intent malformed: version " + intentVersion + " given but " +
"version " + expectedVersion + "expected. Not cleaning up after deleted Account.");
return;
}
// Android Account name, not Sync encoded account name.
final String accountName = intent.getStringExtra(
FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY);
if (accountName == null) {
Logger.warn(LOG_TAG, "Intent malformed: no account name given. Not cleaning up after " +
"deleted Account.");
return;
}
Logger.info(LOG_TAG, "Firefox account named " + accountName + " being removed; " +
"deleting saved pickle file '" + FxAccountConstants.ACCOUNT_PICKLE_FILENAME + "'.");
deletePickle(context);
}
public static void deletePickle(final Context context) {
try {
AccountPickler.deletePickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
} catch (Exception e) {
// This should never happen, but we really don't want to die in a background thread.
Logger.warn(LOG_TAG, "Got exception deleting saved pickle file; ignoring.", e);
}
}
}

View File

@ -21,6 +21,7 @@ import org.mozilla.gecko.browserid.RSACryptoImplementation;
import org.mozilla.gecko.browserid.verifier.BrowserIDRemoteVerifierClient;
import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierDelegate;
import org.mozilla.gecko.fxa.FxAccountConstants;
import org.mozilla.gecko.fxa.authenticator.AccountPickler;
import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
import org.mozilla.gecko.fxa.authenticator.FxAccountAuthenticator;
import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
@ -35,6 +36,7 @@ import org.mozilla.gecko.sync.GlobalSession;
import org.mozilla.gecko.sync.PrefsBackoffHandler;
import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
import org.mozilla.gecko.sync.SyncConfiguration;
import org.mozilla.gecko.sync.ThreadPool;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.sync.crypto.KeyBundle;
import org.mozilla.gecko.sync.delegates.BaseGlobalSessionCallback;
@ -440,6 +442,19 @@ public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter {
fxAccount.dump();
}
// Pickle in a background thread to avoid strict mode warnings.
ThreadPool.run(new Runnable() {
@Override
public void run() {
try {
AccountPickler.pickle(fxAccount, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
} catch (Exception e) {
// Should never happen, but we really don't want to die in a background thread.
Logger.warn(LOG_TAG, "Got exception pickling current account details; ignoring.", e);
}
}
});
final CountDownLatch latch = new CountDownLatch(1);
final SyncDelegate syncDelegate = new SyncDelegate(context, latch, syncResult, fxAccount, notificationManager);

View File

@ -66,3 +66,11 @@
android:noHistory="true"
android:windowSoftInputMode="adjustResize">
</activity>
<receiver
android:name="org.mozilla.gecko.fxa.receivers.FxAccountDeletedReceiver"
android:permission="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE">
<intent-filter>
<action android:name="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.accounts.ACCOUNT_DELETED_ACTION"/>
</intent-filter>
</receiver>

View File

@ -7,3 +7,12 @@
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<!-- A signature level permission granted only to the Firefox
channels sharing an Android Account type. -->
<permission
android:name="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE"
android:protectionLevel="signature">
</permission>
<uses-permission android:name="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE" />

View File

@ -20,3 +20,7 @@
android:name="android.content.SyncAdapter"
android:resource="@xml/fxaccount_syncadapter" />
</service>
<service
android:exported="false"
android:name="org.mozilla.gecko.fxa.receivers.FxAccountDeletedService" >
</service>

View File

@ -69,8 +69,6 @@
android:name="org.mozilla.gecko.sync.receivers.SyncAccountDeletedReceiver"
android:permission="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE">
<intent-filter>
<!-- This needs to be kept the same as
GlobalConstants.SYNC_ACCOUNT_DELETED_ACTION. -->
<action android:name="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.accounts.SYNC_ACCOUNT_DELETED_ACTION"/>
</intent-filter>
</receiver>

View File

@ -9,8 +9,7 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<!-- A signature level permission granted only to the Firefox
versions sharing an Android Account type. This needs to
agree with GlobalConstants.PER_ACCOUNT_TYPE_PERMISSION. -->
versions sharing an Android Account type. -->
<permission
android:name="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE"
android:protectionLevel="signature">

View File

@ -22,6 +22,7 @@ BACKGROUND_TESTS_JAVA_FILES := \
src/db/TestFennecTabsStorage.java \
src/db/TestFormHistoryRepositorySession.java \
src/db/TestPasswordsRepository.java \
src/fxa/authenticator/TestAccountPickler.java \
src/fxa/TestBrowserIDKeyPairGeneration.java \
src/healthreport/MockDatabaseEnvironment.java \
src/healthreport/MockHealthReportDatabaseStorage.java \

View File

@ -0,0 +1,119 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
package org.mozilla.gecko.background.fxa.authenticator;
import org.mozilla.gecko.background.helpers.AndroidSyncTestCase;
import org.mozilla.gecko.fxa.FxAccountConstants;
import org.mozilla.gecko.fxa.authenticator.AccountPickler;
import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
import org.mozilla.gecko.fxa.login.Separated;
import org.mozilla.gecko.fxa.login.State;
import org.mozilla.gecko.background.sync.TestSyncAccounts;
import org.mozilla.gecko.sync.Utils;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.test.InstrumentationTestCase;
import android.test.RenamingDelegatingContext;
public class TestAccountPickler extends AndroidSyncTestCase {
private final static String FILENAME_PREFIX = "TestAccountPickler-";
private final static String PICKLE_FILENAME = "pickle";
public Account account;
public RenamingDelegatingContext context;
public AccountManager accountManager;
@Override
public void setUp() {
this.account = null;
// Randomize the filename prefix in case we don't clean up correctly.
this.context = new RenamingDelegatingContext(getApplicationContext(), FILENAME_PREFIX +
Math.random() * 1000001 + "-");
this.accountManager = AccountManager.get(context);
}
public void tearDown() {
if (this.account != null) {
deleteAccount(this, this.accountManager, this.account);
this.account = null;
}
this.context.deleteFile(PICKLE_FILENAME);
}
public static void deleteAccount(final InstrumentationTestCase test,
final AccountManager accountManager, final Account account) {
TestSyncAccounts.deleteAccount(test, accountManager, account);
}
private boolean accountsExist() {
// Note that we don't use FirefoxAccounts.firefoxAccountsExist because it unpickles.
return AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE).length > 0;
}
public AndroidFxAccount addDummyAccount() throws Exception {
final String email = "iu@fakedomain.io";
final State state = new Separated(email, "uid", false); // State choice is arbitrary.
final AndroidFxAccount account = AndroidFxAccount.addAndroidAccount(context, email,
"profile", "serverURI", "tokenServerURI", state);
assertNotNull(account);
assertTrue(accountsExist()); // Sanity check.
this.account = account.getAndroidAccount(); // To remove in tearDown() if we throw.
return account;
}
public void testPickleAndUnpickle() throws Exception {
final AndroidFxAccount inputAccount = addDummyAccount();
// Sync is enabled by default so we do a more thorough test by disabling it.
inputAccount.disableSyncing();
AccountPickler.pickle(inputAccount, PICKLE_FILENAME);
// unpickle adds an account to the AccountManager so delete it first.
deleteAccount(this, this.accountManager, inputAccount.getAndroidAccount());
assertFalse(accountsExist());
final AndroidFxAccount unpickledAccount =
AccountPickler.unpickle(context, PICKLE_FILENAME);
assertNotNull(unpickledAccount);
this.account = unpickledAccount.getAndroidAccount(); // To remove in tearDown().
assertAccountsEquals(inputAccount, unpickledAccount);
}
public void testDeletePickle() throws Exception {
final AndroidFxAccount account = addDummyAccount();
AccountPickler.pickle(account, PICKLE_FILENAME);
final String s = Utils.readFile(context, PICKLE_FILENAME);
assertNotNull(s);
assertTrue(s.length() > 0);
AccountPickler.deletePickle(context, PICKLE_FILENAME);
org.mozilla.gecko.background.sync.TestAccountPickler.assertFileNotPresent(
context, PICKLE_FILENAME);
}
private void assertAccountsEquals(final AndroidFxAccount expected,
final AndroidFxAccount actual) throws Exception {
// TODO: Write and use AndroidFxAccount.equals
// TODO: protected.
//assertEquals(expected.getAccountVersion(), actual.getAccountVersion());
assertEquals(expected.getProfile(), actual.getProfile());
assertEquals(expected.getAccountServerURI(), actual.getAccountServerURI());
assertEquals(expected.getAudience(), actual.getAudience());
assertEquals(expected.getTokenServerURI(), actual.getTokenServerURI());
assertEquals(expected.getSyncPrefsPath(), actual.getSyncPrefsPath());
assertEquals(expected.isSyncingEnabled(), actual.isSyncingEnabled());
assertEquals(expected.getEmail(), actual.getEmail());
assertStateEquals(expected.getState(), actual.getState());
}
private void assertStateEquals(final State expected, final State actual) throws Exception {
// TODO: Write and use State.equals. Thus, this is only thorough for the State base class.
assertEquals(expected.getStateLabel(), actual.getStateLabel());
assertEquals(expected.email, actual.email);
assertEquals(expected.uid, actual.uid);
assertEquals(expected.verified, actual.verified);
}
}