/*
* 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.ticl.android;
import com.google.ipc.invalidation.common.DigestFunction;
import com.google.ipc.invalidation.common.ObjectIdDigestUtils;
import com.google.ipc.invalidation.external.client.SystemResources.Logger;
import com.google.ipc.invalidation.external.client.android.AndroidInvalidationClient;
import com.google.ipc.invalidation.external.client.android.service.AndroidLogger;
import com.google.ipc.invalidation.external.client.android.service.Request;
import com.google.ipc.invalidation.external.client.android.service.Response;
import com.google.ipc.invalidation.external.client.android.service.Response.Status;
import com.google.ipc.invalidation.external.client.contrib.MultiplexingGcmListener;
import com.google.ipc.invalidation.external.client.types.AckHandle;
import com.google.ipc.invalidation.external.client.types.ObjectId;
import com.google.ipc.invalidation.ticl.InvalidationClientCore;
import com.google.ipc.invalidation.ticl.PersistenceUtils;
import com.google.ipc.invalidation.util.TypedUtil;
import com.google.protos.ipc.invalidation.Client.PersistentTiclState;
import android.accounts.Account;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.util.Base64;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* The AndroidInvalidationService class provides an Android service implementation that bridges
* between the {@code InvalidationService} interface and invalidation client service instances
* executing within the scope of that service. The invalidation service will have an associated
* {@link AndroidClientManager} that is managing the set of active (in memory) clients associated
* with the service. It processes requests from invalidation applications (as invocations on
* the {@code InvalidationService} bound service interface along with GCM registration and
* activity (from {@link ReceiverService}).
*
*/
public class AndroidInvalidationService extends AbstractInvalidationService {
/**
* Service that handles system GCM messages (with support from the base class). It receives
* intents for GCM registration, errors and message delivery. It does some basic processing and
* then forwards the messages to the {@link AndroidInvalidationService} for handling.
*/
public static class ReceiverService extends MultiplexingGcmListener.AbstractListener {
/**
* Receiver for broadcasts by the multiplexed GCM service. It forwards them to
* AndroidMessageReceiverService.
*/
public static class Receiver extends MultiplexingGcmListener.AbstractListener.Receiver {
/* This class is public so that it can be instantiated by the Android runtime. */
@Override
protected Class<?> getServiceClass() {
return ReceiverService.class;
}
}
public ReceiverService() {
super("MsgRcvrSvc");
}
@Override
public void onRegistered(String registrationId) {
logger.info("GCM Registration received: %s", registrationId);
// Upon receiving a new updated GCM ID, notify the invalidation service
Intent serviceIntent =
AndroidInvalidationService.createRegistrationIntent(this, registrationId);
startService(serviceIntent);
}
@Override
public void onUnregistered(String registrationId) {
logger.info("GCM unregistered");
}
@Override
protected void onMessage(Intent intent) {
// Extract expected fields and do basic syntactic checks (but no value checking)
// and forward the result on to the AndroidInvalidationService for processing.
Intent serviceIntent;
String clientKey = intent.getStringExtra(AndroidC2DMConstants.CLIENT_KEY_PARAM);
if (clientKey == null) {
logger.severe("GCM Intent does not contain client key value: %s", intent);
return;
}
String encodedData = intent.getStringExtra(AndroidC2DMConstants.CONTENT_PARAM);
String echoToken = intent.getStringExtra(AndroidC2DMConstants.ECHO_PARAM);
if (encodedData != null) {
try {
byte [] rawData = Base64.decode(encodedData, Base64.URL_SAFE);
serviceIntent = AndroidInvalidationService.createDataIntent(this, clientKey, echoToken,
rawData);
} catch (IllegalArgumentException exception) {
logger.severe("Unable to decode intent data", exception);
return;
}
} else {
logger.severe("Received mailbox intent: %s", intent);
return;
}
startService(serviceIntent);
}
@Override
protected void onDeletedMessages(int total) {
// This method must be implemented if we start using non-collapsable messages with GCM. For
// now, there is nothing to do.
}
}
/** The last created instance, for testing. */
static AtomicReference<AndroidInvalidationService> lastInstanceForTest =
new AtomicReference<AndroidInvalidationService>();
/** For tests only, the number of C2DM errors received. */
static final AtomicInteger numGcmErrorsForTest = new AtomicInteger(0);
/** For tests only, the number of C2DM registration messages received. */
static final AtomicInteger numGcmRegistrationForTest = new AtomicInteger(0);
/** For tests only, the number of C2DM messages received. */
static final AtomicInteger numGcmMessagesForTest = new AtomicInteger(0);
/** For tests only, the number of onCreate calls made. */
static final AtomicInteger numCreateForTest = new AtomicInteger(0);
/** The client manager tracking in-memory client instances */
protected static AndroidClientManager clientManager;
private static final Logger logger = AndroidLogger.forTag("InvService");
/** The HTTP URL of the channel service. */
private static String channelUrl = AndroidHttpConstants.CHANNEL_URL;
// The AndroidInvalidationService handles a set of internal intents that are used for
// communication and coordination between the it and the GCM handling service. These
// are documented here with action and extra names documented with package private
// visibility since they are not intended for use by external components.
/**
* Sent when a new GCM registration activity occurs for the service. This can occur the first
* time the service is run or at any subsequent time if the Android C2DM service decides to issue
* a new GCM registration ID.
*/
static final String REGISTRATION_ACTION = "register";
/**
* The name of the String extra that contains the registration ID for a register intent. If this
* extra is not present, then it indicates that a C2DM notification regarding unregistration has
* been received (not expected during normal operation conditions).
*/
static final String REGISTER_ID = "id";
/**
* This intent is sent when a GCM message targeting the service is received.
*/
static final String MESSAGE_ACTION = "message";
/**
* The name of the String extra that contains the client key for the GCM message.
*/
static final String MESSAGE_CLIENT_KEY = "clientKey";
/**
* The name of the byte array extra that contains the encoded event for the GCM message.
*/
static final String MESSAGE_DATA = "data";
/** The name of the string extra that contains the echo token in the GCM message. */
static final String MESSAGE_ECHO = "echo-token";
/**
* This intent is sent when GCM registration has failed irrevocably.
*/
static final String ERROR_ACTION = "error";
/**
* The name of the String extra that contains the error message describing the registration
* failure.
*/
static final String ERROR_MESSAGE = "message";
/** Returns the client manager for this service */
static AndroidClientManager getClientManager() {
return clientManager;
}
/**
* Creates a new registration intent that notifies the service of a registration ID change
*/
static Intent createRegistrationIntent(Context context, String registrationId) {
Intent intent = new Intent(REGISTRATION_ACTION);
intent.setClass(context, AndroidInvalidationService.class);
if (registrationId != null) {
intent.putExtra(AndroidInvalidationService.REGISTER_ID, registrationId);
}
return intent;
}
/**
* Creates a new message intent to contains event data to deliver directly to a client.
*/
static Intent createDataIntent(Context context, String clientKey, String token,
byte [] data) {
Intent intent = new Intent(MESSAGE_ACTION);
intent.setClass(context, AndroidInvalidationService.class);
intent.putExtra(MESSAGE_CLIENT_KEY, clientKey);
intent.putExtra(MESSAGE_DATA, data);
if (token != null) {
intent.putExtra(MESSAGE_ECHO, token);
}
return intent;
}
/**
* Creates a new message intent that references event data to retrieve from a mailbox.
*/
static Intent createMailboxIntent(Context context, String clientKey, String token) {
Intent intent = new Intent(MESSAGE_ACTION);
intent.setClass(context, AndroidInvalidationService.class);
intent.putExtra(MESSAGE_CLIENT_KEY, clientKey);
if (token != null) {
intent.putExtra(MESSAGE_ECHO, token);
}
return intent;
}
/**
* Creates a new error intent that notifies the service of a registration failure.
*/
static Intent createErrorIntent(Context context, String errorId) {
Intent intent = new Intent(ERROR_ACTION);
intent.setClass(context, AndroidInvalidationService.class);
intent.putExtra(ERROR_MESSAGE, errorId);
return intent;
}
/**
* Overrides the channel URL set in package metadata to enable dynamic port assignment and
* configuration during testing.
*/
static void setChannelUrlForTest(String url) {
channelUrl = url;
}
/**
* Resets the state of the service to destroy any existing clients
*/
static void reset() {
if (clientManager != null) {
clientManager.releaseAll();
}
}
/** The function for computing persistence state digests when rewriting them. */
private final DigestFunction digestFn = new ObjectIdDigestUtils.Sha1DigestFunction();
public AndroidInvalidationService() {
lastInstanceForTest.set(this);
}
@Override
public void onCreate() {
synchronized (lock) {
super.onCreate();
// Create the client manager
if (clientManager == null) {
clientManager = new AndroidClientManager(this);
}
numCreateForTest.incrementAndGet();
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// Process GCM related messages from the ReceiverService. We do not check isCreated here because
// this is part of the stop/start lifecycle, not bind/unbind.
synchronized (lock) {
logger.fine("Received action = %s", intent.getAction());
if (MESSAGE_ACTION.equals(intent.getAction())) {
handleMessage(intent);
} else if (REGISTRATION_ACTION.equals(intent.getAction())) {
handleRegistration(intent);
} else if (ERROR_ACTION.equals(intent.getAction())) {
handleError(intent);
}
final int retval = super.onStartCommand(intent, flags, startId);
// Unless we are explicitly being asked to start, stop ourselves. Request.SERVICE_INTENT
// is the intent used by InvalidationBinder to bind the service, and
// AndroidInvalidationClientImpl uses the intent returned by InvalidationBinder.getIntent
// as the argument to its startService call.
if (!Request.SERVICE_INTENT.getAction().equals(intent.getAction())) {
stopServiceIfNoClientsRemain(intent.getAction());
}
return retval;
}
}
@Override
public void onDestroy() {
synchronized (lock) {
reset();
super.onDestroy();
}
}
@Override
public IBinder onBind(Intent intent) {
return super.onBind(intent);
}
@Override
public boolean onUnbind(Intent intent) {
synchronized (lock) {
logger.fine("onUnbind");
super.onUnbind(intent);
if ((clientManager != null) && (clientManager.getClientCount() > 0)) {
// This isn't wrong, per se, but it's potentially unusual.
logger.info(" clients still active in onUnbind");
}
stopServiceIfNoClientsRemain("onUnbind");
// We don't care about the onRebind event, which is what the documentation says a "true"
// return here will get us, but if we return false then we don't get a second onUnbind() event
// in a bind/unbind/bind/unbind cycle, which we require.
return true;
}
}
// The following protected methods are called holding "lock" by AbstractInvalidationService.
@Override
protected void create(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
int clientType = request.getClientType();
Account account = request.getAccount();
String authType = request.getAuthType();
Intent eventIntent = request.getIntent();
clientManager.create(clientKey, clientType, account, authType, eventIntent);
response.setStatus(Status.SUCCESS);
}
@Override
protected void resume(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AndroidClientProxy client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
response.setAccount(client.getAccount());
response.setAuthType(client.getAuthType());
}
}
@Override
protected void start(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AndroidInvalidationClient client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
client.start();
}
}
@Override
protected void stop(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AndroidInvalidationClient client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
client.stop();
}
}
@Override
protected void register(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AndroidInvalidationClient client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
ObjectId objectId = request.getObjectId();
client.register(objectId);
}
}
@Override
protected void unregister(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AndroidInvalidationClient client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
ObjectId objectId = request.getObjectId();
client.unregister(objectId);
}
}
@Override
protected void acknowledge(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AckHandle ackHandle = request.getAckHandle();
AndroidInvalidationClient client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
client.acknowledge(ackHandle);
}
}
@Override
protected void destroy(Request request, Response.Builder response) {
String clientKey = request.getClientKey();
AndroidInvalidationClient client = clientManager.get(clientKey);
if (setResponseStatus(client, request, response)) {
client.destroy();
}
}
/**
* If {@code client} is {@code null}, sets the {@code response} status to an error. Otherwise,
* sets the status to {@code success}.
* @return whether {@code client} was non-{@code null}. *
*/
private boolean setResponseStatus(AndroidInvalidationClient client, Request request,
Response.Builder response) {
if (client == null) {
response.setError("Client does not exist: " + request);
response.setStatus(Status.INVALID_CLIENT);
return false;
} else {
response.setStatus(Status.SUCCESS);
return true;
}
}
/** Returns the base URL used to send messages to the outbound network channel */
String getChannelUrl() {
synchronized (lock) {
return channelUrl;
}
}
private void handleMessage(Intent intent) {
numGcmMessagesForTest.incrementAndGet();
String clientKey = intent.getStringExtra(MESSAGE_CLIENT_KEY);
AndroidClientProxy proxy = clientManager.get(clientKey);
// Client is unknown or unstarted; we can't deliver the message, but we need to
// remember that we dropped it if the client is known.
if ((proxy == null) || !proxy.isStarted()) {
logger.warning("Dropping GCM message for unknown or unstarted client: %s", clientKey);
handleGcmMessageForUnstartedClient(proxy);
return;
}
// We can deliver the message. Pass the new echo token to the channel.
String echoToken = intent.getStringExtra(MESSAGE_ECHO);
logger.fine("Update %s with new echo token: %s", clientKey, echoToken);
proxy.getChannel().updateEchoToken(echoToken);
byte [] message = intent.getByteArrayExtra(MESSAGE_DATA);
if (message != null) {
logger.fine("Deliver to %s message %s", clientKey, message);
proxy.getChannel().receiveMessage(message);
} else {
logger.severe("Got mailbox intent: %s", intent);
}
}
/**
* Handles receipt of a GCM message for a client that was unknown or not started. If the client
* was unknown, drops the message. If the client was not started, rewrites the client's
* persistent state to have a last-message-sent-time of 0, ensuring that the client will
* send a heartbeat to the server when restarted. Since we drop the received GCM message,
* the client will be disconnected by the invalidation pusher; this heartbeat ensures a
* timely reconnection.
*/
private void handleGcmMessageForUnstartedClient(AndroidClientProxy proxy) {
if (proxy == null) {
// Unknown client; nothing to do.
return;
}
// Client is not started. Open its storage. We are going to use unsafe calls here that
// bypass the normal storage API. This is safe in this context because we hold a lock
// that prevents anyone else from starting this client or accessing its storage. We
// really should not be holding a lock across I/O, but at least this is only local
// file I/O, and we're only writing a few bytes. Additionally, since we currently only
// have one Ticl, we should only ever enter this function if we're not being used for
// anything else.
final String clientKey = proxy.getClientKey();
logger.info("Received message for unloaded client; rewriting state file: %s", clientKey);
// This storage must have been loaded, because we got this proxy from the client manager,
// which always ensures that its entries have that property.
AndroidStorage storageForClient = proxy.getStorage();
PersistentTiclState clientState = decodeTiclState(clientKey, storageForClient);
if (clientState == null) {
// Logging done in decodeTiclState.
return;
}
// Rewrite the last message sent time.
PersistentTiclState newState = PersistentTiclState.newBuilder(clientState)
.setLastMessageSendTimeMs(0).build();
// Serialize the new state.
byte[] newClientState = PersistenceUtils.serializeState(newState, digestFn);
// Write it out.
storageForClient.getPropertiesUnsafe().put(InvalidationClientCore.CLIENT_TOKEN_KEY,
newClientState);
storageForClient.storeUnsafe();
}
private void handleRegistration(Intent intent) {
// Notify the client manager of the updated registration ID
String id = intent.getStringExtra(REGISTER_ID);
clientManager.informRegistrationIdChanged();
numGcmRegistrationForTest.incrementAndGet();
}
private void handleError(Intent intent) {
logger.severe("Unable to perform GCM registration: %s", intent.getStringExtra(ERROR_MESSAGE));
numGcmErrorsForTest.incrementAndGet();
}
/**
* Stops the service if there are no clients in the client manager.
* @param debugInfo short string describing why the check was made
*/
private void stopServiceIfNoClientsRemain(String debugInfo) {
if ((clientManager == null) || clientManager.areAllClientsStopped()) {
logger.info("Stopping AndroidInvalidationService since no clients remain: %s", debugInfo);
stopSelf();
} else {
logger.fine("Not stopping service since %s clients remain (%s)",
clientManager.getClientCount(), debugInfo);
}
}
/**
* Returns the persisted state for the client with key {@code clientKey} in
* {@code storageForClient}, or {@code null} if no valid state could be found.
* <p>
* REQUIRES: {@code storageForClient}.load() has been called successfully.
*/
PersistentTiclState decodeTiclState(final String clientKey, AndroidStorage storageForClient) {
synchronized (lock) {
// Retrieve the serialized state.
final Map<String, byte[]> properties = storageForClient.getPropertiesUnsafe();
byte[] clientStateBytes = TypedUtil.mapGet(properties,
InvalidationClientCore.CLIENT_TOKEN_KEY);
if (clientStateBytes == null) {
logger.warning("No client state found in storage for %s: %s", clientKey,
properties.keySet());
return null;
}
// Deserialize it.
PersistentTiclState clientState =
PersistenceUtils.deserializeState(logger, clientStateBytes, digestFn);
if (clientState == null) {
logger.warning("Invalid client state found in storage for %s", clientKey);
return null;
}
return clientState;
}
}
/**
* Returns whether the client with {@code clientKey} is loaded in the client manager.
*/
public static boolean isLoadedForTest(String clientKey) {
return (getClientManager() != null) && getClientManager().isLoadedForTest(clientKey);
}
}