1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.exchange.service; 18 19 import android.content.ContentResolver; 20 import android.content.ContentUris; 21 import android.content.ContentValues; 22 import android.content.Context; 23 import android.net.Uri; 24 import android.os.Build; 25 import android.os.Bundle; 26 import android.text.TextUtils; 27 import android.text.format.DateUtils; 28 import android.util.Base64; 29 30 import com.android.emailcommon.internet.MimeUtility; 31 import com.android.emailcommon.provider.Account; 32 import com.android.emailcommon.provider.EmailContent; 33 import com.android.emailcommon.provider.HostAuth; 34 import com.android.emailcommon.provider.Mailbox; 35 import com.android.emailcommon.service.AccountServiceProxy; 36 import com.android.emailcommon.utility.EmailClientConnectionManager; 37 import com.android.emailcommon.utility.Utility; 38 import com.android.exchange.Eas; 39 import com.android.exchange.EasResponse; 40 import com.android.exchange.eas.EasConnectionCache; 41 import com.android.exchange.utility.CurlLogger; 42 import com.android.mail.utils.LogUtils; 43 44 import org.apache.http.HttpEntity; 45 import org.apache.http.client.HttpClient; 46 import org.apache.http.client.methods.HttpOptions; 47 import org.apache.http.client.methods.HttpPost; 48 import org.apache.http.client.methods.HttpUriRequest; 49 import org.apache.http.entity.ByteArrayEntity; 50 import org.apache.http.impl.client.DefaultHttpClient; 51 import org.apache.http.params.BasicHttpParams; 52 import org.apache.http.params.HttpConnectionParams; 53 import org.apache.http.params.HttpParams; 54 import org.apache.http.protocol.BasicHttpProcessor; 55 56 import java.io.IOException; 57 import java.net.URI; 58 import java.security.cert.CertificateException; 59 60 /** 61 * Base class for communicating with an EAS server. Anything that needs to send messages to the 62 * server can subclass this to get access to the {@link #sendHttpClientPost} family of functions. 63 * TODO: This class has a regrettable name. It's not a connection, but rather a task that happens 64 * to have (and use) a connection to the server. 65 */ 66 public class EasServerConnection { 67 /** Logging tag. */ 68 private static final String TAG = Eas.LOG_TAG; 69 70 /** 71 * Timeout for establishing a connection to the server. 72 */ 73 private static final long CONNECTION_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS; 74 75 /** 76 * Timeout for http requests after the connection has been established. 77 */ 78 protected static final long COMMAND_TIMEOUT = 30 * DateUtils.SECOND_IN_MILLIS; 79 80 private static final String DEVICE_TYPE = "Android"; 81 private static final String USER_AGENT = DEVICE_TYPE + '/' + Build.VERSION.RELEASE + '-' + 82 Eas.CLIENT_VERSION; 83 84 /** Message MIME type for EAS version 14 and later. */ 85 private static final String EAS_14_MIME_TYPE = "application/vnd.ms-sync.wbxml"; 86 87 /** 88 * Value for {@link #mStoppedReason} when we haven't been stopped. 89 */ 90 public static final int STOPPED_REASON_NONE = 0; 91 92 /** 93 * Passed to {@link #stop} to indicate that this stop request should terminate this task. 94 */ 95 public static final int STOPPED_REASON_ABORT = 1; 96 97 /** 98 * Passed to {@link #stop} to indicate that this stop request should restart this task (e.g. in 99 * order to reload parameters). 100 */ 101 public static final int STOPPED_REASON_RESTART = 2; 102 103 private static final String[] ACCOUNT_SECURITY_KEY_PROJECTION = 104 { EmailContent.AccountColumns.SECURITY_SYNC_KEY }; 105 106 private static String sDeviceId = null; 107 108 protected final Context mContext; 109 // TODO: Make this private if possible. Subclasses must be careful about altering the HostAuth 110 // to not screw up any connection caching (use redirectHostAuth). 111 protected final HostAuth mHostAuth; 112 protected final Account mAccount; 113 private final long mAccountId; 114 115 // Bookkeeping for interrupting a request. This is primarily for use by Ping (there's currently 116 // no mechanism for stopping a sync). 117 // Access to these variables should be synchronized on this. 118 private HttpUriRequest mPendingRequest = null; 119 private boolean mStopped = false; 120 private int mStoppedReason = STOPPED_REASON_NONE; 121 122 /** The protocol version to use, as a double. */ 123 private double mProtocolVersion = 0.0d; 124 /** Whether {@link #setProtocolVersion} was last called with a non-null value. */ 125 private boolean mProtocolVersionIsSet = false; 126 127 /** 128 * The client for any requests made by this object. This is created lazily, and cleared 129 * whenever our host auth is redirected. 130 */ 131 private HttpClient mClient; 132 133 /** 134 * This is used only to check when our client needs to be refreshed. 135 */ 136 private EmailClientConnectionManager mClientConnectionManager; 137 138 public EasServerConnection(final Context context, final Account account, 139 final HostAuth hostAuth) { 140 mContext = context; 141 mHostAuth = hostAuth; 142 mAccount = account; 143 mAccountId = account.mId; 144 setProtocolVersion(account.mProtocolVersion); 145 } 146 147 public EasServerConnection(final Context context, final Account account) { 148 this(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv)); 149 } 150 151 protected EmailClientConnectionManager getClientConnectionManager() 152 throws CertificateException { 153 final EmailClientConnectionManager connManager = 154 EasConnectionCache.instance().getConnectionManager(mContext, mHostAuth); 155 if (mClientConnectionManager != connManager) { 156 mClientConnectionManager = connManager; 157 mClient = null; 158 } 159 return connManager; 160 } 161 162 public void redirectHostAuth(final String newAddress) { 163 mClient = null; 164 mHostAuth.mAddress = newAddress; 165 if (mHostAuth.isSaved()) { 166 EasConnectionCache.instance().uncacheConnectionManager(mHostAuth); 167 final ContentValues cv = new ContentValues(1); 168 cv.put(EmailContent.HostAuthColumns.ADDRESS, newAddress); 169 mHostAuth.update(mContext, cv); 170 } 171 } 172 173 private HttpClient getHttpClient(final long timeout) throws CertificateException { 174 if (mClient == null) { 175 final HttpParams params = new BasicHttpParams(); 176 HttpConnectionParams.setConnectionTimeout(params, (int)(CONNECTION_TIMEOUT)); 177 HttpConnectionParams.setSoTimeout(params, (int)(timeout)); 178 HttpConnectionParams.setSocketBufferSize(params, 8192); 179 mClient = new DefaultHttpClient(getClientConnectionManager(), params) { 180 @Override 181 protected BasicHttpProcessor createHttpProcessor() { 182 final BasicHttpProcessor processor = super.createHttpProcessor(); 183 processor.addRequestInterceptor(new CurlLogger()); 184 return processor; 185 } 186 }; 187 } 188 return mClient; 189 } 190 191 private String makeAuthString() { 192 final String cs = mHostAuth.mLogin + ":" + mHostAuth.mPassword; 193 return "Basic " + Base64.encodeToString(cs.getBytes(), Base64.NO_WRAP); 194 } 195 196 private String makeUserString() { 197 if (sDeviceId == null) { 198 sDeviceId = new AccountServiceProxy(mContext).getDeviceId(); 199 if (sDeviceId == null) { 200 LogUtils.e(TAG, "Could not get device id, defaulting to '0'"); 201 sDeviceId = "0"; 202 } 203 } 204 return "&User=" + Uri.encode(mHostAuth.mLogin) + "&DeviceId=" + 205 sDeviceId + "&DeviceType=" + DEVICE_TYPE; 206 } 207 208 private String makeBaseUriString() { 209 return EmailClientConnectionManager.makeScheme(mHostAuth.shouldUseSsl(), 210 mHostAuth.shouldTrustAllServerCerts(), mHostAuth.mClientCertAlias) + 211 "://" + mHostAuth.mAddress + "/Microsoft-Server-ActiveSync"; 212 } 213 214 public String makeUriString(final String cmd) { 215 String uriString = makeBaseUriString(); 216 if (cmd != null) { 217 uriString += "?Cmd=" + cmd + makeUserString(); 218 } 219 return uriString; 220 } 221 222 private String makeUriString(final String cmd, final String extra) { 223 return makeUriString(cmd) + extra; 224 } 225 226 /** 227 * If a sync causes us to update our protocol version, this function must be called so that 228 * subsequent calls to {@link #getProtocolVersion()} will do the right thing. 229 * @return Whether the protocol version changed. 230 */ 231 public boolean setProtocolVersion(String protocolVersionString) { 232 mProtocolVersionIsSet = (protocolVersionString != null); 233 if (protocolVersionString == null) { 234 protocolVersionString = Eas.DEFAULT_PROTOCOL_VERSION; 235 } 236 final double oldProtocolVersion = mProtocolVersion; 237 mProtocolVersion = Eas.getProtocolVersionDouble(protocolVersionString); 238 return (oldProtocolVersion != mProtocolVersion); 239 } 240 241 /** 242 * @return The protocol version for this connection. 243 */ 244 public double getProtocolVersion() { 245 return mProtocolVersion; 246 } 247 248 /** 249 * @return The useragent string for our client. 250 */ 251 public final String getUserAgent() { 252 return USER_AGENT; 253 } 254 255 /** 256 * Send an http OPTIONS request to server. 257 * @return The {@link EasResponse} from the Exchange server. 258 * @throws IOException 259 */ 260 protected EasResponse sendHttpClientOptions() throws IOException, CertificateException { 261 // For OPTIONS, just use the base string and the single header 262 final HttpOptions method = new HttpOptions(URI.create(makeBaseUriString())); 263 method.setHeader("Authorization", makeAuthString()); 264 method.setHeader("User-Agent", getUserAgent()); 265 return EasResponse.fromHttpRequest(getClientConnectionManager(), 266 getHttpClient(COMMAND_TIMEOUT), method); 267 } 268 269 protected void resetAuthorization(final HttpPost post) { 270 post.removeHeaders("Authorization"); 271 post.setHeader("Authorization", makeAuthString()); 272 } 273 274 /** 275 * Make an {@link HttpPost} for a specific request. 276 * @param uri The uri for this request, as a {@link String}. 277 * @param entity The {@link HttpEntity} for this request. 278 * @param contentType The Content-Type for this request. 279 * @param usePolicyKey Whether or not a policy key should be sent. 280 * @return 281 */ 282 public HttpPost makePost(final String uri, final HttpEntity entity, final String contentType, 283 final boolean usePolicyKey) { 284 final HttpPost post = new HttpPost(uri); 285 post.setHeader("Authorization", makeAuthString()); 286 post.setHeader("MS-ASProtocolVersion", String.valueOf(mProtocolVersion)); 287 post.setHeader("User-Agent", getUserAgent()); 288 post.setHeader("Accept-Encoding", "gzip"); 289 if (contentType != null) { 290 post.setHeader("Content-Type", contentType); 291 } 292 if (usePolicyKey) { 293 // If there's an account in existence, use its key; otherwise (we're creating the 294 // account), send "0". The server will respond with code 449 if there are policies 295 // to be enforced 296 final String key; 297 final String accountKey; 298 if (mAccountId == Account.NO_ACCOUNT) { 299 accountKey = null; 300 } else { 301 accountKey = Utility.getFirstRowString(mContext, 302 ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), 303 ACCOUNT_SECURITY_KEY_PROJECTION, null, null, null, 0); 304 } 305 if (!TextUtils.isEmpty(accountKey)) { 306 key = accountKey; 307 } else { 308 key = "0"; 309 } 310 post.setHeader("X-MS-PolicyKey", key); 311 } 312 post.setEntity(entity); 313 return post; 314 } 315 316 /** 317 * Make an {@link HttpOptions} request for this connection. 318 * @return The {@link HttpOptions} object. 319 */ 320 public HttpOptions makeOptions() { 321 final HttpOptions method = new HttpOptions(URI.create(makeBaseUriString())); 322 method.setHeader("Authorization", makeAuthString()); 323 method.setHeader("User-Agent", getUserAgent()); 324 return method; 325 } 326 327 /** 328 * Send a POST request to the server. 329 * @param cmd The command we're sending to the server. 330 * @param entity The {@link HttpEntity} containing the payload of the message. 331 * @param timeout The timeout for this POST. 332 * @return The response from the Exchange server. 333 * @throws IOException 334 */ 335 protected EasResponse sendHttpClientPost(String cmd, final HttpEntity entity, 336 final long timeout) throws IOException, CertificateException { 337 final boolean isPingCommand = cmd.equals("Ping"); 338 339 // Split the mail sending commands 340 String extra = null; 341 boolean msg = false; 342 if (cmd.startsWith("SmartForward&") || cmd.startsWith("SmartReply&")) { 343 final int cmdLength = cmd.indexOf('&'); 344 extra = cmd.substring(cmdLength); 345 cmd = cmd.substring(0, cmdLength); 346 msg = true; 347 } else if (cmd.startsWith("SendMail&")) { 348 msg = true; 349 } 350 351 // Send the proper Content-Type header; it's always wbxml except for messages when 352 // the EAS protocol version is < 14.0 353 // If entity is null (e.g. for attachments), don't set this header 354 final String contentType; 355 if (msg && (getProtocolVersion() < Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE)) { 356 contentType = MimeUtility.MIME_TYPE_RFC822; 357 } else if (entity != null) { 358 contentType = EAS_14_MIME_TYPE; 359 } 360 else { 361 contentType = null; 362 } 363 final String uriString; 364 if (extra == null) { 365 uriString = makeUriString(cmd); 366 } else { 367 uriString = makeUriString(cmd, extra); 368 } 369 final HttpPost method = makePost(uriString, entity, contentType, !isPingCommand); 370 // NOTE 371 // The next lines are added at the insistence of $VENDOR, who is seeing inappropriate 372 // network activity related to the Ping command on some networks with some servers. 373 // This code should be removed when the underlying issue is resolved 374 if (isPingCommand) { 375 method.setHeader("Connection", "close"); 376 } 377 return executeHttpUriRequest(method, timeout); 378 } 379 380 public EasResponse sendHttpClientPost(final String cmd, final byte[] bytes, 381 final long timeout) throws IOException, CertificateException { 382 final ByteArrayEntity entity; 383 if (bytes == null) { 384 entity = null; 385 } else { 386 entity = new ByteArrayEntity(bytes); 387 } 388 return sendHttpClientPost(cmd, entity, timeout); 389 } 390 391 protected EasResponse sendHttpClientPost(final String cmd, final byte[] bytes) 392 throws IOException, CertificateException { 393 return sendHttpClientPost(cmd, bytes, COMMAND_TIMEOUT); 394 } 395 396 /** 397 * Executes an {@link HttpUriRequest}. 398 * Note: this function must not be called by multiple threads concurrently. Only one thread may 399 * send server requests from a particular object at a time. 400 * @param method The post to execute. 401 * @param timeout The timeout to use. 402 * @return The response from the Exchange server. 403 * @throws IOException 404 */ 405 public EasResponse executeHttpUriRequest(final HttpUriRequest method, final long timeout) 406 throws IOException, CertificateException { 407 LogUtils.d(TAG, "EasServerConnection about to make request %s", method.getRequestLine()); 408 // The synchronized blocks are here to support the stop() function, specifically to handle 409 // when stop() is called first. Notably, they are NOT here in order to guard against 410 // concurrent access to this function, which is not supported. 411 synchronized (this) { 412 if (mStopped) { 413 mStopped = false; 414 // If this gets stopped after the POST actually starts, it throws an IOException. 415 // Therefore if we get stopped here, let's throw the same sort of exception, so 416 // callers can equate IOException with "this POST got killed for some reason". 417 throw new IOException("Command was stopped before POST"); 418 } 419 mPendingRequest = method; 420 } 421 boolean postCompleted = false; 422 try { 423 final EasResponse response = EasResponse.fromHttpRequest(getClientConnectionManager(), 424 getHttpClient(timeout), method); 425 postCompleted = true; 426 return response; 427 } finally { 428 synchronized (this) { 429 mPendingRequest = null; 430 if (postCompleted) { 431 mStoppedReason = STOPPED_REASON_NONE; 432 } 433 } 434 } 435 } 436 437 protected EasResponse executePost(final HttpPost method) 438 throws IOException, CertificateException { 439 return executeHttpUriRequest(method, COMMAND_TIMEOUT); 440 } 441 442 /** 443 * If called while this object is executing a POST, interrupt it with an {@link IOException}. 444 * Otherwise cause the next attempt to execute a POST to be interrupted with an 445 * {@link IOException}. 446 * @param reason The reason for requesting a stop. This should be one of the STOPPED_REASON_* 447 * constants defined in this class, other than {@link #STOPPED_REASON_NONE} which 448 * is used to signify that no stop has occurred. 449 * This class simply stores the value; subclasses are responsible for checking 450 * this value when catching the {@link IOException} and responding appropriately. 451 */ 452 public synchronized void stop(final int reason) { 453 // Only process legitimate reasons. 454 if (reason >= STOPPED_REASON_ABORT && reason <= STOPPED_REASON_RESTART) { 455 final boolean isMidPost = (mPendingRequest != null); 456 LogUtils.i(TAG, "%s with reason %d", (isMidPost ? "Interrupt" : "Stop next"), reason); 457 mStoppedReason = reason; 458 if (isMidPost) { 459 mPendingRequest.abort(); 460 } else { 461 mStopped = true; 462 } 463 } 464 } 465 466 /** 467 * @return The reason supplied to the last call to {@link #stop}, or 468 * {@link #STOPPED_REASON_NONE} if {@link #stop} hasn't been called since the last 469 * successful POST. 470 */ 471 public synchronized int getStoppedReason() { 472 return mStoppedReason; 473 } 474 475 /** 476 * Try to register our client certificate, if needed. 477 * @return True if we succeeded or didn't need a client cert, false if we failed to register it. 478 */ 479 public boolean registerClientCert() { 480 if (mHostAuth.mClientCertAlias != null) { 481 try { 482 getClientConnectionManager().registerClientCert(mContext, mHostAuth); 483 } catch (final CertificateException e) { 484 // The client certificate the user specified is invalid/inaccessible. 485 return false; 486 } 487 } 488 return true; 489 } 490 491 /** 492 * @return Whether {@link #setProtocolVersion} was last called with a non-null value. Note that 493 * at construction time it is set to whatever protocol version is in the account. 494 */ 495 public boolean isProtocolVersionSet() { 496 return mProtocolVersionIsSet; 497 } 498 499 /** 500 * Convenience method for adding a Message to an account's outbox 501 * @param account The {@link Account} from which to send the message. 502 * @param msg The message to send 503 */ 504 protected void sendMessage(final Account account, final EmailContent.Message msg) { 505 long mailboxId = Mailbox.findMailboxOfType(mContext, account.mId, Mailbox.TYPE_OUTBOX); 506 // TODO: Improve system mailbox handling. 507 if (mailboxId == Mailbox.NO_MAILBOX) { 508 LogUtils.d(TAG, "No outbox for account %d, creating it", account.mId); 509 final Mailbox outbox = 510 Mailbox.newSystemMailbox(mContext, account.mId, Mailbox.TYPE_OUTBOX); 511 outbox.save(mContext); 512 mailboxId = outbox.mId; 513 } 514 msg.mMailboxKey = mailboxId; 515 msg.mAccountKey = account.mId; 516 msg.save(mContext); 517 requestSyncForMailbox(new android.accounts.Account(account.mEmailAddress, 518 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), EmailContent.AUTHORITY, mailboxId); 519 } 520 521 /** 522 * Issue a {@link android.content.ContentResolver#requestSync} for a specific mailbox. 523 * @param amAccount The {@link android.accounts.Account} for the account we're pinging. 524 * @param authority The authority for the mailbox that needs to sync. 525 * @param mailboxId The id of the mailbox that needs to sync. 526 */ 527 protected static void requestSyncForMailbox(final android.accounts.Account amAccount, 528 final String authority, final long mailboxId) { 529 final Bundle extras = Mailbox.createSyncBundle(mailboxId); 530 ContentResolver.requestSync(amAccount, authority, extras); 531 LogUtils.d(TAG, "requestSync EasServerConnection requestSyncForMailbox %s, %s", 532 amAccount.toString(), extras.toString()); 533 } 534 } 535