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