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