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