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         final EmailClientConnectionManager connManager =
    153                 EasConnectionCache.instance().getConnectionManager(mContext, mHostAuth);
    154         if (mClientConnectionManager != connManager) {
    155             mClientConnectionManager = connManager;
    156             mClient = null;
    157         }
    158         return connManager;
    159     }
    160 
    161     public void redirectHostAuth(final String newAddress) {
    162         mClient = 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         LogUtils.d(TAG, "EasServerConnection about to make request %s", method.getRequestLine());
    407         // The synchronized blocks are here to support the stop() function, specifically to handle
    408         // when stop() is called first. Notably, they are NOT here in order to guard against
    409         // concurrent access to this function, which is not supported.
    410         synchronized (this) {
    411             if (mStopped) {
    412                 mStopped = false;
    413                 // If this gets stopped after the POST actually starts, it throws an IOException.
    414                 // Therefore if we get stopped here, let's throw the same sort of exception, so
    415                 // callers can equate IOException with "this POST got killed for some reason".
    416                 throw new IOException("Command was stopped before POST");
    417             }
    418            mPendingRequest = method;
    419         }
    420         boolean postCompleted = false;
    421         try {
    422             final EasResponse response = EasResponse.fromHttpRequest(getClientConnectionManager(),
    423                     getHttpClient(timeout), method);
    424             postCompleted = true;
    425             return response;
    426         } finally {
    427             synchronized (this) {
    428                 mPendingRequest = null;
    429                 if (postCompleted) {
    430                     mStoppedReason = STOPPED_REASON_NONE;
    431                 }
    432             }
    433         }
    434     }
    435 
    436     protected EasResponse executePost(final HttpPost method) throws IOException {
    437         return executeHttpUriRequest(method, COMMAND_TIMEOUT);
    438     }
    439 
    440     /**
    441      * If called while this object is executing a POST, interrupt it with an {@link IOException}.
    442      * Otherwise cause the next attempt to execute a POST to be interrupted with an
    443      * {@link IOException}.
    444      * @param reason The reason for requesting a stop. This should be one of the STOPPED_REASON_*
    445      *               constants defined in this class, other than {@link #STOPPED_REASON_NONE} which
    446      *               is used to signify that no stop has occurred.
    447      *               This class simply stores the value; subclasses are responsible for checking
    448      *               this value when catching the {@link IOException} and responding appropriately.
    449      */
    450     public synchronized void stop(final int reason) {
    451         // Only process legitimate reasons.
    452         if (reason >= STOPPED_REASON_ABORT && reason <= STOPPED_REASON_RESTART) {
    453             final boolean isMidPost = (mPendingRequest != null);
    454             LogUtils.i(TAG, "%s with reason %d", (isMidPost ? "Interrupt" : "Stop next"), reason);
    455             mStoppedReason = reason;
    456             if (isMidPost) {
    457                 mPendingRequest.abort();
    458             } else {
    459                 mStopped = true;
    460             }
    461         }
    462     }
    463 
    464     /**
    465      * @return The reason supplied to the last call to {@link #stop}, or
    466      *         {@link #STOPPED_REASON_NONE} if {@link #stop} hasn't been called since the last
    467      *         successful POST.
    468      */
    469     public synchronized int getStoppedReason() {
    470         return mStoppedReason;
    471     }
    472 
    473     /**
    474      * Try to register our client certificate, if needed.
    475      * @return True if we succeeded or didn't need a client cert, false if we failed to register it.
    476      */
    477     public boolean registerClientCert() {
    478         if (mHostAuth.mClientCertAlias != null) {
    479             try {
    480                 getClientConnectionManager().registerClientCert(mContext, mHostAuth);
    481             } catch (final CertificateException e) {
    482                 // The client certificate the user specified is invalid/inaccessible.
    483                 return false;
    484             }
    485         }
    486         return true;
    487     }
    488 
    489     /**
    490      * @return Whether {@link #setProtocolVersion} was last called with a non-null value. Note that
    491      *         at construction time it is set to whatever protocol version is in the account.
    492      */
    493     public boolean isProtocolVersionSet() {
    494         return mProtocolVersionIsSet;
    495     }
    496 
    497     /**
    498      * Convenience method for adding a Message to an account's outbox
    499      * @param account The {@link Account} from which to send the message.
    500      * @param msg The message to send
    501      */
    502     protected void sendMessage(final Account account, final EmailContent.Message msg) {
    503         long mailboxId = Mailbox.findMailboxOfType(mContext, account.mId, Mailbox.TYPE_OUTBOX);
    504         // TODO: Improve system mailbox handling.
    505         if (mailboxId == Mailbox.NO_MAILBOX) {
    506             LogUtils.d(TAG, "No outbox for account %d, creating it", account.mId);
    507             final Mailbox outbox =
    508                     Mailbox.newSystemMailbox(mContext, account.mId, Mailbox.TYPE_OUTBOX);
    509             outbox.save(mContext);
    510             mailboxId = outbox.mId;
    511         }
    512         msg.mMailboxKey = mailboxId;
    513         msg.mAccountKey = account.mId;
    514         msg.save(mContext);
    515         requestSyncForMailbox(new android.accounts.Account(account.mEmailAddress,
    516                 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), EmailContent.AUTHORITY, mailboxId);
    517     }
    518 
    519     /**
    520      * Issue a {@link android.content.ContentResolver#requestSync} for a specific mailbox.
    521      * @param amAccount The {@link android.accounts.Account} for the account we're pinging.
    522      * @param authority The authority for the mailbox that needs to sync.
    523      * @param mailboxId The id of the mailbox that needs to sync.
    524      */
    525     protected static void requestSyncForMailbox(final android.accounts.Account amAccount,
    526             final String authority, final long mailboxId) {
    527         final Bundle extras = Mailbox.createSyncBundle(mailboxId);
    528         ContentResolver.requestSync(amAccount, authority, extras);
    529         LogUtils.d(TAG, "requestSync EasServerConnection requestSyncForMailbox %s, %s",
    530                 amAccount.toString(), extras.toString());
    531     }
    532 }
    533