blob: b9a4e9ad807d4758d51400c99ef872c385c9a1c8 [file] [log] [blame]
/*
* Copyright 2011 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.ipc.invalidation.external.client.contrib;
import com.google.ipc.invalidation.external.client.InvalidationClient;
import com.google.ipc.invalidation.external.client.InvalidationClientConfig;
import com.google.ipc.invalidation.external.client.InvalidationListener;
import com.google.ipc.invalidation.external.client.InvalidationListener.RegistrationState;
import com.google.ipc.invalidation.external.client.SystemResources.Logger;
import com.google.ipc.invalidation.external.client.android.service.AndroidLogger;
import com.google.ipc.invalidation.external.client.types.AckHandle;
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.ipc.invalidation.ticl.InvalidationClientCore;
import com.google.ipc.invalidation.ticl.ProtoWrapperConverter;
import com.google.ipc.invalidation.ticl.android2.AndroidClock;
import com.google.ipc.invalidation.ticl.android2.AndroidInvalidationListenerIntentMapper;
import com.google.ipc.invalidation.ticl.android2.ProtocolIntents;
import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.AuthTokenConstants;
import com.google.ipc.invalidation.ticl.proto.AndroidListenerProtocol;
import com.google.ipc.invalidation.ticl.proto.AndroidListenerProtocol.RegistrationCommand;
import com.google.ipc.invalidation.ticl.proto.AndroidListenerProtocol.StartCommand;
import com.google.ipc.invalidation.ticl.proto.ClientProtocol.ClientConfigP;
import com.google.ipc.invalidation.ticl.proto.ClientProtocol.InvalidationMessage;
import com.google.ipc.invalidation.ticl.proto.ClientProtocol.InvalidationP;
import com.google.ipc.invalidation.ticl.proto.ClientProtocol.ObjectIdP;
import com.google.ipc.invalidation.util.Bytes;
import com.google.ipc.invalidation.util.Preconditions;
import com.google.ipc.invalidation.util.ProtoWrapper.ValidationException;
import android.app.IntentService;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Simplified listener contract for Android clients. Takes care of exponential back-off when
* register or unregister are called for an object after a failure has occurred. Also suppresses
* redundant register requests.
*
* <p>A sample implementation of an {@link AndroidListener} is shown below:
*
* <p><code>
* class ExampleListener extends AndroidListener {
* @Override
* public void reissueRegistrations(byte[] clientId) {
* List<ObjectId> desiredRegistrations = ...;
* register(clientId, desiredRegistrations);
* }
*
* @Override
* public void invalidate(Invalidation invalidation, final byte[] ackHandle) {
* // Track the most recent version of the object (application-specific) and then acknowledge
* // the invalidation.
* ...
* acknowledge(ackHandle);
* }
*
* @Override
* public void informRegistrationFailure(byte[] clientId, ObjectId objectId,
* boolean isTransient, String errorMessage) {
* // Try again if there is a transient failure and we still care whether the object is
* // registered or not.
* if (isTransient) {
* boolean shouldRetry = ...;
* if (shouldRetry) {
* boolean shouldBeRegistered = ...;
* if (shouldBeRegistered) {
* register(clientId, ImmutableList.of(objectId));
* } else {
* unregister(clientId, ImmutableList.of(objectId));
* }
* }
* }
* }
*
* ...
* }
* </code>
*
* <p>See {@link com.google.ipc.invalidation.examples.android2} for a complete sample.
*
*/
public abstract class AndroidListener extends IntentService {
/** External alarm receiver that allows the listener to respond to alarm intents. */
public static final class AlarmReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Preconditions.checkNotNull(context);
Preconditions.checkNotNull(intent);
if (intent.hasExtra(AndroidListenerIntents.EXTRA_REGISTRATION)) {
AndroidListenerIntents.issueAndroidListenerIntent(context, intent);
}
}
}
/** The logger. */
private static final Logger logger = AndroidLogger.forPrefix("");
/** Initial retry delay for exponential backoff (1 minute). */
static int initialMaxDelayMs = (int) TimeUnit.SECONDS.toMillis(60);
/** Maximum delay factor for exponential backoff (6 hours). */
static int maxDelayFactor = 6 * 60;
/** The last client ID passed to the ready up-call. */
static Bytes lastClientIdForTest;
/**
* Invalidation listener implementation. We implement the interface on a private field rather
* than directly to avoid leaking methods that should not be directly called by the client
* application. The listener must be called only on intent service thread.
*/
private final InvalidationListener invalidationListener = new InvalidationListener() {
@Override
public final void ready(final InvalidationClient client) {
Bytes clientId = state.getClientId();
AndroidListener.lastClientIdForTest = clientId;
AndroidListener.this.ready(clientId.getByteArray());
}
@Override
public final void reissueRegistrations(final InvalidationClient client, byte[] prefix,
int prefixLength) {
AndroidListener.this.reissueRegistrations(state.getClientId().getByteArray());
}
@Override
public final void informRegistrationStatus(final InvalidationClient client,
final ObjectId objectId, final RegistrationState regState) {
state.informRegistrationSuccess(objectId);
AndroidListener.this.informRegistrationStatus(state.getClientId().getByteArray(), objectId,
regState);
}
@Override
public final void informRegistrationFailure(final InvalidationClient client,
final ObjectId objectId, final boolean isTransient, final String errorMessage) {
state.informRegistrationFailure(objectId, isTransient);
AndroidListener.this.informRegistrationFailure(state.getClientId().getByteArray(), objectId,
isTransient, errorMessage);
}
@Override
public void invalidate(InvalidationClient client, Invalidation invalidation,
AckHandle ackHandle) {
AndroidListener.this.invalidate(invalidation, ackHandle.getHandleData());
}
@Override
public void invalidateUnknownVersion(InvalidationClient client, ObjectId objectId,
AckHandle ackHandle) {
AndroidListener.this.invalidateUnknownVersion(objectId, ackHandle.getHandleData());
}
@Override
public void invalidateAll(InvalidationClient client, AckHandle ackHandle) {
AndroidListener.this.invalidateAll(ackHandle.getHandleData());
}
@Override
public void informError(InvalidationClient client, ErrorInfo errorInfo) {
AndroidListener.this.informError(errorInfo);
}
};
/**
* The internal state of the listener. Lazy initialization, triggered by {@link #onHandleIntent}.
*/
private AndroidListenerState state;
/** The clock to use when scheduling retry call-backs. */
private final AndroidClock clock = new AndroidClock.SystemClock();
/**
* The mapper used to route intents to the invalidation listener. Lazy initialization triggered
* by {@link #onCreate}.
*/
private AndroidInvalidationListenerIntentMapper intentMapper;
/** Initializes {@link AndroidListener}. */
protected AndroidListener() {
super("");
// If the process dies before an intent is handled, setIntentRedelivery(true) ensures that the
// last intent is redelivered. This optimization is not necessary for correctness: on restart,
// all registrations will be reissued and unacked invalidations will be resent anyways.
setIntentRedelivery(true);
}
/** See specs for {@link InvalidationClient#start}. */
public static Intent createStartIntent(Context context, InvalidationClientConfig config) {
Preconditions.checkNotNull(context);
Preconditions.checkNotNull(config);
Preconditions.checkNotNull(config.clientName);
return AndroidListenerIntents.createStartIntent(context, config.clientType,
Bytes.fromByteArray(config.clientName), config.allowSuppression);
}
/** See specs for {@link InvalidationClient#start}. */
public static Intent createStartIntent(Context context, int clientType, byte[] clientName) {
Preconditions.checkNotNull(context);
Preconditions.checkNotNull(clientName);
final boolean allowSuppression = true;
return AndroidListenerIntents.createStartIntent(context, clientType,
Bytes.fromByteArray(clientName), allowSuppression);
}
/** See specs for {@link InvalidationClient#stop}. */
public static Intent createStopIntent(Context context) {
Preconditions.checkNotNull(context);
return AndroidListenerIntents.createStopIntent(context);
}
/**
* See specs for {@link InvalidationClient#register}.
*
* @param context the context
* @param clientId identifier for the client service for which we are registering
* @param objectIds the object ids being registered
*/
public static Intent createRegisterIntent(Context context, byte[] clientId,
Iterable<ObjectId> objectIds) {
Preconditions.checkNotNull(context);
Preconditions.checkNotNull(clientId);
Preconditions.checkNotNull(objectIds);
final boolean isRegister = true;
return AndroidListenerIntents.createRegistrationIntent(context, Bytes.fromByteArray(clientId),
objectIds, isRegister);
}
/**
* See specs for {@link InvalidationClient#register}.
*
* @param clientId identifier for the client service for which we are registering
* @param objectIds the object ids being registered
*/
public void register(byte[] clientId, Iterable<ObjectId> objectIds) {
Preconditions.checkNotNull(clientId);
Preconditions.checkNotNull(objectIds);
Context context = getApplicationContext();
context.startService(createRegisterIntent(context, clientId, objectIds));
}
/**
* See specs for {@link InvalidationClient#unregister}.
*
* @param context the context
* @param clientId identifier for the client service for which we are unregistering
* @param objectIds the object ids being unregistered
*/
public static Intent createUnregisterIntent(Context context, byte[] clientId,
Iterable<ObjectId> objectIds) {
Preconditions.checkNotNull(context);
Preconditions.checkNotNull(clientId);
Preconditions.checkNotNull(objectIds);
final boolean isRegister = false;
return AndroidListenerIntents.createRegistrationIntent(context, Bytes.fromByteArray(clientId),
objectIds, isRegister);
}
/**
* Sets the authorization token and type used by the invalidation client. Call in response to
* {@link #requestAuthToken} calls.
*
* @param pendingIntent pending intent passed to {@link #requestAuthToken}
* @param authToken authorization token
* @param authType authorization token typo
*/
public static void setAuthToken(Context context, PendingIntent pendingIntent, String authToken,
String authType) {
Preconditions.checkNotNull(pendingIntent);
Preconditions.checkNotNull(authToken);
Preconditions.checkNotNull(authType);
AndroidListenerIntents.issueAuthTokenResponse(context, pendingIntent, authToken, authType);
}
/**
* See specs for {@link InvalidationClient#unregister}.
*
* @param clientId identifier for the client service for which we are registering
* @param objectIds the object ids being unregistered
*/
public void unregister(byte[] clientId, Iterable<ObjectId> objectIds) {
Preconditions.checkNotNull(clientId);
Preconditions.checkNotNull(objectIds);
Context context = getApplicationContext();
context.startService(createUnregisterIntent(context, clientId, objectIds));
}
/** See specs for {@link InvalidationClient#acknowledge}. */
public static Intent createAcknowledgeIntent(Context context, byte[] ackHandle) {
Preconditions.checkNotNull(context);
Preconditions.checkNotNull(ackHandle);
return AndroidListenerIntents.createAckIntent(context, ackHandle);
}
/** See specs for {@link InvalidationClient#acknowledge}. */
public void acknowledge(byte[] ackHandle) {
Preconditions.checkNotNull(ackHandle);
Context context = getApplicationContext();
context.startService(createAcknowledgeIntent(context, ackHandle));
}
/**
* See specs for {@link InvalidationListener#ready}.
*
* @param clientId the client identifier that must be passed to {@link #createRegisterIntent}
* and {@link #createUnregisterIntent}
*/
public abstract void ready(byte[] clientId);
/**
* See specs for {@link InvalidationListener#reissueRegistrations}.
*
* @param clientId the client identifier that must be passed to {@link #createRegisterIntent}
* and {@link #createUnregisterIntent}
*/
public abstract void reissueRegistrations(byte[] clientId);
/**
* See specs for {@link InvalidationListener#informError}.
*/
public abstract void informError(ErrorInfo errorInfo);
/**
* See specs for {@link InvalidationListener#invalidate}.
*
* @param invalidation the invalidation
* @param ackHandle event acknowledgment handle
*/
public abstract void invalidate(Invalidation invalidation, byte[] ackHandle);
/**
* See specs for {@link InvalidationListener#invalidateUnknownVersion}.
*
* @param objectId identifier for the object with unknown version
* @param ackHandle event acknowledgment handle
*/
public abstract void invalidateUnknownVersion(ObjectId objectId, byte[] ackHandle);
/**
* See specs for {@link InvalidationListener#invalidateAll}.
*
* @param ackHandle event acknowledgment handle
*/
public abstract void invalidateAll(byte[] ackHandle);
/**
* Read listener state.
*
* @return serialized state or {@code null} if it is not available
*/
public abstract byte[] readState();
/** Write listener state to some location. */
public abstract void writeState(byte[] data);
/**
* See specs for {@link InvalidationListener#informRegistrationFailure}.
*/
public abstract void informRegistrationFailure(byte[] clientId, ObjectId objectId,
boolean isTransient, String errorMessage);
/**
* See specs for (@link InvalidationListener#informRegistrationStatus}.
*/
public abstract void informRegistrationStatus(byte[] clientId, ObjectId objectId,
RegistrationState regState);
/**
* Called when an authorization token is needed. Respond by calling {@link #setAuthToken}.
*
* @param pendingIntent pending intent that must be used in {@link #setAuthToken} response.
* @param invalidAuthToken the existing invalid token or null if none exists. Implementation
* should invalidate the token.
*/
public abstract void requestAuthToken(PendingIntent pendingIntent,
String invalidAuthToken);
/**
* Handles invalidations received while the client is stopped. An implementation may choose to
* do work in response to these invalidations (delivered best-effort by the invalidation system).
* Not intended for use by most client implementations.
*/
protected void backgroundInvalidateForInternalUse(
@SuppressWarnings("unused") Iterable<Invalidation> invalidations) {
// Ignore background invalidations by default.
}
@Override
public void onCreate() {
super.onCreate();
// Initialize the intent mapper (now that context is available).
intentMapper = new AndroidInvalidationListenerIntentMapper(invalidationListener, this);
}
/**
* Derived classes may override this method to handle custom intents. This is a recommended
* pattern for invalidation-related intents, e.g. for registration and unregistration. Derived
* classes should call {@code super.onHandleIntent(intent)} for any intents they did not
* handle on their own.
*/
@Override
protected void onHandleIntent(Intent intent) {
if (intent == null) {
return;
}
// We lazily initialize state in calls to onHandleIntent rather than initializing in onCreate
// because onCreate runs on the UI thread and initializeState performs I/O.
if (state == null) {
initializeState();
}
// Handle any intents specific to the AndroidListener. For other intents, defer to the
// intentMapper, which handles listener upcalls corresponding to the InvalidationListener
// methods.
if (!tryHandleAuthTokenRequestIntent(intent) &&
!tryHandleRegistrationIntent(intent) &&
!tryHandleStartIntent(intent) &&
!tryHandleStopIntent(intent) &&
!tryHandleAckIntent(intent) &&
!tryHandleBackgroundInvalidationsIntent(intent)) {
intentMapper.handleIntent(intent);
}
// Always check to see if we need to persist state changes after handling an intent.
if (state.getIsDirty()) {
writeState(state.marshal().toByteArray());
state.resetIsDirty();
}
}
/** Returns invalidation client that can be used to trigger intents against the TICL service. */
private InvalidationClient getClient() {
return intentMapper.client;
}
/**
* Initializes listener state either from persistent proto (if available) or from scratch.
*/
private void initializeState() {
AndroidListenerProtocol.AndroidListenerState proto = getPersistentState();
if (proto != null) {
state = new AndroidListenerState(initialMaxDelayMs, maxDelayFactor, proto);
} else {
state = new AndroidListenerState(initialMaxDelayMs, maxDelayFactor);
}
}
/**
* Reads and parses persistent state for the listener. Returns {@code null} if the state does not
* exist or is invalid.
*/
private AndroidListenerProtocol.AndroidListenerState getPersistentState() {
// Defer to application code to read the blob containing the state proto.
byte[] stateData = readState();
try {
if (null != stateData) {
AndroidListenerProtocol.AndroidListenerState state =
AndroidListenerProtocol.AndroidListenerState.parseFrom(stateData);
if (!AndroidListenerProtos.isValidAndroidListenerState(state)) {
logger.warning("Invalid listener state.");
return null;
}
return state;
}
} catch (ValidationException exception) {
logger.warning("Failed to parse listener state: %s", exception);
}
return null;
}
/**
* Tries to handle a request for an authorization token. Returns {@code true} iff the intent is
* an auth token request.
*/
private boolean tryHandleAuthTokenRequestIntent(Intent intent) {
if (!AndroidListenerIntents.isAuthTokenRequest(intent)) {
return false;
}
// Check for invalid auth token. Subclass may have to invalidate it if it exists in the call
// to getNewAuthToken.
String invalidAuthToken = intent.getStringExtra(
AuthTokenConstants.EXTRA_INVALIDATE_AUTH_TOKEN);
// Intent also includes a pending intent that we can use to pass back our response.
PendingIntent pendingIntent = intent.getParcelableExtra(
AuthTokenConstants.EXTRA_PENDING_INTENT);
if (pendingIntent == null) {
logger.warning("Authorization request without pending intent extra.");
} else {
// Delegate to client application to figure out what the new token should be and the auth
// type.
requestAuthToken(pendingIntent, invalidAuthToken);
}
return true;
}
/** Tries to handle a stop intent. Returns {@code true} iff the intent is a stop intent. */
private boolean tryHandleStopIntent(Intent intent) {
if (!AndroidListenerIntents.isStopIntent(intent)) {
return false;
}
getClient().stop();
return true;
}
/**
* Tries to handle a registration intent. Returns {@code true} iff the intent is a registration
* intent.
*/
private boolean tryHandleRegistrationIntent(Intent intent) {
RegistrationCommand command = AndroidListenerIntents.findRegistrationCommand(intent);
if ((command == null) || !AndroidListenerProtos.isValidRegistrationCommand(command)) {
return false;
}
// Make sure the registration is intended for this client. If not, we ignore it (suggests
// there is a new client now).
if (!command.getClientId().equals(state.getClientId())) {
logger.warning("Ignoring registration request for old client. Old ID = %s, New ID = %s",
command.getClientId(), state.getClientId());
return true;
}
boolean isRegister = command.getIsRegister();
for (ObjectIdP objectIdP : command.getObjectId()) {
ObjectId objectId = ProtoWrapperConverter.convertFromObjectIdProto(objectIdP);
// We may need to delay the registration command (if it is not already delayed).
int delayMs = 0;
if (!command.getIsDelayed()) {
delayMs = state.getNextDelay(objectId);
}
if (delayMs == 0) {
issueRegistration(objectId, isRegister);
} else {
AndroidListenerIntents.issueDelayedRegistrationIntent(getApplicationContext(), clock,
state.getClientId(), objectId, isRegister, delayMs, state.getNextRequestCode());
}
}
return true;
}
/**
* Called when the client application requests a new registration. If a redundant register request
* is made -- i.e. when the application attempts to register an object that is already in the
* {@code AndroidListenerState#desiredRegistrations} collection -- the method returns immediately.
* Unregister requests are never ignored since we can't reliably determine whether an unregister
* request is redundant: our policy on failures of any kind is to remove the registration from
* the {@code AndroidListenerState#desiredRegistrations} collection.
*/
private void issueRegistration(ObjectId objectId, boolean isRegister) {
if (isRegister) {
if (state.addDesiredRegistration(objectId)) {
// Don't bother if we think it's already registered. Note that we remove the object from the
// collection when there is a failure.
getClient().register(objectId);
}
} else {
// Remove the object ID from the desired registration collection so that subsequent attempts
// to re-register are not ignored.
state.removeDesiredRegistration(objectId);
getClient().unregister(objectId);
}
}
/** Tries to handle a start intent. Returns {@code true} iff the intent is a start intent. */
private boolean tryHandleStartIntent(Intent intent) {
StartCommand command = AndroidListenerIntents.findStartCommand(intent);
if ((command == null) || !AndroidListenerProtos.isValidStartCommand(command)) {
return false;
}
// Reset the state so that we make no assumptions about desired registrations and can ignore
// messages directed at the wrong instance.
state = new AndroidListenerState(initialMaxDelayMs, maxDelayFactor);
boolean skipStartForTest = false;
ClientConfigP clientConfig = InvalidationClientCore.createConfig();
if (command.getAllowSuppression() != clientConfig.getAllowSuppression()) {
ClientConfigP.Builder clientConfigBuilder = clientConfig.toBuilder();
clientConfigBuilder.allowSuppression = command.getAllowSuppression();
clientConfig = clientConfigBuilder.build();
}
Intent startIntent = ProtocolIntents.InternalDowncalls.newCreateClientIntent(
command.getClientType(), command.getClientName(), clientConfig, skipStartForTest);
AndroidListenerIntents.issueTiclIntent(getApplicationContext(), startIntent);
return true;
}
/** Tries to handle an ack intent. Returns {@code true} iff the intent is an ack intent. */
private boolean tryHandleAckIntent(Intent intent) {
byte[] data = AndroidListenerIntents.findAckHandle(intent);
if (data == null) {
return false;
}
getClient().acknowledge(AckHandle.newInstance(data));
return true;
}
/**
* Tries to handle a background invalidation intent. Returns {@code true} iff the intent is a
* background invalidation intent.
*/
private boolean tryHandleBackgroundInvalidationsIntent(Intent intent) {
byte[] data = intent.getByteArrayExtra(ProtocolIntents.BACKGROUND_INVALIDATION_KEY);
if (data == null) {
return false;
}
try {
InvalidationMessage invalidationMessage = InvalidationMessage.parseFrom(data);
List<Invalidation> invalidations = new ArrayList<Invalidation>();
for (InvalidationP invalidation : invalidationMessage.getInvalidation()) {
invalidations.add(ProtoWrapperConverter.convertFromInvalidationProto(invalidation));
}
backgroundInvalidateForInternalUse(invalidations);
} catch (ValidationException exception) {
logger.info("Failed to parse background invalidation intent payload: %s",
exception.getMessage());
}
return false;
}
/** Returns the current state of the listener, for tests. */
AndroidListenerState getStateForTest() {
return state;
}
}