• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2013 The Android Open Source Project
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 
17 package com.android.exchange.service;
18 
19 import android.content.ContentResolver;
20 import android.content.ContentUris;
21 import android.content.ContentValues;
22 import android.content.Context;
23 import android.net.Uri;
24 import android.os.Build;
25 import android.os.Bundle;
26 import android.text.TextUtils;
27 import android.text.format.DateUtils;
28 import android.util.Base64;
29 
30 import com.android.emailcommon.internet.MimeUtility;
31 import com.android.emailcommon.provider.Account;
32 import com.android.emailcommon.provider.EmailContent;
33 import com.android.emailcommon.provider.HostAuth;
34 import com.android.emailcommon.provider.Mailbox;
35 import com.android.emailcommon.service.AccountServiceProxy;
36 import com.android.emailcommon.utility.EmailClientConnectionManager;
37 import com.android.emailcommon.utility.Utility;
38 import com.android.exchange.Eas;
39 import com.android.exchange.EasResponse;
40 import com.android.exchange.eas.EasConnectionCache;
41 import com.android.exchange.utility.CurlLogger;
42 import com.android.exchange.utility.WbxmlResponseLogger;
43 import com.android.mail.utils.LogUtils;
44 
45 import org.apache.http.HttpEntity;
46 import org.apache.http.client.HttpClient;
47 import org.apache.http.client.methods.HttpOptions;
48 import org.apache.http.client.methods.HttpPost;
49 import org.apache.http.client.methods.HttpUriRequest;
50 import org.apache.http.entity.ByteArrayEntity;
51 import org.apache.http.impl.client.DefaultHttpClient;
52 import org.apache.http.params.BasicHttpParams;
53 import org.apache.http.params.HttpConnectionParams;
54 import org.apache.http.params.HttpParams;
55 import org.apache.http.protocol.BasicHttpProcessor;
56 
57 import java.io.IOException;
58 import java.net.URI;
59 import java.security.cert.CertificateException;
60 
61 /**
62  * Base class for communicating with an EAS server. Anything that needs to send messages to the
63  * server can subclass this to get access to the {@link #sendHttpClientPost} family of functions.
64  * TODO: This class has a regrettable name. It's not a connection, but rather a task that happens
65  * to have (and use) a connection to the server.
66  */
67 public class EasServerConnection {
68     /** Logging tag. */
69     private static final String TAG = Eas.LOG_TAG;
70 
71     /**
72      * Timeout for establishing a connection to the server.
73      */
74     private static final long CONNECTION_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS;
75 
76     /**
77      * Timeout for http requests after the connection has been established.
78      */
79     protected static final long COMMAND_TIMEOUT = 30 * DateUtils.SECOND_IN_MILLIS;
80 
81     private static final String DEVICE_TYPE = "Android";
82     private static final String USER_AGENT = DEVICE_TYPE + '/' + Build.VERSION.RELEASE + '-' +
83         Eas.CLIENT_VERSION;
84 
85     /** Message MIME type for EAS version 14 and later. */
86     private static final String EAS_14_MIME_TYPE = "application/vnd.ms-sync.wbxml";
87 
88     /**
89      * Value for {@link #mStoppedReason} when we haven't been stopped.
90      */
91     public static final int STOPPED_REASON_NONE = 0;
92 
93     /**
94      * Passed to {@link #stop} to indicate that this stop request should terminate this task.
95      */
96     public static final int STOPPED_REASON_ABORT = 1;
97 
98     /**
99      * Passed to {@link #stop} to indicate that this stop request should restart this task (e.g. in
100      * order to reload parameters).
101      */
102     public static final int STOPPED_REASON_RESTART = 2;
103 
104     private static final String[] ACCOUNT_SECURITY_KEY_PROJECTION =
105             { EmailContent.AccountColumns.SECURITY_SYNC_KEY };
106 
107     private static String sDeviceId = null;
108 
109     protected final Context mContext;
110     // TODO: Make this private if possible. Subclasses must be careful about altering the HostAuth
111     // to not screw up any connection caching (use redirectHostAuth).
112     protected final HostAuth mHostAuth;
113     protected final Account mAccount;
114     private final long mAccountId;
115 
116     // Bookkeeping for interrupting a request. This is primarily for use by Ping (there's currently
117     // no mechanism for stopping a sync).
118     // Access to these variables should be synchronized on this.
119     private HttpUriRequest mPendingRequest = null;
120     private boolean mStopped = false;
121     private int mStoppedReason = STOPPED_REASON_NONE;
122 
123     /** The protocol version to use, as a double. */
124     private double mProtocolVersion = 0.0d;
125     /** Whether {@link #setProtocolVersion} was last called with a non-null value. */
126     private boolean mProtocolVersionIsSet = false;
127 
128     /**
129      * The client for any requests made by this object. This is created lazily, and cleared
130      * whenever our host auth is redirected.
131      */
132     private HttpClient mClient;
133 
134     /**
135      * This is used only to check when our client needs to be refreshed.
136      */
137     private EmailClientConnectionManager mClientConnectionManager;
138 
EasServerConnection(final Context context, final Account account, final HostAuth hostAuth)139     public EasServerConnection(final Context context, final Account account,
140             final HostAuth hostAuth) {
141         mContext = context;
142         mHostAuth = hostAuth;
143         mAccount = account;
144         mAccountId = account.mId;
145         setProtocolVersion(account.mProtocolVersion);
146     }
147 
EasServerConnection(final Context context, final Account account)148     public EasServerConnection(final Context context, final Account account) {
149         this(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
150     }
151 
getClientConnectionManager()152     protected EmailClientConnectionManager getClientConnectionManager()
153         throws CertificateException {
154         final EmailClientConnectionManager connManager =
155                 EasConnectionCache.instance().getConnectionManager(mContext, mHostAuth);
156         if (mClientConnectionManager != connManager) {
157             mClientConnectionManager = connManager;
158             mClient = null;
159         }
160         return connManager;
161     }
162 
redirectHostAuth(final String newAddress)163     public void redirectHostAuth(final String newAddress) {
164         mClient = null;
165         mHostAuth.mAddress = newAddress;
166         if (mHostAuth.isSaved()) {
167             EasConnectionCache.instance().uncacheConnectionManager(mHostAuth);
168             final ContentValues cv = new ContentValues(1);
169             cv.put(EmailContent.HostAuthColumns.ADDRESS, newAddress);
170             mHostAuth.update(mContext, cv);
171         }
172     }
173 
getHttpClient(final long timeout)174     private HttpClient getHttpClient(final long timeout) throws CertificateException {
175         if (mClient == null) {
176             final HttpParams params = new BasicHttpParams();
177             HttpConnectionParams.setConnectionTimeout(params, (int)(CONNECTION_TIMEOUT));
178             HttpConnectionParams.setSoTimeout(params, (int)(timeout));
179             HttpConnectionParams.setSocketBufferSize(params, 8192);
180             mClient = new DefaultHttpClient(getClientConnectionManager(), params) {
181                 @Override
182                 protected BasicHttpProcessor createHttpProcessor() {
183                     final BasicHttpProcessor processor = super.createHttpProcessor();
184                     processor.addRequestInterceptor(new CurlLogger());
185                     processor.addResponseInterceptor(new WbxmlResponseLogger());
186                     return processor;
187                 }
188             };
189         }
190         return mClient;
191     }
192 
makeAuthString()193     private String makeAuthString() {
194         final String cs = mHostAuth.mLogin + ":" + mHostAuth.mPassword;
195         return "Basic " + Base64.encodeToString(cs.getBytes(), Base64.NO_WRAP);
196     }
197 
makeUserString()198     private String makeUserString() {
199         if (sDeviceId == null) {
200             sDeviceId = new AccountServiceProxy(mContext).getDeviceId();
201             if (sDeviceId == null) {
202                 LogUtils.e(TAG, "Could not get device id, defaulting to '0'");
203                 sDeviceId = "0";
204             }
205         }
206         return "&User=" + Uri.encode(mHostAuth.mLogin) + "&DeviceId=" +
207                 sDeviceId + "&DeviceType=" + DEVICE_TYPE;
208     }
209 
makeBaseUriString()210     private String makeBaseUriString() {
211         return EmailClientConnectionManager.makeScheme(mHostAuth.shouldUseSsl(),
212                 mHostAuth.shouldTrustAllServerCerts(), mHostAuth.mClientCertAlias) +
213                 "://" + mHostAuth.mAddress + "/Microsoft-Server-ActiveSync";
214     }
215 
makeUriString(final String cmd)216     public String makeUriString(final String cmd) {
217         String uriString = makeBaseUriString();
218         if (cmd != null) {
219             uriString += "?Cmd=" + cmd + makeUserString();
220         }
221         return uriString;
222     }
223 
makeUriString(final String cmd, final String extra)224     private String makeUriString(final String cmd, final String extra) {
225         return makeUriString(cmd) + extra;
226     }
227 
228     /**
229      * If a sync causes us to update our protocol version, this function must be called so that
230      * subsequent calls to {@link #getProtocolVersion()} will do the right thing.
231      * @return Whether the protocol version changed.
232      */
setProtocolVersion(String protocolVersionString)233     public boolean setProtocolVersion(String protocolVersionString) {
234         mProtocolVersionIsSet = (protocolVersionString != null);
235         if (protocolVersionString == null) {
236             protocolVersionString = Eas.DEFAULT_PROTOCOL_VERSION;
237         }
238         final double oldProtocolVersion = mProtocolVersion;
239         mProtocolVersion = Eas.getProtocolVersionDouble(protocolVersionString);
240         return (oldProtocolVersion != mProtocolVersion);
241     }
242 
243     /**
244      * @return The protocol version for this connection.
245      */
getProtocolVersion()246     public double getProtocolVersion() {
247         return mProtocolVersion;
248     }
249 
250     /**
251      * @return The useragent string for our client.
252      */
getUserAgent()253     public final String getUserAgent() {
254         return USER_AGENT;
255     }
256 
257     /**
258      * Send an http OPTIONS request to server.
259      * @return The {@link EasResponse} from the Exchange server.
260      * @throws IOException
261      */
sendHttpClientOptions()262     protected EasResponse sendHttpClientOptions() throws IOException, CertificateException {
263         // For OPTIONS, just use the base string and the single header
264         final HttpOptions method = new HttpOptions(URI.create(makeBaseUriString()));
265         method.setHeader("Authorization", makeAuthString());
266         method.setHeader("User-Agent", getUserAgent());
267         return EasResponse.fromHttpRequest(getClientConnectionManager(),
268                 getHttpClient(COMMAND_TIMEOUT), method);
269     }
270 
resetAuthorization(final HttpPost post)271     protected void resetAuthorization(final HttpPost post) {
272         post.removeHeaders("Authorization");
273         post.setHeader("Authorization", makeAuthString());
274     }
275 
276     /**
277      * Make an {@link HttpPost} for a specific request.
278      * @param uri The uri for this request, as a {@link String}.
279      * @param entity The {@link HttpEntity} for this request.
280      * @param contentType The Content-Type for this request.
281      * @param usePolicyKey Whether or not a policy key should be sent.
282      * @return
283      */
makePost(final String uri, final HttpEntity entity, final String contentType, final boolean usePolicyKey)284     public HttpPost makePost(final String uri, final HttpEntity entity, final String contentType,
285             final boolean usePolicyKey) {
286         final HttpPost post = new HttpPost(uri);
287         post.setHeader("Authorization", makeAuthString());
288         post.setHeader("MS-ASProtocolVersion", String.valueOf(mProtocolVersion));
289         post.setHeader("User-Agent", getUserAgent());
290         post.setHeader("Accept-Encoding", "gzip");
291         // If there is no entity, we should not be setting a content-type since this will
292         // result in a 400 from the server in the case of loading an attachment.
293         if (contentType != null && entity != null) {
294             post.setHeader("Content-Type", contentType);
295         }
296         if (usePolicyKey) {
297             // If there's an account in existence, use its key; otherwise (we're creating the
298             // account), send "0".  The server will respond with code 449 if there are policies
299             // to be enforced
300             final String key;
301             final String accountKey;
302             if (mAccountId == Account.NO_ACCOUNT) {
303                 accountKey = null;
304             } else {
305                accountKey = Utility.getFirstRowString(mContext,
306                         ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId),
307                         ACCOUNT_SECURITY_KEY_PROJECTION, null, null, null, 0);
308             }
309             if (!TextUtils.isEmpty(accountKey)) {
310                 key = accountKey;
311             } else {
312                 key = "0";
313             }
314             post.setHeader("X-MS-PolicyKey", key);
315         }
316         post.setEntity(entity);
317         return post;
318     }
319 
320     /**
321      * Make an {@link HttpOptions} request for this connection.
322      * @return The {@link HttpOptions} object.
323      */
makeOptions()324     public HttpOptions makeOptions() {
325         final HttpOptions method = new HttpOptions(URI.create(makeBaseUriString()));
326         method.setHeader("Authorization", makeAuthString());
327         method.setHeader("User-Agent", getUserAgent());
328         return method;
329     }
330 
331     /**
332      * Send a POST request to the server.
333      * @param cmd The command we're sending to the server.
334      * @param entity The {@link HttpEntity} containing the payload of the message.
335      * @param timeout The timeout for this POST.
336      * @return The response from the Exchange server.
337      * @throws IOException
338      */
sendHttpClientPost(String cmd, final HttpEntity entity, final long timeout)339     protected EasResponse sendHttpClientPost(String cmd, final HttpEntity entity,
340             final long timeout) throws IOException, CertificateException {
341         final boolean isPingCommand = cmd.equals("Ping");
342 
343         // Split the mail sending commands
344         // TODO: This logic should not be here, the command should be generated correctly
345         // in a subclass of EasOperation.
346         String extra = null;
347         boolean msg = false;
348         if (cmd.startsWith("SmartForward&") || cmd.startsWith("SmartReply&")) {
349             final int cmdLength = cmd.indexOf('&');
350             extra = cmd.substring(cmdLength);
351             cmd = cmd.substring(0, cmdLength);
352             msg = true;
353         } else if (cmd.startsWith("SendMail&")) {
354             msg = true;
355         }
356 
357         // Send the proper Content-Type header; it's always wbxml except for messages when
358         // the EAS protocol version is < 14.0
359         // If entity is null (e.g. for attachments), don't set this header
360         final String contentType;
361         if (msg && (getProtocolVersion() < Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE)) {
362             contentType = MimeUtility.MIME_TYPE_RFC822;
363         } else if (entity != null) {
364             contentType = EAS_14_MIME_TYPE;
365         } else {
366             contentType = null;
367         }
368         final String uriString;
369         if (extra == null) {
370             uriString = makeUriString(cmd);
371         } else {
372             uriString = makeUriString(cmd, extra);
373         }
374         final HttpPost method = makePost(uriString, entity, contentType, !isPingCommand);
375         // NOTE
376         // The next lines are added at the insistence of $VENDOR, who is seeing inappropriate
377         // network activity related to the Ping command on some networks with some servers.
378         // This code should be removed when the underlying issue is resolved
379         if (isPingCommand) {
380             method.setHeader("Connection", "close");
381         }
382         return executeHttpUriRequest(method, timeout);
383     }
384 
sendHttpClientPost(final String cmd, final byte[] bytes, final long timeout)385     public EasResponse sendHttpClientPost(final String cmd, final byte[] bytes,
386             final long timeout) throws IOException, CertificateException {
387         final ByteArrayEntity entity;
388         if (bytes == null) {
389             entity = null;
390         } else {
391             entity = new ByteArrayEntity(bytes);
392         }
393         return sendHttpClientPost(cmd, entity, timeout);
394     }
395 
sendHttpClientPost(final String cmd, final byte[] bytes)396     protected EasResponse sendHttpClientPost(final String cmd, final byte[] bytes)
397             throws IOException, CertificateException {
398         return sendHttpClientPost(cmd, bytes, COMMAND_TIMEOUT);
399     }
400 
401     /**
402      * Executes an {@link HttpUriRequest}.
403      * Note: this function must not be called by multiple threads concurrently. Only one thread may
404      * send server requests from a particular object at a time.
405      * @param method The post to execute.
406      * @param timeout The timeout to use.
407      * @return The response from the Exchange server.
408      * @throws IOException
409      */
executeHttpUriRequest(final HttpUriRequest method, final long timeout)410     public EasResponse executeHttpUriRequest(final HttpUriRequest method, final long timeout)
411             throws IOException, CertificateException {
412         LogUtils.d(TAG, "EasServerConnection about to make request %s", method.getRequestLine());
413         // The synchronized blocks are here to support the stop() function, specifically to handle
414         // when stop() is called first. Notably, they are NOT here in order to guard against
415         // concurrent access to this function, which is not supported.
416         synchronized (this) {
417             if (mStopped) {
418                 mStopped = false;
419                 // If this gets stopped after the POST actually starts, it throws an IOException.
420                 // Therefore if we get stopped here, let's throw the same sort of exception, so
421                 // callers can equate IOException with "this POST got killed for some reason".
422                 throw new IOException("Command was stopped before POST");
423             }
424            mPendingRequest = method;
425         }
426         boolean postCompleted = false;
427         try {
428             final EasResponse response = EasResponse.fromHttpRequest(getClientConnectionManager(),
429                     getHttpClient(timeout), method);
430             postCompleted = true;
431             return response;
432         } finally {
433             synchronized (this) {
434                 mPendingRequest = null;
435                 if (postCompleted) {
436                     mStoppedReason = STOPPED_REASON_NONE;
437                 }
438             }
439         }
440     }
441 
executePost(final HttpPost method)442     protected EasResponse executePost(final HttpPost method)
443             throws IOException, CertificateException {
444         return executeHttpUriRequest(method, COMMAND_TIMEOUT);
445     }
446 
447     /**
448      * If called while this object is executing a POST, interrupt it with an {@link IOException}.
449      * Otherwise cause the next attempt to execute a POST to be interrupted with an
450      * {@link IOException}.
451      * @param reason The reason for requesting a stop. This should be one of the STOPPED_REASON_*
452      *               constants defined in this class, other than {@link #STOPPED_REASON_NONE} which
453      *               is used to signify that no stop has occurred.
454      *               This class simply stores the value; subclasses are responsible for checking
455      *               this value when catching the {@link IOException} and responding appropriately.
456      */
stop(final int reason)457     public synchronized void stop(final int reason) {
458         // Only process legitimate reasons.
459         if (reason >= STOPPED_REASON_ABORT && reason <= STOPPED_REASON_RESTART) {
460             final boolean isMidPost = (mPendingRequest != null);
461             LogUtils.i(TAG, "%s with reason %d", (isMidPost ? "Interrupt" : "Stop next"), reason);
462             mStoppedReason = reason;
463             if (isMidPost) {
464                 mPendingRequest.abort();
465             } else {
466                 mStopped = true;
467             }
468         }
469     }
470 
471     /**
472      * @return The reason supplied to the last call to {@link #stop}, or
473      *         {@link #STOPPED_REASON_NONE} if {@link #stop} hasn't been called since the last
474      *         successful POST.
475      */
getStoppedReason()476     public synchronized int getStoppedReason() {
477         return mStoppedReason;
478     }
479 
480     /**
481      * Try to register our client certificate, if needed.
482      * @return True if we succeeded or didn't need a client cert, false if we failed to register it.
483      */
registerClientCert()484     public boolean registerClientCert() {
485         if (mHostAuth.mClientCertAlias != null) {
486             try {
487                 getClientConnectionManager().registerClientCert(mContext, mHostAuth);
488             } catch (final CertificateException e) {
489                 // The client certificate the user specified is invalid/inaccessible.
490                 return false;
491             }
492         }
493         return true;
494     }
495 
496     /**
497      * @return Whether {@link #setProtocolVersion} was last called with a non-null value. Note that
498      *         at construction time it is set to whatever protocol version is in the account.
499      */
isProtocolVersionSet()500     public boolean isProtocolVersionSet() {
501         return mProtocolVersionIsSet;
502     }
503 
504     /**
505      * Convenience method for adding a Message to an account's outbox
506      * @param account The {@link Account} from which to send the message.
507      * @param msg The message to send
508      */
sendMessage(final Account account, final EmailContent.Message msg)509     protected void sendMessage(final Account account, final EmailContent.Message msg) {
510         long mailboxId = Mailbox.findMailboxOfType(mContext, account.mId, Mailbox.TYPE_OUTBOX);
511         // TODO: Improve system mailbox handling.
512         if (mailboxId == Mailbox.NO_MAILBOX) {
513             LogUtils.d(TAG, "No outbox for account %d, creating it", account.mId);
514             final Mailbox outbox =
515                     Mailbox.newSystemMailbox(mContext, account.mId, Mailbox.TYPE_OUTBOX);
516             outbox.save(mContext);
517             mailboxId = outbox.mId;
518         }
519         msg.mMailboxKey = mailboxId;
520         msg.mAccountKey = account.mId;
521         msg.save(mContext);
522         requestSyncForMailbox(new android.accounts.Account(account.mEmailAddress,
523                 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), EmailContent.AUTHORITY, mailboxId);
524     }
525 
526     /**
527      * Issue a {@link android.content.ContentResolver#requestSync} for a specific mailbox.
528      * @param amAccount The {@link android.accounts.Account} for the account we're pinging.
529      * @param authority The authority for the mailbox that needs to sync.
530      * @param mailboxId The id of the mailbox that needs to sync.
531      */
requestSyncForMailbox(final android.accounts.Account amAccount, final String authority, final long mailboxId)532     protected static void requestSyncForMailbox(final android.accounts.Account amAccount,
533             final String authority, final long mailboxId) {
534         final Bundle extras = Mailbox.createSyncBundle(mailboxId);
535         ContentResolver.requestSync(amAccount, authority, extras);
536         LogUtils.d(TAG, "requestSync EasServerConnection requestSyncForMailbox %s, %s",
537                 amAccount.toString(), extras.toString());
538     }
539 }
540