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