/* * 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.android.gcm.GCMRegistrar; import com.google.common.base.Preconditions; import com.google.ipc.invalidation.common.CommonProtos2; import com.google.ipc.invalidation.external.client.SystemResources; import com.google.ipc.invalidation.external.client.SystemResources.Logger; import com.google.ipc.invalidation.external.client.SystemResources.NetworkChannel; import com.google.ipc.invalidation.external.client.android.service.AndroidLogger; import com.google.ipc.invalidation.ticl.TestableNetworkChannel; import com.google.ipc.invalidation.util.ExponentialBackoffDelayGenerator; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protos.ipc.invalidation.AndroidChannel.AddressedAndroidMessage; import com.google.protos.ipc.invalidation.AndroidChannel.MajorVersion; import com.google.protos.ipc.invalidation.Channel.NetworkEndpointId; import com.google.protos.ipc.invalidation.ClientProtocol.Version; import android.accounts.AccountManager; import android.accounts.AccountManagerCallback; import android.accounts.AccountManagerFuture; import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; import android.content.Context; import android.net.http.AndroidHttpClient; import android.os.Build; import android.os.Bundle; import android.util.Base64; import org.apache.http.client.HttpClient; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; /** * Provides a bidirectional channel for Android devices using GCM (data center to device) and the * Android HTTP frontend (device to data center). The android channel computes a network endpoint id * based upon the GCM registration ID for the containing application ID and the client key of the * client using the channel. If an attempt is made to send messages on the channel before a GCM * registration ID has been assigned, it will temporarily buffer the outbound messages and send them * when the registration ID is eventually assigned. * */ class AndroidChannel extends AndroidChannelBase implements TestableNetworkChannel { private static final Logger logger = AndroidLogger.forTag("InvChannel"); /** * The maximum number of outbound messages that will be buffered while waiting for async delivery * of the GCM registration ID and authentication token. The general flow for a new client is * that an 'initialize' message is sent (and resent on a timed interval) until a session token is * sent back and this just prevents accumulation a large number of initialize messages (and * consuming memory) in a long delay or failure scenario. */ private static final int MAX_BUFFERED_MESSAGES = 10; /** The channel version expected by this channel implementation */ static final Version CHANNEL_VERSION = CommonProtos2.newVersion(MajorVersion.INITIAL.getNumber(), 0); /** How to long to wait initially before retrying a failed auth token request. */ private static final int INITIAL_AUTH_TOKEN_RETRY_DELAY_MS = 1 * 1000; // 1 second /** Largest exponential backoff factor to use for auth token retries. */ private static final int MAX_AUTH_TOKEN_RETRY_FACTOR = 60 * 60 * 12; // 12 hours /** Number of C2DM messages for unknown clients. */ static final AtomicInteger numGcmInvalidClients = new AtomicInteger(); /** Invalidation client proxy using the channel. */ private final AndroidClientProxy proxy; /** Android context used to retrieve registration IDs. */ private final Context context; /** System resources for this channel */ private SystemResources resources; /** * When set, this registration ID is used rather than checking * {@link GCMRegistrar#getRegistrationId}. It should not be read directly: call * {@link #getRegistrationId} instead. */ private String registrationIdForTest; /** The authentication token that can be used in channel requests to the server */ private String authToken; /** Listener for network events. */ private NetworkChannel.NetworkListener listener; // TODO: Add code to track time of last network activity (in either direction) // so inactive clients can be detected and periodically flushed from memory. /** * List that holds outbound messages while waiting for a registration ID. Allocated on * demand since it is only needed when there is no registration id. */ private List<byte[]> pendingMessages = null; /** * Testing only flag that disables interactions with the AcccountManager for mock tests. */ static boolean disableAccountManager = false; /** * Returns the default HTTP client to use for requests from the channel based upon its execution * context. The format of the User-Agent string is "<application-pkg>(<android-release>)". */ static AndroidHttpClient getDefaultHttpClient(Context context) { return AndroidHttpClient.newInstance( context.getApplicationInfo().className + "(" + Build.VERSION.RELEASE + ")"); } /** Executor used for HTTP calls to send messages to . */ final ExecutorService scheduler = Executors.newSingleThreadExecutor(); /** * Creates a new AndroidChannel. * * @param proxy the client proxy associated with the channel * @param httpClient the HTTP client to use to communicate with the Android invalidation frontend * @param context Android context */ AndroidChannel(AndroidClientProxy proxy, HttpClient httpClient, Context context) { super(httpClient, proxy.getAuthType(), proxy.getService().getChannelUrl()); this.proxy = Preconditions.checkNotNull(proxy); this.context = Preconditions.checkNotNull(context); } /** * Returns the GCM registration ID associated with the channel. Checks the {@link GCMRegistrar} * unless {@link #setRegistrationIdForTest} has been called. */ String getRegistrationId() { String registrationId = (registrationIdForTest != null) ? registrationIdForTest : GCMRegistrar.getRegistrationId(context); // Callers check for null registration ID rather than "null or empty", so replace empty strings // with null here. if ("".equals(registrationId)) { registrationId = null; } return registrationId; } /** Returns the client proxy that is using the channel */ AndroidClientProxy getClientProxy() { return proxy; } /** * Retrieves the list of pending messages in the channel (or {@code null} if there are none). */ List<byte[]> getPendingMessages() { return pendingMessages; } @Override protected String getAuthToken() { return authToken; } /** A completion callback for an asynchronous operation. */ interface CompletionCallback { void success(); void failure(); } /** An asynchronous runnable that calls a completion callback. */ interface AsyncRunnable { void run(CompletionCallback callback); } /** * A utility function to run an async runnable with exponential backoff after failures. * @param runnable the asynchronous runnable. * @param scheduler used to schedule retries. * @param backOffGenerator a backoff generator that returns how to long to wait between retries. * The client must pass a new instance or reset the backoff generator before calling this * method. */ static void retryUntilSuccessWithBackoff(final SystemResources.Scheduler scheduler, final ExponentialBackoffDelayGenerator backOffGenerator, final AsyncRunnable runnable) { logger.fine("Running %s", runnable); runnable.run(new CompletionCallback() { @Override public void success() { logger.fine("%s succeeded", runnable); } @Override public void failure() { int nextDelay = backOffGenerator.getNextDelay(); logger.fine("%s failed, retrying after %s ms", nextDelay); scheduler.schedule(nextDelay, new Runnable() { @Override public void run() { retryUntilSuccessWithBackoff(scheduler, backOffGenerator, runnable); } }); } }); } /** * Initiates acquisition of an authentication token that can be used with channel HTTP requests. * Android token acquisition is asynchronous since it may require HTTP interactions with the * ClientLogin servers to obtain the token. */ @SuppressWarnings("deprecation") synchronized void requestAuthToken(final CompletionCallback callback) { // If there is currently no token and no pending request, initiate one. if (disableAccountManager) { logger.fine("Not requesting auth token since account manager disabled"); return; } if (authToken == null) { // Ask the AccountManager for the token, with a pending future to store it on the channel // once available. final AndroidChannel theChannel = this; AccountManager accountManager = AccountManager.get(proxy.getService()); accountManager.getAuthToken(proxy.getAccount(), proxy.getAuthType(), true, new AccountManagerCallback<Bundle>() { @Override public void run(AccountManagerFuture<Bundle> future) { try { Bundle result = future.getResult(); if (result.containsKey(AccountManager.KEY_INTENT)) { // TODO: Handle case where there are no authentication // credentials associated with the client account logger.severe("Token acquisition requires user login"); callback.success(); // No further retries. } setAuthToken(result.getString(AccountManager.KEY_AUTHTOKEN)); } catch (OperationCanceledException exception) { logger.warning("Auth cancelled", exception); // TODO: Send error to client } catch (AuthenticatorException exception) { logger.warning("Auth error acquiring token", exception); callback.failure(); } catch (IOException exception) { logger.warning("IO Exception acquiring token", exception); callback.failure(); } } }, null); } else { logger.fine("Auth token request already pending"); callback.success(); } } /* * Updates the registration ID for this channel, flushing any pending outbound messages that * were waiting for an id. */ synchronized void setRegistrationIdForTest(String updatedRegistrationId) { // Synchronized to avoid concurrent access to pendingMessages if (registrationIdForTest != updatedRegistrationId) { logger.fine("Setting registration ID for test for client key %s", proxy.getClientKey()); registrationIdForTest = updatedRegistrationId; informRegistrationIdChanged(); } } /** * Call to inform the Android channel that the registration ID has changed. May kick loose some * pending outbound messages. */ synchronized void informRegistrationIdChanged() { checkReady(); } /** * Sets the authentication token to use for HTTP requests to the invalidation frontend and * flushes any pending messages (if appropriate). * * @param authToken the authentication token */ synchronized void setAuthToken(String authToken) { logger.fine("Auth token received fo %s", proxy.getClientKey()); this.authToken = authToken; checkReady(); } @Override public void setListener(NetworkChannel.NetworkListener listener) { this.listener = Preconditions.checkNotNull(listener); } @Override public synchronized void sendMessage(final byte[] outgoingMessage) { // synchronized to avoid concurrent access to pendingMessages // If there is no registration id, we cannot compute a network endpoint id. If there is no // auth token, then we cannot authenticate the send request. Defer sending messages until both // are received. String registrationId = getRegistrationId(); if ((registrationId == null) || (authToken == null)) { if (pendingMessages == null) { pendingMessages = new ArrayList<byte[]>(); } logger.fine("Buffering outbound message: hasRegId: %s, hasAuthToken: %s", registrationId != null, authToken != null); if (pendingMessages.size() < MAX_BUFFERED_MESSAGES) { pendingMessages.add(outgoingMessage); } else { logger.warning("Exceeded maximum number of buffered messages, dropping outbound message"); } return; } // Do the actual HTTP I/O on a separate thread, since we may be called on the main // thread for the application. scheduler.execute(new Runnable() { @Override public void run() { if (resources.isStarted()) { deliverOutboundMessage(outgoingMessage); } else { logger.warning("Dropping outbound messages because resources are stopped"); } } }); } /** * Called when either the registration or authentication token has been received to check to * see if channel is ready for network activity. If so, the status receiver is notified and * any pending messages are flushed. */ private synchronized void checkReady() { String registrationId = getRegistrationId(); if ((registrationId != null) && (authToken != null)) { logger.fine("Enabling network endpoint: %s", getWebEncodedEndpointId()); // Notify the network listener that we are now network enabled if (listener != null) { listener.onOnlineStatusChange(true); } // Flush any pending messages if (pendingMessages != null) { for (byte [] message : pendingMessages) { sendMessage(message); } pendingMessages = null; } } } void receiveMessage(byte[] inboundMessage) { try { AddressedAndroidMessage addrMessage = AddressedAndroidMessage.parseFrom(inboundMessage); tryDeliverMessage(addrMessage); } catch (InvalidProtocolBufferException exception) { logger.severe("Failed decoding AddressedAndroidMessage as C2DM payload", exception); } } /** * Delivers the payload of {@code addrMessage} to the {@code callbackReceiver} if the client key * of the addressed message matches that of the {@link #proxy}. */ @Override protected void tryDeliverMessage(AddressedAndroidMessage addrMessage) { String clientKey = proxy.getClientKey(); if (addrMessage.getClientKey().equals(clientKey)) { logger.fine("Deliver to %s message %s", clientKey, addrMessage); listener.onMessageReceived(addrMessage.getMessage().toByteArray()); } else { logger.severe("Not delivering message due to key mismatch: %s vs %s", addrMessage.getClientKey(), clientKey); numGcmInvalidClients.incrementAndGet(); } } /** Returns the web encoded version of the channel network endpoint ID for HTTP requests. */ @Override protected String getWebEncodedEndpointId() { NetworkEndpointId networkEndpointId = getNetworkId(); return Base64.encodeToString(networkEndpointId.toByteArray(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); } @Override public void setSystemResources(SystemResources resources) { this.resources = resources; // Prefetch the auth sub token. Since this might require an HTTP round trip, we do this // as soon as the resources are available. // TODO: Find a better place to fetch the auth token; this method // doesn't sound like one that should be doing work. retryUntilSuccessWithBackoff(resources.getInternalScheduler(), new ExponentialBackoffDelayGenerator( new Random(), INITIAL_AUTH_TOKEN_RETRY_DELAY_MS, MAX_AUTH_TOKEN_RETRY_FACTOR), new AsyncRunnable() { @Override public void run(CompletionCallback callback) { requestAuthToken(callback); } }); } @Override public NetworkEndpointId getNetworkIdForTest() { return getNetworkId(); } @Override protected Logger getLogger() { return resources.getLogger(); } private NetworkEndpointId getNetworkId() { String registrationId = getRegistrationId(); return CommonProtos2.newAndroidEndpointId(registrationId, proxy.getClientKey(), proxy.getService().getPackageName(), CHANNEL_VERSION); } ExecutorService getExecutorServiceForTest() { return scheduler; } @Override void setHttpClientForTest(HttpClient client) { if (this.httpClient instanceof AndroidHttpClient) { // Release the previous client if any. ((AndroidHttpClient) this.httpClient).close(); } super.setHttpClientForTest(client); } }