diff options
Diffstat (limited to 'src/com/android/messaging/datamodel/BugleDatabaseOperations.java')
-rw-r--r-- | src/com/android/messaging/datamodel/BugleDatabaseOperations.java | 1919 |
1 files changed, 1919 insertions, 0 deletions
diff --git a/src/com/android/messaging/datamodel/BugleDatabaseOperations.java b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java new file mode 100644 index 0000000..8c40177 --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java @@ -0,0 +1,1919 @@ +/* + * 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.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.v4.util.ArrayMap; +import android.support.v4.util.SimpleArrayMap; +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.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.ParticipantRefresh.ConversationParticipantsQuery; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UriUtil; +import com.android.messaging.widget.WidgetConversationProvider; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import javax.annotation.Nullable; + + +/** + * This class manages updating our local database + */ +public class BugleDatabaseOperations { + + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + // Global cache of phone numbers -> participant id mapping since this call is expensive. + private static final ArrayMap<String, String> sNormalizedPhoneNumberToParticipantIdCache = + new ArrayMap<String, String>(); + + /** + * Convert list of recipient strings (email/phone number) into list of ConversationParticipants + * + * @param recipients The recipient list + * @param refSubId The subId used to normalize phone numbers in the recipients + */ + static ArrayList<ParticipantData> getConversationParticipantsFromRecipients( + final List<String> recipients, final int refSubId) { + // Generate a list of partially formed participants + final ArrayList<ParticipantData> participants = new + ArrayList<ParticipantData>(); + + if (recipients != null) { + for (final String recipient : recipients) { + participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, refSubId)); + } + } + return participants; + } + + /** + * Sanitize a given list of conversation participants by de-duping and stripping out self + * phone number in group conversation. + */ + @DoesNotRunOnMainThread + public static void sanitizeConversationParticipants(final List<ParticipantData> participants) { + Assert.isNotMainThread(); + if (participants.size() > 0) { + // First remove redundant phone numbers + final HashSet<String> recipients = new HashSet<String>(); + for (int i = participants.size() - 1; i >= 0; i--) { + final String recipient = participants.get(i).getNormalizedDestination(); + if (!recipients.contains(recipient)) { + recipients.add(recipient); + } else { + participants.remove(i); + } + } + if (participants.size() > 1) { + // Remove self phone number from group conversation. + final HashSet<String> selfNumbers = + PhoneUtils.getDefault().getNormalizedSelfNumbers(); + int removed = 0; + // Do this two-pass scan to avoid unnecessary memory allocation. + // Prescan to count the self numbers in the list + for (final ParticipantData p : participants) { + if (selfNumbers.contains(p.getNormalizedDestination())) { + removed++; + } + } + // If all are self numbers, maybe that's what the user wants, just leave + // the participants as is. Otherwise, do another scan to remove self numbers. + if (removed < participants.size()) { + for (int i = participants.size() - 1; i >= 0; i--) { + final String recipient = participants.get(i).getNormalizedDestination(); + if (selfNumbers.contains(recipient)) { + participants.remove(i); + } + } + } + } + } + } + + /** + * Convert list of ConversationParticipants into recipient strings (email/phone number) + */ + @DoesNotRunOnMainThread + public static ArrayList<String> getRecipientsFromConversationParticipants( + final List<ParticipantData> participants) { + Assert.isNotMainThread(); + // First find the thread id for this list of participants. + final ArrayList<String> recipients = new ArrayList<String>(); + + for (final ParticipantData participant : participants) { + recipients.add(participant.getSendDestination()); + } + return recipients; + } + + /** + * Get or create a conversation based on the message's thread id + * + * NOTE: There are phones on which you can't get the recipients from the thread id for SMS + * until you have a message, so use getOrCreateConversationFromRecipient instead. + * + * TODO: Should this be in MMS/SMS code? + * + * @param db the database + * @param threadId The message's thread + * @param senderBlocked Flag whether sender of message is in blocked people list + * @param refSubId The reference subId for canonicalize phone numbers + * @return conversationId + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversationFromThreadId(final DatabaseWrapper db, + final long threadId, final boolean senderBlocked, final int refSubId) { + Assert.isNotMainThread(); + final List<String> recipients = MmsUtils.getRecipientsByThread(threadId); + final ArrayList<ParticipantData> participants = + getConversationParticipantsFromRecipients(recipients, refSubId); + + return getOrCreateConversation(db, threadId, senderBlocked, participants, false, false, + null); + } + + /** + * Get or create a conversation based on provided recipient + * + * @param db the database + * @param threadId The message's thread + * @param senderBlocked Flag whether sender of message is in blocked people list + * @param recipient recipient for thread + * @return conversationId + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversationFromRecipient(final DatabaseWrapper db, + final long threadId, final boolean senderBlocked, final ParticipantData recipient) { + Assert.isNotMainThread(); + final ArrayList<ParticipantData> recipients = new ArrayList<>(1); + recipients.add(recipient); + return getOrCreateConversation(db, threadId, senderBlocked, recipients, false, false, null); + } + + /** + * Get or create a conversation based on provided participants + * + * @param db the database + * @param threadId The message's thread + * @param archived Flag whether the conversation should be created archived + * @param participants list of conversation participants + * @param noNotification If notification should be disabled + * @param noVibrate If vibrate on notification should be disabled + * @param soundUri If there is custom sound URI + * @return a conversation id + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversation(final DatabaseWrapper db, final long threadId, + final boolean archived, final ArrayList<ParticipantData> participants, + boolean noNotification, boolean noVibrate, String soundUri) { + Assert.isNotMainThread(); + + // Check to see if this conversation is already in out local db cache + String conversationId = BugleDatabaseOperations.getExistingConversation(db, threadId, + false); + + if (conversationId == null) { + final String conversationName = ConversationListItemData.generateConversationName( + participants); + + // Create the conversation with the default self participant which always maps to + // the system default subscription. + final ParticipantData self = ParticipantData.getSelfParticipant( + ParticipantData.DEFAULT_SELF_SUB_ID); + + db.beginTransaction(); + try { + // Look up the "self" participantId (creating if necessary) + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + // Create a new conversation + conversationId = BugleDatabaseOperations.createConversationInTransaction( + db, threadId, conversationName, selfId, participants, archived, + noNotification, noVibrate, soundUri); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + return conversationId; + } + + /** + * Get a conversation from the local DB based on the message's thread id. + * + * @param dbWrapper The database + * @param threadId The message's thread in the SMS database + * @param senderBlocked Flag whether sender of message is in blocked people list + * @return The existing conversation id or null + */ + @VisibleForTesting + @DoesNotRunOnMainThread + public static String getExistingConversation(final DatabaseWrapper dbWrapper, + final long threadId, final boolean senderBlocked) { + Assert.isNotMainThread(); + String conversationId = null; + + Cursor cursor = null; + try { + // Look for an existing conversation in the db with this thread id + cursor = dbWrapper.rawQuery("SELECT " + ConversationColumns._ID + + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns.SMS_THREAD_ID + "=" + threadId, + null); + + if (cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + conversationId = cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return conversationId; + } + + /** + * Get the thread id for an existing conversation from the local DB. + * + * @param dbWrapper The database + * @param conversationId The conversation to look up thread for + * @return The thread id. Returns -1 if the conversation was not found or if it was found + * but the thread column was NULL. + */ + @DoesNotRunOnMainThread + public static long getThreadId(final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + long threadId = -1; + + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.SMS_THREAD_ID }, + ConversationColumns._ID + " =?", + new String[] { conversationId }, + null, null, null); + + if (cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + if (!cursor.isNull(0)) { + threadId = cursor.getLong(0); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return threadId; + } + + @DoesNotRunOnMainThread + public static boolean isBlockedDestination(final DatabaseWrapper db, final String destination) { + Assert.isNotMainThread(); + return isBlockedParticipant(db, destination, ParticipantColumns.NORMALIZED_DESTINATION); + } + + static boolean isBlockedParticipant(final DatabaseWrapper db, final String participantId) { + return isBlockedParticipant(db, participantId, ParticipantColumns._ID); + } + + static boolean isBlockedParticipant(final DatabaseWrapper db, final String value, + final String column) { + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns.BLOCKED }, + column + "=? AND " + ParticipantColumns.SUB_ID + "=?", + new String[] { value, + Integer.toString(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null, null, null); + + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getInt(0) == 1; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return false; // if there's no row, it's not blocked :-) + } + + /** + * Create a conversation in the local DB based on the message's thread id. + * + * It's up to the caller to make sure that this is all inside a transaction. It will return + * null if it's not in the local DB. + * + * @param dbWrapper The database + * @param threadId The message's thread + * @param selfId The selfId to make default for this conversation + * @param archived Flag whether the conversation should be created archived + * @param noNotification If notification should be disabled + * @param noVibrate If vibrate on notification should be disabled + * @param soundUri The customized sound + * @return The existing conversation id or new conversation id + */ + static String createConversationInTransaction(final DatabaseWrapper dbWrapper, + final long threadId, final String conversationName, final String selfId, + final List<ParticipantData> participants, final boolean archived, + boolean noNotification, boolean noVibrate, String soundUri) { + // We want conversation and participant creation to be atomic + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + boolean hasEmailAddress = false; + for (final ParticipantData participant : participants) { + Assert.isTrue(!participant.isSelf()); + if (participant.isEmail()) { + hasEmailAddress = true; + } + } + + // TODO : Conversations state - normal vs. archived + + // Insert a new local conversation for this thread id + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.SMS_THREAD_ID, threadId); + // Start with conversation hidden - sending a message or saving a draft will change that + values.put(ConversationColumns.SORT_TIMESTAMP, 0L); + values.put(ConversationColumns.CURRENT_SELF_ID, selfId); + values.put(ConversationColumns.PARTICIPANT_COUNT, participants.size()); + values.put(ConversationColumns.INCLUDE_EMAIL_ADDRESS, (hasEmailAddress ? 1 : 0)); + if (archived) { + values.put(ConversationColumns.ARCHIVE_STATUS, 1); + } + if (noNotification) { + values.put(ConversationColumns.NOTIFICATION_ENABLED, 0); + } + if (noVibrate) { + values.put(ConversationColumns.NOTIFICATION_VIBRATION, 0); + } + if (!TextUtils.isEmpty(soundUri)) { + values.put(ConversationColumns.NOTIFICATION_SOUND_URI, soundUri); + } + + fillParticipantData(values, participants); + + final long conversationRowId = dbWrapper.insert(DatabaseHelper.CONVERSATIONS_TABLE, null, + values); + + Assert.isTrue(conversationRowId != -1); + if (conversationRowId == -1) { + LogUtil.e(TAG, "BugleDatabaseOperations : failed to insert conversation into table"); + return null; + } + + final String conversationId = Long.toString(conversationRowId); + + // Make sure that participants are added for this conversation + for (final ParticipantData participant : participants) { + // TODO: Use blocking information + addParticipantToConversation(dbWrapper, participant, conversationId); + } + + // Now fully resolved participants available can update conversation name / avatar. + // b/16437575: We cannot use the participants directly, but instead have to call + // getParticipantsForConversation() to retrieve the actual participants. This is needed + // because the call to addParticipantToConversation() won't fill up the ParticipantData + // if the participant already exists in the participant table. For example, say you have + // an existing conversation with John. Now if you create a new group conversation with + // Jeff & John with only their phone numbers, then when we try to add John's number to the + // group conversation, we see that he's already in the participant table, therefore we + // short-circuit any steps to actually fill out the ParticipantData for John other than + // just returning his participant id. Eventually, the ParticipantData we have is still the + // raw data with just the phone number. getParticipantsForConversation(), on the other + // hand, will fill out all the info for each participant from the participants table. + updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, + getParticipantsForConversation(dbWrapper, conversationId)); + + return conversationId; + } + + private static void fillParticipantData(final ContentValues values, + final List<ParticipantData> participants) { + if (participants != null && !participants.isEmpty()) { + final Uri avatarUri = AvatarUriUtil.createAvatarUri(participants); + values.put(ConversationColumns.ICON, avatarUri.toString()); + + long contactId; + String lookupKey; + String destination; + if (participants.size() == 1) { + final ParticipantData firstParticipant = participants.get(0); + contactId = firstParticipant.getContactId(); + lookupKey = firstParticipant.getLookupKey(); + destination = firstParticipant.getNormalizedDestination(); + } else { + contactId = 0; + lookupKey = null; + destination = null; + } + + values.put(ConversationColumns.PARTICIPANT_CONTACT_ID, contactId); + values.put(ConversationColumns.PARTICIPANT_LOOKUP_KEY, lookupKey); + values.put(ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, destination); + } + } + + /** + * Delete conversation and associated messages/parts + */ + @DoesNotRunOnMainThread + public static boolean deleteConversation(final DatabaseWrapper dbWrapper, + final String conversationId, final long cutoffTimestamp) { + Assert.isNotMainThread(); + dbWrapper.beginTransaction(); + boolean conversationDeleted = false; + boolean conversationMessagesDeleted = false; + try { + // Delete existing messages + if (cutoffTimestamp == Long.MAX_VALUE) { + // Delete parts and messages + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); + conversationMessagesDeleted = true; + } else { + // Delete all messages prior to the cutoff + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.RECEIVED_TIMESTAMP + "<=?", + new String[] { conversationId, Long.toString(cutoffTimestamp) }); + + // Delete any draft message. The delete above may not always include the draft, + // because under certain scenarios (e.g. sending messages in progress), the draft + // timestamp can be larger than the cutoff time, which is generally the conversation + // sort timestamp. Because of how the sms/mms provider works on some newer + // devices, it's important that we never delete all the messages in a conversation + // without also deleting the conversation itself (see b/20262204 for details). + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }); + + // Check to see if there are any messages left in the conversation + final long count = dbWrapper.queryNumEntries(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); + conversationMessagesDeleted = (count == 0); + + // Log detail information if there are still messages left in the conversation + if (!conversationMessagesDeleted) { + final long maxTimestamp = + getConversationMaxTimestamp(dbWrapper, conversationId); + LogUtil.w(TAG, "BugleDatabaseOperations:" + + " cannot delete all messages in a conversation" + + ", after deletion: count=" + count + + ", max timestamp=" + maxTimestamp + + ", cutoff timestamp=" + cutoffTimestamp); + } + } + + if (conversationMessagesDeleted) { + // Delete conversation row + final int count = dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID + "=?", new String[] { conversationId }); + conversationDeleted = (count > 0); + } + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + } + return conversationDeleted; + } + + private static final String MAX_RECEIVED_TIMESTAMP = + "MAX(" + MessageColumns.RECEIVED_TIMESTAMP + ")"; + /** + * Get the max received timestamp of a conversation's messages + */ + private static long getConversationMaxTimestamp(final DatabaseWrapper dbWrapper, + final String conversationId) { + final Cursor cursor = dbWrapper.query( + DatabaseHelper.MESSAGES_TABLE, + new String[]{ MAX_RECEIVED_TIMESTAMP }, + MessageColumns.CONVERSATION_ID + "=?", + new String[]{ conversationId }, + null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + return cursor.getLong(0); + } + } finally { + cursor.close(); + } + } + return 0; + } + + @DoesNotRunOnMainThread + public static void updateConversationMetadataInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final String messageId, final long latestTimestamp, + final boolean keepArchived, final String smsServiceCenter, + final boolean shouldAutoSwitchSelfId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.LATEST_MESSAGE_ID, messageId); + values.put(ConversationColumns.SORT_TIMESTAMP, latestTimestamp); + if (!TextUtils.isEmpty(smsServiceCenter)) { + values.put(ConversationColumns.SMS_SERVICE_CENTER, smsServiceCenter); + } + + // When the conversation gets updated with new messages, unarchive the conversation unless + // the sender is blocked, or we have been told to keep it archived. + if (!keepArchived) { + values.put(ConversationColumns.ARCHIVE_STATUS, 0); + } + + final MessageData message = readMessage(dbWrapper, messageId); + addSnippetTextAndPreviewToContentValues(message, false /* showDraft */, values); + + if (shouldAutoSwitchSelfId) { + addSelfIdAutoSwitchInfoToContentValues(dbWrapper, message, conversationId, values); + } + + // Conversation always exists as this method is called from ActionService only after + // reading and if necessary creating the conversation. + updateConversationRow(dbWrapper, conversationId, values); + + if (shouldAutoSwitchSelfId && OsUtil.isAtLeastL_MR1()) { + // Normally, the draft message compose UI trusts its UI state for providing up-to-date + // conversation self id. Therefore, notify UI through local broadcast receiver about + // this external change so the change can be properly reflected. + UIIntents.get().broadcastConversationSelfIdChange(dbWrapper.getContext(), + conversationId, getConversationSelfId(dbWrapper, conversationId)); + } + } + + @DoesNotRunOnMainThread + public static void updateConversationMetadataInTransaction(final DatabaseWrapper db, + final String conversationId, final String messageId, final long latestTimestamp, + final boolean keepArchived, final boolean shouldAutoSwitchSelfId) { + Assert.isNotMainThread(); + updateConversationMetadataInTransaction( + db, conversationId, messageId, latestTimestamp, keepArchived, null, + shouldAutoSwitchSelfId); + } + + @DoesNotRunOnMainThread + public static void updateConversationArchiveStatusInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final boolean isArchived) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.ARCHIVE_STATUS, isArchived ? 1 : 0); + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + + static void addSnippetTextAndPreviewToContentValues(final MessageData message, + final boolean showDraft, final ContentValues values) { + values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0); + values.put(ConversationColumns.SNIPPET_TEXT, message.getMessageText()); + values.put(ConversationColumns.SUBJECT_TEXT, message.getMmsSubject()); + + String type = null; + String uriString = null; + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment() && + ContentType.isConversationListPreviewableType(part.getContentType())) { + uriString = part.getContentUri().toString(); + type = part.getContentType(); + break; + } + } + values.put(ConversationColumns.PREVIEW_CONTENT_TYPE, type); + values.put(ConversationColumns.PREVIEW_URI, uriString); + } + + /** + * Adds self-id auto switch info for a conversation if the last message has a different + * subscription than the conversation's. + * @return true if self id will need to be changed, false otherwise. + */ + static boolean addSelfIdAutoSwitchInfoToContentValues(final DatabaseWrapper dbWrapper, + final MessageData message, final String conversationId, final ContentValues values) { + // Only auto switch conversation self for incoming messages. + if (!OsUtil.isAtLeastL_MR1() || !message.getIsIncoming()) { + return false; + } + + final String conversationSelfId = getConversationSelfId(dbWrapper, conversationId); + final String messageSelfId = message.getSelfId(); + + if (conversationSelfId == null || messageSelfId == null) { + return false; + } + + // Get the sub IDs in effect for both the message and the conversation and compare them: + // 1. If message is unbound (using default sub id), then the message was sent with + // pre-MSIM support. Don't auto-switch because we don't know the subscription for the + // message. + // 2. If message is bound, + // i. If conversation is unbound, use the system default sub id as its effective sub. + // ii. If conversation is bound, use its subscription directly. + // Compare the message sub id with the conversation's effective sub id. If they are + // different, auto-switch the conversation to the message's sub. + final ParticipantData conversationSelf = getExistingParticipant(dbWrapper, + conversationSelfId); + final ParticipantData messageSelf = getExistingParticipant(dbWrapper, messageSelfId); + if (!messageSelf.isActiveSubscription()) { + // Don't switch if the message subscription is no longer active. + return false; + } + final int messageSubId = messageSelf.getSubId(); + if (messageSubId == ParticipantData.DEFAULT_SELF_SUB_ID) { + return false; + } + + final int conversationEffectiveSubId = + PhoneUtils.getDefault().getEffectiveSubId(conversationSelf.getSubId()); + + if (conversationEffectiveSubId != messageSubId) { + return addConversationSelfIdToContentValues(dbWrapper, messageSelf.getId(), values); + } + return false; + } + + /** + * Adds conversation self id updates to ContentValues given. This performs check on the selfId + * to ensure it's valid and active. + * @return true if self id will need to be changed, false otherwise. + */ + static boolean addConversationSelfIdToContentValues(final DatabaseWrapper dbWrapper, + final String selfId, final ContentValues values) { + // Make sure the selfId passed in is valid and active. + final String selection = ParticipantColumns._ID + "=? AND " + + ParticipantColumns.SIM_SLOT_ID + "<>?"; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns._ID }, selection, + new String[] { selfId, String.valueOf(ParticipantData.INVALID_SLOT_ID) }, + null, null, null); + + if (cursor != null && cursor.getCount() > 0) { + values.put(ConversationColumns.CURRENT_SELF_ID, selfId); + return true; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return false; + } + + private static void updateConversationDraftSnippetAndPreviewInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final MessageData draftMessage) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + long sortTimestamp = 0L; + Cursor cursor = null; + try { + // Check to find the latest message in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=?", + new String[]{conversationId}, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + + if (cursor.moveToFirst()) { + sortTimestamp = cursor.getLong(1); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + + final ContentValues values = new ContentValues(); + if (draftMessage == null || !draftMessage.hasContent()) { + values.put(ConversationColumns.SHOW_DRAFT, 0); + values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, ""); + values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, ""); + values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, ""); + values.put(ConversationColumns.DRAFT_PREVIEW_URI, ""); + } else { + sortTimestamp = Math.max(sortTimestamp, draftMessage.getReceivedTimeStamp()); + values.put(ConversationColumns.SHOW_DRAFT, 1); + values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, draftMessage.getMessageText()); + values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, draftMessage.getMmsSubject()); + String type = null; + String uriString = null; + for (final MessagePartData part : draftMessage.getParts()) { + if (part.isAttachment() && + ContentType.isConversationListPreviewableType(part.getContentType())) { + uriString = part.getContentUri().toString(); + type = part.getContentType(); + break; + } + } + values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, type); + values.put(ConversationColumns.DRAFT_PREVIEW_URI, uriString); + } + values.put(ConversationColumns.SORT_TIMESTAMP, sortTimestamp); + // Called in transaction after reading conversation row + updateConversationRow(dbWrapper, conversationId, values); + } + + @DoesNotRunOnMainThread + public static boolean updateConversationRowIfExists(final DatabaseWrapper dbWrapper, + final String conversationId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID, conversationId, values); + } + + @DoesNotRunOnMainThread + public static void updateConversationRow(final DatabaseWrapper dbWrapper, + final String conversationId, final ContentValues values) { + Assert.isNotMainThread(); + final boolean exists = updateConversationRowIfExists(dbWrapper, conversationId, values); + Assert.isTrue(exists); + } + + @DoesNotRunOnMainThread + public static boolean updateMessageRowIfExists(final DatabaseWrapper dbWrapper, + final String messageId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID, + messageId, values); + } + + @DoesNotRunOnMainThread + public static void updateMessageRow(final DatabaseWrapper dbWrapper, + final String messageId, final ContentValues values) { + Assert.isNotMainThread(); + final boolean exists = updateMessageRowIfExists(dbWrapper, messageId, values); + Assert.isTrue(exists); + } + + @DoesNotRunOnMainThread + public static boolean updatePartRowIfExists(final DatabaseWrapper dbWrapper, + final String partId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.PARTS_TABLE, PartColumns._ID, + partId, values); + } + + /** + * Returns the default conversation name based on its participants. + */ + private static String getDefaultConversationName(final List<ParticipantData> participants) { + return ConversationListItemData.generateConversationName(participants); + } + + /** + * Updates a given conversation's name based on its participants. + */ + @DoesNotRunOnMainThread + public static void updateConversationNameAndAvatarInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ArrayList<ParticipantData> participants = + getParticipantsForConversation(dbWrapper, conversationId); + updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, participants); + } + + /** + * Updates a given conversation's name based on its participants. + */ + private static void updateConversationNameAndAvatarInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final List<ParticipantData> participants) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.NAME, + getDefaultConversationName(participants)); + + fillParticipantData(values, participants); + + // Used by background thread when refreshing conversation so conversation could be deleted. + updateConversationRowIfExists(dbWrapper, conversationId, values); + + WidgetConversationProvider.notifyConversationRenamed(Factory.get().getApplicationContext(), + conversationId); + } + + /** + * Updates a given conversation's self id. + */ + @DoesNotRunOnMainThread + public static void updateConversationSelfIdInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, final String selfId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + if (addConversationSelfIdToContentValues(dbWrapper, selfId, values)) { + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + } + + @DoesNotRunOnMainThread + public static String getConversationSelfId(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.CURRENT_SELF_ID }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + /** + * Frees up memory associated with phone number to participant id matching. + */ + @DoesNotRunOnMainThread + public static void clearParticipantIdCache() { + Assert.isNotMainThread(); + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + sNormalizedPhoneNumberToParticipantIdCache.clear(); + } + } + + @DoesNotRunOnMainThread + public static ArrayList<String> getRecipientsForConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + final ArrayList<ParticipantData> participants = + getParticipantsForConversation(dbWrapper, conversationId); + + final ArrayList<String> recipients = new ArrayList<String>(); + for (final ParticipantData participant : participants) { + recipients.add(participant.getSendDestination()); + } + + return recipients; + } + + @DoesNotRunOnMainThread + public static String getSmsServiceCenterForConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.SMS_SERVICE_CENTER }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + @DoesNotRunOnMainThread + public static ParticipantData getExistingParticipant(final DatabaseWrapper dbWrapper, + final String participantId) { + Assert.isNotMainThread(); + ParticipantData participant = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " =?", + new String[] { participantId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + participant = ParticipantData.getFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return participant; + } + + static int getSelfSubscriptionId(final DatabaseWrapper dbWrapper, + final String selfParticipantId) { + final ParticipantData selfParticipant = BugleDatabaseOperations.getExistingParticipant( + dbWrapper, selfParticipantId); + if (selfParticipant != null) { + Assert.isTrue(selfParticipant.isSelf()); + return selfParticipant.getSubId(); + } + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @VisibleForTesting + @DoesNotRunOnMainThread + public static ArrayList<ParticipantData> getParticipantsForConversation( + final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + final ArrayList<ParticipantData> participants = + new ArrayList<ParticipantData>(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " IN ( " + "SELECT " + + ConversationParticipantsColumns.PARTICIPANT_ID + " AS " + + ParticipantColumns._ID + + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE + + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID + " =? )", + new String[] { conversationId }, null, null, null); + + while (cursor.moveToNext()) { + participants.add(ParticipantData.getFromCursor(cursor)); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return participants; + } + + @DoesNotRunOnMainThread + public static MessageData readMessage(final DatabaseWrapper dbWrapper, final String messageId) { + Assert.isNotMainThread(); + final MessageData message = readMessageData(dbWrapper, messageId); + if (message != null) { + readMessagePartsData(dbWrapper, message, false); + } + return message; + } + + @VisibleForTesting + static MessagePartData readMessagePartData(final DatabaseWrapper dbWrapper, + final String partId) { + MessagePartData messagePartData = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE, + MessagePartData.getProjection(), PartColumns._ID + "=?", + new String[] { partId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + messagePartData = MessagePartData.createFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return messagePartData; + } + + @DoesNotRunOnMainThread + public static MessageData readMessageData(final DatabaseWrapper dbWrapper, + final Uri smsMessageUri) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), MessageColumns.SMS_MESSAGE_URI + "=?", + new String[] { smsMessageUri.toString() }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + @DoesNotRunOnMainThread + public static MessageData readMessageData(final DatabaseWrapper dbWrapper, + final String messageId) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), MessageColumns._ID + "=?", + new String[] { messageId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + /** + * Read all the parts for a message + * @param dbWrapper database + * @param message read parts for this message + * @param checkAttachmentFilesExist check each attachment file and only include if file exists + */ + private static void readMessagePartsData(final DatabaseWrapper dbWrapper, + final MessageData message, final boolean checkAttachmentFilesExist) { + final ContentResolver contentResolver = + Factory.get().getApplicationContext().getContentResolver(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE, + MessagePartData.getProjection(), PartColumns.MESSAGE_ID + "=?", + new String[] { message.getMessageId() }, null, null, null); + while (cursor.moveToNext()) { + final MessagePartData messagePartData = MessagePartData.createFromCursor(cursor); + if (checkAttachmentFilesExist && messagePartData.isAttachment() && + !UriUtil.isBugleAppResource(messagePartData.getContentUri())) { + try { + // Test that the file exists before adding the attachment to the draft + final ParcelFileDescriptor fileDescriptor = + contentResolver.openFileDescriptor( + messagePartData.getContentUri(), "r"); + if (fileDescriptor != null) { + fileDescriptor.close(); + message.addPart(messagePartData); + } + } catch (final IOException e) { + // The attachment's temp storage no longer exists, just ignore the file + } catch (final SecurityException e) { + // Likely thrown by openFileDescriptor due to an expired access grant. + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "uri: " + messagePartData.getContentUri()); + } + } + } else { + message.addPart(messagePartData); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Write a message part to our local database + * + * @param dbWrapper The database + * @param messagePart The message part to insert + * @return The row id of the newly inserted part + */ + static String insertNewMessagePartInTransaction(final DatabaseWrapper dbWrapper, + final MessagePartData messagePart, final String conversationId) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Assert.isTrue(!TextUtils.isEmpty(messagePart.getMessageId())); + + // Insert a new part row + final SQLiteStatement insert = messagePart.getInsertStatement(dbWrapper, conversationId); + final long rowNumber = insert.executeInsert(); + + Assert.inRange(rowNumber, 0, Long.MAX_VALUE); + final String partId = Long.toString(rowNumber); + + // Update the part id + messagePart.updatePartId(partId); + + return partId; + } + + /** + * Insert a message and its parts into the table + */ + @DoesNotRunOnMainThread + public static void insertNewMessageInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + // Insert message row + final SQLiteStatement insert = message.getInsertStatement(dbWrapper); + final long rowNumber = insert.executeInsert(); + + Assert.inRange(rowNumber, 0, Long.MAX_VALUE); + final String messageId = Long.toString(rowNumber); + message.updateMessageId(messageId); + // Insert new parts + for (final MessagePartData messagePart : message.getParts()) { + messagePart.updateMessageId(messageId); + insertNewMessagePartInTransaction(dbWrapper, messagePart, message.getConversationId()); + } + } + + /** + * Update a message and add its parts into the table + */ + @DoesNotRunOnMainThread + public static void updateMessageInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final String messageId = message.getMessageId(); + // Check message still exists (sms sync or delete might have purged it) + final MessageData current = BugleDatabaseOperations.readMessage(dbWrapper, messageId); + if (current != null) { + // Delete existing message parts) + deletePartsForMessage(dbWrapper, message.getMessageId()); + // Insert new parts + for (final MessagePartData messagePart : message.getParts()) { + messagePart.updatePartId(null); + messagePart.updateMessageId(message.getMessageId()); + insertNewMessagePartInTransaction(dbWrapper, messagePart, + message.getConversationId()); + } + // Update message row + final ContentValues values = new ContentValues(); + message.populate(values); + updateMessageRowIfExists(dbWrapper, message.getMessageId(), values); + } + } + + @DoesNotRunOnMainThread + public static void updateMessageAndPartsInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message, final List<MessagePartData> partsToUpdate) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + for (final MessagePartData messagePart : partsToUpdate) { + values.clear(); + messagePart.populate(values); + updatePartRowIfExists(dbWrapper, messagePart.getPartId(), values); + } + values.clear(); + message.populate(values); + updateMessageRowIfExists(dbWrapper, message.getMessageId(), values); + } + + /** + * Delete all parts for a message + */ + static void deletePartsForMessage(final DatabaseWrapper dbWrapper, + final String messageId) { + final int cnt = dbWrapper.delete(DatabaseHelper.PARTS_TABLE, + PartColumns.MESSAGE_ID + " =?", + new String[] { messageId }); + Assert.inRange(cnt, 0, Integer.MAX_VALUE); + } + + /** + * Delete one message and update the conversation (if necessary). + * + * @return number of rows deleted (should be 1 or 0). + */ + @DoesNotRunOnMainThread + public static int deleteMessage(final DatabaseWrapper dbWrapper, final String messageId) { + Assert.isNotMainThread(); + dbWrapper.beginTransaction(); + try { + // Read message to find out which conversation it is in + final MessageData message = BugleDatabaseOperations.readMessage(dbWrapper, messageId); + + int count = 0; + if (message != null) { + final String conversationId = message.getConversationId(); + // Delete message + count = dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns._ID + "=?", new String[] { messageId }); + + if (!deleteConversationIfEmptyInTransaction(dbWrapper, conversationId)) { + // TODO: Should we leave the conversation sort timestamp alone? + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + false/* shouldAutoSwitchSelfId */, false/*archived*/); + } + } + dbWrapper.setTransactionSuccessful(); + return count; + } finally { + dbWrapper.endTransaction(); + } + } + + /** + * Deletes the conversation if there are zero non-draft messages left. + * <p> + * This is necessary because the telephony database has a trigger that deletes threads after + * their last message is deleted. We need to ensure that if a thread goes away, we also delete + * the conversation in Bugle. We don't store draft messages in telephony, so we ignore those + * when querying for the # of messages in the conversation. + * + * @return true if the conversation was deleted + */ + @DoesNotRunOnMainThread + public static boolean deleteConversationIfEmptyInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Cursor cursor = null; + try { + // TODO: The refreshConversationMetadataInTransaction method below uses this + // same query; maybe they should share this logic? + + // Check to see if there are any (non-draft) messages in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + new String[] { conversationId }, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + if (cursor.getCount() == 0) { + dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID + "=?", new String[] { conversationId }); + LogUtil.i(TAG, + "BugleDatabaseOperations: Deleted empty conversation " + conversationId); + return true; + } else { + return false; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private static final String[] REFRESH_CONVERSATION_MESSAGE_PROJECTION = new String[] { + MessageColumns._ID, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SENDER_PARTICIPANT_ID + }; + + /** + * Update conversation snippet, timestamp and optionally self id to match latest message in + * conversation. + */ + @DoesNotRunOnMainThread + public static void refreshConversationMetadataInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final boolean shouldAutoSwitchSelfId, + boolean keepArchived) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Cursor cursor = null; + try { + // Check to see if there are any (non-draft) messages in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + new String[] { conversationId }, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + + if (cursor.moveToFirst()) { + // Refresh latest message in conversation + final String latestMessageId = cursor.getString(0); + final long latestMessageTimestamp = cursor.getLong(1); + final String senderParticipantId = cursor.getString(2); + final boolean senderBlocked = isBlockedParticipant(dbWrapper, senderParticipantId); + updateConversationMetadataInTransaction(dbWrapper, conversationId, + latestMessageId, latestMessageTimestamp, senderBlocked || keepArchived, + shouldAutoSwitchSelfId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * When moving/removing an existing message update conversation metadata if necessary + * @param dbWrapper db wrapper + * @param conversationId conversation to modify + * @param messageId message that is leaving the conversation + * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a + * result of this call when we see a new latest message? + * @param keepArchived should we keep the conversation archived despite refresh + */ + @DoesNotRunOnMainThread + public static void maybeRefreshConversationMetadataInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, final String messageId, + final boolean shouldAutoSwitchSelfId, final boolean keepArchived) { + Assert.isNotMainThread(); + boolean refresh = true; + if (!TextUtils.isEmpty(messageId)) { + refresh = false; + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.LATEST_MESSAGE_ID }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + refresh = TextUtils.equals(cursor.getString(0), messageId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + if (refresh) { + // TODO: I think it is okay to delete the conversation if it is empty... + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + shouldAutoSwitchSelfId, keepArchived); + } + } + + + + // SQL statement to query latest message if for particular conversation + private static final String QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL = "SELECT " + + ConversationColumns.LATEST_MESSAGE_ID + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns._ID + "=? LIMIT 1"; + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + @DoesNotRunOnMainThread + public static SQLiteStatement getQueryConversationsLatestMessageStatement( + final DatabaseWrapper db, final String conversationId) { + Assert.isNotMainThread(); + final SQLiteStatement query = db.getStatementInTransaction( + DatabaseWrapper.INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE, + QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL); + query.clearBindings(); + query.bindString(1, conversationId); + return query; + } + + // SQL statement to query latest message if for particular conversation + private static final String QUERY_MESSAGES_LATEST_MESSAGE_SQL = "SELECT " + + MessageColumns._ID + " FROM " + DatabaseHelper.MESSAGES_TABLE + + " WHERE " + MessageColumns.CONVERSATION_ID + "=? ORDER BY " + + MessageColumns.RECEIVED_TIMESTAMP + " DESC LIMIT 1"; + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + @DoesNotRunOnMainThread + public static SQLiteStatement getQueryMessagesLatestMessageStatement( + final DatabaseWrapper db, final String conversationId) { + Assert.isNotMainThread(); + final SQLiteStatement query = db.getStatementInTransaction( + DatabaseWrapper.INDEX_QUERY_MESSAGES_LATEST_MESSAGE, + QUERY_MESSAGES_LATEST_MESSAGE_SQL); + query.clearBindings(); + query.bindString(1, conversationId); + return query; + } + + /** + * Update conversation metadata if necessary + * @param dbWrapper db wrapper + * @param conversationId conversation to modify + * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a + * result of this call when we see a new latest message? + * @param keepArchived if the conversation should be kept archived + */ + @DoesNotRunOnMainThread + public static void maybeRefreshConversationMetadataInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final boolean shouldAutoSwitchSelfId, boolean keepArchived) { + Assert.isNotMainThread(); + String currentLatestMessageId = null; + String latestMessageId = null; + try { + final SQLiteStatement currentLatestMessageIdSql = + getQueryConversationsLatestMessageStatement(dbWrapper, conversationId); + currentLatestMessageId = currentLatestMessageIdSql.simpleQueryForString(); + + final SQLiteStatement latestMessageIdSql = + getQueryMessagesLatestMessageStatement(dbWrapper, conversationId); + latestMessageId = latestMessageIdSql.simpleQueryForString(); + } catch (final SQLiteDoneException e) { + LogUtil.e(TAG, "BugleDatabaseOperations: Query for latest message failed", e); + } + + if (TextUtils.isEmpty(currentLatestMessageId) || + !TextUtils.equals(currentLatestMessageId, latestMessageId)) { + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + shouldAutoSwitchSelfId, keepArchived); + } + } + + static boolean getConversationExists(final DatabaseWrapper dbWrapper, + final String conversationId) { + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { /* No projection */}, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + return cursor.getCount() == 1; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** Preserve parts in message but clear the stored draft */ + public static final int UPDATE_MODE_CLEAR_DRAFT = 1; + /** Add the message as a draft */ + public static final int UPDATE_MODE_ADD_DRAFT = 2; + + /** + * Update draft message for specified conversation + * @param dbWrapper local database (wrapped) + * @param conversationId conversation to update + * @param message Optional message to preserve attachments for (either as draft or for + * sending) + * @param updateMode either {@link #UPDATE_MODE_CLEAR_DRAFT} or + * {@link #UPDATE_MODE_ADD_DRAFT} + * @return message id of newly written draft (else null) + */ + @DoesNotRunOnMainThread + public static String updateDraftMessageData(final DatabaseWrapper dbWrapper, + final String conversationId, @Nullable final MessageData message, + final int updateMode) { + Assert.isNotMainThread(); + Assert.notNull(conversationId); + Assert.inRange(updateMode, UPDATE_MODE_CLEAR_DRAFT, UPDATE_MODE_ADD_DRAFT); + String messageId = null; + Cursor cursor = null; + dbWrapper.beginTransaction(); + try { + // Find all draft parts for the current conversation + final SimpleArrayMap<Uri, MessagePartData> currentDraftParts = new SimpleArrayMap<>(); + cursor = dbWrapper.query(DatabaseHelper.DRAFT_PARTS_VIEW, + MessagePartData.getProjection(), + MessageColumns.CONVERSATION_ID + " =?", + new String[] { conversationId }, null, null, null); + while (cursor.moveToNext()) { + final MessagePartData part = MessagePartData.createFromCursor(cursor); + if (part.isAttachment()) { + currentDraftParts.put(part.getContentUri(), part); + } + } + // Optionally, preserve attachments for "message" + final boolean conversationExists = getConversationExists(dbWrapper, conversationId); + if (message != null && conversationExists) { + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment()) { + currentDraftParts.remove(part.getContentUri()); + } + } + } + + // Delete orphan content + for (int index = 0; index < currentDraftParts.size(); index++) { + final MessagePartData part = currentDraftParts.valueAt(index); + part.destroySync(); + } + + // Delete existing draft (cascade deletes parts) + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }); + + // Write new draft + if (updateMode == UPDATE_MODE_ADD_DRAFT && message != null + && message.hasContent() && conversationExists) { + Assert.equals(MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + message.getStatus()); + + // Now add draft to message table + insertNewMessageInTransaction(dbWrapper, message); + messageId = message.getMessageId(); + } + + if (conversationExists) { + updateConversationDraftSnippetAndPreviewInTransaction( + dbWrapper, conversationId, message); + + if (message != null && message.getSelfId() != null) { + updateConversationSelfIdInTransaction(dbWrapper, conversationId, + message.getSelfId()); + } + } + + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, + "Updated draft message " + messageId + " for conversation " + conversationId); + } + return messageId; + } + + /** + * Read the first draft message associated with this conversation. + * If none present create an empty (sms) draft message. + */ + @DoesNotRunOnMainThread + public static MessageData readDraftMessageData(final DatabaseWrapper dbWrapper, + final String conversationId, final String conversationSelfId) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + dbWrapper.beginTransaction(); + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bindDraft(cursor, conversationSelfId); + readMessagePartsData(dbWrapper, message, true); + // Disconnect draft parts from DB + for (final MessagePartData part : message.getParts()) { + part.updatePartId(null); + part.updateMessageId(null); + } + message.updateMessageId(null); + } + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + // Internal + private static void addParticipantToConversation(final DatabaseWrapper dbWrapper, + final ParticipantData participant, final String conversationId) { + final String participantId = getOrCreateParticipantInTransaction(dbWrapper, participant); + Assert.notNull(participantId); + + // Add the participant to the conversation participants table + final ContentValues values = new ContentValues(); + values.put(ConversationParticipantsColumns.CONVERSATION_ID, conversationId); + values.put(ConversationParticipantsColumns.PARTICIPANT_ID, participantId); + dbWrapper.insert(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, null, values); + } + + /** + * Get string used as canonical recipient for participant cache for sub id + */ + private static String getCanonicalRecipientFromSubId(final int subId) { + return "SELF(" + subId + ")"; + } + + /** + * Maps from a sub id or phone number to a participant id if there is one. + * + * @return If the participant is available in our cache, or the DB, this returns the + * participant id for the given subid/phone number. Otherwise it returns null. + */ + @VisibleForTesting + private static String getParticipantId(final DatabaseWrapper dbWrapper, + final int subId, final String canonicalRecipient) { + // First check our memory cache for the participant Id + String participantId; + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + participantId = sNormalizedPhoneNumberToParticipantIdCache.get(canonicalRecipient); + } + + if (participantId != null) { + return participantId; + } + + // This code will only be executed for incremental additions. + Cursor cursor = null; + try { + if (subId != ParticipantData.OTHER_THAN_SELF_SUB_ID) { + // Now look for an existing participant in the db with this sub id. + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] {ParticipantColumns._ID}, + ParticipantColumns.SUB_ID + "=?", + new String[] { Integer.toString(subId) }, null, null, null); + } else { + // Look for existing participant with this normalized phone number and no subId. + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] {ParticipantColumns._ID}, + ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " + + ParticipantColumns.SUB_ID + "=?", + new String[] {canonicalRecipient, Integer.toString(subId)}, + null, null, null); + } + + if (cursor.moveToFirst()) { + // TODO Is this assert correct for multi-sim where a new sim was put in? + Assert.isTrue(cursor.getCount() == 1); + + // We found an existing participant in the database + participantId = cursor.getString(0); + + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + // Add it to the cache for next time + sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, + participantId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return participantId; + } + + @DoesNotRunOnMainThread + public static ParticipantData getOrCreateSelf(final DatabaseWrapper dbWrapper, + final int subId) { + Assert.isNotMainThread(); + ParticipantData participant = null; + dbWrapper.beginTransaction(); + try { + final ParticipantData shell = ParticipantData.getSelfParticipant(subId); + final String participantId = getOrCreateParticipantInTransaction(dbWrapper, shell); + participant = getExistingParticipant(dbWrapper, participantId); + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + } + return participant; + } + + /** + * Lookup and if necessary create a new participant + * @param dbWrapper Database wrapper + * @param participant Participant to find/create + * @return participantId ParticipantId for existing or newly created participant + */ + @DoesNotRunOnMainThread + public static String getOrCreateParticipantInTransaction(final DatabaseWrapper dbWrapper, + final ParticipantData participant) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + int subId = ParticipantData.OTHER_THAN_SELF_SUB_ID; + String participantId = null; + String canonicalRecipient = null; + if (participant.isSelf()) { + subId = participant.getSubId(); + canonicalRecipient = getCanonicalRecipientFromSubId(subId); + } else { + canonicalRecipient = participant.getNormalizedDestination(); + } + Assert.notNull(canonicalRecipient); + participantId = getParticipantId(dbWrapper, subId, canonicalRecipient); + + if (participantId != null) { + return participantId; + } + + if (!participant.isContactIdResolved()) { + // Refresh participant's name and avatar with matching contact in CP2. + ParticipantRefresh.refreshParticipant(dbWrapper, participant); + } + + // Insert the participant into the participants table + final ContentValues values = participant.toContentValues(); + final long participantRow = dbWrapper.insert(DatabaseHelper.PARTICIPANTS_TABLE, null, + values); + participantId = Long.toString(participantRow); + Assert.notNull(canonicalRecipient); + + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + // Now that we've inserted it, add it to our cache + sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, participantId); + } + + return participantId; + } + + @DoesNotRunOnMainThread + public static void updateDestination(final DatabaseWrapper dbWrapper, + final String destination, final boolean blocked) { + Assert.isNotMainThread(); + final ContentValues values = new ContentValues(); + values.put(ParticipantColumns.BLOCKED, blocked ? 1 : 0); + dbWrapper.update(DatabaseHelper.PARTICIPANTS_TABLE, values, + ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " + + ParticipantColumns.SUB_ID + "=?", + new String[] { destination, Integer.toString( + ParticipantData.OTHER_THAN_SELF_SUB_ID) }); + } + + @DoesNotRunOnMainThread + public static String getConversationFromOtherParticipantDestination( + final DatabaseWrapper db, final String otherDestination) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID }, + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + "=?", + new String[] { otherDestination }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + + /** + * Get a list of conversations that contain any of participants specified. + */ + private static HashSet<String> getConversationsForParticipants( + final ArrayList<String> participantIds) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final HashSet<String> conversationIds = new HashSet<String>(); + + final String selection = ConversationParticipantsColumns.PARTICIPANT_ID + "=?"; + for (final String participantId : participantIds) { + final String[] selectionArgs = new String[] { participantId }; + final Cursor cursor = db.query(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, + ConversationParticipantsQuery.PROJECTION, + selection, selectionArgs, null, null, null); + + if (cursor != null) { + try { + while (cursor.moveToNext()) { + final String conversationId = cursor.getString( + ConversationParticipantsQuery.INDEX_CONVERSATION_ID); + conversationIds.add(conversationId); + } + } finally { + cursor.close(); + } + } + } + + return conversationIds; + } + + /** + * Refresh conversation names/avatars based on a list of participants that are changed. + */ + @DoesNotRunOnMainThread + public static void refreshConversationsForParticipants(final ArrayList<String> participants) { + Assert.isNotMainThread(); + final HashSet<String> conversationIds = getConversationsForParticipants(participants); + if (conversationIds.size() > 0) { + for (final String conversationId : conversationIds) { + refreshConversation(conversationId); + } + + MessagingContentProvider.notifyConversationListChanged(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Number of conversations refreshed:" + conversationIds.size()); + } + } + } + + /** + * Refresh conversation names/avatars based on a changed participant. + */ + @DoesNotRunOnMainThread + public static void refreshConversationsForParticipant(final String participantId) { + Assert.isNotMainThread(); + final ArrayList<String> participantList = new ArrayList<String>(1); + participantList.add(participantId); + refreshConversationsForParticipants(participantList); + } + + /** + * Refresh one conversation. + */ + private static void refreshConversation(final String conversationId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationNameAndAvatarInTransaction(db, + conversationId); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyParticipantsChanged(conversationId); + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + } + + @DoesNotRunOnMainThread + public static boolean updateRowIfExists(final DatabaseWrapper db, final String table, + final String rowKey, final String rowId, final ContentValues values) { + Assert.isNotMainThread(); + final StringBuilder sb = new StringBuilder(); + final ArrayList<String> whereValues = new ArrayList<String>(values.size() + 1); + whereValues.add(rowId); + + for (final String key : values.keySet()) { + if (sb.length() > 0) { + sb.append(" OR "); + } + final Object value = values.get(key); + sb.append(key); + if (value != null) { + sb.append(" IS NOT ?"); + whereValues.add(value.toString()); + } else { + sb.append(" IS NOT NULL"); + } + } + + final String whereClause = rowKey + "=?" + " AND (" + sb.toString() + ")"; + final String [] whereValuesArray = whereValues.toArray(new String[whereValues.size()]); + final int count = db.update(table, values, whereClause, whereValuesArray); + if (count > 1) { + LogUtil.w(LogUtil.BUGLE_TAG, "Updated more than 1 row " + count + "; " + table + + " for " + rowKey + " = " + rowId + " (deleted?)"); + } + Assert.inRange(count, 0, 1); + return (count >= 0); + } +} |