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.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 
    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 
    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 
    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 
    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 
    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, 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 
    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 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 
    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      */
    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      */
    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 
    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 
    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      */
    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 
    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      */
    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      */
    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      */
    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      */
    502     public boolean isProtocolVersionSet() {
    503         return mProtocolVersionIsSet;
    504     }
    505 }
    506