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