Home | History | Annotate | Download | only in action
      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 
     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 
     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 
    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      */
    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 
    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      */
    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)
    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      */
    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      */
    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