/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.messaging.datamodel; import android.content.Context; import android.content.pm.PackageManager; import android.database.ContentObserver; import android.net.Uri; import android.provider.Telephony; import androidx.collection.LongSparseArray; import com.android.messaging.datamodel.action.SyncMessagesAction; import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.sms.MmsUtils; import com.android.messaging.util.Assert; import com.android.messaging.util.BugleGservices; import com.android.messaging.util.BugleGservicesKeys; import com.android.messaging.util.BuglePrefs; import com.android.messaging.util.BuglePrefsKeys; import com.android.messaging.util.LogUtil; import com.android.messaging.util.OsUtil; import com.android.messaging.util.PhoneUtils; import com.google.common.collect.Lists; import java.util.ArrayList; import java.util.HashSet; import java.util.List; /** * This class manages message sync with the Telephony SmsProvider/MmsProvider. */ public class SyncManager { private static final String TAG = LogUtil.BUGLE_TAG; /** * Record of any user customization to conversation settings */ public static class ConversationCustomization { private final boolean mArchived; private final boolean mMuted; private final boolean mNoVibrate; private final String mNotificationSoundUri; public ConversationCustomization(final boolean archived, final boolean muted, final boolean noVibrate, final String notificationSoundUri) { mArchived = archived; mMuted = muted; mNoVibrate = noVibrate; mNotificationSoundUri = notificationSoundUri; } public boolean isArchived() { return mArchived; } public boolean isMuted() { return mMuted; } public boolean noVibrate() { return mNoVibrate; } public String getNotificationSoundUri() { return mNotificationSoundUri; } } SyncManager() { } /** * Timestamp of in progress sync - used to keep track of whether sync is running */ private long mSyncInProgressTimestamp = -1; /** * Timestamp of current sync batch upper bound - used to determine if message makes batch dirty */ private long mCurrentUpperBoundTimestamp = -1; /** * Timestamp of messages inserted since sync batch started - used to determine if batch dirty */ private long mMaxRecentChangeTimestamp = -1L; private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache(); /** * User customization to conversations. If this is set, we need to recover them after * a full sync. */ private LongSparseArray mCustomization = null; /** * Start an incremental sync (backed off a few seconds) */ public static void sync() { SyncMessagesAction.sync(); } /** * Start an incremental sync (with no backoff) */ public static void immediateSync() { SyncMessagesAction.immediateSync(); } /** * Start a full sync (for debugging) */ public static void forceSync() { SyncMessagesAction.fullSync(); } /** * Called from data model thread when starting a sync batch * @param upperBoundTimestamp upper bound timestamp for sync batch */ public synchronized void startSyncBatch(final long upperBoundTimestamp) { Assert.isTrue(mCurrentUpperBoundTimestamp < 0); mCurrentUpperBoundTimestamp = upperBoundTimestamp; mMaxRecentChangeTimestamp = -1L; } /** * Called from data model thread at end of batch to determine if any messages added in window * @param lowerBoundTimestamp lower bound timestamp for sync batch * @return true if message added within window from lower to upper bound timestamp of batch */ public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) { Assert.isTrue(mCurrentUpperBoundTimestamp >= 0); final long max = mMaxRecentChangeTimestamp; final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp); if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp + " to " + mCurrentUpperBoundTimestamp + " is " + (dirty ? "DIRTY" : "clean") + "; max change timestamp = " + mMaxRecentChangeTimestamp); } mCurrentUpperBoundTimestamp = -1L; mMaxRecentChangeTimestamp = -1L; return dirty; } /** * Called from data model or background worker thread to indicate start of message add process * (add must complete on that thread before action transitions to new thread/stage) * @param timestamp timestamp of message being added */ public synchronized void onNewMessageInserted(final long timestamp) { if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) { // Message insert in current sync window mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp); if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of " + "current sync batch " + mCurrentUpperBoundTimestamp); } } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of " + "current sync batch " + mCurrentUpperBoundTimestamp); } } /** * Synchronously checks whether sync is allowed and starts sync if allowed * @param full - true indicates a full (not incremental) sync operation * @param startTimestamp - starttimestamp for this sync (if allowed) * @return - true if sync should start */ public synchronized boolean shouldSync(final boolean full, final long startTimestamp) { if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "") + "at " + startTimestamp); } if (full) { final long delayUntilFullSync = delayUntilFullSync(startTimestamp); if (delayUntilFullSync > 0) { if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp + " delayed for " + delayUntilFullSync + " ms"); } return false; } } if (isSyncing()) { if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "") + "sync yet; still running sync started at " + mSyncInProgressTimestamp); } return false; } if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at " + startTimestamp); } mSyncInProgressTimestamp = startTimestamp; return true; } /** * Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately) * @param startTimestamp Timestamp used to start the sync * @return 0 if allowed to run now, else delay in ms */ public long delayUntilFullSync(final long startTimestamp) { final BugleGservices bugleGservices = BugleGservices.get(); final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L); final long smsFullSyncBackoffTimeMillis = bugleGservices.getLong( BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS, BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp : lastFullSyncTime + smsFullSyncBackoffTimeMillis); final long delayUntilFullSync = noFullSyncBefore - startTimestamp; if (delayUntilFullSync > 0) { return delayUntilFullSync; } return 0; } /** * Check if sync currently in progress (public for asserts/logging). */ public synchronized boolean isSyncing() { return (mSyncInProgressTimestamp >= 0); } /** * Check if sync batch should be in progress - compares upperBound with in memory value * @param upperBoundTimestamp - upperbound timestamp for sync batch * @return - true if timestamps match (otherwise batch is orphan from older process) */ public synchronized boolean isSyncing(final long upperBoundTimestamp) { Assert.isTrue(upperBoundTimestamp >= 0); return (upperBoundTimestamp == mCurrentUpperBoundTimestamp); } /** * Check if sync has completed for the first time. */ public boolean getHasFirstSyncCompleted() { final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) != BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT; } /** * Called once sync is complete */ public synchronized void complete() { if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp + " marked as complete"); } mSyncInProgressTimestamp = -1L; // Conversation customization only used once mCustomization = null; } private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver(); private boolean mSyncOnChanges = false; private boolean mNotifyOnChanges = false; /** * Register content observer when necessary and kick off a catch up sync */ public void updateSyncObserver(final Context context) { registerObserver(context); // Trigger an sms sync in case we missed and messages before registering this observer or // becoming the SMS provider. immediateSync(); } private void registerObserver(final Context context) { if (!PhoneUtils.getDefault().isDefaultSmsApp()) { // Not default SMS app - need to actively monitor telephony but not notify mNotifyOnChanges = false; mSyncOnChanges = true; } else if (OsUtil.isSecondaryUser()){ // Secondary users default SMS app - need to actively monitor telephony and notify mNotifyOnChanges = true; mSyncOnChanges = true; } else { // Primary users default SMS app - don't monitor telephony (most changes from this app) mNotifyOnChanges = false; mSyncOnChanges = false; if (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) { // This is default SMS app but on Auto platform, even default SMS app needs // to be notified of changes because Bluetooth makes changes to Sms database // (thru ContentProvider). Content Change notifications help the default SMS app // refresh display with changes, whenever Bluetooth changes data in SMS db. mNotifyOnChanges = true; mSyncOnChanges = false; } } if (mNotifyOnChanges || mSyncOnChanges) { context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI, true, mMmsSmsObserver); } else { context.getContentResolver().unregisterContentObserver(mMmsSmsObserver); } } public synchronized void setCustomization( final LongSparseArray customization) { this.mCustomization = customization; } public synchronized ConversationCustomization getCustomizationForThread(final long threadId) { if (mCustomization != null) { return mCustomization.get(threadId); } return null; } public static void resetLastSyncTimestamps() { final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT); prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT); } private class TelephonyMessagesObserver extends ContentObserver { public TelephonyMessagesObserver() { // Just run on default thread super(null); } // Implement the onChange(boolean) method to delegate the change notification to // the onChange(boolean, Uri) method to ensure correct operation on older versions // of the framework that did not have the onChange(boolean, Uri) method. @Override public void onChange(final boolean selfChange) { onChange(selfChange, null); } // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument. @Override public void onChange(final boolean selfChange, final Uri uri) { // Handle change. if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis() + " for " + (uri == null ? "" : uri.toString()) + " " + mSyncOnChanges + "/" + mNotifyOnChanges); } if (mSyncOnChanges) { // If sync is already running this will do nothing - but at end of each sync // action there is a check for recent messages that should catch new changes. SyncManager.immediateSync(); } if (mNotifyOnChanges) { // TODO: Secondary users are not going to get notifications } } } public ThreadInfoCache getThreadInfoCache() { return mThreadInfoCache; } public static class ThreadInfoCache { // Cache of thread->conversationId map private final LongSparseArray mThreadToConversationId = new LongSparseArray(); // Cache of thread->recipients map private final LongSparseArray> mThreadToRecipients = new LongSparseArray>(); // Remember the conversation ids that need to be archived private final HashSet mArchivedConversations = new HashSet<>(); public synchronized void clear() { if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache"); } mThreadToConversationId.clear(); mThreadToRecipients.clear(); mArchivedConversations.clear(); } public synchronized boolean isArchived(final String conversationId) { return mArchivedConversations.contains(conversationId); } /** * Get or create a conversation based on the message's thread id * * @param threadId The message's thread * @param refSubId The subId used for normalizing phone numbers in the thread * @param customization The user setting customization to the conversation if any * @return The existing conversation id or new conversation id */ public synchronized String getOrCreateConversation(final DatabaseWrapper db, final long threadId, int refSubId, final ConversationCustomization customization) { // This function has several components which need to be atomic. Assert.isTrue(db.getDatabase().inTransaction()); // If we already have this conversation ID in our local map, just return it String conversationId = mThreadToConversationId.get(threadId); if (conversationId != null) { return conversationId; } final List recipients = getThreadRecipients(threadId); final ArrayList participants = BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients, refSubId); if (customization != null) { // There is user customization we need to recover conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, customization.isArchived(), participants, customization.isMuted(), customization.noVibrate(), customization.getNotificationSoundUri()); if (customization.isArchived()) { mArchivedConversations.add(conversationId); } } else { conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, false/*archived*/, participants, false/*noNotification*/, false/*noVibrate*/, null/*soundUri*/); } if (conversationId != null) { mThreadToConversationId.put(threadId, conversationId); return conversationId; } return null; } /** * Load the recipients of a thread from telephony provider. If we fail, use * a predefined unknown recipient. This should not return null. * * @param threadId */ public synchronized List getThreadRecipients(final long threadId) { List recipients = mThreadToRecipients.get(threadId); if (recipients == null) { recipients = MmsUtils.getRecipientsByThread(threadId); if (recipients != null && recipients.size() > 0) { mThreadToRecipients.put(threadId, recipients); } } if (recipients == null || recipients.isEmpty()) { LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId + " couldn't find any recipients."); // We want to try our best to load the messages, // so if recipient info is broken, try to fix it with unknown recipient recipients = Lists.newArrayList(); recipients.add(ParticipantData.getUnknownSenderDestination()); } return recipients; } } }