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