| /* |
| * 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); |
| } |
| } |