blob: 6986f5f482bb55fc4bcd95d1d4b4ebbfa1619e70 [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.sync.notifier;
import android.accounts.Account;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import com.google.ipc.invalidation.external.client.InvalidationListener.RegistrationState;
import com.google.ipc.invalidation.external.client.contrib.AndroidListener;
import com.google.ipc.invalidation.external.client.types.ErrorInfo;
import com.google.ipc.invalidation.external.client.types.Invalidation;
import com.google.ipc.invalidation.external.client.types.ObjectId;
import com.google.protos.ipc.invalidation.Types.ClientType;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.CollectionUtil;
import org.chromium.base.VisibleForTesting;
import org.chromium.sync.internal_api.pub.base.ModelType;
import org.chromium.sync.notifier.InvalidationPreferences.EditContext;
import org.chromium.sync.signin.AccountManagerHelper;
import org.chromium.sync.signin.ChromeSigninController;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
import javax.annotation.Nullable;
/**
* Service that controls notifications for sync.
* <p>
* This service serves two roles. On the one hand, it is a client for the notification system
* used to trigger sync. It receives invalidations and converts them into
* {@link ContentResolver#requestSync} calls, and it supplies the notification system with the set
* of desired registrations when requested.
* <p>
* On the other hand, this class is controller for the notification system. It starts it and stops
* it, and it requests that it perform (un)registrations as the set of desired sync types changes.
* <p>
* This class is an {@code IntentService}. All methods are assumed to be executing on its single
* execution thread.
*
* @author dsmyers@google.com
*/
public class InvalidationService extends AndroidListener {
/* This class must be public because it is exposed as a service. */
/** Notification client typecode. */
@VisibleForTesting
static final int CLIENT_TYPE = ClientType.CHROME_SYNC_ANDROID;
private static final String TAG = "InvalidationService";
private static final Random RANDOM = new Random();
/**
* Whether the underlying notification client has been started. This boolean is updated when a
* start or stop intent is issued to the underlying client, not when the intent is actually
* processed.
*/
private static boolean sIsClientStarted;
/**
* The id of the client in use, if any. May be {@code null} if {@link #sIsClientStarted} is
* true if the client has not yet gone ready.
*/
@Nullable private static byte[] sClientId;
@Override
public void onHandleIntent(Intent intent) {
// Ensure that a client is or is not running, as appropriate, and that it is for the
// correct account. ensureAccount will stop the client if account is non-null and doesn't
// match the stored account. Then, if a client should be running, ensureClientStartState
// will start a new one if needed. I.e., these two functions work together to restart the
// client when the account changes.
Account account = intent.hasExtra(InvalidationIntentProtocol.EXTRA_ACCOUNT) ?
(Account) intent.getParcelableExtra(InvalidationIntentProtocol.EXTRA_ACCOUNT)
: null;
ensureAccount(account);
ensureClientStartState();
// Handle the intent.
if (InvalidationIntentProtocol.isStop(intent) && sIsClientStarted) {
// If the intent requests that the client be stopped, stop it.
stopClient();
} else if (InvalidationIntentProtocol.isRegisteredTypesChange(intent)) {
// If the intent requests a change in registrations, change them.
List<String> regTypes = intent.getStringArrayListExtra(
InvalidationIntentProtocol.EXTRA_REGISTERED_TYPES);
setRegisteredTypes(regTypes != null ? new HashSet<String>(regTypes) : null,
InvalidationIntentProtocol.getRegisteredObjectIds(intent));
} else {
// Otherwise, we don't recognize the intent. Pass it to the notification client service.
super.onHandleIntent(intent);
}
}
@Override
public void invalidate(Invalidation invalidation, byte[] ackHandle) {
byte[] payload = invalidation.getPayload();
String payloadStr = (payload == null) ? null : new String(payload);
requestSync(invalidation.getObjectId(), invalidation.getVersion(), payloadStr);
acknowledge(ackHandle);
}
@Override
public void invalidateUnknownVersion(ObjectId objectId, byte[] ackHandle) {
requestSync(objectId, null, null);
acknowledge(ackHandle);
}
@Override
public void invalidateAll(byte[] ackHandle) {
requestSync(null, null, null);
acknowledge(ackHandle);
}
@Override
public void informRegistrationFailure(
byte[] clientId, ObjectId objectId, boolean isTransient, String errorMessage) {
Log.w(TAG, "Registration failure on " + objectId + " ; transient = " + isTransient
+ ": " + errorMessage);
if (isTransient) {
// Retry immediately on transient failures. The base AndroidListener will handle
// exponential backoff if there are repeated failures.
List<ObjectId> objectIdAsList = CollectionUtil.newArrayList(objectId);
if (readRegistrationsFromPrefs().contains(objectId)) {
register(clientId, objectIdAsList);
} else {
unregister(clientId, objectIdAsList);
}
}
}
@Override
public void informRegistrationStatus(
byte[] clientId, ObjectId objectId, RegistrationState regState) {
Log.d(TAG, "Registration status for " + objectId + ": " + regState);
List<ObjectId> objectIdAsList = CollectionUtil.newArrayList(objectId);
boolean registrationisDesired = readRegistrationsFromPrefs().contains(objectId);
if (regState == RegistrationState.REGISTERED) {
if (!registrationisDesired) {
Log.i(TAG, "Unregistering for object we're no longer interested in");
unregister(clientId, objectIdAsList);
}
} else {
if (registrationisDesired) {
Log.i(TAG, "Registering for an object");
register(clientId, objectIdAsList);
}
}
}
@Override
public void informError(ErrorInfo errorInfo) {
Log.w(TAG, "Invalidation client error:" + errorInfo);
if (!errorInfo.isTransient() && sIsClientStarted) {
// It is important not to stop the client if it is already stopped. Otherwise, the
// possibility exists to go into an infinite loop if the stop call itself triggers an
// error (e.g., because no client actually exists).
stopClient();
}
}
@Override
public void ready(byte[] clientId) {
setClientId(clientId);
// We might have accumulated some registrations to do while we were waiting for the client
// to become ready.
reissueRegistrations(clientId);
}
@Override
public void reissueRegistrations(byte[] clientId) {
Set<ObjectId> desiredRegistrations = readRegistrationsFromPrefs();
if (!desiredRegistrations.isEmpty()) {
register(clientId, desiredRegistrations);
}
}
@Override
public void requestAuthToken(final PendingIntent pendingIntent,
@Nullable String invalidAuthToken) {
@Nullable Account account = ChromeSigninController.get(this).getSignedInUser();
if (account == null) {
// This should never happen, because this code should only be run if a user is
// signed-in.
Log.w(TAG, "No signed-in user; cannot send message to data center");
return;
}
// Attempt to retrieve a token for the user. This method will also invalidate
// invalidAuthToken if it is non-null.
AccountManagerHelper.get(this).getNewAuthTokenFromForeground(
account, invalidAuthToken, getOAuth2ScopeWithType(),
new AccountManagerHelper.GetAuthTokenCallback() {
@Override
public void tokenAvailable(String token) {
if (token != null) {
setAuthToken(InvalidationService.this.getApplicationContext(),
pendingIntent, token, getOAuth2ScopeWithType());
}
}
});
}
@Override
public void writeState(byte[] data) {
InvalidationPreferences invPreferences = new InvalidationPreferences(this);
EditContext editContext = invPreferences.edit();
invPreferences.setInternalNotificationClientState(editContext, data);
invPreferences.commit(editContext);
}
@Override
@Nullable public byte[] readState() {
return new InvalidationPreferences(this).getInternalNotificationClientState();
}
/**
* Ensures that the client is running or not running as appropriate, based on the value of
* {@link #shouldClientBeRunning}.
*/
private void ensureClientStartState() {
final boolean shouldClientBeRunning = shouldClientBeRunning();
if (!shouldClientBeRunning && sIsClientStarted) {
// Stop the client if it should not be running and is.
stopClient();
} else if (shouldClientBeRunning && !sIsClientStarted) {
// Start the client if it should be running and isn't.
startClient();
}
}
/**
* If {@code intendedAccount} is non-{@null} and differs from the account stored in preferences,
* then stops the existing client (if any) and updates the stored account.
*/
private void ensureAccount(@Nullable Account intendedAccount) {
if (intendedAccount == null) {
return;
}
InvalidationPreferences invPrefs = new InvalidationPreferences(this);
if (!intendedAccount.equals(invPrefs.getSavedSyncedAccount())) {
if (sIsClientStarted) {
stopClient();
}
setAccount(intendedAccount);
}
}
/**
* Starts a new client, destroying any existing client. {@code owningAccount} is the account
* of the user for which the client is being created; it will be persisted using
* {@link InvalidationPreferences#setAccount}.
*/
private void startClient() {
byte[] clientName = InvalidationClientNameProvider.get().getInvalidatorClientName();
Intent startIntent = AndroidListener.createStartIntent(this, CLIENT_TYPE, clientName);
startService(startIntent);
setIsClientStarted(true);
}
/** Stops the notification client. */
private void stopClient() {
startService(AndroidListener.createStopIntent(this));
setIsClientStarted(false);
setClientId(null);
}
/** Sets the saved sync account in {@link InvalidationPreferences} to {@code owningAccount}. */
private void setAccount(Account owningAccount) {
InvalidationPreferences invPrefs = new InvalidationPreferences(this);
EditContext editContext = invPrefs.edit();
invPrefs.setAccount(editContext, owningAccount);
invPrefs.commit(editContext);
}
/**
* Reads the saved sync types from storage (if any) and returns a set containing the
* corresponding object ids.
*/
private Set<ObjectId> readSyncRegistrationsFromPrefs() {
Set<String> savedTypes = new InvalidationPreferences(this).getSavedSyncedTypes();
if (savedTypes == null) return Collections.emptySet();
else return ModelType.syncTypesToObjectIds(savedTypes);
}
/**
* Reads the saved non-sync object ids from storage (if any) and returns a set containing the
* corresponding object ids.
*/
private Set<ObjectId> readNonSyncRegistrationsFromPrefs() {
Set<ObjectId> objectIds = new InvalidationPreferences(this).getSavedObjectIds();
if (objectIds == null) return Collections.emptySet();
else return objectIds;
}
/**
* Reads the object registrations from storage (if any) and returns a set containing the
* corresponding object ids.
*/
@VisibleForTesting
Set<ObjectId> readRegistrationsFromPrefs() {
return joinRegistrations(readSyncRegistrationsFromPrefs(),
readNonSyncRegistrationsFromPrefs());
}
/**
* Join Sync object registrations with non-Sync object registrations to get the full set of
* desired object registrations.
*/
private static Set<ObjectId> joinRegistrations(Set<ObjectId> syncRegistrations,
Set<ObjectId> nonSyncRegistrations) {
if (nonSyncRegistrations.isEmpty()) {
return syncRegistrations;
}
if (syncRegistrations.isEmpty()) {
return nonSyncRegistrations;
}
Set<ObjectId> registrations = new HashSet<ObjectId>(
syncRegistrations.size() + nonSyncRegistrations.size());
registrations.addAll(syncRegistrations);
registrations.addAll(nonSyncRegistrations);
return registrations;
}
/**
* Sets the types for which notifications are required to {@code syncTypes}. {@code syncTypes}
* is either a list of specific types or the special wildcard type
* {@link ModelType#ALL_TYPES_TYPE}. Also registers for additional objects specified by
* {@code objectIds}. Either parameter may be null if the corresponding registrations are not
* changing.
* <p>
* @param syncTypes
*/
private void setRegisteredTypes(Set<String> syncTypes, Set<ObjectId> objectIds) {
// If we have a ready client and will be making registration change calls on it, then
// read the current registrations from preferences before we write the new values, so that
// we can take the diff of the two registration sets and determine which registration change
// calls to make.
Set<ObjectId> existingSyncRegistrations = (sClientId == null) ?
null : readSyncRegistrationsFromPrefs();
Set<ObjectId> existingNonSyncRegistrations = (sClientId == null) ?
null : readNonSyncRegistrationsFromPrefs();
// Write the new sync types/object ids to preferences. We do not expand the syncTypes to
// take into account the ALL_TYPES_TYPE at this point; we want to persist the wildcard
// unexpanded.
InvalidationPreferences prefs = new InvalidationPreferences(this);
EditContext editContext = prefs.edit();
if (syncTypes != null) {
prefs.setSyncTypes(editContext, syncTypes);
}
if (objectIds != null) {
prefs.setObjectIds(editContext, objectIds);
}
prefs.commit(editContext);
// If we do not have a ready invalidation client, we cannot change its registrations, so
// return. Later, when the client is ready, we will supply the new registrations.
if (sClientId == null) {
return;
}
// We do have a ready client. Unregister any existing registrations not present in the
// new set and register any elements in the new set not already present. This call does
// expansion of the ALL_TYPES_TYPE wildcard.
// NOTE: syncTypes MUST NOT be used below this line, since it contains an unexpanded
// wildcard.
// When computing the desired set of object ids, if only sync types were provided, then
// keep the existing non-sync types, and vice-versa.
Set<ObjectId> desiredSyncRegistrations = syncTypes != null ?
ModelType.syncTypesToObjectIds(syncTypes) : existingSyncRegistrations;
Set<ObjectId> desiredNonSyncRegistrations = objectIds != null ?
objectIds : existingNonSyncRegistrations;
Set<ObjectId> desiredRegistrations = joinRegistrations(desiredNonSyncRegistrations,
desiredSyncRegistrations);
Set<ObjectId> existingRegistrations = joinRegistrations(existingNonSyncRegistrations,
existingSyncRegistrations);
Set<ObjectId> unregistrations = new HashSet<ObjectId>();
Set<ObjectId> registrations = new HashSet<ObjectId>();
computeRegistrationOps(existingRegistrations, desiredRegistrations,
registrations, unregistrations);
unregister(sClientId, unregistrations);
register(sClientId, registrations);
}
/**
* Computes the set of (un)registrations to perform so that the registrations active in the
* Ticl will be {@code desiredRegs}, given that {@existingRegs} already exist.
*
* @param regAccumulator registrations to perform
* @param unregAccumulator unregistrations to perform.
*/
@VisibleForTesting
static void computeRegistrationOps(Set<ObjectId> existingRegs, Set<ObjectId> desiredRegs,
Set<ObjectId> regAccumulator, Set<ObjectId> unregAccumulator) {
// Registrations to do are elements in the new set but not the old set.
regAccumulator.addAll(desiredRegs);
regAccumulator.removeAll(existingRegs);
// Unregistrations to do are elements in the old set but not the new set.
unregAccumulator.addAll(existingRegs);
unregAccumulator.removeAll(desiredRegs);
}
/**
* Requests that the sync system perform a sync.
*
* @param objectId the object that changed, if known.
* @param version the version of the object that changed, if known.
* @param payload the payload of the change, if known.
*/
private void requestSync(@Nullable ObjectId objectId, @Nullable Long version,
@Nullable String payload) {
// Construct the bundle to supply to the native sync code.
Bundle bundle = new Bundle();
if (objectId == null && version == null && payload == null) {
// Use an empty bundle in this case for compatibility with the v1 implementation.
} else {
if (objectId != null) {
bundle.putInt("objectSource", objectId.getSource());
bundle.putString("objectId", new String(objectId.getName()));
}
// We use "0" as the version if we have an unknown-version invalidation. This is OK
// because the native sync code special-cases zero and always syncs for invalidations at
// that version (Tango defines a special UNKNOWN_VERSION constant with this value).
bundle.putLong("version", (version == null) ? 0 : version);
bundle.putString("payload", (payload == null) ? "" : payload);
}
Account account = ChromeSigninController.get(this).getSignedInUser();
String contractAuthority = SyncStatusHelper.get(this).getContractAuthority();
requestSyncFromContentResolver(bundle, account, contractAuthority);
}
/**
* Calls {@link ContentResolver#requestSync(Account, String, Bundle)} to trigger a sync. Split
* into a separate method so that it can be overriden in tests.
*/
@VisibleForTesting
void requestSyncFromContentResolver(
Bundle bundle, Account account, String contractAuthority) {
Log.d(TAG, "Request sync: " + account + " / " + contractAuthority + " / "
+ bundle.keySet());
ContentResolver.requestSync(account, contractAuthority, bundle);
}
/**
* Returns whether the notification client should be running, i.e., whether Chrome is in the
* foreground and sync is enabled.
*/
@VisibleForTesting
boolean shouldClientBeRunning() {
return isSyncEnabled() && isChromeInForeground();
}
/** Returns whether sync is enabled. LLocal method so it can be overridden in tests. */
@VisibleForTesting
boolean isSyncEnabled() {
return SyncStatusHelper.get(getApplicationContext()).isSyncEnabled();
}
/**
* Returns whether Chrome is in the foreground. Local method so it can be overridden in tests.
*/
@VisibleForTesting
boolean isChromeInForeground() {
return ApplicationStatus.hasVisibleActivities();
}
/** Returns whether the notification client has been started, for tests. */
@VisibleForTesting
static boolean getIsClientStartedForTest() {
return sIsClientStarted;
}
/** Returns the notification client id, for tests. */
@VisibleForTesting
@Nullable static byte[] getClientIdForTest() {
return sClientId;
}
private static String getOAuth2ScopeWithType() {
return "oauth2:" + SyncStatusHelper.CHROME_SYNC_OAUTH2_SCOPE;
}
private static void setClientId(byte[] clientId) {
sClientId = clientId;
}
private static void setIsClientStarted(boolean isStarted) {
sIsClientStarted = isStarted;
}
}