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