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
17package com.android.messaging.datamodel.action;
18
19import android.database.Cursor;
20import android.database.sqlite.SQLiteConstraintException;
21import android.provider.Telephony;
22import android.provider.Telephony.Mms;
23import android.provider.Telephony.Sms;
24import android.text.TextUtils;
25
26import com.android.messaging.datamodel.BugleDatabaseOperations;
27import com.android.messaging.datamodel.DataModel;
28import com.android.messaging.datamodel.DatabaseHelper;
29import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
30import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
31import com.android.messaging.datamodel.DatabaseWrapper;
32import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
33import com.android.messaging.datamodel.data.MessageData;
34import com.android.messaging.datamodel.data.ParticipantData;
35import com.android.messaging.mmslib.pdu.PduHeaders;
36import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
37import com.android.messaging.sms.DatabaseMessages.MmsMessage;
38import com.android.messaging.sms.DatabaseMessages.SmsMessage;
39import com.android.messaging.sms.MmsUtils;
40import com.android.messaging.util.Assert;
41import com.android.messaging.util.LogUtil;
42
43import java.util.ArrayList;
44import java.util.Arrays;
45import java.util.HashSet;
46import java.util.List;
47import java.util.Locale;
48
49/**
50 * Update local database with a batch of messages to add/delete in one transaction
51 */
52class 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