| /* |
| * 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.android2.channel; |
| |
| import com.google.android.gcm.GCMRegistrar; |
| import com.google.ipc.invalidation.external.client.SystemResources.Logger; |
| import com.google.ipc.invalidation.external.client.android.service.AndroidLogger; |
| import com.google.ipc.invalidation.ticl.android2.ProtocolIntents; |
| import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.AuthTokenConstants; |
| import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.HttpConstants; |
| import com.google.ipc.invalidation.ticl.proto.AndroidService.AndroidNetworkSendRequest; |
| import com.google.ipc.invalidation.ticl.proto.ChannelCommon.NetworkEndpointId; |
| import com.google.ipc.invalidation.ticl.proto.CommonProtos; |
| import com.google.ipc.invalidation.util.Preconditions; |
| import com.google.ipc.invalidation.util.ProtoWrapper.ValidationException; |
| |
| import android.app.IntentService; |
| import android.app.PendingIntent; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.os.Build; |
| import android.util.Base64; |
| |
| import java.io.BufferedReader; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.net.HttpURLConnection; |
| import java.net.MalformedURLException; |
| import java.net.ProtocolException; |
| import java.net.URL; |
| import java.util.Arrays; |
| |
| |
| /** |
| * Service that sends messages to the data center using HTTP POSTs authenticated as a Google |
| * account. |
| * <p> |
| * Messages are sent as byte-serialized {@code ClientToServerMessage} protocol buffers. |
| * Additionally, the POST requests echo the latest value of the echo token received on C2DM |
| * messages from the data center. |
| * |
| */ |
| public class AndroidMessageSenderService extends IntentService { |
| /* This class is public so that it can be instantiated by the Android runtime. */ |
| |
| /** |
| * A prefix on the "auth token type" that indicates we're using an OAuth2 token to authenticate. |
| */ |
| private static final String OAUTH2_TOKEN_TYPE_PREFIX = "oauth2:"; |
| |
| /** |
| * Client key used in network endpoint ids. We only have one client at present, so there is no |
| * need for a key. |
| */ |
| private static final String NO_CLIENT_KEY = ""; |
| |
| /** An override of the URL, for testing. */ |
| private static String channelUrlForTest = null; |
| |
| private final Logger logger = AndroidLogger.forTag("MsgSenderSvc"); |
| |
| /** The last message sent, for tests. */ |
| public static byte[] lastTiclMessageForTest = null; |
| |
| public AndroidMessageSenderService() { |
| super("AndroidNetworkService"); |
| setIntentRedelivery(true); |
| } |
| |
| @Override |
| public void onCreate() { |
| super.onCreate(); |
| |
| // HTTP connection reuse was buggy pre-Froyo, so disable it on those platforms. |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO) { |
| System.setProperty("http.keepAlive", "false"); |
| } |
| } |
| |
| @Override |
| protected void onHandleIntent(Intent intent) { |
| if (intent == null) { |
| return; |
| } |
| |
| if (intent.hasExtra(ProtocolIntents.OUTBOUND_MESSAGE_KEY)) { |
| // Request from the Ticl service to send a message. |
| handleOutboundMessage(intent.getByteArrayExtra(ProtocolIntents.OUTBOUND_MESSAGE_KEY)); |
| } else if (intent.hasExtra(AndroidChannelConstants.AuthTokenConstants.EXTRA_AUTH_TOKEN)) { |
| // Reply from the app with an auth token and a message to send. |
| handleAuthTokenResponse(intent); |
| } else if (intent.hasExtra(AndroidChannelConstants.MESSAGE_SENDER_SVC_GCM_REGID_CHANGE)) { |
| handleGcmRegIdChange(); |
| } else { |
| logger.warning("Ignoring intent: %s", intent); |
| } |
| } |
| |
| /** |
| * Handles a request to send a message to the data center. Validates the message and sends |
| * an intent to the application to obtain an auth token to use on the HTTP request to the |
| * data center. |
| */ |
| private void handleOutboundMessage(byte[] sendRequestBytes) { |
| // Parse and validate the send request. |
| final AndroidNetworkSendRequest sendRequest; |
| try { |
| sendRequest = AndroidNetworkSendRequest.parseFrom(sendRequestBytes); |
| } catch (ValidationException exception) { |
| logger.warning("Invalid AndroidNetworkSendRequest from %s: %s", |
| sendRequestBytes, exception); |
| return; |
| } |
| |
| // Request an auth token from the application to use when sending the message. |
| byte[] message = sendRequest.getMessage().getByteArray(); |
| requestAuthTokenForMessage(message, null); |
| } |
| |
| /** |
| * Requests an auth token from the application to use to send {@code message} to the data |
| * center. |
| * <p> |
| * If not {@code null}, {@code invalidAuthToken} is an auth token that was previously |
| * found to be invalid. The intent sent to the application to request the new token will include |
| * the invalid token so that the application can invalidate it in the {@code AccountManager}. |
| */ |
| private void requestAuthTokenForMessage(byte[] message, String invalidAuthToken) { |
| /* |
| * Send an intent requesting an auth token. This intent will contain a pending intent |
| * that the recipient can use to send back the token (by attaching the token as a string |
| * extra). That pending intent will also contain the message that we were just asked to send, |
| * so that it will be echoed back to us with the token. This avoids our having to persist |
| * the message while waiting for the token. |
| */ |
| |
| // This is the intent that the application will send back to us (the pending intent allows |
| // it to send the intent). It contains the stored message. We require that it be delivered to |
| // this class only, as a security check. |
| Intent tokenResponseIntent = new Intent(this, getClass()); |
| tokenResponseIntent.putExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE, message); |
| |
| // If we have an invalid auth token, set a bit in the intent that the application will send |
| // back to us. This will let us know that it is a retry; if sending subsequently fails again, |
| // we will not do any further retries. |
| tokenResponseIntent.putExtra(AuthTokenConstants.EXTRA_IS_RETRY, invalidAuthToken != null); |
| |
| // The pending intent allows the application to send us the tokenResponseIntent. |
| PendingIntent pendingIntent = PendingIntent.getService( |
| this, Arrays.hashCode(message), tokenResponseIntent, PendingIntent.FLAG_ONE_SHOT); |
| |
| // We send the pending intent as an extra in a normal intent to the application. We require that |
| // the intent be delivered only within this package, as a security check. The application must |
| // define a service with an intent filter that matches the ACTION_REQUEST_AUTH_TOKEN in order |
| // to receive this intent. |
| Intent requestTokenIntent = new Intent(AuthTokenConstants.ACTION_REQUEST_AUTH_TOKEN); |
| requestTokenIntent.setPackage(getPackageName()); |
| requestTokenIntent.putExtra(AuthTokenConstants.EXTRA_PENDING_INTENT, pendingIntent); |
| if (invalidAuthToken != null) { |
| requestTokenIntent.putExtra(AuthTokenConstants.EXTRA_INVALIDATE_AUTH_TOKEN, invalidAuthToken); |
| } |
| startService(requestTokenIntent); |
| } |
| |
| /** |
| * Handles an intent received from the application that contains both a message to send and |
| * an auth token and type to use when sending it. This is called when the reply to the intent |
| * sent in {@link #requestAuthTokenForMessage(byte[], String)} is received. |
| */ |
| private void handleAuthTokenResponse(Intent intent) { |
| if (!(intent.hasExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE) |
| && intent.hasExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN) |
| && intent.hasExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN_TYPE) |
| && intent.hasExtra(AuthTokenConstants.EXTRA_IS_RETRY))) { |
| logger.warning("auth-token-response intent missing fields: %s, %s", |
| intent, intent.getExtras()); |
| return; |
| } |
| boolean isRetryForInvalidAuthToken = |
| intent.getBooleanExtra(AuthTokenConstants.EXTRA_IS_RETRY, false); |
| deliverOutboundMessage( |
| intent.getByteArrayExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE), |
| intent.getStringExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN), |
| intent.getStringExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN_TYPE), |
| isRetryForInvalidAuthToken); |
| } |
| |
| /** |
| * Sends {@code outgoingMessage} to the data center as a serialized ClientToServerMessage using an |
| * HTTP POST. |
| * <p> |
| * If the HTTP POST fails due to an authentication failure and this is not a retry for an invalid |
| * auth token ({@code isRetryForInvalidAuthToken} is {@code false}), then it will call |
| * {@link #requestAuthTokenForMessage(byte[], String)} with {@code authToken} to invalidate the |
| * token and retry. |
| * |
| * @param authToken the auth token to use in the HTTP POST |
| * @param authTokenType the type of the auth token |
| */ |
| private void deliverOutboundMessage(byte[] outgoingMessage, String authToken, |
| String authTokenType, boolean isRetryForInvalidAuthToken) { |
| NetworkEndpointId networkEndpointId = getNetworkEndpointId(this, logger); |
| if (networkEndpointId == null) { |
| // No GCM registration; buffer the message to send when we become registered. |
| logger.info("Buffering message to the data center: no GCM registration id"); |
| AndroidChannelPreferences.bufferMessage(this, outgoingMessage); |
| return; |
| } |
| logger.fine("Delivering outbound message: %s bytes", outgoingMessage.length); |
| lastTiclMessageForTest = outgoingMessage; |
| URL url = null; |
| HttpURLConnection urlConnection = null; |
| try { |
| // Open the connection. |
| boolean isOAuth2Token = authTokenType.startsWith(OAUTH2_TOKEN_TYPE_PREFIX); |
| url = buildUrl(isOAuth2Token ? null : authTokenType, networkEndpointId); |
| urlConnection = createUrlConnectionForPost(this, url, authToken, isOAuth2Token); |
| |
| // We are seeing EOFException errors when reusing connections. Request that the connection is |
| // closed on response to work around this issue. Client-to-server messages are batched and |
| // infrequent so there isn't much benefit in connection reuse here. |
| urlConnection.setRequestProperty("Connection", "close"); |
| urlConnection.setFixedLengthStreamingMode(outgoingMessage.length); |
| urlConnection.connect(); |
| |
| // Write the outgoing message. |
| urlConnection.getOutputStream().write(outgoingMessage); |
| |
| // Consume all of the response. We do not do anything with the response (except log it for |
| // non-200 response codes), and do not expect any, but certain versions of the Apache HTTP |
| // library have a bug that causes connections to leak when the response is not fully consumed; |
| // out of sheer paranoia, we do the same thing here. |
| String response = readCompleteStream(urlConnection.getInputStream()); |
| |
| // Retry authorization failures and log other non-200 response codes. |
| final int responseCode = urlConnection.getResponseCode(); |
| switch (responseCode) { |
| case HttpURLConnection.HTTP_OK: |
| case HttpURLConnection.HTTP_NO_CONTENT: |
| break; |
| case HttpURLConnection.HTTP_UNAUTHORIZED: |
| if (!isRetryForInvalidAuthToken) { |
| // If we had an auth failure and this is not a retry of an auth failure, then ask the |
| // application to invalidate authToken and give us a new one with which to retry. We |
| // check that this attempt was not a retry to avoid infinite loops if authorization |
| // always fails. |
| requestAuthTokenForMessage(outgoingMessage, authToken); |
| } |
| break; |
| default: |
| logger.warning("Unexpected response code %s for HTTP POST to %s; response = %s", |
| responseCode, url, response); |
| } |
| } catch (MalformedURLException exception) { |
| logger.warning("Malformed URL: %s", exception); |
| } catch (IOException exception) { |
| logger.warning("IOException sending to the data center (%s): %s", url, exception); |
| } finally { |
| if (urlConnection != null) { |
| urlConnection.disconnect(); |
| } |
| } |
| } |
| |
| /** |
| * Handles a change in the GCM registration id by sending the buffered client message (if any) |
| * to the data center. |
| */ |
| private void handleGcmRegIdChange() { |
| byte[] bufferedMessage = AndroidChannelPreferences.takeBufferedMessage(this); |
| if (bufferedMessage != null) { |
| // Rejoin the start of the code path that handles sending outbound messages. |
| requestAuthTokenForMessage(bufferedMessage, null); |
| } |
| } |
| |
| /** |
| * Returns a URL to use to send a message to the data center. |
| * |
| * @param gaiaServiceId Gaia service for which the request will be authenticated (when using a |
| * GoogleLogin token), or {@code null} when using an OAuth2 token. |
| * @param networkEndpointId network id of the client |
| */ |
| private static URL buildUrl(String gaiaServiceId, NetworkEndpointId networkEndpointId) |
| throws MalformedURLException { |
| StringBuilder urlBuilder = new StringBuilder(); |
| |
| // Build base URL that targets the inbound request service with the encoded network endpoint |
| // id. |
| urlBuilder.append((channelUrlForTest != null) ? channelUrlForTest : HttpConstants.CHANNEL_URL); |
| urlBuilder.append(HttpConstants.REQUEST_URL); |
| |
| // TODO: We should be sending a ClientGatewayMessage in the request body |
| // instead of appending the client's network endpoint id to the request URL. Once we do that, we |
| // should use a UriBuilder to build up a structured Uri object instead of the brittle string |
| // concatenation we're doing below. |
| urlBuilder.append(base64Encode(networkEndpointId.toByteArray())); |
| |
| // Add query parameter indicating the service to authenticate against |
| if (gaiaServiceId != null) { |
| urlBuilder.append('?'); |
| urlBuilder.append(HttpConstants.SERVICE_PARAMETER); |
| urlBuilder.append('='); |
| urlBuilder.append(gaiaServiceId); |
| } |
| return new URL(urlBuilder.toString()); |
| } |
| |
| /** |
| * Returns an {@link HttpURLConnection} to use to POST a message to the data center. Sets |
| * the content-type and user-agent headers; also sets the echo token header if we have an |
| * echo token. |
| * |
| * @param context Android context |
| * @param url URL to which to post |
| * @param authToken auth token to provide in the request header |
| * @param isOAuth2Token whether the token is an OAuth2 token (vs. a GoogleLogin token) |
| */ |
| |
| public static HttpURLConnection createUrlConnectionForPost(Context context, URL url, |
| String authToken, boolean isOAuth2Token) throws IOException { |
| HttpURLConnection connection = (HttpURLConnection) url.openConnection(); |
| try { |
| connection.setRequestMethod("POST"); |
| } catch (ProtocolException exception) { |
| throw new RuntimeException("Cannot set request method to POST: " + exception); |
| } |
| connection.setDoOutput(true); |
| if (isOAuth2Token) { |
| connection.setRequestProperty("Authorization", "Bearer " + authToken); |
| } else { |
| connection.setRequestProperty("Authorization", "GoogleLogin auth=" + authToken); |
| } |
| connection.setRequestProperty("Content-Type", HttpConstants.PROTO_CONTENT_TYPE); |
| connection.setRequestProperty("User-Agent", |
| context.getApplicationInfo().className + "(" + Build.VERSION.RELEASE + ")"); |
| String echoToken = AndroidChannelPreferences.getEchoToken(context); |
| if (echoToken != null) { |
| // If we have a token to echo to the server, echo it. |
| connection.setRequestProperty(HttpConstants.ECHO_HEADER, echoToken); |
| } |
| return connection; |
| } |
| |
| /** Reads and all data from {@code in}. */ |
| private static String readCompleteStream(InputStream in) throws IOException { |
| StringBuffer buffer = new StringBuffer(); |
| BufferedReader reader = new BufferedReader(new InputStreamReader(in)); |
| String line; |
| while ((line = reader.readLine()) != null) { |
| buffer.append(line); |
| } |
| return buffer.toString(); |
| } |
| |
| /** Returns a base-64 encoded version of {@code bytes}. */ |
| private static String base64Encode(byte[] bytes) { |
| return Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); |
| } |
| |
| /** Returns the network id for this channel, or {@code null} if one cannot be determined. */ |
| |
| |
| public static NetworkEndpointId getNetworkEndpointId(Context context, Logger logger) { |
| String registrationId = GCMRegistrar.getRegistrationId(context); |
| if ((registrationId == null) || registrationId.isEmpty()) { |
| // No registration with GCM; we cannot compute a network id. The GCM documentation says the |
| // string is never null, but we'll be paranoid. |
| logger.warning("No GCM registration id; cannot determine our network endpoint id: %s", |
| registrationId); |
| return null; |
| } |
| return CommonProtos.newAndroidEndpointId(registrationId, NO_CLIENT_KEY, |
| context.getPackageName(), AndroidChannelConstants.CHANNEL_VERSION); |
| } |
| |
| /** Sets the channel url to {@code url}, for tests. */ |
| public static void setChannelUrlForTest(String url) { |
| channelUrlForTest = Preconditions.checkNotNull(url); |
| } |
| } |