| /* |
| * 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<String> mConversationsToUpdate; |
| // Cache of thread->conversationId map |
| private final ThreadInfoCache mCache; |
| |
| // Set of SMS messages to add |
| private final ArrayList<SmsMessage> mSmsToAdd; |
| // Set of MMS messages to add |
| private final ArrayList<MmsMessage> mMmsToAdd; |
| // Set of local messages to delete |
| private final ArrayList<LocalDatabaseMessage> mMessagesToDelete; |
| |
| SyncMessageBatch(final ArrayList<SmsMessage> smsToAdd, |
| final ArrayList<MmsMessage> mmsToAdd, |
| final ArrayList<LocalDatabaseMessage> messagesToDelete, |
| final ThreadInfoCache cache) { |
| mSmsToAdd = smsToAdd; |
| mMmsToAdd = mmsToAdd; |
| mMessagesToDelete = messagesToDelete; |
| mCache = cache; |
| mConversationsToUpdate = new HashSet<String>(); |
| } |
| |
| 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<LocalDatabaseMessage> 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; |
| } |
| } |