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