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