1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.messaging.datamodel.action; 18 19 import android.database.Cursor; 20 import android.database.sqlite.SQLiteConstraintException; 21 import android.provider.Telephony; 22 import android.provider.Telephony.Mms; 23 import android.provider.Telephony.Sms; 24 import android.text.TextUtils; 25 26 import com.android.messaging.datamodel.BugleDatabaseOperations; 27 import com.android.messaging.datamodel.DataModel; 28 import com.android.messaging.datamodel.DatabaseHelper; 29 import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; 30 import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; 31 import com.android.messaging.datamodel.DatabaseWrapper; 32 import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; 33 import com.android.messaging.datamodel.data.MessageData; 34 import com.android.messaging.datamodel.data.ParticipantData; 35 import com.android.messaging.mmslib.pdu.PduHeaders; 36 import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage; 37 import com.android.messaging.sms.DatabaseMessages.MmsMessage; 38 import com.android.messaging.sms.DatabaseMessages.SmsMessage; 39 import com.android.messaging.sms.MmsUtils; 40 import com.android.messaging.util.Assert; 41 import com.android.messaging.util.LogUtil; 42 43 import java.util.ArrayList; 44 import java.util.Arrays; 45 import java.util.HashSet; 46 import java.util.List; 47 import java.util.Locale; 48 49 /** 50 * Update local database with a batch of messages to add/delete in one transaction 51 */ 52 class SyncMessageBatch { 53 private static final String TAG = LogUtil.BUGLE_TAG; 54 55 // Variables used during executeAction 56 private final HashSet<String> mConversationsToUpdate; 57 // Cache of thread->conversationId map 58 private final ThreadInfoCache mCache; 59 60 // Set of SMS messages to add 61 private final ArrayList<SmsMessage> mSmsToAdd; 62 // Set of MMS messages to add 63 private final ArrayList<MmsMessage> mMmsToAdd; 64 // Set of local messages to delete 65 private final ArrayList<LocalDatabaseMessage> mMessagesToDelete; 66 SyncMessageBatch(final ArrayList<SmsMessage> smsToAdd, final ArrayList<MmsMessage> mmsToAdd, final ArrayList<LocalDatabaseMessage> messagesToDelete, final ThreadInfoCache cache)67 SyncMessageBatch(final ArrayList<SmsMessage> smsToAdd, 68 final ArrayList<MmsMessage> mmsToAdd, 69 final ArrayList<LocalDatabaseMessage> messagesToDelete, 70 final ThreadInfoCache cache) { 71 mSmsToAdd = smsToAdd; 72 mMmsToAdd = mmsToAdd; 73 mMessagesToDelete = messagesToDelete; 74 mCache = cache; 75 mConversationsToUpdate = new HashSet<String>(); 76 } 77 updateLocalDatabase()78 void updateLocalDatabase() { 79 // Perform local database changes in one transaction 80 final DatabaseWrapper db = DataModel.get().getDatabase(); 81 db.beginTransaction(); 82 try { 83 // Store all the SMS messages 84 for (final SmsMessage sms : mSmsToAdd) { 85 storeSms(db, sms); 86 } 87 // Store all the MMS messages 88 for (final MmsMessage mms : mMmsToAdd) { 89 storeMms(db, mms); 90 } 91 // Keep track of conversations with messages deleted 92 for (final LocalDatabaseMessage message : mMessagesToDelete) { 93 mConversationsToUpdate.add(message.getConversationId()); 94 } 95 // Batch delete local messages 96 batchDelete(db, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID, 97 messageListToIds(mMessagesToDelete)); 98 99 for (final LocalDatabaseMessage message : mMessagesToDelete) { 100 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 101 LogUtil.v(TAG, "SyncMessageBatch: Deleted message " + message.getLocalId() 102 + " for SMS/MMS " + message.getUri() + " with timestamp " 103 + message.getTimestampInMillis()); 104 } 105 } 106 107 // Update conversation state for imported messages, like snippet, 108 updateConversations(db); 109 110 db.setTransactionSuccessful(); 111 } finally { 112 db.endTransaction(); 113 } 114 } 115 messageListToIds(final List<LocalDatabaseMessage> messagesToDelete)116 private static String[] messageListToIds(final List<LocalDatabaseMessage> messagesToDelete) { 117 final String[] ids = new String[messagesToDelete.size()]; 118 for (int i = 0; i < ids.length; i++) { 119 ids[i] = Long.toString(messagesToDelete.get(i).getLocalId()); 120 } 121 return ids; 122 } 123 124 /** 125 * Store the SMS message into local database. 126 * 127 * @param sms 128 */ storeSms(final DatabaseWrapper db, final SmsMessage sms)129 private void storeSms(final DatabaseWrapper db, final SmsMessage sms) { 130 if (sms.mBody == null) { 131 LogUtil.w(TAG, "SyncMessageBatch: SMS " + sms.mUri + " has no body; adding empty one"); 132 // try to fix it 133 sms.mBody = ""; 134 } 135 136 if (TextUtils.isEmpty(sms.mAddress)) { 137 LogUtil.e(TAG, "SyncMessageBatch: SMS has no address; using unknown sender"); 138 // try to fix it 139 sms.mAddress = ParticipantData.getUnknownSenderDestination(); 140 } 141 142 // TODO : We need to also deal with messages in a failed/retry state 143 final boolean isOutgoing = sms.mType != Sms.MESSAGE_TYPE_INBOX; 144 145 final String otherPhoneNumber = sms.mAddress; 146 147 // A forced resync of all messages should still keep the archived states. 148 // The database upgrade code notifies sync manager of this. We need to 149 // honor the original customization to this conversation if created. 150 final String conversationId = mCache.getOrCreateConversation(db, sms.mThreadId, sms.mSubId, 151 DataModel.get().getSyncManager().getCustomizationForThread(sms.mThreadId)); 152 if (conversationId == null) { 153 // Cannot create conversation for this message? This should not happen. 154 LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for SMS thread " 155 + sms.mThreadId); 156 return; 157 } 158 final ParticipantData self = ParticipantData.getSelfParticipant(sms.getSubId()); 159 final String selfId = 160 BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); 161 final ParticipantData sender = isOutgoing ? 162 self : 163 ParticipantData.getFromRawPhoneBySimLocale(otherPhoneNumber, sms.getSubId()); 164 final String participantId = (isOutgoing ? selfId : 165 BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender)); 166 167 final int bugleStatus = bugleStatusForSms(isOutgoing, sms.mType, sms.mStatus); 168 169 final MessageData message = MessageData.createSmsMessage( 170 sms.mUri, 171 participantId, 172 selfId, 173 conversationId, 174 bugleStatus, 175 sms.mSeen, 176 sms.mRead, 177 sms.mTimestampSentInMillis, 178 sms.mTimestampInMillis, 179 sms.mBody); 180 181 // Inserting sms content into messages table 182 try { 183 BugleDatabaseOperations.insertNewMessageInTransaction(db, message); 184 } catch (SQLiteConstraintException e) { 185 rethrowSQLiteConstraintExceptionWithDetails(e, db, sms.mUri, sms.mThreadId, 186 conversationId, selfId, participantId); 187 } 188 189 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 190 LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId() 191 + " for SMS " + message.getSmsMessageUri() + " received at " 192 + message.getReceivedTimeStamp()); 193 } 194 195 // Keep track of updated conversation for later updating the conversation snippet, etc. 196 mConversationsToUpdate.add(conversationId); 197 } 198 bugleStatusForSms(final boolean isOutgoing, final int type, final int status)199 public static int bugleStatusForSms(final boolean isOutgoing, final int type, 200 final int status) { 201 int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN; 202 // For a message we sync either 203 if (isOutgoing) { 204 // Outgoing message not yet been sent 205 if (type == Telephony.Sms.MESSAGE_TYPE_FAILED || 206 type == Telephony.Sms.MESSAGE_TYPE_OUTBOX || 207 type == Telephony.Sms.MESSAGE_TYPE_QUEUED || 208 (type == Telephony.Sms.MESSAGE_TYPE_SENT && 209 status == Telephony.Sms.STATUS_FAILED)) { 210 // Not sent counts as failed and available for manual resend 211 bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED; 212 } else if (status == Sms.STATUS_COMPLETE) { 213 bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; 214 } else { 215 // Otherwise outgoing message is complete 216 bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE; 217 } 218 } else { 219 // All incoming SMS messages are complete 220 bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE; 221 } 222 return bugleStatus; 223 } 224 225 /** 226 * Store the MMS message into local database 227 * 228 * @param mms 229 */ storeMms(final DatabaseWrapper db, final MmsMessage mms)230 private void storeMms(final DatabaseWrapper db, final MmsMessage mms) { 231 if (mms.mParts.size() < 1) { 232 LogUtil.w(TAG, "SyncMessageBatch: MMS " + mms.mUri + " has no parts"); 233 } 234 235 // TODO : We need to also deal with messages in a failed/retry state 236 final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX; 237 final boolean isNotification = (mms.mMmsMessageType == 238 PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); 239 240 final String senderId = mms.mSender; 241 242 // A forced resync of all messages should still keep the archived states. 243 // The database upgrade code notifies sync manager of this. We need to 244 // honor the original customization to this conversation if created. 245 final String conversationId = mCache.getOrCreateConversation(db, mms.mThreadId, mms.mSubId, 246 DataModel.get().getSyncManager().getCustomizationForThread(mms.mThreadId)); 247 if (conversationId == null) { 248 LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for MMS thread " 249 + mms.mThreadId); 250 return; 251 } 252 final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId()); 253 final String selfId = 254 BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); 255 final ParticipantData sender = isOutgoing ? 256 self : ParticipantData.getFromRawPhoneBySimLocale(senderId, mms.getSubId()); 257 final String participantId = (isOutgoing ? selfId : 258 BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender)); 259 260 final int bugleStatus = MmsUtils.bugleStatusForMms(isOutgoing, isNotification, mms.mType); 261 262 // Import message and all of the parts. 263 // TODO : For now we are importing these in the order we found them in the MMS 264 // database. Ideally we would load and parse the SMIL which describes how the parts relate 265 // to one another. 266 267 // TODO: Need to set correct status on message 268 final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId, 269 selfId, bugleStatus); 270 271 // Inserting mms content into messages table 272 try { 273 BugleDatabaseOperations.insertNewMessageInTransaction(db, message); 274 } catch (SQLiteConstraintException e) { 275 rethrowSQLiteConstraintExceptionWithDetails(e, db, mms.mUri, mms.mThreadId, 276 conversationId, selfId, participantId); 277 } 278 279 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 280 LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId() 281 + " for MMS " + message.getSmsMessageUri() + " received at " 282 + message.getReceivedTimeStamp()); 283 } 284 285 // Keep track of updated conversation for later updating the conversation snippet, etc. 286 mConversationsToUpdate.add(conversationId); 287 } 288 289 // TODO: Remove this after we no longer see this crash (b/18375758) rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e, DatabaseWrapper db, String messageUri, long threadId, String conversationId, String selfId, String senderId)290 private static void rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e, 291 DatabaseWrapper db, String messageUri, long threadId, String conversationId, 292 String selfId, String senderId) { 293 // Add some extra debug information to the exception for tracking down b/18375758. 294 // The default detail message for SQLiteConstraintException tells us that a foreign 295 // key constraint failed, but not which one! Messages have foreign keys to 3 tables: 296 // conversations, participants (self), participants (sender). We'll query each one 297 // to determine which one(s) violated the constraint, and then throw a new exception 298 // with those details. 299 300 String foundConversationId = null; 301 Cursor cursor = null; 302 try { 303 // Look for an existing conversation in the db with the conversation id 304 cursor = db.rawQuery("SELECT " + ConversationColumns._ID 305 + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE 306 + " WHERE " + ConversationColumns._ID + "=" + conversationId, 307 null); 308 if (cursor != null && cursor.moveToFirst()) { 309 Assert.isTrue(cursor.getCount() == 1); 310 foundConversationId = cursor.getString(0); 311 } 312 } finally { 313 if (cursor != null) { 314 cursor.close(); 315 } 316 } 317 318 ParticipantData foundSelfParticipant = 319 BugleDatabaseOperations.getExistingParticipant(db, selfId); 320 ParticipantData foundSenderParticipant = 321 BugleDatabaseOperations.getExistingParticipant(db, senderId); 322 323 String errorMsg = "SQLiteConstraintException while inserting message for " + messageUri 324 + "; conversation id from getOrCreateConversation = " + conversationId 325 + " (lookup thread = " + threadId + "), found conversation id = " 326 + foundConversationId + ", found self participant = " 327 + LogUtil.sanitizePII(foundSelfParticipant.getNormalizedDestination()) 328 + " (lookup id = " + selfId + "), found sender participant = " 329 + LogUtil.sanitizePII(foundSenderParticipant.getNormalizedDestination()) 330 + " (lookup id = " + senderId + ")"; 331 throw new RuntimeException(errorMsg, e); 332 } 333 334 /** 335 * Use the tracked latest message info to update conversations, including 336 * latest chat message and sort timestamp. 337 */ updateConversations(final DatabaseWrapper db)338 private void updateConversations(final DatabaseWrapper db) { 339 for (final String conversationId : mConversationsToUpdate) { 340 if (BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(db, 341 conversationId)) { 342 continue; 343 } 344 345 final boolean archived = mCache.isArchived(conversationId); 346 // Always attempt to auto-switch conversation self id for sync/import case. 347 BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(db, 348 conversationId, true /*shouldAutoSwitchSelfId*/, archived /*keepArchived*/); 349 } 350 } 351 352 353 /** 354 * Batch delete database rows by matching a column with a list of values, usually some 355 * kind of IDs. 356 * 357 * @param table 358 * @param column 359 * @param ids 360 * @return Total number of deleted messages 361 */ batchDelete(final DatabaseWrapper db, final String table, final String column, final String[] ids)362 private static int batchDelete(final DatabaseWrapper db, final String table, 363 final String column, final String[] ids) { 364 int totalDeleted = 0; 365 final int totalIds = ids.length; 366 for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) { 367 final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding 368 final int count = end - start; 369 final String batchSelection = String.format( 370 Locale.US, 371 "%s IN %s", 372 column, 373 MmsUtils.getSqlInOperand(count)); 374 final String[] batchSelectionArgs = Arrays.copyOfRange(ids, start, end); 375 final int deleted = db.delete( 376 table, 377 batchSelection, 378 batchSelectionArgs); 379 totalDeleted += deleted; 380 } 381 return totalDeleted; 382 } 383 } 384