/* * 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.ContentValues; import android.database.ContentObserver; import android.database.Cursor; import android.database.DatabaseUtils; import android.graphics.Color; import android.provider.ContactsContract.CommonDataKinds.Phone; import androidx.collection.ArrayMap; import android.telephony.SubscriptionInfo; import android.text.TextUtils; import com.android.messaging.Factory; import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.ParticipantData.ParticipantsQuery; import com.android.messaging.ui.UIIntents; import com.android.messaging.util.Assert; import com.android.messaging.util.ContactUtil; import com.android.messaging.util.LogUtil; import com.android.messaging.util.OsUtil; import com.android.messaging.util.PhoneUtils; import com.android.messaging.util.SafeAsyncTask; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; /** * Utility class for refreshing participant information based on matching contact. This updates * 1. name, photo_uri, matching contact_id of participants. * 2. generated_name of conversations. * * There are two kinds of participant refreshes, * 1. Full refresh, this is triggered at application start or activity resumes after contact * change is detected. * 2. Partial refresh, this is triggered when a participant is added to a conversation. This * normally happens during SMS sync. */ @VisibleForTesting public class ParticipantRefresh { private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; /** * Refresh all participants including ones that were resolved before. */ public static final int REFRESH_MODE_FULL = 0; /** * Refresh all unresolved participants. */ public static final int REFRESH_MODE_INCREMENTAL = 1; /** * Force refresh all self participants. */ public static final int REFRESH_MODE_SELF_ONLY = 2; public static class ConversationParticipantsQuery { public static final String[] PROJECTION = new String[] { ConversationParticipantsColumns._ID, ConversationParticipantsColumns.CONVERSATION_ID, ConversationParticipantsColumns.PARTICIPANT_ID }; public static final int INDEX_ID = 0; public static final int INDEX_CONVERSATION_ID = 1; public static final int INDEX_PARTICIPANT_ID = 2; } // Track whether observer is initialized or not. private static volatile boolean sObserverInitialized = false; private static final Object sLock = new Object(); private static final AtomicBoolean sFullRefreshScheduled = new AtomicBoolean(false); private static final Runnable sFullRefreshRunnable = new Runnable() { @Override public void run() { final boolean oldScheduled = sFullRefreshScheduled.getAndSet(false); Assert.isTrue(oldScheduled); refreshParticipants(REFRESH_MODE_FULL); } }; private static final Runnable sSelfOnlyRefreshRunnable = new Runnable() { @Override public void run() { refreshParticipants(REFRESH_MODE_SELF_ONLY); } }; /** * A customized content resolver to track contact changes. */ public static class ContactContentObserver extends ContentObserver { private volatile boolean mContactChanged = false; public ContactContentObserver() { super(null); } @Override public void onChange(final boolean selfChange) { super.onChange(selfChange); if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "Contacts changed"); } mContactChanged = true; } public boolean getContactChanged() { return mContactChanged; } public void resetContactChanged() { mContactChanged = false; } public void initialize() { // TODO: Handle enterprise contacts post M once contacts provider supports it Factory.get().getApplicationContext().getContentResolver().registerContentObserver( Phone.CONTENT_URI, true, this); mContactChanged = true; // Force a full refresh on initialization. } } /** * Refresh participants only if needed, i.e., application start or contact changed. */ public static void refreshParticipantsIfNeeded() { if (ParticipantRefresh.getNeedFullRefresh() && sFullRefreshScheduled.compareAndSet(false, true)) { if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "Started full participant refresh"); } SafeAsyncTask.executeOnThreadPool(sFullRefreshRunnable); } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "Skipped full participant refresh"); } } /** * Refresh self participants on subscription or settings change. */ public static void refreshSelfParticipants() { SafeAsyncTask.executeOnThreadPool(sSelfOnlyRefreshRunnable); } private static boolean getNeedFullRefresh() { final ContactContentObserver observer = Factory.get().getContactContentObserver(); if (observer == null) { // If there is no observer (for unittest cases), we don't need to refresh participants. return false; } if (!sObserverInitialized) { synchronized (sLock) { if (!sObserverInitialized) { observer.initialize(); sObserverInitialized = true; } } } return observer.getContactChanged(); } private static void resetNeedFullRefresh() { final ContactContentObserver observer = Factory.get().getContactContentObserver(); if (observer != null) { observer.resetContactChanged(); } } /** * This class is totally static. Make constructor to be private so that an instance * of this class would not be created by by mistake. */ private ParticipantRefresh() { } /** * Refresh participants in Bugle. * * @param refreshMode the refresh mode desired. See {@link #REFRESH_MODE_FULL}, * {@link #REFRESH_MODE_INCREMENTAL}, and {@link #REFRESH_MODE_SELF_ONLY} */ @VisibleForTesting static void refreshParticipants(final int refreshMode) { Assert.inRange(refreshMode, REFRESH_MODE_FULL, REFRESH_MODE_SELF_ONLY); if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { switch (refreshMode) { case REFRESH_MODE_FULL: LogUtil.v(TAG, "Start full participant refresh"); break; case REFRESH_MODE_INCREMENTAL: LogUtil.v(TAG, "Start partial participant refresh"); break; case REFRESH_MODE_SELF_ONLY: LogUtil.v(TAG, "Start self participant refresh"); break; } } if (!ContactUtil.hasReadContactsPermission() || !OsUtil.hasPhonePermission()) { if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "Skipping participant referesh because of permissions"); } return; } if (refreshMode == REFRESH_MODE_FULL) { // resetNeedFullRefresh right away so that we will skip duplicated full refresh // requests. resetNeedFullRefresh(); } if (refreshMode == REFRESH_MODE_FULL || refreshMode == REFRESH_MODE_SELF_ONLY) { refreshSelfParticipantList(); } final ArrayList changedParticipants = new ArrayList(); String selection = null; String[] selectionArgs = null; if (refreshMode == REFRESH_MODE_INCREMENTAL) { // In case of incremental refresh, filter out participants that are already resolved. selection = ParticipantColumns.CONTACT_ID + "=?"; selectionArgs = new String[] { String.valueOf(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED) }; } else if (refreshMode == REFRESH_MODE_SELF_ONLY) { // In case of self-only refresh, filter out non-self participants. selection = SELF_PARTICIPANTS_CLAUSE; selectionArgs = null; } final DatabaseWrapper db = DataModel.get().getDatabase(); Cursor cursor = null; boolean selfUpdated = false; try { cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, ParticipantsQuery.PROJECTION, selection, selectionArgs, null, null, null); if (cursor != null) { while (cursor.moveToNext()) { try { final ParticipantData participantData = ParticipantData.getFromCursor(cursor); if (refreshParticipant(db, participantData)) { if (participantData.isSelf()) { selfUpdated = true; } updateParticipant(db, participantData); final String id = participantData.getId(); changedParticipants.add(id); } } catch (final Exception exception) { // Failure to update one participant shouldn't cancel the entire refresh. // Log the failure so we know what's going on and resume the loop. LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "ParticipantRefresh: Failed to " + "update participant", exception); } } } } finally { if (cursor != null) { cursor.close(); } } if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "Number of participants refreshed:" + changedParticipants.size()); } // Refresh conversations for participants that are changed. if (changedParticipants.size() > 0) { BugleDatabaseOperations.refreshConversationsForParticipants(changedParticipants); } if (selfUpdated) { // Boom MessagingContentProvider.notifyAllParticipantsChanged(); MessagingContentProvider.notifyAllMessagesChanged(); } } private static final String SELF_PARTICIPANTS_CLAUSE = ParticipantColumns.SUB_ID + " NOT IN ( " + ParticipantData.OTHER_THAN_SELF_SUB_ID + " )"; private static final Set getExistingSubIds() { final DatabaseWrapper db = DataModel.get().getDatabase(); final HashSet existingSubIds = new HashSet(); Cursor cursor = null; try { cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, ParticipantsQuery.PROJECTION, SELF_PARTICIPANTS_CLAUSE, null, null, null, null); if (cursor != null) { while (cursor.moveToNext()) { final int subId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID); existingSubIds.add(subId); } } } finally { if (cursor != null) { cursor.close(); } } return existingSubIds; } private static final String UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL = "UPDATE " + DatabaseHelper.PARTICIPANTS_TABLE + " SET " + ParticipantColumns.SIM_SLOT_ID + " = %d, " + ParticipantColumns.SUBSCRIPTION_COLOR + " = %d, " + ParticipantColumns.SUBSCRIPTION_NAME + " = %s " + " WHERE %s"; static String getUpdateSelfParticipantSubscriptionInfoSql(final int slotId, final int subscriptionColor, final String subscriptionName, final String where) { return String.format((Locale) null /* construct SQL string without localization */, UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL, slotId, subscriptionColor, subscriptionName, where); } /** * Ensure that there is a self participant corresponding to every active SIM. Also, ensure * that any other older SIM self participants are marked as inactive. */ private static void refreshSelfParticipantList() { if (!OsUtil.isAtLeastL_MR1()) { return; } final DatabaseWrapper db = DataModel.get().getDatabase(); final List subInfoRecords = PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList(); final ArrayMap activeSubscriptionIdToRecordMap = new ArrayMap(); db.beginTransaction(); final Set existingSubIds = getExistingSubIds(); try { if (subInfoRecords != null) { for (final SubscriptionInfo subInfoRecord : subInfoRecords) { final int subId = subInfoRecord.getSubscriptionId(); // If its a new subscription, add it to the database. if (!existingSubIds.contains(subId)) { db.execSQL(DatabaseHelper.getCreateSelfParticipantSql(subId)); // Add it to the local set to guard against duplicated entries returned // by subscription manager. existingSubIds.add(subId); } activeSubscriptionIdToRecordMap.put(subId, subInfoRecord); if (subId == PhoneUtils.getDefault().getDefaultSmsSubscriptionId()) { // This is the system default subscription, so update the default self. activeSubscriptionIdToRecordMap.put(ParticipantData.DEFAULT_SELF_SUB_ID, subInfoRecord); } } } // For subscriptions already in the database, refresh ParticipantColumns.SIM_SLOT_ID. for (final Integer subId : activeSubscriptionIdToRecordMap.keySet()) { final SubscriptionInfo record = activeSubscriptionIdToRecordMap.get(subId); final String displayName = DatabaseUtils.sqlEscapeString(record.getDisplayName().toString()); db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(record.getSimSlotIndex(), record.getIconTint(), displayName, ParticipantColumns.SUB_ID + " = " + subId)); } db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql( ParticipantData.INVALID_SLOT_ID, Color.TRANSPARENT, "''", ParticipantColumns.SUB_ID + " NOT IN (" + Joiner.on(", ").join(activeSubscriptionIdToRecordMap.keySet()) + ")")); db.setTransactionSuccessful(); } finally { db.endTransaction(); } // Fix up conversation self ids by reverting to default self for conversations whose self // ids are no longer active. refreshConversationSelfIds(); } /** * Refresh one participant. * @return true if the ParticipantData was changed */ public static boolean refreshParticipant(final DatabaseWrapper db, final ParticipantData participantData) { boolean updated = false; if (participantData.isSelf()) { final int selfChange = refreshFromSelfProfile(db, participantData); if (selfChange == SELF_PROFILE_EXISTS) { // If a self-profile exists, it takes precedence over Contacts data. So we are done. return true; } updated = (selfChange == SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED); // Fall-through and try to update based on Contacts data } updated |= refreshFromContacts(db, participantData); return updated; } private static final int SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED = 1; private static final int SELF_PROFILE_EXISTS = 2; private static int refreshFromSelfProfile(final DatabaseWrapper db, final ParticipantData participantData) { int changed = 0; // Refresh the phone number based on information from telephony if (participantData.updatePhoneNumberForSelfIfChanged()) { changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED; } if (OsUtil.isAtLeastL_MR1()) { // Refresh the subscription info based on information from SubscriptionManager. final SubscriptionInfo subscriptionInfo = PhoneUtils.get(participantData.getSubId()).toLMr1().getActiveSubscriptionInfo(); if (participantData.updateSubscriptionInfoForSelfIfChanged(subscriptionInfo)) { changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED; } } // For self participant, try getting name/avatar from self profile in CP2 first. // TODO: in case of multi-sim, profile would not be able to be used for // different numbers. Need to figure out that. Cursor selfCursor = null; try { selfCursor = ContactUtil.getSelf(db.getContext()).performSynchronousQuery(); if (selfCursor != null && selfCursor.getCount() > 0) { selfCursor.moveToNext(); final long selfContactId = selfCursor.getLong(ContactUtil.INDEX_CONTACT_ID); participantData.setContactId(selfContactId); participantData.setFullName(selfCursor.getString( ContactUtil.INDEX_DISPLAY_NAME)); participantData.setFirstName( ContactUtil.lookupFirstName(db.getContext(), selfContactId)); participantData.setProfilePhotoUri(selfCursor.getString( ContactUtil.INDEX_PHOTO_URI)); participantData.setLookupKey(selfCursor.getString( ContactUtil.INDEX_SELF_QUERY_LOOKUP_KEY)); return SELF_PROFILE_EXISTS; } } catch (final Exception exception) { // It's possible for contact query to fail and we don't want that to crash our app. // However, we need to at least log the exception so we know something was wrong. LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " + "participant. exception=" + exception); } finally { if (selfCursor != null) { selfCursor.close(); } } return changed; } private static boolean refreshFromContacts(final DatabaseWrapper db, final ParticipantData participantData) { final String normalizedDestination = participantData.getNormalizedDestination(); final long currentContactId = participantData.getContactId(); final String currentDisplayName = participantData.getFullName(); final String currentFirstName = participantData.getFirstName(); final String currentPhotoUri = participantData.getProfilePhotoUri(); final String currentContactDestination = participantData.getContactDestination(); Cursor matchingContactCursor = null; long matchingContactId = -1; String matchingDisplayName = null; String matchingFirstName = null; String matchingPhotoUri = null; String matchingLookupKey = null; String matchingDestination = null; boolean updated = false; if (TextUtils.isEmpty(normalizedDestination)) { // The normalized destination can be "" for the self id if we can't get it from the // SIM. Some contact providers throw an IllegalArgumentException if you lookup "", // so we early out. return false; } try { matchingContactCursor = ContactUtil.lookupDestination(db.getContext(), normalizedDestination).performSynchronousQuery(); if (matchingContactCursor == null || matchingContactCursor.getCount() == 0) { // If there is no match, mark the participant as contact not found. if (currentContactId != ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND) { participantData.setContactId(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND); participantData.setFullName(null); participantData.setFirstName(null); participantData.setProfilePhotoUri(null); participantData.setLookupKey(null); updated = true; } return updated; } while (matchingContactCursor.moveToNext()) { final long contactId = matchingContactCursor.getLong(ContactUtil.INDEX_CONTACT_ID); // Pick either the first contact or the contact with same id as previous matched // contact id. if (matchingContactId == -1 || currentContactId == contactId) { matchingContactId = contactId; matchingDisplayName = matchingContactCursor.getString( ContactUtil.INDEX_DISPLAY_NAME); matchingFirstName = ContactUtil.lookupFirstName(db.getContext(), contactId); matchingPhotoUri = matchingContactCursor.getString( ContactUtil.INDEX_PHOTO_URI); matchingLookupKey = matchingContactCursor.getString( ContactUtil.INDEX_LOOKUP_KEY); matchingDestination = matchingContactCursor.getString( ContactUtil.INDEX_PHONE_EMAIL); } // There is no need to try other contacts if the current contactId was not filled... if (currentContactId < 0 // or we found the matching contact id || currentContactId == contactId) { break; } } } catch (final Exception exception) { // It's possible for contact query to fail and we don't want that to crash our app. // However, we need to at least log the exception so we know something was wrong. LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " + "participant. exception=" + exception); return false; } finally { if (matchingContactCursor != null) { matchingContactCursor.close(); } } // Update participant only if something changed. final boolean isContactIdChanged = (matchingContactId != currentContactId); final boolean isDisplayNameChanged = !TextUtils.equals(matchingDisplayName, currentDisplayName); final boolean isFirstNameChanged = !TextUtils.equals(matchingFirstName, currentFirstName); final boolean isPhotoUrlChanged = !TextUtils.equals(matchingPhotoUri, currentPhotoUri); final boolean isDestinationChanged = !TextUtils.equals(matchingDestination, currentContactDestination); if (isContactIdChanged || isDisplayNameChanged || isFirstNameChanged || isPhotoUrlChanged || isDestinationChanged) { participantData.setContactId(matchingContactId); participantData.setFullName(matchingDisplayName); participantData.setFirstName(matchingFirstName); participantData.setProfilePhotoUri(matchingPhotoUri); participantData.setLookupKey(matchingLookupKey); participantData.setContactDestination(matchingDestination); if (isDestinationChanged) { // Update the send destination to the new one entered by user in Contacts. participantData.setSendDestination(matchingDestination); } updated = true; } return updated; } /** * Update participant with matching contact's contactId, displayName and photoUri. */ private static void updateParticipant(final DatabaseWrapper db, final ParticipantData participantData) { final ContentValues values = new ContentValues(); if (participantData.isSelf()) { // Self participants can refresh their normalized phone numbers values.put(ParticipantColumns.NORMALIZED_DESTINATION, participantData.getNormalizedDestination()); values.put(ParticipantColumns.DISPLAY_DESTINATION, participantData.getDisplayDestination()); } values.put(ParticipantColumns.CONTACT_ID, participantData.getContactId()); values.put(ParticipantColumns.LOOKUP_KEY, participantData.getLookupKey()); values.put(ParticipantColumns.FULL_NAME, participantData.getFullName()); values.put(ParticipantColumns.FIRST_NAME, participantData.getFirstName()); values.put(ParticipantColumns.PROFILE_PHOTO_URI, participantData.getProfilePhotoUri()); values.put(ParticipantColumns.CONTACT_DESTINATION, participantData.getContactDestination()); values.put(ParticipantColumns.SEND_DESTINATION, participantData.getSendDestination()); db.beginTransaction(); try { db.update(DatabaseHelper.PARTICIPANTS_TABLE, values, ParticipantColumns._ID + "=?", new String[] { participantData.getId() }); db.setTransactionSuccessful(); } finally { db.endTransaction(); } } /** * Get a list of inactive self ids in the participants table. */ private static List getInactiveSelfParticipantIds() { final DatabaseWrapper db = DataModel.get().getDatabase(); final List inactiveSelf = new ArrayList(); final String selection = ParticipantColumns.SIM_SLOT_ID + "=? AND " + SELF_PARTICIPANTS_CLAUSE; Cursor cursor = null; try { cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, new String[] { ParticipantColumns._ID }, selection, new String[] { String.valueOf(ParticipantData.INVALID_SLOT_ID) }, null, null, null); if (cursor != null) { while (cursor.moveToNext()) { final String participantId = cursor.getString(0); inactiveSelf.add(participantId); } } } finally { if (cursor != null) { cursor.close(); } } return inactiveSelf; } /** * Gets a list of conversations with the given self ids. */ private static List getConversationsWithSelfParticipantIds(final List selfIds) { final DatabaseWrapper db = DataModel.get().getDatabase(); final List conversationIds = new ArrayList(); Cursor cursor = null; try { final StringBuilder selectionList = new StringBuilder(); for (int i = 0; i < selfIds.size(); i++) { selectionList.append('?'); if (i < selfIds.size() - 1) { selectionList.append(','); } } final String selection = ConversationColumns.CURRENT_SELF_ID + " IN (" + selectionList + ")"; cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, new String[] { ConversationColumns._ID }, selection, selfIds.toArray(new String[0]), null, null, null); if (cursor != null) { while (cursor.moveToNext()) { final String conversationId = cursor.getString(0); conversationIds.add(conversationId); } } } finally { if (cursor != null) { cursor.close(); } } return conversationIds; } /** * Refresh one conversation's self id. */ private static void updateConversationSelfId(final String conversationId, final String selfId) { final DatabaseWrapper db = DataModel.get().getDatabase(); db.beginTransaction(); try { BugleDatabaseOperations.updateConversationSelfIdInTransaction(db, conversationId, selfId); db.setTransactionSuccessful(); } finally { db.endTransaction(); } MessagingContentProvider.notifyMessagesChanged(conversationId); MessagingContentProvider.notifyConversationMetadataChanged(conversationId); UIIntents.get().broadcastConversationSelfIdChange(db.getContext(), conversationId, selfId); } /** * After refreshing the self participant list, find all conversations with inactive self ids, * and switch them back to system default. */ private static void refreshConversationSelfIds() { final List inactiveSelfs = getInactiveSelfParticipantIds(); if (inactiveSelfs.size() == 0) { return; } final List conversationsToRefresh = getConversationsWithSelfParticipantIds(inactiveSelfs); if (conversationsToRefresh.size() == 0) { return; } final DatabaseWrapper db = DataModel.get().getDatabase(); final ParticipantData defaultSelf = BugleDatabaseOperations.getOrCreateSelf(db, ParticipantData.DEFAULT_SELF_SUB_ID); if (defaultSelf != null) { for (final String conversationId : conversationsToRefresh) { updateConversationSelfId(conversationId, defaultSelf.getId()); } } } }