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.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 
    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         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 
    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 
    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 
    193     private String makeAuthString() {
    194         final String cs = mHostAuth.mLogin + ":" + mHostAuth.mPassword;
    195         return "Basic " + Base64.encodeToString(cs.getBytes(), Base64.NO_WRAP);
    196     }
    197 
    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 
    210     private String makeBaseUriString() {
    211         return EmailClientConnectionManager.makeScheme(mHostAuth.shouldUseSsl(),
    212                 mHostAuth.shouldTrustAllServerCerts(), mHostAuth.mClientCertAlias) +
    213                 "://" + mHostAuth.mAddress + "/Microsoft-Server-ActiveSync";
    214     }
    215 
    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 
    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      */
    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      */
    246     public double getProtocolVersion() {
    247         return mProtocolVersion;
    248     }
    249 
    250     /**
    251      * @return The useragent string for our client.
    252      */
    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      */
    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 
    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      */
    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      */
    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      */
    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 
    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 
    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      */
    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 
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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