Home | History | Annotate | Download | only in eas
      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