1/*
2 * Copyright (C) 2014 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.service;
18
19import android.app.AlarmManager;
20import android.app.PendingIntent;
21import android.app.Service;
22import android.content.ContentResolver;
23import android.content.Context;
24import android.content.Intent;
25import android.os.Bundle;
26import android.os.SystemClock;
27import android.support.v4.util.LongSparseArray;
28import android.text.format.DateUtils;
29
30import com.android.emailcommon.provider.Account;
31import com.android.emailcommon.provider.EmailContent;
32import com.android.emailcommon.provider.Mailbox;
33import com.android.exchange.Eas;
34import com.android.exchange.eas.EasPing;
35import com.android.mail.utils.LogUtils;
36
37import java.util.concurrent.locks.Condition;
38import java.util.concurrent.locks.Lock;
39import java.util.concurrent.locks.ReentrantLock;
40
41/**
42 * Bookkeeping for handling synchronization between pings and other sync related operations.
43 * "Ping" refers to a hanging POST or GET that is used to receive push notifications. Ping is
44 * the term for the Exchange command, but this code should be generic enough to be extended to IMAP.
45 *
46 * Basic rules of how these interact (note that all rules are per account):
47 * - Only one operation (ping or other active sync operation) may run at a time.
48 * - For shorthand, this class uses "sync" to mean "non-ping operation"; most such operations are
49 *   sync ops, but some may not be (e.g. EAS Settings).
50 * - Syncs can come from many sources concurrently; this class must serialize them.
51 *
52 * WHEN A SYNC STARTS:
53 * - If nothing is running, proceed.
54 * - If something is already running: wait until it's done.
55 * - If the running thing is a ping task: interrupt it.
56 *
57 * WHEN A SYNC ENDS:
58 * - If there are waiting syncs: signal one to proceed.
59 * - If there are no waiting syncs and this account is configured for push: start a ping.
60 * - Otherwise: This account is now idle.
61 *
62 * WHEN A PING TASK ENDS:
63 * - A ping task loops until either it's interrupted by a sync (in which case, there will be one or
64 *   more waiting syncs when the ping terminates), or encounters an error.
65 * - If there are waiting syncs, and we were interrupted: signal one to proceed.
66 * - If there are waiting syncs, but the ping terminated with an error: TODO: How to handle?
67 * - If there are no waiting syncs and this account is configured for push: This means the ping task
68 *   was terminated due to an error. Handle this by sending a sync request through the SyncManager
69 *   that doesn't actually do any syncing, and whose only effect is to restart the ping.
70 * - Otherwise: This account is now idle.
71 *
72 * WHEN AN ACCOUNT WANTS TO START OR CHANGE ITS PUSH BEHAVIOR:
73 * - If nothing is running, start a new ping task.
74 * - If a ping task is currently running, restart it with the new settings.
75 * - If a sync is currently running, do nothing.
76 *
77 * WHEN AN ACCOUNT WANTS TO STOP GETTING PUSH:
78 * - If nothing is running, do nothing.
79 * - If a ping task is currently running, interrupt it.
80 */
81public class PingSyncSynchronizer {
82
83    private static final String TAG = Eas.LOG_TAG;
84
85    private static final long SYNC_ERROR_BACKOFF_MILLIS =  DateUtils.MINUTE_IN_MILLIS;
86    private static final String EXTRA_START_PING = "START_PING";
87    private static final String EXTRA_PING_ACCOUNT = "PING_ACCOUNT";
88
89    // Enable this to make pings get automatically renewed every hour. This
90    // should not be needed, but if there is a software error that results in
91    // the ping being lost, this is a fallback to make sure that messages are
92    // not delayed more than an hour.
93    private static final boolean SCHEDULE_KICK = false;
94    private static final long KICK_SYNC_INTERVAL_SECONDS =
95            DateUtils.HOUR_IN_MILLIS / DateUtils.SECOND_IN_MILLIS;
96
97    /**
98     * This class handles bookkeeping for a single account.
99     */
100    private static class AccountSyncState {
101        /** The currently running {@link PingTask}, or null if we aren't in the middle of a Ping. */
102        private PingTask mPingTask;
103
104        /**
105         * Tracks whether this account wants to get push notifications, based on calls to
106         * {@link #pushModify} and {@link #pushStop} (i.e. it tracks the last requested push state).
107         */
108        private boolean mPushEnabled;
109
110        /**
111         * The number of syncs that are blocked waiting for the current operation to complete.
112         * Unlike Pings, sync operations do not start their own tasks and are assumed to run in
113         * whatever thread calls into this class.
114         */
115        private int mSyncCount;
116
117        /** The condition on which to block syncs that need to wait. */
118        private Condition mCondition;
119
120        public AccountSyncState(final Lock lock ) {
121            mPingTask = null;
122            mPushEnabled = false;
123            mSyncCount = 0;
124            mCondition = lock.newCondition();
125        }
126
127        /**
128         * Update bookkeeping for a new sync:
129         * - Stop the Ping if there is one.
130         * - Wait until there's nothing running for this account before proceeding.
131         */
132        public void syncStart() {
133            ++mSyncCount;
134            if (mPingTask != null) {
135                // Syncs are higher priority than Ping -- terminate the Ping.
136                LogUtils.d(TAG, "Sync is pre-empting a ping");
137                mPingTask.stop();
138            }
139            if (mPingTask != null || mSyncCount > 1) {
140                // There’s something we need to wait for before we can proceed.
141                try {
142                    LogUtils.d(TAG, "Sync needs to wait: Ping: %s, Pending tasks: %d",
143                            mPingTask != null ? "yes" : "no", mSyncCount);
144                    mCondition.await();
145                } catch (final InterruptedException e) {
146                    // TODO: Handle this properly. Not catching it might be the right answer.
147                }
148            }
149        }
150
151        /**
152         * Update bookkeeping when a sync completes. This includes signaling pending ops to
153         * go ahead, or starting the ping if appropriate and there are no waiting ops.
154         * @return Whether this account is now idle.
155         */
156        public boolean syncEnd(final boolean lastSyncHadError, final Account account,
157                               final PingSyncSynchronizer synchronizer) {
158            --mSyncCount;
159            if (mSyncCount > 0) {
160                LogUtils.d(TAG, "Signalling a pending sync to proceed.");
161                mCondition.signal();
162                return false;
163            } else {
164                if (mPushEnabled) {
165                    if (lastSyncHadError) {
166                        final android.accounts.Account amAccount =
167                                new android.accounts.Account(account.mEmailAddress,
168                                    Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
169                        scheduleDelayedPing(synchronizer.getContext(), amAccount);
170                        return true;
171                    } else {
172                        final android.accounts.Account amAccount =
173                                new android.accounts.Account(account.mEmailAddress,
174                                        Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
175                        mPingTask = new PingTask(synchronizer.getContext(), account, amAccount,
176                                synchronizer);
177                        mPingTask.start();
178                        return false;
179                    }
180                }
181            }
182            return true;
183        }
184
185        /**
186         * Update bookkeeping when the ping task terminates, including signaling any waiting ops.
187         * @return Whether this account is now idle.
188         */
189        private boolean pingEnd(final android.accounts.Account amAccount) {
190            mPingTask = null;
191            if (mSyncCount > 0) {
192                mCondition.signal();
193                return false;
194            } else {
195                if (mPushEnabled) {
196                    /**
197                     * This situation only arises if we encountered some sort of error that
198                     * stopped our ping but not due to a sync interruption. In this scenario
199                     * we'll leverage the SyncManager to request a push only sync that will
200                     * restart the ping when the time is right. */
201                    EasPing.requestPing(amAccount);
202                    return false;
203                }
204            }
205            return true;
206        }
207
208        private void scheduleDelayedPing(final Context context,
209                                         final android.accounts.Account amAccount) {
210            LogUtils.d(TAG, "Scheduling a delayed ping.");
211            final Intent intent = new Intent(context, EmailSyncAdapterService.class);
212            intent.setAction(Eas.EXCHANGE_SERVICE_INTENT_ACTION);
213            intent.putExtra(EXTRA_START_PING, true);
214            intent.putExtra(EXTRA_PING_ACCOUNT, amAccount);
215            final PendingIntent pi = PendingIntent.getService(context, 0, intent,
216                    PendingIntent.FLAG_ONE_SHOT);
217            final AlarmManager am = (AlarmManager)context.getSystemService(
218                    Context.ALARM_SERVICE);
219            final long atTime = SystemClock.elapsedRealtime() + SYNC_ERROR_BACKOFF_MILLIS;
220            am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, atTime, pi);
221        }
222
223        /**
224         * Modifies or starts a ping for this account if no syncs are running.
225         */
226        public void pushModify(final Account account, final PingSyncSynchronizer synchronizer) {
227            mPushEnabled = true;
228            final android.accounts.Account amAccount =
229                    new android.accounts.Account(account.mEmailAddress,
230                            Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
231            if (mSyncCount == 0) {
232                if (mPingTask == null) {
233                    // No ping, no running syncs -- start a new ping.
234                    mPingTask = new PingTask(synchronizer.getContext(), account, amAccount,
235                            synchronizer);
236                    mPingTask.start();
237                } else {
238                    // Ping is already running, so tell it to restart to pick up any new params.
239                    mPingTask.restart();
240                }
241            }
242            if (SCHEDULE_KICK) {
243                final Bundle extras = new Bundle(1);
244                extras.putBoolean(Mailbox.SYNC_EXTRA_PUSH_ONLY, true);
245                ContentResolver.addPeriodicSync(amAccount, EmailContent.AUTHORITY, extras,
246                        KICK_SYNC_INTERVAL_SECONDS);
247            }
248        }
249
250        /**
251         * Stop the currently running ping.
252         */
253        public void pushStop() {
254            mPushEnabled = false;
255            if (mPingTask != null) {
256                mPingTask.stop();
257            }
258        }
259    }
260
261    /**
262     * Lock for access to {@link #mAccountStateMap}, also used to create the {@link Condition}s for
263     * each Account.
264     */
265    private final ReentrantLock mLock;
266
267    /**
268     * Map from account ID -> {@link AccountSyncState} for accounts with a running operation.
269     * An account is in this map only when this account is active, i.e. has a ping or sync running
270     * or pending. If an account is not in the middle of a sync and is not configured for push,
271     * it will not be here. This allows to use emptiness of this map to know whether the service
272     * needs to be running, and is also handy when debugging.
273     */
274    private final LongSparseArray<AccountSyncState> mAccountStateMap;
275
276    /** The {@link Service} that this object is managing. */
277    private final Service mService;
278
279    public PingSyncSynchronizer(final Service service) {
280        mLock = new ReentrantLock();
281        mAccountStateMap = new LongSparseArray<AccountSyncState>();
282        mService = service;
283    }
284
285    public Context getContext() {
286        return mService;
287    }
288
289    /**
290     * Gets the {@link AccountSyncState} for an account.
291     * The caller must hold {@link #mLock}.
292     * @param accountId The id for the account we're interested in.
293     * @param createIfNeeded If true, create the account state if it's not already there.
294     * @return The {@link AccountSyncState} for that account, or null if the account is idle and
295     *         createIfNeeded is false.
296     */
297    private AccountSyncState getAccountState(final long accountId, final boolean createIfNeeded) {
298        assert mLock.isHeldByCurrentThread();
299        AccountSyncState state = mAccountStateMap.get(accountId);
300        if (state == null && createIfNeeded) {
301            LogUtils.d(TAG, "PSS adding account state for %d", accountId);
302            state = new AccountSyncState(mLock);
303            mAccountStateMap.put(accountId, state);
304            // TODO: Is this too late to startService?
305            if (mAccountStateMap.size() == 1) {
306                LogUtils.i(TAG, "PSS added first account, starting service");
307                mService.startService(new Intent(mService, mService.getClass()));
308            }
309        }
310        return state;
311    }
312
313    /**
314     * Remove an account from the map. If this was the last account, then also stop this service.
315     * The caller must hold {@link #mLock}.
316     * @param accountId The id for the account we're removing.
317     */
318    private void removeAccount(final long accountId) {
319        assert mLock.isHeldByCurrentThread();
320        LogUtils.d(TAG, "PSS removing account state for %d", accountId);
321        mAccountStateMap.delete(accountId);
322        if (mAccountStateMap.size() == 0) {
323            LogUtils.i(TAG, "PSS removed last account; stopping service.");
324            mService.stopSelf();
325        }
326    }
327
328    public void syncStart(final long accountId) {
329        mLock.lock();
330        try {
331            LogUtils.d(TAG, "PSS syncStart for account %d", accountId);
332            final AccountSyncState accountState = getAccountState(accountId, true);
333            accountState.syncStart();
334        } finally {
335            mLock.unlock();
336        }
337    }
338
339    public void syncEnd(final boolean lastSyncHadError, final Account account) {
340        mLock.lock();
341        try {
342            final long accountId = account.getId();
343            LogUtils.d(TAG, "PSS syncEnd for account %d", account.getId());
344            final AccountSyncState accountState = getAccountState(accountId, false);
345            if (accountState == null) {
346                LogUtils.w(TAG, "PSS syncEnd for account %d but no state found", accountId);
347                return;
348            }
349            if (accountState.syncEnd(lastSyncHadError, account, this)) {
350                removeAccount(accountId);
351            }
352        } finally {
353            mLock.unlock();
354        }
355    }
356
357    public void pingEnd(final long accountId, final android.accounts.Account amAccount) {
358        mLock.lock();
359        try {
360            LogUtils.d(TAG, "PSS pingEnd for account %d", accountId);
361            final AccountSyncState accountState = getAccountState(accountId, false);
362            if (accountState == null) {
363                LogUtils.w(TAG, "PSS pingEnd for account %d but no state found", accountId);
364                return;
365            }
366            if (accountState.pingEnd(amAccount)) {
367                removeAccount(accountId);
368            }
369        } finally {
370            mLock.unlock();
371        }
372    }
373
374    public void pushModify(final Account account) {
375        mLock.lock();
376        try {
377            final long accountId = account.getId();
378            LogUtils.d(TAG, "PSS pushModify for account %d", accountId);
379            final AccountSyncState accountState = getAccountState(accountId, true);
380            accountState.pushModify(account, this);
381        } finally {
382            mLock.unlock();
383        }
384    }
385
386    public void pushStop(final long accountId) {
387        mLock.lock();
388        try {
389            LogUtils.d(TAG, "PSS pushStop for account %d", accountId);
390            final AccountSyncState accountState = getAccountState(accountId, false);
391            if (accountState != null) {
392                accountState.pushStop();
393            }
394        } finally {
395            mLock.unlock();
396        }
397    }
398
399    /**
400     * Stops our service if our map contains no active accounts.
401     */
402    public void stopServiceIfIdle() {
403        mLock.lock();
404        try {
405            LogUtils.d(TAG, "PSS stopIfIdle");
406            if (mAccountStateMap.size() == 0) {
407                LogUtils.i(TAG, "PSS has no active accounts; stopping service.");
408                mService.stopSelf();
409            }
410        } finally {
411            mLock.unlock();
412        }
413    }
414
415    /**
416     * Tells all running ping tasks to stop.
417     */
418    public void stopAllPings() {
419        mLock.lock();
420        try {
421            for (int i = 0; i < mAccountStateMap.size(); ++i) {
422                mAccountStateMap.valueAt(i).pushStop();
423            }
424        } finally {
425            mLock.unlock();
426        }
427    }
428}
429