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
17package com.android.exchange.eas;
18
19import android.content.ContentResolver;
20import android.content.ContentValues;
21import android.content.Context;
22import android.database.Cursor;
23import android.os.Bundle;
24import android.os.SystemClock;
25import android.provider.CalendarContract;
26import android.provider.ContactsContract;
27import android.text.format.DateUtils;
28
29import com.android.emailcommon.provider.Account;
30import com.android.emailcommon.provider.EmailContent;
31import com.android.emailcommon.provider.EmailContent.AccountColumns;
32import com.android.emailcommon.provider.EmailContent.MailboxColumns;
33import com.android.emailcommon.provider.Mailbox;
34import com.android.exchange.CommandStatusException.CommandStatus;
35import com.android.exchange.Eas;
36import com.android.exchange.EasResponse;
37import com.android.exchange.adapter.PingParser;
38import com.android.exchange.adapter.Serializer;
39import com.android.exchange.adapter.Tags;
40import com.android.mail.utils.LogUtils;
41
42import org.apache.http.HttpEntity;
43
44import java.io.IOException;
45import java.util.ArrayList;
46import 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 */
52public 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