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.eas; 18 19 import android.content.ContentResolver; 20 import android.content.ContentValues; 21 import android.content.Context; 22 import android.database.Cursor; 23 import android.os.Bundle; 24 import android.os.SystemClock; 25 import android.text.format.DateUtils; 26 27 import com.android.emailcommon.provider.Account; 28 import com.android.emailcommon.provider.EmailContent; 29 import com.android.emailcommon.provider.EmailContent.AccountColumns; 30 import com.android.emailcommon.provider.EmailContent.MailboxColumns; 31 import com.android.emailcommon.provider.Mailbox; 32 import com.android.exchange.CommandStatusException.CommandStatus; 33 import com.android.exchange.Eas; 34 import com.android.exchange.EasResponse; 35 import com.android.exchange.adapter.PingParser; 36 import com.android.exchange.adapter.Serializer; 37 import com.android.exchange.adapter.Tags; 38 import com.android.mail.utils.LogUtils; 39 40 import org.apache.http.HttpEntity; 41 42 import java.io.IOException; 43 import java.util.ArrayList; 44 import java.util.HashSet; 45 46 /** 47 * Performs an Exchange Ping, which is the command for receiving push notifications. 48 * See http://msdn.microsoft.com/en-us/library/ee200913(v=exchg.80).aspx for more details. 49 */ 50 public class EasPing extends EasOperation { 51 private static final String TAG = Eas.LOG_TAG; 52 53 private static final String WHERE_ACCOUNT_KEY_AND_SERVER_ID = 54 MailboxColumns.ACCOUNT_KEY + "=? and " + MailboxColumns.SERVER_ID + "=?"; 55 56 private final android.accounts.Account mAmAccount; 57 private long mPingDuration; 58 59 /** 60 * The default heartbeat interval specified to the Exchange server. This is the maximum amount 61 * of time (in seconds) that the server should wait before responding to the ping request. 62 */ 63 private static final long DEFAULT_PING_HEARTBEAT = 64 8 * (DateUtils.MINUTE_IN_MILLIS / DateUtils.SECOND_IN_MILLIS); 65 66 /** 67 * The minimum heartbeat interval we should ever use, in seconds. 68 */ 69 private static final long MINIMUM_PING_HEARTBEAT = 70 8 * (DateUtils.MINUTE_IN_MILLIS / DateUtils.SECOND_IN_MILLIS); 71 72 /** 73 * The maximum heartbeat interval we should ever use, in seconds. 74 */ 75 private static final long MAXIMUM_PING_HEARTBEAT = 76 28 * (DateUtils.MINUTE_IN_MILLIS / DateUtils.SECOND_IN_MILLIS); 77 78 /** 79 * The maximum amount that we can change with each adjustment, in seconds. 80 */ 81 private static final long MAXIMUM_HEARTBEAT_INCREMENT = 82 5 * (DateUtils.MINUTE_IN_MILLIS / DateUtils.SECOND_IN_MILLIS); 83 84 /** 85 * The extra time for the timeout used for the HTTP POST (in milliseconds). Notionally this 86 * should be the same as ping heartbeat but in practice is a few seconds longer to allow for 87 * latency in the server's response. 88 */ 89 private static final long EXTRA_POST_TIMEOUT_MILLIS = 5 * DateUtils.SECOND_IN_MILLIS; 90 91 public EasPing(final Context context, final Account account, 92 final android.accounts.Account amAccount) { 93 super(context, account); 94 mAmAccount = amAccount; 95 mPingDuration = account.mPingDuration; 96 if (mPingDuration == 0) { 97 mPingDuration = DEFAULT_PING_HEARTBEAT; 98 } 99 LogUtils.d(TAG, "initial ping duration " + mPingDuration + " account " + getAccountId()); 100 } 101 102 public final int doPing() { 103 final long startTime = SystemClock.elapsedRealtime(); 104 final int result = performOperation(); 105 if (result == RESULT_RESTART) { 106 return PingParser.STATUS_EXPIRED; 107 } else if (result == RESULT_REQUEST_FAILURE) { 108 final long timeoutDuration = SystemClock.elapsedRealtime() - startTime; 109 LogUtils.d(TAG, "doPing request failure, timed out after %d millis", timeoutDuration); 110 decreasePingDuration(); 111 } 112 return result; 113 } 114 115 private void decreasePingDuration() { 116 mPingDuration = Math.max(MINIMUM_PING_HEARTBEAT, 117 mPingDuration - MAXIMUM_HEARTBEAT_INCREMENT); 118 LogUtils.d(TAG, "decreasePingDuration adjusting by " + MAXIMUM_HEARTBEAT_INCREMENT + 119 " new duration " + mPingDuration + " account " + getAccountId()); 120 storePingDuration(); 121 } 122 123 private void increasePingDuration() { 124 mPingDuration = Math.min(MAXIMUM_PING_HEARTBEAT, 125 mPingDuration + MAXIMUM_HEARTBEAT_INCREMENT); 126 LogUtils.d(TAG, "increasePingDuration adjusting by " + MAXIMUM_HEARTBEAT_INCREMENT + 127 " new duration " + mPingDuration + " account " + getAccountId()); 128 storePingDuration(); 129 } 130 131 private void storePingDuration() { 132 final ContentValues values = new ContentValues(1); 133 values.put(AccountColumns.PING_DURATION, mPingDuration); 134 Account.update(mContext, Account.CONTENT_URI, getAccountId(), values); 135 } 136 137 public final android.accounts.Account getAmAccount() { 138 return mAmAccount; 139 } 140 141 @Override 142 protected String getCommand() { 143 return "Ping"; 144 } 145 146 @Override 147 protected HttpEntity getRequestEntity() throws IOException { 148 // Get the mailboxes that need push notifications. 149 final Cursor c = Mailbox.getMailboxesForPush(mContext.getContentResolver(), 150 getAccountId()); 151 if (c == null) { 152 throw new IllegalStateException("Could not read mailboxes"); 153 } 154 155 // TODO: Ideally we never even get here unless we already know we want a push. 156 Serializer s = null; 157 try { 158 while (c.moveToNext()) { 159 final Mailbox mailbox = new Mailbox(); 160 mailbox.restore(c); 161 s = handleOneMailbox(s, mailbox); 162 } 163 } finally { 164 c.close(); 165 } 166 167 if (s == null) { 168 abort(); 169 throw new IOException("No mailboxes want push"); 170 } 171 // This sequence of end()s corresponds to the start()s that occur in handleOneMailbox when 172 // the Serializer is first created. If either side changes, the other must be kept in sync. 173 s.end().end().done(); 174 return makeEntity(s); 175 } 176 177 @Override 178 protected int handleResponse(final EasResponse response) throws IOException { 179 if (response.isEmpty()) { 180 // TODO this should probably not be an IOException, maybe something more descriptive? 181 throw new IOException("Empty ping response"); 182 } 183 184 // Handle a valid response. 185 final PingParser pp = new PingParser(response.getInputStream()); 186 pp.parse(); 187 final int pingStatus = pp.getPingStatus(); 188 189 // Take the appropriate action for this response. 190 // Many of the responses require no explicit action here, they just influence 191 // our re-ping behavior, which is handled by the caller. 192 final long accountId = getAccountId(); 193 switch (pingStatus) { 194 case PingParser.STATUS_EXPIRED: 195 LogUtils.i(TAG, "Ping expired for account %d", accountId); 196 // On successful expiration, we can increase our ping duration 197 increasePingDuration(); 198 break; 199 case PingParser.STATUS_CHANGES_FOUND: 200 LogUtils.i(TAG, "Ping found changed folders for account %d", accountId); 201 requestSyncForSyncList(pp.getSyncList()); 202 break; 203 case PingParser.STATUS_REQUEST_INCOMPLETE: 204 case PingParser.STATUS_REQUEST_MALFORMED: 205 // These two cases indicate that the ping request was somehow bad. 206 // TODO: It's insanity to re-ping with the same data and expect a different 207 // result. Improve this if possible. 208 LogUtils.e(TAG, "Bad ping request for account %d", accountId); 209 break; 210 case PingParser.STATUS_REQUEST_HEARTBEAT_OUT_OF_BOUNDS: 211 long newDuration = pp.getHeartbeatInterval(); 212 LogUtils.i(TAG, "Heartbeat out of bounds for account %d, " + 213 "old duration %d new duration %d", accountId, mPingDuration, newDuration); 214 mPingDuration = newDuration; 215 storePingDuration(); 216 break; 217 case PingParser.STATUS_REQUEST_TOO_MANY_FOLDERS: 218 LogUtils.i(TAG, "Too many folders for account %d", accountId); 219 break; 220 case PingParser.STATUS_FOLDER_REFRESH_NEEDED: 221 LogUtils.i(TAG, "FolderSync needed for account %d", accountId); 222 requestFolderSync(); 223 break; 224 case PingParser.STATUS_SERVER_ERROR: 225 LogUtils.i(TAG, "Server error for account %d", accountId); 226 break; 227 case CommandStatus.SERVER_ERROR_RETRY: 228 // Try again later. 229 LogUtils.i(TAG, "Retryable server error for account %d", accountId); 230 return RESULT_RESTART; 231 232 // These errors should not happen. 233 case CommandStatus.USER_DISABLED_FOR_SYNC: 234 case CommandStatus.USERS_DISABLED_FOR_SYNC: 235 case CommandStatus.USER_ON_LEGACY_SERVER_CANT_SYNC: 236 case CommandStatus.DEVICE_QUARANTINED: 237 case CommandStatus.ACCESS_DENIED: 238 case CommandStatus.USER_ACCOUNT_DISABLED: 239 case CommandStatus.NOT_PROVISIONABLE_PARTIAL: 240 case CommandStatus.NOT_PROVISIONABLE_LEGACY_DEVICE: 241 case CommandStatus.TOO_MANY_PARTNERSHIPS: 242 LogUtils.e(TAG, "Unexpected error %d on ping", pingStatus); 243 return RESULT_AUTHENTICATION_ERROR; 244 245 // These errors should not happen. 246 case CommandStatus.SYNC_STATE_NOT_FOUND: 247 case CommandStatus.SYNC_STATE_LOCKED: 248 case CommandStatus.SYNC_STATE_CORRUPT: 249 case CommandStatus.SYNC_STATE_EXISTS: 250 case CommandStatus.SYNC_STATE_INVALID: 251 case CommandStatus.NEEDS_PROVISIONING_WIPE: 252 case CommandStatus.NEEDS_PROVISIONING: 253 case CommandStatus.NEEDS_PROVISIONING_REFRESH: 254 case CommandStatus.NEEDS_PROVISIONING_INVALID: 255 case CommandStatus.WTF_INVALID_COMMAND: 256 case CommandStatus.WTF_INVALID_PROTOCOL: 257 case CommandStatus.WTF_DEVICE_CLAIMS_EXTERNAL_MANAGEMENT: 258 case CommandStatus.WTF_UNKNOWN_ITEM_TYPE: 259 case CommandStatus.WTF_REQUIRES_PROXY_WITHOUT_SSL: 260 case CommandStatus.ITEM_NOT_FOUND: 261 LogUtils.e(TAG, "Unexpected error %d on ping", pingStatus); 262 return RESULT_OTHER_FAILURE; 263 264 default: 265 break; 266 } 267 268 return pingStatus; 269 } 270 271 272 @Override 273 protected boolean addPolicyKeyHeaderToRequest() { 274 return false; 275 } 276 277 @Override 278 protected long getTimeout() { 279 return mPingDuration * DateUtils.SECOND_IN_MILLIS + EXTRA_POST_TIMEOUT_MILLIS; 280 } 281 282 /** 283 * If mailbox is eligible for push, add it to the ping request, creating the {@link Serializer} 284 * for the request if necessary. 285 * @param mailbox The mailbox to check. 286 * @param s The {@link Serializer} for this request, or null if it hasn't been created yet. 287 * @return The {@link Serializer} for this request, or null if it hasn't been created yet. 288 * @throws IOException 289 */ 290 private Serializer handleOneMailbox(Serializer s, final Mailbox mailbox) throws IOException { 291 // We can't push until the initial sync is done 292 if (mailbox.mSyncKey != null && !mailbox.mSyncKey.equals("0")) { 293 if (ContentResolver.getSyncAutomatically(mAmAccount, 294 Mailbox.getAuthority(mailbox.mType))) { 295 if (s == null) { 296 // No serializer yet, so create and initialize it. 297 // Note that these start()s correspond to the end()s in doInBackground. 298 // If either side changes, the other must be kept in sync. 299 s = new Serializer(); 300 s.start(Tags.PING_PING); 301 s.data(Tags.PING_HEARTBEAT_INTERVAL, Long.toString(mPingDuration)); 302 s.start(Tags.PING_FOLDERS); 303 } 304 s.start(Tags.PING_FOLDER); 305 s.data(Tags.PING_ID, mailbox.mServerId); 306 s.data(Tags.PING_CLASS, Eas.getFolderClass(mailbox.mType)); 307 s.end(); 308 } 309 } 310 return s; 311 } 312 313 /** 314 * Make the appropriate calls to {@link ContentResolver#requestSync} indicated by the 315 * current ping response. 316 * @param syncList The list of folders that need to be synced. 317 */ 318 private void requestSyncForSyncList(final ArrayList<String> syncList) { 319 final String[] bindArguments = new String[2]; 320 bindArguments[0] = Long.toString(getAccountId()); 321 322 final ArrayList<Long> mailboxIds = new ArrayList<Long>(); 323 final HashSet<Integer> contentTypes = new HashSet<Integer>(); 324 325 for (final String serverId : syncList) { 326 bindArguments[1] = serverId; 327 // TODO: Rather than one query per ping mailbox, do it all in one? 328 final Cursor c = mContext.getContentResolver().query(Mailbox.CONTENT_URI, 329 Mailbox.CONTENT_PROJECTION, WHERE_ACCOUNT_KEY_AND_SERVER_ID, 330 bindArguments, null); 331 if (c == null) { 332 // TODO: proper error handling. 333 break; 334 } 335 try { 336 /** 337 * Check the boxes reporting changes to see if there really were any... 338 * We do this because bugs in various Exchange servers can put us into a 339 * looping behavior by continually reporting changes in a mailbox, even 340 * when there aren't any. 341 * 342 * This behavior is seemingly random, and therefore we must code 343 * defensively by backing off of push behavior when it is detected. 344 * 345 * One known cause, on certain Exchange 2003 servers, is acknowledged by 346 * Microsoft, and the server hotfix for this case can be found at 347 * http://support.microsoft.com/kb/923282 348 */ 349 // TODO: Implement the above. 350 /* 351 String status = c.getString(Mailbox.CONTENT_SYNC_STATUS_COLUMN); 352 int type = ExchangeService.getStatusType(status); 353 // This check should always be true... 354 if (type == ExchangeService.SYNC_PING) { 355 int changeCount = ExchangeService.getStatusChangeCount(status); 356 if (changeCount > 0) { 357 errorMap.remove(serverId); 358 } else if (changeCount == 0) { 359 // This means that a ping reported changes in error; we keep a 360 // count of consecutive errors of this kind 361 String name = c.getString(Mailbox.CONTENT_DISPLAY_NAME_COLUMN); 362 Integer failures = errorMap.get(serverId); 363 if (failures == null) { 364 userLog("Last ping reported changes in error for: ", name); 365 errorMap.put(serverId, 1); 366 } else if (failures > MAX_PING_FAILURES) { 367 // We'll back off of push for this box 368 pushFallback(c.getLong(Mailbox.CONTENT_ID_COLUMN)); 369 continue; 370 } else { 371 userLog("Last ping reported changes in error for: ", name); 372 errorMap.put(serverId, failures + 1); 373 } 374 } 375 } 376 */ 377 if (c.moveToFirst()) { 378 final long mailboxId = c.getLong(Mailbox.CONTENT_ID_COLUMN); 379 final int contentType = c.getInt(Mailbox.CONTENT_TYPE_COLUMN); 380 mailboxIds.add(mailboxId); 381 contentTypes.add(contentType); 382 } 383 } finally { 384 c.close(); 385 } 386 } 387 388 for (final int type : contentTypes) { 389 switch (type) { 390 case Mailbox.TYPE_CALENDAR: 391 case Mailbox.TYPE_CONTACTS: 392 // Ask for a no-op sync so that we'll see calendar or contacts 393 // syncing in settings. 394 requestNoOpSync(mAmAccount, Mailbox.getAuthority(type)); 395 default: 396 // Do nothing, we're already doing an Email sync. 397 } 398 } 399 // Ask the EmailSyncAdapter to sync all of these mailboxes, whether they're regular 400 // mailboxes or calendar or contacts. 401 requestSyncForMailboxes(mAmAccount, mailboxIds); 402 } 403 404 /** 405 * Issue a {@link ContentResolver#requestSync} to trigger a FolderSync for an account. 406 */ 407 private void requestFolderSync() { 408 final Bundle extras = new Bundle(1); 409 extras.putBoolean(Mailbox.SYNC_EXTRA_ACCOUNT_ONLY, true); 410 ContentResolver.requestSync(mAmAccount, EmailContent.AUTHORITY, extras); 411 LogUtils.i(LOG_TAG, "requestFolderSync EasOperation %s, %s", 412 mAmAccount.toString(), extras.toString()); 413 } 414 415 /** 416 * Request a ping-only sync via the SyncManager. This is used in error paths, which is also why 417 * we don't just create and start a new ping task immediately: in the case where we have loss 418 * of network, we want to take advantage of the SyncManager to schedule this when we expect it 419 * to be able to work. 420 * @param amAccount Account that needs to ping. 421 */ 422 public static void requestPing(final android.accounts.Account amAccount) { 423 final Bundle extras = new Bundle(2); 424 extras.putBoolean(Mailbox.SYNC_EXTRA_PUSH_ONLY, true); 425 extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); 426 ContentResolver.requestSync(amAccount, EmailContent.AUTHORITY, extras); 427 LogUtils.i(LOG_TAG, "requestPing EasOperation %s, %s", 428 amAccount.toString(), extras.toString()); 429 } 430 431 } 432