2 * Copyright 2011 Google Inc.
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
8 * http://www.apache.org/licenses/LICENSE-2.0
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
17 package com.google.ipc.invalidation.ticl.android;
19 import com.google.android.gcm.GCMRegistrar;
20 import com.google.common.base.Preconditions;
21 import com.google.ipc.invalidation.common.CommonProtos2;
22 import com.google.ipc.invalidation.external.client.SystemResources;
23 import com.google.ipc.invalidation.external.client.SystemResources.Logger;
24 import com.google.ipc.invalidation.external.client.SystemResources.NetworkChannel;
25 import com.google.ipc.invalidation.external.client.android.service.AndroidLogger;
26 import com.google.ipc.invalidation.ticl.TestableNetworkChannel;
27 import com.google.ipc.invalidation.util.ExponentialBackoffDelayGenerator;
28 import com.google.protobuf.InvalidProtocolBufferException;
29 import com.google.protos.ipc.invalidation.AndroidChannel.AddressedAndroidMessage;
30 import com.google.protos.ipc.invalidation.AndroidChannel.MajorVersion;
31 import com.google.protos.ipc.invalidation.ChannelCommon.NetworkEndpointId;
32 import com.google.protos.ipc.invalidation.ClientProtocol.Version;
34 import android.accounts.AccountManager;
35 import android.accounts.AccountManagerCallback;
36 import android.accounts.AccountManagerFuture;
37 import android.accounts.AuthenticatorException;
38 import android.accounts.OperationCanceledException;
39 import android.content.Context;
40 import android.net.http.AndroidHttpClient;
41 import android.os.Build;
42 import android.os.Bundle;
43 import android.util.Base64;
45 import org.apache.http.client.HttpClient;
47 import java.io.IOException;
48 import java.util.ArrayList;
49 import java.util.List;
50 import java.util.Random;
51 import java.util.concurrent.ExecutorService;
52 import java.util.concurrent.Executors;
53 import java.util.concurrent.atomic.AtomicInteger;
57 * Provides a bidirectional channel for Android devices using GCM (data center to device) and the
58 * Android HTTP frontend (device to data center). The android channel computes a network endpoint id
59 * based upon the GCM registration ID for the containing application ID and the client key of the
60 * client using the channel. If an attempt is made to send messages on the channel before a GCM
61 * registration ID has been assigned, it will temporarily buffer the outbound messages and send them
62 * when the registration ID is eventually assigned.
65 class AndroidChannel extends AndroidChannelBase implements TestableNetworkChannel {
67 private static final Logger logger = AndroidLogger.forTag("InvChannel");
70 * The maximum number of outbound messages that will be buffered while waiting for async delivery
71 * of the GCM registration ID and authentication token. The general flow for a new client is
72 * that an 'initialize' message is sent (and resent on a timed interval) until a session token is
73 * sent back and this just prevents accumulation a large number of initialize messages (and
74 * consuming memory) in a long delay or failure scenario.
76 private static final int MAX_BUFFERED_MESSAGES = 10;
78 /** The channel version expected by this channel implementation */
80 static final Version CHANNEL_VERSION =
81 CommonProtos2.newVersion(MajorVersion.INITIAL.getNumber(), 0);
83 /** How to long to wait initially before retrying a failed auth token request. */
84 private static final int INITIAL_AUTH_TOKEN_RETRY_DELAY_MS = 1 * 1000; // 1 second
86 /** Largest exponential backoff factor to use for auth token retries. */
87 private static final int MAX_AUTH_TOKEN_RETRY_FACTOR = 60 * 60 * 12; // 12 hours
89 /** Number of C2DM messages for unknown clients. */
91 static final AtomicInteger numGcmInvalidClients = new AtomicInteger();
93 /** Invalidation client proxy using the channel. */
94 private final AndroidClientProxy proxy;
96 /** Android context used to retrieve registration IDs. */
97 private final Context context;
99 /** System resources for this channel */
100 private SystemResources resources;
103 * When set, this registration ID is used rather than checking
104 * {@link GCMRegistrar#getRegistrationId}. It should not be read directly: call
105 * {@link #getRegistrationId} instead.
107 private String registrationIdForTest;
109 /** The authentication token that can be used in channel requests to the server */
110 private String authToken;
112 /** Listener for network events. */
113 private NetworkChannel.NetworkListener listener;
115 // TODO: Add code to track time of last network activity (in either direction)
116 // so inactive clients can be detected and periodically flushed from memory.
119 * List that holds outbound messages while waiting for a registration ID. Allocated on
120 * demand since it is only needed when there is no registration id.
122 private List<byte[]> pendingMessages = null;
125 * Testing only flag that disables interactions with the AcccountManager for mock tests.
127 static boolean disableAccountManager = false;
130 * Returns the default HTTP client to use for requests from the channel based upon its execution
131 * context. The format of the User-Agent string is "<application-pkg>(<android-release>)".
133 static AndroidHttpClient getDefaultHttpClient(Context context) {
134 return AndroidHttpClient.newInstance(
135 context.getApplicationInfo().className + "(" + Build.VERSION.RELEASE + ")");
138 /** Executor used for HTTP calls to send messages to . */
140 final ExecutorService scheduler = Executors.newSingleThreadExecutor();
143 * Creates a new AndroidChannel.
145 * @param proxy the client proxy associated with the channel
146 * @param httpClient the HTTP client to use to communicate with the Android invalidation frontend
147 * @param context Android context
149 AndroidChannel(AndroidClientProxy proxy, HttpClient httpClient, Context context) {
150 super(httpClient, proxy.getAuthType(), proxy.getService().getChannelUrl());
151 this.proxy = Preconditions.checkNotNull(proxy);
152 this.context = Preconditions.checkNotNull(context);
156 * Returns the GCM registration ID associated with the channel. Checks the {@link GCMRegistrar}
157 * unless {@link #setRegistrationIdForTest} has been called.
159 String getRegistrationId() {
160 String registrationId = (registrationIdForTest != null) ? registrationIdForTest :
161 GCMRegistrar.getRegistrationId(context);
163 // Callers check for null registration ID rather than "null or empty", so replace empty strings
165 if ("".equals(registrationId)) {
166 registrationId = null;
168 return registrationId;
171 /** Returns the client proxy that is using the channel */
172 AndroidClientProxy getClientProxy() {
177 * Retrieves the list of pending messages in the channel (or {@code null} if there are none).
179 List<byte[]> getPendingMessages() {
180 return pendingMessages;
185 protected String getAuthToken() {
189 /** A completion callback for an asynchronous operation. */
190 interface CompletionCallback {
195 /** An asynchronous runnable that calls a completion callback. */
196 interface AsyncRunnable {
197 void run(CompletionCallback callback);
201 * A utility function to run an async runnable with exponential backoff after failures.
202 * @param runnable the asynchronous runnable.
203 * @param scheduler used to schedule retries.
204 * @param backOffGenerator a backoff generator that returns how to long to wait between retries.
205 * The client must pass a new instance or reset the backoff generator before calling this
209 static void retryUntilSuccessWithBackoff(final SystemResources.Scheduler scheduler,
210 final ExponentialBackoffDelayGenerator backOffGenerator, final AsyncRunnable runnable) {
211 logger.fine("Running %s", runnable);
212 runnable.run(new CompletionCallback() {
214 public void success() {
215 logger.fine("%s succeeded", runnable);
219 public void failure() {
220 int nextDelay = backOffGenerator.getNextDelay();
221 logger.fine("%s failed, retrying after %s ms", nextDelay);
222 scheduler.schedule(nextDelay, new Runnable() {
225 retryUntilSuccessWithBackoff(scheduler, backOffGenerator, runnable);
233 * Initiates acquisition of an authentication token that can be used with channel HTTP requests.
234 * Android token acquisition is asynchronous since it may require HTTP interactions with the
235 * ClientLogin servers to obtain the token.
237 @SuppressWarnings("deprecation")
239 synchronized void requestAuthToken(final CompletionCallback callback) {
240 // If there is currently no token and no pending request, initiate one.
241 if (disableAccountManager) {
242 logger.fine("Not requesting auth token since account manager disabled");
245 if (authToken == null) {
246 // Ask the AccountManager for the token, with a pending future to store it on the channel
248 final AndroidChannel theChannel = this;
249 AccountManager accountManager = AccountManager.get(proxy.getService());
250 accountManager.getAuthToken(proxy.getAccount(), proxy.getAuthType(), true,
251 new AccountManagerCallback<Bundle>() {
253 public void run(AccountManagerFuture<Bundle> future) {
255 Bundle result = future.getResult();
256 if (result.containsKey(AccountManager.KEY_INTENT)) {
257 // TODO: Handle case where there are no authentication
258 // credentials associated with the client account
259 logger.severe("Token acquisition requires user login");
260 callback.success(); // No further retries.
262 setAuthToken(result.getString(AccountManager.KEY_AUTHTOKEN));
263 } catch (OperationCanceledException exception) {
264 logger.warning("Auth cancelled", exception);
265 // TODO: Send error to client
266 } catch (AuthenticatorException exception) {
267 logger.warning("Auth error acquiring token", exception);
269 } catch (IOException exception) {
270 logger.warning("IO Exception acquiring token", exception);
276 logger.fine("Auth token request already pending");
282 * Updates the registration ID for this channel, flushing any pending outbound messages that
283 * were waiting for an id.
285 synchronized void setRegistrationIdForTest(String updatedRegistrationId) {
286 // Synchronized to avoid concurrent access to pendingMessages
287 if (registrationIdForTest != updatedRegistrationId) {
288 logger.fine("Setting registration ID for test for client key %s", proxy.getClientKey());
289 registrationIdForTest = updatedRegistrationId;
290 informRegistrationIdChanged();
295 * Call to inform the Android channel that the registration ID has changed. May kick loose some
296 * pending outbound messages.
298 synchronized void informRegistrationIdChanged() {
303 * Sets the authentication token to use for HTTP requests to the invalidation frontend and
304 * flushes any pending messages (if appropriate).
306 * @param authToken the authentication token
308 synchronized void setAuthToken(String authToken) {
309 logger.fine("Auth token received fo %s", proxy.getClientKey());
310 this.authToken = authToken;
315 public void setListener(NetworkChannel.NetworkListener listener) {
316 this.listener = Preconditions.checkNotNull(listener);
320 public synchronized void sendMessage(final byte[] outgoingMessage) {
321 // synchronized to avoid concurrent access to pendingMessages
323 // If there is no registration id, we cannot compute a network endpoint id. If there is no
324 // auth token, then we cannot authenticate the send request. Defer sending messages until both
326 String registrationId = getRegistrationId();
327 if ((registrationId == null) || (authToken == null)) {
328 if (pendingMessages == null) {
329 pendingMessages = new ArrayList<byte[]>();
331 logger.fine("Buffering outbound message: hasRegId: %s, hasAuthToken: %s",
332 registrationId != null, authToken != null);
333 if (pendingMessages.size() < MAX_BUFFERED_MESSAGES) {
334 pendingMessages.add(outgoingMessage);
336 logger.warning("Exceeded maximum number of buffered messages, dropping outbound message");
341 // Do the actual HTTP I/O on a separate thread, since we may be called on the main
342 // thread for the application.
343 scheduler.execute(new Runnable() {
346 if (resources.isStarted()) {
347 deliverOutboundMessage(outgoingMessage);
349 logger.warning("Dropping outbound messages because resources are stopped");
356 * Called when either the registration or authentication token has been received to check to
357 * see if channel is ready for network activity. If so, the status receiver is notified and
358 * any pending messages are flushed.
360 private synchronized void checkReady() {
361 String registrationId = getRegistrationId();
362 if ((registrationId != null) && (authToken != null)) {
364 logger.fine("Enabling network endpoint: %s", getWebEncodedEndpointId());
366 // Notify the network listener that we are now network enabled
367 if (listener != null) {
368 listener.onOnlineStatusChange(true);
371 // Flush any pending messages
372 if (pendingMessages != null) {
373 for (byte [] message : pendingMessages) {
374 sendMessage(message);
376 pendingMessages = null;
381 void receiveMessage(byte[] inboundMessage) {
383 AddressedAndroidMessage addrMessage = AddressedAndroidMessage.parseFrom(inboundMessage);
384 tryDeliverMessage(addrMessage);
385 } catch (InvalidProtocolBufferException exception) {
386 logger.severe("Failed decoding AddressedAndroidMessage as C2DM payload", exception);
391 * Delivers the payload of {@code addrMessage} to the {@code callbackReceiver} if the client key
392 * of the addressed message matches that of the {@link #proxy}.
395 protected void tryDeliverMessage(AddressedAndroidMessage addrMessage) {
396 String clientKey = proxy.getClientKey();
397 if (addrMessage.getClientKey().equals(clientKey)) {
398 logger.fine("Deliver to %s message %s", clientKey, addrMessage);
399 listener.onMessageReceived(addrMessage.getMessage().toByteArray());
401 logger.severe("Not delivering message due to key mismatch: %s vs %s",
402 addrMessage.getClientKey(), clientKey);
403 numGcmInvalidClients.incrementAndGet();
407 /** Returns the web encoded version of the channel network endpoint ID for HTTP requests. */
409 protected String getWebEncodedEndpointId() {
410 NetworkEndpointId networkEndpointId = getNetworkId();
411 return Base64.encodeToString(networkEndpointId.toByteArray(),
412 Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
416 public void setSystemResources(SystemResources resources) {
417 this.resources = resources;
419 // Prefetch the auth sub token. Since this might require an HTTP round trip, we do this
420 // as soon as the resources are available.
421 // TODO: Find a better place to fetch the auth token; this method
422 // doesn't sound like one that should be doing work.
423 retryUntilSuccessWithBackoff(resources.getInternalScheduler(),
424 new ExponentialBackoffDelayGenerator(
425 new Random(), INITIAL_AUTH_TOKEN_RETRY_DELAY_MS, MAX_AUTH_TOKEN_RETRY_FACTOR),
426 new AsyncRunnable() {
428 public void run(CompletionCallback callback) {
429 requestAuthToken(callback);
435 public NetworkEndpointId getNetworkIdForTest() {
436 return getNetworkId();
440 protected Logger getLogger() {
441 return resources.getLogger();
444 private NetworkEndpointId getNetworkId() {
445 String registrationId = getRegistrationId();
446 return CommonProtos2.newAndroidEndpointId(registrationId, proxy.getClientKey(),
447 proxy.getService().getPackageName(), CHANNEL_VERSION);
450 ExecutorService getExecutorServiceForTest() {
455 void setHttpClientForTest(HttpClient client) {
456 if (this.httpClient instanceof AndroidHttpClient) {
457 // Release the previous client if any.
458 ((AndroidHttpClient) this.httpClient).close();
460 super.setHttpClientForTest(client);