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.
16 package com.google.ipc.invalidation.ticl.android2.channel;
18 import com.google.android.gcm.GCMRegistrar;
19 import com.google.common.base.Preconditions;
20 import com.google.common.base.Strings;
21 import com.google.ipc.invalidation.common.CommonProtos2;
22 import com.google.ipc.invalidation.external.client.SystemResources.Logger;
23 import com.google.ipc.invalidation.external.client.android.service.AndroidLogger;
24 import com.google.ipc.invalidation.ticl.android2.AndroidIntentProtocolValidator;
25 import com.google.ipc.invalidation.ticl.android2.ProtocolIntents;
26 import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.AuthTokenConstants;
27 import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.HttpConstants;
28 import com.google.protobuf.InvalidProtocolBufferException;
29 import com.google.protos.ipc.invalidation.AndroidService.AndroidNetworkSendRequest;
30 import com.google.protos.ipc.invalidation.Channel.NetworkEndpointId;
32 import android.app.IntentService;
33 import android.app.PendingIntent;
34 import android.content.Context;
35 import android.content.Intent;
36 import android.os.Build;
37 import android.util.Base64;
39 import java.io.BufferedReader;
40 import java.io.IOException;
41 import java.io.InputStream;
42 import java.io.InputStreamReader;
43 import java.net.HttpURLConnection;
44 import java.net.MalformedURLException;
45 import java.net.ProtocolException;
50 * Service that sends messages to the data center using HTTP POSTs authenticated as a Google
53 * Messages are sent as byte-serialized {@code ClientToServerMessage} protocol buffers.
54 * Additionally, the POST requests echo the latest value of the echo token received on C2DM
55 * messages from the data center.
58 public class AndroidMessageSenderService extends IntentService {
59 /* This class is public so that it can be instantiated by the Android runtime. */
62 * A prefix on the "auth token type" that indicates we're using an OAuth2 token to authenticate.
64 private static final String OAUTH2_TOKEN_TYPE_PREFIX = "oauth2:";
67 * Client key used in network endpoint ids. We only have one client at present, so there is no
70 private static final String NO_CLIENT_KEY = "";
72 /** An override of the URL, for testing. */
73 private static String channelUrlForTest = null;
75 private final Logger logger = AndroidLogger.forTag("MsgSenderSvc");
77 private final AndroidIntentProtocolValidator validator =
78 new AndroidIntentProtocolValidator(logger);
80 /** The last message sent, for tests. */
81 public static byte[] lastTiclMessageForTest = null;
83 public AndroidMessageSenderService() {
84 super("AndroidNetworkService");
88 public void onCreate() {
91 // HTTP connection reuse was buggy pre-Froyo, so disable it on those platforms.
92 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO) {
93 System.setProperty("http.keepAlive", "false");
98 protected void onHandleIntent(Intent intent) {
99 if (intent.hasExtra(ProtocolIntents.OUTBOUND_MESSAGE_KEY)) {
100 // Request from the Ticl service to send a message.
101 handleOutboundMessage(intent.getByteArrayExtra(ProtocolIntents.OUTBOUND_MESSAGE_KEY));
102 } else if (intent.hasExtra(AndroidChannelConstants.AuthTokenConstants.EXTRA_AUTH_TOKEN)) {
103 // Reply from the app with an auth token and a message to send.
104 handleAuthTokenResponse(intent);
105 } else if (intent.hasExtra(AndroidChannelConstants.MESSAGE_SENDER_SVC_GCM_REGID_CHANGE)) {
106 handleGcmRegIdChange();
108 logger.warning("Ignoring intent: %s", intent);
113 * Handles a request to send a message to the data center. Validates the message and sends
114 * an intent to the application to obtain an auth token to use on the HTTP request to the
117 private void handleOutboundMessage(byte[] sendRequestBytes) {
118 // Parse and validate the send request.
119 final AndroidNetworkSendRequest sendRequest;
121 sendRequest = AndroidNetworkSendRequest.parseFrom(sendRequestBytes);
122 } catch (InvalidProtocolBufferException exception) {
123 logger.warning("Failed parsing AndroidNetworkSendRequest from %s: %s",
124 sendRequestBytes, exception);
127 if (!validator.isNetworkSendRequestValid(sendRequest)) {
128 logger.warning("Ignoring invalid send request: %s", sendRequest);
132 // Request an auth token from the application to use when sending the message.
133 byte[] message = sendRequest.getMessage().toByteArray();
134 requestAuthTokenForMessage(message, null);
138 * Requests an auth token from the application to use to send {@code message} to the data
141 * If not {@code null}, {@code invalidAuthToken} is an auth token that was previously
142 * found to be invalid. The intent sent to the application to request the new token will include
143 * the invalid token so that the application can invalidate it in the {@code AccountManager}.
145 private void requestAuthTokenForMessage(byte[] message, String invalidAuthToken) {
147 * Send an intent requesting an auth token. This intent will contain a pending intent
148 * that the recipient can use to send back the token (by attaching the token as a string
149 * extra). That pending intent will also contain the message that we were just asked to send,
150 * so that it will be echoed back to us with the token. This avoids our having to persist
151 * the message while waiting for the token.
154 // This is the intent that the application will send back to us (the pending intent allows
155 // it to send the intent). It contains the stored message. We require that it be delivered to
156 // this class only, as a security check.
157 Intent tokenResponseIntent = new Intent(this, getClass());
158 tokenResponseIntent.putExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE, message);
160 // If we have an invalid auth token, set a bit in the intent that the application will send
161 // back to us. This will let us know that it is a retry; if sending subsequently fails again,
162 // we will not do any further retries.
163 tokenResponseIntent.putExtra(AuthTokenConstants.EXTRA_IS_RETRY, invalidAuthToken != null);
165 // The pending intent allows the application to send us the tokenResponseIntent.
166 PendingIntent pendingIntent = PendingIntent.getService(
167 this, message.hashCode(), tokenResponseIntent, PendingIntent.FLAG_ONE_SHOT);
169 // We send the pending intent as an extra in a normal intent to the application. We require that
170 // the intent be delivered only within this package, as a security check. The application must
171 // define a service with an intent filter that matches the ACTION_REQUEST_AUTH_TOKEN in order
172 // to receive this intent.
173 Intent requestTokenIntent = new Intent(AuthTokenConstants.ACTION_REQUEST_AUTH_TOKEN);
174 requestTokenIntent.setPackage(getPackageName());
175 requestTokenIntent.putExtra(AuthTokenConstants.EXTRA_PENDING_INTENT, pendingIntent);
176 if (invalidAuthToken != null) {
177 requestTokenIntent.putExtra(AuthTokenConstants.EXTRA_INVALIDATE_AUTH_TOKEN, invalidAuthToken);
179 startService(requestTokenIntent);
183 * Handles an intent received from the application that contains both a message to send and
184 * an auth token and type to use when sending it. This is called when the reply to the intent
185 * sent in {@link #requestAuthTokenForMessage(byte[], String)} is received.
187 private void handleAuthTokenResponse(Intent intent) {
188 if (!(intent.hasExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE)
189 && intent.hasExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN)
190 && intent.hasExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN_TYPE)
191 && intent.hasExtra(AuthTokenConstants.EXTRA_IS_RETRY))) {
192 logger.warning("auth-token-response intent missing fields: %s, %s",
193 intent, intent.getExtras());
196 boolean isRetryForInvalidAuthToken =
197 intent.getBooleanExtra(AuthTokenConstants.EXTRA_IS_RETRY, false);
198 deliverOutboundMessage(
199 intent.getByteArrayExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE),
200 intent.getStringExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN),
201 intent.getStringExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN_TYPE),
202 isRetryForInvalidAuthToken);
206 * Sends {@code outgoingMessage} to the data center as a serialized ClientToServerMessage using an
209 * If the HTTP POST fails due to an authentication failure and this is not a retry for an invalid
210 * auth token ({@code isRetryForInvalidAuthToken} is {@code false}), then it will call
211 * {@link #requestAuthTokenForMessage(byte[], String)} with {@code authToken} to invalidate the
214 * @param authToken the auth token to use in the HTTP POST
215 * @param authTokenType the type of the auth token
217 private void deliverOutboundMessage(byte[] outgoingMessage, String authToken,
218 String authTokenType, boolean isRetryForInvalidAuthToken) {
219 NetworkEndpointId networkEndpointId = getNetworkEndpointId(this, logger);
220 if (networkEndpointId == null) {
221 // No GCM registration; buffer the message to send when we become registered.
222 logger.info("Buffering message to the data center: no GCM registration id");
223 AndroidChannelPreferences.bufferMessage(this, outgoingMessage);
226 logger.fine("Delivering outbound message: %s bytes", outgoingMessage.length);
227 lastTiclMessageForTest = outgoingMessage;
229 HttpURLConnection urlConnection = null;
231 // Open the connection.
232 boolean isOAuth2Token = authTokenType.startsWith(OAUTH2_TOKEN_TYPE_PREFIX);
233 url = buildUrl(isOAuth2Token ? null : authTokenType, networkEndpointId);
234 urlConnection = createUrlConnectionForPost(this, url, authToken, isOAuth2Token);
235 urlConnection.setFixedLengthStreamingMode(outgoingMessage.length);
236 urlConnection.connect();
238 // Write the outgoing message.
239 urlConnection.getOutputStream().write(outgoingMessage);
241 // Consume all of the response. We do not do anything with the response (except log it for
242 // non-200 response codes), and do not expect any, but certain versions of the Apache HTTP
243 // library have a bug that causes connections to leak when the response is not fully consumed;
244 // out of sheer paranoia, we do the same thing here.
245 String response = readCompleteStream(urlConnection.getInputStream());
247 // Retry authorization failures and log other non-200 response codes.
248 final int responseCode = urlConnection.getResponseCode();
249 switch (responseCode) {
250 case HttpURLConnection.HTTP_OK:
251 case HttpURLConnection.HTTP_NO_CONTENT:
253 case HttpURLConnection.HTTP_UNAUTHORIZED:
254 if (!isRetryForInvalidAuthToken) {
255 // If we had an auth failure and this is not a retry of an auth failure, then ask the
256 // application to invalidate authToken and give us a new one with which to retry. We
257 // check that this attempt was not a retry to avoid infinite loops if authorization
259 requestAuthTokenForMessage(outgoingMessage, authToken);
263 logger.warning("Unexpected response code %s for HTTP POST to %s; response = %s",
264 responseCode, url, response);
266 } catch (MalformedURLException exception) {
267 logger.warning("Malformed URL: %s", exception);
268 } catch (IOException exception) {
269 logger.warning("IOException sending to the data center (%s): %s", url, exception);
271 if (urlConnection != null) {
272 urlConnection.disconnect();
278 * Handles a change in the GCM registration id by sending the buffered client message (if any)
279 * to the data center.
281 private void handleGcmRegIdChange() {
282 byte[] bufferedMessage = AndroidChannelPreferences.takeBufferedMessage(this);
283 if (bufferedMessage != null) {
284 // Rejoin the start of the code path that handles sending outbound messages.
285 requestAuthTokenForMessage(bufferedMessage, null);
290 * Returns a URL to use to send a message to the data center.
292 * @param gaiaServiceId Gaia service for which the request will be authenticated (when using a
293 * GoogleLogin token), or {@code null} when using an OAuth2 token.
294 * @param networkEndpointId network id of the client
296 private static URL buildUrl(String gaiaServiceId, NetworkEndpointId networkEndpointId)
297 throws MalformedURLException {
298 StringBuilder urlBuilder = new StringBuilder();
300 // Build base URL that targets the inbound request service with the encoded network endpoint
302 urlBuilder.append((channelUrlForTest != null) ? channelUrlForTest : HttpConstants.CHANNEL_URL);
303 urlBuilder.append(HttpConstants.REQUEST_URL);
305 // TODO: We should be sending a ClientGatewayMessage in the request body
306 // instead of appending the client's network endpoint id to the request URL. Once we do that, we
307 // should use a UriBuilder to build up a structured Uri object instead of the brittle string
308 // concatenation we're doing below.
309 urlBuilder.append(base64Encode(networkEndpointId.toByteArray()));
311 // Add query parameter indicating the service to authenticate against
312 if (gaiaServiceId != null) {
313 urlBuilder.append('?');
314 urlBuilder.append(HttpConstants.SERVICE_PARAMETER);
315 urlBuilder.append('=');
316 urlBuilder.append(gaiaServiceId);
318 return new URL(urlBuilder.toString());
322 * Returns an {@link HttpURLConnection} to use to POST a message to the data center. Sets
323 * the content-type and user-agent headers; also sets the echo token header if we have an
326 * @param context Android context
327 * @param url URL to which to post
328 * @param authToken auth token to provide in the request header
329 * @param isOAuth2Token whether the token is an OAuth2 token (vs. a GoogleLogin token)
332 public static HttpURLConnection createUrlConnectionForPost(Context context, URL url,
333 String authToken, boolean isOAuth2Token) throws IOException {
334 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
336 connection.setRequestMethod("POST");
337 } catch (ProtocolException exception) {
338 throw new RuntimeException("Cannot set request method to POST: " + exception);
340 connection.setDoOutput(true);
342 connection.setRequestProperty("Authorization", "Bearer " + authToken);
344 connection.setRequestProperty("Authorization", "GoogleLogin auth=" + authToken);
346 connection.setRequestProperty("Content-Type", HttpConstants.PROTO_CONTENT_TYPE);
347 connection.setRequestProperty("User-Agent",
348 context.getApplicationInfo().className + "(" + Build.VERSION.RELEASE + ")");
349 String echoToken = AndroidChannelPreferences.getEchoToken(context);
350 if (echoToken != null) {
351 // If we have a token to echo to the server, echo it.
352 connection.setRequestProperty(HttpConstants.ECHO_HEADER, echoToken);
357 /** Reads and all data from {@code in}. */
358 private static String readCompleteStream(InputStream in) throws IOException {
359 StringBuffer buffer = new StringBuffer();
360 BufferedReader reader = new BufferedReader(new InputStreamReader(in));
362 while ((line = reader.readLine()) != null) {
365 return buffer.toString();
368 /** Returns a base-64 encoded version of {@code bytes}. */
369 private static String base64Encode(byte[] bytes) {
370 return Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
373 /** Returns the network id for this channel, or {@code null} if one cannot be determined. */
376 public static NetworkEndpointId getNetworkEndpointId(Context context, Logger logger) {
377 String registrationId = GCMRegistrar.getRegistrationId(context);
378 if (Strings.isNullOrEmpty(registrationId)) {
379 // No registration with GCM; we cannot compute a network id. The GCM documentation says the
380 // string is never null, but we'll be paranoid.
381 logger.warning("No GCM registration id; cannot determine our network endpoint id: %s",
385 return CommonProtos2.newAndroidEndpointId(registrationId,
386 NO_CLIENT_KEY, context.getPackageName(), AndroidChannelConstants.CHANNEL_VERSION);
389 /** Sets the channel url to {@code url}, for tests. */
390 public static void setChannelUrlForTest(String url) {
391 channelUrlForTest = Preconditions.checkNotNull(url);