Upstream version 10.39.225.0
[platform/framework/web/crosswalk.git] / src / third_party / cacheinvalidation / src / java / com / google / ipc / invalidation / ticl / android2 / channel / AndroidMessageSenderService.java
1 /*
2  * Copyright 2011 Google Inc.
3  *
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
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
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.
15  */
16 package com.google.ipc.invalidation.ticl.android2.channel;
17
18 import com.google.android.gcm.GCMRegistrar;
19 import com.google.ipc.invalidation.external.client.SystemResources.Logger;
20 import com.google.ipc.invalidation.external.client.android.service.AndroidLogger;
21 import com.google.ipc.invalidation.ticl.android2.ProtocolIntents;
22 import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.AuthTokenConstants;
23 import com.google.ipc.invalidation.ticl.android2.channel.AndroidChannelConstants.HttpConstants;
24 import com.google.ipc.invalidation.ticl.proto.AndroidService.AndroidNetworkSendRequest;
25 import com.google.ipc.invalidation.ticl.proto.ChannelCommon.NetworkEndpointId;
26 import com.google.ipc.invalidation.ticl.proto.CommonProtos;
27 import com.google.ipc.invalidation.util.Preconditions;
28 import com.google.ipc.invalidation.util.ProtoWrapper.ValidationException;
29
30 import android.app.IntentService;
31 import android.app.PendingIntent;
32 import android.content.Context;
33 import android.content.Intent;
34 import android.os.Build;
35 import android.util.Base64;
36
37 import java.io.BufferedReader;
38 import java.io.IOException;
39 import java.io.InputStream;
40 import java.io.InputStreamReader;
41 import java.net.HttpURLConnection;
42 import java.net.MalformedURLException;
43 import java.net.ProtocolException;
44 import java.net.URL;
45 import java.util.Arrays;
46
47
48 /**
49  * Service that sends messages to the data center using HTTP POSTs authenticated as a Google
50  * account.
51  * <p>
52  * Messages are sent as byte-serialized {@code ClientToServerMessage} protocol buffers.
53  * Additionally, the POST requests echo the latest value of the echo token received on C2DM
54  * messages from the data center.
55  *
56  */
57 public class AndroidMessageSenderService extends IntentService {
58   /* This class is public so that it can be instantiated by the Android runtime. */
59
60   /**
61    * A prefix on the "auth token type" that indicates we're using an OAuth2 token to authenticate.
62    */
63   private static final String OAUTH2_TOKEN_TYPE_PREFIX = "oauth2:";
64
65   /**
66    * Client key used in network endpoint ids. We only have one client at present, so there is no
67    * need for a key.
68    */
69   private static final String NO_CLIENT_KEY = "";
70
71   /** An override of the URL, for testing. */
72   private static String channelUrlForTest = null;
73
74   private final Logger logger = AndroidLogger.forTag("MsgSenderSvc");
75
76   /** The last message sent, for tests. */
77   public static byte[] lastTiclMessageForTest = null;
78
79   public AndroidMessageSenderService() {
80     super("AndroidNetworkService");
81     setIntentRedelivery(true);
82   }
83
84   @Override
85   public void onCreate() {
86     super.onCreate();
87
88     // HTTP connection reuse was buggy pre-Froyo, so disable it on those platforms.
89     if (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO) {
90         System.setProperty("http.keepAlive", "false");
91     }
92   }
93
94   @Override
95   protected void onHandleIntent(Intent intent) {
96     if (intent == null) {
97       return;
98     }
99
100     if (intent.hasExtra(ProtocolIntents.OUTBOUND_MESSAGE_KEY)) {
101       // Request from the Ticl service to send a message.
102       handleOutboundMessage(intent.getByteArrayExtra(ProtocolIntents.OUTBOUND_MESSAGE_KEY));
103     } else if (intent.hasExtra(AndroidChannelConstants.AuthTokenConstants.EXTRA_AUTH_TOKEN)) {
104       // Reply from the app with an auth token and a message to send.
105       handleAuthTokenResponse(intent);
106     } else if (intent.hasExtra(AndroidChannelConstants.MESSAGE_SENDER_SVC_GCM_REGID_CHANGE)) {
107       handleGcmRegIdChange();
108     } else {
109       logger.warning("Ignoring intent: %s", intent);
110     }
111   }
112
113   /**
114    * Handles a request to send a message to the data center. Validates the message and sends
115    * an intent to the application to obtain an auth token to use on the HTTP request to the
116    * data center.
117    */
118   private void handleOutboundMessage(byte[] sendRequestBytes) {
119     // Parse and validate the send request.
120     final AndroidNetworkSendRequest sendRequest;
121     try {
122        sendRequest = AndroidNetworkSendRequest.parseFrom(sendRequestBytes);
123     } catch (ValidationException exception) {
124       logger.warning("Invalid AndroidNetworkSendRequest from %s: %s",
125           sendRequestBytes, exception);
126       return;
127     }
128
129     // Request an auth token from the application to use when sending the message.
130     byte[] message = sendRequest.getMessage().getByteArray();
131     requestAuthTokenForMessage(message, null);
132   }
133
134   /**
135    * Requests an auth token from the application to use to send {@code message} to the data
136    * center.
137    * <p>
138    * If not {@code null}, {@code invalidAuthToken} is an auth token that was previously
139    * found to be invalid. The intent sent to the application to request the new token will include
140    * the invalid token so that the application can invalidate it in the {@code AccountManager}.
141    */
142   private void requestAuthTokenForMessage(byte[] message, String invalidAuthToken) {
143     /*
144      * Send an intent requesting an auth token. This intent will contain a pending intent
145      * that the recipient can use to send back the token (by attaching the token as a string
146      * extra). That pending intent will also contain the message that we were just asked to send,
147      * so that it will be echoed back to us with the token. This avoids our having to persist
148      * the message while waiting for the token.
149      */
150
151     // This is the intent that the application will send back to us (the pending intent allows
152     // it to send the intent). It contains the stored message. We require that it be delivered to
153     // this class only, as a security check.
154     Intent tokenResponseIntent = new Intent(this, getClass());
155     tokenResponseIntent.putExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE, message);
156
157     // If we have an invalid auth token, set a bit in the intent that the application will send
158     // back to us. This will let us know that it is a retry; if sending subsequently fails again,
159     // we will not do any further retries.
160     tokenResponseIntent.putExtra(AuthTokenConstants.EXTRA_IS_RETRY, invalidAuthToken != null);
161
162     // The pending intent allows the application to send us the tokenResponseIntent.
163     PendingIntent pendingIntent = PendingIntent.getService(
164         this, Arrays.hashCode(message), tokenResponseIntent, PendingIntent.FLAG_ONE_SHOT);
165
166     // We send the pending intent as an extra in a normal intent to the application. We require that
167     // the intent be delivered only within this package, as a security check. The application must
168     // define a service with an intent filter that matches the ACTION_REQUEST_AUTH_TOKEN in order
169     // to receive this intent.
170     Intent requestTokenIntent = new Intent(AuthTokenConstants.ACTION_REQUEST_AUTH_TOKEN);
171     requestTokenIntent.setPackage(getPackageName());
172     requestTokenIntent.putExtra(AuthTokenConstants.EXTRA_PENDING_INTENT, pendingIntent);
173     if (invalidAuthToken != null) {
174       requestTokenIntent.putExtra(AuthTokenConstants.EXTRA_INVALIDATE_AUTH_TOKEN, invalidAuthToken);
175     }
176     startService(requestTokenIntent);
177   }
178
179   /**
180    * Handles an intent received from the application that contains both a message to send and
181    * an auth token and type to use when sending it. This is called when the reply to the intent
182    * sent in {@link #requestAuthTokenForMessage(byte[], String)} is received.
183    */
184   private void handleAuthTokenResponse(Intent intent) {
185     if (!(intent.hasExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE)
186         && intent.hasExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN)
187         && intent.hasExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN_TYPE)
188         && intent.hasExtra(AuthTokenConstants.EXTRA_IS_RETRY))) {
189       logger.warning("auth-token-response intent missing fields: %s, %s",
190           intent, intent.getExtras());
191       return;
192     }
193     boolean isRetryForInvalidAuthToken =
194         intent.getBooleanExtra(AuthTokenConstants.EXTRA_IS_RETRY, false);
195     deliverOutboundMessage(
196         intent.getByteArrayExtra(AuthTokenConstants.EXTRA_STORED_MESSAGE),
197         intent.getStringExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN),
198         intent.getStringExtra(AuthTokenConstants.EXTRA_AUTH_TOKEN_TYPE),
199         isRetryForInvalidAuthToken);
200   }
201
202   /**
203    * Sends {@code outgoingMessage} to the data center as a serialized ClientToServerMessage using an
204    * HTTP POST.
205    * <p>
206    * If the HTTP POST fails due to an authentication failure and this is not a retry for an invalid
207    * auth token ({@code isRetryForInvalidAuthToken} is {@code false}), then it will call
208    * {@link #requestAuthTokenForMessage(byte[], String)} with {@code authToken} to invalidate the
209    * token and retry.
210    *
211    * @param authToken the auth token to use in the HTTP POST
212    * @param authTokenType the type of the auth token
213    */
214   private void deliverOutboundMessage(byte[] outgoingMessage, String authToken,
215       String authTokenType, boolean isRetryForInvalidAuthToken) {
216     NetworkEndpointId networkEndpointId = getNetworkEndpointId(this, logger);
217     if (networkEndpointId == null) {
218       // No GCM registration; buffer the message to send when we become registered.
219       logger.info("Buffering message to the data center: no GCM registration id");
220       AndroidChannelPreferences.bufferMessage(this, outgoingMessage);
221       return;
222     }
223     logger.fine("Delivering outbound message: %s bytes", outgoingMessage.length);
224     lastTiclMessageForTest = outgoingMessage;
225     URL url = null;
226     HttpURLConnection urlConnection = null;
227     try {
228       // Open the connection.
229       boolean isOAuth2Token = authTokenType.startsWith(OAUTH2_TOKEN_TYPE_PREFIX);
230       url = buildUrl(isOAuth2Token ? null : authTokenType, networkEndpointId);
231       urlConnection = createUrlConnectionForPost(this, url, authToken, isOAuth2Token);
232
233       // We are seeing EOFException errors when reusing connections. Request that the connection is
234       // closed on response to work around this issue. Client-to-server messages are batched and
235       // infrequent so there isn't much benefit in connection reuse here.
236       urlConnection.setRequestProperty("Connection", "close");
237       urlConnection.setFixedLengthStreamingMode(outgoingMessage.length);
238       urlConnection.connect();
239
240       // Write the outgoing message.
241       urlConnection.getOutputStream().write(outgoingMessage);
242
243       // Consume all of the response. We do not do anything with the response (except log it for
244       // non-200 response codes), and do not expect any, but certain versions of the Apache HTTP
245       // library have a bug that causes connections to leak when the response is not fully consumed;
246       // out of sheer paranoia, we do the same thing here.
247       String response = readCompleteStream(urlConnection.getInputStream());
248
249       // Retry authorization failures and log other non-200 response codes.
250       final int responseCode = urlConnection.getResponseCode();
251       switch (responseCode) {
252         case HttpURLConnection.HTTP_OK:
253         case HttpURLConnection.HTTP_NO_CONTENT:
254           break;
255         case HttpURLConnection.HTTP_UNAUTHORIZED:
256           if (!isRetryForInvalidAuthToken) {
257             // If we had an auth failure and this is not a retry of an auth failure, then ask the
258             // application to invalidate authToken and give us a new one with which to retry. We
259             // check that this attempt was not a retry to avoid infinite loops if authorization
260             // always fails.
261             requestAuthTokenForMessage(outgoingMessage, authToken);
262           }
263           break;
264         default:
265           logger.warning("Unexpected response code %s for HTTP POST to %s; response = %s",
266               responseCode, url, response);
267       }
268     } catch (MalformedURLException exception) {
269       logger.warning("Malformed URL: %s", exception);
270     } catch (IOException exception) {
271       logger.warning("IOException sending to the data center (%s): %s", url, exception);
272     } finally {
273       if (urlConnection != null) {
274         urlConnection.disconnect();
275       }
276     }
277   }
278
279   /**
280    * Handles a change in the GCM registration id by sending the buffered client message (if any)
281    * to the data center.
282    */
283   private void handleGcmRegIdChange() {
284     byte[] bufferedMessage = AndroidChannelPreferences.takeBufferedMessage(this);
285     if (bufferedMessage != null) {
286       // Rejoin the start of the code path that handles sending outbound messages.
287       requestAuthTokenForMessage(bufferedMessage, null);
288     }
289   }
290
291   /**
292    * Returns a URL to use to send a message to the data center.
293    *
294    * @param gaiaServiceId Gaia service for which the request will be authenticated (when using a
295    *      GoogleLogin token), or {@code null} when using an OAuth2 token.
296    * @param networkEndpointId network id of the client
297    */
298   private static URL buildUrl(String gaiaServiceId, NetworkEndpointId networkEndpointId)
299       throws MalformedURLException {
300     StringBuilder urlBuilder = new StringBuilder();
301
302     // Build base URL that targets the inbound request service with the encoded network endpoint
303     // id.
304     urlBuilder.append((channelUrlForTest != null) ? channelUrlForTest : HttpConstants.CHANNEL_URL);
305     urlBuilder.append(HttpConstants.REQUEST_URL);
306
307     // TODO: We should be sending a ClientGatewayMessage in the request body
308     // instead of appending the client's network endpoint id to the request URL. Once we do that, we
309     // should use a UriBuilder to build up a structured Uri object instead of the brittle string
310     // concatenation we're doing below.
311     urlBuilder.append(base64Encode(networkEndpointId.toByteArray()));
312
313     // Add query parameter indicating the service to authenticate against
314     if (gaiaServiceId != null) {
315       urlBuilder.append('?');
316       urlBuilder.append(HttpConstants.SERVICE_PARAMETER);
317       urlBuilder.append('=');
318       urlBuilder.append(gaiaServiceId);
319     }
320     return new URL(urlBuilder.toString());
321   }
322
323   /**
324    * Returns an {@link HttpURLConnection} to use to POST a message to the data center. Sets
325    * the content-type and user-agent headers; also sets the echo token header if we have an
326    * echo token.
327    *
328    * @param context Android context
329    * @param url URL to which to post
330    * @param authToken auth token to provide in the request header
331    * @param isOAuth2Token whether the token is an OAuth2 token (vs. a GoogleLogin token)
332    */
333   
334   public static HttpURLConnection createUrlConnectionForPost(Context context, URL url,
335       String authToken, boolean isOAuth2Token) throws IOException {
336     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
337     try {
338       connection.setRequestMethod("POST");
339     } catch (ProtocolException exception) {
340       throw new RuntimeException("Cannot set request method to POST: " + exception);
341     }
342     connection.setDoOutput(true);
343     if (isOAuth2Token) {
344       connection.setRequestProperty("Authorization", "Bearer " + authToken);
345     } else {
346       connection.setRequestProperty("Authorization", "GoogleLogin auth=" + authToken);
347     }
348     connection.setRequestProperty("Content-Type", HttpConstants.PROTO_CONTENT_TYPE);
349     connection.setRequestProperty("User-Agent",
350         context.getApplicationInfo().className + "(" + Build.VERSION.RELEASE + ")");
351     String echoToken = AndroidChannelPreferences.getEchoToken(context);
352     if (echoToken != null) {
353       // If we have a token to echo to the server, echo it.
354       connection.setRequestProperty(HttpConstants.ECHO_HEADER, echoToken);
355     }
356     return connection;
357   }
358
359   /** Reads and all data from {@code in}. */
360   private static String readCompleteStream(InputStream in) throws IOException {
361     StringBuffer buffer = new StringBuffer();
362     BufferedReader reader = new BufferedReader(new InputStreamReader(in));
363     String line;
364     while ((line = reader.readLine()) != null) {
365       buffer.append(line);
366     }
367     return buffer.toString();
368   }
369
370   /** Returns a base-64 encoded version of {@code bytes}. */
371   private static String base64Encode(byte[] bytes) {
372     return Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP  | Base64.NO_PADDING);
373   }
374
375   /** Returns the network id for this channel, or {@code null} if one cannot be determined. */
376   
377   
378   public static NetworkEndpointId getNetworkEndpointId(Context context, Logger logger) {
379     String registrationId = GCMRegistrar.getRegistrationId(context);
380     if ((registrationId == null) || registrationId.isEmpty()) {
381       // No registration with GCM; we cannot compute a network id. The GCM documentation says the
382       // string is never null, but we'll be paranoid.
383       logger.warning("No GCM registration id; cannot determine our network endpoint id: %s",
384           registrationId);
385       return null;
386     }
387     return CommonProtos.newAndroidEndpointId(registrationId, NO_CLIENT_KEY,
388         context.getPackageName(), AndroidChannelConstants.CHANNEL_VERSION);
389   }
390
391   /** Sets the channel url to {@code url}, for tests. */
392   public static void setChannelUrlForTest(String url) {
393     channelUrlForTest = Preconditions.checkNotNull(url);
394   }
395 }