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