/* * 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.action; import android.database.Cursor; import android.database.sqlite.SQLiteConstraintException; import android.provider.Telephony; import android.provider.Telephony.Mms; import android.provider.Telephony.Sms; import android.text.TextUtils; import com.android.messaging.datamodel.BugleDatabaseOperations; import com.android.messaging.datamodel.DataModel; import com.android.messaging.datamodel.DatabaseHelper; import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; import com.android.messaging.datamodel.DatabaseWrapper; import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.mmslib.pdu.PduHeaders; import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage; import com.android.messaging.sms.DatabaseMessages.MmsMessage; import com.android.messaging.sms.DatabaseMessages.SmsMessage; import com.android.messaging.sms.MmsUtils; import com.android.messaging.util.Assert; import com.android.messaging.util.LogUtil; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Locale; /** * Update local database with a batch of messages to add/delete in one transaction */ class SyncMessageBatch { private static final String TAG = LogUtil.BUGLE_TAG; // Variables used during executeAction private final HashSet mConversationsToUpdate; // Cache of thread->conversationId map private final ThreadInfoCache mCache; // Set of SMS messages to add private final ArrayList mSmsToAdd; // Set of MMS messages to add private final ArrayList mMmsToAdd; // Set of local messages to delete private final ArrayList mMessagesToDelete; SyncMessageBatch(final ArrayList smsToAdd, final ArrayList mmsToAdd, final ArrayList messagesToDelete, final ThreadInfoCache cache) { mSmsToAdd = smsToAdd; mMmsToAdd = mmsToAdd; mMessagesToDelete = messagesToDelete; mCache = cache; mConversationsToUpdate = new HashSet(); } void updateLocalDatabase() { // Perform local database changes in one transaction final DatabaseWrapper db = DataModel.get().getDatabase(); db.beginTransaction(); try { // Store all the SMS messages for (final SmsMessage sms : mSmsToAdd) { storeSms(db, sms); } // Store all the MMS messages for (final MmsMessage mms : mMmsToAdd) { storeMms(db, mms); } // Keep track of conversations with messages deleted for (final LocalDatabaseMessage message : mMessagesToDelete) { mConversationsToUpdate.add(message.getConversationId()); } // Batch delete local messages batchDelete(db, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID, messageListToIds(mMessagesToDelete)); for (final LocalDatabaseMessage message : mMessagesToDelete) { if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "SyncMessageBatch: Deleted message " + message.getLocalId() + " for SMS/MMS " + message.getUri() + " with timestamp " + message.getTimestampInMillis()); } } // Update conversation state for imported messages, like snippet, updateConversations(db); db.setTransactionSuccessful(); } finally { db.endTransaction(); } } private static String[] messageListToIds(final List messagesToDelete) { final String[] ids = new String[messagesToDelete.size()]; for (int i = 0; i < ids.length; i++) { ids[i] = Long.toString(messagesToDelete.get(i).getLocalId()); } return ids; } /** * Store the SMS message into local database. * * @param sms */ private void storeSms(final DatabaseWrapper db, final SmsMessage sms) { if (sms.mBody == null) { LogUtil.w(TAG, "SyncMessageBatch: SMS " + sms.mUri + " has no body; adding empty one"); // try to fix it sms.mBody = ""; } if (TextUtils.isEmpty(sms.mAddress)) { LogUtil.e(TAG, "SyncMessageBatch: SMS has no address; using unknown sender"); // try to fix it sms.mAddress = ParticipantData.getUnknownSenderDestination(); } // TODO : We need to also deal with messages in a failed/retry state final boolean isOutgoing = sms.mType != Sms.MESSAGE_TYPE_INBOX; final String otherPhoneNumber = sms.mAddress; // A forced resync of all messages should still keep the archived states. // The database upgrade code notifies sync manager of this. We need to // honor the original customization to this conversation if created. final String conversationId = mCache.getOrCreateConversation(db, sms.mThreadId, sms.mSubId, DataModel.get().getSyncManager().getCustomizationForThread(sms.mThreadId)); if (conversationId == null) { // Cannot create conversation for this message? This should not happen. LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for SMS thread " + sms.mThreadId); return; } final ParticipantData self = ParticipantData.getSelfParticipant(sms.getSubId()); final String selfId = BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); final ParticipantData sender = isOutgoing ? self : ParticipantData.getFromRawPhoneBySimLocale(otherPhoneNumber, sms.getSubId()); final String participantId = (isOutgoing ? selfId : BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender)); final int bugleStatus = bugleStatusForSms(isOutgoing, sms.mType, sms.mStatus); final MessageData message = MessageData.createSmsMessage( sms.mUri, participantId, selfId, conversationId, bugleStatus, sms.mSeen, sms.mRead, sms.mTimestampSentInMillis, sms.mTimestampInMillis, sms.mBody); // Inserting sms content into messages table try { BugleDatabaseOperations.insertNewMessageInTransaction(db, message); } catch (SQLiteConstraintException e) { rethrowSQLiteConstraintExceptionWithDetails(e, db, sms.mUri, sms.mThreadId, conversationId, selfId, participantId); } if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId() + " for SMS " + message.getSmsMessageUri() + " received at " + message.getReceivedTimeStamp()); } // Keep track of updated conversation for later updating the conversation snippet, etc. mConversationsToUpdate.add(conversationId); } public static int bugleStatusForSms(final boolean isOutgoing, final int type, final int status) { int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN; // For a message we sync either if (isOutgoing) { // Outgoing message not yet been sent if (type == Telephony.Sms.MESSAGE_TYPE_FAILED || type == Telephony.Sms.MESSAGE_TYPE_OUTBOX || type == Telephony.Sms.MESSAGE_TYPE_QUEUED || (type == Telephony.Sms.MESSAGE_TYPE_SENT && status == Telephony.Sms.STATUS_FAILED)) { // Not sent counts as failed and available for manual resend bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED; } else if (status == Sms.STATUS_COMPLETE) { bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; } else { // Otherwise outgoing message is complete bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE; } } else { // All incoming SMS messages are complete bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE; } return bugleStatus; } /** * Store the MMS message into local database * * @param mms */ private void storeMms(final DatabaseWrapper db, final MmsMessage mms) { if (mms.mParts.size() < 1) { LogUtil.w(TAG, "SyncMessageBatch: MMS " + mms.mUri + " has no parts"); } // TODO : We need to also deal with messages in a failed/retry state final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX; final boolean isNotification = (mms.mMmsMessageType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); final String senderId = mms.mSender; // A forced resync of all messages should still keep the archived states. // The database upgrade code notifies sync manager of this. We need to // honor the original customization to this conversation if created. final String conversationId = mCache.getOrCreateConversation(db, mms.mThreadId, mms.mSubId, DataModel.get().getSyncManager().getCustomizationForThread(mms.mThreadId)); if (conversationId == null) { LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for MMS thread " + mms.mThreadId); return; } final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId()); final String selfId = BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); final ParticipantData sender = isOutgoing ? self : ParticipantData.getFromRawPhoneBySimLocale(senderId, mms.getSubId()); final String participantId = (isOutgoing ? selfId : BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender)); final int bugleStatus = MmsUtils.bugleStatusForMms(isOutgoing, isNotification, mms.mType); // Import message and all of the parts. // TODO : For now we are importing these in the order we found them in the MMS // database. Ideally we would load and parse the SMIL which describes how the parts relate // to one another. // TODO: Need to set correct status on message final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId, bugleStatus); // Inserting mms content into messages table try { BugleDatabaseOperations.insertNewMessageInTransaction(db, message); } catch (SQLiteConstraintException e) { rethrowSQLiteConstraintExceptionWithDetails(e, db, mms.mUri, mms.mThreadId, conversationId, selfId, participantId); } if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId() + " for MMS " + message.getSmsMessageUri() + " received at " + message.getReceivedTimeStamp()); } // Keep track of updated conversation for later updating the conversation snippet, etc. mConversationsToUpdate.add(conversationId); } // TODO: Remove this after we no longer see this crash (b/18375758) private static void rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e, DatabaseWrapper db, String messageUri, long threadId, String conversationId, String selfId, String senderId) { // Add some extra debug information to the exception for tracking down b/18375758. // The default detail message for SQLiteConstraintException tells us that a foreign // key constraint failed, but not which one! Messages have foreign keys to 3 tables: // conversations, participants (self), participants (sender). We'll query each one // to determine which one(s) violated the constraint, and then throw a new exception // with those details. String foundConversationId = null; Cursor cursor = null; try { // Look for an existing conversation in the db with the conversation id cursor = db.rawQuery("SELECT " + ConversationColumns._ID + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + " WHERE " + ConversationColumns._ID + "=" + conversationId, null); if (cursor != null && cursor.moveToFirst()) { Assert.isTrue(cursor.getCount() == 1); foundConversationId = cursor.getString(0); } } finally { if (cursor != null) { cursor.close(); } } ParticipantData foundSelfParticipant = BugleDatabaseOperations.getExistingParticipant(db, selfId); ParticipantData foundSenderParticipant = BugleDatabaseOperations.getExistingParticipant(db, senderId); String errorMsg = "SQLiteConstraintException while inserting message for " + messageUri + "; conversation id from getOrCreateConversation = " + conversationId + " (lookup thread = " + threadId + "), found conversation id = " + foundConversationId + ", found self participant = " + LogUtil.sanitizePII(foundSelfParticipant.getNormalizedDestination()) + " (lookup id = " + selfId + "), found sender participant = " + LogUtil.sanitizePII(foundSenderParticipant.getNormalizedDestination()) + " (lookup id = " + senderId + ")"; throw new RuntimeException(errorMsg, e); } /** * Use the tracked latest message info to update conversations, including * latest chat message and sort timestamp. */ private void updateConversations(final DatabaseWrapper db) { for (final String conversationId : mConversationsToUpdate) { if (BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(db, conversationId)) { continue; } final boolean archived = mCache.isArchived(conversationId); // Always attempt to auto-switch conversation self id for sync/import case. BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(db, conversationId, true /*shouldAutoSwitchSelfId*/, archived /*keepArchived*/); } } /** * Batch delete database rows by matching a column with a list of values, usually some * kind of IDs. * * @param table * @param column * @param ids * @return Total number of deleted messages */ private static int batchDelete(final DatabaseWrapper db, final String table, final String column, final String[] ids) { int totalDeleted = 0; final int totalIds = ids.length; for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) { final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding final int count = end - start; final String batchSelection = String.format( Locale.US, "%s IN %s", column, MmsUtils.getSqlInOperand(count)); final String[] batchSelectionArgs = Arrays.copyOfRange(ids, start, end); final int deleted = db.delete( table, batchSelection, batchSelectionArgs); totalDeleted += deleted; } return totalDeleted; } }