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 
17 package com.android.exchange.service;
18 
19 import android.app.AlarmManager;
20 import android.app.PendingIntent;
21 import android.app.Service;
22 import android.content.ContentResolver;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.os.Bundle;
26 import android.os.SystemClock;
27 import android.support.v4.util.LongSparseArray;
28 import android.text.format.DateUtils;
29 
30 import com.android.emailcommon.provider.Account;
31 import com.android.emailcommon.provider.EmailContent;
32 import com.android.emailcommon.provider.Mailbox;
33 import com.android.exchange.Eas;
34 import com.android.exchange.eas.EasPing;
35 import com.android.mail.utils.LogUtils;
36 
37 import java.util.concurrent.locks.Condition;
38 import java.util.concurrent.locks.Lock;
39 import 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  */
81 public 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 
87     // Enable this to make pings get automatically renewed every hour. This
88     // should not be needed, but if there is a software error that results in
89     // the ping being lost, this is a fallback to make sure that messages are
90     // not delayed more than an hour.
91     private static final boolean SCHEDULE_KICK = true;
92     private static final long KICK_SYNC_INTERVAL_SECONDS =
93             DateUtils.HOUR_IN_MILLIS / DateUtils.SECOND_IN_MILLIS;
94 
95     /**
96      * This class handles bookkeeping for a single account.
97      */
98     private class AccountSyncState {
99         /** The currently running {@link PingTask}, or null if we aren't in the middle of a Ping. */
100         private PingTask mPingTask;
101 
102         // Values for mPushEnabled.
103         public static final int PUSH_UNKNOWN = 0;
104         public static final int PUSH_ENABLED = 1;
105         public static final int PUSH_DISABLED = 2;
106 
107         /**
108          * Tracks whether this account wants to get push notifications, based on calls to
109          * {@link #pushModify} and {@link #pushStop} (i.e. it tracks the last requested push state).
110          */
111         private int mPushEnabled;
112 
113         /**
114          * The number of syncs that are blocked waiting for the current operation to complete.
115          * Unlike Pings, sync operations do not start their own tasks and are assumed to run in
116          * whatever thread calls into this class.
117          */
118         private int mSyncCount;
119 
120         /** The condition on which to block syncs that need to wait. */
121         private Condition mCondition;
122 
123         /** The accountId for this accountState, used for logging */
124         private long mAccountId;
125 
AccountSyncState(final Lock lock, final long accountId)126         public AccountSyncState(final Lock lock, final long accountId) {
127             mPingTask = null;
128             // We don't yet have enough information to know whether or not push should be enabled.
129             // We need to look up the account and it's folders, which won't yet exist for a newly
130             // created account.
131             mPushEnabled = PUSH_UNKNOWN;
132             mSyncCount = 0;
133             mCondition = lock.newCondition();
134             mAccountId = accountId;
135         }
136 
137         /**
138          * Update bookkeeping for a new sync:
139          * - Stop the Ping if there is one.
140          * - Wait until there's nothing running for this account before proceeding.
141          */
syncStart()142         public void syncStart() {
143             ++mSyncCount;
144             if (mPingTask != null) {
145                 // Syncs are higher priority than Ping -- terminate the Ping.
146                 LogUtils.i(TAG, "PSS Sync is pre-empting a ping acct:%d", mAccountId);
147                 mPingTask.stop();
148             }
149             if (mPingTask != null || mSyncCount > 1) {
150                 // There’s something we need to wait for before we can proceed.
151                 try {
152                     LogUtils.i(TAG, "PSS Sync needs to wait: Ping: %s, Pending tasks: %d acct: %d",
153                             mPingTask != null ? "yes" : "no", mSyncCount, mAccountId);
154                     mCondition.await();
155                 } catch (final InterruptedException e) {
156                     // TODO: Handle this properly. Not catching it might be the right answer.
157                     LogUtils.i(TAG, "PSS InterruptedException acct:%d", mAccountId);
158                 }
159             }
160         }
161 
162         /**
163          * Update bookkeeping when a sync completes. This includes signaling pending ops to
164          * go ahead, or starting the ping if appropriate and there are no waiting ops.
165          * @return Whether this account is now idle.
166          */
syncEnd(final boolean lastSyncHadError, final Account account, final PingSyncSynchronizer synchronizer)167         public boolean syncEnd(final boolean lastSyncHadError, final Account account,
168                                final PingSyncSynchronizer synchronizer) {
169             --mSyncCount;
170             if (mSyncCount > 0) {
171                 LogUtils.i(TAG, "PSS Signalling a pending sync to proceed acct:%d.",
172                         account.getId());
173                 mCondition.signal();
174                 return false;
175             } else {
176                 if (mPushEnabled == PUSH_UNKNOWN) {
177                     LogUtils.i(TAG, "PSS push enabled is unknown");
178                     mPushEnabled = (EasService.pingNeededForAccount(mService, account) ?
179                             PUSH_ENABLED : PUSH_DISABLED);
180                 }
181                 if (mPushEnabled == PUSH_ENABLED) {
182                     if (lastSyncHadError) {
183                         LogUtils.i(TAG, "PSS last sync had error, scheduling delayed ping acct:%d.",
184                                 account.getId());
185                         scheduleDelayedPing(synchronizer.getContext(), account);
186                         return true;
187                     } else {
188                         LogUtils.i(TAG, "PSS last sync succeeded, starting new ping acct:%d.",
189                                 account.getId());
190                         final android.accounts.Account amAccount =
191                                 new android.accounts.Account(account.mEmailAddress,
192                                         Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
193                         mPingTask = new PingTask(synchronizer.getContext(), account, amAccount,
194                                 synchronizer);
195                         mPingTask.start();
196                         return false;
197                     }
198                 }
199             }
200             LogUtils.i(TAG, "PSS no push enabled acct:%d.", account.getId());
201             return true;
202         }
203 
204         /**
205          * Update bookkeeping when the ping task terminates, including signaling any waiting ops.
206          * @return Whether this account is now idle.
207          */
pingEnd(final android.accounts.Account amAccount)208         private boolean pingEnd(final android.accounts.Account amAccount) {
209             mPingTask = null;
210             if (mSyncCount > 0) {
211                 LogUtils.i(TAG, "PSS pingEnd, syncs still in progress acct:%d.", mAccountId);
212                 mCondition.signal();
213                 return false;
214             } else {
215                 if (mPushEnabled == PUSH_ENABLED || mPushEnabled == PUSH_UNKNOWN) {
216                     if (mPushEnabled == PUSH_UNKNOWN) {
217                         // This should not occur. If mPushEnabled is unknown, we should not
218                         // have started a ping. Still, we'd rather err on the side of restarting
219                         // the ping, so log an error and request a new ping. Eventually we should
220                         // do a sync, and then we'll correctly initialize mPushEnabled in
221                         // syncEnd().
222                         LogUtils.e(TAG, "PSS pingEnd, with mPushEnabled UNKNOWN?");
223                     }
224                     LogUtils.i(TAG, "PSS pingEnd, starting new ping acct:%d.", mAccountId);
225                     /**
226                      * This situation only arises if we encountered some sort of error that
227                      * stopped our ping but not due to a sync interruption. In this scenario
228                      * we'll leverage the SyncManager to request a push only sync that will
229                      * restart the ping when the time is right. */
230                     EasPing.requestPing(amAccount);
231                     return false;
232                 }
233             }
234             LogUtils.i(TAG, "PSS pingEnd, no longer need ping acct:%d.", mAccountId);
235             return true;
236         }
237 
scheduleDelayedPing(final Context context, final Account account)238         private void scheduleDelayedPing(final Context context,
239                                          final Account account) {
240             LogUtils.i(TAG, "PSS Scheduling a delayed ping acct:%d.", account.getId());
241             final Intent intent = new Intent(context, EasService.class);
242             intent.setAction(Eas.EXCHANGE_SERVICE_INTENT_ACTION);
243             intent.putExtra(EasService.EXTRA_START_PING, true);
244             intent.putExtra(EasService.EXTRA_PING_ACCOUNT, account);
245 
246             final PendingIntent pi = PendingIntent.getService(context, 0, intent,
247                     PendingIntent.FLAG_ONE_SHOT);
248             final AlarmManager am = (AlarmManager)context.getSystemService(
249                     Context.ALARM_SERVICE);
250             final long atTime = SystemClock.elapsedRealtime() + SYNC_ERROR_BACKOFF_MILLIS;
251             am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, atTime, pi);
252         }
253 
254         /**
255          * Modifies or starts a ping for this account if no syncs are running.
256          */
pushModify(final Account account, final PingSyncSynchronizer synchronizer)257         public void pushModify(final Account account, final PingSyncSynchronizer synchronizer) {
258             LogUtils.i(LogUtils.TAG, "PSS pushModify acct:%d", account.getId());
259             mPushEnabled = PUSH_ENABLED;
260             final android.accounts.Account amAccount =
261                     new android.accounts.Account(account.mEmailAddress,
262                             Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
263             if (mSyncCount == 0) {
264                 if (mPingTask == null) {
265                     // No ping, no running syncs -- start a new ping.
266                     LogUtils.i(LogUtils.TAG, "PSS starting ping task acct:%d", account.getId());
267                     mPingTask = new PingTask(synchronizer.getContext(), account, amAccount,
268                             synchronizer);
269                     mPingTask.start();
270                 } else {
271                     // Ping is already running, so tell it to restart to pick up any new params.
272                     LogUtils.i(LogUtils.TAG, "PSS restarting ping task acct:%d", account.getId());
273                     mPingTask.restart();
274                 }
275             } else {
276                 LogUtils.i(LogUtils.TAG, "PSS syncs still in progress acct:%d", account.getId());
277             }
278             if (SCHEDULE_KICK) {
279                 final Bundle extras = new Bundle(1);
280                 extras.putBoolean(Mailbox.SYNC_EXTRA_PUSH_ONLY, true);
281                 ContentResolver.addPeriodicSync(amAccount, EmailContent.AUTHORITY, extras,
282                         KICK_SYNC_INTERVAL_SECONDS);
283             }
284         }
285 
286         /**
287          * Stop the currently running ping.
288          */
pushStop()289         public void pushStop() {
290             LogUtils.i(LogUtils.TAG, "PSS pushStop acct:%d", mAccountId);
291             mPushEnabled = PUSH_DISABLED;
292             if (mPingTask != null) {
293                 mPingTask.stop();
294             }
295         }
296     }
297 
298     /**
299      * Lock for access to {@link #mAccountStateMap}, also used to create the {@link Condition}s for
300      * each Account.
301      */
302     private final ReentrantLock mLock;
303 
304     /**
305      * Map from account ID -> {@link AccountSyncState} for accounts with a running operation.
306      * An account is in this map only when this account is active, i.e. has a ping or sync running
307      * or pending. If an account is not in the middle of a sync and is not configured for push,
308      * it will not be here. This allows to use emptiness of this map to know whether the service
309      * needs to be running, and is also handy when debugging.
310      */
311     private final LongSparseArray<AccountSyncState> mAccountStateMap;
312 
313     /** The {@link Service} that this object is managing. */
314     private final Service mService;
315 
PingSyncSynchronizer(final Service service)316     public PingSyncSynchronizer(final Service service) {
317         mLock = new ReentrantLock();
318         mAccountStateMap = new LongSparseArray<AccountSyncState>();
319         mService = service;
320     }
321 
getContext()322     public Context getContext() {
323         return mService;
324     }
325 
326     /**
327      * Gets the {@link AccountSyncState} for an account.
328      * The caller must hold {@link #mLock}.
329      * @param accountId The id for the account we're interested in.
330      * @param createIfNeeded If true, create the account state if it's not already there.
331      * @return The {@link AccountSyncState} for that account, or null if the account is idle and
332      *         createIfNeeded is false.
333      */
getAccountState(final long accountId, final boolean createIfNeeded)334     private AccountSyncState getAccountState(final long accountId, final boolean createIfNeeded) {
335         assert mLock.isHeldByCurrentThread();
336         AccountSyncState state = mAccountStateMap.get(accountId);
337         if (state == null && createIfNeeded) {
338             LogUtils.i(TAG, "PSS adding account state for acct:%d", accountId);
339             state = new AccountSyncState(mLock, accountId);
340             mAccountStateMap.put(accountId, state);
341             // TODO: Is this too late to startService?
342             if (mAccountStateMap.size() == 1) {
343                 LogUtils.i(TAG, "PSS added first account, starting service");
344                 mService.startService(new Intent(mService, mService.getClass()));
345             }
346         }
347         return state;
348     }
349 
350     /**
351      * Remove an account from the map. If this was the last account, then also stop this service.
352      * The caller must hold {@link #mLock}.
353      * @param accountId The id for the account we're removing.
354      */
removeAccount(final long accountId)355     private void removeAccount(final long accountId) {
356         assert mLock.isHeldByCurrentThread();
357         LogUtils.i(TAG, "PSS removing account state for acct:%d", accountId);
358         mAccountStateMap.delete(accountId);
359         if (mAccountStateMap.size() == 0) {
360             LogUtils.i(TAG, "PSS removed last account; stopping service.");
361             mService.stopSelf();
362         }
363     }
364 
syncStart(final long accountId)365     public void syncStart(final long accountId) {
366         mLock.lock();
367         try {
368             LogUtils.i(TAG, "PSS syncStart for account acct:%d", accountId);
369             final AccountSyncState accountState = getAccountState(accountId, true);
370             accountState.syncStart();
371         } finally {
372             mLock.unlock();
373         }
374     }
375 
syncEnd(final boolean lastSyncHadError, final Account account)376     public void syncEnd(final boolean lastSyncHadError, final Account account) {
377         mLock.lock();
378         try {
379             final long accountId = account.getId();
380             LogUtils.i(TAG, "PSS syncEnd for account acct:%d", account.getId());
381             final AccountSyncState accountState = getAccountState(accountId, false);
382             if (accountState == null) {
383                 LogUtils.w(TAG, "PSS syncEnd for account %d but no state found", accountId);
384                 return;
385             }
386             if (accountState.syncEnd(lastSyncHadError, account, this)) {
387                 removeAccount(accountId);
388             }
389         } finally {
390             mLock.unlock();
391         }
392     }
393 
pingEnd(final long accountId, final android.accounts.Account amAccount)394     public void pingEnd(final long accountId, final android.accounts.Account amAccount) {
395         mLock.lock();
396         try {
397             LogUtils.i(TAG, "PSS pingEnd for account %d", accountId);
398             final AccountSyncState accountState = getAccountState(accountId, false);
399             if (accountState == null) {
400                 LogUtils.w(TAG, "PSS pingEnd for account %d but no state found", accountId);
401                 return;
402             }
403             if (accountState.pingEnd(amAccount)) {
404                 removeAccount(accountId);
405             }
406         } finally {
407             mLock.unlock();
408         }
409     }
410 
pushModify(final Account account)411     public void pushModify(final Account account) {
412         mLock.lock();
413         try {
414             final long accountId = account.getId();
415             LogUtils.i(TAG, "PSS pushModify acct:%d", accountId);
416             final AccountSyncState accountState = getAccountState(accountId, true);
417             accountState.pushModify(account, this);
418         } finally {
419             mLock.unlock();
420         }
421     }
422 
pushStop(final long accountId)423     public void pushStop(final long accountId) {
424         mLock.lock();
425         try {
426             LogUtils.i(TAG, "PSS pushStop acct:%d", accountId);
427             final AccountSyncState accountState = getAccountState(accountId, false);
428             if (accountState != null) {
429                 accountState.pushStop();
430             }
431         } finally {
432             mLock.unlock();
433         }
434     }
435 
436     /**
437      * Stops our service if our map contains no active accounts.
438      */
stopServiceIfIdle()439     public void stopServiceIfIdle() {
440         mLock.lock();
441         try {
442             LogUtils.i(TAG, "PSS stopIfIdle");
443             if (mAccountStateMap.size() == 0) {
444                 LogUtils.i(TAG, "PSS has no active accounts; stopping service.");
445                 mService.stopSelf();
446             }
447         } finally {
448             mLock.unlock();
449         }
450     }
451 
452     /**
453      * Tells all running ping tasks to stop.
454      */
stopAllPings()455     public void stopAllPings() {
456         mLock.lock();
457         try {
458             for (int i = 0; i < mAccountStateMap.size(); ++i) {
459                 mAccountStateMap.valueAt(i).pushStop();
460             }
461         } finally {
462             mLock.unlock();
463         }
464     }
465 }
466