1/*
2 * Copyright (C) 2015 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.messaging.datamodel;
18
19import android.content.Context;
20import android.database.ContentObserver;
21import android.net.Uri;
22import android.provider.Telephony;
23import android.support.v4.util.LongSparseArray;
24
25import com.android.messaging.datamodel.action.SyncMessagesAction;
26import com.android.messaging.datamodel.data.ParticipantData;
27import com.android.messaging.sms.MmsUtils;
28import com.android.messaging.util.Assert;
29import com.android.messaging.util.BugleGservices;
30import com.android.messaging.util.BugleGservicesKeys;
31import com.android.messaging.util.BuglePrefs;
32import com.android.messaging.util.BuglePrefsKeys;
33import com.android.messaging.util.LogUtil;
34import com.android.messaging.util.OsUtil;
35import com.android.messaging.util.PhoneUtils;
36import com.google.common.collect.Lists;
37
38import java.util.ArrayList;
39import java.util.HashSet;
40import java.util.List;
41
42/**
43 * This class manages message sync with the Telephony SmsProvider/MmsProvider.
44 */
45public class SyncManager {
46    private static final String TAG = LogUtil.BUGLE_TAG;
47
48    /**
49     * Record of any user customization to conversation settings
50     */
51    public static class ConversationCustomization {
52        private final boolean mArchived;
53        private final boolean mMuted;
54        private final boolean mNoVibrate;
55        private final String mNotificationSoundUri;
56
57        public ConversationCustomization(final boolean archived, final boolean muted,
58                final boolean noVibrate, final String notificationSoundUri) {
59            mArchived = archived;
60            mMuted = muted;
61            mNoVibrate = noVibrate;
62            mNotificationSoundUri = notificationSoundUri;
63        }
64
65        public boolean isArchived() {
66            return mArchived;
67        }
68
69        public boolean isMuted() {
70            return mMuted;
71        }
72
73        public boolean noVibrate() {
74            return mNoVibrate;
75        }
76
77        public String getNotificationSoundUri() {
78            return mNotificationSoundUri;
79        }
80    }
81
82    SyncManager() {
83    }
84
85    /**
86     * Timestamp of in progress sync - used to keep track of whether sync is running
87     */
88    private long mSyncInProgressTimestamp = -1;
89
90    /**
91     * Timestamp of current sync batch upper bound - used to determine if message makes batch dirty
92     */
93    private long mCurrentUpperBoundTimestamp = -1;
94
95    /**
96     * Timestamp of messages inserted since sync batch started - used to determine if batch dirty
97     */
98    private long mMaxRecentChangeTimestamp = -1L;
99
100    private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache();
101
102    /**
103     * User customization to conversations. If this is set, we need to recover them after
104     * a full sync.
105     */
106    private LongSparseArray<ConversationCustomization> mCustomization = null;
107
108    /**
109     * Start an incremental sync (backed off a few seconds)
110     */
111    public static void sync() {
112        SyncMessagesAction.sync();
113    }
114
115    /**
116     * Start an incremental sync (with no backoff)
117     */
118    public static void immediateSync() {
119        SyncMessagesAction.immediateSync();
120    }
121
122    /**
123     * Start a full sync (for debugging)
124     */
125    public static void forceSync() {
126        SyncMessagesAction.fullSync();
127    }
128
129    /**
130     * Called from data model thread when starting a sync batch
131     * @param upperBoundTimestamp upper bound timestamp for sync batch
132     */
133    public synchronized void startSyncBatch(final long upperBoundTimestamp) {
134        Assert.isTrue(mCurrentUpperBoundTimestamp < 0);
135        mCurrentUpperBoundTimestamp = upperBoundTimestamp;
136        mMaxRecentChangeTimestamp = -1L;
137    }
138
139    /**
140     * Called from data model thread at end of batch to determine if any messages added in window
141     * @param lowerBoundTimestamp lower bound timestamp for sync batch
142     * @return true if message added within window from lower to upper bound timestamp of batch
143     */
144    public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) {
145        Assert.isTrue(mCurrentUpperBoundTimestamp >= 0);
146        final long max = mMaxRecentChangeTimestamp;
147
148        final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp);
149        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
150            LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp
151                    + " to " + mCurrentUpperBoundTimestamp + " is "
152                    + (dirty ? "DIRTY" : "clean") + "; max change timestamp = "
153                    + mMaxRecentChangeTimestamp);
154        }
155
156        mCurrentUpperBoundTimestamp = -1L;
157        mMaxRecentChangeTimestamp = -1L;
158
159        return dirty;
160    }
161
162    /**
163     * Called from data model or background worker thread to indicate start of message add process
164     * (add must complete on that thread before action transitions to new thread/stage)
165     * @param timestamp timestamp of message being added
166     */
167    public synchronized void onNewMessageInserted(final long timestamp) {
168        if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) {
169            // Message insert in current sync window
170            mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp);
171            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
172                LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of "
173                        + "current sync batch " + mCurrentUpperBoundTimestamp);
174            }
175        } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
176            LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of "
177                    + "current sync batch " + mCurrentUpperBoundTimestamp);
178        }
179    }
180
181    /**
182     * Synchronously checks whether sync is allowed and starts sync if allowed
183     * @param full - true indicates a full (not incremental) sync operation
184     * @param startTimestamp - starttimestamp for this sync (if allowed)
185     * @return - true if sync should start
186     */
187    public synchronized boolean shouldSync(final boolean full, final long startTimestamp) {
188        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
189            LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "")
190                    + "at " + startTimestamp);
191        }
192
193        if (full) {
194            final long delayUntilFullSync = delayUntilFullSync(startTimestamp);
195            if (delayUntilFullSync > 0) {
196                if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
197                    LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp
198                            + " delayed for " + delayUntilFullSync + " ms");
199                }
200                return false;
201            }
202        }
203
204        if (isSyncing()) {
205            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
206                LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "")
207                        + "sync yet; still running sync started at " + mSyncInProgressTimestamp);
208            }
209            return false;
210        }
211        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
212            LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at "
213                    + startTimestamp);
214        }
215
216        mSyncInProgressTimestamp = startTimestamp;
217
218        return true;
219    }
220
221    /**
222     * Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately)
223     * @param startTimestamp Timestamp used to start the sync
224     * @return 0 if allowed to run now, else delay in ms
225     */
226    public long delayUntilFullSync(final long startTimestamp) {
227        final BugleGservices bugleGservices = BugleGservices.get();
228        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
229
230        final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L);
231        final long smsFullSyncBackoffTimeMillis = bugleGservices.getLong(
232                BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS,
233                BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
234        final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp :
235            lastFullSyncTime + smsFullSyncBackoffTimeMillis);
236
237        final long delayUntilFullSync = noFullSyncBefore - startTimestamp;
238        if (delayUntilFullSync > 0) {
239            return delayUntilFullSync;
240        }
241        return 0;
242    }
243
244    /**
245     * Check if sync currently in progress (public for asserts/logging).
246     */
247    public synchronized boolean isSyncing() {
248        return (mSyncInProgressTimestamp >= 0);
249    }
250
251    /**
252     * Check if sync batch should be in progress - compares upperBound with in memory value
253     * @param upperBoundTimestamp - upperbound timestamp for sync batch
254     * @return - true if timestamps match (otherwise batch is orphan from older process)
255     */
256    public synchronized boolean isSyncing(final long upperBoundTimestamp) {
257        Assert.isTrue(upperBoundTimestamp >= 0);
258        return (upperBoundTimestamp == mCurrentUpperBoundTimestamp);
259    }
260
261    /**
262     * Check if sync has completed for the first time.
263     */
264    public boolean getHasFirstSyncCompleted() {
265        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
266        return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
267                BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) !=
268                BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT;
269    }
270
271    /**
272     * Called once sync is complete
273     */
274    public synchronized void complete() {
275        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
276            LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp
277                    + " marked as complete");
278        }
279        mSyncInProgressTimestamp = -1L;
280        // Conversation customization only used once
281        mCustomization = null;
282    }
283
284    private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver();
285    private boolean mSyncOnChanges = false;
286    private boolean mNotifyOnChanges = false;
287
288    /**
289     * Register content observer when necessary and kick off a catch up sync
290     */
291    public void updateSyncObserver(final Context context) {
292        registerObserver(context);
293        // Trigger an sms sync in case we missed and messages before registering this observer or
294        // becoming the SMS provider.
295        immediateSync();
296    }
297
298    private void registerObserver(final Context context) {
299        if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
300            // Not default SMS app - need to actively monitor telephony but not notify
301            mNotifyOnChanges = false;
302            mSyncOnChanges = true;
303        } else if (OsUtil.isSecondaryUser()){
304            // Secondary users default SMS app - need to actively monitor telephony and notify
305            mNotifyOnChanges = true;
306            mSyncOnChanges = true;
307        } else {
308            // Primary users default SMS app - don't monitor telephony (most changes from this app)
309            mNotifyOnChanges = false;
310            mSyncOnChanges = false;
311        }
312        if (mNotifyOnChanges || mSyncOnChanges) {
313            context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI,
314                    true, mMmsSmsObserver);
315        } else {
316            context.getContentResolver().unregisterContentObserver(mMmsSmsObserver);
317        }
318    }
319
320    public synchronized void setCustomization(
321            final LongSparseArray<ConversationCustomization> customization) {
322        this.mCustomization = customization;
323    }
324
325    public synchronized ConversationCustomization getCustomizationForThread(final long threadId) {
326        if (mCustomization != null) {
327            return mCustomization.get(threadId);
328        }
329        return null;
330    }
331
332    public static void resetLastSyncTimestamps() {
333        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
334        prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME,
335                BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT);
336        prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
337    }
338
339    private class TelephonyMessagesObserver extends ContentObserver {
340        public TelephonyMessagesObserver() {
341            // Just run on default thread
342            super(null);
343        }
344
345        // Implement the onChange(boolean) method to delegate the change notification to
346        // the onChange(boolean, Uri) method to ensure correct operation on older versions
347        // of the framework that did not have the onChange(boolean, Uri) method.
348        @Override
349        public void onChange(final boolean selfChange) {
350            onChange(selfChange, null);
351        }
352
353        // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument.
354        @Override
355        public void onChange(final boolean selfChange, final Uri uri) {
356            // Handle change.
357            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
358                LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis()
359                        + " for " + (uri == null ? "<unk>" : uri.toString()) + " "
360                        + mSyncOnChanges + "/" + mNotifyOnChanges);
361            }
362
363            if (mSyncOnChanges) {
364                // If sync is already running this will do nothing - but at end of each sync
365                // action there is a check for recent messages that should catch new changes.
366                SyncManager.immediateSync();
367            }
368            if (mNotifyOnChanges) {
369                // TODO: Secondary users are not going to get notifications
370            }
371        }
372    }
373
374    public ThreadInfoCache getThreadInfoCache() {
375        return mThreadInfoCache;
376    }
377
378    public static class ThreadInfoCache {
379        // Cache of thread->conversationId map
380        private final LongSparseArray<String> mThreadToConversationId =
381                new LongSparseArray<String>();
382
383        // Cache of thread->recipients map
384        private final LongSparseArray<List<String>> mThreadToRecipients =
385                new LongSparseArray<List<String>>();
386
387        // Remember the conversation ids that need to be archived
388        private final HashSet<String> mArchivedConversations = new HashSet<>();
389
390        public synchronized void clear() {
391            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
392                LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache");
393            }
394            mThreadToConversationId.clear();
395            mThreadToRecipients.clear();
396            mArchivedConversations.clear();
397        }
398
399        public synchronized boolean isArchived(final String conversationId) {
400            return mArchivedConversations.contains(conversationId);
401        }
402
403        /**
404         * Get or create a conversation based on the message's thread id
405         *
406         * @param threadId The message's thread
407         * @param refSubId The subId used for normalizing phone numbers in the thread
408         * @param customization The user setting customization to the conversation if any
409         * @return The existing conversation id or new conversation id
410         */
411        public synchronized String getOrCreateConversation(final DatabaseWrapper db,
412                final long threadId, int refSubId, final ConversationCustomization customization) {
413            // This function has several components which need to be atomic.
414            Assert.isTrue(db.getDatabase().inTransaction());
415
416            // If we already have this conversation ID in our local map, just return it
417            String conversationId = mThreadToConversationId.get(threadId);
418            if (conversationId != null) {
419                return conversationId;
420            }
421
422            final List<String> recipients = getThreadRecipients(threadId);
423            final ArrayList<ParticipantData> participants =
424                    BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients,
425                            refSubId);
426
427            if (customization != null) {
428                // There is user customization we need to recover
429                conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
430                        customization.isArchived(), participants, customization.isMuted(),
431                        customization.noVibrate(), customization.getNotificationSoundUri());
432                if (customization.isArchived()) {
433                    mArchivedConversations.add(conversationId);
434                }
435            } else {
436                conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
437                        false/*archived*/, participants, false/*noNotification*/,
438                        false/*noVibrate*/, null/*soundUri*/);
439            }
440
441            if (conversationId != null) {
442                mThreadToConversationId.put(threadId, conversationId);
443                return conversationId;
444            }
445
446            return null;
447        }
448
449
450        /**
451         * Load the recipients of a thread from telephony provider. If we fail, use
452         * a predefined unknown recipient. This should not return null.
453         *
454         * @param threadId
455         */
456        public synchronized List<String> getThreadRecipients(final long threadId) {
457            List<String> recipients = mThreadToRecipients.get(threadId);
458            if (recipients == null) {
459                recipients = MmsUtils.getRecipientsByThread(threadId);
460                if (recipients != null && recipients.size() > 0) {
461                    mThreadToRecipients.put(threadId, recipients);
462                }
463            }
464
465            if (recipients == null || recipients.isEmpty()) {
466                LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId +
467                        " couldn't find any recipients.");
468
469                // We want to try our best to load the messages,
470                // so if recipient info is broken, try to fix it with unknown recipient
471                recipients = Lists.newArrayList();
472                recipients.add(ParticipantData.getUnknownSenderDestination());
473            }
474
475            return recipients;
476        }
477    }
478}
479