Bug 1329793 - Re-subscribe for a push channel periodically r=eoger,nalexander
On startup and at the beginning of a sync we check how long it has been since we've subscribed
to a channel for fxa service. If it's been over 21 days, request re-subscription.
MozReview-Commit-ID: GzvPecZ9hTy
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
@@ -12,17 +12,16 @@ import android.util.Log;
import org.json.JSONObject;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.gcm.GcmTokenClient;
import org.mozilla.gecko.push.autopush.AutopushClientException;
import org.mozilla.gecko.util.ThreadUtils;
import java.io.IOException;
import java.util.Collections;
-import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
/**
* The push manager advances push registrations, ensuring that the upstream autopush endpoint has
* a fresh GCM token. It brokers channel subscription requests to the upstream and maintains
* local state.
* <p/>
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -1,15 +1,17 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
* 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.push;
+import android.accounts.Account;
+import android.accounts.AccountManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
@@ -17,17 +19,21 @@ import org.mozilla.gecko.EventDispatcher
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoProfile;
import org.mozilla.gecko.GeckoService;
import org.mozilla.gecko.GeckoThread;
import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract;
import org.mozilla.gecko.annotation.ReflectionTarget;
import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
import org.mozilla.gecko.fxa.FxAccountPushHandler;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
import org.mozilla.gecko.gcm.GcmTokenClient;
import org.mozilla.gecko.push.autopush.AutopushClientException;
import org.mozilla.gecko.util.BundleEventListener;
import org.mozilla.gecko.util.EventCallback;
import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.ThreadUtils;
import java.io.File;
@@ -97,33 +103,82 @@ public class PushService implements Bund
protected final PushManager pushManager;
// NB: These are not thread-safe, we're depending on these being access from the same background thread.
private boolean isReadyPushServiceAndroidGCM = false;
private boolean isReadyFxAccountsPush = false;
private final List<JSONObject> pendingPushMessages;
+ // NB, on context use in AccountManager and AndroidFxAccount:
+ // We are not going to register any listeners, or surface any UI out of AccountManager.
+ // It should be fine to use a potentially short-lived context then, as opposed to a long-lived
+ // application context, contrary to what AndroidFxAccount docs ask for.
+ private final Context context;
+
public PushService(Context context) {
+ this.context = context;
pushManager = new PushManager(new PushState(context, "GeckoPushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() {
@Override
public PushClient getPushClient(String autopushEndpoint, boolean debug) {
return new PushClient(autopushEndpoint);
}
});
pendingPushMessages = new LinkedList<>();
}
public void onStartup() {
Log.i(LOG_TAG, "Starting up.");
ThreadUtils.assertOnBackgroundThread();
try {
pushManager.startup(System.currentTimeMillis());
+
+ // Determine if we need to renew our FxA Push Subscription. Unused subscriptions expire
+ // once a month, and so we do a simple check on startup to determine if it's time to get
+ // a new one. Note that this is sub-optimal, as we might have a perfectly valid (but old)
+ // subscription which we'll nevertheless unsubscribe in lieu of a new one. Improvements
+ // to this will be addressed as part of a larger Bug 1345651.
+
+ // From the Android permission docs:
+ // Prior to API 23, GET_ACCOUNTS permission was necessary to get access to information
+ // about any account. Beginning with API 23, if an app shares the signature of the
+ // authenticator that manages an account, it does not need "GET_ACCOUNTS" permission to
+ // read information about that account.
+ // We list GET_ACCOUNTS in our manifest for pre-23 devices.
+ final AccountManager accountManager = AccountManager.get(context);
+ final Account[] fxAccounts = accountManager.getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
+
+ // Nothing to renew if there isn't an account.
+ if (fxAccounts.length == 0) {
+ return;
+ }
+
+ // Defensively obtain account state. We are in a startup situation: try to not crash.
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, fxAccounts[0]);
+ final State fxAccountState;
+ try {
+ fxAccountState = fxAccount.getState();
+ } catch (IllegalStateException e) {
+ Log.e(LOG_TAG, "Failed to obtain FxA account state while renewing registration", e);
+ return;
+ }
+
+ // This decision will be re-addressed as part of Bug 1346061.
+ if (!State.StateLabel.Married.equals(fxAccountState.getStateLabel())) {
+ Log.i(LOG_TAG, "FxA account not in Married state, not proceeding with registration renewal");
+ return;
+ }
+
+ // We'll obtain a new subscription as part of device registration.
+ if (FxAccountDeviceRegistrator.needToRenewRegistration(fxAccount.getDeviceRegistrationTimestamp())) {
+ Log.i(LOG_TAG, "FxA device needs registration renewal");
+ FxAccountDeviceRegistrator.renewRegistration(context);
+ }
} catch (Exception e) {
Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
return;
}
}
public void onRefresh() {
Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; invalidating GCM token and running startup again.");
--- a/mobile/android/components/FxAccountsPush.js
+++ b/mobile/android/components/FxAccountsPush.js
@@ -33,16 +33,19 @@ function FxAccountsPush() {
FxAccountsPush.prototype = {
observe: function (subject, topic, data) {
switch (topic) {
case "android-push-service":
if (data === "android-fxa-subscribe") {
this._subscribe();
} else if (data === "android-fxa-unsubscribe") {
this._unsubscribe();
+ } else if (data === "android-fxa-resubscribe") {
+ // If unsubscription fails, we still want to try to subscribe.
+ this._unsubscribe().then(this._subscribe, this._subscribe);
}
break;
case "FxAccountsPush:ReceivedPushMessageToDecode":
this._decodePushMessage(data);
break;
}
},
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
@@ -35,16 +35,23 @@ import java.util.concurrent.Executors;
/* This class provides a way to register the current device against FxA
* and also stores the registration details in the Android FxAccount.
* This should be used in a state where we possess a sessionToken, most likely the Married state.
*/
public class FxAccountDeviceRegistrator implements BundleEventListener {
private static final String LOG_TAG = "FxADeviceRegistrator";
+ // The autopush endpoint expires stale channel subscriptions every 30 days (at a set time during
+ // the month, although we don't depend on this). To avoid the FxA service channel silently
+ // expiring from underneath us, we unsubscribe and resubscribe every 21 days.
+ // Note that this simple schedule means that we might unsubscribe perfectly valid (but old)
+ // subscriptions. This will be improved as part of Bug 1345651.
+ private static final long TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS = 21 * 24 * 60 * 60 * 1000L;
+
// The current version of the device registration, we use this to re-register
// devices after we update what we send on device registration.
public static final Integer DEVICE_REGISTRATION_VERSION = 2;
private static FxAccountDeviceRegistrator instance;
private final WeakReference<Context> context;
private FxAccountDeviceRegistrator(Context appContext) {
@@ -55,40 +62,69 @@ public class FxAccountDeviceRegistrator
if (instance == null) {
FxAccountDeviceRegistrator tempInstance = new FxAccountDeviceRegistrator(appContext);
tempInstance.setupListeners(); // Set up listener for FxAccountPush:Subscribe:Response
instance = tempInstance;
}
return instance;
}
+ public static boolean needToRenewRegistration(final long timestamp) {
+ // NB: we're comparing wall clock to wall clock, at different points in time.
+ // It's possible that wall clocks have changed, and our comparison will be meaningless.
+ // However, this happens in the context of a sync, and we won't be able to sync anyways if our
+ // wall clock deviates too much from time on the server.
+ return (System.currentTimeMillis() - timestamp) > TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS;
+ }
+
public static void register(Context context) {
Context appContext = context.getApplicationContext();
try {
getInstance(appContext).beginRegistration(appContext);
} catch (Exception e) {
Log.e(LOG_TAG, "Could not start FxA device registration", e);
}
}
+ public static void renewRegistration(Context context) {
+ Context appContext = context.getApplicationContext();
+ try {
+ getInstance(appContext).beginRegistrationRenewal(appContext);
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Could not start FxA device re-registration", e);
+ }
+ }
+
private void beginRegistration(Context context) {
// Fire up gecko and send event
// We create the Intent ourselves instead of using GeckoService.getIntentToCreateServices
// because we can't import these modules (circular dependency between browser and services)
- final Intent geckoIntent = new Intent();
- geckoIntent.setAction("create-services");
- geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService");
- geckoIntent.putExtra("category", "android-push-service");
- geckoIntent.putExtra("data", "android-fxa-subscribe");
- final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
- geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
+ final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-subscribe");
+ context.startService(geckoIntent);
+ // -> handleMessage()
+ }
+
+ private void beginRegistrationRenewal(Context context) {
+ // Same as registration, but unsubscribe first to get a fresh subscription.
+ final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-resubscribe");
context.startService(geckoIntent);
// -> handleMessage()
}
+ private Intent buildCreatePushServiceIntent(final Context context, final String data) {
+ final Intent intent = new Intent();
+ intent.setAction("create-services");
+ intent.setClassName(context, "org.mozilla.gecko.GeckoService");
+ intent.putExtra("category", "android-push-service");
+ intent.putExtra("data", data);
+ final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+ intent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
+ return intent;
+ }
+
@Override
public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
if ("FxAccountsPush:Subscribe:Response".equals(event)) {
try {
doFxaRegistration(message.getBundle("subscription"));
} catch (InvalidFxAState e) {
Log.d(LOG_TAG, "Invalid state when trying to register with FxA ", e);
}
@@ -130,50 +166,55 @@ public class FxAccountDeviceRegistrator
ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread
final FxAccountClient20 fxAccountClient =
new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
fxAccountClient.registerOrUpdateDevice(sessionToken, device, new RequestDelegate<FxAccountDevice>() {
@Override
public void handleError(Exception e) {
Log.e(LOG_TAG, "Error while updating a device registration: ", e);
+ fxAccount.setDeviceRegistrationTimestamp(0L);
}
@Override
public void handleFailure(FxAccountClientRemoteException error) {
Log.e(LOG_TAG, "Error while updating a device registration: ", error);
+
+ fxAccount.setDeviceRegistrationTimestamp(0L);
+
if (error.httpStatusCode == 400) {
if (error.apiErrorNumber == FxAccountRemoteError.UNKNOWN_DEVICE) {
recoverFromUnknownDevice(fxAccount);
} else if (error.apiErrorNumber == FxAccountRemoteError.DEVICE_SESSION_CONFLICT) {
recoverFromDeviceSessionConflict(error, fxAccountClient, sessionToken, fxAccount, context,
subscription, allowRecursion);
}
} else
if (error.httpStatusCode == 401
&& error.apiErrorNumber == FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN) {
handleTokenError(error, fxAccountClient, fxAccount);
} else {
- logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
+ logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
}
}
@Override
public void handleSuccess(FxAccountDevice result) {
Log.i(LOG_TAG, "Device registration complete");
Logger.pii(LOG_TAG, "Registered device ID: " + result.id);
- fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION);
+ fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION, System.currentTimeMillis());
}
});
}
- private static void logErrorAndResetDeviceRegistrationVersion(
+ private static void logErrorAndResetDeviceRegistrationVersionAndTimestamp(
final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount) {
Log.e(LOG_TAG, "Device registration failed", error);
fxAccount.resetDeviceRegistrationVersion();
+ fxAccount.setDeviceRegistrationTimestamp(0L);
}
@Nullable
private static String getClientName(final AndroidFxAccount fxAccount, final Context context) {
try {
SharedPreferencesClientsDataDelegate clientsDataDelegate =
new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context);
return clientsDataDelegate.getClientName();
@@ -182,17 +223,17 @@ public class FxAccountDeviceRegistrator
return null;
}
}
private static void handleTokenError(final FxAccountClientRemoteException error,
final FxAccountClient fxAccountClient,
final AndroidFxAccount fxAccount) {
Log.i(LOG_TAG, "Recovering from invalid token error: ", error);
- logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
+ logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
fxAccountClient.accountStatus(fxAccount.getState().uid,
new RequestDelegate<AccountStatusResponse>() {
@Override
public void handleError(Exception e) {
}
@Override
public void handleFailure(FxAccountClientRemoteException e) {
@@ -228,34 +269,34 @@ public class FxAccountDeviceRegistrator
final AndroidFxAccount fxAccount,
final Context context,
final GeckoBundle subscription,
final boolean allowRecursion) {
Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id");
fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() {
private void onError() {
Log.e(LOG_TAG, "failed to recover from device-session conflict");
- logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
+ logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
}
@Override
public void handleError(Exception e) {
onError();
}
@Override
public void handleFailure(FxAccountClientRemoteException e) {
onError();
}
@Override
public void handleSuccess(FxAccountDevice[] devices) {
for (FxAccountDevice device : devices) {
if (device.isCurrentDevice) {
- fxAccount.setFxAUserData(device.id, 0); // Reset device registration version
+ fxAccount.setFxAUserData(device.id, 0, 0L); // Reset device registration version/timestamp
if (!allowRecursion) {
Log.d(LOG_TAG, "Failure to register a device on the second try");
break;
}
try {
doFxaRegistration(context, subscription, false);
return;
} catch (InvalidFxAState e) {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
@@ -75,16 +75,17 @@ public class AndroidFxAccount {
public static final int CURRENT_BUNDLE_VERSION = 2;
public static final String BUNDLE_KEY_BUNDLE_VERSION = "version";
public static final String BUNDLE_KEY_STATE_LABEL = "stateLabel";
public static final String BUNDLE_KEY_STATE = "state";
public static final String BUNDLE_KEY_PROFILE_JSON = "profile";
public static final String ACCOUNT_KEY_DEVICE_ID = "deviceId";
public static final String ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION = "deviceRegistrationVersion";
+ private static final String ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP = "deviceRegistrationTimestamp";
// Account authentication token type for fetching account profile.
public static final String PROFILE_OAUTH_TOKEN_TYPE = "oauth::profile";
// Services may request OAuth tokens from the Firefox Account dynamically.
// Each such token is prefixed with "oauth::" and a service-dependent scope.
// Such tokens should be destroyed when the account is removed from the device.
// This list collects all the known "oauth::" token types in order to delete them when necessary.
@@ -397,16 +398,17 @@ public class AndroidFxAccount {
o.put("email", account.name);
try {
o.put("emailUTF8", Utils.byte2Hex(account.name.getBytes("UTF-8")));
} catch (UnsupportedEncodingException e) {
// Ignore.
}
o.put("fxaDeviceId", getDeviceId());
o.put("fxaDeviceRegistrationVersion", getDeviceRegistrationVersion());
+ o.put("fxaDeviceRegistrationTimestamp", getDeviceRegistrationTimestamp());
return o;
}
public static AndroidFxAccount addAndroidAccount(
Context context,
String email,
String profile,
String idpServerURI,
@@ -824,33 +826,57 @@ public class AndroidFxAccount {
try {
return Integer.parseInt(versionStr);
} catch (NumberFormatException ex) {
return 0;
}
}
}
+ public synchronized long getDeviceRegistrationTimestamp() {
+ final String timestampStr = accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP);
+
+ if (TextUtils.isEmpty(timestampStr)) {
+ return 0L;
+ }
+
+ // Long.valueOf might throw; while it's not expected that this might happen, let's not risk
+ // crashing here as this method will be called on startup.
+ try {
+ return Long.valueOf(timestampStr);
+ } catch (NumberFormatException e) {
+ Logger.warn(LOG_TAG, "Couldn't parse deviceRegistrationTimestamp; defaulting to 0L.", e);
+ return 0L;
+ }
+ }
+
public synchronized void setDeviceId(String id) {
accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id);
}
public synchronized void setDeviceRegistrationVersion(int deviceRegistrationVersion) {
accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION,
Integer.toString(deviceRegistrationVersion));
}
+ public synchronized void setDeviceRegistrationTimestamp(long timestamp) {
+ accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP,
+ Long.toString(timestamp));
+ }
+
public synchronized void resetDeviceRegistrationVersion() {
setDeviceRegistrationVersion(0);
}
- public synchronized void setFxAUserData(String id, int deviceRegistrationVersion) {
+ public synchronized void setFxAUserData(String id, int deviceRegistrationVersion, long timestamp) {
accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id);
accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION,
- Integer.toString(deviceRegistrationVersion));
+ Integer.toString(deviceRegistrationVersion));
+ accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP,
+ Long.toString(timestamp));
}
@SuppressLint("ParcelCreator") // The CREATOR field is defined in the super class.
private class ProfileResultReceiver extends ResultReceiver {
public ProfileResultReceiver(Handler handler) {
super(handler);
}
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
@@ -562,20 +562,28 @@ public class FxAccountSyncAdapter extend
final SessionCallback sessionCallback = new SessionCallback(syncDelegate, schedulePolicy);
final KeyBundle syncKeyBundle = married.getSyncKeyBundle();
final String clientState = married.getClientState();
syncWithAssertion(
assertion, tokenServerEndpointURI, tokenBackoffHandler, sharedPrefs,
syncKeyBundle, clientState, sessionCallback, extras, fxAccount, syncDeadline);
- // Register the device if necessary (asynchronous, in another thread)
+ // Register the device if necessary (asynchronous, in another thread).
+ // As part of device registration, we obtain a PushSubscription, register our push endpoint
+ // with FxA, and update account data with fxaDeviceId, which is part of our synced
+ // clients record.
if (fxAccount.getDeviceRegistrationVersion() != FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION
- || TextUtils.isEmpty(fxAccount.getDeviceId())) {
+ || TextUtils.isEmpty(fxAccount.getDeviceId())) {
FxAccountDeviceRegistrator.register(context);
+ // We might need to re-register periodically to ensure our FxA push subscription is valid.
+ // This involves unsubscribing, subscribing and updating remote FxA device record with
+ // new push subscription information.
+ } else if (FxAccountDeviceRegistrator.needToRenewRegistration(fxAccount.getDeviceRegistrationTimestamp())) {
+ FxAccountDeviceRegistrator.renewRegistration(context);
}
// Force fetch the profile avatar information. (asynchronous, in another thread)
Logger.info(LOG_TAG, "Fetching profile avatar information.");
fxAccount.fetchProfileJSON();
} catch (Exception e) {
syncDelegate.handleError(e);
return;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
@@ -58,16 +58,17 @@ public class SharedPreferencesClientsDat
public synchronized void setClientName(String clientName, long now) {
saveClientNameToSharedPreferences(clientName, now);
// Update the FxA device registration
final Account account = FirefoxAccounts.getFirefoxAccount(context);
if (account != null) {
final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
fxAccount.resetDeviceRegistrationVersion();
+ fxAccount.setDeviceRegistrationTimestamp(0L);
}
}
@Override
public String getDefaultClientName() {
return FxAccountUtils.defaultClientName(context);
}