diff options
author | Mike Dodd <mdodd@google.com> | 2015-08-11 11:16:59 -0700 |
---|---|---|
committer | Mike Dodd <mdodd@google.com> | 2015-08-12 12:47:26 -0700 |
commit | d3b009ae55651f1e60950342468e3c37fdeb0796 (patch) | |
tree | bc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/datamodel | |
parent | ef8c7abbcfc9c770385d6609a4b4bc70240ebdc4 (diff) | |
download | packages_apps_Messaging-d3b009ae55651f1e60950342468e3c37fdeb0796.tar.gz packages_apps_Messaging-d3b009ae55651f1e60950342468e3c37fdeb0796.tar.bz2 packages_apps_Messaging-d3b009ae55651f1e60950342468e3c37fdeb0796.zip |
Initial checkin of AOSP Messaging app.
b/23110861
Change-Id: I11db999bd10656801e618f78ab2b2ef74136fff1
Diffstat (limited to 'src/com/android/messaging/datamodel')
134 files changed, 31547 insertions, 0 deletions
diff --git a/src/com/android/messaging/datamodel/BitmapPool.java b/src/com/android/messaging/datamodel/BitmapPool.java new file mode 100644 index 0000000..1ec4f76 --- /dev/null +++ b/src/com/android/messaging/datamodel/BitmapPool.java @@ -0,0 +1,364 @@ +/* + * 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; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.SparseArray; + +import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.InputStream; + +/** + * Class for creating / loading / reusing bitmaps. This class allow the user to create a new bitmap, + * reuse an bitmap from the pool and to return a bitmap for future reuse. The pool of bitmaps + * allows for faster decode and more efficient memory usage. + * Note: consumers should not create BitmapPool directly, but instead get the pool they want from + * the BitmapPoolManager. + */ +public class BitmapPool implements MemoryCache { + public static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF; + + protected static final boolean VERBOSE = false; + + /** + * Number of reuse failures to skip before reporting. + */ + private static final int FAILED_REPORTING_FREQUENCY = 100; + + /** + * Count of reuse failures which have occurred. + */ + private static volatile int sFailedBitmapReuseCount = 0; + + /** + * Overall pool data structure which currently only supports rectangular bitmaps. The size of + * one of the sides is used to index into the SparseArray. + */ + private final SparseArray<SingleSizePool> mPool; + private final Object mPoolLock = new Object(); + private final String mPoolName; + private final int mMaxSize; + + /** + * Inner structure which holds a pool of bitmaps all the same size (i.e. all have the same + * width as each other and height as each other, but not necessarily the same). + */ + private class SingleSizePool { + int mNumItems; + final Bitmap[] mBitmaps; + + SingleSizePool(final int maxPoolSize) { + mNumItems = 0; + mBitmaps = new Bitmap[maxPoolSize]; + } + } + + /** + * Creates a pool of reused bitmaps with helper decode methods which will attempt to use the + * reclaimed bitmaps. This will help speed up the creation of bitmaps by using already allocated + * bitmaps. + * @param maxSize The overall max size of the pool. When the pool exceeds this size, all calls + * to reclaimBitmap(Bitmap) will result in recycling the bitmap. + * @param name Name of the bitmap pool and only used for logging. Can not be null. + */ + BitmapPool(final int maxSize, @NonNull final String name) { + Assert.isTrue(maxSize > 0); + Assert.isTrue(!TextUtils.isEmpty(name)); + mPoolName = name; + mMaxSize = maxSize; + mPool = new SparseArray<SingleSizePool>(); + } + + @Override + public void reclaim() { + synchronized (mPoolLock) { + for (int p = 0; p < mPool.size(); p++) { + final SingleSizePool singleSizePool = mPool.valueAt(p); + for (int i = 0; i < singleSizePool.mNumItems; i++) { + singleSizePool.mBitmaps[i].recycle(); + singleSizePool.mBitmaps[i] = null; + } + singleSizePool.mNumItems = 0; + } + mPool.clear(); + } + } + + /** + * Creates a new BitmapFactory.Options. + */ + public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled, + final int inputDensity, final int targetDensity) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = scaled; + options.inDensity = inputDensity; + options.inTargetDensity = targetDensity; + options.inSampleSize = 1; + options.inJustDecodeBounds = false; + options.inMutable = true; + return options; + } + + /** + * @return The pool key for the provided image dimensions or 0 if either width or height is + * greater than the max supported image dimension. + */ + private int getPoolKey(final int width, final int height) { + if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) { + return 0; + } + return (width << 16) | height; + } + + /** + * + * @return A bitmap in the pool with the specified dimensions or null if no bitmap with the + * specified dimension is available. + */ + private Bitmap findPoolBitmap(final int width, final int height) { + final int poolKey = getPoolKey(width, height); + if (poolKey != 0) { + synchronized (mPoolLock) { + // Take a bitmap from the pool if one is available + final SingleSizePool singlePool = mPool.get(poolKey); + if (singlePool != null && singlePool.mNumItems > 0) { + singlePool.mNumItems--; + final Bitmap foundBitmap = singlePool.mBitmaps[singlePool.mNumItems]; + singlePool.mBitmaps[singlePool.mNumItems] = null; + return foundBitmap; + } + } + } + return null; + } + + /** + * Internal function to try and find a bitmap in the pool which matches the desired width and + * height and then set that in the bitmap options properly. + * + * TODO: Why do we take a width/height? Shouldn't this already be in the + * BitmapFactory.Options instance? Can we assert that they match? + * @param optionsTmp The BitmapFactory.Options to update with the bitmap for the system to try + * to reuse. + * @param width The width of the reusable bitmap. + * @param height The height of the reusable bitmap. + */ + private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width, + final int height) { + if (optionsTmp.inJustDecodeBounds) { + return; + } + optionsTmp.inBitmap = findPoolBitmap(width, height); + } + + /** + * Load a resource into a bitmap. Uses a bitmap from the pool if possible to reduce memory + * turnover. + * @param resourceId Resource id to load. + * @param resources Application resources. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). Cannot + * be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + */ + public Bitmap decodeSampledBitmapFromResource(final int resourceId, + @NonNull final Resources resources, @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) { + Assert.notNull(resources); + Assert.notNull(optionsTmp); + Assert.isTrue(width > 0); + Assert.isTrue(height > 0); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeResource(resources, resourceId, optionsTmp); + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeResource(resources, resourceId, optionsTmp); + sFailedBitmapReuseCount++; + if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_TAG, + "Pooled bitmap consistently not being reused count = " + + sFailedBitmapReuseCount); + } + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Oom decoding resource " + resourceId); + reclaim(); + } + return b; + } + + /** + * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce memory + * turnover. + * @param inputStream InputStream load. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). Cannot + * be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + */ + public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream, + @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) { + Assert.notNull(inputStream); + Assert.isTrue(width > 0); + Assert.isTrue(height > 0); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + sFailedBitmapReuseCount++; + if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_TAG, + "Pooled bitmap consistently not being reused count = " + + sFailedBitmapReuseCount); + } + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Oom decoding inputStream"); + reclaim(); + } + return b; + } + + /** + * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce memory + * turnover. + * @param bytes Encoded bytes to draw on the bitmap. Cannot be null. + * @param optionsTmp The bitmap will set here and the input should be generated from + * getBitmapOptionsForPool(). Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return A Bitmap with the encoded bytes drawn in it. + */ + public Bitmap decodeByteArray(@NonNull final byte[] bytes, + @NonNull final BitmapFactory.Options optionsTmp, final int width, + final int height) throws OutOfMemoryError { + Assert.notNull(bytes); + Assert.notNull(optionsTmp); + Assert.isTrue(width > 0); + Assert.isTrue(height > 0); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + } catch (final IllegalArgumentException e) { + if (VERBOSE) { + LogUtil.v(LogUtil.BUGLE_TAG, "BitmapPool(" + mPoolName + + ") Unable to use pool bitmap"); + } + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + // (i.e. without the bitmap from the pool) + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + sFailedBitmapReuseCount++; + if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_TAG, + "Pooled bitmap consistently not being reused count = " + + sFailedBitmapReuseCount); + } + } + } + return b; + } + + /** + * Creates a bitmap with the given size, this will reuse a bitmap in the pool, if one is + * available, otherwise this will create a new one. + * @param width The desired width of the bitmap. + * @param height The desired height of the bitmap. + * @return A bitmap with the desired width and height, this maybe a reused bitmap from the pool. + */ + public Bitmap createOrReuseBitmap(final int width, final int height) { + Bitmap b = findPoolBitmap(width, height); + if (b == null) { + b = createBitmap(width, height); + } + return b; + } + + /** + * This will create a new bitmap regardless of pool state. + * @param width The desired width of the bitmap. + * @param height The desired height of the bitmap. + * @return A bitmap with the desired width and height. + */ + private Bitmap createBitmap(final int width, final int height) { + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + } + + /** + * Called when a bitmap is finished being used so that it can be used for another bitmap in the + * future or recycled. Any bitmaps returned should not be used by the caller again. + * @param b The bitmap to return to the pool for future usage or recycled. This cannot be null. + */ + public void reclaimBitmap(@NonNull final Bitmap b) { + Assert.notNull(b); + final int poolKey = getPoolKey(b.getWidth(), b.getHeight()); + if (poolKey == 0 || !b.isMutable()) { + // Unsupported image dimensions or a immutable bitmap. + b.recycle(); + return; + } + synchronized (mPoolLock) { + SingleSizePool singleSizePool = mPool.get(poolKey); + if (singleSizePool == null) { + singleSizePool = new SingleSizePool(mMaxSize); + mPool.append(poolKey, singleSizePool); + } + if (singleSizePool.mNumItems < singleSizePool.mBitmaps.length) { + singleSizePool.mBitmaps[singleSizePool.mNumItems] = b; + singleSizePool.mNumItems++; + } else { + b.recycle(); + } + } + } + + /** + * @return whether the pool is full for a given width and height. + */ + public boolean isFull(final int width, final int height) { + final int poolKey = getPoolKey(width, height); + synchronized (mPoolLock) { + final SingleSizePool singleSizePool = mPool.get(poolKey); + if (singleSizePool != null && + singleSizePool.mNumItems >= singleSizePool.mBitmaps.length) { + return true; + } + return false; + } + } +} diff --git a/src/com/android/messaging/datamodel/BoundCursorLoader.java b/src/com/android/messaging/datamodel/BoundCursorLoader.java new file mode 100644 index 0000000..84d38e6 --- /dev/null +++ b/src/com/android/messaging/datamodel/BoundCursorLoader.java @@ -0,0 +1,46 @@ +/* + * 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; + +import android.content.Context; +import android.content.CursorLoader; +import android.net.Uri; + +/** + * Extension to basic cursor loader that has an attached binding id + */ +public class BoundCursorLoader extends CursorLoader { + private final String mBindingId; + + /** + * Create cursor loader for associated binding id + */ + public BoundCursorLoader(final String bindingId, final Context context, final Uri uri, + final String[] projection, final String selection, final String[] selectionArgs, + final String sortOrder) { + super(context, uri, projection, selection, selectionArgs, sortOrder); + mBindingId = bindingId; + } + + /** + * Binding id associated with this loader - consume can check to verify data still valid + * @return + */ + public String getBindingId() { + return mBindingId; + } +} diff --git a/src/com/android/messaging/datamodel/BugleDatabaseOperations.java b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java new file mode 100644 index 0000000..8c40177 --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java @@ -0,0 +1,1919 @@ +/* + * 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; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.v4.util.ArrayMap; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.ParticipantRefresh.ConversationParticipantsQuery; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UriUtil; +import com.android.messaging.widget.WidgetConversationProvider; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import javax.annotation.Nullable; + + +/** + * This class manages updating our local database + */ +public class BugleDatabaseOperations { + + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + // Global cache of phone numbers -> participant id mapping since this call is expensive. + private static final ArrayMap<String, String> sNormalizedPhoneNumberToParticipantIdCache = + new ArrayMap<String, String>(); + + /** + * Convert list of recipient strings (email/phone number) into list of ConversationParticipants + * + * @param recipients The recipient list + * @param refSubId The subId used to normalize phone numbers in the recipients + */ + static ArrayList<ParticipantData> getConversationParticipantsFromRecipients( + final List<String> recipients, final int refSubId) { + // Generate a list of partially formed participants + final ArrayList<ParticipantData> participants = new + ArrayList<ParticipantData>(); + + if (recipients != null) { + for (final String recipient : recipients) { + participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, refSubId)); + } + } + return participants; + } + + /** + * Sanitize a given list of conversation participants by de-duping and stripping out self + * phone number in group conversation. + */ + @DoesNotRunOnMainThread + public static void sanitizeConversationParticipants(final List<ParticipantData> participants) { + Assert.isNotMainThread(); + if (participants.size() > 0) { + // First remove redundant phone numbers + final HashSet<String> recipients = new HashSet<String>(); + for (int i = participants.size() - 1; i >= 0; i--) { + final String recipient = participants.get(i).getNormalizedDestination(); + if (!recipients.contains(recipient)) { + recipients.add(recipient); + } else { + participants.remove(i); + } + } + if (participants.size() > 1) { + // Remove self phone number from group conversation. + final HashSet<String> selfNumbers = + PhoneUtils.getDefault().getNormalizedSelfNumbers(); + int removed = 0; + // Do this two-pass scan to avoid unnecessary memory allocation. + // Prescan to count the self numbers in the list + for (final ParticipantData p : participants) { + if (selfNumbers.contains(p.getNormalizedDestination())) { + removed++; + } + } + // If all are self numbers, maybe that's what the user wants, just leave + // the participants as is. Otherwise, do another scan to remove self numbers. + if (removed < participants.size()) { + for (int i = participants.size() - 1; i >= 0; i--) { + final String recipient = participants.get(i).getNormalizedDestination(); + if (selfNumbers.contains(recipient)) { + participants.remove(i); + } + } + } + } + } + } + + /** + * Convert list of ConversationParticipants into recipient strings (email/phone number) + */ + @DoesNotRunOnMainThread + public static ArrayList<String> getRecipientsFromConversationParticipants( + final List<ParticipantData> participants) { + Assert.isNotMainThread(); + // First find the thread id for this list of participants. + final ArrayList<String> recipients = new ArrayList<String>(); + + for (final ParticipantData participant : participants) { + recipients.add(participant.getSendDestination()); + } + return recipients; + } + + /** + * Get or create a conversation based on the message's thread id + * + * NOTE: There are phones on which you can't get the recipients from the thread id for SMS + * until you have a message, so use getOrCreateConversationFromRecipient instead. + * + * TODO: Should this be in MMS/SMS code? + * + * @param db the database + * @param threadId The message's thread + * @param senderBlocked Flag whether sender of message is in blocked people list + * @param refSubId The reference subId for canonicalize phone numbers + * @return conversationId + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversationFromThreadId(final DatabaseWrapper db, + final long threadId, final boolean senderBlocked, final int refSubId) { + Assert.isNotMainThread(); + final List<String> recipients = MmsUtils.getRecipientsByThread(threadId); + final ArrayList<ParticipantData> participants = + getConversationParticipantsFromRecipients(recipients, refSubId); + + return getOrCreateConversation(db, threadId, senderBlocked, participants, false, false, + null); + } + + /** + * Get or create a conversation based on provided recipient + * + * @param db the database + * @param threadId The message's thread + * @param senderBlocked Flag whether sender of message is in blocked people list + * @param recipient recipient for thread + * @return conversationId + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversationFromRecipient(final DatabaseWrapper db, + final long threadId, final boolean senderBlocked, final ParticipantData recipient) { + Assert.isNotMainThread(); + final ArrayList<ParticipantData> recipients = new ArrayList<>(1); + recipients.add(recipient); + return getOrCreateConversation(db, threadId, senderBlocked, recipients, false, false, null); + } + + /** + * Get or create a conversation based on provided participants + * + * @param db the database + * @param threadId The message's thread + * @param archived Flag whether the conversation should be created archived + * @param participants list of conversation participants + * @param noNotification If notification should be disabled + * @param noVibrate If vibrate on notification should be disabled + * @param soundUri If there is custom sound URI + * @return a conversation id + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversation(final DatabaseWrapper db, final long threadId, + final boolean archived, final ArrayList<ParticipantData> participants, + boolean noNotification, boolean noVibrate, String soundUri) { + Assert.isNotMainThread(); + + // Check to see if this conversation is already in out local db cache + String conversationId = BugleDatabaseOperations.getExistingConversation(db, threadId, + false); + + if (conversationId == null) { + final String conversationName = ConversationListItemData.generateConversationName( + participants); + + // Create the conversation with the default self participant which always maps to + // the system default subscription. + final ParticipantData self = ParticipantData.getSelfParticipant( + ParticipantData.DEFAULT_SELF_SUB_ID); + + db.beginTransaction(); + try { + // Look up the "self" participantId (creating if necessary) + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + // Create a new conversation + conversationId = BugleDatabaseOperations.createConversationInTransaction( + db, threadId, conversationName, selfId, participants, archived, + noNotification, noVibrate, soundUri); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + return conversationId; + } + + /** + * Get a conversation from the local DB based on the message's thread id. + * + * @param dbWrapper The database + * @param threadId The message's thread in the SMS database + * @param senderBlocked Flag whether sender of message is in blocked people list + * @return The existing conversation id or null + */ + @VisibleForTesting + @DoesNotRunOnMainThread + public static String getExistingConversation(final DatabaseWrapper dbWrapper, + final long threadId, final boolean senderBlocked) { + Assert.isNotMainThread(); + String conversationId = null; + + Cursor cursor = null; + try { + // Look for an existing conversation in the db with this thread id + cursor = dbWrapper.rawQuery("SELECT " + ConversationColumns._ID + + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns.SMS_THREAD_ID + "=" + threadId, + null); + + if (cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + conversationId = cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return conversationId; + } + + /** + * Get the thread id for an existing conversation from the local DB. + * + * @param dbWrapper The database + * @param conversationId The conversation to look up thread for + * @return The thread id. Returns -1 if the conversation was not found or if it was found + * but the thread column was NULL. + */ + @DoesNotRunOnMainThread + public static long getThreadId(final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + long threadId = -1; + + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.SMS_THREAD_ID }, + ConversationColumns._ID + " =?", + new String[] { conversationId }, + null, null, null); + + if (cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + if (!cursor.isNull(0)) { + threadId = cursor.getLong(0); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return threadId; + } + + @DoesNotRunOnMainThread + public static boolean isBlockedDestination(final DatabaseWrapper db, final String destination) { + Assert.isNotMainThread(); + return isBlockedParticipant(db, destination, ParticipantColumns.NORMALIZED_DESTINATION); + } + + static boolean isBlockedParticipant(final DatabaseWrapper db, final String participantId) { + return isBlockedParticipant(db, participantId, ParticipantColumns._ID); + } + + static boolean isBlockedParticipant(final DatabaseWrapper db, final String value, + final String column) { + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns.BLOCKED }, + column + "=? AND " + ParticipantColumns.SUB_ID + "=?", + new String[] { value, + Integer.toString(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null, null, null); + + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getInt(0) == 1; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return false; // if there's no row, it's not blocked :-) + } + + /** + * Create a conversation in the local DB based on the message's thread id. + * + * It's up to the caller to make sure that this is all inside a transaction. It will return + * null if it's not in the local DB. + * + * @param dbWrapper The database + * @param threadId The message's thread + * @param selfId The selfId to make default for this conversation + * @param archived Flag whether the conversation should be created archived + * @param noNotification If notification should be disabled + * @param noVibrate If vibrate on notification should be disabled + * @param soundUri The customized sound + * @return The existing conversation id or new conversation id + */ + static String createConversationInTransaction(final DatabaseWrapper dbWrapper, + final long threadId, final String conversationName, final String selfId, + final List<ParticipantData> participants, final boolean archived, + boolean noNotification, boolean noVibrate, String soundUri) { + // We want conversation and participant creation to be atomic + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + boolean hasEmailAddress = false; + for (final ParticipantData participant : participants) { + Assert.isTrue(!participant.isSelf()); + if (participant.isEmail()) { + hasEmailAddress = true; + } + } + + // TODO : Conversations state - normal vs. archived + + // Insert a new local conversation for this thread id + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.SMS_THREAD_ID, threadId); + // Start with conversation hidden - sending a message or saving a draft will change that + values.put(ConversationColumns.SORT_TIMESTAMP, 0L); + values.put(ConversationColumns.CURRENT_SELF_ID, selfId); + values.put(ConversationColumns.PARTICIPANT_COUNT, participants.size()); + values.put(ConversationColumns.INCLUDE_EMAIL_ADDRESS, (hasEmailAddress ? 1 : 0)); + if (archived) { + values.put(ConversationColumns.ARCHIVE_STATUS, 1); + } + if (noNotification) { + values.put(ConversationColumns.NOTIFICATION_ENABLED, 0); + } + if (noVibrate) { + values.put(ConversationColumns.NOTIFICATION_VIBRATION, 0); + } + if (!TextUtils.isEmpty(soundUri)) { + values.put(ConversationColumns.NOTIFICATION_SOUND_URI, soundUri); + } + + fillParticipantData(values, participants); + + final long conversationRowId = dbWrapper.insert(DatabaseHelper.CONVERSATIONS_TABLE, null, + values); + + Assert.isTrue(conversationRowId != -1); + if (conversationRowId == -1) { + LogUtil.e(TAG, "BugleDatabaseOperations : failed to insert conversation into table"); + return null; + } + + final String conversationId = Long.toString(conversationRowId); + + // Make sure that participants are added for this conversation + for (final ParticipantData participant : participants) { + // TODO: Use blocking information + addParticipantToConversation(dbWrapper, participant, conversationId); + } + + // Now fully resolved participants available can update conversation name / avatar. + // b/16437575: We cannot use the participants directly, but instead have to call + // getParticipantsForConversation() to retrieve the actual participants. This is needed + // because the call to addParticipantToConversation() won't fill up the ParticipantData + // if the participant already exists in the participant table. For example, say you have + // an existing conversation with John. Now if you create a new group conversation with + // Jeff & John with only their phone numbers, then when we try to add John's number to the + // group conversation, we see that he's already in the participant table, therefore we + // short-circuit any steps to actually fill out the ParticipantData for John other than + // just returning his participant id. Eventually, the ParticipantData we have is still the + // raw data with just the phone number. getParticipantsForConversation(), on the other + // hand, will fill out all the info for each participant from the participants table. + updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, + getParticipantsForConversation(dbWrapper, conversationId)); + + return conversationId; + } + + private static void fillParticipantData(final ContentValues values, + final List<ParticipantData> participants) { + if (participants != null && !participants.isEmpty()) { + final Uri avatarUri = AvatarUriUtil.createAvatarUri(participants); + values.put(ConversationColumns.ICON, avatarUri.toString()); + + long contactId; + String lookupKey; + String destination; + if (participants.size() == 1) { + final ParticipantData firstParticipant = participants.get(0); + contactId = firstParticipant.getContactId(); + lookupKey = firstParticipant.getLookupKey(); + destination = firstParticipant.getNormalizedDestination(); + } else { + contactId = 0; + lookupKey = null; + destination = null; + } + + values.put(ConversationColumns.PARTICIPANT_CONTACT_ID, contactId); + values.put(ConversationColumns.PARTICIPANT_LOOKUP_KEY, lookupKey); + values.put(ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, destination); + } + } + + /** + * Delete conversation and associated messages/parts + */ + @DoesNotRunOnMainThread + public static boolean deleteConversation(final DatabaseWrapper dbWrapper, + final String conversationId, final long cutoffTimestamp) { + Assert.isNotMainThread(); + dbWrapper.beginTransaction(); + boolean conversationDeleted = false; + boolean conversationMessagesDeleted = false; + try { + // Delete existing messages + if (cutoffTimestamp == Long.MAX_VALUE) { + // Delete parts and messages + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); + conversationMessagesDeleted = true; + } else { + // Delete all messages prior to the cutoff + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.RECEIVED_TIMESTAMP + "<=?", + new String[] { conversationId, Long.toString(cutoffTimestamp) }); + + // Delete any draft message. The delete above may not always include the draft, + // because under certain scenarios (e.g. sending messages in progress), the draft + // timestamp can be larger than the cutoff time, which is generally the conversation + // sort timestamp. Because of how the sms/mms provider works on some newer + // devices, it's important that we never delete all the messages in a conversation + // without also deleting the conversation itself (see b/20262204 for details). + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }); + + // Check to see if there are any messages left in the conversation + final long count = dbWrapper.queryNumEntries(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); + conversationMessagesDeleted = (count == 0); + + // Log detail information if there are still messages left in the conversation + if (!conversationMessagesDeleted) { + final long maxTimestamp = + getConversationMaxTimestamp(dbWrapper, conversationId); + LogUtil.w(TAG, "BugleDatabaseOperations:" + + " cannot delete all messages in a conversation" + + ", after deletion: count=" + count + + ", max timestamp=" + maxTimestamp + + ", cutoff timestamp=" + cutoffTimestamp); + } + } + + if (conversationMessagesDeleted) { + // Delete conversation row + final int count = dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID + "=?", new String[] { conversationId }); + conversationDeleted = (count > 0); + } + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + } + return conversationDeleted; + } + + private static final String MAX_RECEIVED_TIMESTAMP = + "MAX(" + MessageColumns.RECEIVED_TIMESTAMP + ")"; + /** + * Get the max received timestamp of a conversation's messages + */ + private static long getConversationMaxTimestamp(final DatabaseWrapper dbWrapper, + final String conversationId) { + final Cursor cursor = dbWrapper.query( + DatabaseHelper.MESSAGES_TABLE, + new String[]{ MAX_RECEIVED_TIMESTAMP }, + MessageColumns.CONVERSATION_ID + "=?", + new String[]{ conversationId }, + null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + return cursor.getLong(0); + } + } finally { + cursor.close(); + } + } + return 0; + } + + @DoesNotRunOnMainThread + public static void updateConversationMetadataInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final String messageId, final long latestTimestamp, + final boolean keepArchived, final String smsServiceCenter, + final boolean shouldAutoSwitchSelfId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.LATEST_MESSAGE_ID, messageId); + values.put(ConversationColumns.SORT_TIMESTAMP, latestTimestamp); + if (!TextUtils.isEmpty(smsServiceCenter)) { + values.put(ConversationColumns.SMS_SERVICE_CENTER, smsServiceCenter); + } + + // When the conversation gets updated with new messages, unarchive the conversation unless + // the sender is blocked, or we have been told to keep it archived. + if (!keepArchived) { + values.put(ConversationColumns.ARCHIVE_STATUS, 0); + } + + final MessageData message = readMessage(dbWrapper, messageId); + addSnippetTextAndPreviewToContentValues(message, false /* showDraft */, values); + + if (shouldAutoSwitchSelfId) { + addSelfIdAutoSwitchInfoToContentValues(dbWrapper, message, conversationId, values); + } + + // Conversation always exists as this method is called from ActionService only after + // reading and if necessary creating the conversation. + updateConversationRow(dbWrapper, conversationId, values); + + if (shouldAutoSwitchSelfId && OsUtil.isAtLeastL_MR1()) { + // Normally, the draft message compose UI trusts its UI state for providing up-to-date + // conversation self id. Therefore, notify UI through local broadcast receiver about + // this external change so the change can be properly reflected. + UIIntents.get().broadcastConversationSelfIdChange(dbWrapper.getContext(), + conversationId, getConversationSelfId(dbWrapper, conversationId)); + } + } + + @DoesNotRunOnMainThread + public static void updateConversationMetadataInTransaction(final DatabaseWrapper db, + final String conversationId, final String messageId, final long latestTimestamp, + final boolean keepArchived, final boolean shouldAutoSwitchSelfId) { + Assert.isNotMainThread(); + updateConversationMetadataInTransaction( + db, conversationId, messageId, latestTimestamp, keepArchived, null, + shouldAutoSwitchSelfId); + } + + @DoesNotRunOnMainThread + public static void updateConversationArchiveStatusInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final boolean isArchived) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.ARCHIVE_STATUS, isArchived ? 1 : 0); + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + + static void addSnippetTextAndPreviewToContentValues(final MessageData message, + final boolean showDraft, final ContentValues values) { + values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0); + values.put(ConversationColumns.SNIPPET_TEXT, message.getMessageText()); + values.put(ConversationColumns.SUBJECT_TEXT, message.getMmsSubject()); + + String type = null; + String uriString = null; + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment() && + ContentType.isConversationListPreviewableType(part.getContentType())) { + uriString = part.getContentUri().toString(); + type = part.getContentType(); + break; + } + } + values.put(ConversationColumns.PREVIEW_CONTENT_TYPE, type); + values.put(ConversationColumns.PREVIEW_URI, uriString); + } + + /** + * Adds self-id auto switch info for a conversation if the last message has a different + * subscription than the conversation's. + * @return true if self id will need to be changed, false otherwise. + */ + static boolean addSelfIdAutoSwitchInfoToContentValues(final DatabaseWrapper dbWrapper, + final MessageData message, final String conversationId, final ContentValues values) { + // Only auto switch conversation self for incoming messages. + if (!OsUtil.isAtLeastL_MR1() || !message.getIsIncoming()) { + return false; + } + + final String conversationSelfId = getConversationSelfId(dbWrapper, conversationId); + final String messageSelfId = message.getSelfId(); + + if (conversationSelfId == null || messageSelfId == null) { + return false; + } + + // Get the sub IDs in effect for both the message and the conversation and compare them: + // 1. If message is unbound (using default sub id), then the message was sent with + // pre-MSIM support. Don't auto-switch because we don't know the subscription for the + // message. + // 2. If message is bound, + // i. If conversation is unbound, use the system default sub id as its effective sub. + // ii. If conversation is bound, use its subscription directly. + // Compare the message sub id with the conversation's effective sub id. If they are + // different, auto-switch the conversation to the message's sub. + final ParticipantData conversationSelf = getExistingParticipant(dbWrapper, + conversationSelfId); + final ParticipantData messageSelf = getExistingParticipant(dbWrapper, messageSelfId); + if (!messageSelf.isActiveSubscription()) { + // Don't switch if the message subscription is no longer active. + return false; + } + final int messageSubId = messageSelf.getSubId(); + if (messageSubId == ParticipantData.DEFAULT_SELF_SUB_ID) { + return false; + } + + final int conversationEffectiveSubId = + PhoneUtils.getDefault().getEffectiveSubId(conversationSelf.getSubId()); + + if (conversationEffectiveSubId != messageSubId) { + return addConversationSelfIdToContentValues(dbWrapper, messageSelf.getId(), values); + } + return false; + } + + /** + * Adds conversation self id updates to ContentValues given. This performs check on the selfId + * to ensure it's valid and active. + * @return true if self id will need to be changed, false otherwise. + */ + static boolean addConversationSelfIdToContentValues(final DatabaseWrapper dbWrapper, + final String selfId, final ContentValues values) { + // Make sure the selfId passed in is valid and active. + final String selection = ParticipantColumns._ID + "=? AND " + + ParticipantColumns.SIM_SLOT_ID + "<>?"; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns._ID }, selection, + new String[] { selfId, String.valueOf(ParticipantData.INVALID_SLOT_ID) }, + null, null, null); + + if (cursor != null && cursor.getCount() > 0) { + values.put(ConversationColumns.CURRENT_SELF_ID, selfId); + return true; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return false; + } + + private static void updateConversationDraftSnippetAndPreviewInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final MessageData draftMessage) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + long sortTimestamp = 0L; + Cursor cursor = null; + try { + // Check to find the latest message in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=?", + new String[]{conversationId}, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + + if (cursor.moveToFirst()) { + sortTimestamp = cursor.getLong(1); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + + final ContentValues values = new ContentValues(); + if (draftMessage == null || !draftMessage.hasContent()) { + values.put(ConversationColumns.SHOW_DRAFT, 0); + values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, ""); + values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, ""); + values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, ""); + values.put(ConversationColumns.DRAFT_PREVIEW_URI, ""); + } else { + sortTimestamp = Math.max(sortTimestamp, draftMessage.getReceivedTimeStamp()); + values.put(ConversationColumns.SHOW_DRAFT, 1); + values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, draftMessage.getMessageText()); + values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, draftMessage.getMmsSubject()); + String type = null; + String uriString = null; + for (final MessagePartData part : draftMessage.getParts()) { + if (part.isAttachment() && + ContentType.isConversationListPreviewableType(part.getContentType())) { + uriString = part.getContentUri().toString(); + type = part.getContentType(); + break; + } + } + values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, type); + values.put(ConversationColumns.DRAFT_PREVIEW_URI, uriString); + } + values.put(ConversationColumns.SORT_TIMESTAMP, sortTimestamp); + // Called in transaction after reading conversation row + updateConversationRow(dbWrapper, conversationId, values); + } + + @DoesNotRunOnMainThread + public static boolean updateConversationRowIfExists(final DatabaseWrapper dbWrapper, + final String conversationId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID, conversationId, values); + } + + @DoesNotRunOnMainThread + public static void updateConversationRow(final DatabaseWrapper dbWrapper, + final String conversationId, final ContentValues values) { + Assert.isNotMainThread(); + final boolean exists = updateConversationRowIfExists(dbWrapper, conversationId, values); + Assert.isTrue(exists); + } + + @DoesNotRunOnMainThread + public static boolean updateMessageRowIfExists(final DatabaseWrapper dbWrapper, + final String messageId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID, + messageId, values); + } + + @DoesNotRunOnMainThread + public static void updateMessageRow(final DatabaseWrapper dbWrapper, + final String messageId, final ContentValues values) { + Assert.isNotMainThread(); + final boolean exists = updateMessageRowIfExists(dbWrapper, messageId, values); + Assert.isTrue(exists); + } + + @DoesNotRunOnMainThread + public static boolean updatePartRowIfExists(final DatabaseWrapper dbWrapper, + final String partId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.PARTS_TABLE, PartColumns._ID, + partId, values); + } + + /** + * Returns the default conversation name based on its participants. + */ + private static String getDefaultConversationName(final List<ParticipantData> participants) { + return ConversationListItemData.generateConversationName(participants); + } + + /** + * Updates a given conversation's name based on its participants. + */ + @DoesNotRunOnMainThread + public static void updateConversationNameAndAvatarInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ArrayList<ParticipantData> participants = + getParticipantsForConversation(dbWrapper, conversationId); + updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, participants); + } + + /** + * Updates a given conversation's name based on its participants. + */ + private static void updateConversationNameAndAvatarInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final List<ParticipantData> participants) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.NAME, + getDefaultConversationName(participants)); + + fillParticipantData(values, participants); + + // Used by background thread when refreshing conversation so conversation could be deleted. + updateConversationRowIfExists(dbWrapper, conversationId, values); + + WidgetConversationProvider.notifyConversationRenamed(Factory.get().getApplicationContext(), + conversationId); + } + + /** + * Updates a given conversation's self id. + */ + @DoesNotRunOnMainThread + public static void updateConversationSelfIdInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, final String selfId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + if (addConversationSelfIdToContentValues(dbWrapper, selfId, values)) { + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + } + + @DoesNotRunOnMainThread + public static String getConversationSelfId(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.CURRENT_SELF_ID }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + /** + * Frees up memory associated with phone number to participant id matching. + */ + @DoesNotRunOnMainThread + public static void clearParticipantIdCache() { + Assert.isNotMainThread(); + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + sNormalizedPhoneNumberToParticipantIdCache.clear(); + } + } + + @DoesNotRunOnMainThread + public static ArrayList<String> getRecipientsForConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + final ArrayList<ParticipantData> participants = + getParticipantsForConversation(dbWrapper, conversationId); + + final ArrayList<String> recipients = new ArrayList<String>(); + for (final ParticipantData participant : participants) { + recipients.add(participant.getSendDestination()); + } + + return recipients; + } + + @DoesNotRunOnMainThread + public static String getSmsServiceCenterForConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.SMS_SERVICE_CENTER }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + @DoesNotRunOnMainThread + public static ParticipantData getExistingParticipant(final DatabaseWrapper dbWrapper, + final String participantId) { + Assert.isNotMainThread(); + ParticipantData participant = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " =?", + new String[] { participantId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + participant = ParticipantData.getFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return participant; + } + + static int getSelfSubscriptionId(final DatabaseWrapper dbWrapper, + final String selfParticipantId) { + final ParticipantData selfParticipant = BugleDatabaseOperations.getExistingParticipant( + dbWrapper, selfParticipantId); + if (selfParticipant != null) { + Assert.isTrue(selfParticipant.isSelf()); + return selfParticipant.getSubId(); + } + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @VisibleForTesting + @DoesNotRunOnMainThread + public static ArrayList<ParticipantData> getParticipantsForConversation( + final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + final ArrayList<ParticipantData> participants = + new ArrayList<ParticipantData>(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " IN ( " + "SELECT " + + ConversationParticipantsColumns.PARTICIPANT_ID + " AS " + + ParticipantColumns._ID + + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE + + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID + " =? )", + new String[] { conversationId }, null, null, null); + + while (cursor.moveToNext()) { + participants.add(ParticipantData.getFromCursor(cursor)); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return participants; + } + + @DoesNotRunOnMainThread + public static MessageData readMessage(final DatabaseWrapper dbWrapper, final String messageId) { + Assert.isNotMainThread(); + final MessageData message = readMessageData(dbWrapper, messageId); + if (message != null) { + readMessagePartsData(dbWrapper, message, false); + } + return message; + } + + @VisibleForTesting + static MessagePartData readMessagePartData(final DatabaseWrapper dbWrapper, + final String partId) { + MessagePartData messagePartData = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE, + MessagePartData.getProjection(), PartColumns._ID + "=?", + new String[] { partId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + messagePartData = MessagePartData.createFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return messagePartData; + } + + @DoesNotRunOnMainThread + public static MessageData readMessageData(final DatabaseWrapper dbWrapper, + final Uri smsMessageUri) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), MessageColumns.SMS_MESSAGE_URI + "=?", + new String[] { smsMessageUri.toString() }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + @DoesNotRunOnMainThread + public static MessageData readMessageData(final DatabaseWrapper dbWrapper, + final String messageId) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), MessageColumns._ID + "=?", + new String[] { messageId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + /** + * Read all the parts for a message + * @param dbWrapper database + * @param message read parts for this message + * @param checkAttachmentFilesExist check each attachment file and only include if file exists + */ + private static void readMessagePartsData(final DatabaseWrapper dbWrapper, + final MessageData message, final boolean checkAttachmentFilesExist) { + final ContentResolver contentResolver = + Factory.get().getApplicationContext().getContentResolver(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE, + MessagePartData.getProjection(), PartColumns.MESSAGE_ID + "=?", + new String[] { message.getMessageId() }, null, null, null); + while (cursor.moveToNext()) { + final MessagePartData messagePartData = MessagePartData.createFromCursor(cursor); + if (checkAttachmentFilesExist && messagePartData.isAttachment() && + !UriUtil.isBugleAppResource(messagePartData.getContentUri())) { + try { + // Test that the file exists before adding the attachment to the draft + final ParcelFileDescriptor fileDescriptor = + contentResolver.openFileDescriptor( + messagePartData.getContentUri(), "r"); + if (fileDescriptor != null) { + fileDescriptor.close(); + message.addPart(messagePartData); + } + } catch (final IOException e) { + // The attachment's temp storage no longer exists, just ignore the file + } catch (final SecurityException e) { + // Likely thrown by openFileDescriptor due to an expired access grant. + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "uri: " + messagePartData.getContentUri()); + } + } + } else { + message.addPart(messagePartData); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Write a message part to our local database + * + * @param dbWrapper The database + * @param messagePart The message part to insert + * @return The row id of the newly inserted part + */ + static String insertNewMessagePartInTransaction(final DatabaseWrapper dbWrapper, + final MessagePartData messagePart, final String conversationId) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Assert.isTrue(!TextUtils.isEmpty(messagePart.getMessageId())); + + // Insert a new part row + final SQLiteStatement insert = messagePart.getInsertStatement(dbWrapper, conversationId); + final long rowNumber = insert.executeInsert(); + + Assert.inRange(rowNumber, 0, Long.MAX_VALUE); + final String partId = Long.toString(rowNumber); + + // Update the part id + messagePart.updatePartId(partId); + + return partId; + } + + /** + * Insert a message and its parts into the table + */ + @DoesNotRunOnMainThread + public static void insertNewMessageInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + // Insert message row + final SQLiteStatement insert = message.getInsertStatement(dbWrapper); + final long rowNumber = insert.executeInsert(); + + Assert.inRange(rowNumber, 0, Long.MAX_VALUE); + final String messageId = Long.toString(rowNumber); + message.updateMessageId(messageId); + // Insert new parts + for (final MessagePartData messagePart : message.getParts()) { + messagePart.updateMessageId(messageId); + insertNewMessagePartInTransaction(dbWrapper, messagePart, message.getConversationId()); + } + } + + /** + * Update a message and add its parts into the table + */ + @DoesNotRunOnMainThread + public static void updateMessageInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final String messageId = message.getMessageId(); + // Check message still exists (sms sync or delete might have purged it) + final MessageData current = BugleDatabaseOperations.readMessage(dbWrapper, messageId); + if (current != null) { + // Delete existing message parts) + deletePartsForMessage(dbWrapper, message.getMessageId()); + // Insert new parts + for (final MessagePartData messagePart : message.getParts()) { + messagePart.updatePartId(null); + messagePart.updateMessageId(message.getMessageId()); + insertNewMessagePartInTransaction(dbWrapper, messagePart, + message.getConversationId()); + } + // Update message row + final ContentValues values = new ContentValues(); + message.populate(values); + updateMessageRowIfExists(dbWrapper, message.getMessageId(), values); + } + } + + @DoesNotRunOnMainThread + public static void updateMessageAndPartsInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message, final List<MessagePartData> partsToUpdate) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + for (final MessagePartData messagePart : partsToUpdate) { + values.clear(); + messagePart.populate(values); + updatePartRowIfExists(dbWrapper, messagePart.getPartId(), values); + } + values.clear(); + message.populate(values); + updateMessageRowIfExists(dbWrapper, message.getMessageId(), values); + } + + /** + * Delete all parts for a message + */ + static void deletePartsForMessage(final DatabaseWrapper dbWrapper, + final String messageId) { + final int cnt = dbWrapper.delete(DatabaseHelper.PARTS_TABLE, + PartColumns.MESSAGE_ID + " =?", + new String[] { messageId }); + Assert.inRange(cnt, 0, Integer.MAX_VALUE); + } + + /** + * Delete one message and update the conversation (if necessary). + * + * @return number of rows deleted (should be 1 or 0). + */ + @DoesNotRunOnMainThread + public static int deleteMessage(final DatabaseWrapper dbWrapper, final String messageId) { + Assert.isNotMainThread(); + dbWrapper.beginTransaction(); + try { + // Read message to find out which conversation it is in + final MessageData message = BugleDatabaseOperations.readMessage(dbWrapper, messageId); + + int count = 0; + if (message != null) { + final String conversationId = message.getConversationId(); + // Delete message + count = dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns._ID + "=?", new String[] { messageId }); + + if (!deleteConversationIfEmptyInTransaction(dbWrapper, conversationId)) { + // TODO: Should we leave the conversation sort timestamp alone? + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + false/* shouldAutoSwitchSelfId */, false/*archived*/); + } + } + dbWrapper.setTransactionSuccessful(); + return count; + } finally { + dbWrapper.endTransaction(); + } + } + + /** + * Deletes the conversation if there are zero non-draft messages left. + * <p> + * This is necessary because the telephony database has a trigger that deletes threads after + * their last message is deleted. We need to ensure that if a thread goes away, we also delete + * the conversation in Bugle. We don't store draft messages in telephony, so we ignore those + * when querying for the # of messages in the conversation. + * + * @return true if the conversation was deleted + */ + @DoesNotRunOnMainThread + public static boolean deleteConversationIfEmptyInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Cursor cursor = null; + try { + // TODO: The refreshConversationMetadataInTransaction method below uses this + // same query; maybe they should share this logic? + + // Check to see if there are any (non-draft) messages in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + new String[] { conversationId }, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + if (cursor.getCount() == 0) { + dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID + "=?", new String[] { conversationId }); + LogUtil.i(TAG, + "BugleDatabaseOperations: Deleted empty conversation " + conversationId); + return true; + } else { + return false; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private static final String[] REFRESH_CONVERSATION_MESSAGE_PROJECTION = new String[] { + MessageColumns._ID, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SENDER_PARTICIPANT_ID + }; + + /** + * Update conversation snippet, timestamp and optionally self id to match latest message in + * conversation. + */ + @DoesNotRunOnMainThread + public static void refreshConversationMetadataInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final boolean shouldAutoSwitchSelfId, + boolean keepArchived) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Cursor cursor = null; + try { + // Check to see if there are any (non-draft) messages in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + new String[] { conversationId }, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + + if (cursor.moveToFirst()) { + // Refresh latest message in conversation + final String latestMessageId = cursor.getString(0); + final long latestMessageTimestamp = cursor.getLong(1); + final String senderParticipantId = cursor.getString(2); + final boolean senderBlocked = isBlockedParticipant(dbWrapper, senderParticipantId); + updateConversationMetadataInTransaction(dbWrapper, conversationId, + latestMessageId, latestMessageTimestamp, senderBlocked || keepArchived, + shouldAutoSwitchSelfId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * When moving/removing an existing message update conversation metadata if necessary + * @param dbWrapper db wrapper + * @param conversationId conversation to modify + * @param messageId message that is leaving the conversation + * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a + * result of this call when we see a new latest message? + * @param keepArchived should we keep the conversation archived despite refresh + */ + @DoesNotRunOnMainThread + public static void maybeRefreshConversationMetadataInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, final String messageId, + final boolean shouldAutoSwitchSelfId, final boolean keepArchived) { + Assert.isNotMainThread(); + boolean refresh = true; + if (!TextUtils.isEmpty(messageId)) { + refresh = false; + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.LATEST_MESSAGE_ID }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + refresh = TextUtils.equals(cursor.getString(0), messageId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + if (refresh) { + // TODO: I think it is okay to delete the conversation if it is empty... + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + shouldAutoSwitchSelfId, keepArchived); + } + } + + + + // SQL statement to query latest message if for particular conversation + private static final String QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL = "SELECT " + + ConversationColumns.LATEST_MESSAGE_ID + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns._ID + "=? LIMIT 1"; + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + @DoesNotRunOnMainThread + public static SQLiteStatement getQueryConversationsLatestMessageStatement( + final DatabaseWrapper db, final String conversationId) { + Assert.isNotMainThread(); + final SQLiteStatement query = db.getStatementInTransaction( + DatabaseWrapper.INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE, + QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL); + query.clearBindings(); + query.bindString(1, conversationId); + return query; + } + + // SQL statement to query latest message if for particular conversation + private static final String QUERY_MESSAGES_LATEST_MESSAGE_SQL = "SELECT " + + MessageColumns._ID + " FROM " + DatabaseHelper.MESSAGES_TABLE + + " WHERE " + MessageColumns.CONVERSATION_ID + "=? ORDER BY " + + MessageColumns.RECEIVED_TIMESTAMP + " DESC LIMIT 1"; + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + @DoesNotRunOnMainThread + public static SQLiteStatement getQueryMessagesLatestMessageStatement( + final DatabaseWrapper db, final String conversationId) { + Assert.isNotMainThread(); + final SQLiteStatement query = db.getStatementInTransaction( + DatabaseWrapper.INDEX_QUERY_MESSAGES_LATEST_MESSAGE, + QUERY_MESSAGES_LATEST_MESSAGE_SQL); + query.clearBindings(); + query.bindString(1, conversationId); + return query; + } + + /** + * Update conversation metadata if necessary + * @param dbWrapper db wrapper + * @param conversationId conversation to modify + * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a + * result of this call when we see a new latest message? + * @param keepArchived if the conversation should be kept archived + */ + @DoesNotRunOnMainThread + public static void maybeRefreshConversationMetadataInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final boolean shouldAutoSwitchSelfId, boolean keepArchived) { + Assert.isNotMainThread(); + String currentLatestMessageId = null; + String latestMessageId = null; + try { + final SQLiteStatement currentLatestMessageIdSql = + getQueryConversationsLatestMessageStatement(dbWrapper, conversationId); + currentLatestMessageId = currentLatestMessageIdSql.simpleQueryForString(); + + final SQLiteStatement latestMessageIdSql = + getQueryMessagesLatestMessageStatement(dbWrapper, conversationId); + latestMessageId = latestMessageIdSql.simpleQueryForString(); + } catch (final SQLiteDoneException e) { + LogUtil.e(TAG, "BugleDatabaseOperations: Query for latest message failed", e); + } + + if (TextUtils.isEmpty(currentLatestMessageId) || + !TextUtils.equals(currentLatestMessageId, latestMessageId)) { + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + shouldAutoSwitchSelfId, keepArchived); + } + } + + static boolean getConversationExists(final DatabaseWrapper dbWrapper, + final String conversationId) { + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { /* No projection */}, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + return cursor.getCount() == 1; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** Preserve parts in message but clear the stored draft */ + public static final int UPDATE_MODE_CLEAR_DRAFT = 1; + /** Add the message as a draft */ + public static final int UPDATE_MODE_ADD_DRAFT = 2; + + /** + * Update draft message for specified conversation + * @param dbWrapper local database (wrapped) + * @param conversationId conversation to update + * @param message Optional message to preserve attachments for (either as draft or for + * sending) + * @param updateMode either {@link #UPDATE_MODE_CLEAR_DRAFT} or + * {@link #UPDATE_MODE_ADD_DRAFT} + * @return message id of newly written draft (else null) + */ + @DoesNotRunOnMainThread + public static String updateDraftMessageData(final DatabaseWrapper dbWrapper, + final String conversationId, @Nullable final MessageData message, + final int updateMode) { + Assert.isNotMainThread(); + Assert.notNull(conversationId); + Assert.inRange(updateMode, UPDATE_MODE_CLEAR_DRAFT, UPDATE_MODE_ADD_DRAFT); + String messageId = null; + Cursor cursor = null; + dbWrapper.beginTransaction(); + try { + // Find all draft parts for the current conversation + final SimpleArrayMap<Uri, MessagePartData> currentDraftParts = new SimpleArrayMap<>(); + cursor = dbWrapper.query(DatabaseHelper.DRAFT_PARTS_VIEW, + MessagePartData.getProjection(), + MessageColumns.CONVERSATION_ID + " =?", + new String[] { conversationId }, null, null, null); + while (cursor.moveToNext()) { + final MessagePartData part = MessagePartData.createFromCursor(cursor); + if (part.isAttachment()) { + currentDraftParts.put(part.getContentUri(), part); + } + } + // Optionally, preserve attachments for "message" + final boolean conversationExists = getConversationExists(dbWrapper, conversationId); + if (message != null && conversationExists) { + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment()) { + currentDraftParts.remove(part.getContentUri()); + } + } + } + + // Delete orphan content + for (int index = 0; index < currentDraftParts.size(); index++) { + final MessagePartData part = currentDraftParts.valueAt(index); + part.destroySync(); + } + + // Delete existing draft (cascade deletes parts) + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }); + + // Write new draft + if (updateMode == UPDATE_MODE_ADD_DRAFT && message != null + && message.hasContent() && conversationExists) { + Assert.equals(MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + message.getStatus()); + + // Now add draft to message table + insertNewMessageInTransaction(dbWrapper, message); + messageId = message.getMessageId(); + } + + if (conversationExists) { + updateConversationDraftSnippetAndPreviewInTransaction( + dbWrapper, conversationId, message); + + if (message != null && message.getSelfId() != null) { + updateConversationSelfIdInTransaction(dbWrapper, conversationId, + message.getSelfId()); + } + } + + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, + "Updated draft message " + messageId + " for conversation " + conversationId); + } + return messageId; + } + + /** + * Read the first draft message associated with this conversation. + * If none present create an empty (sms) draft message. + */ + @DoesNotRunOnMainThread + public static MessageData readDraftMessageData(final DatabaseWrapper dbWrapper, + final String conversationId, final String conversationSelfId) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + dbWrapper.beginTransaction(); + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bindDraft(cursor, conversationSelfId); + readMessagePartsData(dbWrapper, message, true); + // Disconnect draft parts from DB + for (final MessagePartData part : message.getParts()) { + part.updatePartId(null); + part.updateMessageId(null); + } + message.updateMessageId(null); + } + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + // Internal + private static void addParticipantToConversation(final DatabaseWrapper dbWrapper, + final ParticipantData participant, final String conversationId) { + final String participantId = getOrCreateParticipantInTransaction(dbWrapper, participant); + Assert.notNull(participantId); + + // Add the participant to the conversation participants table + final ContentValues values = new ContentValues(); + values.put(ConversationParticipantsColumns.CONVERSATION_ID, conversationId); + values.put(ConversationParticipantsColumns.PARTICIPANT_ID, participantId); + dbWrapper.insert(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, null, values); + } + + /** + * Get string used as canonical recipient for participant cache for sub id + */ + private static String getCanonicalRecipientFromSubId(final int subId) { + return "SELF(" + subId + ")"; + } + + /** + * Maps from a sub id or phone number to a participant id if there is one. + * + * @return If the participant is available in our cache, or the DB, this returns the + * participant id for the given subid/phone number. Otherwise it returns null. + */ + @VisibleForTesting + private static String getParticipantId(final DatabaseWrapper dbWrapper, + final int subId, final String canonicalRecipient) { + // First check our memory cache for the participant Id + String participantId; + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + participantId = sNormalizedPhoneNumberToParticipantIdCache.get(canonicalRecipient); + } + + if (participantId != null) { + return participantId; + } + + // This code will only be executed for incremental additions. + Cursor cursor = null; + try { + if (subId != ParticipantData.OTHER_THAN_SELF_SUB_ID) { + // Now look for an existing participant in the db with this sub id. + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] {ParticipantColumns._ID}, + ParticipantColumns.SUB_ID + "=?", + new String[] { Integer.toString(subId) }, null, null, null); + } else { + // Look for existing participant with this normalized phone number and no subId. + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] {ParticipantColumns._ID}, + ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " + + ParticipantColumns.SUB_ID + "=?", + new String[] {canonicalRecipient, Integer.toString(subId)}, + null, null, null); + } + + if (cursor.moveToFirst()) { + // TODO Is this assert correct for multi-sim where a new sim was put in? + Assert.isTrue(cursor.getCount() == 1); + + // We found an existing participant in the database + participantId = cursor.getString(0); + + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + // Add it to the cache for next time + sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, + participantId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return participantId; + } + + @DoesNotRunOnMainThread + public static ParticipantData getOrCreateSelf(final DatabaseWrapper dbWrapper, + final int subId) { + Assert.isNotMainThread(); + ParticipantData participant = null; + dbWrapper.beginTransaction(); + try { + final ParticipantData shell = ParticipantData.getSelfParticipant(subId); + final String participantId = getOrCreateParticipantInTransaction(dbWrapper, shell); + participant = getExistingParticipant(dbWrapper, participantId); + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + } + return participant; + } + + /** + * Lookup and if necessary create a new participant + * @param dbWrapper Database wrapper + * @param participant Participant to find/create + * @return participantId ParticipantId for existing or newly created participant + */ + @DoesNotRunOnMainThread + public static String getOrCreateParticipantInTransaction(final DatabaseWrapper dbWrapper, + final ParticipantData participant) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + int subId = ParticipantData.OTHER_THAN_SELF_SUB_ID; + String participantId = null; + String canonicalRecipient = null; + if (participant.isSelf()) { + subId = participant.getSubId(); + canonicalRecipient = getCanonicalRecipientFromSubId(subId); + } else { + canonicalRecipient = participant.getNormalizedDestination(); + } + Assert.notNull(canonicalRecipient); + participantId = getParticipantId(dbWrapper, subId, canonicalRecipient); + + if (participantId != null) { + return participantId; + } + + if (!participant.isContactIdResolved()) { + // Refresh participant's name and avatar with matching contact in CP2. + ParticipantRefresh.refreshParticipant(dbWrapper, participant); + } + + // Insert the participant into the participants table + final ContentValues values = participant.toContentValues(); + final long participantRow = dbWrapper.insert(DatabaseHelper.PARTICIPANTS_TABLE, null, + values); + participantId = Long.toString(participantRow); + Assert.notNull(canonicalRecipient); + + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + // Now that we've inserted it, add it to our cache + sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, participantId); + } + + return participantId; + } + + @DoesNotRunOnMainThread + public static void updateDestination(final DatabaseWrapper dbWrapper, + final String destination, final boolean blocked) { + Assert.isNotMainThread(); + final ContentValues values = new ContentValues(); + values.put(ParticipantColumns.BLOCKED, blocked ? 1 : 0); + dbWrapper.update(DatabaseHelper.PARTICIPANTS_TABLE, values, + ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " + + ParticipantColumns.SUB_ID + "=?", + new String[] { destination, Integer.toString( + ParticipantData.OTHER_THAN_SELF_SUB_ID) }); + } + + @DoesNotRunOnMainThread + public static String getConversationFromOtherParticipantDestination( + final DatabaseWrapper db, final String otherDestination) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID }, + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + "=?", + new String[] { otherDestination }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + + /** + * Get a list of conversations that contain any of participants specified. + */ + private static HashSet<String> getConversationsForParticipants( + final ArrayList<String> participantIds) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final HashSet<String> conversationIds = new HashSet<String>(); + + final String selection = ConversationParticipantsColumns.PARTICIPANT_ID + "=?"; + for (final String participantId : participantIds) { + final String[] selectionArgs = new String[] { participantId }; + final Cursor cursor = db.query(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, + ConversationParticipantsQuery.PROJECTION, + selection, selectionArgs, null, null, null); + + if (cursor != null) { + try { + while (cursor.moveToNext()) { + final String conversationId = cursor.getString( + ConversationParticipantsQuery.INDEX_CONVERSATION_ID); + conversationIds.add(conversationId); + } + } finally { + cursor.close(); + } + } + } + + return conversationIds; + } + + /** + * Refresh conversation names/avatars based on a list of participants that are changed. + */ + @DoesNotRunOnMainThread + public static void refreshConversationsForParticipants(final ArrayList<String> participants) { + Assert.isNotMainThread(); + final HashSet<String> conversationIds = getConversationsForParticipants(participants); + if (conversationIds.size() > 0) { + for (final String conversationId : conversationIds) { + refreshConversation(conversationId); + } + + MessagingContentProvider.notifyConversationListChanged(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Number of conversations refreshed:" + conversationIds.size()); + } + } + } + + /** + * Refresh conversation names/avatars based on a changed participant. + */ + @DoesNotRunOnMainThread + public static void refreshConversationsForParticipant(final String participantId) { + Assert.isNotMainThread(); + final ArrayList<String> participantList = new ArrayList<String>(1); + participantList.add(participantId); + refreshConversationsForParticipants(participantList); + } + + /** + * Refresh one conversation. + */ + private static void refreshConversation(final String conversationId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationNameAndAvatarInTransaction(db, + conversationId); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyParticipantsChanged(conversationId); + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + } + + @DoesNotRunOnMainThread + public static boolean updateRowIfExists(final DatabaseWrapper db, final String table, + final String rowKey, final String rowId, final ContentValues values) { + Assert.isNotMainThread(); + final StringBuilder sb = new StringBuilder(); + final ArrayList<String> whereValues = new ArrayList<String>(values.size() + 1); + whereValues.add(rowId); + + for (final String key : values.keySet()) { + if (sb.length() > 0) { + sb.append(" OR "); + } + final Object value = values.get(key); + sb.append(key); + if (value != null) { + sb.append(" IS NOT ?"); + whereValues.add(value.toString()); + } else { + sb.append(" IS NOT NULL"); + } + } + + final String whereClause = rowKey + "=?" + " AND (" + sb.toString() + ")"; + final String [] whereValuesArray = whereValues.toArray(new String[whereValues.size()]); + final int count = db.update(table, values, whereClause, whereValuesArray); + if (count > 1) { + LogUtil.w(LogUtil.BUGLE_TAG, "Updated more than 1 row " + count + "; " + table + + " for " + rowKey + " = " + rowId + " (deleted?)"); + } + Assert.inRange(count, 0, 1); + return (count >= 0); + } +} diff --git a/src/com/android/messaging/datamodel/BugleNotifications.java b/src/com/android/messaging/datamodel/BugleNotifications.java new file mode 100644 index 0000000..b796e73 --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleNotifications.java @@ -0,0 +1,1221 @@ +/* + * 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; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.Typeface; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.WearableExtender; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.app.RemoteInput; +import android.support.v4.util.SimpleArrayMap; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.text.style.TextAppearanceSpan; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState; +import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo; +import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState; +import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState; +import com.android.messaging.datamodel.action.MarkAsReadAction; +import com.android.messaging.datamodel.action.MarkAsSeenAction; +import com.android.messaging.datamodel.action.RedownloadMmsAction; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.NotificationPlayer; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PendingIntentConstants; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.RingtoneUtil; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UriUtil; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Handle posting, updating and removing all conversation notifications. + * + * There are currently two main classes of notification and their rules: <p> + * 1) Messages - {@link MessageNotificationState}. Only one message notification. + * Unread messages across senders and conversations are coalesced.<p> + * 2) Failed Messages - {@link MessageNotificationState#checkFailedMesages } Only one failed + * message. Multiple failures are coalesced.<p> + * + * To add a new class of notifications, subclass the NotificationState and add commands which + * create one and pass into general creation function. + * + */ +public class BugleNotifications { + // Logging + public static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG; + + // Constants to use for update. + public static final int UPDATE_NONE = 0; + public static final int UPDATE_MESSAGES = 1; + public static final int UPDATE_ERRORS = 2; + public static final int UPDATE_ALL = UPDATE_MESSAGES + UPDATE_ERRORS; + + // Constants for notification type used for audio and vibration settings. + public static final int LOCAL_SMS_NOTIFICATION = 0; + + private static final String SMS_NOTIFICATION_TAG = ":sms:"; + private static final String SMS_ERROR_NOTIFICATION_TAG = ":error:"; + + private static final String WEARABLE_COMPANION_APP_PACKAGE = "com.google.android.wearable.app"; + + private static final Set<NotificationState> sPendingNotifications = + new HashSet<NotificationState>(); + + private static int sWearableImageWidth; + private static int sWearableImageHeight; + private static int sIconWidth; + private static int sIconHeight; + + private static boolean sInitialized = false; + + private static final Object mLock = new Object(); + + // sLastMessageDingTime is a map between a conversation id and a time. It's used to keep track + // of the time we last dinged a message for this conversation. When messages are coming in + // at flurry, we don't want to over-ding the user. + private static final SimpleArrayMap<String, Long> sLastMessageDingTime = + new SimpleArrayMap<String, Long>(); + private static int sTimeBetweenDingsMs; + + /** + * This is the volume at which to play the observable-conversation notification sound, + * expressed as a fraction of the system notification volume. + */ + private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f; + + /** + * Entry point for posting notifications. + * Don't call this on the UI thread. + * @param silent If true, no ring will be played. If false, checks global settings before + * playing a ringtone + * @param coverage Indicates which notification types should be checked. Valid values are + * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL + */ + public static void update(final boolean silent, final int coverage) { + update(silent, null /* conversationId */, coverage); + } + + /** + * Entry point for posting notifications. + * Don't call this on the UI thread. + * @param silent If true, no ring will be played. If false, checks global settings before + * playing a ringtone + * @param conversationId Conversation ID where a new message was received + * @param coverage Indicates which notification types should be checked. Valid values are + * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL + */ + public static void update(final boolean silent, final String conversationId, + final int coverage) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Update: silent = " + silent + + " conversationId = " + conversationId + + " coverage = " + coverage); + } + Assert.isNotMainThread(); + checkInitialized(); + + if (!shouldNotify()) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Notifications disabled"); + } + cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); + return; + } else { + if ((coverage & UPDATE_MESSAGES) != 0) { + createMessageNotification(silent, conversationId); + } + } + if ((coverage & UPDATE_ERRORS) != 0) { + MessageNotificationState.checkFailedMessages(); + } + } + + /** + * Cancel all notifications of a certain type. + * + * @param type Message or error notifications from Constants. + */ + private static synchronized void cancel(final int type) { + cancel(type, null, false); + } + + /** + * Cancel all notifications of a certain type. + * + * @param type Message or error notifications from Constants. + * @param conversationId If set, cancel the notification for this + * conversation only. For message notifications, this only works + * if the notifications are bundled (group children). + * @param isBundledNotification True if this notification is part of a + * notification bundle. This only applies to message notifications, + * which are bundled together with other message notifications. + */ + private static synchronized void cancel(final int type, final String conversationId, + final boolean isBundledNotification) { + final String notificationTag = buildNotificationTag(type, conversationId, + isBundledNotification); + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + + // Find all pending notifications and cancel them. + synchronized (sPendingNotifications) { + final Iterator<NotificationState> iter = sPendingNotifications.iterator(); + while (iter.hasNext()) { + final NotificationState notifState = iter.next(); + if (notifState.mType == type) { + notifState.mCanceled = true; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Canceling pending notification"); + } + iter.remove(); + } + } + } + notificationManager.cancel(notificationTag, type); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Canceled notifications of type " + type); + } + + // Message notifications for multiple conversations can be grouped together (see comment in + // createMessageNotification). We need to do bookkeeping to track the current set of + // notification group children, including removing them when we cancel notifications). + if (type == PendingIntentConstants.SMS_NOTIFICATION_ID) { + final Context context = Factory.get().getApplicationContext(); + final ConversationIdSet groupChildIds = getGroupChildIds(context); + + if (groupChildIds != null && groupChildIds.size() > 0) { + // If a conversation is specified, remove just that notification. Otherwise, + // we're removing the group summary so clear all children. + if (conversationId != null) { + groupChildIds.remove(conversationId); + writeGroupChildIds(context, groupChildIds); + } else { + cancelStaleGroupChildren(groupChildIds, null); + // We'll update the group children preference as we cancel each child, + // so we don't need to do it here. + } + } + } + } + + /** + * Cancels stale notifications from the currently active group of + * notifications. If the {@code state} parameter is an instance of + * {@link MultiConversationNotificationState} it represents a new + * notification group. This method will cancel any notifications that were + * in the old group, but not the new one. If the new notification is not a + * group, then all existing grouped notifications are cancelled. + * + * @param previousGroupChildren Conversation ids for the active notification + * group + * @param state New notification state + */ + private static void cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren, + final NotificationState state) { + final ConversationIdSet newChildren = new ConversationIdSet(); + if (state instanceof MultiConversationNotificationState) { + for (final NotificationState child : + ((MultiConversationNotificationState) state).mChildren) { + if (child.mConversationIds != null) { + newChildren.add(child.mConversationIds.first()); + } + } + } + for (final String childConversationId : previousGroupChildren) { + if (!newChildren.contains(childConversationId)) { + cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, childConversationId, true); + } + } + } + + /** + * Returns {@code true} if incoming notifications should display a + * notification, {@code false} otherwise. + * + * @return true if the notification should occur + */ + private static boolean shouldNotify() { + // If we're not the default sms app, don't put up any notifications. + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + return false; + } + + // Now check prefs (i.e. settings) to see if the user turned off notifications. + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.notifications_enabled_pref_key); + final boolean defaultValue = context.getResources().getBoolean( + R.bool.notifications_enabled_pref_default); + return prefs.getBoolean(prefKey, defaultValue); + } + + /** + * Returns {@code true} if incoming notifications for the given {@link NotificationState} + * should vibrate the device, {@code false} otherwise. + * + * @return true if vibration should be used + */ + public static boolean shouldVibrate(final NotificationState state) { + // The notification should vibrate if the global setting is turned on AND + // the per-conversation setting is turned on (default). + if (!state.getNotificationVibrate()) { + return false; + } else { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.notification_vibration_pref_key); + final boolean defaultValue = context.getResources().getBoolean( + R.bool.notification_vibration_pref_default); + return prefs.getBoolean(prefKey, defaultValue); + } + } + + private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final ConversationListItemData convData = + ConversationListItemData.getExistingConversation(db, conversationId); + return RingtoneUtil.getNotificationRingtoneUri( + convData != null ? convData.getNotificationSoundUri() : null); + } + + /** + * Returns a unique tag to identify a notification. + * + * @param name The tag name (in practice, the type) + * @param conversationId The conversation id (optional) + */ + private static String buildNotificationTag(final String name, + final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + if (conversationId != null) { + return context.getPackageName() + name + ":" + conversationId; + } else { + return context.getPackageName() + name; + } + } + + /** + * Returns a unique tag to identify a notification. + * <p> + * This delegates to + * {@link #buildNotificationTag(int, String, boolean)} and can be + * used when the notification is never bundled (e.g. error notifications). + */ + static String buildNotificationTag(final int type, final String conversationId) { + return buildNotificationTag(type, conversationId, false /* bundledNotification */); + } + + /** + * Returns a unique tag to identify a notification. + * + * @param type One of the constants in {@link PendingIntentConstants} + * @param conversationId The conversation id (where applicable) + * @param bundledNotification Set to true if this notification will be + * bundled together with other notifications (e.g. on a wearable + * device). + */ + static String buildNotificationTag(final int type, final String conversationId, + final boolean bundledNotification) { + String tag = null; + switch(type) { + case PendingIntentConstants.SMS_NOTIFICATION_ID: + if (bundledNotification) { + tag = buildNotificationTag(SMS_NOTIFICATION_TAG, conversationId); + } else { + tag = buildNotificationTag(SMS_NOTIFICATION_TAG, null); + } + break; + case PendingIntentConstants.MSG_SEND_ERROR: + tag = buildNotificationTag(SMS_ERROR_NOTIFICATION_TAG, null); + break; + } + return tag; + } + + private static void checkInitialized() { + if (!sInitialized) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + sWearableImageWidth = resources.getDimensionPixelSize( + R.dimen.notification_wearable_image_width); + sWearableImageHeight = resources.getDimensionPixelSize( + R.dimen.notification_wearable_image_height); + sIconHeight = (int) resources.getDimension( + android.R.dimen.notification_large_icon_height); + sIconWidth = + (int) resources.getDimension(android.R.dimen.notification_large_icon_width); + + sInitialized = true; + } + } + + private static void processAndSend(final NotificationState state, final boolean silent, + final boolean softSound) { + final Context context = Factory.get().getApplicationContext(); + final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context); + notifBuilder.setCategory(Notification.CATEGORY_MESSAGE); + // TODO: Need to fix this for multi conversation notifications to rate limit dings. + final String conversationId = state.mConversationIds.first(); + + + final Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(state.getRingtoneUri()); + // If the notification's conversation is currently observable (focused or in the + // conversation list), then play a notification beep at a low volume and don't display an + // actual notification. + if (softSound) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "processAndSend: fromConversationId == " + + "sCurrentlyDisplayedConversationId so NOT showing notification," + + " but playing soft sound. conversationId: " + conversationId); + } + playObservableConversationNotificationSound(ringtoneUri); + return; + } + state.mBaseRequestCode = state.mType; + + // Set the delete intent (except for bundled wearable notifications, which are dismissed + // as a group, either from the wearable or when the summary notification is dismissed from + // the host device). + if (!(state instanceof BundledMessageNotificationState)) { + final PendingIntent clearIntent = state.getClearIntent(); + notifBuilder.setDeleteIntent(clearIntent); + } + + updateBuilderAudioVibrate(state, notifBuilder, silent, ringtoneUri, conversationId); + + // Set the content intent + PendingIntent destinationIntent; + if (state.mConversationIds.size() > 1) { + // We have notifications for multiple conversation, go to the conversation list. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationListActivity(context); + } else { + // We have a single conversation, go directly to that conversation. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationActivity(context, + state.mConversationIds.first(), + null /*draft*/); + } + notifBuilder.setContentIntent(destinationIntent); + + // TODO: set based on contact coming from a favorite. + notifBuilder.setPriority(state.getPriority()); + + // Save the state of the notification in-progress so when the avatar is loaded, + // we can continue building the notification. + final NotificationCompat.Style notifStyle = state.build(notifBuilder); + state.mNotificationBuilder = notifBuilder; + state.mNotificationStyle = notifStyle; + if (!state.mPeople.isEmpty()) { + final Bundle people = new Bundle(); + people.putStringArray(NotificationCompat.EXTRA_PEOPLE, + state.mPeople.toArray(new String[state.mPeople.size()])); + notifBuilder.addExtras(people); + } + + if (state.mParticipantAvatarsUris != null) { + final Uri avatarUri = state.mParticipantAvatarsUris.get(0); + final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(avatarUri, + sIconWidth, sIconHeight, OsUtil.isAtLeastL()); + final MediaRequest<ImageResource> imageRequest = descriptor.buildSyncMediaRequest( + context); + + synchronized (sPendingNotifications) { + sPendingNotifications.add(state); + } + + // Synchronously load the avatar. + final ImageResource avatarImage = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (avatarImage != null) { + ImageResource avatarHiRes = null; + try { + if (isWearCompanionAppInstalled()) { + // For Wear users, we need to request a high-res avatar image to use as the + // notification card background. If the sender has a contact photo, we'll + // request the display photo from the Contacts provider. Otherwise, we ask + // the local content provider for a hi-res version of the generic avatar + // (e.g. letter with colored background). + avatarHiRes = requestContactDisplayPhoto(context, + getDisplayPhotoUri(avatarUri)); + if (avatarHiRes == null) { + final AvatarRequestDescriptor hiResDesc = + new AvatarRequestDescriptor(avatarUri, + sWearableImageWidth, + sWearableImageHeight, + false /* cropToCircle */, + true /* isWearBackground */); + avatarHiRes = MediaResourceManager.get().requestMediaResourceSync( + hiResDesc.buildSyncMediaRequest(context)); + } + } + + // We have to make copies of the bitmaps to hand to the NotificationManager + // because the bitmap in the ImageResource is managed and will automatically + // get released. + Bitmap avatarBitmap = Bitmap.createBitmap(avatarImage.getBitmap()); + Bitmap avatarHiResBitmap = (avatarHiRes != null) ? + Bitmap.createBitmap(avatarHiRes.getBitmap()) : null; + sendNotification(state, avatarBitmap, avatarHiResBitmap); + return; + } finally { + avatarImage.release(); + if (avatarHiRes != null) { + avatarHiRes.release(); + } + } + } + } + // We have no avatar. Post the notification anyway. + sendNotification(state, null, null); + } + + /** + * Returns the thumbnailUri from the avatar URI, or null if avatar URI does not have thumbnail. + */ + private static Uri getThumbnailUri(final Uri avatarUri) { + Uri localUri = null; + final String avatarType = AvatarUriUtil.getAvatarType(avatarUri); + if (TextUtils.equals(avatarType, AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI)) { + localUri = AvatarUriUtil.getPrimaryUri(avatarUri); + } else if (UriUtil.isLocalResourceUri(avatarUri)) { + localUri = avatarUri; + } + if (localUri != null && localUri.getAuthority().equals(ContactsContract.AUTHORITY)) { + // Contact photos are of the form: content://com.android.contacts/contacts/123/photo + final List<String> pathParts = localUri.getPathSegments(); + if (pathParts.size() == 3 && + pathParts.get(2).equals(Contacts.Photo.CONTENT_DIRECTORY)) { + return localUri; + } + } + return null; + } + + /** + * Returns the displayPhotoUri from the avatar URI, or null if avatar URI + * does not have a displayPhotoUri. + */ + private static Uri getDisplayPhotoUri(final Uri avatarUri) { + final Uri thumbnailUri = getThumbnailUri(avatarUri); + if (thumbnailUri == null) { + return null; + } + final List<String> originalPaths = thumbnailUri.getPathSegments(); + final int originalPathsSize = originalPaths.size(); + final StringBuilder newPathBuilder = new StringBuilder(); + // Change content://com.android.contacts/contacts("_corp")/123/photo to + // content://com.android.contacts/contacts("_corp")/123/display_photo + for (int i = 0; i < originalPathsSize; i++) { + newPathBuilder.append('/'); + if (i == 2) { + newPathBuilder.append(ContactsContract.Contacts.Photo.DISPLAY_PHOTO); + } else { + newPathBuilder.append(originalPaths.get(i)); + } + } + return thumbnailUri.buildUpon().path(newPathBuilder.toString()).build(); + } + + private static ImageResource requestContactDisplayPhoto(final Context context, + final Uri displayPhotoUri) { + final UriImageRequestDescriptor bgDescriptor = + new UriImageRequestDescriptor(displayPhotoUri, + sWearableImageWidth, + sWearableImageHeight, + false, /* allowCompression */ + true, /* isStatic */ + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + return MediaResourceManager.get().requestMediaResourceSync( + bgDescriptor.buildSyncMediaRequest(context)); + } + + private static void createMessageNotification(final boolean silent, + final String conversationId) { + final NotificationState state = MessageNotificationState.getNotificationState(); + final boolean softSound = DataModel.get().isNewMessageObservable(conversationId); + if (state == null) { + cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); + if (softSound && !TextUtils.isEmpty(conversationId)) { + final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId); + playObservableConversationNotificationSound(ringtoneUri); + } + return; + } + processAndSend(state, silent, softSound); + + // The rest of the logic here is for supporting Android Wear devices, specifically for when + // we are notifying about multiple conversations. In that case, the Inbox-style summary + // notification (which we already processed above) appears on the phone (as it always has), + // but wearables show per-conversation notifications, bundled together in a group. + + // It is valid to replace a notification group with another group with fewer conversations, + // or even with one notification for a single conversation. In either case, we need to + // explicitly cancel any children from the old group which are not being notified about now. + final Context context = Factory.get().getApplicationContext(); + final ConversationIdSet oldGroupChildIds = getGroupChildIds(context); + if (oldGroupChildIds != null && oldGroupChildIds.size() > 0) { + cancelStaleGroupChildren(oldGroupChildIds, state); + } + + // Send per-conversation notifications (if there are multiple conversations). + final ConversationIdSet groupChildIds = new ConversationIdSet(); + if (state instanceof MultiConversationNotificationState) { + for (final NotificationState child : + ((MultiConversationNotificationState) state).mChildren) { + processAndSend(child, true /* silent */, softSound); + if (child.mConversationIds != null) { + groupChildIds.add(child.mConversationIds.first()); + } + } + } + + // Record the new set of group children. + writeGroupChildIds(context, groupChildIds); + } + + private static void updateBuilderAudioVibrate(final NotificationState state, + final NotificationCompat.Builder notifBuilder, final boolean silent, + final Uri ringtoneUri, final String conversationId) { + int defaults = Notification.DEFAULT_LIGHTS; + if (!silent) { + final BuglePrefs prefs = Factory.get().getApplicationPrefs(); + final long latestNotificationTimestamp = prefs.getLong( + BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE); + final long latestReceivedTimestamp = state.getLatestReceivedTimestamp(); + prefs.putLong( + BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, + Math.max(latestNotificationTimestamp, latestReceivedTimestamp)); + if (latestReceivedTimestamp > latestNotificationTimestamp) { + synchronized (mLock) { + // Find out the last time we dinged for this conversation + Long lastTime = sLastMessageDingTime.get(conversationId); + if (sTimeBetweenDingsMs == 0) { + sTimeBetweenDingsMs = BugleGservices.get().getInt( + BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS, + BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT) * + 1000; + } + if (lastTime == null + || SystemClock.elapsedRealtime() - lastTime > sTimeBetweenDingsMs) { + sLastMessageDingTime.put(conversationId, SystemClock.elapsedRealtime()); + notifBuilder.setSound(ringtoneUri); + if (shouldVibrate(state)) { + defaults |= Notification.DEFAULT_VIBRATE; + } + } + } + } + } + notifBuilder.setDefaults(defaults); + } + + // TODO: this doesn't seem to be defined in NotificationCompat yet. Temporarily + // define it here until it makes its way from Notification -> NotificationCompat. + /** + * Notification category: incoming direct message (SMS, instant message, etc.). + */ + private static final String CATEGORY_MESSAGE = "msg"; + + private static void sendNotification(final NotificationState notificationState, + final Bitmap avatarIcon, final Bitmap avatarHiRes) { + final Context context = Factory.get().getApplicationContext(); + if (notificationState.mCanceled) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "sendNotification: Notification already cancelled; dropping it"); + } + return; + } + + synchronized (sPendingNotifications) { + if (sPendingNotifications.contains(notificationState)) { + sPendingNotifications.remove(notificationState); + } + } + + notificationState.mNotificationBuilder + .setSmallIcon(notificationState.getIcon()) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setColor(context.getResources().getColor(R.color.notification_accent_color)) +// .setPublicVersion(null) // TODO: when/if we ever support different + // text on the lockscreen, instead of "contents hidden" + .setCategory(CATEGORY_MESSAGE); + + if (avatarIcon != null) { + notificationState.mNotificationBuilder.setLargeIcon(avatarIcon); + } + + if (notificationState.mParticipantContactUris != null && + notificationState.mParticipantContactUris.size() > 0) { + for (final Uri contactUri : notificationState.mParticipantContactUris) { + notificationState.mNotificationBuilder.addPerson(contactUri.toString()); + } + } + + final Uri attachmentUri = notificationState.getAttachmentUri(); + final String attachmentType = notificationState.getAttachmentType(); + Bitmap attachmentBitmap = null; + + // For messages with photo/video attachment, request an image to show in the notification. + if (attachmentUri != null && notificationState.mNotificationStyle != null && + (notificationState.mNotificationStyle instanceof + NotificationCompat.BigPictureStyle) && + (ContentType.isImageType(attachmentType) || + ContentType.isVideoType(attachmentType))) { + final boolean isVideo = ContentType.isVideoType(attachmentType); + + MediaRequest<ImageResource> imageRequest; + if (isVideo) { + Assert.isTrue(VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()); + final MessagePartVideoThumbnailRequestDescriptor videoDescriptor = + new MessagePartVideoThumbnailRequestDescriptor(attachmentUri); + imageRequest = videoDescriptor.buildSyncMediaRequest(context); + } else { + final UriImageRequestDescriptor imageDescriptor = + new UriImageRequestDescriptor(attachmentUri, + sWearableImageWidth, + sWearableImageHeight, + false /* allowCompression */, + true /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + imageRequest = imageDescriptor.buildSyncMediaRequest(context); + } + final ImageResource imageResource = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (imageResource != null) { + try { + // Copy the bitmap, because the one in the ImageResource is managed by + // MediaResourceManager. + Bitmap imageResourceBitmap = imageResource.getBitmap(); + Config config = imageResourceBitmap.getConfig(); + + // Make sure our bitmap has a valid format. + if (config == null) { + config = Bitmap.Config.ARGB_8888; + } + attachmentBitmap = imageResourceBitmap.copy(config, true); + } finally { + imageResource.release(); + } + } + } + + fireOffNotification(notificationState, attachmentBitmap, avatarIcon, avatarHiRes); + } + + private static void fireOffNotification(final NotificationState notificationState, + final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap) { + if (notificationState.mCanceled) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Firing off notification, but notification already canceled"); + } + return; + } + + final Context context = Factory.get().getApplicationContext(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MMS picture loaded, bitmap: " + attachmentBitmap); + } + + final NotificationCompat.Builder notifBuilder = notificationState.mNotificationBuilder; + notifBuilder.setStyle(notificationState.mNotificationStyle); + notifBuilder.setColor(context.getResources().getColor(R.color.notification_accent_color)); + + final WearableExtender wearableExtender = new WearableExtender(); + setWearableGroupOptions(notifBuilder, notificationState); + + if (avatarHiResBitmap != null) { + wearableExtender.setBackground(avatarHiResBitmap); + } else if (avatarBitmap != null) { + // Nothing to do here; we already set avatarBitmap as the notification icon + } else { + final Bitmap defaultBackground = BitmapFactory.decodeResource( + context.getResources(), R.drawable.bg_sms); + wearableExtender.setBackground(defaultBackground); + } + + if (notificationState instanceof MultiMessageNotificationState) { + if (attachmentBitmap != null) { + // When we've got a picture attachment, we do some switcheroo trickery. When + // the notification is expanded, we show the picture as a bigPicture. The small + // icon shows the sender's avatar. When that same notification is collapsed, the + // picture is shown in the location where the avatar is normally shown. The lines + // below make all that happen. + + // Here we're taking the picture attachment and making a small, scaled, center + // cropped version of the picture we can stuff into the place where the avatar + // goes when the notification is collapsed. + final Bitmap smallBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, sIconWidth, + sIconHeight); + ((NotificationCompat.BigPictureStyle) notificationState.mNotificationStyle) + .bigPicture(attachmentBitmap) + .bigLargeIcon(avatarBitmap); + notificationState.mNotificationBuilder.setLargeIcon(smallBitmap); + + // Add a wearable page with no visible card so you can more easily see the photo. + final NotificationCompat.Builder photoPageNotifBuilder = + new NotificationCompat.Builder(Factory.get().getApplicationContext()); + final WearableExtender photoPageWearableExtender = new WearableExtender(); + photoPageWearableExtender.setHintShowBackgroundOnly(true); + if (attachmentBitmap != null) { + final Bitmap wearBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, + sWearableImageWidth, sWearableImageHeight); + photoPageWearableExtender.setBackground(wearBitmap); + } + photoPageNotifBuilder.extend(photoPageWearableExtender); + wearableExtender.addPage(photoPageNotifBuilder.build()); + } + + maybeAddWearableConversationLog(wearableExtender, + (MultiMessageNotificationState) notificationState); + addDownloadMmsAction(notifBuilder, wearableExtender, notificationState); + addWearableVoiceReplyAction(wearableExtender, notificationState); + } + + // Apply the wearable options and build & post the notification + notifBuilder.extend(wearableExtender); + doNotify(notifBuilder.build(), notificationState); + } + + private static void setWearableGroupOptions(final NotificationCompat.Builder notifBuilder, + final NotificationState notificationState) { + final String groupKey = "groupkey"; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Group key (for wearables)=" + groupKey); + } + if (notificationState instanceof MultiConversationNotificationState) { + notifBuilder.setGroup(groupKey).setGroupSummary(true); + } else if (notificationState instanceof BundledMessageNotificationState) { + final int order = ((BundledMessageNotificationState) notificationState).mGroupOrder; + // Convert the order to a zero-padded string ("00", "01", "02", etc). + // The Wear library orders notifications within a bundle lexicographically + // by the sort key, hence the need for zeroes to preserve the ordering. + final String sortKey = String.format(Locale.US, "%02d", order); + notifBuilder.setGroup(groupKey).setSortKey(sortKey); + } + } + + private static void maybeAddWearableConversationLog( + final WearableExtender wearableExtender, + final MultiMessageNotificationState notificationState) { + if (!isWearCompanionAppInstalled()) { + return; + } + final String convId = notificationState.mConversationIds.first(); + ConversationLineInfo convInfo = notificationState.mConvList.mConvInfos.get(0); + final Notification page = MessageNotificationState.buildConversationPageForWearable( + convId, + convInfo.mParticipantCount); + if (page != null) { + wearableExtender.addPage(page); + } + } + + private static void addWearableVoiceReplyAction( + final WearableExtender wearableExtender, final NotificationState notificationState) { + if (!(notificationState instanceof MultiMessageNotificationState)) { + return; + } + final MultiMessageNotificationState multiMessageNotificationState = + (MultiMessageNotificationState) notificationState; + final Context context = Factory.get().getApplicationContext(); + + final String conversationId = notificationState.mConversationIds.first(); + final ConversationLineInfo convInfo = + multiMessageNotificationState.mConvList.mConvInfos.get(0); + final String selfId = convInfo.mSelfParticipantId; + + final boolean requiresMms = + MmsSmsUtils.getRequireMmsForEmailAddress( + convInfo.mIncludeEmailAddress, convInfo.mSubId) || + (convInfo.mIsGroup && MmsUtils.groupMmsEnabled(convInfo.mSubId)); + + final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode(); + final PendingIntent replyPendingIntent = UIIntents.get() + .getPendingIntentForSendingMessageToConversation(context, + conversationId, selfId, requiresMms, requestCode); + + final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms : + R.string.notification_reply_via_sms; + + final NotificationCompat.Action.Builder actionBuilder = + new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply, + context.getString(replyLabelRes), replyPendingIntent); + final String[] choices = context.getResources().getStringArray( + R.array.notification_reply_choices); + final RemoteInput remoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT).setLabel( + context.getString(R.string.notification_reply_prompt)). + setChoices(choices) + .build(); + actionBuilder.addRemoteInput(remoteInput); + wearableExtender.addAction(actionBuilder.build()); + } + + private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder, + final WearableExtender wearableExtender, final NotificationState notificationState) { + if (!(notificationState instanceof MultiMessageNotificationState)) { + return; + } + final MultiMessageNotificationState multiMessageNotificationState = + (MultiMessageNotificationState) notificationState; + final ConversationLineInfo convInfo = + multiMessageNotificationState.mConvList.mConvInfos.get(0); + if (!convInfo.getDoesLatestMessageNeedDownload()) { + return; + } + final String messageId = convInfo.getLatestMessageId(); + if (messageId == null) { + // No message Id, no download for you + return; + } + final Context context = Factory.get().getApplicationContext(); + final PendingIntent downloadPendingIntent = + RedownloadMmsAction.getPendingIntentForRedownloadMms(context, messageId); + + final NotificationCompat.Action.Builder actionBuilder = + new NotificationCompat.Action.Builder(R.drawable.ic_file_download_light, + context.getString(R.string.notification_download_mms), + downloadPendingIntent); + final NotificationCompat.Action downloadAction = actionBuilder.build(); + notifBuilder.addAction(downloadAction); + + // Support the action on a wearable device as well + wearableExtender.addAction(downloadAction); + } + + private static synchronized void doNotify(final Notification notification, + final NotificationState notificationState) { + if (notification == null) { + return; + } + final int type = notificationState.mType; + final ConversationIdSet conversationIds = notificationState.mConversationIds; + final boolean isBundledNotification = + (notificationState instanceof BundledMessageNotificationState); + + // Mark the notification as finished + notificationState.mCanceled = true; + + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + // Only need conversationId for tags with a single conversation. + String conversationId = null; + if (conversationIds != null && conversationIds.size() == 1) { + conversationId = conversationIds.first(); + } + final String notificationTag = buildNotificationTag(type, + conversationId, isBundledNotification); + + notification.flags |= Notification.FLAG_AUTO_CANCEL; + notification.defaults |= Notification.DEFAULT_LIGHTS; + + notificationManager.notify(notificationTag, type, notification); + + LogUtil.i(TAG, "Notifying for conversation " + conversationId + "; " + + "tag = " + notificationTag + ", type = " + type); + } + + // This is the message string used in each line of an inboxStyle notification. + // TODO: add attachment type + static CharSequence formatInboxMessage(final String sender, + final CharSequence message, final Uri attachmentUri, final String attachmentType) { + final Context context = Factory.get().getApplicationContext(); + final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan( + context, R.style.NotificationSenderText); + + final TextAppearanceSpan notificationTertiaryText = new TextAppearanceSpan( + context, R.style.NotificationTertiaryText); + + final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); + if (!TextUtils.isEmpty(sender)) { + spannableStringBuilder.append(sender); + spannableStringBuilder.setSpan(notificationSenderSpan, 0, sender.length(), 0); + } + final String separator = context.getString(R.string.notification_separator); + + if (!TextUtils.isEmpty(message)) { + if (spannableStringBuilder.length() > 0) { + spannableStringBuilder.append(separator); + } + final int start = spannableStringBuilder.length(); + spannableStringBuilder.append(message); + spannableStringBuilder.setSpan(notificationTertiaryText, start, + start + message.length(), 0); + } + if (attachmentUri != null) { + if (spannableStringBuilder.length() > 0) { + spannableStringBuilder.append(separator); + } + spannableStringBuilder.append(formatAttachmentTag(null, attachmentType)); + } + return spannableStringBuilder; + } + + protected static CharSequence buildColonSeparatedMessage( + final String title, final CharSequence content, final Uri attachmentUri, + final String attachmentType) { + return buildBoldedMessage(title, content, attachmentUri, attachmentType, + R.string.notification_ticker_separator); + } + + protected static CharSequence buildSpaceSeparatedMessage( + final String title, final CharSequence content, final Uri attachmentUri, + final String attachmentType) { + return buildBoldedMessage(title, content, attachmentUri, attachmentType, + R.string.notification_space_separator); + } + + /** + * buildBoldedMessage - build a formatted message where the title is bold, there's a + * separator, then the message. + */ + private static CharSequence buildBoldedMessage( + final String title, final CharSequence message, final Uri attachmentUri, + final String attachmentType, + final int separatorId) { + final Context context = Factory.get().getApplicationContext(); + final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); + + // Boldify the title (which is the sender's name) + if (!TextUtils.isEmpty(title)) { + spanBuilder.append(title); + spanBuilder.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (!TextUtils.isEmpty(message)) { + if (spanBuilder.length() > 0) { + spanBuilder.append(context.getString(separatorId)); + } + spanBuilder.append(message); + } + if (attachmentUri != null) { + if (spanBuilder.length() > 0) { + final String separator = context.getString(R.string.notification_separator); + spanBuilder.append(separator); + } + spanBuilder.append(formatAttachmentTag(null, attachmentType)); + } + return spanBuilder; + } + + static CharSequence formatAttachmentTag(final String author, final String attachmentType) { + final Context context = Factory.get().getApplicationContext(); + final TextAppearanceSpan notificationSecondaryText = new TextAppearanceSpan( + context, R.style.NotificationSecondaryText); + final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); + if (!TextUtils.isEmpty(author)) { + final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan( + context, R.style.NotificationSenderText); + spannableStringBuilder.append(author); + spannableStringBuilder.setSpan(notificationSenderSpan, 0, author.length(), 0); + final String separator = context.getString(R.string.notification_separator); + spannableStringBuilder.append(separator); + } + final int start = spannableStringBuilder.length(); + // The default attachment type is an image, since that's what was originally + // supported. When there's no content type, assume it's an image. + int message = R.string.notification_picture; + if (ContentType.isAudioType(attachmentType)) { + message = R.string.notification_audio; + } else if (ContentType.isVideoType(attachmentType)) { + message = R.string.notification_video; + } else if (ContentType.isVCardType(attachmentType)) { + message = R.string.notification_vcard; + } + spannableStringBuilder.append(context.getText(message)); + spannableStringBuilder.setSpan(notificationSecondaryText, start, + spannableStringBuilder.length(), 0); + return spannableStringBuilder; + } + + /** + * Play the observable conversation notification sound (it's the regular notification sound, but + * played at half-volume) + */ + private static void playObservableConversationNotificationSound(final Uri ringtoneUri) { + final Context context = Factory.get().getApplicationContext(); + final AudioManager audioManager = (AudioManager) context + .getSystemService(Context.AUDIO_SERVICE); + final boolean silenced = + audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL; + if (silenced) { + return; + } + + final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG); + player.play(ringtoneUri, false, + AudioManager.STREAM_NOTIFICATION, + OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME); + + // Stop the sound after five seconds to handle continuous ringtones + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + player.stop(); + } + }, 5000); + } + + public static boolean isWearCompanionAppInstalled() { + boolean found = false; + try { + Factory.get().getApplicationContext().getPackageManager() + .getPackageInfo(WEARABLE_COMPANION_APP_PACKAGE, 0); + found = true; + } catch (final NameNotFoundException e) { + // Ignore; found is already false + } + return found; + } + + /** + * When we go to the conversation list, call this to mark all messages as seen. That means + * we won't show a notification again for the same message. + */ + public static void markAllMessagesAsSeen() { + MarkAsSeenAction.markAllAsSeen(); + resetLastMessageDing(null); // reset the ding timeout for all conversations + } + + /** + * When we open a particular conversation, call this to mark all messages as read. + */ + public static void markMessagesAsRead(final String conversationId) { + MarkAsReadAction.markAsRead(conversationId); + resetLastMessageDing(conversationId); + } + + /** + * Returns the conversation ids of all active, grouped notifications, or + * {code null} if no notifications are currently active and grouped. + */ + private static ConversationIdSet getGroupChildIds(final Context context) { + final String prefKey = context.getString(R.string.notifications_group_children_key); + final String groupChildIdsText = BuglePrefs.getApplicationPrefs().getString(prefKey, ""); + if (!TextUtils.isEmpty(groupChildIdsText)) { + return ConversationIdSet.createSet(groupChildIdsText); + } else { + return null; + } + } + + /** + * Records the conversation ids of the currently active grouped notifications. + */ + private static void writeGroupChildIds(final Context context, + final ConversationIdSet childIds) { + final ConversationIdSet oldChildIds = getGroupChildIds(context); + if (childIds.equals(oldChildIds)) { + return; + } + final String prefKey = context.getString(R.string.notifications_group_children_key); + BuglePrefs.getApplicationPrefs().putString(prefKey, childIds.getDelimitedString()); + } + + /** + * Reset the timer for a notification ding on a particular conversation or all conversations. + */ + public static void resetLastMessageDing(final String conversationId) { + synchronized (mLock) { + if (TextUtils.isEmpty(conversationId)) { + // reset all conversation dings + sLastMessageDingTime.clear(); + } else { + sLastMessageDingTime.remove(conversationId); + } + } + } + + public static void notifyEmergencySmsFailed(final String emergencyNumber, + final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + + final CharSequence line1 = MessageNotificationState.applyWarningTextColor(context, + context.getString(R.string.notification_emergency_send_failure_line1, + emergencyNumber)); + final String line2 = context.getString(R.string.notification_emergency_send_failure_line2, + emergencyNumber); + final PendingIntent destinationIntent = UIIntents.get() + .getPendingIntentForConversationActivity(context, conversationId, null /* draft */); + + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + builder.setTicker(line1) + .setContentTitle(line1) + .setContentText(line2) + .setStyle(new NotificationCompat.BigTextStyle(builder).bigText(line2)) + .setSmallIcon(R.drawable.ic_failed_light) + .setContentIntent(destinationIntent) + .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure)); + + final String tag = context.getPackageName() + ":emergency_sms_error"; + NotificationManagerCompat.from(context).notify( + tag, + PendingIntentConstants.MSG_SEND_ERROR, + builder.build()); + } +} + diff --git a/src/com/android/messaging/datamodel/BugleRecipientEntry.java b/src/com/android/messaging/datamodel/BugleRecipientEntry.java new file mode 100644 index 0000000..2a9e5ff --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleRecipientEntry.java @@ -0,0 +1,64 @@ +/* + * 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; + +import android.net.Uri; +import android.text.TextUtils; + +import com.android.ex.chips.RecipientEntry; + +/** + * An extension of RecipientEntry for Bugle's use since Bugle uses phone numbers to identify + * participants / recipients instead of contact ids. This allows the user to send to multiple + * phone numbers of the same contact. + */ +public class BugleRecipientEntry extends RecipientEntry { + + protected BugleRecipientEntry(final int entryType, final String displayName, + final String destination, final int destinationType, final String destinationLabel, + final long contactId, final Long directoryId, final long dataId, + final Uri photoThumbnailUri, final boolean isFirstLevel, final boolean isValid, + final String lookupKey) { + super(entryType, displayName, destination, destinationType, destinationLabel, contactId, + directoryId, dataId, photoThumbnailUri, isFirstLevel, isValid, lookupKey); + } + + public static BugleRecipientEntry constructTopLevelEntry(final String displayName, + final int displayNameSource, final String destination, final int destinationType, + final String destinationLabel, final long contactId, final Long directoryId, + final long dataId, final String thumbnailUriAsString, final boolean isValid, + final String lookupKey) { + return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType, + destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null + ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey); + } + + public static BugleRecipientEntry constructSecondLevelEntry(final String displayName, + final int displayNameSource, final String destination, final int destinationType, + final String destinationLabel, final long contactId, final Long directoryId, + final long dataId, final String thumbnailUriAsString, final boolean isValid, + final String lookupKey) { + return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType, + destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null + ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey); + } + + @Override + public boolean isSamePerson(final RecipientEntry entry) { + return getDestination() != null && entry.getDestination() != null && + TextUtils.equals(getDestination(), entry.getDestination()); + } +} diff --git a/src/com/android/messaging/datamodel/ConversationImagePartsView.java b/src/com/android/messaging/datamodel/ConversationImagePartsView.java new file mode 100644 index 0000000..70ba381 --- /dev/null +++ b/src/com/android/messaging/datamodel/ConversationImagePartsView.java @@ -0,0 +1,120 @@ +/* + * 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; + +import android.provider.BaseColumns; + +import com.android.ex.photo.provider.PhotoContract.PhotoViewColumns; + +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.util.ContentType; + +/** + * View for the image parts for the conversation. It is used to provide the photoviewer with a + * a data source for all the photos in a conversation, so that the photoviewer can support paging + * through all the photos of the conversation. The columns of the view are a superset of + * {@link com.android.ex.photo.provider.PhotoContract.PhotoViewColumns}. + */ +public class ConversationImagePartsView { + private static final String VIEW_NAME = "conversation_image_parts_view"; + + private static final String CREATE_SQL = "CREATE VIEW " + + VIEW_NAME + " AS SELECT " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID + + " as " + Columns.CONVERSATION_ID + ", " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI + + " as " + Columns.URI + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME + + " as " + Columns.SENDER_FULL_NAME + ", " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI + + " as " + Columns.CONTENT_URI + ", " + // Use NULL as the thumbnail uri + + " NULL as " + Columns.THUMBNAIL_URI + ", " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE + + " as " + Columns.CONTENT_TYPE + ", " + // + // Columns in addition to those specified by PhotoContract + // + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + + " as " + Columns.DISPLAY_DESTINATION + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + + " as " + Columns.RECEIVED_TIMESTAMP + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS + + " as " + Columns.STATUS + " " + + + " FROM " + DatabaseHelper.MESSAGES_TABLE + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE + + " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID + + "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") " + + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON (" + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")" + + // "content_type like 'image/%'" + + " WHERE " + DatabaseHelper.PARTS_TABLE + "." + PartColumns.CONTENT_TYPE + + " like '" + ContentType.IMAGE_PREFIX + "%'" + + + " ORDER BY " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " ASC, " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + " ASC"; + + static class Columns implements BaseColumns { + static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID; + static final String URI = PhotoViewColumns.URI; + static final String SENDER_FULL_NAME = PhotoViewColumns.NAME; + static final String CONTENT_URI = PhotoViewColumns.CONTENT_URI; + static final String THUMBNAIL_URI = PhotoViewColumns.THUMBNAIL_URI; + static final String CONTENT_TYPE = PhotoViewColumns.CONTENT_TYPE; + // Columns in addition to those specified by PhotoContract + static final String DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION; + static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP; + static final String STATUS = MessageColumns.STATUS; + } + + public interface PhotoViewQuery { + public final String[] PROJECTION = { + PhotoViewColumns.URI, + PhotoViewColumns.NAME, + PhotoViewColumns.CONTENT_URI, + PhotoViewColumns.THUMBNAIL_URI, + PhotoViewColumns.CONTENT_TYPE, + // Columns in addition to those specified by PhotoContract + Columns.DISPLAY_DESTINATION, + Columns.RECEIVED_TIMESTAMP, + Columns.STATUS, + }; + + public final int INDEX_URI = 0; + public final int INDEX_SENDER_FULL_NAME = 1; + public final int INDEX_CONTENT_URI = 2; + public final int INDEX_THUMBNAIL_URI = 3; + public final int INDEX_CONTENT_TYPE = 4; + // Columns in addition to those specified by PhotoContract + public final int INDEX_DISPLAY_DESTINATION = 5; + public final int INDEX_RECEIVED_TIMESTAMP = 6; + public final int INDEX_STATUS = 7; + } + + static final String getViewName() { + return VIEW_NAME; + } + + static final String getCreateSql() { + return CREATE_SQL; + } +} diff --git a/src/com/android/messaging/datamodel/CursorQueryData.java b/src/com/android/messaging/datamodel/CursorQueryData.java new file mode 100644 index 0000000..3e6a656 --- /dev/null +++ b/src/com/android/messaging/datamodel/CursorQueryData.java @@ -0,0 +1,82 @@ +/* + * 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; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.google.common.annotations.VisibleForTesting; + +/** + * Holds parameters and data (such as content URI) for performing queries on the content provider. + * This class could then be used to perform a query using either a BoundCursorLoader or querying + * on the content resolver directly. + * + * This class is used for cases where the way to load a cursor is not fixed. For example, + * when using ContactUtil to query for phone numbers, the ContactPickerFragment wants to use + * a CursorLoader to asynchronously load the data and tie in nicely with its data binding + * paradigm, whereas ContactRecipientAdapter wants to synchronously perform the query on the + * worker thread. + */ +public class CursorQueryData { + protected final Uri mUri; + protected final String[] mProjection; + protected final String mSelection; + protected final String[] mSelectionArgs; + protected final String mSortOrder; + protected final Context mContext; + + public CursorQueryData(final Context context, final Uri uri, final String[] projection, + final String selection, final String[] selectionArgs, final String sortOrder) { + mContext = context; + mUri = uri; + mProjection = projection; + mSelection = selection; + mSelectionArgs = selectionArgs; + mSortOrder = sortOrder; + } + + public BoundCursorLoader createBoundCursorLoader(final String bindingId) { + return new BoundCursorLoader(bindingId, mContext, mUri, mProjection, mSelection, + mSelectionArgs, mSortOrder); + } + + public Cursor performSynchronousQuery() { + Assert.isNotMainThread(); + if (mUri == null) { + // See {@link #getEmptyQueryData} + return null; + } else { + return mContext.getContentResolver().query(mUri, mProjection, mSelection, + mSelectionArgs, mSortOrder); + } + } + + @VisibleForTesting + public Uri getUri() { + return mUri; + } + + /** + * Representation of an invalid query. {@link #performSynchronousQuery} will return + * a null Cursor. + */ + public static CursorQueryData getEmptyQueryData() { + return new CursorQueryData(null, null, null, null, null, null); + } +} diff --git a/src/com/android/messaging/datamodel/DataModel.java b/src/com/android/messaging/datamodel/DataModel.java new file mode 100644 index 0000000..936b51c --- /dev/null +++ b/src/com/android/messaging/datamodel/DataModel.java @@ -0,0 +1,158 @@ +/* + * 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; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.action.Action; +import com.android.messaging.datamodel.action.ActionService; +import com.android.messaging.datamodel.action.BackgroundWorker; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ConnectivityUtil; + +public abstract class DataModel { + private String mFocusedConversation; + private boolean mConversationListScrolledToNewestConversation; + + public static DataModel get() { + return Factory.get().getDataModel(); + } + + public static final void startActionService(final Action action) { + get().getActionService().startAction(action); + } + + public static final void scheduleAction(final Action action, + final int code, final long delayMs) { + get().getActionService().scheduleAction(action, code, delayMs); + } + + public abstract ConversationListData createConversationListData(final Context context, + final ConversationListDataListener listener, final boolean archivedMode); + + public abstract ConversationData createConversationData(final Context context, + final ConversationDataListener listener, final String conversationId); + + public abstract ContactListItemData createContactListItemData(); + + public abstract ContactPickerData createContactPickerData(final Context context, + final ContactPickerDataListener listener); + + public abstract MediaPickerData createMediaPickerData(final Context context); + + public abstract GalleryGridItemData createGalleryGridItemData(); + + public abstract LaunchConversationData createLaunchConversationData( + LaunchConversationDataListener listener); + + public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context); + + public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId, + final Context context, final PeopleAndOptionsDataListener listener); + + public abstract VCardContactItemData createVCardContactItemData(final Context context, + final MessagePartData data); + + public abstract VCardContactItemData createVCardContactItemData(final Context context, + final Uri vCardUri); + + public abstract ParticipantListItemData createParticipantListItemData( + final ParticipantData participant); + + public abstract BlockedParticipantsData createBlockedParticipantsData(Context context, + BlockedParticipantsDataListener listener); + + public abstract SubscriptionListData createSubscriptonListData(Context context); + + public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener); + + public abstract DraftMessageData createDraftMessageData(String conversationId); + + public abstract ActionService getActionService(); + + public abstract BackgroundWorker getBackgroundWorkerForActionService(); + + @DoesNotRunOnMainThread + public abstract DatabaseWrapper getDatabase(); + + // Allow DataModel to coordinate with activity lifetime events. + public abstract void onActivityResume(); + + abstract void onCreateTables(final SQLiteDatabase db); + + public void setFocusedConversation(final String conversationId) { + mFocusedConversation = conversationId; + } + + public boolean isFocusedConversation(final String conversationId) { + return !TextUtils.isEmpty(mFocusedConversation) + && TextUtils.equals(mFocusedConversation, conversationId); + } + + public void setConversationListScrolledToNewestConversation( + final boolean scrolledToNewestConversation) { + mConversationListScrolledToNewestConversation = scrolledToNewestConversation; + } + + public boolean isConversationListScrolledToNewestConversation() { + return mConversationListScrolledToNewestConversation; + } + + /** + * If a new message is received in the specified conversation, will the user be able to + * observe it in some UI within the app? + * @param conversationId conversation with the new incoming message + */ + public boolean isNewMessageObservable(final String conversationId) { + return isConversationListScrolledToNewestConversation() + || isFocusedConversation(conversationId); + } + + public abstract void onApplicationCreated(); + + public abstract ConnectivityUtil getConnectivityUtil(); + + public abstract SyncManager getSyncManager(); +} diff --git a/src/com/android/messaging/datamodel/DataModelException.java b/src/com/android/messaging/datamodel/DataModelException.java new file mode 100644 index 0000000..7084438 --- /dev/null +++ b/src/com/android/messaging/datamodel/DataModelException.java @@ -0,0 +1,101 @@ +/* + * 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; + +public class DataModelException extends Exception { + private static final long serialVersionUID = 1L; + + private static final int FIRST = 100; + + // ERRORS GENERATED INTERNALLY BY DATA MODEL. + + // ERRORS RELATED WITH SMS. + public static final int ERROR_SMS_TEMPORARY_FAILURE = 116; + public static final int ERROR_SMS_PERMANENT_FAILURE = 117; + public static final int ERROR_MMS_TEMPORARY_FAILURE = 118; + public static final int ERROR_MMS_PERMANENT_UNKNOWN_FAILURE = 119; + + // Request expired. + public static final int ERROR_EXPIRED = 120; + // Request canceled by user. + public static final int ERROR_CANCELED = 121; + + public static final int ERROR_MOBILE_DATA_DISABLED = 123; + public static final int ERROR_MMS_SERVICE_BLOCKED = 124; + public static final int ERROR_MMS_INVALID_ADDRESS = 125; + public static final int ERROR_MMS_NETWORK_PROBLEM = 126; + public static final int ERROR_MMS_MESSAGE_NOT_FOUND = 127; + public static final int ERROR_MMS_MESSAGE_FORMAT_CORRUPT = 128; + public static final int ERROR_MMS_CONTENT_NOT_ACCEPTED = 129; + public static final int ERROR_MMS_MESSAGE_NOT_SUPPORTED = 130; + public static final int ERROR_MMS_REPLY_CHARGING_ERROR = 131; + public static final int ERROR_MMS_ADDRESS_HIDING_NOT_SUPPORTED = 132; + public static final int ERROR_MMS_LACK_OF_PREPAID = 133; + public static final int ERROR_MMS_CAN_NOT_PERSIST = 134; + public static final int ERROR_MMS_NO_AVAILABLE_APN = 135; + public static final int ERROR_MMS_INVALID_MESSAGE_TO_SEND = 136; + public static final int ERROR_MMS_INVALID_MESSAGE_RECEIVED = 137; + public static final int ERROR_MMS_NO_CONFIGURATION = 138; + + private static final int LAST = 138; + + private final boolean mIsInjection; + private final int mErrorCode; + private final String mMessage; + private final long mBackoff; + + public DataModelException(final int errorCode, final Exception innerException, + final long backoff, final boolean injection, final String message) { + // Since some of the exceptions passed in may not be serializable, only record message + // instead of setting inner exception for Exception class. Otherwise, we will get + // serialization issues when we pass ServerRequestException as intent extra later. + if (errorCode < FIRST || errorCode > LAST) { + throw new IllegalArgumentException("error code out of range: " + errorCode); + } + mIsInjection = injection; + mErrorCode = errorCode; + if (innerException != null) { + mMessage = innerException.getMessage() + " -- " + + (mIsInjection ? "[INJECTED] -- " : "") + message; + } else { + mMessage = (mIsInjection ? "[INJECTED] -- " : "") + message; + } + + mBackoff = backoff; + } + + public DataModelException(final int errorCode) { + this(errorCode, null, 0, false, null); + } + + public DataModelException(final int errorCode, final Exception innerException) { + this(errorCode, innerException, 0, false, null); + } + + public DataModelException(final int errorCode, final String message) { + this(errorCode, null, 0, false, message); + } + + @Override + public String getMessage() { + return mMessage; + } + + public int getErrorCode() { + return mErrorCode; + } +} diff --git a/src/com/android/messaging/datamodel/DataModelImpl.java b/src/com/android/messaging/datamodel/DataModelImpl.java new file mode 100644 index 0000000..6ab3f00 --- /dev/null +++ b/src/com/android/messaging/datamodel/DataModelImpl.java @@ -0,0 +1,236 @@ +/* + * 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; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.telephony.SubscriptionManager; + +import com.android.messaging.datamodel.action.ActionService; +import com.android.messaging.datamodel.action.BackgroundWorker; +import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction; +import com.android.messaging.datamodel.action.ProcessPendingMessagesAction; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ConnectivityUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class DataModelImpl extends DataModel { + private final Context mContext; + private final ActionService mActionService; + private final BackgroundWorker mDataModelWorker; + private final DatabaseHelper mDatabaseHelper; + private final ConnectivityUtil mConnectivityUtil; + private final SyncManager mSyncManager; + + public DataModelImpl(final Context context) { + super(); + mContext = context; + mActionService = new ActionService(); + mDataModelWorker = new BackgroundWorker(); + mDatabaseHelper = DatabaseHelper.getInstance(context); + mConnectivityUtil = new ConnectivityUtil(context); + mSyncManager = new SyncManager(); + } + + @Override + public ConversationListData createConversationListData(final Context context, + final ConversationListDataListener listener, final boolean archivedMode) { + return new ConversationListData(context, listener, archivedMode); + } + + @Override + public ConversationData createConversationData(final Context context, + final ConversationDataListener listener, final String conversationId) { + return new ConversationData(context, listener, conversationId); + } + + @Override + public ContactListItemData createContactListItemData() { + return new ContactListItemData(); + } + + @Override + public ContactPickerData createContactPickerData(final Context context, + final ContactPickerDataListener listener) { + return new ContactPickerData(context, listener); + } + + @Override + public BlockedParticipantsData createBlockedParticipantsData( + final Context context, final BlockedParticipantsDataListener listener) { + return new BlockedParticipantsData(context, listener); + } + + @Override + public MediaPickerData createMediaPickerData(final Context context) { + return new MediaPickerData(context); + } + + @Override + public GalleryGridItemData createGalleryGridItemData() { + return new GalleryGridItemData(); + } + + @Override + public LaunchConversationData createLaunchConversationData( + final LaunchConversationDataListener listener) { + return new LaunchConversationData(listener); + } + + @Override + public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) { + return new PeopleOptionsItemData(context); + } + + @Override + public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId, + final Context context, final PeopleAndOptionsDataListener listener) { + return new PeopleAndOptionsData(conversationId, context, listener); + } + + @Override + public VCardContactItemData createVCardContactItemData(final Context context, + final MessagePartData data) { + return new VCardContactItemData(context, data); + } + + @Override + public VCardContactItemData createVCardContactItemData(final Context context, + final Uri vCardUri) { + return new VCardContactItemData(context, vCardUri); + } + + @Override + public ParticipantListItemData createParticipantListItemData( + final ParticipantData participant) { + return new ParticipantListItemData(participant); + } + + @Override + public SubscriptionListData createSubscriptonListData(Context context) { + return new SubscriptionListData(context); + } + + @Override + public SettingsData createSettingsData(Context context, SettingsDataListener listener) { + return new SettingsData(context, listener); + } + + @Override + public DraftMessageData createDraftMessageData(String conversationId) { + return new DraftMessageData(conversationId); + } + + @Override + public ActionService getActionService() { + // We need to allow access to this on the UI thread since it's used to start actions. + return mActionService; + } + + @Override + public BackgroundWorker getBackgroundWorkerForActionService() { + return mDataModelWorker; + } + + @Override + @DoesNotRunOnMainThread + public DatabaseWrapper getDatabase() { + // We prevent the main UI thread from accessing the database since we have to allow + // public access to this class to enable sub-packages to access data. + Assert.isNotMainThread(); + return mDatabaseHelper.getDatabase(); + } + + @Override + public ConnectivityUtil getConnectivityUtil() { + return mConnectivityUtil; + } + + @Override + public SyncManager getSyncManager() { + return mSyncManager; + } + + @Override + void onCreateTables(final SQLiteDatabase db) { + LogUtil.w(LogUtil.BUGLE_TAG, "Rebuilt databases: reseting related state"); + // Clear other things that implicitly reference the DB + SyncManager.resetLastSyncTimestamps(); + } + + @Override + public void onActivityResume() { + // Perform an incremental sync and register for changes if necessary + mSyncManager.updateSyncObserver(mContext); + + // Trigger a participant refresh if needed, we should only need to refresh if there is + // contact change while the activity was paused. + ParticipantRefresh.refreshParticipantsIfNeeded(); + } + + @Override + public void onApplicationCreated() { + FixupMessageStatusOnStartupAction.fixupMessageStatus(); + ProcessPendingMessagesAction.processFirstPendingMessage(); + SyncManager.immediateSync(); + + if (OsUtil.isAtLeastL_MR1()) { + // Start listening for subscription change events for refreshing self participants. + PhoneUtils.getDefault().toLMr1().registerOnSubscriptionsChangedListener( + new SubscriptionManager.OnSubscriptionsChangedListener() { + @Override + public void onSubscriptionsChanged() { + // TODO: This dynamically changes the mms config that app is + // currently using. It may cause inconsistency in some cases. We need + // to check the usage of mms config and handle the dynamic change + // gracefully + MmsConfig.loadAsync(); + ParticipantRefresh.refreshSelfParticipants(); + } + }); + } + } +} diff --git a/src/com/android/messaging/datamodel/DatabaseHelper.java b/src/com/android/messaging/datamodel/DatabaseHelper.java new file mode 100644 index 0000000..f16bb3c --- /dev/null +++ b/src/com/android/messaging/datamodel/DatabaseHelper.java @@ -0,0 +1,813 @@ +/* + * 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; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.provider.BaseColumns; + +import com.android.messaging.BugleApplication; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * TODO: Open Issues: + * - Should we be storing the draft messages in the regular messages table or should we have a + * separate table for drafts to keep the normal messages query as simple as possible? + */ + +/** + * Allows access to the SQL database. This is package private. + */ +public class DatabaseHelper extends SQLiteOpenHelper { + public static final String DATABASE_NAME = "bugle_db"; + + private static final int getDatabaseVersion(final Context context) { + return Integer.parseInt(context.getResources().getString(R.string.database_version)); + } + + /** Table containing names of all other tables and views */ + private static final String MASTER_TABLE = "sqlite_master"; + /** Column containing the name of the tables and views */ + private static final String[] MASTER_COLUMNS = new String[] { "name", }; + + // Table names + public static final String CONVERSATIONS_TABLE = "conversations"; + public static final String MESSAGES_TABLE = "messages"; + public static final String PARTS_TABLE = "parts"; + public static final String PARTICIPANTS_TABLE = "participants"; + public static final String CONVERSATION_PARTICIPANTS_TABLE = "conversation_participants"; + + // Views + static final String DRAFT_PARTS_VIEW = "draft_parts_view"; + + // Conversations table schema + public static class ConversationColumns implements BaseColumns { + /* SMS/MMS Thread ID from the system provider */ + public static final String SMS_THREAD_ID = "sms_thread_id"; + + /* Display name for the conversation */ + public static final String NAME = "name"; + + /* Latest Message ID for the read status to display in conversation list */ + public static final String LATEST_MESSAGE_ID = "latest_message_id"; + + /* Latest text snippet for display in conversation list */ + public static final String SNIPPET_TEXT = "snippet_text"; + + /* Latest text subject for display in conversation list, empty string if none exists */ + public static final String SUBJECT_TEXT = "subject_text"; + + /* Preview Uri */ + public static final String PREVIEW_URI = "preview_uri"; + + /* The preview uri's content type */ + public static final String PREVIEW_CONTENT_TYPE = "preview_content_type"; + + /* If we should display the current draft snippet/preview pair or snippet/preview pair */ + public static final String SHOW_DRAFT = "show_draft"; + + /* Latest draft text subject for display in conversation list, empty string if none exists*/ + public static final String DRAFT_SUBJECT_TEXT = "draft_subject_text"; + + /* Latest draft text snippet for display, empty string if none exists */ + public static final String DRAFT_SNIPPET_TEXT = "draft_snippet_text"; + + /* Draft Preview Uri, empty string if none exists */ + public static final String DRAFT_PREVIEW_URI = "draft_preview_uri"; + + /* The preview uri's content type */ + public static final String DRAFT_PREVIEW_CONTENT_TYPE = "draft_preview_content_type"; + + /* If this conversation is archived */ + public static final String ARCHIVE_STATUS = "archive_status"; + + /* Timestamp for sorting purposes */ + public static final String SORT_TIMESTAMP = "sort_timestamp"; + + /* Last read message timestamp */ + public static final String LAST_READ_TIMESTAMP = "last_read_timestamp"; + + /* Avatar for the conversation. Could be for group of individual */ + public static final String ICON = "icon"; + + /* Participant contact ID if this conversation has a single participant. -1 otherwise */ + public static final String PARTICIPANT_CONTACT_ID = "participant_contact_id"; + + /* Participant lookup key if this conversation has a single participant. null otherwise */ + public static final String PARTICIPANT_LOOKUP_KEY = "participant_lookup_key"; + + /* + * Participant's normalized destination if this conversation has a single participant. + * null otherwise. + */ + public static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION = + "participant_normalized_destination"; + + /* Default self participant for the conversation */ + public static final String CURRENT_SELF_ID = "current_self_id"; + + /* Participant count not including self (so will be 1 for 1:1 or bigger for group) */ + public static final String PARTICIPANT_COUNT = "participant_count"; + + /* Should notifications be enabled for this conversation? */ + public static final String NOTIFICATION_ENABLED = "notification_enabled"; + + /* Notification sound used for the conversation */ + public static final String NOTIFICATION_SOUND_URI = "notification_sound_uri"; + + /* Should vibrations be enabled for the conversation's notification? */ + public static final String NOTIFICATION_VIBRATION = "notification_vibration"; + + /* Conversation recipients include email address */ + public static final String INCLUDE_EMAIL_ADDRESS = "include_email_addr"; + + // Record the last received sms's service center info if it indicates that the reply path + // is present (TP-Reply-Path), so that we could use it for the subsequent message to send. + // Refer to TS 23.040 D.6 and SmsMessageSender.java in Android Messaging app. + public static final String SMS_SERVICE_CENTER = "sms_service_center"; + } + + // Conversation table SQL + private static final String CREATE_CONVERSATIONS_TABLE_SQL = + "CREATE TABLE " + CONVERSATIONS_TABLE + "(" + + ConversationColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + // TODO : Int? Required not default? + + ConversationColumns.SMS_THREAD_ID + " INT DEFAULT(0), " + + ConversationColumns.NAME + " TEXT, " + + ConversationColumns.LATEST_MESSAGE_ID + " INT, " + + ConversationColumns.SNIPPET_TEXT + " TEXT, " + + ConversationColumns.SUBJECT_TEXT + " TEXT, " + + ConversationColumns.PREVIEW_URI + " TEXT, " + + ConversationColumns.PREVIEW_CONTENT_TYPE + " TEXT, " + + ConversationColumns.SHOW_DRAFT + " INT DEFAULT(0), " + + ConversationColumns.DRAFT_SNIPPET_TEXT + " TEXT, " + + ConversationColumns.DRAFT_SUBJECT_TEXT + " TEXT, " + + ConversationColumns.DRAFT_PREVIEW_URI + " TEXT, " + + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, " + + ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), " + + ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), " + + ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), " + + ConversationColumns.ICON + " TEXT, " + + ConversationColumns.PARTICIPANT_CONTACT_ID + " INT DEFAULT ( " + + ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), " + + ConversationColumns.PARTICIPANT_LOOKUP_KEY + " TEXT, " + + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + " TEXT, " + + ConversationColumns.CURRENT_SELF_ID + " TEXT, " + + ConversationColumns.PARTICIPANT_COUNT + " INT DEFAULT(0), " + + ConversationColumns.NOTIFICATION_ENABLED + " INT DEFAULT(1), " + + ConversationColumns.NOTIFICATION_SOUND_URI + " TEXT, " + + ConversationColumns.NOTIFICATION_VIBRATION + " INT DEFAULT(1), " + + ConversationColumns.INCLUDE_EMAIL_ADDRESS + " INT DEFAULT(0), " + + ConversationColumns.SMS_SERVICE_CENTER + " TEXT " + + ");"; + + private static final String CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SMS_THREAD_ID + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.SMS_THREAD_ID + ")"; + + private static final String CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.ARCHIVE_STATUS + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.ARCHIVE_STATUS + ")"; + + private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.SORT_TIMESTAMP + ")"; + + // Messages table schema + public static class MessageColumns implements BaseColumns { + /* conversation id that this message belongs to */ + public static final String CONVERSATION_ID = "conversation_id"; + + /* participant which send this message */ + public static final String SENDER_PARTICIPANT_ID = "sender_id"; + + /* This is bugle's internal status for the message */ + public static final String STATUS = "message_status"; + + /* Type of message: SMS, MMS or MMS notification */ + public static final String PROTOCOL = "message_protocol"; + + /* This is the time that the sender sent the message */ + public static final String SENT_TIMESTAMP = "sent_timestamp"; + + /* Time that we received the message on this device */ + public static final String RECEIVED_TIMESTAMP = "received_timestamp"; + + /* When the message has been seen by a user in a notification */ + public static final String SEEN = "seen"; + + /* When the message has been read by a user */ + public static final String READ = "read"; + + /* participant representing the sim which processed this message */ + public static final String SELF_PARTICIPANT_ID = "self_id"; + + /* + * Time when a retry is initiated. This is used to compute the retry window + * when we retry sending/downloading a message. + */ + public static final String RETRY_START_TIMESTAMP = "retry_start_timestamp"; + + // Columns which map to the SMS provider + + /* Message ID from the platform provider */ + public static final String SMS_MESSAGE_URI = "sms_message_uri"; + + /* The message priority for MMS message */ + public static final String SMS_PRIORITY = "sms_priority"; + + /* The message size for MMS message */ + public static final String SMS_MESSAGE_SIZE = "sms_message_size"; + + /* The subject for MMS message */ + public static final String MMS_SUBJECT = "mms_subject"; + + /* Transaction id for MMS notificaiton */ + public static final String MMS_TRANSACTION_ID = "mms_transaction_id"; + + /* Content location for MMS notificaiton */ + public static final String MMS_CONTENT_LOCATION = "mms_content_location"; + + /* The expiry time (ms) for MMS message */ + public static final String MMS_EXPIRY = "mms_expiry"; + + /* The detailed status (RESPONSE_STATUS or RETRIEVE_STATUS) for MMS message */ + public static final String RAW_TELEPHONY_STATUS = "raw_status"; + } + + // Messages table SQL + private static final String CREATE_MESSAGES_TABLE_SQL = + "CREATE TABLE " + MESSAGES_TABLE + " (" + + MessageColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + MessageColumns.CONVERSATION_ID + " INT, " + + MessageColumns.SENDER_PARTICIPANT_ID + " INT, " + + MessageColumns.SENT_TIMESTAMP + " INT DEFAULT(0), " + + MessageColumns.RECEIVED_TIMESTAMP + " INT DEFAULT(0), " + + MessageColumns.PROTOCOL + " INT DEFAULT(0), " + + MessageColumns.STATUS + " INT DEFAULT(0), " + + MessageColumns.SEEN + " INT DEFAULT(0), " + + MessageColumns.READ + " INT DEFAULT(0), " + + MessageColumns.SMS_MESSAGE_URI + " TEXT, " + + MessageColumns.SMS_PRIORITY + " INT DEFAULT(0), " + + MessageColumns.SMS_MESSAGE_SIZE + " INT DEFAULT(0), " + + MessageColumns.MMS_SUBJECT + " TEXT, " + + MessageColumns.MMS_TRANSACTION_ID + " TEXT, " + + MessageColumns.MMS_CONTENT_LOCATION + " TEXT, " + + MessageColumns.MMS_EXPIRY + " INT DEFAULT(0), " + + MessageColumns.RAW_TELEPHONY_STATUS + " INT DEFAULT(0), " + + MessageColumns.SELF_PARTICIPANT_ID + " INT, " + + MessageColumns.RETRY_START_TIMESTAMP + " INT DEFAULT(0), " + + "FOREIGN KEY (" + MessageColumns.CONVERSATION_ID + ") REFERENCES " + + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE " + + "FOREIGN KEY (" + MessageColumns.SENDER_PARTICIPANT_ID + ") REFERENCES " + + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL " + + "FOREIGN KEY (" + MessageColumns.SELF_PARTICIPANT_ID + ") REFERENCES " + + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL " + + ");"; + + // Primary sort index for messages table : by conversation id, status, received timestamp. + private static final String MESSAGES_TABLE_SORT_INDEX_SQL = + "CREATE INDEX index_" + MESSAGES_TABLE + "_sort ON " + MESSAGES_TABLE + "(" + + MessageColumns.CONVERSATION_ID + ", " + + MessageColumns.STATUS + ", " + + MessageColumns.RECEIVED_TIMESTAMP + ")"; + + private static final String MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL = + "CREATE INDEX index_" + MESSAGES_TABLE + "_status_seen ON " + MESSAGES_TABLE + "(" + + MessageColumns.STATUS + ", " + + MessageColumns.SEEN + ")"; + + // Parts table schema + // A part may contain text or a media url, but not both. + public static class PartColumns implements BaseColumns { + /* message id that this part belongs to */ + public static final String MESSAGE_ID = "message_id"; + + /* conversation id that this part belongs to */ + public static final String CONVERSATION_ID = "conversation_id"; + + /* text for this part */ + public static final String TEXT = "text"; + + /* content uri for this part */ + public static final String CONTENT_URI = "uri"; + + /* content type for this part */ + public static final String CONTENT_TYPE = "content_type"; + + /* cached width for this part (for layout while loading) */ + public static final String WIDTH = "width"; + + /* cached height for this part (for layout while loading) */ + public static final String HEIGHT = "height"; + + /* de-normalized copy of timestamp from the messages table. This is populated + * via an insert trigger on the parts table. + */ + public static final String TIMESTAMP = "timestamp"; + } + + // Message part table SQL + private static final String CREATE_PARTS_TABLE_SQL = + "CREATE TABLE " + PARTS_TABLE + "(" + + PartColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + PartColumns.MESSAGE_ID + " INT," + + PartColumns.TEXT + " TEXT," + + PartColumns.CONTENT_URI + " TEXT," + + PartColumns.CONTENT_TYPE + " TEXT," + + PartColumns.WIDTH + " INT DEFAULT(" + + MessagingContentProvider.UNSPECIFIED_SIZE + ")," + + PartColumns.HEIGHT + " INT DEFAULT(" + + MessagingContentProvider.UNSPECIFIED_SIZE + ")," + + PartColumns.TIMESTAMP + " INT, " + + PartColumns.CONVERSATION_ID + " INT NOT NULL," + + "FOREIGN KEY (" + PartColumns.MESSAGE_ID + ") REFERENCES " + + MESSAGES_TABLE + "(" + MessageColumns._ID + ") ON DELETE CASCADE " + + "FOREIGN KEY (" + PartColumns.CONVERSATION_ID + ") REFERENCES " + + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE " + + ");"; + + public static final String CREATE_PARTS_TRIGGER_SQL = + "CREATE TRIGGER " + PARTS_TABLE + "_TRIGGER" + " AFTER INSERT ON " + PARTS_TABLE + + " FOR EACH ROW " + + " BEGIN UPDATE " + PARTS_TABLE + + " SET " + PartColumns.TIMESTAMP + "=" + + " (SELECT received_timestamp FROM " + MESSAGES_TABLE + " WHERE " + MESSAGES_TABLE + + "." + MessageColumns._ID + "=" + "NEW." + PartColumns.MESSAGE_ID + ")" + + " WHERE " + PARTS_TABLE + "." + PartColumns._ID + "=" + "NEW." + PartColumns._ID + + "; END"; + + public static final String CREATE_MESSAGES_TRIGGER_SQL = + "CREATE TRIGGER " + MESSAGES_TABLE + "_TRIGGER" + " AFTER UPDATE OF " + + MessageColumns.RECEIVED_TIMESTAMP + " ON " + MESSAGES_TABLE + + " FOR EACH ROW BEGIN UPDATE " + PARTS_TABLE + " SET " + PartColumns.TIMESTAMP + + " = NEW." + MessageColumns.RECEIVED_TIMESTAMP + " WHERE " + PARTS_TABLE + "." + + PartColumns.MESSAGE_ID + " = NEW." + MessageColumns._ID + + "; END;"; + + // Primary sort index for parts table : by message_id + private static final String PARTS_TABLE_MESSAGE_INDEX_SQL = + "CREATE INDEX index_" + PARTS_TABLE + "_message_id ON " + PARTS_TABLE + "(" + + PartColumns.MESSAGE_ID + ")"; + + // Participants table schema + public static class ParticipantColumns implements BaseColumns { + /* The subscription id for the sim associated with this self participant. + * Introduced in L. For earlier versions will always be default_sub_id (-1). + * For multi sim devices (or cases where the sim was changed) single device + * may have several different sub_id values */ + public static final String SUB_ID = "sub_id"; + + /* The slot of the active SIM (inserted in the device) for this self-participant. If the + * self-participant doesn't correspond to any active SIM, this will be + * {@link android.telephony.SubscriptionManager#INVALID_SLOT_ID}. + * The column is ignored for all non-self participants. + */ + public static final String SIM_SLOT_ID = "sim_slot_id"; + + /* The phone number stored in a standard E164 format if possible. This is unique for a + * given participant. We can't handle multiple participants with the same phone number + * since we don't know which of them a message comes from. This can also be an email + * address, in which case this is the same as the displayed address */ + public static final String NORMALIZED_DESTINATION = "normalized_destination"; + + /* The phone number as originally supplied and used for dialing. Not necessarily in E164 + * format or unique */ + public static final String SEND_DESTINATION = "send_destination"; + + /* The user-friendly formatting of the phone number according to the region setting of + * the device when the row was added. */ + public static final String DISPLAY_DESTINATION = "display_destination"; + + /* A string with this participant's full name or a pretty printed phone number */ + public static final String FULL_NAME = "full_name"; + + /* A string with just this participant's first name */ + public static final String FIRST_NAME = "first_name"; + + /* A local URI to an asset for the icon for this participant */ + public static final String PROFILE_PHOTO_URI = "profile_photo_uri"; + + /* Contact id for matching local contact for this participant */ + public static final String CONTACT_ID = "contact_id"; + + /* String that contains hints on how to find contact information in a contact lookup */ + public static final String LOOKUP_KEY = "lookup_key"; + + /* If this participant is blocked */ + public static final String BLOCKED = "blocked"; + + /* The color of the subscription (FOR SELF PARTICIPANTS ONLY) */ + public static final String SUBSCRIPTION_COLOR = "subscription_color"; + + /* The name of the subscription (FOR SELF PARTICIPANTS ONLY) */ + public static final String SUBSCRIPTION_NAME = "subscription_name"; + + /* The exact destination stored in Contacts for this participant */ + public static final String CONTACT_DESTINATION = "contact_destination"; + } + + // Participants table SQL + private static final String CREATE_PARTICIPANTS_TABLE_SQL = + "CREATE TABLE " + PARTICIPANTS_TABLE + "(" + + ParticipantColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + ParticipantColumns.SUB_ID + " INT DEFAULT(" + + ParticipantData.OTHER_THAN_SELF_SUB_ID + ")," + + ParticipantColumns.SIM_SLOT_ID + " INT DEFAULT(" + + ParticipantData.INVALID_SLOT_ID + ")," + + ParticipantColumns.NORMALIZED_DESTINATION + " TEXT," + + ParticipantColumns.SEND_DESTINATION + " TEXT," + + ParticipantColumns.DISPLAY_DESTINATION + " TEXT," + + ParticipantColumns.FULL_NAME + " TEXT," + + ParticipantColumns.FIRST_NAME + " TEXT," + + ParticipantColumns.PROFILE_PHOTO_URI + " TEXT, " + + ParticipantColumns.CONTACT_ID + " INT DEFAULT( " + + ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), " + + ParticipantColumns.LOOKUP_KEY + " STRING, " + + ParticipantColumns.BLOCKED + " INT DEFAULT(0), " + + ParticipantColumns.SUBSCRIPTION_NAME + " TEXT, " + + ParticipantColumns.SUBSCRIPTION_COLOR + " INT DEFAULT(0), " + + ParticipantColumns.CONTACT_DESTINATION + " TEXT, " + + "UNIQUE (" + ParticipantColumns.NORMALIZED_DESTINATION + ", " + + ParticipantColumns.SUB_ID + ") ON CONFLICT FAIL" + ");"; + + private static final String CREATE_SELF_PARTICIPANT_SQL = + "INSERT INTO " + PARTICIPANTS_TABLE + + " ( " + ParticipantColumns.SUB_ID + " ) VALUES ( %s )"; + + static String getCreateSelfParticipantSql(int subId) { + return String.format(CREATE_SELF_PARTICIPANT_SQL, subId); + } + + // Conversation Participants table schema - contains a list of participants excluding the user + // in a given conversation. + public static class ConversationParticipantsColumns implements BaseColumns { + /* participant id of someone in this conversation */ + public static final String PARTICIPANT_ID = "participant_id"; + + /* conversation id that this participant belongs to */ + public static final String CONVERSATION_ID = "conversation_id"; + } + + // Conversation Participants table SQL + private static final String CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL = + "CREATE TABLE " + CONVERSATION_PARTICIPANTS_TABLE + "(" + + ConversationParticipantsColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + ConversationParticipantsColumns.CONVERSATION_ID + " INT," + + ConversationParticipantsColumns.PARTICIPANT_ID + " INT," + + "UNIQUE (" + ConversationParticipantsColumns.CONVERSATION_ID + "," + + ConversationParticipantsColumns.PARTICIPANT_ID + ") ON CONFLICT FAIL, " + + "FOREIGN KEY (" + ConversationParticipantsColumns.CONVERSATION_ID + ") " + + "REFERENCES " + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ")" + + " ON DELETE CASCADE " + + "FOREIGN KEY (" + ConversationParticipantsColumns.PARTICIPANT_ID + ")" + + " REFERENCES " + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + "));"; + + // Primary access pattern for conversation participants is to look them up for a specific + // conversation. + private static final String CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATION_PARTICIPANTS_TABLE + "_" + + ConversationParticipantsColumns.CONVERSATION_ID + + " ON " + CONVERSATION_PARTICIPANTS_TABLE + + "(" + ConversationParticipantsColumns.CONVERSATION_ID + ")"; + + // View for getting parts which are for draft messages. + static final String DRAFT_PARTS_VIEW_SQL = "CREATE VIEW " + + DRAFT_PARTS_VIEW + " AS SELECT " + + PARTS_TABLE + '.' + PartColumns._ID + + " as " + PartColumns._ID + ", " + + PARTS_TABLE + '.' + PartColumns.MESSAGE_ID + + " as " + PartColumns.MESSAGE_ID + ", " + + PARTS_TABLE + '.' + PartColumns.TEXT + + " as " + PartColumns.TEXT + ", " + + PARTS_TABLE + '.' + PartColumns.CONTENT_URI + + " as " + PartColumns.CONTENT_URI + ", " + + PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE + + " as " + PartColumns.CONTENT_TYPE + ", " + + PARTS_TABLE + '.' + PartColumns.WIDTH + + " as " + PartColumns.WIDTH + ", " + + PARTS_TABLE + '.' + PartColumns.HEIGHT + + " as " + PartColumns.HEIGHT + ", " + + MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID + + " as " + MessageColumns.CONVERSATION_ID + " " + + " FROM " + MESSAGES_TABLE + " LEFT JOIN " + PARTS_TABLE + " ON (" + + MESSAGES_TABLE + "." + MessageColumns._ID + + "=" + PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ")" + // Exclude draft messages from main view + + " WHERE " + MESSAGES_TABLE + "." + MessageColumns.STATUS + + " = " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT; + + // List of all our SQL tables + private static final String[] CREATE_TABLE_SQLS = new String[] { + CREATE_CONVERSATIONS_TABLE_SQL, + CREATE_MESSAGES_TABLE_SQL, + CREATE_PARTS_TABLE_SQL, + CREATE_PARTICIPANTS_TABLE_SQL, + CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL, + }; + + // List of all our indices + private static final String[] CREATE_INDEX_SQLS = new String[] { + CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL, + CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL, + CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL, + MESSAGES_TABLE_SORT_INDEX_SQL, + MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL, + PARTS_TABLE_MESSAGE_INDEX_SQL, + CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL, + }; + + // List of all our SQL triggers + private static final String[] CREATE_TRIGGER_SQLS = new String[] { + CREATE_PARTS_TRIGGER_SQL, + CREATE_MESSAGES_TRIGGER_SQL, + }; + + // List of all our views + private static final String[] CREATE_VIEW_SQLS = new String[] { + ConversationListItemData.getConversationListViewSql(), + ConversationImagePartsView.getCreateSql(), + DRAFT_PARTS_VIEW_SQL, + }; + + private static final Object sLock = new Object(); + private final Context mApplicationContext; + private static DatabaseHelper sHelperInstance; // Protected by sLock. + + private final Object mDatabaseWrapperLock = new Object(); + private DatabaseWrapper mDatabaseWrapper; // Protected by mDatabaseWrapperLock. + private final DatabaseUpgradeHelper mUpgradeHelper = new DatabaseUpgradeHelper(); + + /** + * Get a (singleton) instance of {@link DatabaseHelper}, creating one if there isn't one yet. + * This is the only public method for getting a new instance of the class. + * @param context Should be the application context (or something that will live for the + * lifetime of the application). + * @return The current (or a new) DatabaseHelper instance. + */ + public static DatabaseHelper getInstance(final Context context) { + synchronized (sLock) { + if (sHelperInstance == null) { + sHelperInstance = new DatabaseHelper(context); + } + return sHelperInstance; + } + } + + /** + * Private constructor, used from {@link #getInstance()}. + * @param context Should be the application context (or something that will live for the + * lifetime of the application). + */ + private DatabaseHelper(final Context context) { + super(context, DATABASE_NAME, null, getDatabaseVersion(context), null); + mApplicationContext = context; + } + + /** + * Test method that always instantiates a new DatabaseHelper instance. This should + * be used ONLY by the tests and never by the real application. + * @param context Test context. + * @return Brand new DatabaseHelper instance. + */ + @VisibleForTesting + static DatabaseHelper getNewInstanceForTest(final Context context) { + Assert.isEngBuild(); + Assert.isTrue(BugleApplication.isRunningTests()); + return new DatabaseHelper(context); + } + + /** + * Get the (singleton) instance of @{link DatabaseWrapper}. + * <p>The database is always opened as a writeable database. + * @return The current (or a new) DatabaseWrapper instance. + */ + @DoesNotRunOnMainThread + DatabaseWrapper getDatabase() { + // We prevent the main UI thread from accessing the database here since we have to allow + // public access to this class to enable sub-packages to access data. + Assert.isNotMainThread(); + + synchronized (mDatabaseWrapperLock) { + if (mDatabaseWrapper == null) { + mDatabaseWrapper = new DatabaseWrapper(mApplicationContext, getWritableDatabase()); + } + return mDatabaseWrapper; + } + } + + @Override + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + mUpgradeHelper.onDowngrade(db, oldVersion, newVersion); + } + + /** + * Drops and recreates all tables. + */ + public static void rebuildTables(final SQLiteDatabase db) { + // Drop tables first, then views, and indices. + dropAllTables(db); + dropAllViews(db); + dropAllIndexes(db); + dropAllTriggers(db); + + // Recreate the whole database. + createDatabase(db); + } + + /** + * Drop and rebuild a given view. + */ + static void rebuildView(final SQLiteDatabase db, final String viewName, + final String createViewSql) { + dropView(db, viewName, true /* throwOnFailure */); + db.execSQL(createViewSql); + } + + private static void dropView(final SQLiteDatabase db, final String viewName, + final boolean throwOnFailure) { + final String dropPrefix = "DROP VIEW IF EXISTS "; + try { + db.execSQL(dropPrefix + viewName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop view " + viewName + " " + + ex); + } + + if (throwOnFailure) { + throw ex; + } + } + } + + /** + * Drops all user-defined tables from the given database. + */ + private static void dropAllTables(final SQLiteDatabase db) { + final Cursor tableCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='table'", null, null, null, null); + if (tableCursor != null) { + try { + final String dropPrefix = "DROP TABLE IF EXISTS "; + while (tableCursor.moveToNext()) { + final String tableName = tableCursor.getString(0); + + // Skip special tables + if (tableName.startsWith("android_") || tableName.startsWith("sqlite_")) { + continue; + } + try { + db.execSQL(dropPrefix + tableName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop table " + tableName + " " + + ex); + } + } + } + } finally { + tableCursor.close(); + } + } + } + + /** + * Drops all user-defined triggers from the given database. + */ + private static void dropAllTriggers(final SQLiteDatabase db) { + final Cursor triggerCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='trigger'", null, null, null, null); + if (triggerCursor != null) { + try { + final String dropPrefix = "DROP TRIGGER IF EXISTS "; + while (triggerCursor.moveToNext()) { + final String triggerName = triggerCursor.getString(0); + + // Skip special tables + if (triggerName.startsWith("android_") || triggerName.startsWith("sqlite_")) { + continue; + } + try { + db.execSQL(dropPrefix + triggerName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop trigger " + triggerName + + " " + ex); + } + } + } + } finally { + triggerCursor.close(); + } + } + } + + /** + * Drops all user-defined views from the given database. + */ + private static void dropAllViews(final SQLiteDatabase db) { + final Cursor viewCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='view'", null, null, null, null); + if (viewCursor != null) { + try { + while (viewCursor.moveToNext()) { + final String viewName = viewCursor.getString(0); + dropView(db, viewName, false /* throwOnFailure */); + } + } finally { + viewCursor.close(); + } + } + } + + /** + * Drops all user-defined views from the given database. + */ + private static void dropAllIndexes(final SQLiteDatabase db) { + final Cursor indexCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='index'", null, null, null, null); + if (indexCursor != null) { + try { + final String dropPrefix = "DROP INDEX IF EXISTS "; + while (indexCursor.moveToNext()) { + final String indexName = indexCursor.getString(0); + try { + db.execSQL(dropPrefix + indexName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop index " + indexName + " " + + ex); + } + } + } + } finally { + indexCursor.close(); + } + } + } + + private static void createDatabase(final SQLiteDatabase db) { + for (final String sql : CREATE_TABLE_SQLS) { + db.execSQL(sql); + } + + for (final String sql : CREATE_INDEX_SQLS) { + db.execSQL(sql); + } + + for (final String sql : CREATE_VIEW_SQLS) { + db.execSQL(sql); + } + + for (final String sql : CREATE_TRIGGER_SQLS) { + db.execSQL(sql); + } + + // Enable foreign key constraints + db.execSQL("PRAGMA foreign_keys=ON;"); + + // Add the default self participant. The default self will be assigned a proper slot id + // during participant refresh. + db.execSQL(getCreateSelfParticipantSql(ParticipantData.DEFAULT_SELF_SUB_ID)); + + DataModel.get().onCreateTables(db); + } + + @Override + public void onCreate(SQLiteDatabase db) { + createDatabase(db); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + mUpgradeHelper.doOnUpgrade(db, oldVersion, newVersion); + } +} diff --git a/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java b/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java new file mode 100644 index 0000000..d112533 --- /dev/null +++ b/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java @@ -0,0 +1,42 @@ +/* + * 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; + +import android.database.sqlite.SQLiteDatabase; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +public class DatabaseUpgradeHelper { + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + public void doOnUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + Assert.isTrue(newVersion >= oldVersion); + if (oldVersion == newVersion) { + return; + } + + LogUtil.i(TAG, "Database upgrade started from version " + oldVersion + " to " + newVersion); + + // Add future upgrade code here + } + + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + DatabaseHelper.rebuildTables(db); + LogUtil.e(TAG, "Database downgrade requested for version " + + oldVersion + " version " + newVersion + ", forcing db rebuild!"); + } +} diff --git a/src/com/android/messaging/datamodel/DatabaseWrapper.java b/src/com/android/messaging/datamodel/DatabaseWrapper.java new file mode 100644 index 0000000..ca7a331 --- /dev/null +++ b/src/com/android/messaging/datamodel/DatabaseWrapper.java @@ -0,0 +1,482 @@ +/* + * 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; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteFullException; +import android.database.sqlite.SQLiteQueryBuilder; +import android.database.sqlite.SQLiteStatement; +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +import java.util.Locale; +import java.util.Stack; +import java.util.regex.Pattern; + +public class DatabaseWrapper { + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + private final SQLiteDatabase mDatabase; + private final Context mContext; + private final boolean mLog; + /** + * Set mExplainQueryPlanRegexp (via {@link BugleGservicesKeys#EXPLAIN_QUERY_PLAN_REGEXP} + * to regex matching queries to see query plans. For example, ".*" to show all query plans. + */ + // See + private final String mExplainQueryPlanRegexp; + private static final int sTimingThreshold = 50; // in milliseconds + + public static final int INDEX_INSERT_MESSAGE_PART = 0; + public static final int INDEX_INSERT_MESSAGE = 1; + public static final int INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE = 2; + public static final int INDEX_QUERY_MESSAGES_LATEST_MESSAGE = 3; + + private final SparseArray<SQLiteStatement> mCompiledStatements; + + static class TransactionData { + long time; + boolean transactionSuccessful; + } + + // track transaction on a per thread basis + private static ThreadLocal<Stack<TransactionData>> sTransactionDepth = + new ThreadLocal<Stack<TransactionData>>() { + @Override + public Stack<TransactionData> initialValue() { + return new Stack<TransactionData>(); + } + }; + + private static String[] sFormatStrings = new String[] { + "took %d ms to %s", + " took %d ms to %s", + " took %d ms to %s", + }; + + DatabaseWrapper(final Context context, final SQLiteDatabase db) { + mLog = LogUtil.isLoggable(LogUtil.BUGLE_DATABASE_PERF_TAG, LogUtil.VERBOSE); + mExplainQueryPlanRegexp = Factory.get().getBugleGservices().getString( + BugleGservicesKeys.EXPLAIN_QUERY_PLAN_REGEXP, null); + mDatabase = db; + mContext = context; + mCompiledStatements = new SparseArray<SQLiteStatement>(); + } + + public SQLiteStatement getStatementInTransaction(final int index, final String statement) { + // Use transaction to serialize access to statements + Assert.isTrue(mDatabase.inTransaction()); + SQLiteStatement compiled = mCompiledStatements.get(index); + if (compiled == null) { + compiled = mDatabase.compileStatement(statement); + Assert.isTrue(compiled.toString().contains(statement.trim())); + mCompiledStatements.put(index, compiled); + } + return compiled; + } + + private void maybePlayDebugNoise() { + DebugUtils.maybePlayDebugNoise(mContext, DebugUtils.DEBUG_SOUND_DB_OP); + } + + private static void printTiming(final long t1, final String msg) { + final int transactionDepth = sTransactionDepth.get().size(); + final long t2 = System.currentTimeMillis(); + final long delta = t2 - t1; + if (delta > sTimingThreshold) { + LogUtil.v(LogUtil.BUGLE_DATABASE_PERF_TAG, String.format(Locale.US, + sFormatStrings[Math.min(sFormatStrings.length - 1, transactionDepth)], + delta, + msg)); + } + } + + public Context getContext() { + return mContext; + } + + public void beginTransaction() { + final long t1 = System.currentTimeMillis(); + + // push the current time onto the transaction stack + final TransactionData f = new TransactionData(); + f.time = t1; + sTransactionDepth.get().push(f); + + mDatabase.beginTransaction(); + } + + public void setTransactionSuccessful() { + final TransactionData f = sTransactionDepth.get().peek(); + f.transactionSuccessful = true; + mDatabase.setTransactionSuccessful(); + } + + public void endTransaction() { + long t1 = 0; + long transactionStartTime = 0; + final TransactionData f = sTransactionDepth.get().pop(); + if (f.transactionSuccessful == false) { + LogUtil.w(TAG, "endTransaction without setting successful"); + for (final StackTraceElement st : (new Exception()).getStackTrace()) { + LogUtil.w(TAG, " " + st.toString()); + } + } + if (mLog) { + transactionStartTime = f.time; + t1 = System.currentTimeMillis(); + } + try { + mDatabase.endTransaction(); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to endTransaction", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, + ">>> endTransaction (total for this transaction: %d)", + (System.currentTimeMillis() - transactionStartTime))); + } + } + + public void yieldTransaction() { + long yieldStartTime = 0; + if (mLog) { + yieldStartTime = System.currentTimeMillis(); + } + final boolean wasYielded = mDatabase.yieldIfContendedSafely(); + if (wasYielded && mLog) { + printTiming(yieldStartTime, "yieldTransaction"); + } + } + + public void insertWithOnConflict(final String searchTable, final String nullColumnHack, + final ContentValues initialValues, final int conflictAlgorithm) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + try { + mDatabase.insertWithOnConflict(searchTable, nullColumnHack, initialValues, + conflictAlgorithm); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to insertWithOnConflict", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, + "insertWithOnConflict with ", searchTable)); + } + } + + private void explainQueryPlan(final SQLiteQueryBuilder qb, final SQLiteDatabase db, + final String[] projection, final String selection, + @SuppressWarnings("unused") + final String[] queryArgs, + final String groupBy, + @SuppressWarnings("unused") + final String having, + final String sortOrder, final String limit) { + final String queryString = qb.buildQuery( + projection, + selection, + groupBy, + null/*having*/, + sortOrder, + limit); + explainQueryPlan(db, queryString, queryArgs); + } + + private void explainQueryPlan(final SQLiteDatabase db, final String sql, + final String[] queryArgs) { + if (!Pattern.matches(mExplainQueryPlanRegexp, sql)) { + return; + } + final Cursor planCursor = db.rawQuery("explain query plan " + sql, queryArgs); + try { + if (planCursor != null && planCursor.moveToFirst()) { + final int detailColumn = planCursor.getColumnIndex("detail"); + final StringBuilder sb = new StringBuilder(); + do { + sb.append(planCursor.getString(detailColumn)); + sb.append("\n"); + } while (planCursor.moveToNext()); + if (sb.length() > 0) { + sb.setLength(sb.length() - 1); + } + LogUtil.v(TAG, "for query " + sql + "\nplan is: " + + sb.toString()); + } + } catch (final Exception e) { + LogUtil.w(TAG, "Query plan failed ", e); + } finally { + if (planCursor != null) { + planCursor.close(); + } + } + } + + public Cursor query(final String searchTable, final String[] projection, + final String selection, final String[] selectionArgs, final String groupBy, + final String having, final String orderBy, final String limit) { + if (mExplainQueryPlanRegexp != null) { + final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + qb.setTables(searchTable); + explainQueryPlan(qb, mDatabase, projection, selection, selectionArgs, + groupBy, having, orderBy, limit); + } + + maybePlayDebugNoise(); + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + final Cursor cursor = mDatabase.query(searchTable, projection, selection, selectionArgs, + groupBy, having, orderBy, limit); + if (mLog) { + printTiming( + t1, + String.format(Locale.US, "query %s with %s ==> %d", + searchTable, selection, cursor.getCount())); + } + return cursor; + } + + public Cursor query(final String searchTable, final String[] columns, + final String selection, final String[] selectionArgs, final String groupBy, + final String having, final String orderBy) { + return query( + searchTable, columns, selection, selectionArgs, + groupBy, having, orderBy, null); + } + + public Cursor query(final SQLiteQueryBuilder qb, + final String[] projection, final String selection, final String[] queryArgs, + final String groupBy, final String having, final String sortOrder, final String limit) { + if (mExplainQueryPlanRegexp != null) { + explainQueryPlan(qb, mDatabase, projection, selection, queryArgs, + groupBy, having, sortOrder, limit); + } + maybePlayDebugNoise(); + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + final Cursor cursor = qb.query(mDatabase, projection, selection, queryArgs, groupBy, + having, sortOrder, limit); + if (mLog) { + printTiming( + t1, + String.format(Locale.US, "query %s with %s ==> %d", + qb.getTables(), selection, cursor.getCount())); + } + return cursor; + } + + public long queryNumEntries(final String table, final String selection, + final String[] selectionArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + final long retval = + DatabaseUtils.queryNumEntries(mDatabase, table, selection, selectionArgs); + if (mLog){ + printTiming( + t1, + String.format(Locale.US, "queryNumEntries %s with %s ==> %d", table, + selection, retval)); + } + return retval; + } + + public Cursor rawQuery(final String sql, final String[] args) { + if (mExplainQueryPlanRegexp != null) { + explainQueryPlan(mDatabase, sql, args); + } + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + final Cursor cursor = mDatabase.rawQuery(sql, args); + if (mLog) { + printTiming( + t1, + String.format(Locale.US, "rawQuery %s ==> %d", sql, cursor.getCount())); + } + return cursor; + } + + public int update(final String table, final ContentValues values, + final String selection, final String[] selectionArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + int count = 0; + try { + count = mDatabase.update(table, values, selection, selectionArgs); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to update", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "update %s with %s ==> %d", + table, selection, count)); + } + return count; + } + + public int delete(final String table, final String whereClause, final String[] whereArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + int count = 0; + try { + count = mDatabase.delete(table, whereClause, whereArgs); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to delete", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, + String.format(Locale.US, "delete from %s with %s ==> %d", table, + whereClause, count)); + } + return count; + } + + public long insert(final String table, final String nullColumnHack, + final ContentValues values) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + long rowId = -1; + try { + rowId = mDatabase.insert(table, nullColumnHack, values); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to insert", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "insert to %s", table)); + } + return rowId; + } + + public long replace(final String table, final String nullColumnHack, + final ContentValues values) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + long rowId = -1; + try { + rowId = mDatabase.replace(table, nullColumnHack, values); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to replace", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "replace to %s", table)); + } + return rowId; + } + + public void setLocale(final Locale locale) { + mDatabase.setLocale(locale); + } + + public void execSQL(final String sql, final String[] bindArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + try { + mDatabase.execSQL(sql, bindArgs); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to execSQL", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + + if (mLog) { + printTiming(t1, String.format(Locale.US, "execSQL %s", sql)); + } + } + + public void execSQL(final String sql) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + try { + mDatabase.execSQL(sql); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to execSQL", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + + if (mLog) { + printTiming(t1, String.format(Locale.US, "execSQL %s", sql)); + } + } + + public int execSQLUpdateDelete(final String sql) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + final SQLiteStatement statement = mDatabase.compileStatement(sql); + int rowsUpdated = 0; + try { + rowsUpdated = statement.executeUpdateDelete(); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to execSQLUpdateDelete", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "execSQLUpdateDelete %s", sql)); + } + return rowsUpdated; + } + + public SQLiteDatabase getDatabase() { + return mDatabase; + } +} diff --git a/src/com/android/messaging/datamodel/FileProvider.java b/src/com/android/messaging/datamodel/FileProvider.java new file mode 100644 index 0000000..ee332cd --- /dev/null +++ b/src/com/android/messaging/datamodel/FileProvider.java @@ -0,0 +1,151 @@ +/* + * 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; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Random; + +/** + * A very simple content provider that can serve files. + */ +public abstract class FileProvider extends ContentProvider { + // Object to generate random id for temp images. + private static final Random RANDOM_ID = new Random(); + + abstract File getFile(final String path, final String extension); + + private static final String FILE_EXTENSION_PARAM_KEY = "ext"; + + /** + * Check if filename conforms to requirement for our provider + * @param fileId filename (optionally starting with path character + * @return true if filename consists only of digits + */ + protected static boolean isValidFileId(final String fileId) { + // Ignore initial "/" + for (int index = (fileId.startsWith("/") ? 1 : 0); index < fileId.length(); index++) { + final Character c = fileId.charAt(index); + if (!Character.isDigit(c)) { + return false; + } + } + return true; + } + + /** + * Create a temp file (to allow writing to that one particular file) + * @param file the file to create + * @return true if file successfully created + */ + protected static boolean ensureFileExists(final File file) { + try { + final File parentDir = file.getParentFile(); + if (parentDir.exists() || parentDir.mkdirs()) { + return file.createNewFile(); + } + } catch (final IOException e) { + // fail on exceptions creating the file + } + return false; + } + + /** + * Build uri for a new temporary file (creating file) + * @param authority authority with which to populate uri + * @param extension optional file extension + * @return unique uri that can be used to write temporary files + */ + protected static Uri buildFileUri(final String authority, final String extension) { + final long fileId = Math.abs(RANDOM_ID.nextLong()); + final Uri.Builder builder = (new Uri.Builder()).authority(authority).scheme( + ContentResolver.SCHEME_CONTENT); + builder.appendPath(String.valueOf(fileId)); + if (!TextUtils.isEmpty(extension)) { + builder.appendQueryParameter(FILE_EXTENSION_PARAM_KEY, extension); + } + return builder.build(); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) { + final String fileId = uri.getPath(); + if (isValidFileId(fileId)) { + final File file = getFile(fileId, getExtensionFromUri(uri)); + return file.delete() ? 1 : 0; + } + return 0; + } + + @Override + public ParcelFileDescriptor openFile(final Uri uri, final String fileMode) + throws FileNotFoundException { + final String fileId = uri.getPath(); + if (isValidFileId(fileId)) { + final File file = getFile(fileId, getExtensionFromUri(uri)); + final int mode = + (TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY : + ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE); + return ParcelFileDescriptor.open(file, mode); + } + return null; + } + + protected static String getExtensionFromUri(final Uri uri) { + return uri.getQueryParameter(FILE_EXTENSION_PARAM_KEY); + } + + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + // Don't support queries. + return null; + } + + @Override + public Uri insert(final Uri uri, final ContentValues values) { + // Don't support inserts. + return null; + } + + @Override + public int update(final Uri uri, final ContentValues values, final String selection, + final String[] selectionArgs) { + // Don't support updates. + return 0; + } + + @Override + public String getType(final Uri uri) { + // No need for mime types. + return null; + } +} diff --git a/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java b/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java new file mode 100644 index 0000000..62483a0 --- /dev/null +++ b/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java @@ -0,0 +1,179 @@ +/* + * 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; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.SimpleArrayMap; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +/** + * A cursor builder that takes the frequent contacts cursor and aggregate it with the all contacts + * cursor to fill in contact details such as phone numbers and strip away invalid contacts. + * + * Because the frequent contact list depends on the loading of two cursors, it needs to temporarily + * store the cursor that it receives with setFrequents() and setAllContacts() calls. Because it + * doesn't know which one will be finished first, it always checks whether both cursors are ready + * to pull data from and construct the aggregate cursor when it's ready to do so. Note that + * this cursor builder doesn't assume ownership of the cursors passed in - it merely references + * them and always does a isClosed() check before consuming them. The ownership still belongs to + * the loader framework and the cursor may be closed when the UI is torn down. + */ +public class FrequentContactsCursorBuilder { + private Cursor mAllContactsCursor; + private Cursor mFrequentContactsCursor; + + /** + * Sets the frequent contacts cursor as soon as it is loaded, or null if it's reset. + * @return this builder instance for chained operations + */ + public FrequentContactsCursorBuilder setFrequents(final Cursor frequentContactsCursor) { + mFrequentContactsCursor = frequentContactsCursor; + return this; + } + + /** + * Sets the all contacts cursor as soon as it is loaded, or null if it's reset. + * @return this builder instance for chained operations + */ + public FrequentContactsCursorBuilder setAllContacts(final Cursor allContactsCursor) { + mAllContactsCursor = allContactsCursor; + return this; + } + + /** + * Reset this builder. Must be called when the consumer resets its data. + */ + public void resetBuilder() { + mAllContactsCursor = null; + mFrequentContactsCursor = null; + } + + /** + * Attempt to build the cursor records from the frequent and all contacts cursor if they + * are both ready to be consumed. + * @return the frequent contact cursor if built successfully, or null if it can't be built yet. + */ + public Cursor build() { + if (mFrequentContactsCursor != null && mAllContactsCursor != null) { + Assert.isTrue(!mFrequentContactsCursor.isClosed()); + Assert.isTrue(!mAllContactsCursor.isClosed()); + + // Frequent contacts cursor has one record per contact, plus it doesn't contain info + // such as phone number and type. In order for the records to be usable by Bugle, we + // would like to populate it with information from the all contacts cursor. + final MatrixCursor retCursor = new MatrixCursor(ContactUtil.PhoneQuery.PROJECTION); + + // First, go through the frequents cursor and take note of all lookup keys and their + // corresponding rank in the frequents list. + final SimpleArrayMap<String, Integer> lookupKeyToRankMap = + new SimpleArrayMap<String, Integer>(); + int oldPosition = mFrequentContactsCursor.getPosition(); + int rank = 0; + mFrequentContactsCursor.moveToPosition(-1); + while (mFrequentContactsCursor.moveToNext()) { + final String lookupKey = mFrequentContactsCursor.getString( + ContactUtil.INDEX_LOOKUP_KEY_FREQUENT); + lookupKeyToRankMap.put(lookupKey, rank++); + } + mFrequentContactsCursor.moveToPosition(oldPosition); + + // Second, go through the all contacts cursor once and retrieve all information + // (multiple phone numbers etc.) and store that in an array list. Since the all + // contacts list only contains phone contacts, this step will ensure that we filter + // out any invalid/email contacts in the frequents list. + final ArrayList<Object[]> rows = + new ArrayList<Object[]>(mFrequentContactsCursor.getCount()); + oldPosition = mAllContactsCursor.getPosition(); + mAllContactsCursor.moveToPosition(-1); + while (mAllContactsCursor.moveToNext()) { + final String lookupKey = mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY); + if (lookupKeyToRankMap.containsKey(lookupKey)) { + final Object[] row = new Object[ContactUtil.PhoneQuery.PROJECTION.length]; + row[ContactUtil.INDEX_DATA_ID] = + mAllContactsCursor.getLong(ContactUtil.INDEX_DATA_ID); + row[ContactUtil.INDEX_CONTACT_ID] = + mAllContactsCursor.getLong(ContactUtil.INDEX_CONTACT_ID); + row[ContactUtil.INDEX_LOOKUP_KEY] = + mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY); + row[ContactUtil.INDEX_DISPLAY_NAME] = + mAllContactsCursor.getString(ContactUtil.INDEX_DISPLAY_NAME); + row[ContactUtil.INDEX_PHOTO_URI] = + mAllContactsCursor.getString(ContactUtil.INDEX_PHOTO_URI); + row[ContactUtil.INDEX_PHONE_EMAIL] = + mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL); + row[ContactUtil.INDEX_PHONE_EMAIL_TYPE] = + mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE); + row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] = + mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL); + rows.add(row); + } + } + mAllContactsCursor.moveToPosition(oldPosition); + + // Now we have a list of rows containing frequent contacts in alphabetical order. + // Therefore, sort all the rows according to their actual ranks in the frequents list. + Collections.sort(rows, new Comparator<Object[]>() { + @Override + public int compare(final Object[] lhs, final Object[] rhs) { + final String lookupKeyLhs = (String) lhs[ContactUtil.INDEX_LOOKUP_KEY]; + final String lookupKeyRhs = (String) rhs[ContactUtil.INDEX_LOOKUP_KEY]; + Assert.isTrue(lookupKeyToRankMap.containsKey(lookupKeyLhs) && + lookupKeyToRankMap.containsKey(lookupKeyRhs)); + final int rankLhs = lookupKeyToRankMap.get(lookupKeyLhs); + final int rankRhs = lookupKeyToRankMap.get(lookupKeyRhs); + if (rankLhs < rankRhs) { + return -1; + } else if (rankLhs > rankRhs) { + return 1; + } else { + // Same rank, so it's two contact records for the same contact. + // Perform secondary sorting on the phone type. Always place + // mobile before everything else. + final int phoneTypeLhs = (int) lhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE]; + final int phoneTypeRhs = (int) rhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE]; + if (phoneTypeLhs == Phone.TYPE_MOBILE && + phoneTypeRhs == Phone.TYPE_MOBILE) { + return 0; + } else if (phoneTypeLhs == Phone.TYPE_MOBILE) { + return -1; + } else if (phoneTypeRhs == Phone.TYPE_MOBILE) { + return 1; + } else { + // Use the default sort order, i.e. sort by phoneType value. + return phoneTypeLhs < phoneTypeRhs ? -1 : + (phoneTypeLhs == phoneTypeRhs ? 0 : 1); + } + } + } + }); + + // Finally, add all the rows to this cursor. + for (final Object[] row : rows) { + retCursor.addRow(row); + } + return retCursor; + } + return null; + } +} diff --git a/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java b/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java new file mode 100644 index 0000000..d1759ad --- /dev/null +++ b/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java @@ -0,0 +1,114 @@ +/* + * 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; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; + +import com.android.messaging.util.FallbackStrategies; +import com.android.messaging.util.FallbackStrategies.Strategy; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +/** + * Helper for querying frequent (and/or starred) contacts. + */ +public class FrequentContactsCursorQueryData extends CursorQueryData { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static class FrequentContactsCursorLoader extends BoundCursorLoader { + private final Uri mOriginalUri; + + FrequentContactsCursorLoader(String bindingId, Context context, Uri uri, + String[] projection, String selection, String[] selectionArgs, String sortOrder) { + super(bindingId, context, uri, projection, selection, selectionArgs, sortOrder); + mOriginalUri = uri; + } + + @Override + public Cursor loadInBackground() { + return FallbackStrategies + .startWith(new PrimaryStrequentContactsQueryStrategy()) + .thenTry(new FrequentOnlyContactsQueryStrategy()) + .thenTry(new PhoneOnlyStrequentContactsQueryStrategy()) + .execute(null); + } + + private abstract class StrequentContactsQueryStrategy implements Strategy<Void, Cursor> { + @Override + public Cursor execute(Void params) throws Exception { + final Uri uri = getUri(); + if (uri != null) { + setUri(uri); + } + return FrequentContactsCursorLoader.super.loadInBackground(); + } + protected abstract Uri getUri(); + } + + private class PrimaryStrequentContactsQueryStrategy extends StrequentContactsQueryStrategy { + @Override + protected Uri getUri() { + // Use the original URI requested. + return mOriginalUri; + } + } + + private class FrequentOnlyContactsQueryStrategy extends StrequentContactsQueryStrategy { + @Override + protected Uri getUri() { + // Some phones have a buggy implementation of the Contacts provider which crashes + // when we query for strequent (starred+frequent) contacts (b/17991485). + // If this happens, switch to just querying for frequent contacts. + return Contacts.CONTENT_FREQUENT_URI; + } + } + + private class PhoneOnlyStrequentContactsQueryStrategy extends + StrequentContactsQueryStrategy { + @Override + protected Uri getUri() { + // Some 3rd party ROMs have content provider + // implementation where invalid SQL queries are returned for regular strequent + // queries. Using strequent_phone_only query as a fallback to display only phone + // contacts. This is the last-ditch effort; if this fails, we will display an + // empty frequent list (b/18354836). + final String strequentQueryParam = OsUtil.isAtLeastL() ? + ContactsContract.STREQUENT_PHONE_ONLY : "strequent_phone_only"; + // TODO: Handle enterprise contacts post M once contacts provider supports it + return Contacts.CONTENT_STREQUENT_URI.buildUpon() + .appendQueryParameter(strequentQueryParam, "true").build(); + } + } + } + + public FrequentContactsCursorQueryData(Context context, String[] projection, + String selection, String[] selectionArgs, String sortOrder) { + // TODO: Handle enterprise contacts post M once contacts provider supports it + super(context, Contacts.CONTENT_STREQUENT_URI, projection, selection, selectionArgs, + sortOrder); + } + + @Override + public BoundCursorLoader createBoundCursorLoader(String bindingId) { + return new FrequentContactsCursorLoader(bindingId, mContext, mUri, mProjection, mSelection, + mSelectionArgs, mSortOrder); + } +} diff --git a/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java new file mode 100644 index 0000000..28ec303 --- /dev/null +++ b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java @@ -0,0 +1,49 @@ +/* + * 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; + +import android.content.Context; +import android.net.Uri; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; +import android.provider.MediaStore.Images.Media; + +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.google.common.base.Joiner; + +/** + * A BoundCursorLoader that reads local media on the device. + */ +public class GalleryBoundCursorLoader extends BoundCursorLoader { + public static final String MEDIA_SCANNER_VOLUME_EXTERNAL = "external"; + private static final Uri STORAGE_URI = Files.getContentUri(MEDIA_SCANNER_VOLUME_EXTERNAL); + private static final String SORT_ORDER = Media.DATE_MODIFIED + " DESC"; + private static final String IMAGE_SELECTION = createSelection( + MessagePartData.ACCEPTABLE_IMAGE_TYPES, + new Integer[] { FileColumns.MEDIA_TYPE_IMAGE }); + + public GalleryBoundCursorLoader(final String bindingId, final Context context) { + super(bindingId, context, STORAGE_URI, GalleryGridItemData.IMAGE_PROJECTION, + IMAGE_SELECTION, null, SORT_ORDER); + } + + private static String createSelection(final String[] mimeTypes, Integer[] mediaTypes) { + return Media.MIME_TYPE + " IN ('" + Joiner.on("','").join(mimeTypes) + "') AND " + + FileColumns.MEDIA_TYPE + " IN (" + Joiner.on(',').join(mediaTypes) + ")"; + } +} diff --git a/src/com/android/messaging/datamodel/MediaScratchFileProvider.java b/src/com/android/messaging/datamodel/MediaScratchFileProvider.java new file mode 100644 index 0000000..29ae4f4 --- /dev/null +++ b/src/com/android/messaging/datamodel/MediaScratchFileProvider.java @@ -0,0 +1,132 @@ +/* + * 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; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MatrixCursor.RowBuilder; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.util.List; + +/** + * A very simple content provider that can serve media files from our cache directory. + */ +public class MediaScratchFileProvider extends FileProvider { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = + new SimpleArrayMap<Uri, String>(); + + @VisibleForTesting + public static final String AUTHORITY = + "com.android.messaging.datamodel.MediaScratchFileProvider"; + private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace"; + + public static boolean isMediaScratchSpaceUri(final Uri uri) { + if (uri == null) { + return false; + } + + final List<String> segments = uri.getPathSegments(); + return (TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_CONTENT) && + TextUtils.equals(uri.getAuthority(), AUTHORITY) && + segments.size() == 1 && FileProvider.isValidFileId(segments.get(0))); + } + + /** + * Returns a uri that can be used to access a raw mms file. + * + * @return the URI for an raw mms file + */ + public static Uri buildMediaScratchSpaceUri(final String extension) { + final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension); + final File file = getFileWithExtension(uri.getPath(), extension); + if (!ensureFileExists(file)) { + LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath()); + } + return uri; + } + + public static File getFileFromUri(final Uri uri) { + Assert.equals(AUTHORITY, uri.getAuthority()); + return getFileWithExtension(uri.getPath(), getExtensionFromUri(uri)); + } + + public static Uri.Builder getUriBuilder() { + return (new Uri.Builder()).authority(AUTHORITY).scheme(ContentResolver.SCHEME_CONTENT); + } + + @Override + File getFile(final String path, final String extension) { + return getFileWithExtension(path, extension); + } + + private static File getFileWithExtension(final String path, final String extension) { + final Context context = Factory.get().getApplicationContext(); + return new File(getDirectory(context), + TextUtils.isEmpty(extension) ? path : path + "." + extension); + } + + private static File getDirectory(final Context context) { + return new File(context.getCacheDir(), MEDIA_SCRATCH_SPACE_DIR); + } + + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + if (projection != null && projection.length > 0 && + TextUtils.equals(projection[0], OpenableColumns.DISPLAY_NAME) && + isMediaScratchSpaceUri(uri)) { + // Retrieve the display name associated with a temp file. This is used by the Contacts + // ImportVCardActivity to retrieve the name of the contact(s) being imported. + String displayName; + synchronized (sUriToDisplayNameMap) { + displayName = sUriToDisplayNameMap.get(uri); + } + if (!TextUtils.isEmpty(displayName)) { + MatrixCursor cursor = + new MatrixCursor(new String[] { OpenableColumns.DISPLAY_NAME }); + RowBuilder row = cursor.newRow(); + row.add(displayName); + return cursor; + } + } + return null; + } + + public static void addUriToDisplayNameEntry(final Uri scratchFileUri, + final String displayName) { + if (TextUtils.isEmpty(displayName)) { + return; + } + synchronized (sUriToDisplayNameMap) { + sUriToDisplayNameMap.put(scratchFileUri, displayName); + } + } +} diff --git a/src/com/android/messaging/datamodel/MemoryCacheManager.java b/src/com/android/messaging/datamodel/MemoryCacheManager.java new file mode 100644 index 0000000..0968cff --- /dev/null +++ b/src/com/android/messaging/datamodel/MemoryCacheManager.java @@ -0,0 +1,75 @@ +/* + * 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; + +import com.android.messaging.Factory; + +import java.util.HashSet; + +/** + * Utility abstraction which allows MemoryCaches in an application to register and then when there + * is memory pressure provide a callback to reclaim the memory in the caches. + */ +public class MemoryCacheManager { + private final HashSet<MemoryCache> mMemoryCaches = new HashSet<MemoryCache>(); + private final Object mMemoryCacheLock = new Object(); + + public static MemoryCacheManager get() { + return Factory.get().getMemoryCacheManager(); + } + + /** + * Extend this interface to provide a reclaim method on a memory cache. + */ + public interface MemoryCache { + void reclaim(); + } + + /** + * Register the memory cache with the application. + */ + public void registerMemoryCache(final MemoryCache cache) { + synchronized (mMemoryCacheLock) { + mMemoryCaches.add(cache); + } + } + + /** + * Unregister the memory cache with the application. + */ + public void unregisterMemoryCache(final MemoryCache cache) { + synchronized (mMemoryCacheLock) { + mMemoryCaches.remove(cache); + } + } + + /** + * Reclaim memory in all the memory caches in the application. + */ + @SuppressWarnings("unchecked") + public void reclaimMemory() { + // We're creating a cache copy in the lock to ensure we're not working on a concurrently + // modified set, then reclaim outside of the lock to minimize the time within the lock. + final HashSet<MemoryCache> shallowCopy; + synchronized (mMemoryCacheLock) { + shallowCopy = (HashSet<MemoryCache>) mMemoryCaches.clone(); + } + for (final MemoryCache cache : shallowCopy) { + cache.reclaim(); + } + } +} diff --git a/src/com/android/messaging/datamodel/MessageNotificationState.java b/src/com/android/messaging/datamodel/MessageNotificationState.java new file mode 100644 index 0000000..0bd4aaa --- /dev/null +++ b/src/com/android/messaging/datamodel/MessageNotificationState.java @@ -0,0 +1,1342 @@ +/* + * 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; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Typeface; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.Builder; +import android.support.v4.app.NotificationCompat.WearableExtender; +import android.support.v4.app.NotificationManagerCompat; +import android.text.Html; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.TextAppearanceSpan; +import android.text.style.URLSpan; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PendingIntentConstants; +import com.android.messaging.util.UriUtil; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Notification building class for conversation messages. + * + * Message Notifications are built in several stages with several utility classes. + * 1) Perform a database query and fill a data structure with information on messages and + * conversations which need to be notified. + * 2) Based on the data structure choose an appropriate NotificationState subclass to + * represent all the notifications. + * -- For one or more messages in one conversation: MultiMessageNotificationState. + * -- For multiple messages in multiple conversations: MultiConversationNotificationState + * + * A three level structure is used to coalesce the data from the database. From bottom to top: + * 1) NotificationLineInfo - A single message that needs to be notified. + * 2) ConversationLineInfo - A list of NotificationLineInfo in a single conversation. + * 3) ConversationInfoList - A list of ConversationLineInfo and the total number of messages. + * + * The createConversationInfoList function performs the query and creates the data structure. + */ +public abstract class MessageNotificationState extends NotificationState { + // Logging + static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG; + private static final int MAX_MESSAGES_IN_WEARABLE_PAGE = 20; + + private static final int MAX_CHARACTERS_IN_GROUP_NAME = 30; + + private static final int REPLY_INTENT_REQUEST_CODE_OFFSET = 0; + private static final int NUM_EXTRA_REQUEST_CODES_NEEDED = 1; + protected String mTickerSender = null; + protected CharSequence mTickerText = null; + protected String mTitle = null; + protected CharSequence mContent = null; + protected Uri mAttachmentUri = null; + protected String mAttachmentType = null; + protected boolean mTickerNoContent; + + @Override + protected Uri getAttachmentUri() { + return mAttachmentUri; + } + + @Override + protected String getAttachmentType() { + return mAttachmentType; + } + + @Override + public int getIcon() { + return R.drawable.ic_sms_light; + } + + @Override + public int getPriority() { + // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker + // isn't displayed. + return Notification.PRIORITY_HIGH; + } + + /** + * Base class for single notification events for messages. Multiple of these + * may be grouped into a single conversation. + */ + static class NotificationLineInfo { + + final int mNotificationType; + + NotificationLineInfo() { + mNotificationType = BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + + NotificationLineInfo(final int notificationType) { + mNotificationType = notificationType; + } + } + + /** + * Information on a single chat message which should be shown in a notification. + */ + static class MessageLineInfo extends NotificationLineInfo { + final CharSequence mText; + Uri mAttachmentUri; + String mAttachmentType; + final String mAuthorFullName; + final String mAuthorFirstName; + boolean mIsManualDownloadNeeded; + final String mMessageId; + + MessageLineInfo(final boolean isGroup, final String authorFullName, + final String authorFirstName, final CharSequence text, final Uri attachmentUrl, + final String attachmentType, final boolean isManualDownloadNeeded, + final String messageId) { + super(BugleNotifications.LOCAL_SMS_NOTIFICATION); + mAuthorFullName = authorFullName; + mAuthorFirstName = authorFirstName; + mText = text; + mAttachmentUri = attachmentUrl; + mAttachmentType = attachmentType; + mIsManualDownloadNeeded = isManualDownloadNeeded; + mMessageId = messageId; + } + } + + /** + * Information on all the notification messages within a single conversation. + */ + static class ConversationLineInfo { + // Conversation id of the latest message in the notification for this merged conversation. + final String mConversationId; + + // True if this represents a group conversation. + final boolean mIsGroup; + + // Name of the group conversation if available. + final String mGroupConversationName; + + // True if this conversation's recipients includes one or more email address(es) + // (see ConversationColumns.INCLUDE_EMAIL_ADDRESS) + final boolean mIncludeEmailAddress; + + // Timestamp of the latest message + final long mReceivedTimestamp; + + // Self participant id. + final String mSelfParticipantId; + + // List of individual line notifications to be parsed later. + final List<NotificationLineInfo> mLineInfos; + + // Total number of messages. Might be different that mLineInfos.size() as the number of + // line infos is capped. + int mTotalMessageCount; + + // Custom ringtone if set + final String mRingtoneUri; + + // Should notification be enabled for this conversation? + final boolean mNotificationEnabled; + + // Should notifications vibrate for this conversation? + final boolean mNotificationVibrate; + + // Avatar uri of sender + final Uri mAvatarUri; + + // Contact uri of sender + final Uri mContactUri; + + // Subscription id. + final int mSubId; + + // Number of participants + final int mParticipantCount; + + public ConversationLineInfo(final String conversationId, + final boolean isGroup, + final String groupConversationName, + final boolean includeEmailAddress, + final long receivedTimestamp, + final String selfParticipantId, + final String ringtoneUri, + final boolean notificationEnabled, + final boolean notificationVibrate, + final Uri avatarUri, + final Uri contactUri, + final int subId, + final int participantCount) { + mConversationId = conversationId; + mIsGroup = isGroup; + mGroupConversationName = groupConversationName; + mIncludeEmailAddress = includeEmailAddress; + mReceivedTimestamp = receivedTimestamp; + mSelfParticipantId = selfParticipantId; + mLineInfos = new ArrayList<NotificationLineInfo>(); + mTotalMessageCount = 0; + mRingtoneUri = ringtoneUri; + mAvatarUri = avatarUri; + mContactUri = contactUri; + mNotificationEnabled = notificationEnabled; + mNotificationVibrate = notificationVibrate; + mSubId = subId; + mParticipantCount = participantCount; + } + + public int getLatestMessageNotificationType() { + final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); + if (messageLineInfo == null) { + return BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + return messageLineInfo.mNotificationType; + } + + public String getLatestMessageId() { + final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); + if (messageLineInfo == null) { + return null; + } + return messageLineInfo.mMessageId; + } + + public boolean getDoesLatestMessageNeedDownload() { + final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); + if (messageLineInfo == null) { + return false; + } + return messageLineInfo.mIsManualDownloadNeeded; + } + + private MessageLineInfo getLatestMessageLineInfo() { + // The latest message is stored at index zero of the message line infos. + if (mLineInfos.size() > 0 && mLineInfos.get(0) instanceof MessageLineInfo) { + return (MessageLineInfo) mLineInfos.get(0); + } + return null; + } + } + + /** + * Information on all the notification messages across all conversations. + */ + public static class ConversationInfoList { + final int mMessageCount; + final List<ConversationLineInfo> mConvInfos; + public ConversationInfoList(final int count, final List<ConversationLineInfo> infos) { + mMessageCount = count; + mConvInfos = infos; + } + } + + final ConversationInfoList mConvList; + private long mLatestReceivedTimestamp; + + private static ConversationIdSet makeConversationIdSet(final ConversationInfoList convList) { + ConversationIdSet set = null; + if (convList != null && convList.mConvInfos != null && convList.mConvInfos.size() > 0) { + set = new ConversationIdSet(); + for (final ConversationLineInfo info : convList.mConvInfos) { + set.add(info.mConversationId); + } + } + return set; + } + + protected MessageNotificationState(final ConversationInfoList convList) { + super(makeConversationIdSet(convList)); + mConvList = convList; + mType = PendingIntentConstants.SMS_NOTIFICATION_ID; + mLatestReceivedTimestamp = Long.MIN_VALUE; + if (convList != null) { + for (final ConversationLineInfo info : convList.mConvInfos) { + mLatestReceivedTimestamp = Math.max(mLatestReceivedTimestamp, + info.mReceivedTimestamp); + } + } + } + + @Override + public long getLatestReceivedTimestamp() { + return mLatestReceivedTimestamp; + } + + @Override + public int getNumRequestCodesNeeded() { + // Get additional request codes for the Reply PendingIntent (wearables only) + // and the DND PendingIntent. + return super.getNumRequestCodesNeeded() + NUM_EXTRA_REQUEST_CODES_NEEDED; + } + + private int getBaseExtraRequestCode() { + return mBaseRequestCode + super.getNumRequestCodesNeeded(); + } + + public int getReplyIntentRequestCode() { + return getBaseExtraRequestCode() + REPLY_INTENT_REQUEST_CODE_OFFSET; + } + + @Override + public PendingIntent getClearIntent() { + return UIIntents.get().getPendingIntentForClearingNotifications( + Factory.get().getApplicationContext(), + BugleNotifications.UPDATE_MESSAGES, + mConversationIds, + getClearIntentRequestCode()); + } + + /** + * Notification for multiple messages in at least 2 different conversations. + */ + public static class MultiConversationNotificationState extends MessageNotificationState { + + public final List<MessageNotificationState> + mChildren = new ArrayList<MessageNotificationState>(); + + public MultiConversationNotificationState( + final ConversationInfoList convList, final MessageNotificationState state) { + super(convList); + mAttachmentUri = null; + mAttachmentType = null; + + // Pull the ticker title/text from the single notification + mTickerSender = state.getTitle(); + mTitle = Factory.get().getApplicationContext().getResources().getQuantityString( + R.plurals.notification_new_messages, + convList.mMessageCount, convList.mMessageCount); + mTickerText = state.mContent; + + // Create child notifications for each conversation, + // which will be displayed (only) on a wearable device. + for (int i = 0; i < convList.mConvInfos.size(); i++) { + final ConversationLineInfo convInfo = convList.mConvInfos.get(i); + if (!(convInfo.mLineInfos.get(0) instanceof MessageLineInfo)) { + continue; + } + setPeopleForConversation(convInfo.mConversationId); + final ConversationInfoList list = new ConversationInfoList( + convInfo.mTotalMessageCount, Lists.newArrayList(convInfo)); + mChildren.add(new BundledMessageNotificationState(list, i)); + } + } + + @Override + public int getIcon() { + return R.drawable.ic_sms_multi_light; + } + + @Override + protected NotificationCompat.Style build(final Builder builder) { + builder.setContentTitle(mTitle); + NotificationCompat.InboxStyle inboxStyle = null; + inboxStyle = new NotificationCompat.InboxStyle(builder); + + final Context context = Factory.get().getApplicationContext(); + // enumeration_comma is defined as ", " + final String separator = context.getString(R.string.enumeration_comma); + final StringBuilder senders = new StringBuilder(); + long when = 0; + for (int i = 0; i < mConvList.mConvInfos.size(); i++) { + final ConversationLineInfo convInfo = mConvList.mConvInfos.get(i); + if (convInfo.mReceivedTimestamp > when) { + when = convInfo.mReceivedTimestamp; + } + String sender; + CharSequence text; + final NotificationLineInfo lineInfo = convInfo.mLineInfos.get(0); + final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfo; + if (convInfo.mIsGroup) { + sender = (convInfo.mGroupConversationName.length() > + MAX_CHARACTERS_IN_GROUP_NAME) ? + truncateGroupMessageName(convInfo.mGroupConversationName) + : convInfo.mGroupConversationName; + } else { + sender = messageLineInfo.mAuthorFullName; + } + text = messageLineInfo.mText; + mAttachmentUri = messageLineInfo.mAttachmentUri; + mAttachmentType = messageLineInfo.mAttachmentType; + + inboxStyle.addLine(BugleNotifications.formatInboxMessage( + sender, text, mAttachmentUri, mAttachmentType)); + if (sender != null) { + if (senders.length() > 0) { + senders.append(separator); + } + senders.append(sender); + } + } + // for collapsed state + mContent = senders; + builder.setContentText(senders) + .setTicker(getTicker()) + .setWhen(when); + + return inboxStyle; + } + } + + /** + * Truncate group conversation name to be displayed in the notifications. This either truncates + * the entire group name or finds the last comma in the available length and truncates the name + * at that point + */ + private static String truncateGroupMessageName(final String conversationName) { + int endIndex = MAX_CHARACTERS_IN_GROUP_NAME; + for (int i = MAX_CHARACTERS_IN_GROUP_NAME; i >= 0; i--) { + // The dividing marker should stay consistent with ConversationListItemData.DIVIDER_TEXT + if (conversationName.charAt(i) == ',') { + endIndex = i; + break; + } + } + return conversationName.substring(0, endIndex) + '\u2026'; + } + + /** + * Notification for multiple messages in a single conversation. Also used if there is a single + * message in a single conversation. + */ + public static class MultiMessageNotificationState extends MessageNotificationState { + + public MultiMessageNotificationState(final ConversationInfoList convList) { + super(convList); + // This conversation has been accepted. + final ConversationLineInfo convInfo = convList.mConvInfos.get(0); + setAvatarUrlsForConversation(convInfo.mConversationId); + setPeopleForConversation(convInfo.mConversationId); + + final Context context = Factory.get().getApplicationContext(); + MessageLineInfo messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0); + // attached photo + mAttachmentUri = messageInfo.mAttachmentUri; + mAttachmentType = messageInfo.mAttachmentType; + mContent = messageInfo.mText; + + if (mAttachmentUri != null) { + // The default attachment type is an image, since that's what was originally + // supported. When there's no content type, assume it's an image. + int message = R.string.notification_picture; + if (ContentType.isAudioType(mAttachmentType)) { + message = R.string.notification_audio; + } else if (ContentType.isVideoType(mAttachmentType)) { + message = R.string.notification_video; + } else if (ContentType.isVCardType(mAttachmentType)) { + message = R.string.notification_vcard; + } + final String attachment = context.getString(message); + final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); + if (!TextUtils.isEmpty(mContent)) { + spanBuilder.append(mContent).append(System.getProperty("line.separator")); + } + final int start = spanBuilder.length(); + spanBuilder.append(attachment); + spanBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, spanBuilder.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + mContent = spanBuilder; + } + if (convInfo.mIsGroup) { + // When the message is part of a group, the sender's first name + // is prepended to the message, but not for the ticker message. + mTickerText = mContent; + mTickerSender = messageInfo.mAuthorFullName; + // append the bold name to the front of the message + mContent = BugleNotifications.buildSpaceSeparatedMessage( + messageInfo.mAuthorFullName, mContent, mAttachmentUri, + mAttachmentType); + mTitle = convInfo.mGroupConversationName; + } else { + // No matter how many messages there are, since this is a 1:1, just + // get the author full name from the first one. + messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0); + mTitle = messageInfo.mAuthorFullName; + } + } + + @Override + protected NotificationCompat.Style build(final Builder builder) { + builder.setContentTitle(mTitle) + .setTicker(getTicker()); + + NotificationCompat.Style notifStyle = null; + final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0); + final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos; + final int messageCount = lineInfos.size(); + // At this point, all the messages come from the same conversation. We need to load + // the sender's avatar and then finish building the notification on a callback. + + builder.setContentText(mContent); // for collapsed state + + if (messageCount == 1) { + final boolean shouldShowImage = ContentType.isImageType(mAttachmentType) + || (ContentType.isVideoType(mAttachmentType) + && VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()); + if (mAttachmentUri != null && shouldShowImage) { + // Show "Picture" as the content + final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0); + String authorFirstName = messageLineInfo.mAuthorFirstName; + + // For the collapsed state, just show "picture" unless this is a + // group conversation. If it's a group, show the sender name and + // "picture". + final CharSequence tickerTag = + BugleNotifications.formatAttachmentTag(authorFirstName, + mAttachmentType); + // For 1:1 notifications don't show first name in the notification, but + // do show it in the ticker text + CharSequence pictureTag = tickerTag; + if (!convInfo.mIsGroup) { + authorFirstName = null; + pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName, + mAttachmentType); + } + builder.setContentText(pictureTag); + builder.setTicker(tickerTag); + + notifStyle = new NotificationCompat.BigPictureStyle(builder) + .setSummaryText(BugleNotifications.formatInboxMessage( + authorFirstName, + null, null, + null)); // expanded state, just show sender + } else { + notifStyle = new NotificationCompat.BigTextStyle(builder) + .bigText(mContent); + } + } else { + // We've got multiple messages for the same sender. + // Starting with the oldest new message, display the full text of each message. + // Begin a line for each subsequent message. + final SpannableStringBuilder buf = new SpannableStringBuilder(); + + for (int i = lineInfos.size() - 1; i >= 0; --i) { + final NotificationLineInfo info = lineInfos.get(i); + final MessageLineInfo messageLineInfo = (MessageLineInfo) info; + mAttachmentUri = messageLineInfo.mAttachmentUri; + mAttachmentType = messageLineInfo.mAttachmentType; + CharSequence text = messageLineInfo.mText; + if (!TextUtils.isEmpty(text) || mAttachmentUri != null) { + if (convInfo.mIsGroup) { + // append the bold name to the front of the message + text = BugleNotifications.buildSpaceSeparatedMessage( + messageLineInfo.mAuthorFullName, text, mAttachmentUri, + mAttachmentType); + } else { + text = BugleNotifications.buildSpaceSeparatedMessage( + null, text, mAttachmentUri, mAttachmentType); + } + buf.append(text); + if (i > 0) { + buf.append('\n'); + } + } + } + + // Show a single notification -- big style with the text of all the messages + notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf); + } + builder.setWhen(convInfo.mReceivedTimestamp); + return notifStyle; + } + + } + + private static boolean firstNameUsedMoreThanOnce( + final HashMap<String, Integer> map, final String firstName) { + if (map == null) { + return false; + } + if (firstName == null) { + return false; + } + final Integer count = map.get(firstName); + if (count != null) { + return count > 1; + } else { + return false; + } + } + + private static HashMap<String, Integer> scanFirstNames(final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + final Uri uri = + MessagingContentProvider.buildConversationParticipantsUri(conversationId); + final Cursor participantsCursor = context.getContentResolver().query( + uri, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + final ConversationParticipantsData participantsData = new ConversationParticipantsData(); + participantsData.bind(participantsCursor); + final Iterator<ParticipantData> iter = participantsData.iterator(); + + final HashMap<String, Integer> firstNames = new HashMap<String, Integer>(); + boolean seenSelf = false; + while (iter.hasNext()) { + final ParticipantData participant = iter.next(); + // Make sure we only add the self participant once + if (participant.isSelf()) { + if (seenSelf) { + continue; + } else { + seenSelf = true; + } + } + + final String firstName = participant.getFirstName(); + if (firstName == null) { + continue; + } + + final int currentCount = firstNames.containsKey(firstName) + ? firstNames.get(firstName) + : 0; + firstNames.put(firstName, currentCount + 1); + } + return firstNames; + } + + // Essentially, we're building a list of the past 20 messages for this conversation to display + // on the wearable. + public static Notification buildConversationPageForWearable(final String conversationId, + int participantCount) { + final Context context = Factory.get().getApplicationContext(); + + // Limit the number of messages to show. We just want enough to provide context for the + // notification. Fetch one more than we need, so we can tell if there are more messages + // before the one we're showing. + // TODO: in the query, a multipart message will contain a row for each part. + // We might need a smarter GROUP_BY. On the other hand, we might want to show each of the + // parts as separate messages on the wearable. + final int limit = MAX_MESSAGES_IN_WEARABLE_PAGE + 1; + + final List<CharSequence> messages = Lists.newArrayList(); + boolean hasSeenMessagesBeforeNotification = false; + Cursor convMessageCursor = null; + try { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String[] queryArgs = { conversationId }; + final String convPageSql = ConversationMessageData.getWearableQuerySql() + " LIMIT " + + limit; + convMessageCursor = db.rawQuery( + convPageSql, + queryArgs); + + if (convMessageCursor == null || !convMessageCursor.moveToFirst()) { + return null; + } + final ConversationMessageData convMessageData = + new ConversationMessageData(); + + final HashMap<String, Integer> firstNames = scanFirstNames(conversationId); + do { + convMessageData.bind(convMessageCursor); + + final String authorFullName = convMessageData.getSenderFullName(); + final String authorFirstName = convMessageData.getSenderFirstName(); + String text = convMessageData.getText(); + + final boolean isSmsPushNotification = convMessageData.getIsMmsNotification(); + + // if auto-download was off to show a message to tap to download the message. We + // might need to get that working again. + if (isSmsPushNotification && text != null) { + text = convertHtmlAndStripUrls(text).toString(); + } + // Skip messages without any content + if (TextUtils.isEmpty(text) && !convMessageData.hasAttachments()) { + continue; + } + // Track whether there are messages prior to the one(s) shown in the notification. + if (convMessageData.getIsSeen()) { + hasSeenMessagesBeforeNotification = true; + } + + final boolean usedMoreThanOnce = firstNameUsedMoreThanOnce( + firstNames, authorFirstName); + String displayName = usedMoreThanOnce ? authorFullName : authorFirstName; + if (TextUtils.isEmpty(displayName)) { + if (convMessageData.getIsIncoming()) { + displayName = convMessageData.getSenderDisplayDestination(); + if (TextUtils.isEmpty(displayName)) { + displayName = context.getString(R.string.unknown_sender); + } + } else { + displayName = context.getString(R.string.unknown_self_participant); + } + } + + Uri attachmentUri = null; + String attachmentType = null; + final List<MessagePartData> attachments = convMessageData.getAttachments(); + for (final MessagePartData messagePartData : attachments) { + // Look for the first attachment that's not the text piece. + if (!messagePartData.isText()) { + attachmentUri = messagePartData.getContentUri(); + attachmentType = messagePartData.getContentType(); + break; + } + } + + final CharSequence message = BugleNotifications.buildSpaceSeparatedMessage( + displayName, text, attachmentUri, attachmentType); + messages.add(message); + + } while (convMessageCursor.moveToNext()); + } finally { + if (convMessageCursor != null) { + convMessageCursor.close(); + } + } + + // If there is no conversation history prior to what is already visible in the main + // notification, there's no need to include the conversation log, too. + final int maxMessagesInNotification = getMaxMessagesInConversationNotification(); + if (!hasSeenMessagesBeforeNotification && messages.size() <= maxMessagesInNotification) { + return null; + } + + final SpannableStringBuilder bigText = new SpannableStringBuilder(); + // There is at least 1 message prior to the first one that we're going to show. + // Indicate this by inserting an ellipsis at the beginning of the conversation log. + if (convMessageCursor.getCount() == limit) { + bigText.append(context.getString(R.string.ellipsis) + "\n\n"); + if (messages.size() > MAX_MESSAGES_IN_WEARABLE_PAGE) { + messages.remove(messages.size() - 1); + } + } + // Messages are sorted in descending timestamp order, so iterate backwards + // to get them back in ascending order for display purposes. + for (int i = messages.size() - 1; i >= 0; --i) { + bigText.append(messages.get(i)); + if (i > 0) { + bigText.append("\n\n"); + } + } + ++participantCount; // Add in myself + + if (participantCount > 2) { + final SpannableString statusText = new SpannableString( + context.getResources().getQuantityString(R.plurals.wearable_participant_count, + participantCount, participantCount)); + statusText.setSpan(new ForegroundColorSpan(context.getResources().getColor( + R.color.wearable_notification_participants_count)), 0, statusText.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + bigText.append("\n\n").append(statusText); + } + + final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context); + final NotificationCompat.Style notifStyle = + new NotificationCompat.BigTextStyle(notifBuilder).bigText(bigText); + notifBuilder.setStyle(notifStyle); + + final WearableExtender wearableExtender = new WearableExtender(); + wearableExtender.setStartScrollBottom(true); + notifBuilder.extend(wearableExtender); + + return notifBuilder.build(); + } + + /** + * Notification for one or more messages in a single conversation, which is bundled together + * with notifications for other conversations on a wearable device. + */ + public static class BundledMessageNotificationState extends MultiMessageNotificationState { + public int mGroupOrder; + public BundledMessageNotificationState(final ConversationInfoList convList, + final int groupOrder) { + super(convList); + mGroupOrder = groupOrder; + } + } + + /** + * Performs a query on the database. + */ + private static ConversationInfoList createConversationInfoList() { + // Map key is conversation id. We use LinkedHashMap to ensure that entries are iterated in + // the same order they were originally added. We scan unseen messages from newest to oldest, + // so the corresponding conversations are added in that order, too. + final Map<String, ConversationLineInfo> convLineInfos = new LinkedHashMap<>(); + int messageCount = 0; + + Cursor convMessageCursor = null; + try { + final Context context = Factory.get().getApplicationContext(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + convMessageCursor = db.rawQuery( + ConversationMessageData.getNotificationQuerySql(), + null); + + if (convMessageCursor != null && convMessageCursor.moveToFirst()) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MessageNotificationState: Found unseen message notifications."); + } + final ConversationMessageData convMessageData = + new ConversationMessageData(); + + HashMap<String, Integer> firstNames = null; + String conversationIdForFirstNames = null; + String groupConversationName = null; + final int maxMessages = getMaxMessagesInConversationNotification(); + + do { + convMessageData.bind(convMessageCursor); + + // First figure out if this is a valid message. + String authorFullName = convMessageData.getSenderFullName(); + String authorFirstName = convMessageData.getSenderFirstName(); + final String messageText = convMessageData.getText(); + + final String convId = convMessageData.getConversationId(); + final String messageId = convMessageData.getMessageId(); + + CharSequence text = messageText; + final boolean isManualDownloadNeeded = convMessageData.getIsMmsNotification(); + if (isManualDownloadNeeded) { + // Don't try and convert the text from html if it's sms and not a sms push + // notification. + Assert.equals(MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD, + convMessageData.getStatus()); + text = context.getResources().getString( + R.string.message_title_manual_download); + } + ConversationLineInfo currConvInfo = convLineInfos.get(convId); + if (currConvInfo == null) { + final ConversationListItemData convData = + ConversationListItemData.getExistingConversation(db, convId); + if (!convData.getNotificationEnabled()) { + // Skip conversations that have notifications disabled. + continue; + } + final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db, + convData.getSelfId()); + groupConversationName = convData.getName(); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + convMessageData.getSenderProfilePhotoUri(), + convMessageData.getSenderFullName(), + convMessageData.getSenderNormalizedDestination(), + convMessageData.getSenderContactLookupKey()); + currConvInfo = new ConversationLineInfo(convId, + convData.getIsGroup(), + groupConversationName, + convData.getIncludeEmailAddress(), + convMessageData.getReceivedTimeStamp(), + convData.getSelfId(), + convData.getNotificationSoundUri(), + convData.getNotificationEnabled(), + convData.getNotifiationVibrate(), + avatarUri, + convMessageData.getSenderContactLookupUri(), + subId, + convData.getParticipantCount()); + convLineInfos.put(convId, currConvInfo); + } + // Prepare the message line + if (currConvInfo.mTotalMessageCount < maxMessages) { + if (currConvInfo.mIsGroup) { + if (authorFirstName == null) { + // authorFullName might be null as well. In that case, we won't + // show an author. That is better than showing all the group + // names again on the 2nd line. + authorFirstName = authorFullName; + } + } else { + // don't recompute this if we don't need to + if (!TextUtils.equals(conversationIdForFirstNames, convId)) { + firstNames = scanFirstNames(convId); + conversationIdForFirstNames = convId; + } + if (firstNames != null) { + final Integer count = firstNames.get(authorFirstName); + if (count != null && count > 1) { + authorFirstName = authorFullName; + } + } + + if (authorFullName == null) { + authorFullName = groupConversationName; + } + if (authorFirstName == null) { + authorFirstName = groupConversationName; + } + } + final String subjectText = MmsUtils.cleanseMmsSubject( + context.getResources(), + convMessageData.getMmsSubject()); + if (!TextUtils.isEmpty(subjectText)) { + final String subjectLabel = + context.getString(R.string.subject_label); + final SpannableStringBuilder spanBuilder = + new SpannableStringBuilder(); + + spanBuilder.append(context.getString(R.string.notification_subject, + subjectLabel, subjectText)); + spanBuilder.setSpan(new TextAppearanceSpan( + context, R.style.NotificationSubjectText), 0, + subjectLabel.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + if (!TextUtils.isEmpty(text)) { + // Now add the actual message text below the subject header. + spanBuilder.append(System.getProperty("line.separator") + text); + } + text = spanBuilder; + } + // If we've got attachments, find the best one. If one of the messages is + // a photo, save the url so we'll display a big picture notification. + // Otherwise, show the first one we find. + Uri attachmentUri = null; + String attachmentType = null; + final MessagePartData messagePartData = + getMostInterestingAttachment(convMessageData); + if (messagePartData != null) { + attachmentUri = messagePartData.getContentUri(); + attachmentType = messagePartData.getContentType(); + } + currConvInfo.mLineInfos.add(new MessageLineInfo(currConvInfo.mIsGroup, + authorFullName, authorFirstName, text, + attachmentUri, attachmentType, isManualDownloadNeeded, messageId)); + } + messageCount++; + currConvInfo.mTotalMessageCount++; + } while (convMessageCursor.moveToNext()); + } + } finally { + if (convMessageCursor != null) { + convMessageCursor.close(); + } + } + if (convLineInfos.isEmpty()) { + return null; + } else { + return new ConversationInfoList(messageCount, + Lists.newLinkedList(convLineInfos.values())); + } + } + + /** + * Scans all the attachments for a message and returns the most interesting one that we'll + * show in a notification. By order of importance, in case there are multiple attachments: + * 1- an image (because we can show the image as a BigPictureNotification) + * 2- a video (because we can show a video frame as a BigPictureNotification) + * 3- a vcard + * 4- an audio attachment + * @return MessagePartData for the most interesting part. Can be null. + */ + private static MessagePartData getMostInterestingAttachment( + final ConversationMessageData convMessageData) { + final List<MessagePartData> attachments = convMessageData.getAttachments(); + + MessagePartData imagePart = null; + MessagePartData audioPart = null; + MessagePartData vcardPart = null; + MessagePartData videoPart = null; + + // 99.99% of the time there will be 0 or 1 part, since receiving slideshows is so + // uncommon. + + // Remember the first of each type of part. + for (final MessagePartData messagePartData : attachments) { + if (messagePartData.isImage() && imagePart == null) { + imagePart = messagePartData; + } + if (messagePartData.isVideo() && videoPart == null) { + videoPart = messagePartData; + } + if (messagePartData.isVCard() && vcardPart == null) { + vcardPart = messagePartData; + } + if (messagePartData.isAudio() && audioPart == null) { + audioPart = messagePartData; + } + } + if (imagePart != null) { + return imagePart; + } else if (videoPart != null) { + return videoPart; + } else if (audioPart != null) { + return audioPart; + } else if (vcardPart != null) { + return vcardPart; + } + return null; + } + + private static int getMaxMessagesInConversationNotification() { + if (!BugleNotifications.isWearCompanionAppInstalled()) { + return BugleGservices.get().getInt( + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION, + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT); + } + return BugleGservices.get().getInt( + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE, + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT); + } + + /** + * Scans the database for messages that need to go into notifications. Creates the appropriate + * MessageNotificationState depending on if there are multiple senders, or + * messages from one sender. + * @return NotificationState for the notification created. + */ + public static NotificationState getNotificationState() { + MessageNotificationState state = null; + final ConversationInfoList convList = createConversationInfoList(); + + if (convList == null || convList.mConvInfos.size() == 0) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MessageNotificationState: No unseen notifications"); + } + } else { + final ConversationLineInfo convInfo = convList.mConvInfos.get(0); + state = new MultiMessageNotificationState(convList); + + if (convList.mConvInfos.size() > 1) { + // We've got notifications across multiple conversations. Pass in the notification + // we just built of the most recent notification so we can use that to show the + // user the new message in the ticker. + state = new MultiConversationNotificationState(convList, state); + } else { + // For now, only show avatars for notifications for a single conversation. + if (convInfo.mAvatarUri != null) { + if (state.mParticipantAvatarsUris == null) { + state.mParticipantAvatarsUris = new ArrayList<Uri>(1); + } + state.mParticipantAvatarsUris.add(convInfo.mAvatarUri); + } + if (convInfo.mContactUri != null) { + if (state.mParticipantContactUris == null) { + state.mParticipantContactUris = new ArrayList<Uri>(1); + } + state.mParticipantContactUris.add(convInfo.mContactUri); + } + } + } + if (state != null && LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MessageNotificationState: Notification state created" + + ", title = " + LogUtil.sanitizePII(state.mTitle) + + ", content = " + LogUtil.sanitizePII(state.mContent.toString())); + } + return state; + } + + protected String getTitle() { + return mTitle; + } + + @Override + public int getLatestMessageNotificationType() { + // This function is called to determine whether the most recent notification applies + // to an sms conversation or a hangout conversation. We have different ringtone/vibrate + // settings for both types of conversations. + if (mConvList.mConvInfos.size() > 0) { + final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0); + return convInfo.getLatestMessageNotificationType(); + } + return BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + + @Override + public String getRingtoneUri() { + if (mConvList.mConvInfos.size() > 0) { + return mConvList.mConvInfos.get(0).mRingtoneUri; + } + return null; + } + + @Override + public boolean getNotificationVibrate() { + if (mConvList.mConvInfos.size() > 0) { + return mConvList.mConvInfos.get(0).mNotificationVibrate; + } + return false; + } + + protected CharSequence getTicker() { + return BugleNotifications.buildColonSeparatedMessage( + mTickerSender != null ? mTickerSender : mTitle, + mTickerText != null ? mTickerText : (mTickerNoContent ? null : mContent), null, + null); + } + + private static CharSequence convertHtmlAndStripUrls(final String s) { + final Spanned text = Html.fromHtml(s); + if (text instanceof Spannable) { + stripUrls((Spannable) text); + } + return text; + } + + // Since we don't want to show URLs in notifications, a function + // to remove them in place. + private static void stripUrls(final Spannable text) { + final URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class); + for (final URLSpan span : spans) { + text.removeSpan(span); + } + } + + /* + private static void updateAlertStatusMessages(final long thresholdDeltaMs) { + // TODO may need this when supporting error notifications + final EsDatabaseHelper helper = EsDatabaseHelper.getDatabaseHelper(); + final ContentValues values = new ContentValues(); + final long nowMicros = System.currentTimeMillis() * 1000; + values.put(MessageColumns.ALERT_STATUS, "1"); + final String selection = + MessageColumns.ALERT_STATUS + "=0 AND (" + + MessageColumns.STATUS + "=" + EsProvider.MESSAGE_STATUS_FAILED_TO_SEND + " OR (" + + MessageColumns.STATUS + "!=" + EsProvider.MESSAGE_STATUS_ON_SERVER + " AND " + + MessageColumns.TIMESTAMP + "+" + thresholdDeltaMs*1000 + "<" + nowMicros + ")) "; + + final int updateCount = helper.getWritableDatabaseWrapper().update( + EsProvider.MESSAGES_TABLE, + values, + selection, + null); + if (updateCount > 0) { + EsConversationsData.notifyConversationsChanged(); + } + }*/ + + static CharSequence applyWarningTextColor(final Context context, + final CharSequence text) { + if (text == null) { + return null; + } + final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); + spanBuilder.append(text); + spanBuilder.setSpan(new ForegroundColorSpan(context.getResources().getColor( + R.color.notification_warning_color)), 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spanBuilder; + } + + /** + * Check for failed messages and post notifications as needed. + * TODO: Rewrite this as a NotificationState. + */ + public static void checkFailedMessages() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final Cursor messageDataCursor = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE, + null /*selectionArgs*/, + null /*groupBy*/, + null /*having*/, + FailedMessageQuery.FAILED_ORDER_BY); + + try { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(context); + if (messageDataCursor != null) { + final MessageData messageData = new MessageData(); + + final HashSet<String> conversationsWithFailedMessages = new HashSet<String>(); + + // track row ids in case we want to display something that requires this + // information + final ArrayList<Integer> failedMessages = new ArrayList<Integer>(); + + int cursorPosition = -1; + final long when = 0; + + messageDataCursor.moveToPosition(-1); + while (messageDataCursor.moveToNext()) { + messageData.bind(messageDataCursor); + + final String conversationId = messageData.getConversationId(); + if (DataModel.get().isNewMessageObservable(conversationId)) { + // Don't post a system notification for an observable conversation + // because we already show an angry red annotation in the conversation + // itself or in the conversation preview snippet. + continue; + } + + cursorPosition = messageDataCursor.getPosition(); + failedMessages.add(cursorPosition); + conversationsWithFailedMessages.add(conversationId); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Found " + failedMessages.size() + " failed messages"); + } + if (failedMessages.size() > 0) { + final NotificationCompat.Builder builder = + new NotificationCompat.Builder(context); + + CharSequence line1; + CharSequence line2; + final boolean isRichContent = false; + ConversationIdSet conversationIds = null; + PendingIntent destinationIntent; + if (failedMessages.size() == 1) { + messageDataCursor.moveToPosition(cursorPosition); + messageData.bind(messageDataCursor); + final String conversationId = messageData.getConversationId(); + + // We have a single conversation, go directly to that conversation. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationActivity(context, + conversationId, + null /*draft*/); + + conversationIds = ConversationIdSet.createSet(conversationId); + + final String failedMessgeSnippet = messageData.getMessageText(); + int failureStringId; + if (messageData.getStatus() == + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) { + failureStringId = + R.string.notification_download_failures_line1_singular; + } else { + failureStringId = R.string.notification_send_failures_line1_singular; + } + line1 = resources.getString(failureStringId); + line2 = failedMessgeSnippet; + // Set rich text for non-SMS messages or MMS push notification messages + // which we generate locally with rich text + // TODO- fix this +// if (messageData.isMmsInd()) { +// isRichContent = true; +// } + } else { + // We have notifications for multiple conversation, go to the conversation + // list. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationListActivity(context); + + int line1StringId; + int line2PluralsId; + if (messageData.getStatus() == + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) { + line1StringId = + R.string.notification_download_failures_line1_plural; + line2PluralsId = R.plurals.notification_download_failures; + } else { + line1StringId = R.string.notification_send_failures_line1_plural; + line2PluralsId = R.plurals.notification_send_failures; + } + line1 = resources.getString(line1StringId); + line2 = resources.getQuantityString( + line2PluralsId, + conversationsWithFailedMessages.size(), + failedMessages.size(), + conversationsWithFailedMessages.size()); + } + line1 = applyWarningTextColor(context, line1); + line2 = applyWarningTextColor(context, line2); + + final PendingIntent pendingIntentForDelete = + UIIntents.get().getPendingIntentForClearingNotifications( + context, + BugleNotifications.UPDATE_ERRORS, + conversationIds, + 0); + + builder + .setContentTitle(line1) + .setTicker(line1) + .setWhen(when > 0 ? when : System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_failed_light) + .setDeleteIntent(pendingIntentForDelete) + .setContentIntent(destinationIntent) + .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure)); + if (isRichContent && !TextUtils.isEmpty(line2)) { + final NotificationCompat.InboxStyle inboxStyle = + new NotificationCompat.InboxStyle(builder); + if (line2 != null) { + inboxStyle.addLine(Html.fromHtml(line2.toString())); + } + builder.setStyle(inboxStyle); + } else { + builder.setContentText(line2); + } + + if (builder != null) { + notificationManager.notify( + BugleNotifications.buildNotificationTag( + PendingIntentConstants.MSG_SEND_ERROR, null), + PendingIntentConstants.MSG_SEND_ERROR, + builder.build()); + } + } else { + notificationManager.cancel( + BugleNotifications.buildNotificationTag( + PendingIntentConstants.MSG_SEND_ERROR, null), + PendingIntentConstants.MSG_SEND_ERROR); + } + } + } finally { + if (messageDataCursor != null) { + messageDataCursor.close(); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/MessageTextStats.java b/src/com/android/messaging/datamodel/MessageTextStats.java new file mode 100644 index 0000000..2bd24ff --- /dev/null +++ b/src/com/android/messaging/datamodel/MessageTextStats.java @@ -0,0 +1,92 @@ +/* + * 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; + +import android.telephony.SmsMessage; + +import com.android.messaging.sms.MmsConfig; + +public class MessageTextStats { + private boolean mMessageLengthRequiresMms; + private int mMessageCount; + private int mCodePointsRemainingInCurrentMessage; + + public MessageTextStats() { + mCodePointsRemainingInCurrentMessage = Integer.MAX_VALUE; + } + + public int getNumMessagesToBeSent() { + return mMessageCount; + } + + public int getCodePointsRemainingInCurrentMessage() { + return mCodePointsRemainingInCurrentMessage; + } + + public boolean getMessageLengthRequiresMms() { + return mMessageLengthRequiresMms; + } + + public void updateMessageTextStats(final int selfSubId, final String messageText) { + final int[] params = SmsMessage.calculateLength(messageText, false); + /* SmsMessage.calculateLength returns an int[4] with: + * int[0] being the number of SMS's required, + * int[1] the number of code points used, + * int[2] is the number of code points remaining until the next message. + * int[3] is the encoding type that should be used for the message. + */ + mMessageCount = params[0]; + mCodePointsRemainingInCurrentMessage = params[2]; + + final MmsConfig mmsConfig = MmsConfig.get(selfSubId); + if (!mmsConfig.getMultipartSmsEnabled() && + !mmsConfig.getSendMultipartSmsAsSeparateMessages()) { + // The provider doesn't support multi-part sms's and we should use MMS to + // send multi-part sms, so as soon as the user types + // an sms longer than one segment, we have to turn the message into an mms. + mMessageLengthRequiresMms = mMessageCount > 1; + } else { + final int threshold = mmsConfig.getSmsToMmsTextThreshold(); + mMessageLengthRequiresMms = threshold > 0 && mMessageCount > threshold; + } + // Some carriers require any SMS message longer than 80 to be sent as MMS + // see b/12122333 + int smsToMmsLengthThreshold = mmsConfig.getSmsToMmsTextLengthThreshold(); + if (smsToMmsLengthThreshold > 0) { + final int usedInCurrentMessage = params[1]; + /* + * A little hacky way to find out if we should count characters in double bytes. + * SmsMessage.calculateLength counts message code units based on the characters + * in input. If all of them are ascii, the max length is + * SmsMessage.MAX_USER_DATA_SEPTETS (160). If any of them are double-byte, like + * Korean or Chinese, the max length is SmsMessage.MAX_USER_DATA_BYTES (140) bytes + * (70 code units). + * Here we check if the total code units we can use is smaller than 140. If so, + * we know we should count threshold in double-byte, so divide the threshold by 2. + * In this way, we will count Korean text correctly with regard to the length threshold. + */ + if (usedInCurrentMessage + mCodePointsRemainingInCurrentMessage + < SmsMessage.MAX_USER_DATA_BYTES) { + smsToMmsLengthThreshold /= 2; + } + if (usedInCurrentMessage > smsToMmsLengthThreshold) { + mMessageLengthRequiresMms = true; + } + } + } + +} diff --git a/src/com/android/messaging/datamodel/MessagingContentProvider.java b/src/com/android/messaging/datamodel/MessagingContentProvider.java new file mode 100644 index 0000000..7688abd --- /dev/null +++ b/src/com/android/messaging/datamodel/MessagingContentProvider.java @@ -0,0 +1,476 @@ +/* + * 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; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import com.android.messaging.BugleApplication; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.widget.BugleWidgetProvider; +import com.android.messaging.widget.WidgetConversationProvider; +import com.google.common.annotations.VisibleForTesting; + +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.PrintWriter; + +/** + * A centralized provider for Uris exposed by Bugle. + * */ +public class MessagingContentProvider extends ContentProvider { + private static final String TAG = LogUtil.BUGLE_TAG; + + @VisibleForTesting + public static final String AUTHORITY = + "com.android.messaging.datamodel.MessagingContentProvider"; + private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/'; + + // Conversations query + private static final String CONVERSATIONS_QUERY = "conversations"; + + public static final Uri CONVERSATIONS_URI = Uri.parse(CONTENT_AUTHORITY + CONVERSATIONS_QUERY); + static final Uri PARTS_URI = Uri.parse(CONTENT_AUTHORITY + DatabaseHelper.PARTS_TABLE); + + // Messages query + private static final String MESSAGES_QUERY = "messages"; + + static final Uri MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + MESSAGES_QUERY); + + public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + + MESSAGES_QUERY + "/conversation"); + + // Conversation participants query + private static final String PARTICIPANTS_QUERY = "participants"; + + static class ConversationParticipantsQueryColumns extends ParticipantColumns { + static final String CONVERSATION_ID = ConversationParticipantsColumns.CONVERSATION_ID; + } + + static final Uri CONVERSATION_PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY + + PARTICIPANTS_QUERY + "/conversation"); + + public static final Uri PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY + PARTICIPANTS_QUERY); + + // Conversation images query + private static final String CONVERSATION_IMAGES_QUERY = "conversation_images"; + + public static final Uri CONVERSATION_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY + + CONVERSATION_IMAGES_QUERY); + + private static final String DRAFT_IMAGES_QUERY = "draft_images"; + + public static final Uri DRAFT_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY + + DRAFT_IMAGES_QUERY); + + /** + * Notifies that <i>all</i> data exposed by the provider needs to be refreshed. + * <p> + * <b>IMPORTANT!</b> You probably shouldn't be calling this. Prefer to notify more specific + * uri's instead. Currently only sync uses this, because sync can potentially update many + * different tables at once. + */ + public static void notifyEverythingChanged() { + final Uri uri = Uri.parse(CONTENT_AUTHORITY); + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + cr.notifyChange(uri, null); + + // Notify any conversations widgets the conversation list has changed. + BugleWidgetProvider.notifyConversationListChanged(context); + + // Notify all conversation widgets to update. + WidgetConversationProvider.notifyMessagesChanged(context, null /*conversationId*/); + } + + /** + * Build a participant uri from the conversation id. + */ + public static Uri buildConversationParticipantsUri(final String conversationId) { + final Uri.Builder builder = CONVERSATION_PARTICIPANTS_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + public static void notifyParticipantsChanged(final String conversationId) { + final Uri uri = buildConversationParticipantsUri(conversationId); + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(uri, null); + } + + public static void notifyAllMessagesChanged() { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(CONVERSATION_MESSAGES_URI, null); + } + + public static void notifyAllParticipantsChanged() { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(CONVERSATION_PARTICIPANTS_URI, null); + } + + // Default value for unknown dimension of image + public static final int UNSPECIFIED_SIZE = -1; + + // Internal + private static final int CONVERSATIONS_QUERY_CODE = 10; + + private static final int CONVERSATION_QUERY_CODE = 20; + private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30; + private static final int CONVERSATION_PARTICIPANTS_QUERY_CODE = 40; + private static final int CONVERSATION_IMAGES_QUERY_CODE = 50; + private static final int DRAFT_IMAGES_QUERY_CODE = 60; + private static final int PARTICIPANTS_QUERY_CODE = 70; + + // TODO: Move to a better structured URI namespace. + private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH); + static { + sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY, CONVERSATIONS_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*", + CONVERSATION_MESSAGES_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*", + CONVERSATION_PARTICIPANTS_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, CONVERSATION_IMAGES_QUERY + "/*", + CONVERSATION_IMAGES_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, DRAFT_IMAGES_QUERY + "/*", + DRAFT_IMAGES_QUERY_CODE); + } + + /** + * Build a messages uri from the conversation id. + */ + public static Uri buildConversationMessagesUri(final String conversationId) { + final Uri.Builder builder = CONVERSATION_MESSAGES_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + public static void notifyMessagesChanged(final String conversationId) { + final Uri uri = buildConversationMessagesUri(conversationId); + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + cr.notifyChange(uri, null); + notifyConversationListChanged(); + + // Notify the widget the messages changed + WidgetConversationProvider.notifyMessagesChanged(context, conversationId); + } + + /** + * Build a conversation metadata uri from a conversation id. + */ + public static Uri buildConversationMetadataUri(final String conversationId) { + final Uri.Builder builder = CONVERSATIONS_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + public static void notifyConversationMetadataChanged(final String conversationId) { + final Uri uri = buildConversationMetadataUri(conversationId); + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(uri, null); + notifyConversationListChanged(); + } + + public static void notifyPartsChanged() { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(PARTS_URI, null); + } + + public static void notifyConversationListChanged() { + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + cr.notifyChange(CONVERSATIONS_URI, null); + + // Notify the widget the conversation list changed + BugleWidgetProvider.notifyConversationListChanged(context); + } + + /** + * Build a conversation images uri from a conversation id. + */ + public static Uri buildConversationImagesUri(final String conversationId) { + final Uri.Builder builder = CONVERSATION_IMAGES_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + /** + * Build a draft images uri from a conversation id. + */ + public static Uri buildDraftImagesUri(final String conversationId) { + final Uri.Builder builder = DRAFT_IMAGES_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + private DatabaseHelper mDatabaseHelper; + private DatabaseWrapper mDatabaseWrapper; + + public MessagingContentProvider() { + super(); + } + + @VisibleForTesting + public void setDatabaseForTest(final DatabaseWrapper db) { + Assert.isTrue(BugleApplication.isRunningTests()); + mDatabaseWrapper = db; + } + + private DatabaseWrapper getDatabaseWrapper() { + if (mDatabaseWrapper == null) { + mDatabaseWrapper = mDatabaseHelper.getDatabase(); + } + return mDatabaseWrapper; + } + + @Override + public Cursor query(final Uri uri, final String[] projection, String selection, + final String[] selectionArgs, String sortOrder) { + + // Processes other than self are allowed to temporarily access the media + // scratch space; we grant uri read access on a case-by-case basis. Dialer app and + // contacts app would doQuery() on the vCard uri before trying to open the inputStream. + // There's nothing that we need to return for this uri so just No-Op. + //if (isMediaScratchSpaceUri(uri)) { + // return null; + //} + + final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + + String[] queryArgs = selectionArgs; + final int match = sURIMatcher.match(uri); + String groupBy = null; + String limit = null; + switch (match) { + case CONVERSATIONS_QUERY_CODE: + queryBuilder.setTables(ConversationListItemData.getConversationListView()); + // Hide empty conversations (ones with 0 sort_timestamp) + queryBuilder.appendWhere(ConversationColumns.SORT_TIMESTAMP + " > 0 "); + break; + case CONVERSATION_QUERY_CODE: + queryBuilder.setTables(ConversationListItemData.getConversationListView()); + if (uri.getPathSegments().size() == 2) { + queryBuilder.appendWhere(ConversationColumns._ID + "=?"); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case CONVERSATION_PARTICIPANTS_QUERY_CODE: + queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE); + if (uri.getPathSegments().size() == 3 && + TextUtils.equals(uri.getPathSegments().get(1), "conversation")) { + queryBuilder.appendWhere(ParticipantColumns._ID + " IN ( " + "SELECT " + + ConversationParticipantsColumns.PARTICIPANT_ID + " AS " + + ParticipantColumns._ID + + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE + + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID + + " =? UNION SELECT " + ParticipantColumns._ID + " FROM " + + DatabaseHelper.PARTICIPANTS_TABLE + " WHERE " + + ParticipantColumns.SUB_ID + " != " + + ParticipantData.OTHER_THAN_SELF_SUB_ID + " )"); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(2)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case PARTICIPANTS_QUERY_CODE: + queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE); + if (uri.getPathSegments().size() != 1) { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case CONVERSATION_MESSAGES_QUERY_CODE: + if (uri.getPathSegments().size() == 3 && + TextUtils.equals(uri.getPathSegments().get(1), "conversation")) { + // Get the conversation id from the uri + final String conversationId = uri.getPathSegments().get(2); + + // We need to handle this query differently, instead of falling through to the + // generic query call at the bottom. For performance reasons, the conversation + // messages query is executed as a raw query. It is invalid to specify + // selection/sorting for this query. + + if (selection == null && selectionArgs == null && sortOrder == null) { + return queryConversationMessages(conversationId, uri); + } else { + throw new IllegalArgumentException( + "Cannot set selection or sort order with this query"); + } + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + case CONVERSATION_IMAGES_QUERY_CODE: + queryBuilder.setTables(ConversationImagePartsView.getViewName()); + if (uri.getPathSegments().size() == 2) { + // Exclude draft. + queryBuilder.appendWhere( + ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " + + ConversationImagePartsView.Columns.STATUS + "<>" + + MessageData.BUGLE_STATUS_OUTGOING_DRAFT); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case DRAFT_IMAGES_QUERY_CODE: + queryBuilder.setTables(ConversationImagePartsView.getViewName()); + if (uri.getPathSegments().size() == 2) { + // Draft only. + queryBuilder.appendWhere( + ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " + + ConversationImagePartsView.Columns.STATUS + "=" + + MessageData.BUGLE_STATUS_OUTGOING_DRAFT); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + default: { + throw new IllegalArgumentException("Unknown URI " + uri); + } + } + + final Cursor cursor = getDatabaseWrapper().query(queryBuilder, projection, selection, + queryArgs, groupBy, null, sortOrder, limit); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + private Cursor queryConversationMessages(final String conversationId, final Uri notifyUri) { + final String[] queryArgs = { conversationId }; + final Cursor cursor = getDatabaseWrapper().rawQuery( + ConversationMessageData.getConversationMessagesQuerySql(), queryArgs); + cursor.setNotificationUri(getContext().getContentResolver(), notifyUri); + return cursor; + } + + @Override + public String getType(final Uri uri) { + final StringBuilder sb = new + StringBuilder("vnd.android.cursor.dir/vnd.android.messaging."); + + switch (sURIMatcher.match(uri)) { + case CONVERSATIONS_QUERY_CODE: { + sb.append(CONVERSATIONS_QUERY); + break; + } + default: { + throw new IllegalArgumentException("Unknown URI: " + uri); + } + } + return sb.toString(); + } + + protected DatabaseHelper getDatabase() { + return DatabaseHelper.getInstance(getContext()); + } + + @Override + public ParcelFileDescriptor openFile(final Uri uri, final String fileMode) + throws FileNotFoundException { + throw new IllegalArgumentException("openFile not supported: " + uri); + } + + @Override + public Uri insert(final Uri uri, final ContentValues values) { + throw new IllegalStateException("Insert not supported " + uri); + } + + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) { + throw new IllegalArgumentException("Delete not supported: " + uri); + } + + @Override + public int update(final Uri uri, final ContentValues values, final String selection, + final String[] selectionArgs) { + throw new IllegalArgumentException("Update not supported: " + uri); + } + + /** + * Prepends new arguments to the existing argument list. + * + * @param oldArgList The current list of arguments. May be {@code null} + * @param args The new arguments to prepend + * @return A new argument list with the given arguments prepended + */ + private String[] prependArgs(final String[] oldArgList, final String... args) { + if (args == null || args.length == 0) { + return oldArgList; + } + final int oldArgCount = (oldArgList == null ? 0 : oldArgList.length); + final int newArgCount = args.length; + + final String[] newArgs = new String[oldArgCount + newArgCount]; + System.arraycopy(args, 0, newArgs, 0, newArgCount); + if (oldArgCount > 0) { + System.arraycopy(oldArgList, 0, newArgs, newArgCount, oldArgCount); + } + return newArgs; + } + /** + * {@inheritDoc} + */ + @Override + public void dump(final FileDescriptor fd, final PrintWriter writer, final String[] args) { + // First dump out the default SMS app package name + String defaultSmsApp = PhoneUtils.getDefault().getDefaultSmsApp(); + if (TextUtils.isEmpty(defaultSmsApp)) { + if (OsUtil.isAtLeastKLP()) { + defaultSmsApp = "None"; + } else { + defaultSmsApp = "None (pre-Kitkat)"; + } + } + writer.println("Default SMS app: " + defaultSmsApp); + // Now dump logs + LogUtil.dump(writer); + } + + @Override + public boolean onCreate() { + // This is going to wind up calling into createDatabase() below. + mDatabaseHelper = (DatabaseHelper) getDatabase(); + // We cannot initialize mDatabaseWrapper yet as the Factory may not be initialized + return true; + } +} diff --git a/src/com/android/messaging/datamodel/MmsFileProvider.java b/src/com/android/messaging/datamodel/MmsFileProvider.java new file mode 100644 index 0000000..0022630 --- /dev/null +++ b/src/com/android/messaging/datamodel/MmsFileProvider.java @@ -0,0 +1,69 @@ +/* + * 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; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.Factory; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; + +/** + * A very simple content provider that can serve mms files from our cache directory. + */ +public class MmsFileProvider extends FileProvider { + private static final String TAG = LogUtil.BUGLE_TAG; + + @VisibleForTesting + static final String AUTHORITY = "com.android.messaging.datamodel.MmsFileProvider"; + private static final String RAW_MMS_DIR = "rawmms"; + + /** + * Returns a uri that can be used to access a raw mms file. + * + * @return the URI for an raw mms file + */ + public static Uri buildRawMmsUri() { + final Uri uri = FileProvider.buildFileUri(AUTHORITY, null); + final File file = getFile(uri.getPath()); + if (!ensureFileExists(file)) { + LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath()); + } + return uri; + } + + @Override + File getFile(final String path, final String extension) { + return getFile(path); + } + + public static File getFile(final Uri uri) { + return getFile(uri.getPath()); + } + + private static File getFile(final String path) { + final Context context = Factory.get().getApplicationContext(); + return new File(getDirectory(context), path + ".dat"); + } + + private static File getDirectory(final Context context) { + return new File(context.getCacheDir(), RAW_MMS_DIR); + } +} diff --git a/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java b/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java new file mode 100644 index 0000000..791ff34 --- /dev/null +++ b/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java @@ -0,0 +1,148 @@ +/* + * 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; + +import android.app.IntentService; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.RemoteInput; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.action.UpdateMessageNotificationAction; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.util.LogUtil; + +/** + * Respond to a special intent and send an SMS message without the user's intervention, unless + * the intent extra "showUI" is true. + */ +public class NoConfirmationSmsSendService extends IntentService { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final String EXTRA_SUBSCRIPTION = "subscription"; + public static final String EXTRA_SELF_ID = "self_id"; + + public NoConfirmationSmsSendService() { + // Class name will be the thread name. + super(NoConfirmationSmsSendService.class.getName()); + + // Intent should be redelivered if the process gets killed before completing the job. + setIntentRedelivery(true); + } + + @Override + protected void onHandleIntent(final Intent intent) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent"); + } + + final String action = intent.getAction(); + if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(action)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent wrong action: " + + action); + } + return; + } + final Bundle extras = intent.getExtras(); + if (extras == null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Called to send SMS but no extras"); + } + return; + } + + // Get all possible extras from intent + final String conversationId = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + final String selfId = intent.getStringExtra(EXTRA_SELF_ID); + final boolean requiresMms = intent.getBooleanExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, + false); + final String message = getText(intent, Intent.EXTRA_TEXT); + final String subject = getText(intent, Intent.EXTRA_SUBJECT); + final int subId = extras.getInt(EXTRA_SUBSCRIPTION, ParticipantData.DEFAULT_SELF_SUB_ID); + + final Uri intentUri = intent.getData(); + final String recipients = intentUri != null ? MmsUtils.getSmsRecipients(intentUri) : null; + + if (TextUtils.isEmpty(recipients) && TextUtils.isEmpty(conversationId)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Both conversationId and recipient(s) cannot be empty"); + } + return; + } + + if (extras.getBoolean("showUI", false)) { + startActivity(new Intent(this, ConversationListActivity.class)); + } else { + if (TextUtils.isEmpty(message)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Message cannot be empty"); + } + return; + } + + // TODO: it's possible that a long message would require sending it via mms, + // but we're not testing for that here and we're sending the message as an sms. + + if (TextUtils.isEmpty(conversationId)) { + InsertNewMessageAction.insertNewMessage(subId, recipients, message, subject); + } else { + MessageData messageData = null; + if (requiresMms) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Auto-sending MMS message in conversation: " + + conversationId); + } + messageData = MessageData.createDraftMmsMessage(conversationId, selfId, message, + subject); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Auto-sending SMS message in conversation: " + + conversationId); + } + messageData = MessageData.createDraftSmsMessage(conversationId, selfId, + message); + } + InsertNewMessageAction.insertNewMessage(messageData); + } + UpdateMessageNotificationAction.updateMessageNotification(); + } + } + + private String getText(final Intent intent, final String textType) { + final String message = intent.getStringExtra(textType); + if (message == null) { + final Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + if (remoteInput != null) { + final CharSequence extra = remoteInput.getCharSequence(textType); + if (extra != null) { + return extra.toString(); + } + } + } + return message; + } + +} diff --git a/src/com/android/messaging/datamodel/NotificationState.java b/src/com/android/messaging/datamodel/NotificationState.java new file mode 100644 index 0000000..d589874 --- /dev/null +++ b/src/com/android/messaging/datamodel/NotificationState.java @@ -0,0 +1,149 @@ +/* + * 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; + +import android.app.PendingIntent; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; + +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.ConversationIdSet; + +import java.util.ArrayList; +import java.util.HashSet; + +/** + * Base class for representing notifications. The main reason for this class is that in order to + * show pictures or avatars they might need to be loaded in the background. This class and + * subclasses can do the main work to get the notification ready and then wait until any images + * that are needed are ready before posting. + * + * The creation of a notification is split into two parts. The NotificationState ctor should + * setup the basic information including the mContentIntent. A Notification Builder is created in + * RealTimeChatNotifications and passed to the build() method of each notification where the + * Notification is fully specified. + * + * TODO: There is still some duplication and inconsistency in the utility functions and + * placement of different building blocks across notification types (e.g. summary text for accounts) + */ +public abstract class NotificationState { + private static final int CONTENT_INTENT_REQUEST_CODE_OFFSET = 0; + private static final int CLEAR_INTENT_REQUEST_CODE_OFFSET = 1; + private static final int NUM_REQUEST_CODES_NEEDED = 2; + + public interface FailedMessageQuery { + static final String FAILED_MESSAGES_WHERE_CLAUSE = + "((" + MessageColumns.STATUS + " = " + + MessageData.BUGLE_STATUS_OUTGOING_FAILED + " OR " + + MessageColumns.STATUS + " = " + + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED + ") AND " + + DatabaseHelper.MessageColumns.SEEN + " = 0)"; + + static final String FAILED_ORDER_BY = DatabaseHelper.MessageColumns.CONVERSATION_ID + ", " + + DatabaseHelper.MessageColumns.SENT_TIMESTAMP + " asc"; + } + + public final ConversationIdSet mConversationIds; + public final HashSet<String> mPeople; + + public NotificationCompat.Style mNotificationStyle; + public NotificationCompat.Builder mNotificationBuilder; + public boolean mCanceled; + public int mType; + public int mBaseRequestCode; + public ArrayList<Uri> mParticipantAvatarsUris = null; + public ArrayList<Uri> mParticipantContactUris = null; + + NotificationState(final ConversationIdSet conversationIds) { + mConversationIds = conversationIds; + mPeople = new HashSet<String>(); + } + + /** + * The intent to be triggered when the notification is dismissed. + */ + public abstract PendingIntent getClearIntent(); + + protected Uri getAttachmentUri() { + return null; + } + + // Returns the mime type of the attachment (See ContentType class for definitions) + protected String getAttachmentType() { + return null; + } + + /** + * Build the notification using the given builder. + * @param builder + * @return The style of the notification. + */ + protected abstract NotificationCompat.Style build(NotificationCompat.Builder builder); + + protected void setAvatarUrlsForConversation(final String conversationId) { + } + + protected void setPeopleForConversation(final String conversationId) { + } + + /** + * Reserves request codes for this notification type. By default 2 codes are reserved, one for + * the main intent and another for the cancel intent. Override this function to reserve more. + */ + public int getNumRequestCodesNeeded() { + return NUM_REQUEST_CODES_NEEDED; + } + + public int getContentIntentRequestCode() { + return mBaseRequestCode + CONTENT_INTENT_REQUEST_CODE_OFFSET; + } + + public int getClearIntentRequestCode() { + return mBaseRequestCode + CLEAR_INTENT_REQUEST_CODE_OFFSET; + } + + /** + * Gets the appropriate icon needed for notifications. + */ + public abstract int getIcon(); + + /** + * @return the type of notification that should be used from {@link RealTimeChatNotifications} + * so that the proper ringtone and vibrate settings can be used. + */ + public int getLatestMessageNotificationType() { + return BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + + /** + * @return the notification priority level for this notification. + */ + public abstract int getPriority(); + + /** @return custom ringtone URI or null if not set */ + public String getRingtoneUri() { + return null; + } + + public boolean getNotificationVibrate() { + return false; + } + + public long getLatestReceivedTimestamp() { + return Long.MIN_VALUE; + } +} diff --git a/src/com/android/messaging/datamodel/ParticipantRefresh.java b/src/com/android/messaging/datamodel/ParticipantRefresh.java new file mode 100644 index 0000000..5324496 --- /dev/null +++ b/src/com/android/messaging/datamodel/ParticipantRefresh.java @@ -0,0 +1,738 @@ +/* + * 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; + +import android.content.ContentValues; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.graphics.Color; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.ArrayMap; +import android.telephony.SubscriptionInfo; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantData.ParticipantsQuery; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Utility class for refreshing participant information based on matching contact. This updates + * 1. name, photo_uri, matching contact_id of participants. + * 2. generated_name of conversations. + * + * There are two kinds of participant refreshes, + * 1. Full refresh, this is triggered at application start or activity resumes after contact + * change is detected. + * 2. Partial refresh, this is triggered when a participant is added to a conversation. This + * normally happens during SMS sync. + */ +@VisibleForTesting +public class ParticipantRefresh { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Refresh all participants including ones that were resolved before. + */ + public static final int REFRESH_MODE_FULL = 0; + + /** + * Refresh all unresolved participants. + */ + public static final int REFRESH_MODE_INCREMENTAL = 1; + + /** + * Force refresh all self participants. + */ + public static final int REFRESH_MODE_SELF_ONLY = 2; + + public static class ConversationParticipantsQuery { + public static final String[] PROJECTION = new String[] { + ConversationParticipantsColumns._ID, + ConversationParticipantsColumns.CONVERSATION_ID, + ConversationParticipantsColumns.PARTICIPANT_ID + }; + + public static final int INDEX_ID = 0; + public static final int INDEX_CONVERSATION_ID = 1; + public static final int INDEX_PARTICIPANT_ID = 2; + } + + // Track whether observer is initialized or not. + private static volatile boolean sObserverInitialized = false; + private static final Object sLock = new Object(); + private static final AtomicBoolean sFullRefreshScheduled = new AtomicBoolean(false); + private static final Runnable sFullRefreshRunnable = new Runnable() { + @Override + public void run() { + final boolean oldScheduled = sFullRefreshScheduled.getAndSet(false); + Assert.isTrue(oldScheduled); + refreshParticipants(REFRESH_MODE_FULL); + } + }; + private static final Runnable sSelfOnlyRefreshRunnable = new Runnable() { + @Override + public void run() { + refreshParticipants(REFRESH_MODE_SELF_ONLY); + } + }; + + /** + * A customized content resolver to track contact changes. + */ + public static class ContactContentObserver extends ContentObserver { + private volatile boolean mContactChanged = false; + + public ContactContentObserver() { + super(null); + } + + @Override + public void onChange(final boolean selfChange) { + super.onChange(selfChange); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Contacts changed"); + } + mContactChanged = true; + } + + public boolean getContactChanged() { + return mContactChanged; + } + + public void resetContactChanged() { + mContactChanged = false; + } + + public void initialize() { + // TODO: Handle enterprise contacts post M once contacts provider supports it + Factory.get().getApplicationContext().getContentResolver().registerContentObserver( + Phone.CONTENT_URI, true, this); + mContactChanged = true; // Force a full refresh on initialization. + } + } + + /** + * Refresh participants only if needed, i.e., application start or contact changed. + */ + public static void refreshParticipantsIfNeeded() { + if (ParticipantRefresh.getNeedFullRefresh() && + sFullRefreshScheduled.compareAndSet(false, true)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Started full participant refresh"); + } + SafeAsyncTask.executeOnThreadPool(sFullRefreshRunnable); + } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Skipped full participant refresh"); + } + } + + /** + * Refresh self participants on subscription or settings change. + */ + public static void refreshSelfParticipants() { + SafeAsyncTask.executeOnThreadPool(sSelfOnlyRefreshRunnable); + } + + private static boolean getNeedFullRefresh() { + final ContactContentObserver observer = Factory.get().getContactContentObserver(); + if (observer == null) { + // If there is no observer (for unittest cases), we don't need to refresh participants. + return false; + } + + if (!sObserverInitialized) { + synchronized (sLock) { + if (!sObserverInitialized) { + observer.initialize(); + sObserverInitialized = true; + } + } + } + + return observer.getContactChanged(); + } + + private static void resetNeedFullRefresh() { + final ContactContentObserver observer = Factory.get().getContactContentObserver(); + if (observer != null) { + observer.resetContactChanged(); + } + } + + /** + * This class is totally static. Make constructor to be private so that an instance + * of this class would not be created by by mistake. + */ + private ParticipantRefresh() { + } + + /** + * Refresh participants in Bugle. + * + * @param refreshMode the refresh mode desired. See {@link #REFRESH_MODE_FULL}, + * {@link #REFRESH_MODE_INCREMENTAL}, and {@link #REFRESH_MODE_SELF_ONLY} + */ + @VisibleForTesting + static void refreshParticipants(final int refreshMode) { + Assert.inRange(refreshMode, REFRESH_MODE_FULL, REFRESH_MODE_SELF_ONLY); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + switch (refreshMode) { + case REFRESH_MODE_FULL: + LogUtil.v(TAG, "Start full participant refresh"); + break; + case REFRESH_MODE_INCREMENTAL: + LogUtil.v(TAG, "Start partial participant refresh"); + break; + case REFRESH_MODE_SELF_ONLY: + LogUtil.v(TAG, "Start self participant refresh"); + break; + } + } + + if (!ContactUtil.hasReadContactsPermission() || !OsUtil.hasPhonePermission()) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Skipping participant referesh because of permissions"); + } + return; + } + + if (refreshMode == REFRESH_MODE_FULL) { + // resetNeedFullRefresh right away so that we will skip duplicated full refresh + // requests. + resetNeedFullRefresh(); + } + + if (refreshMode == REFRESH_MODE_FULL || refreshMode == REFRESH_MODE_SELF_ONLY) { + refreshSelfParticipantList(); + } + + final ArrayList<String> changedParticipants = new ArrayList<String>(); + + String selection = null; + String[] selectionArgs = null; + + if (refreshMode == REFRESH_MODE_INCREMENTAL) { + // In case of incremental refresh, filter out participants that are already resolved. + selection = ParticipantColumns.CONTACT_ID + "=?"; + selectionArgs = new String[] { + String.valueOf(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED) }; + } else if (refreshMode == REFRESH_MODE_SELF_ONLY) { + // In case of self-only refresh, filter out non-self participants. + selection = SELF_PARTICIPANTS_CLAUSE; + selectionArgs = null; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + Cursor cursor = null; + boolean selfUpdated = false; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, selection, selectionArgs, null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + try { + final ParticipantData participantData = + ParticipantData.getFromCursor(cursor); + if (refreshParticipant(db, participantData)) { + if (participantData.isSelf()) { + selfUpdated = true; + } + updateParticipant(db, participantData); + final String id = participantData.getId(); + changedParticipants.add(id); + } + } catch (final Exception exception) { + // Failure to update one participant shouldn't cancel the entire refresh. + // Log the failure so we know what's going on and resume the loop. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "ParticipantRefresh: Failed to " + + "update participant", exception); + } + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Number of participants refreshed:" + changedParticipants.size()); + } + + // Refresh conversations for participants that are changed. + if (changedParticipants.size() > 0) { + BugleDatabaseOperations.refreshConversationsForParticipants(changedParticipants); + } + if (selfUpdated) { + // Boom + MessagingContentProvider.notifyAllParticipantsChanged(); + MessagingContentProvider.notifyAllMessagesChanged(); + } + } + + private static final String SELF_PARTICIPANTS_CLAUSE = ParticipantColumns.SUB_ID + + " NOT IN ( " + + ParticipantData.OTHER_THAN_SELF_SUB_ID + + " )"; + + private static final Set<Integer> getExistingSubIds() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final HashSet<Integer> existingSubIds = new HashSet<Integer>(); + + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, + SELF_PARTICIPANTS_CLAUSE, null, null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + final int subId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID); + existingSubIds.add(subId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return existingSubIds; + } + + private static final String UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL = + "UPDATE " + DatabaseHelper.PARTICIPANTS_TABLE + " SET " + + ParticipantColumns.SIM_SLOT_ID + " = %d, " + + ParticipantColumns.SUBSCRIPTION_COLOR + " = %d, " + + ParticipantColumns.SUBSCRIPTION_NAME + " = %s " + + " WHERE %s"; + + static String getUpdateSelfParticipantSubscriptionInfoSql(final int slotId, + final int subscriptionColor, final String subscriptionName, final String where) { + return String.format((Locale) null /* construct SQL string without localization */, + UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL, + slotId, subscriptionColor, subscriptionName, where); + } + + /** + * Ensure that there is a self participant corresponding to every active SIM. Also, ensure + * that any other older SIM self participants are marked as inactive. + */ + private static void refreshSelfParticipantList() { + if (!OsUtil.isAtLeastL_MR1()) { + return; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final List<SubscriptionInfo> subInfoRecords = + PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList(); + final ArrayMap<Integer, SubscriptionInfo> activeSubscriptionIdToRecordMap = + new ArrayMap<Integer, SubscriptionInfo>(); + db.beginTransaction(); + final Set<Integer> existingSubIds = getExistingSubIds(); + + try { + if (subInfoRecords != null) { + for (final SubscriptionInfo subInfoRecord : subInfoRecords) { + final int subId = subInfoRecord.getSubscriptionId(); + // If its a new subscription, add it to the database. + if (!existingSubIds.contains(subId)) { + db.execSQL(DatabaseHelper.getCreateSelfParticipantSql(subId)); + // Add it to the local set to guard against duplicated entries returned + // by subscription manager. + existingSubIds.add(subId); + } + activeSubscriptionIdToRecordMap.put(subId, subInfoRecord); + + if (subId == PhoneUtils.getDefault().getDefaultSmsSubscriptionId()) { + // This is the system default subscription, so update the default self. + activeSubscriptionIdToRecordMap.put(ParticipantData.DEFAULT_SELF_SUB_ID, + subInfoRecord); + } + } + } + + // For subscriptions already in the database, refresh ParticipantColumns.SIM_SLOT_ID. + for (final Integer subId : activeSubscriptionIdToRecordMap.keySet()) { + final SubscriptionInfo record = activeSubscriptionIdToRecordMap.get(subId); + final String displayName = + DatabaseUtils.sqlEscapeString(record.getDisplayName().toString()); + db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(record.getSimSlotIndex(), + record.getIconTint(), displayName, + ParticipantColumns.SUB_ID + " = " + subId)); + } + db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql( + ParticipantData.INVALID_SLOT_ID, Color.TRANSPARENT, "''", + ParticipantColumns.SUB_ID + " NOT IN (" + + Joiner.on(", ").join(activeSubscriptionIdToRecordMap.keySet()) + ")")); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + // Fix up conversation self ids by reverting to default self for conversations whose self + // ids are no longer active. + refreshConversationSelfIds(); + } + + /** + * Refresh one participant. + * @return true if the ParticipantData was changed + */ + public static boolean refreshParticipant(final DatabaseWrapper db, + final ParticipantData participantData) { + boolean updated = false; + + if (participantData.isSelf()) { + final int selfChange = refreshFromSelfProfile(db, participantData); + + if (selfChange == SELF_PROFILE_EXISTS) { + // If a self-profile exists, it takes precedence over Contacts data. So we are done. + return true; + } + + updated = (selfChange == SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED); + + // Fall-through and try to update based on Contacts data + } + + updated |= refreshFromContacts(db, participantData); + return updated; + } + + private static final int SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED = 1; + private static final int SELF_PROFILE_EXISTS = 2; + + private static int refreshFromSelfProfile(final DatabaseWrapper db, + final ParticipantData participantData) { + int changed = 0; + // Refresh the phone number based on information from telephony + if (participantData.updatePhoneNumberForSelfIfChanged()) { + changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED; + } + + if (OsUtil.isAtLeastL_MR1()) { + // Refresh the subscription info based on information from SubscriptionManager. + final SubscriptionInfo subscriptionInfo = + PhoneUtils.get(participantData.getSubId()).toLMr1().getActiveSubscriptionInfo(); + if (participantData.updateSubscriptionInfoForSelfIfChanged(subscriptionInfo)) { + changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED; + } + } + + // For self participant, try getting name/avatar from self profile in CP2 first. + // TODO: in case of multi-sim, profile would not be able to be used for + // different numbers. Need to figure out that. + Cursor selfCursor = null; + try { + selfCursor = ContactUtil.getSelf(db.getContext()).performSynchronousQuery(); + if (selfCursor != null && selfCursor.getCount() > 0) { + selfCursor.moveToNext(); + final long selfContactId = selfCursor.getLong(ContactUtil.INDEX_CONTACT_ID); + participantData.setContactId(selfContactId); + participantData.setFullName(selfCursor.getString( + ContactUtil.INDEX_DISPLAY_NAME)); + participantData.setFirstName( + ContactUtil.lookupFirstName(db.getContext(), selfContactId)); + participantData.setProfilePhotoUri(selfCursor.getString( + ContactUtil.INDEX_PHOTO_URI)); + participantData.setLookupKey(selfCursor.getString( + ContactUtil.INDEX_SELF_QUERY_LOOKUP_KEY)); + return SELF_PROFILE_EXISTS; + } + } catch (final Exception exception) { + // It's possible for contact query to fail and we don't want that to crash our app. + // However, we need to at least log the exception so we know something was wrong. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " + + "participant. exception=" + exception); + } finally { + if (selfCursor != null) { + selfCursor.close(); + } + } + return changed; + } + + private static boolean refreshFromContacts(final DatabaseWrapper db, + final ParticipantData participantData) { + final String normalizedDestination = participantData.getNormalizedDestination(); + final long currentContactId = participantData.getContactId(); + final String currentDisplayName = participantData.getFullName(); + final String currentFirstName = participantData.getFirstName(); + final String currentPhotoUri = participantData.getProfilePhotoUri(); + final String currentContactDestination = participantData.getContactDestination(); + + Cursor matchingContactCursor = null; + long matchingContactId = -1; + String matchingDisplayName = null; + String matchingFirstName = null; + String matchingPhotoUri = null; + String matchingLookupKey = null; + String matchingDestination = null; + boolean updated = false; + + if (TextUtils.isEmpty(normalizedDestination)) { + // The normalized destination can be "" for the self id if we can't get it from the + // SIM. Some contact providers throw an IllegalArgumentException if you lookup "", + // so we early out. + return false; + } + + try { + matchingContactCursor = ContactUtil.lookupDestination(db.getContext(), + normalizedDestination).performSynchronousQuery(); + if (matchingContactCursor == null || matchingContactCursor.getCount() == 0) { + // If there is no match, mark the participant as contact not found. + if (currentContactId != ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND) { + participantData.setContactId(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND); + participantData.setFullName(null); + participantData.setFirstName(null); + participantData.setProfilePhotoUri(null); + participantData.setLookupKey(null); + updated = true; + } + return updated; + } + + while (matchingContactCursor.moveToNext()) { + final long contactId = matchingContactCursor.getLong(ContactUtil.INDEX_CONTACT_ID); + // Pick either the first contact or the contact with same id as previous matched + // contact id. + if (matchingContactId == -1 || currentContactId == contactId) { + matchingContactId = contactId; + matchingDisplayName = matchingContactCursor.getString( + ContactUtil.INDEX_DISPLAY_NAME); + matchingFirstName = ContactUtil.lookupFirstName(db.getContext(), contactId); + matchingPhotoUri = matchingContactCursor.getString( + ContactUtil.INDEX_PHOTO_URI); + matchingLookupKey = matchingContactCursor.getString( + ContactUtil.INDEX_LOOKUP_KEY); + matchingDestination = matchingContactCursor.getString( + ContactUtil.INDEX_PHONE_EMAIL); + } + + // There is no need to try other contacts if the current contactId was not filled... + if (currentContactId < 0 + // or we found the matching contact id + || currentContactId == contactId) { + break; + } + } + } catch (final Exception exception) { + // It's possible for contact query to fail and we don't want that to crash our app. + // However, we need to at least log the exception so we know something was wrong. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " + + "participant. exception=" + exception); + return false; + } finally { + if (matchingContactCursor != null) { + matchingContactCursor.close(); + } + } + + // Update participant only if something changed. + final boolean isContactIdChanged = (matchingContactId != currentContactId); + final boolean isDisplayNameChanged = + !TextUtils.equals(matchingDisplayName, currentDisplayName); + final boolean isFirstNameChanged = !TextUtils.equals(matchingFirstName, currentFirstName); + final boolean isPhotoUrlChanged = !TextUtils.equals(matchingPhotoUri, currentPhotoUri); + final boolean isDestinationChanged = !TextUtils.equals(matchingDestination, + currentContactDestination); + + if (isContactIdChanged || isDisplayNameChanged || isFirstNameChanged || isPhotoUrlChanged + || isDestinationChanged) { + participantData.setContactId(matchingContactId); + participantData.setFullName(matchingDisplayName); + participantData.setFirstName(matchingFirstName); + participantData.setProfilePhotoUri(matchingPhotoUri); + participantData.setLookupKey(matchingLookupKey); + participantData.setContactDestination(matchingDestination); + if (isDestinationChanged) { + // Update the send destination to the new one entered by user in Contacts. + participantData.setSendDestination(matchingDestination); + } + updated = true; + } + + return updated; + } + + /** + * Update participant with matching contact's contactId, displayName and photoUri. + */ + private static void updateParticipant(final DatabaseWrapper db, + final ParticipantData participantData) { + final ContentValues values = new ContentValues(); + if (participantData.isSelf()) { + // Self participants can refresh their normalized phone numbers + values.put(ParticipantColumns.NORMALIZED_DESTINATION, + participantData.getNormalizedDestination()); + values.put(ParticipantColumns.DISPLAY_DESTINATION, + participantData.getDisplayDestination()); + } + values.put(ParticipantColumns.CONTACT_ID, participantData.getContactId()); + values.put(ParticipantColumns.LOOKUP_KEY, participantData.getLookupKey()); + values.put(ParticipantColumns.FULL_NAME, participantData.getFullName()); + values.put(ParticipantColumns.FIRST_NAME, participantData.getFirstName()); + values.put(ParticipantColumns.PROFILE_PHOTO_URI, participantData.getProfilePhotoUri()); + values.put(ParticipantColumns.CONTACT_DESTINATION, participantData.getContactDestination()); + values.put(ParticipantColumns.SEND_DESTINATION, participantData.getSendDestination()); + + db.beginTransaction(); + try { + db.update(DatabaseHelper.PARTICIPANTS_TABLE, values, ParticipantColumns._ID + "=?", + new String[] { participantData.getId() }); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Get a list of inactive self ids in the participants table. + */ + private static List<String> getInactiveSelfParticipantIds() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final List<String> inactiveSelf = new ArrayList<String>(); + + final String selection = ParticipantColumns.SIM_SLOT_ID + "=? AND " + + SELF_PARTICIPANTS_CLAUSE; + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns._ID }, + selection, new String[] { String.valueOf(ParticipantData.INVALID_SLOT_ID) }, + null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + final String participantId = cursor.getString(0); + inactiveSelf.add(participantId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return inactiveSelf; + } + + /** + * Gets a list of conversations with the given self ids. + */ + private static List<String> getConversationsWithSelfParticipantIds(final List<String> selfIds) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final List<String> conversationIds = new ArrayList<String>(); + + Cursor cursor = null; + try { + final StringBuilder selectionList = new StringBuilder(); + for (int i = 0; i < selfIds.size(); i++) { + selectionList.append('?'); + if (i < selfIds.size() - 1) { + selectionList.append(','); + } + } + final String selection = + ConversationColumns.CURRENT_SELF_ID + " IN (" + selectionList + ")"; + cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID }, + selection, selfIds.toArray(new String[0]), + null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + final String conversationId = cursor.getString(0); + conversationIds.add(conversationId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return conversationIds; + } + + /** + * Refresh one conversation's self id. + */ + private static void updateConversationSelfId(final String conversationId, + final String selfId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationSelfIdInTransaction(db, conversationId, + selfId); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + UIIntents.get().broadcastConversationSelfIdChange(db.getContext(), conversationId, selfId); + } + + /** + * After refreshing the self participant list, find all conversations with inactive self ids, + * and switch them back to system default. + */ + private static void refreshConversationSelfIds() { + final List<String> inactiveSelfs = getInactiveSelfParticipantIds(); + if (inactiveSelfs.size() == 0) { + return; + } + final List<String> conversationsToRefresh = + getConversationsWithSelfParticipantIds(inactiveSelfs); + if (conversationsToRefresh.size() == 0) { + return; + } + final DatabaseWrapper db = DataModel.get().getDatabase(); + final ParticipantData defaultSelf = + BugleDatabaseOperations.getOrCreateSelf(db, ParticipantData.DEFAULT_SELF_SUB_ID); + + if (defaultSelf != null) { + for (final String conversationId : conversationsToRefresh) { + updateConversationSelfId(conversationId, defaultSelf.getId()); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/SyncManager.java b/src/com/android/messaging/datamodel/SyncManager.java new file mode 100644 index 0000000..b3571bf --- /dev/null +++ b/src/com/android/messaging/datamodel/SyncManager.java @@ -0,0 +1,478 @@ +/* + * 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; + +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.provider.Telephony; +import android.support.v4.util.LongSparseArray; + +import com.android.messaging.datamodel.action.SyncMessagesAction; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/** + * This class manages message sync with the Telephony SmsProvider/MmsProvider. + */ +public class SyncManager { + private static final String TAG = LogUtil.BUGLE_TAG; + + /** + * Record of any user customization to conversation settings + */ + public static class ConversationCustomization { + private final boolean mArchived; + private final boolean mMuted; + private final boolean mNoVibrate; + private final String mNotificationSoundUri; + + public ConversationCustomization(final boolean archived, final boolean muted, + final boolean noVibrate, final String notificationSoundUri) { + mArchived = archived; + mMuted = muted; + mNoVibrate = noVibrate; + mNotificationSoundUri = notificationSoundUri; + } + + public boolean isArchived() { + return mArchived; + } + + public boolean isMuted() { + return mMuted; + } + + public boolean noVibrate() { + return mNoVibrate; + } + + public String getNotificationSoundUri() { + return mNotificationSoundUri; + } + } + + SyncManager() { + } + + /** + * Timestamp of in progress sync - used to keep track of whether sync is running + */ + private long mSyncInProgressTimestamp = -1; + + /** + * Timestamp of current sync batch upper bound - used to determine if message makes batch dirty + */ + private long mCurrentUpperBoundTimestamp = -1; + + /** + * Timestamp of messages inserted since sync batch started - used to determine if batch dirty + */ + private long mMaxRecentChangeTimestamp = -1L; + + private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache(); + + /** + * User customization to conversations. If this is set, we need to recover them after + * a full sync. + */ + private LongSparseArray<ConversationCustomization> mCustomization = null; + + /** + * Start an incremental sync (backed off a few seconds) + */ + public static void sync() { + SyncMessagesAction.sync(); + } + + /** + * Start an incremental sync (with no backoff) + */ + public static void immediateSync() { + SyncMessagesAction.immediateSync(); + } + + /** + * Start a full sync (for debugging) + */ + public static void forceSync() { + SyncMessagesAction.fullSync(); + } + + /** + * Called from data model thread when starting a sync batch + * @param upperBoundTimestamp upper bound timestamp for sync batch + */ + public synchronized void startSyncBatch(final long upperBoundTimestamp) { + Assert.isTrue(mCurrentUpperBoundTimestamp < 0); + mCurrentUpperBoundTimestamp = upperBoundTimestamp; + mMaxRecentChangeTimestamp = -1L; + } + + /** + * Called from data model thread at end of batch to determine if any messages added in window + * @param lowerBoundTimestamp lower bound timestamp for sync batch + * @return true if message added within window from lower to upper bound timestamp of batch + */ + public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) { + Assert.isTrue(mCurrentUpperBoundTimestamp >= 0); + final long max = mMaxRecentChangeTimestamp; + + final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp + + " to " + mCurrentUpperBoundTimestamp + " is " + + (dirty ? "DIRTY" : "clean") + "; max change timestamp = " + + mMaxRecentChangeTimestamp); + } + + mCurrentUpperBoundTimestamp = -1L; + mMaxRecentChangeTimestamp = -1L; + + return dirty; + } + + /** + * Called from data model or background worker thread to indicate start of message add process + * (add must complete on that thread before action transitions to new thread/stage) + * @param timestamp timestamp of message being added + */ + public synchronized void onNewMessageInserted(final long timestamp) { + if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) { + // Message insert in current sync window + mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of " + + "current sync batch " + mCurrentUpperBoundTimestamp); + } + } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of " + + "current sync batch " + mCurrentUpperBoundTimestamp); + } + } + + /** + * Synchronously checks whether sync is allowed and starts sync if allowed + * @param full - true indicates a full (not incremental) sync operation + * @param startTimestamp - starttimestamp for this sync (if allowed) + * @return - true if sync should start + */ + public synchronized boolean shouldSync(final boolean full, final long startTimestamp) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "") + + "at " + startTimestamp); + } + + if (full) { + final long delayUntilFullSync = delayUntilFullSync(startTimestamp); + if (delayUntilFullSync > 0) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp + + " delayed for " + delayUntilFullSync + " ms"); + } + return false; + } + } + + if (isSyncing()) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "") + + "sync yet; still running sync started at " + mSyncInProgressTimestamp); + } + return false; + } + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at " + + startTimestamp); + } + + mSyncInProgressTimestamp = startTimestamp; + + return true; + } + + /** + * Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately) + * @param startTimestamp Timestamp used to start the sync + * @return 0 if allowed to run now, else delay in ms + */ + public long delayUntilFullSync(final long startTimestamp) { + final BugleGservices bugleGservices = BugleGservices.get(); + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + + final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L); + final long smsFullSyncBackoffTimeMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS, + BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); + final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp : + lastFullSyncTime + smsFullSyncBackoffTimeMillis); + + final long delayUntilFullSync = noFullSyncBefore - startTimestamp; + if (delayUntilFullSync > 0) { + return delayUntilFullSync; + } + return 0; + } + + /** + * Check if sync currently in progress (public for asserts/logging). + */ + public synchronized boolean isSyncing() { + return (mSyncInProgressTimestamp >= 0); + } + + /** + * Check if sync batch should be in progress - compares upperBound with in memory value + * @param upperBoundTimestamp - upperbound timestamp for sync batch + * @return - true if timestamps match (otherwise batch is orphan from older process) + */ + public synchronized boolean isSyncing(final long upperBoundTimestamp) { + Assert.isTrue(upperBoundTimestamp >= 0); + return (upperBoundTimestamp == mCurrentUpperBoundTimestamp); + } + + /** + * Check if sync has completed for the first time. + */ + public boolean getHasFirstSyncCompleted() { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME, + BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) != + BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT; + } + + /** + * Called once sync is complete + */ + public synchronized void complete() { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp + + " marked as complete"); + } + mSyncInProgressTimestamp = -1L; + // Conversation customization only used once + mCustomization = null; + } + + private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver(); + private boolean mSyncOnChanges = false; + private boolean mNotifyOnChanges = false; + + /** + * Register content observer when necessary and kick off a catch up sync + */ + public void updateSyncObserver(final Context context) { + registerObserver(context); + // Trigger an sms sync in case we missed and messages before registering this observer or + // becoming the SMS provider. + immediateSync(); + } + + private void registerObserver(final Context context) { + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + // Not default SMS app - need to actively monitor telephony but not notify + mNotifyOnChanges = false; + mSyncOnChanges = true; + } else if (OsUtil.isSecondaryUser()){ + // Secondary users default SMS app - need to actively monitor telephony and notify + mNotifyOnChanges = true; + mSyncOnChanges = true; + } else { + // Primary users default SMS app - don't monitor telephony (most changes from this app) + mNotifyOnChanges = false; + mSyncOnChanges = false; + } + if (mNotifyOnChanges || mSyncOnChanges) { + context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI, + true, mMmsSmsObserver); + } else { + context.getContentResolver().unregisterContentObserver(mMmsSmsObserver); + } + } + + public synchronized void setCustomization( + final LongSparseArray<ConversationCustomization> customization) { + this.mCustomization = customization; + } + + public synchronized ConversationCustomization getCustomizationForThread(final long threadId) { + if (mCustomization != null) { + return mCustomization.get(threadId); + } + return null; + } + + public static void resetLastSyncTimestamps() { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, + BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT); + prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT); + } + + private class TelephonyMessagesObserver extends ContentObserver { + public TelephonyMessagesObserver() { + // Just run on default thread + super(null); + } + + // Implement the onChange(boolean) method to delegate the change notification to + // the onChange(boolean, Uri) method to ensure correct operation on older versions + // of the framework that did not have the onChange(boolean, Uri) method. + @Override + public void onChange(final boolean selfChange) { + onChange(selfChange, null); + } + + // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument. + @Override + public void onChange(final boolean selfChange, final Uri uri) { + // Handle change. + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis() + + " for " + (uri == null ? "<unk>" : uri.toString()) + " " + + mSyncOnChanges + "/" + mNotifyOnChanges); + } + + if (mSyncOnChanges) { + // If sync is already running this will do nothing - but at end of each sync + // action there is a check for recent messages that should catch new changes. + SyncManager.immediateSync(); + } + if (mNotifyOnChanges) { + // TODO: Secondary users are not going to get notifications + } + } + } + + public ThreadInfoCache getThreadInfoCache() { + return mThreadInfoCache; + } + + public static class ThreadInfoCache { + // Cache of thread->conversationId map + private final LongSparseArray<String> mThreadToConversationId = + new LongSparseArray<String>(); + + // Cache of thread->recipients map + private final LongSparseArray<List<String>> mThreadToRecipients = + new LongSparseArray<List<String>>(); + + // Remember the conversation ids that need to be archived + private final HashSet<String> mArchivedConversations = new HashSet<>(); + + public synchronized void clear() { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache"); + } + mThreadToConversationId.clear(); + mThreadToRecipients.clear(); + mArchivedConversations.clear(); + } + + public synchronized boolean isArchived(final String conversationId) { + return mArchivedConversations.contains(conversationId); + } + + /** + * Get or create a conversation based on the message's thread id + * + * @param threadId The message's thread + * @param refSubId The subId used for normalizing phone numbers in the thread + * @param customization The user setting customization to the conversation if any + * @return The existing conversation id or new conversation id + */ + public synchronized String getOrCreateConversation(final DatabaseWrapper db, + final long threadId, int refSubId, final ConversationCustomization customization) { + // This function has several components which need to be atomic. + Assert.isTrue(db.getDatabase().inTransaction()); + + // If we already have this conversation ID in our local map, just return it + String conversationId = mThreadToConversationId.get(threadId); + if (conversationId != null) { + return conversationId; + } + + final List<String> recipients = getThreadRecipients(threadId); + final ArrayList<ParticipantData> participants = + BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients, + refSubId); + + if (customization != null) { + // There is user customization we need to recover + conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + customization.isArchived(), participants, customization.isMuted(), + customization.noVibrate(), customization.getNotificationSoundUri()); + if (customization.isArchived()) { + mArchivedConversations.add(conversationId); + } + } else { + conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + false/*archived*/, participants, false/*noNotification*/, + false/*noVibrate*/, null/*soundUri*/); + } + + if (conversationId != null) { + mThreadToConversationId.put(threadId, conversationId); + return conversationId; + } + + return null; + } + + + /** + * Load the recipients of a thread from telephony provider. If we fail, use + * a predefined unknown recipient. This should not return null. + * + * @param threadId + */ + public synchronized List<String> getThreadRecipients(final long threadId) { + List<String> recipients = mThreadToRecipients.get(threadId); + if (recipients == null) { + recipients = MmsUtils.getRecipientsByThread(threadId); + if (recipients != null && recipients.size() > 0) { + mThreadToRecipients.put(threadId, recipients); + } + } + + if (recipients == null || recipients.isEmpty()) { + LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId + + " couldn't find any recipients."); + + // We want to try our best to load the messages, + // so if recipient info is broken, try to fix it with unknown recipient + recipients = Lists.newArrayList(); + recipients.add(ParticipantData.getUnknownSenderDestination()); + } + + return recipients; + } + } +} diff --git a/src/com/android/messaging/datamodel/action/Action.java b/src/com/android/messaging/datamodel/action/Action.java new file mode 100644 index 0000000..e4c332e --- /dev/null +++ b/src/com/android/messaging/datamodel/action/Action.java @@ -0,0 +1,295 @@ +/* + * 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.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.action.ActionMonitor.ActionExecutedListener; +import com.android.messaging.util.LogUtil; + +import java.util.LinkedList; +import java.util.List; + +/** + * Base class for operations that perform application business logic off the main UI thread while + * holding a wake lock. + * . + * Note all derived classes need to provide real implementation of Parcelable (this is abstract) + */ +public abstract class Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + // Members holding the parameters common to all actions - no action state + public final String actionKey; + + // If derived classes keep their data in actionParameters then parcelable is trivial + protected Bundle actionParameters; + + // This does not get written to the parcel + private final List<Action> mBackgroundActions = new LinkedList<Action>(); + + /** + * Process the action locally - runs on action service thread. + * TODO: Currently, there is no way for this method to indicate failure + * @return result to be passed in to {@link ActionExecutedListener#onActionExecuted}. It is + * also the result passed in to {@link ActionCompletedListener#onActionSucceeded} if + * there is no background work. + */ + protected Object executeAction() { + return null; + } + + /** + * Queues up background work ie. {@link #doBackgroundWork} will be called on the + * background worker thread. + */ + protected void requestBackgroundWork() { + mBackgroundActions.add(this); + } + + /** + * Queues up background actions for background processing after the current action has + * completed its processing ({@link #executeAction}, {@link processBackgroundCompletion} + * or {@link #processBackgroundFailure}) on the Action thread. + * @param backgroundAction + */ + protected void requestBackgroundWork(final Action backgroundAction) { + mBackgroundActions.add(backgroundAction); + } + + /** + * Return flag indicating if any actions have been queued + */ + public boolean hasBackgroundActions() { + return !mBackgroundActions.isEmpty(); + } + + /** + * Send queued actions to the background worker provided + */ + public void sendBackgroundActions(final BackgroundWorker worker) { + worker.queueBackgroundWork(mBackgroundActions); + mBackgroundActions.clear(); + } + + /** + * Do work in a long running background worker thread. + * {@link #requestBackgroundWork} needs to be called for this method to + * be called. {@link #processBackgroundFailure} will be called on the Action service thread + * if this method throws {@link DataModelException}. + * @return response that is to be passed to {@link #processBackgroundResponse} + */ + protected Bundle doBackgroundWork() throws DataModelException { + return null; + } + + /** + * Process the success response from the background worker. Runs on action service thread. + * @param response the response returned by {@link #doBackgroundWork} + * @return result to be passed in to {@link ActionCompletedListener#onActionSucceeded} + */ + protected Object processBackgroundResponse(final Bundle response) { + return null; + } + + /** + * Called in case of failures when sending background actions. Runs on action service thread + * @return result to be passed in to {@link ActionCompletedListener#onActionFailed} + */ + protected Object processBackgroundFailure() { + return null; + } + + /** + * Constructor + */ + protected Action(final String key) { + this.actionKey = key; + this.actionParameters = new Bundle(); + } + + /** + * Constructor + */ + protected Action() { + this.actionKey = generateUniqueActionKey(getClass().getSimpleName()); + this.actionParameters = new Bundle(); + } + + /** + * Queue an action and monitor for processing by the ActionService via the factory helper + */ + protected void start(final ActionMonitor monitor) { + ActionMonitor.registerActionMonitor(this.actionKey, monitor); + DataModel.startActionService(this); + } + + /** + * Queue an action for processing by the ActionService via the factory helper + */ + public void start() { + DataModel.startActionService(this); + } + + /** + * Queue an action for delayed processing by the ActionService via the factory helper + */ + public void schedule(final int requestCode, final long delayMs) { + DataModel.scheduleAction(this, requestCode, delayMs); + } + + /** + * Called when action queues ActionService intent + */ + protected final void markStart() { + ActionMonitor.setState(this, ActionMonitor.STATE_CREATED, + ActionMonitor.STATE_QUEUED); + } + + /** + * Mark the beginning of local action execution + */ + protected final void markBeginExecute() { + ActionMonitor.setState(this, ActionMonitor.STATE_QUEUED, + ActionMonitor.STATE_EXECUTING); + } + + /** + * Mark the end of local action execution - either completes the action or queues + * background actions + */ + protected final void markEndExecute(final Object result) { + final boolean hasBackgroundActions = hasBackgroundActions(); + ActionMonitor.setExecutedState(this, ActionMonitor.STATE_EXECUTING, + hasBackgroundActions, result); + if (!hasBackgroundActions) { + ActionMonitor.setCompleteState(this, ActionMonitor.STATE_EXECUTING, + result, true); + } + } + + /** + * Update action state to indicate that the background worker is starting + */ + protected final void markBackgroundWorkStarting() { + ActionMonitor.setState(this, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION); + } + + /** + * Update action state to indicate that the background worker has posted its response + * (or failure) to the Action service + */ + protected final void markBackgroundCompletionQueued() { + ActionMonitor.setState(this, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED); + } + + /** + * Update action state to indicate the background action failed but is being re-queued for retry + */ + protected final void markBackgroundWorkQueued() { + ActionMonitor.setState(this, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED); + } + + /** + * Called by ActionService to process a response from the background worker + * @param response the response returned by {@link #doBackgroundWork} + */ + protected final void processBackgroundWorkResponse(final Bundle response) { + ActionMonitor.setState(this, + ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED, + ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE); + final Object result = processBackgroundResponse(response); + ActionMonitor.setCompleteState(this, + ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE, result, true); + } + + /** + * Called by ActionService when a background action fails + */ + protected final void processBackgroundWorkFailure() { + final Object result = processBackgroundFailure(); + ActionMonitor.setCompleteState(this, ActionMonitor.STATE_UNDEFINED, + result, false); + } + + private static final Object sLock = new Object(); + private static long sActionIdx = System.currentTimeMillis() * 1000; + + /** + * Helper method to generate a unique operation index + */ + protected static long getActionIdx() { + long idx = 0; + synchronized (sLock) { + idx = ++sActionIdx; + } + return idx; + } + + /** + * This helper can be used to generate a unique key used to identify an action. + * @param baseKey - key generated to identify the action parameters + * @return - composite key generated by appending unique index + */ + protected static String generateUniqueActionKey(final String baseKey) { + final StringBuilder key = new StringBuilder(); + if (!TextUtils.isEmpty(baseKey)) { + key.append(baseKey); + } + key.append(":"); + key.append(getActionIdx()); + return key.toString(); + } + + /** + * Most derived classes use this base implementation (unless they include files handles) + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Derived classes need to implement writeToParcel (but typically should call this method + * to parcel Action member variables before they parcel their member variables). + */ + public void writeActionToParcel(final Parcel parcel, final int flags) { + parcel.writeString(this.actionKey); + parcel.writeBundle(this.actionParameters); + } + + /** + * Helper for derived classes to implement parcelable + */ + public Action(final Parcel in) { + this.actionKey = in.readString(); + // Note: Need to set classloader to ensure we can un-parcel classes from this package + this.actionParameters = in.readBundle(Action.class.getClassLoader()); + } +} diff --git a/src/com/android/messaging/datamodel/action/ActionMonitor.java b/src/com/android/messaging/datamodel/action/ActionMonitor.java new file mode 100644 index 0000000..cb080aa --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ActionMonitor.java @@ -0,0 +1,477 @@ +/* + * 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.os.Handler; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.util.Assert.RunsOnAnyThread; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * Base class for action monitors + * Actions come in various flavors but + * o) Fire and forget - no monitor + * o) Immediate local processing only - will trigger ActionCompletedListener when done + * o) Background worker processing only - will trigger ActionCompletedListener when done + * o) Immediate local processing followed by background work followed by more local processing + * - will trigger ActionExecutedListener once local processing complete and + * ActionCompletedListener when second set of local process (dealing with background + * worker response) is complete + */ +public class ActionMonitor { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Interface used to notify on completion of local execution for an action + */ + public interface ActionExecutedListener { + /** + * @param result value returned by {@link Action#executeAction} + */ + @RunsOnMainThread + abstract void onActionExecuted(ActionMonitor monitor, final Action action, + final Object data, final Object result); + } + + /** + * Interface used to notify action completion + */ + public interface ActionCompletedListener { + /** + * @param result object returned from processing the action. This is the value returned by + * {@link Action#executeAction} if there is no background work, or + * else the value returned by + * {@link Action#processBackgroundResponse} + */ + @RunsOnMainThread + abstract void onActionSucceeded(ActionMonitor monitor, + final Action action, final Object data, final Object result); + /** + * @param result value returned by {@link Action#processBackgroundFailure} + */ + @RunsOnMainThread + abstract void onActionFailed(ActionMonitor monitor, final Action action, + final Object data, final Object result); + } + + /** + * Interface for being notified of action state changes - used for profiling, testing only + */ + protected interface ActionStateChangedListener { + /** + * @param action the action that is changing state + * @param state the new state of the action + */ + @RunsOnAnyThread + void onActionStateChanged(Action action, int state); + } + + /** + * Operations always start out as STATE_CREATED and finish as STATE_COMPLETE. + * Some common state transition sequences in between include: + * <ul> + * <li>Local data change only : STATE_QUEUED - STATE_EXECUTING + * <li>Background worker request only : STATE_BACKGROUND_ACTIONS_QUEUED + * - STATE_EXECUTING_BACKGROUND_ACTION + * - STATE_BACKGROUND_COMPLETION_QUEUED + * - STATE_PROCESSING_BACKGROUND_RESPONSE + * <li>Local plus background worker request : STATE_QUEUED - STATE_EXECUTING + * - STATE_BACKGROUND_ACTIONS_QUEUED + * - STATE_EXECUTING_BACKGROUND_ACTION + * - STATE_BACKGROUND_COMPLETION_QUEUED + * - STATE_PROCESSING_BACKGROUND_RESPONSE + * </ul> + */ + protected static final int STATE_UNDEFINED = 0; + protected static final int STATE_CREATED = 1; // Just created + protected static final int STATE_QUEUED = 2; // Action queued for processing + protected static final int STATE_EXECUTING = 3; // Action processing on datamodel thread + protected static final int STATE_BACKGROUND_ACTIONS_QUEUED = 4; + protected static final int STATE_EXECUTING_BACKGROUND_ACTION = 5; + // The background work has completed, either returning a success response or resulting in a + // failure + protected static final int STATE_BACKGROUND_COMPLETION_QUEUED = 6; + protected static final int STATE_PROCESSING_BACKGROUND_RESPONSE = 7; + protected static final int STATE_COMPLETE = 8; // Action complete + + /** + * Lock used to protect access to state and listeners + */ + private final Object mLock = new Object(); + + /** + * Current state of action + */ + @VisibleForTesting + protected int mState; + + /** + * Listener which is notified on action completion + */ + private ActionCompletedListener mCompletedListener; + + /** + * Listener which is notified on action executed + */ + private ActionExecutedListener mExecutedListener; + + /** + * Listener which is notified of state changes + */ + private ActionStateChangedListener mStateChangedListener; + + /** + * Handler used to post results back to caller + */ + private final Handler mHandler; + + /** + * Data passed back to listeners (associated with the action when it is created) + */ + private final Object mData; + + /** + * The action key is used to determine equivalence of operations and their requests + */ + private final String mActionKey; + + /** + * Get action key identifying associated action + */ + public String getActionKey() { + return mActionKey; + } + + /** + * Unregister listeners so that they will not be called back - override this method if needed + */ + public void unregister() { + clearListeners(); + } + + /** + * Unregister listeners so that they will not be called + */ + protected final void clearListeners() { + synchronized (mLock) { + mCompletedListener = null; + mExecutedListener = null; + } + } + + /** + * Create a monitor associated with a particular action instance + */ + protected ActionMonitor(final int initialState, final String actionKey, + final Object data) { + mHandler = ThreadUtil.getMainThreadHandler(); + mActionKey = actionKey; + mState = initialState; + mData = data; + } + + /** + * Return flag to indicate if action is complete + */ + public boolean isComplete() { + boolean complete = false; + synchronized (mLock) { + complete = (mState == STATE_COMPLETE); + } + return complete; + } + + /** + * Set listener that will be called with action completed result + */ + protected final void setCompletedListener(final ActionCompletedListener listener) { + synchronized (mLock) { + mCompletedListener = listener; + } + } + + /** + * Set listener that will be called with local execution result + */ + protected final void setExecutedListener(final ActionExecutedListener listener) { + synchronized (mLock) { + mExecutedListener = listener; + } + } + + /** + * Set listener that will be called with local execution result + */ + protected final void setStateChangedListener(final ActionStateChangedListener listener) { + synchronized (mLock) { + mStateChangedListener = listener; + } + } + + /** + * Perform a state update transition + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param newState - new state which will be set + */ + @VisibleForTesting + protected void updateState(final Action action, final int expectedOldState, + final int newState) { + ActionStateChangedListener listener = null; + synchronized (mLock) { + if (expectedOldState != STATE_UNDEFINED && + mState != expectedOldState) { + throw new IllegalStateException("On updateState to " + newState + " was " + mState + + " expecting " + expectedOldState); + } + if (newState != mState) { + mState = newState; + listener = mStateChangedListener; + } + } + if (listener != null) { + listener.onActionStateChanged(action, newState); + } + } + + /** + * Perform a state update transition + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param newState - new state which will be set + */ + static void setState(final Action action, final int expectedOldState, + final int newState) { + int oldMonitorState = expectedOldState; + int newMonitorState = newState; + final ActionMonitor monitor + = ActionMonitor.lookupActionMonitor(action.actionKey); + if (monitor != null) { + oldMonitorState = monitor.mState; + monitor.updateState(action, expectedOldState, newState); + newMonitorState = monitor.mState; + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date()) + + "UTC State = " + oldMonitorState + " - " + newMonitorState); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param result - object returned from processing the action. This is the value returned by + * {@link Action#executeAction} if there is no background work, or + * else the value returned by {@link Action#processBackgroundResponse} + * or {@link Action#processBackgroundFailure} + */ + private final void complete(final Action action, + final int expectedOldState, final Object result, + final boolean succeeded) { + ActionCompletedListener completedListener = null; + synchronized (mLock) { + setState(action, expectedOldState, STATE_COMPLETE); + completedListener = mCompletedListener; + mExecutedListener = null; + mStateChangedListener = null; + } + if (completedListener != null) { + // Marshal to UI thread + mHandler.post(new Runnable() { + @Override + public void run() { + ActionCompletedListener listener = null; + synchronized (mLock) { + if (mCompletedListener != null) { + listener = mCompletedListener; + } + mCompletedListener = null; + } + if (listener != null) { + if (succeeded) { + listener.onActionSucceeded(ActionMonitor.this, + action, mData, result); + } else { + listener.onActionFailed(ActionMonitor.this, + action, mData, result); + } + } + } + }); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param result - object returned from processing the action. This is the value returned by + * {@link Action#executeAction} if there is no background work, or + * else the value returned by {@link Action#processBackgroundResponse} + * or {@link Action#processBackgroundFailure} + */ + static void setCompleteState(final Action action, final int expectedOldState, + final Object result, final boolean succeeded) { + int oldMonitorState = expectedOldState; + final ActionMonitor monitor + = ActionMonitor.lookupActionMonitor(action.actionKey); + if (monitor != null) { + oldMonitorState = monitor.mState; + monitor.complete(action, expectedOldState, result, succeeded); + unregisterActionMonitorIfComplete(action.actionKey, monitor); + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date()) + + "UTC State = " + oldMonitorState + " - " + STATE_COMPLETE); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param hasBackgroundActions - has the completing action requested background work + * @param result - the return value of {@link Action#executeAction} + */ + final void executed(final Action action, + final int expectedOldState, final boolean hasBackgroundActions, final Object result) { + ActionExecutedListener executedListener = null; + synchronized (mLock) { + if (hasBackgroundActions) { + setState(action, expectedOldState, STATE_BACKGROUND_ACTIONS_QUEUED); + } + executedListener = mExecutedListener; + } + if (executedListener != null) { + // Marshal to UI thread + mHandler.post(new Runnable() { + @Override + public void run() { + ActionExecutedListener listener = null; + synchronized (mLock) { + if (mExecutedListener != null) { + listener = mExecutedListener; + mExecutedListener = null; + } + } + if (listener != null) { + listener.onActionExecuted(ActionMonitor.this, + action, mData, result); + } + } + }); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param hasBackgroundActions - has the completing action requested background work + * @param result - the return value of {@link Action#executeAction} + */ + static void setExecutedState(final Action action, + final int expectedOldState, final boolean hasBackgroundActions, final Object result) { + int oldMonitorState = expectedOldState; + final ActionMonitor monitor + = ActionMonitor.lookupActionMonitor(action.actionKey); + if (monitor != null) { + oldMonitorState = monitor.mState; + monitor.executed(action, expectedOldState, hasBackgroundActions, result); + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date()) + + "UTC State = " + oldMonitorState + " - EXECUTED"); + } + } + + /** + * Map of action monitors indexed by actionKey + */ + @VisibleForTesting + static SimpleArrayMap<String, ActionMonitor> sActionMonitors = + new SimpleArrayMap<String, ActionMonitor>(); + + /** + * Insert new monitor into map + */ + static void registerActionMonitor(final String actionKey, + final ActionMonitor monitor) { + if (monitor != null + && (TextUtils.isEmpty(monitor.getActionKey()) + || TextUtils.isEmpty(actionKey) + || !actionKey.equals(monitor.getActionKey()))) { + throw new IllegalArgumentException("Monitor key " + monitor.getActionKey() + + " not compatible with action key " + actionKey); + } + synchronized (sActionMonitors) { + sActionMonitors.put(actionKey, monitor); + } + } + + /** + * Find monitor associated with particular action + */ + private static ActionMonitor lookupActionMonitor(final String actionKey) { + ActionMonitor monitor = null; + synchronized (sActionMonitors) { + monitor = sActionMonitors.get(actionKey); + } + return monitor; + } + + /** + * Remove monitor from map + */ + @VisibleForTesting + static void unregisterActionMonitor(final String actionKey, + final ActionMonitor monitor) { + if (monitor != null) { + synchronized (sActionMonitors) { + sActionMonitors.remove(actionKey); + } + } + } + + /** + * Remove monitor from map if the action is complete + */ + static void unregisterActionMonitorIfComplete(final String actionKey, + final ActionMonitor monitor) { + if (monitor != null && monitor.isComplete()) { + synchronized (sActionMonitors) { + sActionMonitors.remove(actionKey); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/action/ActionService.java b/src/com/android/messaging/datamodel/action/ActionService.java new file mode 100644 index 0000000..29225fa --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ActionService.java @@ -0,0 +1,63 @@ +/* + * 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.app.PendingIntent; +import android.content.Context; +import android.os.Bundle; + +/** + * Class providing interface for the ActionService - can be stubbed for testing + */ +public class ActionService { + protected static PendingIntent makeStartActionPendingIntent(final Context context, + final Action action, final int requestCode, final boolean launchesAnActivity) { + return ActionServiceImpl.makeStartActionPendingIntent(context, action, requestCode, + launchesAnActivity); + } + + /** + * Start an action by posting it over the the ActionService + */ + public void startAction(final Action action) { + ActionServiceImpl.startAction(action); + } + + /** + * Schedule a delayed action by posting it over the the ActionService + */ + public void scheduleAction(final Action action, final int code, + final long delayMs) { + ActionServiceImpl.scheduleAction(action, code, delayMs); + } + + /** + * Process a response from the BackgroundWorker in the ActionService + */ + protected void handleResponseFromBackgroundWorker( + final Action action, final Bundle response) { + ActionServiceImpl.handleResponseFromBackgroundWorker(action, response); + } + + /** + * Process a failure from the BackgroundWorker in the ActionService + */ + protected void handleFailureFromBackgroundWorker(final Action action, + final Exception exception) { + ActionServiceImpl.handleFailureFromBackgroundWorker(action, exception); + } +} diff --git a/src/com/android/messaging/datamodel/action/ActionServiceImpl.java b/src/com/android/messaging/datamodel/action/ActionServiceImpl.java new file mode 100644 index 0000000..a408dac --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ActionServiceImpl.java @@ -0,0 +1,341 @@ +/* + * 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.app.AlarmManager; +import android.app.IntentService; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.SystemClock; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.LoggingTimer; +import com.android.messaging.util.WakeLockHelper; +import com.google.common.annotations.VisibleForTesting; + +/** + * ActionService used to perform background processing for data model + */ +public class ActionServiceImpl extends IntentService { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final boolean VERBOSE = false; + + public ActionServiceImpl() { + super("ActionService"); + } + + /** + * Start action by sending intent to the service + * @param action - action to start + */ + protected static void startAction(final Action action) { + final Intent intent = makeIntent(OP_START_ACTION); + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + action.markStart(); + startServiceWithIntent(intent); + } + + /** + * Schedule an action to run after specified delay using alarm manager to send pendingintent + * @param action - action to start + * @param requestCode - request code used to collapse requests + * @param delayMs - delay in ms (from now) before action will start + */ + protected static void scheduleAction(final Action action, final int requestCode, + final long delayMs) { + final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION); + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + + PendingActionReceiver.scheduleAlarm(intent, requestCode, delayMs); + } + + /** + * Handle response returned by BackgroundWorker + * @param request - request generating response + * @param response - response from service + */ + protected static void handleResponseFromBackgroundWorker(final Action action, + final Bundle response) { + final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_RESPONSE); + + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + intent.putExtra(EXTRA_WORKER_RESPONSE, response); + + startServiceWithIntent(intent); + } + + /** + * Handle response returned by BackgroundWorker + * @param request - request generating failure + */ + protected static void handleFailureFromBackgroundWorker(final Action action, + final Exception exception) { + final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_FAILURE); + + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + intent.putExtra(EXTRA_WORKER_EXCEPTION, exception); + + startServiceWithIntent(intent); + } + + // ops + @VisibleForTesting + protected static final int OP_START_ACTION = 200; + @VisibleForTesting + protected static final int OP_RECEIVE_BACKGROUND_RESPONSE = 201; + @VisibleForTesting + protected static final int OP_RECEIVE_BACKGROUND_FAILURE = 202; + + // extras + @VisibleForTesting + protected static final String EXTRA_OP_CODE = "op"; + @VisibleForTesting + protected static final String EXTRA_ACTION_BUNDLE = "datamodel_action_bundle"; + @VisibleForTesting + protected static final String EXTRA_WORKER_EXCEPTION = "worker_exception"; + @VisibleForTesting + protected static final String EXTRA_WORKER_RESPONSE = "worker_response"; + @VisibleForTesting + protected static final String EXTRA_WORKER_UPDATE = "worker_update"; + @VisibleForTesting + protected static final String BUNDLE_ACTION = "bundle_action"; + + private BackgroundWorker mBackgroundWorker; + + /** + * Allocate an intent with a specific opcode. + */ + private static Intent makeIntent(final int opcode) { + final Intent intent = new Intent(Factory.get().getApplicationContext(), + ActionServiceImpl.class); + intent.putExtra(EXTRA_OP_CODE, opcode); + return intent; + } + + /** + * Broadcast receiver for alarms scheduled through ActionService. + */ + public static class PendingActionReceiver extends BroadcastReceiver { + static final String ACTION = "com.android.messaging.datamodel.PENDING_ACTION"; + + /** + * Allocate an intent with a specific opcode and alarm action. + */ + public static Intent makeIntent(final int opcode) { + final Intent intent = new Intent(Factory.get().getApplicationContext(), + PendingActionReceiver.class); + intent.setAction(ACTION); + intent.putExtra(EXTRA_OP_CODE, opcode); + return intent; + } + + public static void scheduleAlarm(final Intent intent, final int requestCode, + final long delayMs) { + final Context context = Factory.get().getApplicationContext(); + final PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, requestCode, intent, PendingIntent.FLAG_CANCEL_CURRENT); + + final AlarmManager mgr = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + if (delayMs < Long.MAX_VALUE) { + mgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + delayMs, pendingIntent); + } else { + mgr.cancel(pendingIntent); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onReceive(final Context context, final Intent intent) { + ActionServiceImpl.startServiceWithIntent(intent); + } + } + + /** + * Creates a pending intent that will trigger a data model action when the intent is + * triggered + */ + public static PendingIntent makeStartActionPendingIntent(final Context context, + final Action action, final int requestCode, final boolean launchesAnActivity) { + final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION); + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + if (launchesAnActivity) { + intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + } + return PendingIntent.getBroadcast(context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreate() { + super.onCreate(); + mBackgroundWorker = DataModel.get().getBackgroundWorkerForActionService(); + DataModel.get().getConnectivityUtil().registerForSignalStrength(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DataModel.get().getConnectivityUtil().unregisterForSignalStrength(); + } + + private static final String WAKELOCK_ID = "bugle_datamodel_service_wakelock"; + @VisibleForTesting + static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID); + + /** + * Queue intent to the ActionService after acquiring wake lock + */ + private static void startServiceWithIntent(final Intent intent) { + final Context context = Factory.get().getApplicationContext(); + final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0); + // Increase refCount on wake lock - acquiring if necessary + if (VERBOSE) { + LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode); + } + sWakeLock.acquire(context, intent, opcode); + intent.setClass(context, ActionServiceImpl.class); + + // TODO: Note that intent will be quietly discarded if it exceeds available rpc + // memory (in total around 1MB). See this article for background + // http://developer.android.com/reference/android/os/TransactionTooLargeException.html + // Perhaps we should keep large structures in the action monitor? + if (context.startService(intent) == null) { + LogUtil.e(TAG, + "ActionService.startServiceWithIntent: failed to start service for intent " + + intent); + sWakeLock.release(intent, opcode); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected void onHandleIntent(final Intent intent) { + if (intent == null) { + // Shouldn't happen but sometimes does following another crash. + LogUtil.w(TAG, "ActionService.onHandleIntent: Called with null intent"); + return; + } + final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0); + sWakeLock.ensure(intent, opcode); + + try { + Action action; + final Bundle actionBundle = intent.getBundleExtra(EXTRA_ACTION_BUNDLE); + actionBundle.setClassLoader(getClassLoader()); + switch(opcode) { + case OP_START_ACTION: { + action = (Action) actionBundle.getParcelable(BUNDLE_ACTION); + executeAction(action); + break; + } + + case OP_RECEIVE_BACKGROUND_RESPONSE: { + action = (Action) actionBundle.getParcelable(BUNDLE_ACTION); + final Bundle response = intent.getBundleExtra(EXTRA_WORKER_RESPONSE); + processBackgroundResponse(action, response); + break; + } + + case OP_RECEIVE_BACKGROUND_FAILURE: { + action = (Action) actionBundle.getParcelable(BUNDLE_ACTION); + processBackgroundFailure(action); + break; + } + + default: + throw new RuntimeException("Unrecognized opcode in ActionServiceImpl"); + } + + action.sendBackgroundActions(mBackgroundWorker); + } finally { + // Decrease refCount on wake lock - releasing if necessary + sWakeLock.release(intent, opcode); + } + } + + private static final long EXECUTION_TIME_WARN_LIMIT_MS = 1000; // 1 second + /** + * Local execution of action on ActionService thread + */ + private void executeAction(final Action action) { + action.markBeginExecute(); + + final LoggingTimer timer = createLoggingTimer(action, "#executeAction"); + timer.start(); + + final Object result = action.executeAction(); + + timer.stopAndLog(); + + action.markEndExecute(result); + } + + /** + * Process response on ActionService thread + */ + private void processBackgroundResponse(final Action action, final Bundle response) { + final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundResponse"); + timer.start(); + + action.processBackgroundWorkResponse(response); + + timer.stopAndLog(); + } + + /** + * Process failure on ActionService thread + */ + private void processBackgroundFailure(final Action action) { + final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundFailure"); + timer.start(); + + action.processBackgroundWorkFailure(); + + timer.stopAndLog(); + } + + private static LoggingTimer createLoggingTimer( + final Action action, final String methodName) { + return new LoggingTimer(TAG, action.getClass().getSimpleName() + methodName, + EXECUTION_TIME_WARN_LIMIT_MS); + } +} diff --git a/src/com/android/messaging/datamodel/action/BackgroundWorker.java b/src/com/android/messaging/datamodel/action/BackgroundWorker.java new file mode 100644 index 0000000..aad3c07 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/BackgroundWorker.java @@ -0,0 +1,32 @@ +/* + * 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 java.util.List; + +/** + * Interface between action service and its workers + */ +public class BackgroundWorker { + + /** + * Send list of requests from action service to a worker + */ + public void queueBackgroundWork(final List<Action> backgroundActions) { + BackgroundWorkerService.queueBackgroundWork(backgroundActions); + } +} diff --git a/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java b/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java new file mode 100644 index 0000000..4d4b150 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java @@ -0,0 +1,168 @@ +/* + * 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.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.LoggingTimer; +import com.android.messaging.util.WakeLockHelper; +import com.google.common.annotations.VisibleForTesting; + +import java.util.List; + +/** + * Background worker service is an initial example of a background work queue handler + * Used to actually "send" messages which may take some time and should not block ActionService + * or UI + */ +public class BackgroundWorkerService extends IntentService { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final boolean VERBOSE = false; + + private static final String WAKELOCK_ID = "bugle_background_worker_wakelock"; + @VisibleForTesting + static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID); + + private final ActionService mHost; + + public BackgroundWorkerService() { + super("BackgroundWorker"); + mHost = DataModel.get().getActionService(); + } + + /** + * Queue a list of requests from action service to this worker + */ + public static void queueBackgroundWork(final List<Action> actions) { + for (final Action action : actions) { + startServiceWithAction(action, 0); + } + } + + // ops + @VisibleForTesting + protected static final int OP_PROCESS_REQUEST = 400; + + // extras + @VisibleForTesting + protected static final String EXTRA_OP_CODE = "op"; + @VisibleForTesting + protected static final String EXTRA_ACTION = "action"; + @VisibleForTesting + protected static final String EXTRA_ATTEMPT = "retry_attempt"; + + /** + * Queue action intent to the BackgroundWorkerService after acquiring wake lock + */ + private static void startServiceWithAction(final Action action, + final int retryCount) { + final Intent intent = new Intent(); + intent.putExtra(EXTRA_ACTION, action); + intent.putExtra(EXTRA_ATTEMPT, retryCount); + startServiceWithIntent(OP_PROCESS_REQUEST, intent); + } + + /** + * Queue intent to the BackgroundWorkerService after acquiring wake lock + */ + private static void startServiceWithIntent(final int opcode, final Intent intent) { + final Context context = Factory.get().getApplicationContext(); + + intent.setClass(context, BackgroundWorkerService.class); + intent.putExtra(EXTRA_OP_CODE, opcode); + sWakeLock.acquire(context, intent, opcode); + if (VERBOSE) { + LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode); + } + + if (context.startService(intent) == null) { + LogUtil.e(TAG, + "BackgroundWorkerService.startServiceWithAction: failed to start service for " + + opcode); + sWakeLock.release(intent, opcode); + } + } + + @Override + protected void onHandleIntent(final Intent intent) { + if (intent == null) { + // Shouldn't happen but sometimes does following another crash. + LogUtil.w(TAG, "BackgroundWorkerService.onHandleIntent: Called with null intent"); + return; + } + final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0); + sWakeLock.ensure(intent, opcode); + + try { + switch(opcode) { + case OP_PROCESS_REQUEST: { + final Action action = intent.getParcelableExtra(EXTRA_ACTION); + final int attempt = intent.getIntExtra(EXTRA_ATTEMPT, -1); + doBackgroundWork(action, attempt); + break; + } + + default: + throw new RuntimeException("Unrecognized opcode in BackgroundWorkerService"); + } + } finally { + sWakeLock.release(intent, opcode); + } + } + + /** + * Local execution of background work for action on ActionService thread + */ + private void doBackgroundWork(final Action action, final int attempt) { + action.markBackgroundWorkStarting(); + Bundle response = null; + try { + final LoggingTimer timer = new LoggingTimer( + TAG, action.getClass().getSimpleName() + "#doBackgroundWork"); + timer.start(); + + response = action.doBackgroundWork(); + + timer.stopAndLog(); + action.markBackgroundCompletionQueued(); + mHost.handleResponseFromBackgroundWorker(action, response); + } catch (final Exception exception) { + final boolean retry = false; + LogUtil.e(TAG, "Error in background worker", exception); + if (!(exception instanceof DataModelException)) { + // DataModelException is expected (sort-of) and handled in handleFailureFromWorker + // below, but other exceptions should crash ENG builds + Assert.fail("Unexpected error in background worker - abort"); + } + if (retry) { + action.markBackgroundWorkQueued(); + startServiceWithAction(action, attempt + 1); + } else { + action.markBackgroundCompletionQueued(); + mHost.handleFailureFromBackgroundWorker(action, exception); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/action/BugleActionToasts.java b/src/com/android/messaging/datamodel/action/BugleActionToasts.java new file mode 100644 index 0000000..f60facd --- /dev/null +++ b/src/com/android/messaging/datamodel/action/BugleActionToasts.java @@ -0,0 +1,172 @@ +/* + * 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.content.Context; +import android.content.res.Resources; +import android.widget.Toast; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.ThreadUtil; + +import javax.annotation.Nullable; + +/** + * Shows one-time, transient notifications in response to action failures (i.e. permanent failures + * when sending a message) by showing toasts. + */ +public class BugleActionToasts { + /** + * Called when SendMessageAction or DownloadMmsAction finishes + * @param conversationId the conversation of the sent or downloaded message + * @param success did the action succeed + * @param status the message sending status + * @param isSms whether the message is sent using SMS + * @param subId the subId of the SIM related to this send + * @param isSend whether it is a send (false for download) + */ + static void onSendMessageOrManualDownloadActionCompleted( + final String conversationId, + final boolean success, + final int status, + final boolean isSms, + final int subId, + final boolean isSend) { + // We only show notifications for two cases, i.e. when mobile data is off or when we are + // in airplane mode, both of which fail fast with permanent failures. + if (!success && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) { + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + if (phoneUtils.isAirplaneModeOn()) { + if (isSend) { + showToast(R.string.send_message_failure_airplane_mode); + } else { + showToast(R.string.download_message_failure_airplane_mode); + } + return; + } else if (!isSms && !phoneUtils.isMobileDataEnabled()) { + if (isSend) { + showToast(R.string.send_message_failure_no_data); + } else { + showToast(R.string.download_message_failure_no_data); + } + return; + } + } + + if (AccessibilityUtil.isTouchExplorationEnabled(Factory.get().getApplicationContext())) { + final boolean isFocusedConversation = DataModel.get().isFocusedConversation(conversationId); + if (isFocusedConversation && success) { + // Using View.announceForAccessibility may be preferable, but we do not have a + // View, and so we use a toast instead. + showToast(isSend ? R.string.send_message_success + : R.string.download_message_success); + return; + } + + // {@link MessageNotificationState#checkFailedMessages} does not post a notification for + // failures in observable conversations. For accessibility, we provide an indication + // here. + final boolean isObservableConversation = DataModel.get().isNewMessageObservable( + conversationId); + if (isObservableConversation && !success) { + showToast(isSend ? R.string.send_message_failure + : R.string.download_message_failure); + } + } + } + + public static void onMessageReceived(final String conversationId, + @Nullable final ParticipantData sender, @Nullable final MessageData message) { + final Context context = Factory.get().getApplicationContext(); + if (AccessibilityUtil.isTouchExplorationEnabled(context)) { + final boolean isFocusedConversation = DataModel.get().isFocusedConversation( + conversationId); + if (isFocusedConversation) { + final Resources res = context.getResources(); + final String senderDisplayName = (sender == null) + ? res.getString(R.string.unknown_sender) : sender.getDisplayName(false); + final String announcement = res.getString( + R.string.incoming_message_announcement, senderDisplayName, + (message == null) ? "" : message.getMessageText()); + showToast(announcement); + } + } + } + + public static void onConversationDeleted() { + showToast(R.string.conversation_deleted); + } + + private static void showToast(final int messageResId) { + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + Toast.makeText(getApplicationContext(), + getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show(); + } + }); + } + + private static void showToast(final String message) { + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); + } + }); + } + + private static Context getApplicationContext() { + return Factory.get().getApplicationContext(); + } + + private static class UpdateDestinationBlockedActionToast + implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener { + private final Context mContext; + + UpdateDestinationBlockedActionToast(final Context context) { + mContext = context; + } + + @Override + public void onUpdateDestinationBlockedAction( + final UpdateDestinationBlockedAction action, + final boolean success, + final boolean block, + final String destination) { + if (success) { + Toast.makeText(mContext, + block + ? R.string.update_destination_blocked + : R.string.update_destination_unblocked, + Toast.LENGTH_LONG + ).show(); + } + } + } + + public static UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + makeUpdateDestinationBlockedActionListener(final Context context) { + return new UpdateDestinationBlockedActionToast(context); + } +} diff --git a/src/com/android/messaging/datamodel/action/DeleteConversationAction.java b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java new file mode 100644 index 0000000..a00f6d6 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java @@ -0,0 +1,205 @@ +/* + * 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.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.widget.WidgetConversationProvider; + +import java.util.ArrayList; +import java.util.List; + +/** + * Action used to delete a conversation. + */ +public class DeleteConversationAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void deleteConversation(final String conversationId, final long cutoffTimestamp) { + final DeleteConversationAction action = new DeleteConversationAction(conversationId, + cutoffTimestamp); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_CUTOFF_TIMESTAMP = "cutoff_timestamp"; + + private DeleteConversationAction(final String conversationId, final long cutoffTimestamp) { + super(); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + // TODO: Should we set cuttoff timestamp to prevent us deleting new messages? + actionParameters.putLong(KEY_CUTOFF_TIMESTAMP, cutoffTimestamp); + } + + // Delete conversation from both the local DB and telephony in the background so sync cannot + // run concurrently and incorrectly try to recreate the conversation's messages locally. The + // telephony database can sometimes be quite slow to delete conversations, so we delete from + // the local DB first, notify the UI, and then delete from telephony. + @Override + protected Bundle doBackgroundWork() throws DataModelException { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final long cutoffTimestamp = actionParameters.getLong(KEY_CUTOFF_TIMESTAMP); + + if (!TextUtils.isEmpty(conversationId)) { + // First find the thread id for this conversation. + final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId); + + if (BugleDatabaseOperations.deleteConversation(db, conversationId, cutoffTimestamp)) { + LogUtil.i(TAG, "DeleteConversationAction: Deleted local conversation " + + conversationId); + + BugleActionToasts.onConversationDeleted(); + + // Remove notifications if necessary + BugleNotifications.update(true /* silent */, null /* conversationId */, + BugleNotifications.UPDATE_MESSAGES); + + // We have changed the conversation list + MessagingContentProvider.notifyConversationListChanged(); + + // Notify the widget the conversation is deleted so it can go into its configure state. + WidgetConversationProvider.notifyConversationDeleted( + Factory.get().getApplicationContext(), + conversationId); + } else { + LogUtil.w(TAG, "DeleteConversationAction: Could not delete local conversation " + + conversationId); + return null; + } + + // Now delete from telephony DB. MmsSmsProvider throws an exception if the thread id is + // less than 0. If it's greater than zero, it will delete all messages with that thread + // id, even if there's no corresponding row in the threads table. + if (threadId >= 0) { + final int count = MmsUtils.deleteThread(threadId, cutoffTimestamp); + if (count > 0) { + LogUtil.i(TAG, "DeleteConversationAction: Deleted telephony thread " + + threadId + " (cutoffTimestamp = " + cutoffTimestamp + ")"); + } else { + LogUtil.w(TAG, "DeleteConversationAction: Could not delete thread from " + + "telephony: conversationId = " + conversationId + ", thread id = " + + threadId); + } + } else { + LogUtil.w(TAG, "DeleteConversationAction: Local conversation " + conversationId + + " has an invalid telephony thread id; will delete messages individually"); + deleteConversationMessagesFromTelephony(); + } + } else { + LogUtil.e(TAG, "DeleteConversationAction: conversationId is empty"); + } + + return null; + } + + /** + * Deletes all the telephony messages for the local conversation being deleted. + * <p> + * This is a fallback used when the conversation is not associated with any telephony thread, + * or its thread id is invalid (e.g. negative). This is not common, but can happen sometimes + * (e.g. the Unknown Sender conversation). In the usual case of deleting a conversation, we + * don't need this because the telephony provider automatically deletes messages when a thread + * is deleted. + */ + private void deleteConversationMessagesFromTelephony() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + Assert.notNull(conversationId); + + final List<Uri> messageUris = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + new String[] { MessageColumns.SMS_MESSAGE_URI }, + MessageColumns.CONVERSATION_ID + "=?", + new String[] { conversationId }, + null, null, null); + while (cursor.moveToNext()) { + String messageUri = cursor.getString(0); + try { + messageUris.add(Uri.parse(messageUri)); + } catch (Exception e) { + LogUtil.e(TAG, "DeleteConversationAction: Could not parse message uri " + + messageUri); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + for (Uri messageUri : messageUris) { + int count = MmsUtils.deleteMessage(messageUri); + if (count > 0) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "DeleteConversationAction: Deleted telephony message " + + messageUri); + } + } else { + LogUtil.w(TAG, "DeleteConversationAction: Could not delete telephony message " + + messageUri); + } + } + } + + @Override + protected Object executeAction() { + requestBackgroundWork(); + return null; + } + + private DeleteConversationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<DeleteConversationAction> CREATOR + = new Parcelable.Creator<DeleteConversationAction>() { + @Override + public DeleteConversationAction createFromParcel(final Parcel in) { + return new DeleteConversationAction(in); + } + + @Override + public DeleteConversationAction[] newArray(final int size) { + return new DeleteConversationAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/DeleteMessageAction.java b/src/com/android/messaging/datamodel/action/DeleteMessageAction.java new file mode 100644 index 0000000..9ddb2a6 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DeleteMessageAction.java @@ -0,0 +1,135 @@ +/* + * 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.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.LogUtil; + +/** + * Action used to delete a single message. + */ +public class DeleteMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void deleteMessage(final String messageId) { + final DeleteMessageAction action = new DeleteMessageAction(messageId); + action.start(); + } + + private static final String KEY_MESSAGE_ID = "message_id"; + + private DeleteMessageAction(final String messageId) { + super(); + actionParameters.putString(KEY_MESSAGE_ID, messageId); + } + + // Doing this work in the background so that we're not competing with sync + // which could bring the deleted message back to life between the time we deleted + // it locally and deleted it in telephony (sync is also done on doBackgroundWork). + // + // Previously this block of code deleted from telephony first but that can be very + // slow (on the order of seconds) so this was modified to first delete locally, trigger + // the UI update, then delete from telephony. + @Override + protected Bundle doBackgroundWork() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // First find the thread id for this conversation. + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + if (!TextUtils.isEmpty(messageId)) { + // Check message still exists + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + if (message != null) { + // Delete from local DB + int count = BugleDatabaseOperations.deleteMessage(db, messageId); + if (count > 0) { + LogUtil.i(TAG, "DeleteMessageAction: Deleted local message " + + messageId); + } else { + LogUtil.w(TAG, "DeleteMessageAction: Could not delete local message " + + messageId); + } + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + // We may have changed the conversation list + MessagingContentProvider.notifyConversationListChanged(); + + final Uri messageUri = message.getSmsMessageUri(); + if (messageUri != null) { + // Delete from telephony DB + count = MmsUtils.deleteMessage(messageUri); + if (count > 0) { + LogUtil.i(TAG, "DeleteMessageAction: Deleted telephony message " + + messageUri); + } else { + LogUtil.w(TAG, "DeleteMessageAction: Could not delete message from " + + "telephony: messageId = " + messageId + ", telephony uri = " + + messageUri); + } + } else { + LogUtil.i(TAG, "DeleteMessageAction: Local message " + messageId + + " has no telephony uri."); + } + } else { + LogUtil.w(TAG, "DeleteMessageAction: Message " + messageId + " no longer exists"); + } + } + return null; + } + + /** + * Delete the message. + */ + @Override + protected Object executeAction() { + requestBackgroundWork(); + return null; + } + + private DeleteMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<DeleteMessageAction> CREATOR + = new Parcelable.Creator<DeleteMessageAction>() { + @Override + public DeleteMessageAction createFromParcel(final Parcel in) { + return new DeleteMessageAction(in); + } + + @Override + public DeleteMessageAction[] newArray(final int size) { + return new DeleteMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/DownloadMmsAction.java b/src/com/android/messaging/datamodel/action/DownloadMmsAction.java new file mode 100644 index 0000000..7a8c907 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DownloadMmsAction.java @@ -0,0 +1,340 @@ +/* + * 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.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; + +/** + * Downloads an MMS message. + * <p> + * This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to + * access the EXTRA_* fields for setting up the 'downloaded' pending intent. + */ +public class DownloadMmsAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Interface for DownloadMmsAction listeners + */ + public interface DownloadMmsActionListener { + @RunsOnMainThread + abstract void onDownloadMessageStarting(final ActionMonitor monitor, + final Object data, final MessageData message); + @RunsOnMainThread + abstract void onDownloadMessageSucceeded(final ActionMonitor monitor, + final Object data, final MessageData message); + @RunsOnMainThread + abstract void onDownloadMessageFailed(final ActionMonitor monitor, + final Object data, final MessageData message); + } + + /** + * Queue download of an mms notification message (can only be called during execute of action) + */ + static boolean queueMmsForDownloadInBackground(final String messageId, + final Action processingAction) { + // When this method is being called, it is always from auto download + final DownloadMmsAction action = new DownloadMmsAction(); + // This could queue nothing + return action.queueAction(messageId, processingAction); + } + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_PARTICIPANT_ID = "participant_id"; + private static final String KEY_CONTENT_LOCATION = "content_location"; + private static final String KEY_TRANSACTION_ID = "transaction_id"; + private static final String KEY_NOTIFICATION_URI = "notification_uri"; + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number"; + private static final String KEY_AUTO_DOWNLOAD = "auto_download"; + private static final String KEY_FAILURE_STATUS = "failure_status"; + + // Values we attach to the pending intent that's fired when the message is downloaded. + // Only applicable when downloading via the platform APIs on L+. + public static final String EXTRA_MESSAGE_ID = "message_id"; + public static final String EXTRA_CONTENT_URI = "content_uri"; + public static final String EXTRA_NOTIFICATION_URI = "notification_uri"; + public static final String EXTRA_SUB_ID = "sub_id"; + public static final String EXTRA_SUB_PHONE_NUMBER = "sub_phone_number"; + public static final String EXTRA_TRANSACTION_ID = "transaction_id"; + public static final String EXTRA_CONTENT_LOCATION = "content_location"; + public static final String EXTRA_AUTO_DOWNLOAD = "auto_download"; + public static final String EXTRA_RECEIVED_TIMESTAMP = "received_timestamp"; + public static final String EXTRA_CONVERSATION_ID = "conversation_id"; + public static final String EXTRA_PARTICIPANT_ID = "participant_id"; + public static final String EXTRA_STATUS_IF_FAILED = "status_if_failed"; + + private DownloadMmsAction() { + super(); + } + + @Override + protected Object executeAction() { + Assert.fail("DownloadMmsAction must be queued rather than started"); + return null; + } + + protected boolean queueAction(final String messageId, final Action processingAction) { + actionParameters.putString(KEY_MESSAGE_ID, messageId); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + // Read the message from local db + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + if (message != null && message.canDownloadMessage()) { + final Uri notificationUri = message.getSmsMessageUri(); + final String conversationId = message.getConversationId(); + final int status = message.getStatus(); + + final String selfId = message.getSelfId(); + final ParticipantData self = BugleDatabaseOperations + .getExistingParticipant(db, selfId); + final int subId = self.getSubId(); + actionParameters.putInt(KEY_SUB_ID, subId); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putString(KEY_PARTICIPANT_ID, message.getParticipantId()); + actionParameters.putString(KEY_CONTENT_LOCATION, message.getMmsContentLocation()); + actionParameters.putString(KEY_TRANSACTION_ID, message.getMmsTransactionId()); + actionParameters.putParcelable(KEY_NOTIFICATION_URI, notificationUri); + actionParameters.putBoolean(KEY_AUTO_DOWNLOAD, isAutoDownload(status)); + + final long now = System.currentTimeMillis(); + if (message.getInDownloadWindow(now)) { + // We can still retry + actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination()); + + final int downloadingStatus = getDownloadingStatus(status); + // Update message status to indicate downloading. + updateMessageStatus(notificationUri, messageId, conversationId, + downloadingStatus, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED); + // Pre-compute the next status when failed so we don't have to load from db again + actionParameters.putInt(KEY_FAILURE_STATUS, getFailureStatus(downloadingStatus)); + + // Actual download happens in background + processingAction.requestBackgroundWork(this); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, + "DownloadMmsAction: Queued download of MMS message " + messageId); + } + return true; + } else { + LogUtil.w(TAG, "DownloadMmsAction: Download of MMS message " + messageId + + " failed (outside download window)"); + + // Retries depleted and we failed. Update the message status so we won't retry again + updateMessageStatus(notificationUri, messageId, conversationId, + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED, + MessageData.RAW_TELEPHONY_STATUS_UNDEFINED); + if (status == MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD) { + // For auto download failure, we should send a DEFERRED NotifyRespInd + // to carrier to indicate we will manual download later + ProcessDownloadedMmsAction.sendDeferredRespStatus( + messageId, message.getMmsTransactionId(), + message.getMmsContentLocation(), subId); + return true; + } + } + } + return false; + } + + /** + * Find out the auto download state of this message based on its starting status + * + * @param status The starting status of the message. + * @return True if this is a message doing auto downloading, false otherwise + */ + private static boolean isAutoDownload(final int status) { + switch (status) { + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + return false; + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + return true; + default: + Assert.fail("isAutoDownload: invalid input status " + status); + return false; + } + } + + /** + * Get the corresponding downloading status based on the starting status of the message + * + * @param status The starting status of the message. + * @return The downloading status + */ + private static int getDownloadingStatus(final int status) { + switch (status) { + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING; + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + return MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING; + default: + Assert.fail("isAutoDownload: invalid input status " + status); + return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING; + } + } + + /** + * Get the corresponding failed status based on the current downloading status + * + * @param status The downloading status + * @return The status the message should have if downloading failed + */ + private static int getFailureStatus(final int status) { + switch (status) { + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + return MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD; + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD; + default: + Assert.fail("isAutoDownload: invalid input status " + status); + return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD; + } + } + + @Override + protected Bundle doBackgroundWork() { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final Uri notificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI); + final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID); + final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS); + + final long receivedTimestampRoundedToSecond = + 1000 * ((System.currentTimeMillis() + 500) / 1000); + + LogUtil.i(TAG, "DownloadMmsAction: Downloading MMS message " + messageId + + " (" + (autoDownload ? "auto" : "manual") + ")"); + + // Bundle some values we'll need after the message is downloaded (via platform APIs) + final Bundle extras = new Bundle(); + extras.putString(EXTRA_MESSAGE_ID, messageId); + extras.putString(EXTRA_CONVERSATION_ID, conversationId); + extras.putString(EXTRA_PARTICIPANT_ID, participantId); + extras.putInt(EXTRA_STATUS_IF_FAILED, statusIfFailed); + + // Start the download + final MmsUtils.StatusPlusUri status = MmsUtils.downloadMmsMessage(context, + notificationUri, subId, subPhoneNumber, transactionId, contentLocation, + autoDownload, receivedTimestampRoundedToSecond / 1000L, extras); + if (status == MmsUtils.STATUS_PENDING) { + // Async download; no status yet + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "DownloadMmsAction: Downloading MMS message " + messageId + + " asynchronously; waiting for pending intent to signal completion"); + } + } else { + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(receivedTimestampRoundedToSecond); + // Handle downloaded message + ProcessDownloadedMmsAction.processMessageDownloadFastFailed(messageId, + notificationUri, conversationId, participantId, contentLocation, subId, + subPhoneNumber, statusIfFailed, autoDownload, transactionId, + status.resultCode); + } + return null; + } + + @Override + protected Object processBackgroundResponse(final Bundle response) { + // Nothing to do here; post-download actions handled by ProcessDownloadedMmsAction + return null; + } + + @Override + protected Object processBackgroundFailure() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID); + final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS); + final int subId = actionParameters.getInt(KEY_SUB_ID); + + ProcessDownloadedMmsAction.processDownloadActionFailure(messageId, + MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + conversationId, participantId, statusIfFailed, subId, transactionId); + + return null; + } + + static void updateMessageStatus(final Uri messageUri, final String messageId, + final String conversationId, final int status, final int rawStatus) { + final Context context = Factory.get().getApplicationContext(); + // Downloading status just kept in local DB but need to fix up telephony DB first + if (status == MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING || + status == MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) { + MmsUtils.clearMmsStatus(context, messageUri); + } + // Then mark downloading status in our local DB + final ContentValues values = new ContentValues(); + values.put(MessageColumns.STATUS, status); + values.put(MessageColumns.RAW_TELEPHONY_STATUS, rawStatus); + final DatabaseWrapper db = DataModel.get().getDatabase(); + BugleDatabaseOperations.updateMessageRowIfExists(db, messageId, values); + + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + + private DownloadMmsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<DownloadMmsAction> CREATOR + = new Parcelable.Creator<DownloadMmsAction>() { + @Override + public DownloadMmsAction createFromParcel(final Parcel in) { + return new DownloadMmsAction(in); + } + + @Override + public DownloadMmsAction[] newArray(final int size) { + return new DownloadMmsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java b/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java new file mode 100644 index 0000000..ab320bf --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java @@ -0,0 +1,124 @@ +/* + * 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.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public class DumpDatabaseAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + public static final String DUMP_NAME = "db_copy.db"; + private static final int BUFFER_SIZE = 16384; + + /** + * Copy the database to external storage + */ + public static void dumpDatabase() { + final DumpDatabaseAction action = new DumpDatabaseAction(); + action.start(); + } + + private DumpDatabaseAction() { + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final String dbName = DatabaseHelper.DATABASE_NAME; + BufferedOutputStream bos = null; + BufferedInputStream bis = null; + + long originalSize = 0; + final File inFile = context.getDatabasePath(dbName); + if (inFile.exists() && inFile.isFile()) { + originalSize = inFile.length(); + } + final File outFile = DebugUtils.getDebugFile(DUMP_NAME, true); + if (outFile != null) { + int totalBytes = 0; + try { + bos = new BufferedOutputStream(new FileOutputStream(outFile)); + bis = new BufferedInputStream(new FileInputStream(inFile)); + + final byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = bis.read(buffer)) > 0) { + bos.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + } catch (final IOException e) { + LogUtil.w(TAG, "Exception copying the database;" + + " destination may not be complete.", e); + } finally { + if (bos != null) { + try { + bos.close(); + } catch (final IOException e) { + // Nothing to do + } + } + + if (bis != null) { + try { + bis.close(); + } catch (final IOException e) { + // Nothing to do + } + } + DebugUtils.ensureReadable(outFile); + LogUtil.i(TAG, "Dump complete; orig size: " + originalSize + + ", copy size: " + totalBytes); + } + } + return null; + } + + private DumpDatabaseAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<DumpDatabaseAction> CREATOR + = new Parcelable.Creator<DumpDatabaseAction>() { + @Override + public DumpDatabaseAction createFromParcel(final Parcel in) { + return new DumpDatabaseAction(in); + } + + @Override + public DumpDatabaseAction[] newArray(final int size) { + return new DumpDatabaseAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java b/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java new file mode 100644 index 0000000..e3d131d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java @@ -0,0 +1,114 @@ +/* + * 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.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +/** + * Action used to fixup actively downloading or sending status at startup - just in case we + * crash - never run this when a message might actually be sending or downloading. + */ +public class FixupMessageStatusOnStartupAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void fixupMessageStatus() { + final FixupMessageStatusOnStartupAction action = new FixupMessageStatusOnStartupAction(); + action.start(); + } + + private FixupMessageStatusOnStartupAction() { + } + + @Override + protected Object executeAction() { + // Now mark any messages in active sending or downloading state as inactive + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + int downloadFailedCnt = 0; + int sendFailedCnt = 0; + try { + // For both sending and downloading messages, let's assume they failed. + // For MMS sent/downloaded via platform, the sent/downloaded pending intent + // may come back. That will update the message. User may see the message + // in wrong status within a short window if that happens. But this should + // rarely happen. This is a simple solution to situations like app gets killed + // while the pending intent is still in the fly. Alternatively, we could + // keep the status for platform sent/downloaded MMS and timeout these messages. + // But that is much more complex. + final ContentValues values = new ContentValues(); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED); + downloadFailedCnt += db.update(DatabaseHelper.MESSAGES_TABLE, values, + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[]{ + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING), + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) + }); + values.clear(); + + values.clear(); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_OUTGOING_FAILED); + sendFailedCnt = db.update(DatabaseHelper.MESSAGES_TABLE, values, + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[]{ + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING), + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING) + }); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + LogUtil.i(TAG, "Fixup: Send failed - " + sendFailedCnt + + " Download failed - " + downloadFailedCnt); + + // Don't send contentObserver notifications as displayed text should not change + return null; + } + + private FixupMessageStatusOnStartupAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<FixupMessageStatusOnStartupAction> CREATOR + = new Parcelable.Creator<FixupMessageStatusOnStartupAction>() { + @Override + public FixupMessageStatusOnStartupAction createFromParcel(final Parcel in) { + return new FixupMessageStatusOnStartupAction(in); + } + + @Override + public FixupMessageStatusOnStartupAction[] newArray(final int size) { + return new FixupMessageStatusOnStartupAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java b/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java new file mode 100644 index 0000000..b262141 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java @@ -0,0 +1,173 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Action used to get or create a conversation for a list of conversation participants. + */ +public class GetOrCreateConversationAction extends Action implements Parcelable { + /** + * Interface for GetOrCreateConversationAction listeners + */ + public interface GetOrCreateConversationActionListener { + @RunsOnMainThread + abstract void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId); + + @RunsOnMainThread + abstract void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data); + } + + public static GetOrCreateConversationActionMonitor getOrCreateConversation( + final ArrayList<ParticipantData> participants, final Object data, + final GetOrCreateConversationActionListener listener) { + final GetOrCreateConversationActionMonitor monitor = new + GetOrCreateConversationActionMonitor(data, listener); + final GetOrCreateConversationAction action = new GetOrCreateConversationAction(participants, + monitor.getActionKey()); + action.start(monitor); + return monitor; + } + + + public static GetOrCreateConversationActionMonitor getOrCreateConversation( + final String[] recipients, final Object data, final LaunchConversationData listener) { + final ArrayList<ParticipantData> participants = new ArrayList<>(); + for (String recipient : recipients) { + recipient = recipient.trim(); + if (!TextUtils.isEmpty(recipient)) { + participants.add(ParticipantData.getFromRawPhoneBySystemLocale(recipient)); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "getOrCreateConversation hit empty recipient"); + } + } + return getOrCreateConversation(participants, data, listener); + } + + private static final String KEY_PARTICIPANTS_LIST = "participants_list"; + + private GetOrCreateConversationAction(final ArrayList<ParticipantData> participants, + final String actionKey) { + super(actionKey); + actionParameters.putParcelableArrayList(KEY_PARTICIPANTS_LIST, participants); + } + + /** + * Lookup the conversation or create a new one. + */ + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // First find the thread id for this list of participants. + final ArrayList<ParticipantData> participants = + actionParameters.getParcelableArrayList(KEY_PARTICIPANTS_LIST); + BugleDatabaseOperations.sanitizeConversationParticipants(participants); + final ArrayList<String> recipients = + BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants); + + final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(), + recipients); + + if (threadId < 0) { + LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't create a threadId in SMS db for numbers : " + + LogUtil.sanitizePII(recipients.toString())); + // TODO: Add a better way to indicate an error from executeAction. + return null; + } + + final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + false, participants, false, false, null); + + return conversationId; + } + + /** + * A monitor that notifies a listener upon completion + */ + public static class GetOrCreateConversationActionMonitor extends ActionMonitor + implements ActionCompletedListener { + private final GetOrCreateConversationActionListener mListener; + + GetOrCreateConversationActionMonitor(final Object data, + final GetOrCreateConversationActionListener listener) { + super(STATE_CREATED, generateUniqueActionKey("GetOrCreateConversationAction"), data); + setCompletedListener(this); + mListener = listener; + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + if (result == null) { + mListener.onGetOrCreateConversationFailed(monitor, data); + } else { + mListener.onGetOrCreateConversationSucceeded(monitor, data, (String) result); + } + } + + @Override + public void onActionFailed(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + // TODO: Currently onActionFailed is only called if there is an error in + // processing requests, not for errors in the local processing. + Assert.fail("Unreachable"); + mListener.onGetOrCreateConversationFailed(monitor, data); + } + } + + private GetOrCreateConversationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<GetOrCreateConversationAction> CREATOR + = new Parcelable.Creator<GetOrCreateConversationAction>() { + @Override + public GetOrCreateConversationAction createFromParcel(final Parcel in) { + return new GetOrCreateConversationAction(in); + } + + @Override + public GetOrCreateConversationAction[] newArray(final int size) { + return new GetOrCreateConversationAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java b/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java new file mode 100644 index 0000000..7bfcfe0 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java @@ -0,0 +1,94 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.sms.SmsReleaseStorage; +import com.android.messaging.util.Assert; + +/** + * Action used to handle low storage related issues on the device. + */ +public class HandleLowStorageAction extends Action implements Parcelable { + private static final int SUB_OP_CODE_CLEAR_MEDIA_MESSAGES = 100; + private static final int SUB_OP_CODE_CLEAR_OLD_MESSAGES = 101; + + public static void handleDeleteMediaMessages(final long durationInMillis) { + final HandleLowStorageAction action = new HandleLowStorageAction( + SUB_OP_CODE_CLEAR_MEDIA_MESSAGES, durationInMillis); + action.start(); + } + + public static void handleDeleteOldMessages(final long durationInMillis) { + final HandleLowStorageAction action = new HandleLowStorageAction( + SUB_OP_CODE_CLEAR_OLD_MESSAGES, durationInMillis); + action.start(); + } + + private static final String KEY_SUB_OP_CODE = "sub_op_code"; + private static final String KEY_CUTOFF_DURATION_MILLIS = "cutoff_duration_millis"; + + private HandleLowStorageAction(final int subOpcode, final long durationInMillis) { + super(); + actionParameters.putInt(KEY_SUB_OP_CODE, subOpcode); + actionParameters.putLong(KEY_CUTOFF_DURATION_MILLIS, durationInMillis); + } + + @Override + protected Object executeAction() { + final int subOpCode = actionParameters.getInt(KEY_SUB_OP_CODE); + final long durationInMillis = actionParameters.getLong(KEY_CUTOFF_DURATION_MILLIS); + switch (subOpCode) { + case SUB_OP_CODE_CLEAR_MEDIA_MESSAGES: + SmsReleaseStorage.deleteMessages(0, durationInMillis); + break; + + case SUB_OP_CODE_CLEAR_OLD_MESSAGES: + SmsReleaseStorage.deleteMessages(1, durationInMillis); + break; + + default: + Assert.fail("Unsupported action type!"); + break; + } + return true; + } + + private HandleLowStorageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<HandleLowStorageAction> CREATOR + = new Parcelable.Creator<HandleLowStorageAction>() { + @Override + public HandleLowStorageAction createFromParcel(final Parcel in) { + return new HandleLowStorageAction(in); + } + + @Override + public HandleLowStorageAction[] newArray(final int size) { + return new HandleLowStorageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java b/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java new file mode 100644 index 0000000..2567ca9 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java @@ -0,0 +1,480 @@ +/* + * 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.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Action used to convert a draft message to an outgoing message. Its writes SMS messages to + * the telephony db, but {@link SendMessageAction} is responsible for inserting MMS message into + * the telephony DB. The latter also does the actual sending of the message in the background. + * The latter is also responsible for re-sending a failed message. + */ +public class InsertNewMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static long sLastSentMessageTimestamp = -1; + + /** + * Insert message (no listener) + */ + public static void insertNewMessage(final MessageData message) { + final InsertNewMessageAction action = new InsertNewMessageAction(message); + action.start(); + } + + /** + * Insert message (no listener) with a given non-default subId. + */ + public static void insertNewMessage(final MessageData message, final int subId) { + Assert.isFalse(subId == ParticipantData.DEFAULT_SELF_SUB_ID); + final InsertNewMessageAction action = new InsertNewMessageAction(message, subId); + action.start(); + } + + /** + * Insert message (no listener) + */ + public static void insertNewMessage(final int subId, final String recipients, + final String messageText, final String subject) { + final InsertNewMessageAction action = new InsertNewMessageAction( + subId, recipients, messageText, subject); + action.start(); + } + + public static long getLastSentMessageTimestamp() { + return sLastSentMessageTimestamp; + } + + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_MESSAGE = "message"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_MESSAGE_TEXT = "message_text"; + private static final String KEY_SUBJECT_TEXT = "subject_text"; + + private InsertNewMessageAction(final MessageData message) { + this(message, ParticipantData.DEFAULT_SELF_SUB_ID); + actionParameters.putParcelable(KEY_MESSAGE, message); + } + + private InsertNewMessageAction(final MessageData message, final int subId) { + super(); + actionParameters.putParcelable(KEY_MESSAGE, message); + actionParameters.putInt(KEY_SUB_ID, subId); + } + + private InsertNewMessageAction(final int subId, final String recipients, + final String messageText, final String subject) { + super(); + if (TextUtils.isEmpty(recipients) || TextUtils.isEmpty(messageText)) { + Assert.fail("InsertNewMessageAction: Can't have empty recipients or message"); + } + actionParameters.putInt(KEY_SUB_ID, subId); + actionParameters.putString(KEY_RECIPIENTS, recipients); + actionParameters.putString(KEY_MESSAGE_TEXT, messageText); + actionParameters.putString(KEY_SUBJECT_TEXT, subject); + } + + /** + * Add message to database in pending state and queue actual sending + */ + @Override + protected Object executeAction() { + LogUtil.i(TAG, "InsertNewMessageAction: inserting new message"); + MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + if (message == null) { + LogUtil.i(TAG, "InsertNewMessageAction: Creating MessageData with provided data"); + message = createMessage(); + if (message == null) { + LogUtil.w(TAG, "InsertNewMessageAction: Could not create MessageData"); + return null; + } + } + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = message.getConversationId(); + + final ParticipantData self = getSelf(db, conversationId, message); + if (self == null) { + return null; + } + message.bindSelfId(self.getId()); + // If the user taps the Send button before the conversation draft is created/loaded by + // ReadDraftDataAction (maybe the action service thread was busy), the MessageData may not + // have the participant id set. It should be equal to the self id, so we'll use that. + if (message.getParticipantId() == null) { + message.bindParticipantId(self.getId()); + } + + final long timestamp = System.currentTimeMillis(); + final ArrayList<String> recipients = + BugleDatabaseOperations.getRecipientsForConversation(db, conversationId); + if (recipients.size() < 1) { + LogUtil.w(TAG, "InsertNewMessageAction: message recipients is empty"); + return null; + } + final int subId = self.getSubId(); + + // TODO: Work out whether to send with SMS or MMS (taking into account recipients)? + final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS); + if (isSms) { + String sendingConversationId = conversationId; + if (recipients.size() > 1) { + // Broadcast SMS - put message in "fake conversation" before farming out to real 1:1 + final long laterTimestamp = timestamp + 1; + // Send a single message + insertBroadcastSmsMessage(conversationId, message, subId, + laterTimestamp, recipients); + + sendingConversationId = null; + } + + for (final String recipient : recipients) { + // Start actual sending + insertSendingSmsMessage(message, subId, recipient, + timestamp, sendingConversationId); + } + + // Can now clear draft from conversation (deleting attachments if necessary) + BugleDatabaseOperations.updateDraftMessageData(db, conversationId, + null /* message */, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT); + } else { + final long timestampRoundedToSecond = 1000 * ((timestamp + 500) / 1000); + // Write place holder message directly referencing parts from the draft + final MessageData messageToSend = insertSendingMmsMessage(conversationId, + message, timestampRoundedToSecond); + + // Can now clear draft from conversation (preserving attachments which are now + // referenced by messageToSend) + BugleDatabaseOperations.updateDraftMessageData(db, conversationId, + messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT); + } + MessagingContentProvider.notifyConversationListChanged(); + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + + return message; + } + + private ParticipantData getSelf( + final DatabaseWrapper db, final String conversationId, final MessageData message) { + ParticipantData self; + // Check if we are asked to bind to a non-default subId. This is directly passed in from + // the UI thread so that the sub id may be locked as soon as the user clicks on the Send + // button. + final int requestedSubId = actionParameters.getInt( + KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + if (requestedSubId != ParticipantData.DEFAULT_SELF_SUB_ID) { + self = BugleDatabaseOperations.getOrCreateSelf(db, requestedSubId); + } else { + String selfId = message.getSelfId(); + if (selfId == null) { + // The conversation draft provides no self id hint, meaning that 1) conversation + // self id was not loaded AND 2) the user didn't pick a SIM from the SIM selector. + // In this case, use the conversation's self id. + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + if (conversation != null) { + selfId = conversation.getSelfId(); + } else { + LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId + + "already deleted before sending draft message " + + message.getMessageId() + ". Aborting InsertNewMessageAction."); + return null; + } + } + + // We do not use SubscriptionManager.DEFAULT_SUB_ID for sending a message, so we need + // to bind the message to the system default subscription if it's unbound. + final ParticipantData unboundSelf = BugleDatabaseOperations.getExistingParticipant( + db, selfId); + if (unboundSelf.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID + && OsUtil.isAtLeastL_MR1()) { + final int defaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId(); + self = BugleDatabaseOperations.getOrCreateSelf(db, defaultSubId); + } else { + self = unboundSelf; + } + } + return self; + } + + /** Create MessageData using KEY_RECIPIENTS, KEY_MESSAGE_TEXT and KEY_SUBJECT */ + private MessageData createMessage() { + // First find the thread id for this list of participants. + final String recipientsList = actionParameters.getString(KEY_RECIPIENTS); + final String messageText = actionParameters.getString(KEY_MESSAGE_TEXT); + final String subjectText = actionParameters.getString(KEY_SUBJECT_TEXT); + final int subId = actionParameters.getInt( + KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + + final ArrayList<ParticipantData> participants = new ArrayList<>(); + for (final String recipient : recipientsList.split(",")) { + participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, subId)); + } + if (participants.size() == 0) { + Assert.fail("InsertNewMessage: Empty participants"); + return null; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + BugleDatabaseOperations.sanitizeConversationParticipants(participants); + final ArrayList<String> recipients = + BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants); + if (recipients.size() == 0) { + Assert.fail("InsertNewMessage: Empty recipients"); + return null; + } + + final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(), + recipients); + + if (threadId < 0) { + Assert.fail("InsertNewMessage: Couldn't get threadId in SMS db for these recipients: " + + recipients.toString()); + // TODO: How do we fail the action? + return null; + } + + final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + false, participants, false, false, null); + + final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId); + + if (TextUtils.isEmpty(subjectText)) { + return MessageData.createDraftSmsMessage(conversationId, self.getId(), messageText); + } else { + return MessageData.createDraftMmsMessage(conversationId, self.getId(), messageText, + subjectText); + } + } + + private void insertBroadcastSmsMessage(final String conversationId, + final MessageData message, final int subId, final long laterTimestamp, + final ArrayList<String> recipients) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "InsertNewMessageAction: Inserting broadcast SMS message " + + message.getMessageId()); + } + final Context context = Factory.get().getApplicationContext(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Inform sync that message is being added at timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(laterTimestamp); + + final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId); + final String address = TextUtils.join(" ", recipients); + + final String messageText = message.getMessageText(); + // Insert message into telephony database sms message table + final Uri messageUri = MmsUtils.insertSmsMessage(context, + Telephony.Sms.CONTENT_URI, + subId, + address, + messageText, + laterTimestamp, + Telephony.Sms.STATUS_COMPLETE, + Telephony.Sms.MESSAGE_TYPE_SENT, threadId); + if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) { + db.beginTransaction(); + try { + message.updateSendingMessage(conversationId, messageUri, laterTimestamp); + message.markMessageSent(laterTimestamp); + + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), laterTimestamp, + false /* senderBlocked */, false /* shouldAutoSwitchSelfId */); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "InsertNewMessageAction: Inserted broadcast SMS message " + + message.getMessageId() + ", uri = " + message.getSmsMessageUri()); + } + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + } else { + // Ignore error as we only really care about the individual messages? + LogUtil.e(TAG, + "InsertNewMessageAction: No uri for broadcast SMS " + message.getMessageId() + + " inserted into telephony DB"); + } + } + + /** + * Insert SMS messaging into our database and telephony db. + */ + private MessageData insertSendingSmsMessage(final MessageData content, final int subId, + final String recipient, final long timestamp, final String sendingConversationId) { + sLastSentMessageTimestamp = timestamp; + + final Context context = Factory.get().getApplicationContext(); + + // Inform sync that message is being added at timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(timestamp); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Send a single message + long threadId; + String conversationId; + if (sendingConversationId == null) { + // For 1:1 message generated sending broadcast need to look up threadId+conversationId + threadId = MmsUtils.getOrCreateSmsThreadId(context, recipient); + conversationId = BugleDatabaseOperations.getOrCreateConversationFromRecipient( + db, threadId, false /* sender blocked */, + ParticipantData.getFromRawPhoneBySimLocale(recipient, subId)); + } else { + // Otherwise just look up threadId + threadId = BugleDatabaseOperations.getThreadId(db, sendingConversationId); + conversationId = sendingConversationId; + } + + final String messageText = content.getMessageText(); + + // Insert message into telephony database sms message table + final Uri messageUri = MmsUtils.insertSmsMessage(context, + Telephony.Sms.CONTENT_URI, + subId, + recipient, + messageText, + timestamp, + Telephony.Sms.STATUS_NONE, + Telephony.Sms.MESSAGE_TYPE_SENT, threadId); + + MessageData message = null; + if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) { + db.beginTransaction(); + try { + message = MessageData.createDraftSmsMessage(conversationId, + content.getSelfId(), messageText); + message.updateSendingMessage(conversationId, messageUri, timestamp); + + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + // Do not update the conversation summary to reflect autogenerated 1:1 messages + if (sendingConversationId != null) { + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), timestamp, + false /* senderBlocked */, false /* shouldAutoSwitchSelfId */); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "InsertNewMessageAction: Inserted SMS message " + + message.getMessageId() + " (uri = " + message.getSmsMessageUri() + + ", timestamp = " + message.getReceivedTimeStamp() + ")"); + } + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + } else { + LogUtil.e(TAG, "InsertNewMessageAction: No uri for SMS inserted into telephony DB"); + } + + return message; + } + + /** + * Insert MMS messaging into our database. + */ + private MessageData insertSendingMmsMessage(final String conversationId, + final MessageData message, final long timestamp) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + final List<MessagePartData> attachmentsUpdated = new ArrayList<>(); + try { + sLastSentMessageTimestamp = timestamp; + + // Insert "draft" message as placeholder until the final message is written to + // the telephony db + message.updateSendingMessage(conversationId, null/*messageUri*/, timestamp); + + // No need to inform SyncManager as message currently has no Uri... + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), timestamp, + false /* senderBlocked */, false /* shouldAutoSwitchSelfId */); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "InsertNewMessageAction: Inserted MMS message " + + message.getMessageId() + " (timestamp = " + timestamp + ")"); + } + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + + return message; + } + + private InsertNewMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<InsertNewMessageAction> CREATOR + = new Parcelable.Creator<InsertNewMessageAction>() { + @Override + public InsertNewMessageAction createFromParcel(final Parcel in) { + return new InsertNewMessageAction(in); + } + + @Override + public InsertNewMessageAction[] newArray(final int size) { + return new InsertNewMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java b/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java new file mode 100644 index 0000000..441a5a2 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java @@ -0,0 +1,153 @@ +/* + * 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.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Threads; +import android.provider.Telephony.ThreadsColumns; + +import com.android.messaging.Factory; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; + +public class LogTelephonyDatabaseAction extends Action implements Parcelable { + // Because we use sanitizePII, we should also use BUGLE_TAG + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final String[] ALL_THREADS_PROJECTION = { + Threads._ID, + Threads.DATE, + Threads.MESSAGE_COUNT, + Threads.RECIPIENT_IDS, + Threads.SNIPPET, + Threads.SNIPPET_CHARSET, + Threads.READ, + Threads.ERROR, + Threads.HAS_ATTACHMENT }; + + // Constants from the Telephony Database + private static final int ID = 0; + private static final int DATE = 1; + private static final int MESSAGE_COUNT = 2; + private static final int RECIPIENT_IDS = 3; + private static final int SNIPPET = 4; + private static final int SNIPPET_CHAR_SET = 5; + private static final int READ = 6; + private static final int ERROR = 7; + private static final int HAS_ATTACHMENT = 8; + + /** + * Log telephony data to logcat + */ + public static void dumpDatabase() { + final LogTelephonyDatabaseAction action = new LogTelephonyDatabaseAction(); + action.start(); + } + + private LogTelephonyDatabaseAction() { + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + + if (!DebugUtils.isDebugEnabled()) { + LogUtil.e(TAG, "Can't log telephony database unless debugging is enabled"); + return null; + } + + if (!LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.w(TAG, "Can't log telephony database unless DEBUG is turned on for TAG: " + + TAG); + return null; + } + + LogUtil.d(TAG, "\n"); + LogUtil.d(TAG, "Dump of canoncial_addresses table"); + LogUtil.d(TAG, "*********************************"); + + Cursor cursor = SqliteWrapper.query(context, context.getContentResolver(), + Uri.parse("content://mms-sms/canonical-addresses"), null, null, null, null); + + if (cursor == null) { + LogUtil.w(TAG, "null Cursor in content://mms-sms/canonical-addresses"); + } else { + try { + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + String number = cursor.getString(1); + LogUtil.d(TAG, LogUtil.sanitizePII("id: " + id + " number: " + number)); + } + } finally { + cursor.close(); + } + } + + LogUtil.d(TAG, "\n"); + LogUtil.d(TAG, "Dump of threads table"); + LogUtil.d(TAG, "*********************"); + + cursor = SqliteWrapper.query(context, context.getContentResolver(), + Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(), + ALL_THREADS_PROJECTION, null, null, "date ASC"); + try { + while (cursor.moveToNext()) { + LogUtil.d(TAG, LogUtil.sanitizePII("threadId: " + cursor.getLong(ID) + + " " + ThreadsColumns.DATE + " : " + cursor.getLong(DATE) + + " " + ThreadsColumns.MESSAGE_COUNT + " : " + cursor.getInt(MESSAGE_COUNT) + + " " + ThreadsColumns.SNIPPET + " : " + cursor.getString(SNIPPET) + + " " + ThreadsColumns.READ + " : " + cursor.getInt(READ) + + " " + ThreadsColumns.ERROR + " : " + cursor.getInt(ERROR) + + " " + ThreadsColumns.HAS_ATTACHMENT + " : " + + cursor.getInt(HAS_ATTACHMENT) + + " " + ThreadsColumns.RECIPIENT_IDS + " : " + + cursor.getString(RECIPIENT_IDS))); + } + } finally { + cursor.close(); + } + + return null; + } + + private LogTelephonyDatabaseAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<LogTelephonyDatabaseAction> CREATOR + = new Parcelable.Creator<LogTelephonyDatabaseAction>() { + @Override + public LogTelephonyDatabaseAction createFromParcel(final Parcel in) { + return new LogTelephonyDatabaseAction(in); + } + + @Override + public LogTelephonyDatabaseAction[] newArray(final int size) { + return new LogTelephonyDatabaseAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/MarkAsReadAction.java b/src/com/android/messaging/datamodel/action/MarkAsReadAction.java new file mode 100644 index 0000000..31bc59d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/MarkAsReadAction.java @@ -0,0 +1,113 @@ +/* + * 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.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.LogUtil; + +/** + * Action used to mark all the messages in a conversation as read + */ +public class MarkAsReadAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + + /** + * Mark all the messages as read for a particular conversation. + */ + public static void markAsRead(final String conversationId) { + final MarkAsReadAction action = new MarkAsReadAction(conversationId); + action.start(); + } + + private MarkAsReadAction(final String conversationId) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + + // TODO: Consider doing this in background service to avoid delaying other actions + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Mark all messages in thread as read in telephony + final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId); + if (threadId != -1) { + MmsUtils.updateSmsReadStatus(threadId, Long.MAX_VALUE); + } + + // Update local db + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.CONVERSATION_ID, conversationId); + values.put(MessageColumns.READ, 1); + values.put(MessageColumns.SEEN, 1); // if they read it, they saw it + + final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values, + "(" + MessageColumns.READ + " !=1 OR " + + MessageColumns.SEEN + " !=1 ) AND " + + MessageColumns.CONVERSATION_ID + "=?", + new String[] { conversationId }); + if (count > 0) { + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + // After marking messages as read, update the notifications. This will + // clear the now stale notifications. + BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL); + return null; + } + + private MarkAsReadAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<MarkAsReadAction> CREATOR + = new Parcelable.Creator<MarkAsReadAction>() { + @Override + public MarkAsReadAction createFromParcel(final Parcel in) { + return new MarkAsReadAction(in); + } + + @Override + public MarkAsReadAction[] newArray(final int size) { + return new MarkAsReadAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java new file mode 100644 index 0000000..28f55fd --- /dev/null +++ b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java @@ -0,0 +1,126 @@ +/* + * 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.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.LogUtil; + +/** + * Action used to mark all messages as seen + */ +public class MarkAsSeenAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final String KEY_CONVERSATION_ID = "conversation_id"; + + /** + * Mark all messages as seen. + */ + public static void markAllAsSeen() { + final MarkAsSeenAction action = new MarkAsSeenAction((String) null/*conversationId*/); + action.start(); + } + + /** + * Mark all messages of a given conversation as seen. + */ + public static void markAsSeen(final String conversationId) { + final MarkAsSeenAction action = new MarkAsSeenAction(conversationId); + action.start(); + } + + /** + * ctor for MarkAsSeenAction. + * @param conversationId the conversation id for which to mark as seen, or null to mark all + * messages as seen + */ + public MarkAsSeenAction(final String conversationId) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + } + + @Override + protected Object executeAction() { + final String conversationId = + actionParameters.getString(KEY_CONVERSATION_ID); + final boolean hasSpecificConversation = !TextUtils.isEmpty(conversationId); + + // Everything in telephony should already have the seen bit set. + // Possible exception are messages which did not have seen set and + // were sync'ed into bugle. + + // Now mark the messages as seen in the bugle db + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + + try { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.SEEN, 1); + + if (hasSpecificConversation) { + final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values, + MessageColumns.SEEN + " != 1 AND " + + MessageColumns.CONVERSATION_ID + "=?", + new String[] { conversationId }); + if (count > 0) { + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + } else { + db.update(DatabaseHelper.MESSAGES_TABLE, values, + MessageColumns.SEEN + " != 1", null/*selectionArgs*/); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + // After marking messages as seen, update the notifications. This will + // clear the now stale notifications. + BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL); + return null; + } + + private MarkAsSeenAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<MarkAsSeenAction> CREATOR + = new Parcelable.Creator<MarkAsSeenAction>() { + @Override + public MarkAsSeenAction createFromParcel(final Parcel in) { + return new MarkAsSeenAction(in); + } + + @Override + public MarkAsSeenAction[] newArray(final int size) { + return new MarkAsSeenAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java b/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java new file mode 100644 index 0000000..fbd4e82 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java @@ -0,0 +1,122 @@ +/* + * 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.content.ContentUris; +import android.content.ContentValues; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.concurrent.TimeUnit; + +public class ProcessDeliveryReportAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_URI = "uri"; + private static final String KEY_STATUS = "status"; + + private ProcessDeliveryReportAction(final Uri uri, final int status) { + actionParameters.putParcelable(KEY_URI, uri); + actionParameters.putInt(KEY_STATUS, status); + } + + public static void deliveryReportReceived(final Uri uri, final int status) { + final ProcessDeliveryReportAction action = new ProcessDeliveryReportAction(uri, status); + action.start(); + } + + @Override + protected Object executeAction() { + final Uri smsMessageUri = actionParameters.getParcelable(KEY_URI); + final int status = actionParameters.getInt(KEY_STATUS); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final long messageRowId = ContentUris.parseId(smsMessageUri); + if (messageRowId < 0) { + LogUtil.e(TAG, "ProcessDeliveryReportAction: can't find message"); + return null; + } + final long timeSentInMillis = System.currentTimeMillis(); + // Update telephony provider + if (smsMessageUri != null) { + MmsUtils.updateSmsStatusAndDateSent(smsMessageUri, status, timeSentInMillis); + } + + // Update local message + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + final int bugleStatus = SyncMessageBatch.bugleStatusForSms(true /*outgoing*/, + Telephony.Sms.MESSAGE_TYPE_SENT /* type */, status); + values.put(DatabaseHelper.MessageColumns.STATUS, bugleStatus); + values.put(DatabaseHelper.MessageColumns.SENT_TIMESTAMP, + TimeUnit.MILLISECONDS.toMicros(timeSentInMillis)); + + final MessageData messageData = + BugleDatabaseOperations.readMessageData(db, smsMessageUri); + + // Check the message was not removed before the delivery report comes in + if (messageData != null) { + Assert.isTrue(smsMessageUri.equals(messageData.getSmsMessageUri())); + + // Row must exist as was just loaded above (on ActionService thread) + BugleDatabaseOperations.updateMessageRow(db, messageData.getMessageId(), values); + + MessagingContentProvider.notifyMessagesChanged(messageData.getConversationId()); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return null; + } + + private ProcessDeliveryReportAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ProcessDeliveryReportAction> CREATOR + = new Parcelable.Creator<ProcessDeliveryReportAction>() { + @Override + public ProcessDeliveryReportAction createFromParcel(final Parcel in) { + return new ProcessDeliveryReportAction(in); + } + + @Override + public ProcessDeliveryReportAction[] newArray(final int size) { + return new ProcessDeliveryReportAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java b/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java new file mode 100644 index 0000000..757ea05 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java @@ -0,0 +1,573 @@ +/* + * 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.app.Activity; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Mms; +import android.telephony.SmsManager; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.MmsFileProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.mmslib.pdu.RetrieveConf; +import com.android.messaging.sms.DatabaseMessages; +import com.android.messaging.sms.MmsSender; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.io.Files; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; + +/** + * Processes an MMS message after it has been downloaded. + * NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure). + */ +public class ProcessDownloadedMmsAction extends Action { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + // Always set when message downloaded + private static final String KEY_DOWNLOADED_BY_PLATFORM = "downloaded_by_platform"; + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_NOTIFICATION_URI = "notification_uri"; + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_PARTICIPANT_ID = "participant_id"; + private static final String KEY_STATUS_IF_FAILED = "status_if_failed"; + + // Set when message downloaded by platform (L+) + private static final String KEY_RESULT_CODE = "result_code"; + private static final String KEY_HTTP_STATUS_CODE = "http_status_code"; + private static final String KEY_CONTENT_URI = "content_uri"; + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number"; + private static final String KEY_TRANSACTION_ID = "transaction_id"; + private static final String KEY_CONTENT_LOCATION = "content_location"; + private static final String KEY_AUTO_DOWNLOAD = "auto_download"; + private static final String KEY_RECEIVED_TIMESTAMP = "received_timestamp"; + + // Set when message downloaded by us (legacy) + private static final String KEY_STATUS = "status"; + private static final String KEY_RAW_STATUS = "raw_status"; + private static final String KEY_MMS_URI = "mms_uri"; + + // Used to send a deferred response in response to auto-download failure + private static final String KEY_SEND_DEFERRED_RESP_STATUS = "send_deferred_resp_status"; + + // Results passed from background worker to processCompletion + private static final String BUNDLE_REQUEST_STATUS = "request_status"; + private static final String BUNDLE_RAW_TELEPHONY_STATUS = "raw_status"; + private static final String BUNDLE_MMS_URI = "mms_uri"; + + // This is called when MMS lib API returns via PendingIntent + public static void processMessageDownloaded(final int resultCode, final Bundle extras) { + final String messageId = extras.getString(DownloadMmsAction.EXTRA_MESSAGE_ID); + final Uri contentUri = extras.getParcelable(DownloadMmsAction.EXTRA_CONTENT_URI); + final Uri notificationUri = extras.getParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI); + final String conversationId = extras.getString(DownloadMmsAction.EXTRA_CONVERSATION_ID); + final String participantId = extras.getString(DownloadMmsAction.EXTRA_PARTICIPANT_ID); + Assert.notNull(messageId); + Assert.notNull(contentUri); + Assert.notNull(notificationUri); + Assert.notNull(conversationId); + Assert.notNull(participantId); + + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true); + params.putString(KEY_MESSAGE_ID, messageId); + params.putInt(KEY_RESULT_CODE, resultCode); + params.putInt(KEY_HTTP_STATUS_CODE, + extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0)); + params.putParcelable(KEY_CONTENT_URI, contentUri); + params.putParcelable(KEY_NOTIFICATION_URI, notificationUri); + params.putInt(KEY_SUB_ID, + extras.getInt(DownloadMmsAction.EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID)); + params.putString(KEY_SUB_PHONE_NUMBER, + extras.getString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER)); + params.putString(KEY_TRANSACTION_ID, + extras.getString(DownloadMmsAction.EXTRA_TRANSACTION_ID)); + params.putString(KEY_CONTENT_LOCATION, + extras.getString(DownloadMmsAction.EXTRA_CONTENT_LOCATION)); + params.putBoolean(KEY_AUTO_DOWNLOAD, + extras.getBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD)); + params.putLong(KEY_RECEIVED_TIMESTAMP, + extras.getLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP)); + params.putString(KEY_CONVERSATION_ID, conversationId); + params.putString(KEY_PARTICIPANT_ID, participantId); + params.putInt(KEY_STATUS_IF_FAILED, + extras.getInt(DownloadMmsAction.EXTRA_STATUS_IF_FAILED)); + action.start(); + } + + // This is called for fast failing downloading (due to airplane mode or mobile data ) + public static void processMessageDownloadFastFailed(final String messageId, + final Uri notificationUri, final String conversationId, final String participantId, + final String contentLocation, final int subId, final String subPhoneNumber, + final int statusIfFailed, final boolean autoDownload, final String transactionId, + final int resultCode) { + Assert.notNull(messageId); + Assert.notNull(notificationUri); + Assert.notNull(conversationId); + Assert.notNull(participantId); + + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true); + params.putString(KEY_MESSAGE_ID, messageId); + params.putInt(KEY_RESULT_CODE, resultCode); + params.putParcelable(KEY_NOTIFICATION_URI, notificationUri); + params.putInt(KEY_SUB_ID, subId); + params.putString(KEY_SUB_PHONE_NUMBER, subPhoneNumber); + params.putString(KEY_CONTENT_LOCATION, contentLocation); + params.putBoolean(KEY_AUTO_DOWNLOAD, autoDownload); + params.putString(KEY_CONVERSATION_ID, conversationId); + params.putString(KEY_PARTICIPANT_ID, participantId); + params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed); + params.putString(KEY_TRANSACTION_ID, transactionId); + action.start(); + } + + public static void processDownloadActionFailure(final String messageId, final int status, + final int rawStatus, final String conversationId, final String participantId, + final int statusIfFailed, final int subId, final String transactionId) { + Assert.notNull(messageId); + Assert.notNull(conversationId); + Assert.notNull(participantId); + + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, false); + params.putString(KEY_MESSAGE_ID, messageId); + params.putInt(KEY_STATUS, status); + params.putInt(KEY_RAW_STATUS, rawStatus); + params.putString(KEY_CONVERSATION_ID, conversationId); + params.putString(KEY_PARTICIPANT_ID, participantId); + params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed); + params.putInt(KEY_SUB_ID, subId); + params.putString(KEY_TRANSACTION_ID, transactionId); + action.start(); + } + + public static void sendDeferredRespStatus(final String messageId, final String transactionId, + final String contentLocation, final int subId) { + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putString(KEY_MESSAGE_ID, messageId); + params.putString(KEY_TRANSACTION_ID, transactionId); + params.putString(KEY_CONTENT_LOCATION, contentLocation); + params.putBoolean(KEY_SEND_DEFERRED_RESP_STATUS, true); + params.putInt(KEY_SUB_ID, subId); + action.start(); + } + + private ProcessDownloadedMmsAction() { + // Callers must use one of the static methods above + } + + @Override + protected Object executeAction() { + // Fire up the background worker + requestBackgroundWork(); + return null; + } + + @Override + protected Bundle doBackgroundWork() throws DataModelException { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + final boolean sendDeferredRespStatus = + actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS, false); + + // Send a response indicating that auto-download failed + if (sendDeferredRespStatus) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "DownloadMmsAction: Auto-download of message " + messageId + + " failed; sending DEFERRED NotifyRespInd"); + } + MmsUtils.sendNotifyResponseForMmsDownload( + context, + subId, + MmsUtils.stringToBytes(transactionId, "UTF-8"), + contentLocation, + PduHeaders.STATUS_DEFERRED); + return null; + } + + // Processing a real MMS download + final boolean downloadedByPlatform = actionParameters.getBoolean( + KEY_DOWNLOADED_BY_PLATFORM); + + final int status; + int rawStatus = MmsUtils.PDU_HEADER_VALUE_UNDEFINED; + Uri mmsUri = null; + + if (downloadedByPlatform) { + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + if (resultCode == Activity.RESULT_OK) { + final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI); + final File downloadedFile = MmsFileProvider.getFile(contentUri); + byte[] downloadedData = null; + try { + downloadedData = Files.toByteArray(downloadedFile); + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "ProcessDownloadedMmsAction: MMS download file not found: " + + downloadedFile.getAbsolutePath()); + } catch (final IOException e) { + LogUtil.e(TAG, "ProcessDownloadedMmsAction: Error reading MMS download file: " + + downloadedFile.getAbsolutePath(), e); + } + + // Can delete the temp file now + if (downloadedFile.exists()) { + downloadedFile.delete(); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessDownloadedMmsAction: Deleted temp file with " + + "downloaded MMS pdu: " + downloadedFile.getAbsolutePath()); + } + } + + if (downloadedData != null) { + final RetrieveConf retrieveConf = + MmsSender.parseRetrieveConf(downloadedData, subId); + if (MmsUtils.isDumpMmsEnabled()) { + MmsUtils.dumpPdu(downloadedData, retrieveConf); + } + if (retrieveConf != null) { + // Insert the downloaded MMS into telephony + final Uri notificationUri = actionParameters.getParcelable( + KEY_NOTIFICATION_URI); + final String subPhoneNumber = actionParameters.getString( + KEY_SUB_PHONE_NUMBER); + final boolean autoDownload = actionParameters.getBoolean( + KEY_AUTO_DOWNLOAD); + final long receivedTimestampInSeconds = + actionParameters.getLong(KEY_RECEIVED_TIMESTAMP); + + // Inform sync we're adding a message to telephony + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(receivedTimestampInSeconds * 1000L); + + final MmsUtils.StatusPlusUri result = + MmsUtils.insertDownloadedMessageAndSendResponse(context, + notificationUri, subId, subPhoneNumber, transactionId, + contentLocation, autoDownload, receivedTimestampInSeconds, + retrieveConf); + status = result.status; + rawStatus = result.rawStatus; + mmsUri = result.uri; + } else { + // Invalid response PDU + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } else { + // Failed to read download file + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } else { + LogUtil.w(TAG, "ProcessDownloadedMmsAction: Platform returned error resultCode: " + + resultCode); + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode); + } + } else { + // Message was already processed by the internal API, or the download action failed. + // In either case, we just need to copy the status to the response bundle. + status = actionParameters.getInt(KEY_STATUS); + rawStatus = actionParameters.getInt(KEY_RAW_STATUS); + mmsUri = actionParameters.getParcelable(KEY_MMS_URI); + } + + final Bundle response = new Bundle(); + response.putInt(BUNDLE_REQUEST_STATUS, status); + response.putInt(BUNDLE_RAW_TELEPHONY_STATUS, rawStatus); + response.putParcelable(BUNDLE_MMS_URI, mmsUri); + return response; + } + + @Override + protected Object processBackgroundResponse(final Bundle response) { + if (response == null) { + // No message download to process; doBackgroundWork sent a notify deferred response + Assert.isTrue(actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)); + return null; + } + + final int status = response.getInt(BUNDLE_REQUEST_STATUS); + final int rawStatus = response.getInt(BUNDLE_RAW_TELEPHONY_STATUS); + final Uri messageUri = response.getParcelable(BUNDLE_MMS_URI); + final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + // Do post-processing on downloaded message + final MessageData message = processResult(status, rawStatus, messageUri); + + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + // If we were trying to auto-download but have failed need to send the deferred response + if (autoDownload && message == null && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) { + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + sendDeferredRespStatus(messageId, transactionId, contentLocation, subId); + } + + if (autoDownload) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + MessageData toastMessage = message; + if (toastMessage == null) { + // If the downloaded failed (message is null), then we should announce the + // receiving of the wap push message. Load the wap push message here instead. + toastMessage = BugleDatabaseOperations.readMessageData(db, messageId); + } + if (toastMessage != null) { + final ParticipantData sender = ParticipantData.getFromId( + db, toastMessage.getParticipantId()); + BugleActionToasts.onMessageReceived( + toastMessage.getConversationId(), sender, toastMessage); + } + } else { + final boolean success = message != null && status == MmsUtils.MMS_REQUEST_SUCCEEDED; + BugleActionToasts.onSendMessageOrManualDownloadActionCompleted( + // If download failed, use the wap push message's conversation instead + success ? message.getConversationId() + : actionParameters.getString(KEY_CONVERSATION_ID), + success, status, false/*isSms*/, subId, false /*isSend*/); + } + + final boolean failed = (messageUri == null); + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(failed, this); + if (failed) { + BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS); + } + + return message; + } + + @Override + protected Object processBackgroundFailure() { + if (actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)) { + // We can early-out for these failures. processResult is only designed to handle + // post-processing of MMS downloads (whether successful or not). + LogUtil.w(TAG, + "ProcessDownloadedMmsAction: Exception while sending deferred NotifyRespInd"); + return null; + } + + // Background worker threw an exception; require manual retry + processResult(MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + null /* mmsUri */); + + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true /* failed */, + this); + + return null; + } + + private MessageData processResult(final int status, final int rawStatus, final Uri mmsUri) { + final Context context = Factory.get().getApplicationContext(); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final Uri mmsNotificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI); + final String notificationConversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String notificationParticipantId = actionParameters.getString(KEY_PARTICIPANT_ID); + final int statusIfFailed = actionParameters.getInt(KEY_STATUS_IF_FAILED); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + + Assert.notNull(messageId); + + LogUtil.i(TAG, "ProcessDownloadedMmsAction: Processed MMS download of message " + messageId + + "; status is " + MmsUtils.getRequestStatusDescription(status)); + + DatabaseMessages.MmsMessage mms = null; + if (status == MmsUtils.MMS_REQUEST_SUCCEEDED && mmsUri != null) { + // Delete the initial M-Notification.ind from telephony + SqliteWrapper.delete(context, context.getContentResolver(), + mmsNotificationUri, null, null); + + // Read the sent MMS from the telephony provider + mms = MmsUtils.loadMms(mmsUri); + } + + boolean messageInFocusedConversation = false; + boolean messageInObservableConversation = false; + String conversationId = null; + MessageData message = null; + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + if (mms != null) { + final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId()); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + + final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId); + String from = MmsUtils.getMmsSender(recipients, mms.getUri()); + if (from == null) { + LogUtil.w(TAG, + "Downloaded an MMS without sender address; using unknown sender."); + from = ParticipantData.getUnknownSenderDestination(); + } + final ParticipantData sender = ParticipantData.getFromRawPhoneBySimLocale(from, + subId); + final String senderParticipantId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender); + if (!senderParticipantId.equals(notificationParticipantId)) { + LogUtil.e(TAG, "ProcessDownloadedMmsAction: Downloaded MMS message " + + messageId + " has different sender (participantId = " + + senderParticipantId + ") than notification (" + + notificationParticipantId + ")"); + } + final boolean blockedSender = BugleDatabaseOperations.isBlockedDestination( + db, sender.getNormalizedDestination()); + conversationId = BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, + mms.mThreadId, blockedSender, subId); + + messageInFocusedConversation = + DataModel.get().isFocusedConversation(conversationId); + messageInObservableConversation = + DataModel.get().isNewMessageObservable(conversationId); + + // TODO: Also write these values to the telephony provider + mms.mRead = messageInFocusedConversation; + mms.mSeen = messageInObservableConversation; + + // Translate to our format + message = MmsUtils.createMmsMessage(mms, conversationId, senderParticipantId, + selfId, MessageData.BUGLE_STATUS_INCOMING_COMPLETE); + // Update image sizes. + message.updateSizesForImageParts(); + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(message.getReceivedTimeStamp()); + final MessageData current = BugleDatabaseOperations.readMessageData(db, messageId); + if (current == null) { + LogUtil.w(TAG, "Message deleted prior to update"); + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + } else { + // Overwrite existing notification message + message.updateMessageId(messageId); + // Write message + BugleDatabaseOperations.updateMessageInTransaction(db, message); + } + + if (!TextUtils.equals(notificationConversationId, conversationId)) { + // If this is a group conversation, the message is moved. So the original + // 1v1 conversation (as referenced by notificationConversationId) could + // be left with no non-draft message. Delete the conversation if that + // happens. See the comment for the method below for why we need to do this. + if (!BugleDatabaseOperations.deleteConversationIfEmptyInTransaction( + db, notificationConversationId)) { + BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction( + db, notificationConversationId, messageId, + true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/); + } + } + + BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, conversationId, + true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/); + } else { + messageInFocusedConversation = + DataModel.get().isFocusedConversation(notificationConversationId); + + // Default to retry status unless status indicates otherwise + int bugleStatus = statusIfFailed; + if (status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) { + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED; + } else if (status == MmsUtils.MMS_REQUEST_NO_RETRY) { + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE; + } + DownloadMmsAction.updateMessageStatus(mmsNotificationUri, messageId, + notificationConversationId, bugleStatus, rawStatus); + + // Log MMS download failed + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + + // Just in case this was the latest message update the summary data + BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, + notificationConversationId, true /*shouldAutoSwitchSelfId*/, + false /*keepArchived*/); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (mmsUri != null) { + // Update mms table with read status now we know the conversation id + final ContentValues values = new ContentValues(1); + values.put(Mms.READ, messageInFocusedConversation); + SqliteWrapper.update(context, context.getContentResolver(), mmsUri, values, + null, null); + } + + // Show a notification to let the user know a new message has arrived + BugleNotifications.update(false /*silent*/, conversationId, BugleNotifications.UPDATE_ALL); + + // Messages may have changed in two conversations + if (conversationId != null) { + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + MessagingContentProvider.notifyMessagesChanged(notificationConversationId); + MessagingContentProvider.notifyPartsChanged(); + + return message; + } + + private ProcessDownloadedMmsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ProcessDownloadedMmsAction> CREATOR + = new Parcelable.Creator<ProcessDownloadedMmsAction>() { + @Override + public ProcessDownloadedMmsAction createFromParcel(final Parcel in) { + return new ProcessDownloadedMmsAction(in); + } + + @Override + public ProcessDownloadedMmsAction[] newArray(final int size) { + return new ProcessDownloadedMmsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java b/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java new file mode 100644 index 0000000..8a41f4a --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java @@ -0,0 +1,470 @@ +/* + * 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.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.os.Parcel; +import android.os.Parcelable; +import android.telephony.ServiceState; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.ConnectivityUtil.ConnectivityListener; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.HashSet; +import java.util.Set; + +/** + * Action used to lookup any messages in the pending send/download state and either fail them or + * retry their action. This action only initiates one retry at a time - further retries should be + * triggered by successful sending of a message, network status change or exponential backoff timer. + */ +public class ProcessPendingMessagesAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final int PENDING_INTENT_REQUEST_CODE = 101; + + public static void processFirstPendingMessage() { + // Clear any pending alarms or connectivity events + unregister(); + // Clear retry count + setRetry(0); + + // Start action + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + action.start(); + } + + public static void scheduleProcessPendingMessagesAction(final boolean failed, + final Action processingAction) { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages" + + (failed ? "(message failed)" : "")); + // Can safely clear any pending alarms or connectivity events as either an action + // is currently running or we will run now or register if pending actions possible. + unregister(); + + final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp(); + boolean scheduleAlarm = false; + // If message succeeded and if Bugle is default SMS app just carry on with next message + if (!failed && isDefaultSmsApp) { + // Clear retry attempt count as something just succeeded + setRetry(0); + + // Lookup and queue next message for immediate processing by background worker + // iff there are no pending messages this will do nothing and return true. + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + if (action.queueActions(processingAction)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + if (processingAction.hasBackgroundActions()) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued"); + } else { + LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue"); + } + } + // Have queued next action if needed, nothing more to do + return; + } + // In case of error queuing schedule a retry + scheduleAlarm = true; + LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying"); + } + if (getHavePendingMessages() || scheduleAlarm) { + // Still have a pending message that needs to be queued for processing + final ConnectivityListener listener = new ConnectivityListener() { + @Override + public void onConnectivityStateChanged(final Context context, final Intent intent) { + final int networkType = + MmsUtils.getConnectivityEventNetworkType(context, intent); + if (networkType != ConnectivityManager.TYPE_MOBILE) { + return; + } + final boolean isConnected = !intent.getBooleanExtra( + ConnectivityManager.EXTRA_NO_CONNECTIVITY, false); + // TODO: Should we check in more detail? + if (isConnected) { + onConnected(); + } + } + + @Override + public void onPhoneStateChanged(final Context context, final int serviceState) { + if (serviceState == ServiceState.STATE_IN_SERVICE) { + onConnected(); + } + } + + private void onConnected() { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected; starting action"); + + // Clear any pending alarms or connectivity events but leave attempt count alone + unregister(); + + // Start action + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + action.start(); + } + }; + // Read and increment attempt number from shared prefs + final int retryAttempt = getNextRetry(); + register(listener, retryAttempt); + } else { + // No more pending messages (presumably the message that failed has expired) or it + // may be possible that a send and a download are already in process. + // Clear retry attempt count. + // TODO Might be premature if send and download in process... + // but worst case means we try to send a bit more often. + setRetry(0); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: No more pending messages"); + } + } + } + + private static void register(final ConnectivityListener listener, final int retryAttempt) { + int retryNumber = retryAttempt; + + // Register to be notified about connectivity changes + DataModel.get().getConnectivityUtil().register(listener); + + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + final long initialBackoffMs = BugleGservices.get().getLong( + BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS, + BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT); + final long maxDelayMs = BugleGservices.get().getLong( + BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS, + BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT); + long delayMs; + long nextDelayMs = initialBackoffMs; + do { + delayMs = nextDelayMs; + retryNumber--; + nextDelayMs = delayMs * 2; + } + while (retryNumber > 0 && nextDelayMs < maxDelayMs); + + LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt + + " in " + delayMs + " ms"); + + action.schedule(PENDING_INTENT_REQUEST_CODE, delayMs); + } + + private static void unregister() { + // Clear any pending alarms or connectivity events + DataModel.get().getConnectivityUtil().unregister(); + + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + action.schedule(PENDING_INTENT_REQUEST_CODE, Long.MAX_VALUE); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed " + + "events and clearing scheduled alarm"); + } + } + + private static void setRetry(final int retryAttempt) { + final BuglePrefs prefs = Factory.get().getApplicationPrefs(); + prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt); + } + + private static int getNextRetry() { + final BuglePrefs prefs = Factory.get().getApplicationPrefs(); + final int retryAttempt = + prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1; + prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt); + return retryAttempt; + } + + private ProcessPendingMessagesAction() { + } + + /** + * Read from the DB and determine if there are any messages we should process + * @return true if we have pending messages + */ + private static boolean getHavePendingMessages() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final long now = System.currentTimeMillis(); + + final String toSendMessageId = findNextMessageToSend(db, now); + if (toSendMessageId != null) { + return true; + } else { + final String toDownloadMessageId = findNextMessageToDownload(db, now); + if (toDownloadMessageId != null) { + return true; + } + } + // Messages may be in the process of sending/downloading even when there are no pending + // messages... + return false; + } + + /** + * Queue any pending actions + * @param actionState + * @return true if action queued (or no actions to queue) else false + */ + private boolean queueActions(final Action processingAction) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final long now = System.currentTimeMillis(); + boolean succeeded = true; + + // Will queue no more than one message to send plus one message to download + // This keeps outgoing messages "in order" but allow downloads to happen even if sending + // gets blocked until messages time out. Manual resend bumps messages to head of queue. + final String toSendMessageId = findNextMessageToSend(db, now); + final String toDownloadMessageId = findNextMessageToDownload(db, now); + if (toSendMessageId != null) { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId + + " for sending"); + // This could queue nothing + if (!SendMessageAction.queueForSendInBackground(toSendMessageId, processingAction)) { + LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message " + + toSendMessageId + " for sending"); + succeeded = false; + } + } + if (toDownloadMessageId != null) { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toDownloadMessageId + + " for download"); + // This could queue nothing + if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId, + processingAction)) { + LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message " + + toDownloadMessageId + " for download"); + succeeded = false; + } + } + if (toSendMessageId == null && toDownloadMessageId == null) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessPendingMessagesAction: No messages to send or download"); + } + } + return succeeded; + } + + @Override + protected Object executeAction() { + // If triggered by alarm will not have unregistered yet + unregister(); + + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + queueActions(this); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling"); + } + scheduleProcessPendingMessagesAction(true, this); + } + + return null; + } + + private static String findNextMessageToSend(final DatabaseWrapper db, final long now) { + String toSendMessageId = null; + db.beginTransaction(); + Cursor sending = null; + Cursor cursor = null; + int sendingCnt = 0; + int pendingCnt = 0; + int failedCnt = 0; + try { + // First check to see if we have any messages already sending + sending = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[]{Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING), + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)}, + null, + null, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC"); + final boolean messageCurrentlySending = sending.moveToNext(); + sendingCnt = sending.getCount(); + // Look for messages we could send + final ContentValues values = new ContentValues(); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_OUTGOING_FAILED); + cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + DatabaseHelper.MessageColumns.STATUS + " IN (" + + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + "," + + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ")", + null, + null, + null, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC"); + pendingCnt = cursor.getCount(); + + while (cursor.moveToNext()) { + final MessageData message = new MessageData(); + message.bind(cursor); + if (message.getInResendWindow(now)) { + // If no messages currently sending + if (!messageCurrentlySending) { + // Resend this message + toSendMessageId = message.getMessageId(); + // Before queuing the message for resending, check if the message's self is + // active. If not, switch back to the system's default subscription. + if (OsUtil.isAtLeastL_MR1()) { + final ParticipantData messageSelf = BugleDatabaseOperations + .getExistingParticipant(db, message.getSelfId()); + if (messageSelf == null || !messageSelf.isActiveSubscription()) { + final ParticipantData defaultSelf = BugleDatabaseOperations + .getOrCreateSelf(db, PhoneUtils.getDefault() + .getDefaultSmsSubscriptionId()); + if (defaultSelf != null) { + message.bindSelfId(defaultSelf.getId()); + final ContentValues selfValues = new ContentValues(); + selfValues.put(MessageColumns.SELF_PARTICIPANT_ID, + defaultSelf.getId()); + BugleDatabaseOperations.updateMessageRow(db, + message.getMessageId(), selfValues); + MessagingContentProvider.notifyMessagesChanged( + message.getConversationId()); + } + } + } + } + break; + } else { + failedCnt++; + + // Mark message as failed + BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values); + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + } + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + if (cursor != null) { + cursor.close(); + } + if (sending != null) { + sending.close(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessPendingMessagesAction: " + + sendingCnt + " messages already sending, " + + pendingCnt + " messages to send, " + + failedCnt + " failed messages"); + } + + return toSendMessageId; + } + + private static String findNextMessageToDownload(final DatabaseWrapper db, final long now) { + String toDownloadMessageId = null; + db.beginTransaction(); + Cursor cursor = null; + int downloadingCnt = 0; + int pendingCnt = 0; + try { + // First check if we have any messages already downloading + downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE, + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING), + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) + }); + + // TODO: This query is not actually needed if downloadingCnt == 0. + cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + DatabaseHelper.MessageColumns.STATUS + " =? OR " + + DatabaseHelper.MessageColumns.STATUS + " =?", + new String[]{ + Integer.toString( + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD), + Integer.toString( + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD) + }, + null, + null, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC"); + + pendingCnt = cursor.getCount(); + + // If no messages are currently downloading and there is a download pending, + // queue the download of the oldest pending message. + if (downloadingCnt == 0 && cursor.moveToNext()) { + // Always start the next pending message. We will check if a download has + // expired in DownloadMmsAction and mark message failed there. + final MessageData message = new MessageData(); + message.bind(cursor); + toDownloadMessageId = message.getMessageId(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessPendingMessagesAction: " + + downloadingCnt + " messages already downloading, " + + pendingCnt + " messages to download"); + } + + return toDownloadMessageId; + } + + private ProcessPendingMessagesAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ProcessPendingMessagesAction> CREATOR + = new Parcelable.Creator<ProcessPendingMessagesAction>() { + @Override + public ProcessPendingMessagesAction createFromParcel(final Parcel in) { + return new ProcessPendingMessagesAction(in); + } + + @Override + public ProcessPendingMessagesAction[] newArray(final int size) { + return new ProcessPendingMessagesAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java b/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java new file mode 100644 index 0000000..f408e47 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java @@ -0,0 +1,310 @@ +/* + * 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.app.Activity; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.telephony.PhoneNumberUtils; +import android.telephony.SmsManager; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MmsFileProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.pdu.SendConf; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.sms.MmsSender; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.File; +import java.util.ArrayList; + +/** +* Update message status to reflect success or failure +* Can also update the message itself if a "final" message is now available from telephony db +*/ +public class ProcessSentMessageAction extends Action { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + // These are always set + private static final String KEY_SMS = "is_sms"; + private static final String KEY_SENT_BY_PLATFORM = "sent_by_platform"; + + // These are set when we're processing a message sent by the user. They are null for messages + // sent automatically (e.g. a NotifyRespInd/AcknowledgeInd sent in response to a download). + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_MESSAGE_URI = "message_uri"; + private static final String KEY_UPDATED_MESSAGE_URI = "updated_message_uri"; + private static final String KEY_SUB_ID = "sub_id"; + + // These are set for messages sent by the platform (L+) + public static final String KEY_RESULT_CODE = "result_code"; + public static final String KEY_HTTP_STATUS_CODE = "http_status_code"; + private static final String KEY_CONTENT_URI = "content_uri"; + private static final String KEY_RESPONSE = "response"; + private static final String KEY_RESPONSE_IMPORTANT = "response_important"; + + // These are set for messages we sent ourself (legacy), or which we fast-failed before sending. + private static final String KEY_STATUS = "status"; + private static final String KEY_RAW_STATUS = "raw_status"; + + // This is called when MMS lib API returns via PendingIntent + public static void processMmsSent(final int resultCode, final Uri messageUri, + final Bundle extras) { + final ProcessSentMessageAction action = new ProcessSentMessageAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_SMS, false); + params.putBoolean(KEY_SENT_BY_PLATFORM, true); + params.putString(KEY_MESSAGE_ID, extras.getString(SendMessageAction.EXTRA_MESSAGE_ID)); + params.putParcelable(KEY_MESSAGE_URI, messageUri); + params.putParcelable(KEY_UPDATED_MESSAGE_URI, + extras.getParcelable(SendMessageAction.EXTRA_UPDATED_MESSAGE_URI)); + params.putInt(KEY_SUB_ID, + extras.getInt(SendMessageAction.KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID)); + params.putInt(KEY_RESULT_CODE, resultCode); + params.putInt(KEY_HTTP_STATUS_CODE, extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0)); + params.putParcelable(KEY_CONTENT_URI, + extras.getParcelable(SendMessageAction.EXTRA_CONTENT_URI)); + params.putByteArray(KEY_RESPONSE, extras.getByteArray(SmsManager.EXTRA_MMS_DATA)); + params.putBoolean(KEY_RESPONSE_IMPORTANT, + extras.getBoolean(SendMessageAction.EXTRA_RESPONSE_IMPORTANT)); + action.start(); + } + + public static void processMessageSentFastFailed(final String messageId, + final Uri messageUri, final Uri updatedMessageUri, final int subId, final boolean isSms, + final int status, final int rawStatus, final int resultCode) { + final ProcessSentMessageAction action = new ProcessSentMessageAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_SMS, isSms); + params.putBoolean(KEY_SENT_BY_PLATFORM, false); + params.putString(KEY_MESSAGE_ID, messageId); + params.putParcelable(KEY_MESSAGE_URI, messageUri); + params.putParcelable(KEY_UPDATED_MESSAGE_URI, updatedMessageUri); + params.putInt(KEY_SUB_ID, subId); + params.putInt(KEY_STATUS, status); + params.putInt(KEY_RAW_STATUS, rawStatus); + params.putInt(KEY_RESULT_CODE, resultCode); + action.start(); + } + + private ProcessSentMessageAction() { + // Callers must use one of the static methods above + } + + /** + * Update message status to reflect success or failure + * Can also update the message itself if a "final" message is now available from telephony db + */ + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI); + final Uri updatedMessageUri = actionParameters.getParcelable(KEY_UPDATED_MESSAGE_URI); + final boolean isSms = actionParameters.getBoolean(KEY_SMS); + final boolean sentByPlatform = actionParameters.getBoolean(KEY_SENT_BY_PLATFORM); + + int status = actionParameters.getInt(KEY_STATUS, MmsUtils.MMS_REQUEST_MANUAL_RETRY); + int rawStatus = actionParameters.getInt(KEY_RAW_STATUS, + MmsUtils.PDU_HEADER_VALUE_UNDEFINED); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + + if (sentByPlatform) { + // Delete temporary file backing the contentUri passed to MMS service + final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI); + Assert.isTrue(contentUri != null); + final File tempFile = MmsFileProvider.getFile(contentUri); + long messageSize = 0; + if (tempFile.exists()) { + messageSize = tempFile.length(); + tempFile.delete(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessSentMessageAction: Deleted temp file with outgoing " + + "MMS pdu: " + contentUri); + } + } + + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + final boolean responseImportant = actionParameters.getBoolean(KEY_RESPONSE_IMPORTANT); + if (resultCode == Activity.RESULT_OK) { + if (responseImportant) { + // Get the status from the response PDU and update telephony + final byte[] response = actionParameters.getByteArray(KEY_RESPONSE); + final SendConf sendConf = MmsSender.parseSendConf(response, subId); + if (sendConf != null) { + final MmsUtils.StatusPlusUri result = + MmsUtils.updateSentMmsMessageStatus(context, messageUri, sendConf); + status = result.status; + rawStatus = result.rawStatus; + } + } + } else { + String errorMsg = "ProcessSentMessageAction: Platform returned error resultCode: " + + resultCode; + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + if (httpStatusCode != 0) { + errorMsg += (", HTTP status code: " + httpStatusCode); + } + LogUtil.w(TAG, errorMsg); + status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode); + + // Check for MMS messages that failed because they exceeded the maximum size, + // indicated by an I/O error from the platform. + if (resultCode == SmsManager.MMS_ERROR_IO_ERROR) { + if (messageSize > MmsConfig.get(subId).getMaxMessageSize()) { + rawStatus = MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG; + } + } + } + } + if (messageId != null) { + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + processResult( + messageId, updatedMessageUri, status, rawStatus, isSms, this, subId, + resultCode, httpStatusCode); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessSentMessageAction: No sent message to process (it was " + + "probably a notify response for an MMS download)"); + } + } + return null; + } + + static void processResult(final String messageId, Uri updatedMessageUri, int status, + final int rawStatus, final boolean isSms, final Action processingAction, + final int subId, final int resultCode, final int httpStatusCode) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + final MessageData originalMessage = message; + if (message == null) { + LogUtil.w(TAG, "ProcessSentMessageAction: Sent message " + messageId + + " missing from local database"); + return; + } + final String conversationId = message.getConversationId(); + if (updatedMessageUri != null) { + // Update message if we have newly written final message in the telephony db + final MessageData update = MmsUtils.readSendingMmsMessage(updatedMessageUri, + conversationId, message.getParticipantId(), message.getSelfId()); + if (update != null) { + // Set message Id of final message to that of the existing place holder. + update.updateMessageId(message.getMessageId()); + // Update image sizes. + update.updateSizesForImageParts(); + // Temp attachments are no longer needed + for (final MessagePartData part : message.getParts()) { + part.destroySync(); + } + message = update; + // processResult will rewrite the complete message as part of update + } else { + updatedMessageUri = null; + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + LogUtil.e(TAG, "ProcessSentMessageAction: Unable to read sending message"); + } + } + + final long timestamp = System.currentTimeMillis(); + boolean failed; + if (status == MmsUtils.MMS_REQUEST_SUCCEEDED) { + message.markMessageSent(timestamp); + failed = false; + } else if (status == MmsUtils.MMS_REQUEST_AUTO_RETRY + && message.getInResendWindow(timestamp)) { + message.markMessageNotSent(timestamp); + message.setRawTelephonyStatus(rawStatus); + failed = false; + } else { + message.markMessageFailed(timestamp); + message.setRawTelephonyStatus(rawStatus); + message.setMessageSeen(false); + failed = true; + } + + // We have special handling for when a message to an emergency number fails. In this case, + // we notify immediately of any failure (even if we auto-retry), and instruct the user to + // try calling the emergency number instead. + if (status != MmsUtils.MMS_REQUEST_SUCCEEDED) { + final ArrayList<String> recipients = + BugleDatabaseOperations.getRecipientsForConversation(db, conversationId); + for (final String recipient : recipients) { + if (PhoneNumberUtils.isEmergencyNumber(recipient)) { + BugleNotifications.notifyEmergencySmsFailed(recipient, conversationId); + message.markMessageFailedEmergencyNumber(timestamp); + failed = true; + break; + } + } + } + + // Update the message status and optionally refresh the message with final parts/values. + if (SendMessageAction.updateMessageAndStatus(isSms, message, updatedMessageUri, failed)) { + // We shouldn't show any notifications if we're not allowed to modify Telephony for + // this message. + if (failed) { + BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS); + } + BugleActionToasts.onSendMessageOrManualDownloadActionCompleted( + conversationId, !failed, status, isSms, subId, true/*isSend*/); + } + + LogUtil.i(TAG, "ProcessSentMessageAction: Done sending " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + + " in conversation " + conversationId + + "; status is " + MmsUtils.getRequestStatusDescription(status)); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction( + status != MmsUtils.MMS_REQUEST_SUCCEEDED, processingAction); + } + + private ProcessSentMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ProcessSentMessageAction> CREATOR + = new Parcelable.Creator<ProcessSentMessageAction>() { + @Override + public ProcessSentMessageAction createFromParcel(final Parcel in) { + return new ProcessSentMessageAction(in); + } + + @Override + public ProcessSentMessageAction[] newArray(final int size) { + return new ProcessSentMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java b/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java new file mode 100644 index 0000000..7ac646b --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java @@ -0,0 +1,166 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +public class ReadDraftDataAction extends Action implements Parcelable { + + /** + * Interface for ReadDraftDataAction listeners + */ + public interface ReadDraftDataActionListener { + @RunsOnMainThread + abstract void onReadDraftDataSucceeded(final ReadDraftDataAction action, + final Object data, final MessageData message, + final ConversationListItemData conversation); + @RunsOnMainThread + abstract void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data); + } + + /** + * Read draft message and associated data (with listener) + */ + public static ReadDraftDataActionMonitor readDraftData(final String conversationId, + final MessageData incomingDraft, final Object data, + final ReadDraftDataActionListener listener) { + final ReadDraftDataActionMonitor monitor = new ReadDraftDataActionMonitor(data, + listener); + final ReadDraftDataAction action = new ReadDraftDataAction(conversationId, + incomingDraft, monitor.getActionKey()); + action.start(monitor); + return monitor; + } + + private static final String KEY_CONVERSATION_ID = "conversationId"; + private static final String KEY_INCOMING_DRAFT = "draftMessage"; + + private ReadDraftDataAction(final String conversationId, final MessageData incomingDraft, + final String actionKey) { + super(actionKey); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putParcelable(KEY_INCOMING_DRAFT, incomingDraft); + } + + @VisibleForTesting + class DraftData { + public final MessageData message; + public final ConversationListItemData conversation; + + DraftData(final MessageData message, final ConversationListItemData conversation) { + this.message = message; + this.conversation = conversation; + } + } + + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final MessageData incomingDraft = actionParameters.getParcelable(KEY_INCOMING_DRAFT); + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + MessageData message = null; + if (conversation != null) { + if (incomingDraft == null) { + message = BugleDatabaseOperations.readDraftMessageData(db, conversationId, + conversation.getSelfId()); + } + if (message == null) { + message = MessageData.createDraftMessage(conversationId, conversation.getSelfId(), + incomingDraft); + LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: created draft. " + + "conversationId=" + conversationId + + " selfId=" + conversation.getSelfId()); + } else { + LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: read draft. " + + "conversationId=" + conversationId + + " selfId=" + conversation.getSelfId()); + } + return new DraftData(message, conversation); + } + return null; + } + + /** + * An operation that notifies a listener upon completion + */ + public static class ReadDraftDataActionMonitor extends ActionMonitor + implements ActionCompletedListener { + + private final ReadDraftDataActionListener mListener; + + ReadDraftDataActionMonitor(final Object data, + final ReadDraftDataActionListener completed) { + super(STATE_CREATED, generateUniqueActionKey("ReadDraftDataAction"), data); + setCompletedListener(this); + mListener = completed; + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + final DraftData draft = (DraftData) result; + if (draft == null) { + mListener.onReadDraftDataFailed((ReadDraftDataAction) action, data); + } else { + mListener.onReadDraftDataSucceeded((ReadDraftDataAction) action, data, + draft.message, draft.conversation); + } + } + + @Override + public void onActionFailed(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + Assert.fail("Reading draft should not fail"); + } + } + + private ReadDraftDataAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ReadDraftDataAction> CREATOR + = new Parcelable.Creator<ReadDraftDataAction>() { + @Override + public ReadDraftDataAction createFromParcel(final Parcel in) { + return new ReadDraftDataAction(in); + } + + @Override + public ReadDraftDataAction[] newArray(final int size) { + return new ReadDraftDataAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java new file mode 100644 index 0000000..6794b17 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java @@ -0,0 +1,197 @@ +/* + * 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.content.Context; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +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; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.LogUtil; + +import java.util.List; + +/** + * Action used to "receive" an incoming message + */ +public class ReceiveMmsMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_PUSH_DATA = "push_data"; + private static final String KEY_TRANSACTION_ID = "transaction_id"; + private static final String KEY_CONTENT_LOCATION = "content_location"; + + /** + * Create a message received from a particular number in a particular conversation + */ + public ReceiveMmsMessageAction(final int subId, final byte[] pushData) { + actionParameters.putInt(KEY_SUB_ID, subId); + actionParameters.putByteArray(KEY_PUSH_DATA, pushData); + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final byte[] pushData = actionParameters.getByteArray(KEY_PUSH_DATA); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Write received message to telephony DB + MessageData message = null; + final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId); + + final long received = System.currentTimeMillis(); + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(received); + + // TODO: Should use local time to set received time in MMS message + final DatabaseMessages.MmsMessage mms = MmsUtils.processReceivedPdu( + context, pushData, self.getSubId(), self.getNormalizedDestination()); + + if (mms != null) { + final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId); + String from = MmsUtils.getMmsSender(recipients, mms.getUri()); + if (from == null) { + LogUtil.w(TAG, "Received an MMS without sender address; using unknown sender."); + from = ParticipantData.getUnknownSenderDestination(); + } + final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale( + from, subId); + final boolean blocked = BugleDatabaseOperations.isBlockedDestination( + db, rawSender.getNormalizedDestination()); + final boolean autoDownload = (!blocked && MmsUtils.allowMmsAutoRetrieve(subId)); + final String conversationId = + BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, mms.mThreadId, + blocked, subId); + + final boolean messageInFocusedConversation = + DataModel.get().isFocusedConversation(conversationId); + final boolean messageInObservableConversation = + DataModel.get().isNewMessageObservable(conversationId); + + // TODO: Also write these values to the telephony provider + mms.mRead = messageInFocusedConversation; + mms.mSeen = messageInObservableConversation || blocked; + + // Write received placeholder message to our DB + db.beginTransaction(); + try { + final String participantId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + + message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId, + (autoDownload ? MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD : + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD)); + // Write the message + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + if (!autoDownload) { + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), message.getReceivedTimeStamp(), + blocked, true /* shouldAutoSwitchSelfId */); + final ParticipantData sender = ParticipantData .getFromId( + db, participantId); + BugleActionToasts.onMessageReceived(conversationId, sender, message); + } + // else update the conversation once we have downloaded final message (or failed) + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + // Update conversation if not immediately initiating a download + if (!autoDownload) { + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + MessagingContentProvider.notifyPartsChanged(); + + // Show a notification to let the user know a new message has arrived + BugleNotifications.update(false/*silent*/, conversationId, + BugleNotifications.UPDATE_ALL); + + // Send the NotifyRespInd with DEFERRED status since no auto download + actionParameters.putString(KEY_TRANSACTION_ID, mms.mTransactionId); + actionParameters.putString(KEY_CONTENT_LOCATION, mms.mContentLocation); + requestBackgroundWork(); + } + + LogUtil.i(TAG, "ReceiveMmsMessageAction: Received MMS message " + message.getMessageId() + + " in conversation " + message.getConversationId() + + ", uri = " + message.getSmsMessageUri()); + } else { + LogUtil.e(TAG, "ReceiveMmsMessageAction: Skipping processing of incoming PDU"); + } + + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + + return message; + } + + @Override + protected Bundle doBackgroundWork() throws DataModelException { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + MmsUtils.sendNotifyResponseForMmsDownload( + context, + subId, + MmsUtils.stringToBytes(transactionId, "UTF-8"), + contentLocation, + PduHeaders.STATUS_DEFERRED); + // We don't need to return anything. + return null; + } + + private ReceiveMmsMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ReceiveMmsMessageAction> CREATOR + = new Parcelable.Creator<ReceiveMmsMessageAction>() { + @Override + public ReceiveMmsMessageAction createFromParcel(final Parcel in) { + return new ReceiveMmsMessageAction(in); + } + + @Override + public ReceiveMmsMessageAction[] newArray(final int size) { + return new ReceiveMmsMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java new file mode 100644 index 0000000..5ffb35d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java @@ -0,0 +1,198 @@ +/* + * 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.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Sms; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +/** + * Action used to "receive" an incoming message + */ +public class ReceiveSmsMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_MESSAGE_VALUES = "message_values"; + + /** + * Create a message received from a particular number in a particular conversation + */ + public ReceiveSmsMessageAction(final ContentValues messageValues) { + actionParameters.putParcelable(KEY_MESSAGE_VALUES, messageValues); + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final ContentValues messageValues = actionParameters.getParcelable(KEY_MESSAGE_VALUES); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Get the SIM subscription ID + Integer subId = messageValues.getAsInteger(Sms.SUBSCRIPTION_ID); + if (subId == null) { + subId = ParticipantData.DEFAULT_SELF_SUB_ID; + } + // Make sure we have a sender address + String address = messageValues.getAsString(Sms.ADDRESS); + if (TextUtils.isEmpty(address)) { + LogUtil.w(TAG, "Received an SMS without an address; using unknown sender."); + address = ParticipantData.getUnknownSenderDestination(); + messageValues.put(Sms.ADDRESS, address); + } + final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale( + address, subId); + + // TODO: Should use local timestamp for this? + final long received = messageValues.getAsLong(Sms.DATE); + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(received); + + // Make sure we've got a thread id + final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(context, address); + messageValues.put(Sms.THREAD_ID, threadId); + final boolean blocked = BugleDatabaseOperations.isBlockedDestination( + db, rawSender.getNormalizedDestination()); + final String conversationId = BugleDatabaseOperations. + getOrCreateConversationFromRecipient(db, threadId, blocked, rawSender); + + final boolean messageInFocusedConversation = + DataModel.get().isFocusedConversation(conversationId); + final boolean messageInObservableConversation = + DataModel.get().isNewMessageObservable(conversationId); + + MessageData message = null; + // Only the primary user gets to insert the message into the telephony db and into bugle's + // db. The secondary user goes through this path, but skips doing the actual insert. It + // goes through this path because it needs to compute messageInFocusedConversation in order + // to calculate whether to skip the notification and play a soft sound if the user is + // already in the conversation. + if (!OsUtil.isSecondaryUser()) { + final boolean read = messageValues.getAsBoolean(Sms.Inbox.READ) + || messageInFocusedConversation; + // If you have read it you have seen it + final boolean seen = read || messageInObservableConversation || blocked; + messageValues.put(Sms.Inbox.READ, read ? Integer.valueOf(1) : Integer.valueOf(0)); + + // incoming messages are marked as seen in the telephony db + messageValues.put(Sms.Inbox.SEEN, 1); + + // Insert into telephony + final Uri messageUri = context.getContentResolver().insert(Sms.Inbox.CONTENT_URI, + messageValues); + + if (messageUri != null) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ReceiveSmsMessageAction: Inserted SMS message into telephony, " + + "uri = " + messageUri); + } + } else { + LogUtil.e(TAG, "ReceiveSmsMessageAction: Failed to insert SMS into telephony!"); + } + + final String text = messageValues.getAsString(Sms.BODY); + final String subject = messageValues.getAsString(Sms.SUBJECT); + final long sent = messageValues.getAsLong(Sms.DATE_SENT); + final ParticipantData self = ParticipantData.getSelfParticipant(subId); + final Integer pathPresent = messageValues.getAsInteger(Sms.REPLY_PATH_PRESENT); + final String smsServiceCenter = messageValues.getAsString(Sms.SERVICE_CENTER); + String conversationServiceCenter = null; + // Only set service center if message REPLY_PATH_PRESENT = 1 + if (pathPresent != null && pathPresent == 1 && !TextUtils.isEmpty(smsServiceCenter)) { + conversationServiceCenter = smsServiceCenter; + } + db.beginTransaction(); + try { + final String participantId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + + message = MessageData.createReceivedSmsMessage(messageUri, conversationId, + participantId, selfId, text, subject, sent, received, seen, read); + + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, conversationId, + message.getMessageId(), message.getReceivedTimeStamp(), blocked, + conversationServiceCenter, true /* shouldAutoSwitchSelfId */); + + final ParticipantData sender = ParticipantData.getFromId(db, participantId); + BugleActionToasts.onMessageReceived(conversationId, sender, message); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + LogUtil.i(TAG, "ReceiveSmsMessageAction: Received SMS message " + message.getMessageId() + + " in conversation " + message.getConversationId() + + ", uri = " + messageUri); + + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ReceiveSmsMessageAction: Not inserting received SMS message for " + + "secondary user."); + } + } + // Show a notification to let the user know a new message has arrived + BugleNotifications.update(false/*silent*/, conversationId, BugleNotifications.UPDATE_ALL); + + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + + return message; + } + + private ReceiveSmsMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ReceiveSmsMessageAction> CREATOR + = new Parcelable.Creator<ReceiveSmsMessageAction>() { + @Override + public ReceiveSmsMessageAction createFromParcel(final Parcel in) { + return new ReceiveSmsMessageAction(in); + } + + @Override + public ReceiveSmsMessageAction[] newArray(final int size) { + return new ReceiveSmsMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java b/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java new file mode 100644 index 0000000..e899b0c --- /dev/null +++ b/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java @@ -0,0 +1,128 @@ +/* + * 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.app.PendingIntent; +import android.content.ContentValues; +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +/** + * Action to manually start an MMS download (after failed or manual mms download) + */ +public class RedownloadMmsAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final int REQUEST_CODE_PENDING_INTENT = 102; + + /** + * Download an MMS message + */ + public static void redownloadMessage(final String messageId) { + final RedownloadMmsAction action = new RedownloadMmsAction(messageId); + action.start(); + } + + /** + * Get a pending intent of for downloading an MMS + */ + public static PendingIntent getPendingIntentForRedownloadMms( + final Context context, final String messageId) { + final Action action = new RedownloadMmsAction(messageId); + return ActionService.makeStartActionPendingIntent(context, + action, REQUEST_CODE_PENDING_INTENT, false /*launchesAnActivity*/); + } + + // Core parameters needed for all types of message + private static final String KEY_MESSAGE_ID = "message_id"; + + /** + * Constructor used for retrying sending in the background (only message id available) + */ + RedownloadMmsAction(final String messageId) { + super(); + actionParameters.putString(KEY_MESSAGE_ID, messageId); + } + + /** + * Read message from database and change status to allow downloading + */ + @Override + protected Object executeAction() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + // Check message can be redownloaded + if (message != null && message.canRedownloadMessage()) { + final long timestamp = System.currentTimeMillis(); + + final ContentValues values = new ContentValues(2); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD); + values.put(DatabaseHelper.MessageColumns.RETRY_START_TIMESTAMP, timestamp); + + // Row must exist as was just loaded above (on ActionService thread) + BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values); + + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + } else { + message = null; + LogUtil.e(LogUtil.BUGLE_TAG, + "Attempt to download a missing or un-redownloadable message"); + } + // Immediately update the notifications in case we came from the download action from a + // heads-up notification. This will dismiss the heads-up notification. + BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL); + return message; + } + + private RedownloadMmsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<RedownloadMmsAction> CREATOR + = new Parcelable.Creator<RedownloadMmsAction>() { + @Override + public RedownloadMmsAction createFromParcel(final Parcel in) { + return new RedownloadMmsAction(in); + } + + @Override + public RedownloadMmsAction[] newArray(final int size) { + return new RedownloadMmsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ResendMessageAction.java b/src/com/android/messaging/datamodel/action/ResendMessageAction.java new file mode 100644 index 0000000..2201965 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ResendMessageAction.java @@ -0,0 +1,128 @@ +/* + * 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.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +/** + * Action used to manually resend an outgoing message + */ +public class ResendMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Manual send of existing message (no listener) + */ + public static void resendMessage(final String messageId) { + final ResendMessageAction action = new ResendMessageAction(messageId); + action.start(); + } + + // Core parameters needed for all types of message + private static final String KEY_MESSAGE_ID = "message_id"; + + /** + * Constructor used for retrying sending in the background (only message id available) + */ + ResendMessageAction(final String messageId) { + super(); + actionParameters.putString(KEY_MESSAGE_ID, messageId); + } + + /** + * Read message from database and change status to allow sending + */ + @Override + protected Object executeAction() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + // Check message can be resent + if (message != null && message.canResendMessage()) { + final boolean isMms = message.getIsMms(); + long timestamp = System.currentTimeMillis(); + if (isMms) { + // MMS expects timestamp rounded to nearest second + timestamp = 1000 * ((timestamp + 500) / 1000); + } + + LogUtil.i(TAG, "ResendMessageAction: Resending message " + messageId + + "; changed timestamp from " + message.getReceivedTimeStamp() + " to " + + timestamp); + + final ContentValues values = new ContentValues(); + values.put(MessageColumns.STATUS, MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND); + values.put(MessageColumns.RECEIVED_TIMESTAMP, timestamp); + values.put(MessageColumns.SENT_TIMESTAMP, timestamp); + values.put(MessageColumns.RETRY_START_TIMESTAMP, timestamp); + + // Row must exist as was just loaded above (on ActionService thread) + BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values); + + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + + return message; + } else { + String error = "ResendMessageAction: Cannot resend message " + messageId + "; "; + if (message != null) { + error += ("status = " + MessageData.getStatusDescription(message.getStatus())); + } else { + error += "not found in database"; + } + LogUtil.e(TAG, error); + } + + return null; + } + + private ResendMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<ResendMessageAction> CREATOR + = new Parcelable.Creator<ResendMessageAction>() { + @Override + public ResendMessageAction createFromParcel(final Parcel in) { + return new ResendMessageAction(in); + } + + @Override + public ResendMessageAction[] newArray(final int size) { + return new ResendMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/SendMessageAction.java b/src/com/android/messaging/datamodel/action/SendMessageAction.java new file mode 100644 index 0000000..d7ebe8f --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SendMessageAction.java @@ -0,0 +1,447 @@ +/* + * 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.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Action used to send an outgoing message. It writes MMS messages to the telephony db + * ({@link InsertNewMessageAction}) writes SMS messages to the telephony db). It also + * initiates the actual sending. It will all be used for re-sending a failed message. + * NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure). + * <p> + * This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to + * access the EXTRA_* fields for setting up the 'sent' pending intent. + */ +public class SendMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Queue sending of existing message (can only be called during execute of action) + */ + static boolean queueForSendInBackground(final String messageId, + final Action processingAction) { + final SendMessageAction action = new SendMessageAction(); + return action.queueAction(messageId, processingAction); + } + + public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false; + public static final int MAX_SMS_RETRY = 3; + + // Core parameters needed for all types of message + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_MESSAGE = "message"; + private static final String KEY_MESSAGE_URI = "message_uri"; + private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number"; + + // For sms messages a few extra values are included in the bundle + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_SMS_SERVICE_CENTER = "sms_service_center"; + + // Values we attach to the pending intent that's fired when the message is sent. + // Only applicable when sending via the platform APIs on L+. + public static final String KEY_SUB_ID = "sub_id"; + public static final String EXTRA_MESSAGE_ID = "message_id"; + public static final String EXTRA_UPDATED_MESSAGE_URI = "updated_message_uri"; + public static final String EXTRA_CONTENT_URI = "content_uri"; + public static final String EXTRA_RESPONSE_IMPORTANT = "response_important"; + + /** + * Constructor used for retrying sending in the background (only message id available) + */ + private SendMessageAction() { + super(); + } + + /** + * Read message from database and queue actual sending + */ + private boolean queueAction(final String messageId, final Action processingAction) { + actionParameters.putString(KEY_MESSAGE_ID, messageId); + + final long timestamp = System.currentTimeMillis(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + // Check message can be resent + if (message != null && message.canSendMessage()) { + final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS); + + final ParticipantData self = BugleDatabaseOperations.getExistingParticipant( + db, message.getSelfId()); + final Uri messageUri = message.getSmsMessageUri(); + final String conversationId = message.getConversationId(); + + // Update message status + if (message.getYetToSend()) { + // Initial sending of message + message.markMessageSending(timestamp); + } else { + // Automatic resend of message + message.markMessageResending(timestamp); + } + if (!updateMessageAndStatus(isSms, message, null /* messageUri */, false /*notify*/)) { + // If message is missing in the telephony database we don't need to send it + return false; + } + + final ArrayList<String> recipients = + BugleDatabaseOperations.getRecipientsForConversation(db, conversationId); + + // Update action state with parameters needed for background sending + actionParameters.putParcelable(KEY_MESSAGE_URI, messageUri); + actionParameters.putParcelable(KEY_MESSAGE, message); + actionParameters.putStringArrayList(KEY_RECIPIENTS, recipients); + actionParameters.putInt(KEY_SUB_ID, self.getSubId()); + actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination()); + + if (isSms) { + final String smsc = BugleDatabaseOperations.getSmsServiceCenterForConversation( + db, conversationId); + actionParameters.putString(KEY_SMS_SERVICE_CENTER, smsc); + + if (recipients.size() == 1) { + final String recipient = recipients.get(0); + + actionParameters.putString(KEY_RECIPIENT, recipient); + // Queue actual sending for SMS + processingAction.requestBackgroundWork(this); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SendMessageAction: Queued SMS message " + messageId + + " for sending"); + } + return true; + } else { + LogUtil.wtf(TAG, "Trying to resend a broadcast SMS - not allowed"); + } + } else { + // Queue actual sending for MMS + processingAction.requestBackgroundWork(this); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SendMessageAction: Queued MMS message " + messageId + + " for sending"); + } + return true; + } + } + + return false; + } + + + /** + * Never called + */ + @Override + protected Object executeAction() { + Assert.fail("SendMessageAction must be queued rather than started"); + return null; + } + + /** + * Send message on background worker thread + */ + @Override + protected Bundle doBackgroundWork() { + final MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI); + Uri updatedMessageUri = null; + final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS; + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER); + + LogUtil.i(TAG, "SendMessageAction: Sending " + (isSms ? "SMS" : "MMS") + " message " + + messageId + " in conversation " + message.getConversationId()); + + int status; + int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + int resultCode = MessageData.UNKNOWN_RESULT_CODE; + if (isSms) { + Assert.notNull(messageUri); + final String recipient = actionParameters.getString(KEY_RECIPIENT); + final String messageText = message.getMessageText(); + final String smsServiceCenter = actionParameters.getString(KEY_SMS_SERVICE_CENTER); + final boolean deliveryReportRequired = MmsUtils.isDeliveryReportRequired(subId); + + status = MmsUtils.sendSmsMessage(recipient, messageText, messageUri, subId, + smsServiceCenter, deliveryReportRequired); + } else { + final Context context = Factory.get().getApplicationContext(); + final ArrayList<String> recipients = + actionParameters.getStringArrayList(KEY_RECIPIENTS); + if (messageUri == null) { + final long timestamp = message.getReceivedTimeStamp(); + + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(timestamp); + + // For MMS messages first need to write to telephony (resizing images if needed) + updatedMessageUri = MmsUtils.insertSendingMmsMessage(context, recipients, + message, subId, subPhoneNumber, timestamp); + if (updatedMessageUri != null) { + messageUri = updatedMessageUri; + // To prevent Sync seeing inconsistent state must write to DB on this thread + updateMessageUri(messageId, updatedMessageUri); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SendMessageAction: Updated message " + messageId + + " with new uri " + messageUri); + } + } + } + if (messageUri != null) { + // Actually send the MMS + final Bundle extras = new Bundle(); + extras.putString(EXTRA_MESSAGE_ID, messageId); + extras.putParcelable(EXTRA_UPDATED_MESSAGE_URI, updatedMessageUri); + final MmsUtils.StatusPlusUri result = MmsUtils.sendMmsMessage(context, subId, + messageUri, extras); + if (result == MmsUtils.STATUS_PENDING) { + // Async send, so no status yet + LogUtil.d(TAG, "SendMessageAction: Sending MMS message " + messageId + + " asynchronously; waiting for callback to finish processing"); + return null; + } + status = result.status; + rawStatus = result.rawStatus; + resultCode = result.resultCode; + } else { + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } + + // When we fast-fail before calling the MMS lib APIs (e.g. airplane mode, + // sending message is deleted). + ProcessSentMessageAction.processMessageSentFastFailed(messageId, messageUri, + updatedMessageUri, subId, isSms, status, rawStatus, resultCode); + return null; + } + + private void updateMessageUri(final String messageId, final Uri updatedMessageUri) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.SMS_MESSAGE_URI, updatedMessageUri.toString()); + BugleDatabaseOperations.updateMessageRow(db, messageId, values); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + @Override + protected Object processBackgroundResponse(final Bundle response) { + // Nothing to do here, post-send tasks handled by ProcessSentMessageAction + return null; + } + + /** + * Update message status to reflect success or failure + */ + @Override + protected Object processBackgroundFailure() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS; + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final int resultCode = actionParameters.getInt(ProcessSentMessageAction.KEY_RESULT_CODE); + final int httpStatusCode = + actionParameters.getInt(ProcessSentMessageAction.KEY_HTTP_STATUS_CODE); + + ProcessSentMessageAction.processResult(messageId, null /* updatedMessageUri */, + MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + isSms, this, subId, resultCode, httpStatusCode); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true, this); + + return null; + } + + /** + * Update the message status (and message itself if necessary) + * @param isSms whether this is an SMS or MMS + * @param message message to update + * @param updatedMessageUri message uri for newly-inserted messages; null otherwise + * @param clearSeen whether the message 'seen' status should be reset if error occurs + */ + public static boolean updateMessageAndStatus(final boolean isSms, final MessageData message, + final Uri updatedMessageUri, final boolean clearSeen) { + final Context context = Factory.get().getApplicationContext(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // TODO: We're optimistically setting the type/box of outgoing messages to + // 'SENT' even before they actually are. We should technically be using QUEUED or OUTBOX + // instead, but if we do that, it's possible that the Messaging app will try to send them + // as part of its clean-up logic that runs when it starts (http://b/18155366). + // + // We also use the wrong status when inserting queued SMS messages in + // InsertNewMessageAction.insertBroadcastSmsMessage and insertSendingSmsMessage (should be + // QUEUED or OUTBOX), and in MmsUtils.insertSendReq (should be OUTBOX). + + boolean updatedTelephony = true; + int messageBox; + int type; + switch(message.getStatus()) { + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: + type = Sms.MESSAGE_TYPE_SENT; + messageBox = Mms.MESSAGE_BOX_SENT; + break; + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + type = Sms.MESSAGE_TYPE_SENT; + messageBox = Mms.MESSAGE_BOX_SENT; + break; + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + type = Sms.MESSAGE_TYPE_SENT; + messageBox = Mms.MESSAGE_BOX_SENT; + break; + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + type = Sms.MESSAGE_TYPE_FAILED; + messageBox = Mms.MESSAGE_BOX_FAILED; + break; + default: + type = Sms.MESSAGE_TYPE_ALL; + messageBox = Mms.MESSAGE_BOX_ALL; + break; + } + // First in the telephony DB + if (isSms) { + // Ignore update message Uri + if (type != Sms.MESSAGE_TYPE_ALL) { + if (!MmsUtils.updateSmsMessageSendingStatus(context, message.getSmsMessageUri(), + type, message.getReceivedTimeStamp())) { + message.markMessageFailed(message.getSentTimeStamp()); + updatedTelephony = false; + } + } + } else if (message.getSmsMessageUri() != null) { + if (messageBox != Mms.MESSAGE_BOX_ALL) { + if (!MmsUtils.updateMmsMessageSendingStatus(context, message.getSmsMessageUri(), + messageBox, message.getReceivedTimeStamp())) { + message.markMessageFailed(message.getSentTimeStamp()); + updatedTelephony = false; + } + } + } + if (updatedTelephony) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + + " in telephony (" + message.getSmsMessageUri() + ")"); + } + } else { + LogUtil.w(TAG, "SendMessageAction: Failed to update " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + + " in telephony (" + message.getSmsMessageUri() + "); marking message failed"); + } + + // Update the local DB + db.beginTransaction(); + try { + if (updatedMessageUri != null) { + // Update all message and part fields + BugleDatabaseOperations.updateMessageInTransaction(db, message); + BugleDatabaseOperations.refreshConversationMetadataInTransaction( + db, message.getConversationId(), false/* shouldAutoSwitchSelfId */, + false/*archived*/); + } else { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.STATUS, message.getStatus()); + + if (clearSeen) { + // When a message fails to send, the message needs to + // be unseen to be selected as an error notification. + values.put(MessageColumns.SEEN, 0); + } + values.put(MessageColumns.RECEIVED_TIMESTAMP, message.getReceivedTimeStamp()); + values.put(MessageColumns.RAW_TELEPHONY_STATUS, message.getRawTelephonyStatus()); + + BugleDatabaseOperations.updateMessageRowIfExists(db, message.getMessageId(), + values); + } + db.setTransactionSuccessful(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + " in local db. Timestamp = " + + message.getReceivedTimeStamp()); + } + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + if (updatedMessageUri != null) { + MessagingContentProvider.notifyPartsChanged(); + } + + return updatedTelephony; + } + + private SendMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<SendMessageAction> CREATOR + = new Parcelable.Creator<SendMessageAction>() { + @Override + public SendMessageAction createFromParcel(final Parcel in) { + return new SendMessageAction(in); + } + + @Override + public SendMessageAction[] newArray(final int size) { + return new SendMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/SyncCursorPair.java b/src/com/android/messaging/datamodel/action/SyncCursorPair.java new file mode 100644 index 0000000..b3a2676 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SyncCursorPair.java @@ -0,0 +1,712 @@ +/* + * 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.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; +import android.support.v4.util.LongSparseArray; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.sms.DatabaseMessages; +import com.android.messaging.sms.DatabaseMessages.DatabaseMessage; +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 com.google.common.collect.Sets; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Class holding a pair of cursors - one for local db and one for telephony provider - allowing + * synchronous stepping through messages as part of sync. + */ +class SyncCursorPair { + private static final String TAG = LogUtil.BUGLE_TAG; + + static final long SYNC_COMPLETE = -1L; + static final long SYNC_STARTING = Long.MAX_VALUE; + + private CursorIterator mLocalCursorIterator; + private CursorIterator mRemoteCursorsIterator; + + private final String mLocalSelection; + private final String mRemoteSmsSelection; + private final String mRemoteMmsSelection; + + /** + * Check if SMS has been synchronized. We compare the counts of messages on both + * sides and return true if they are equal. + * + * Note that this may not be the most reliable way to tell if messages are in sync. + * For example, the local misses one message and has one obsolete message. + * However, we have background sms sync once a while, also some other events might + * trigger a full sync. So we will eventually catch up. And this should be rare to + * happen. + * + * @return If sms is in sync with telephony sms/mms providers + */ + static boolean allSynchronized(final DatabaseWrapper db) { + return isSynchronized(db, LOCAL_MESSAGES_SELECTION, null, + getSmsTypeSelectionSql(), null, getMmsTypeSelectionSql(), null); + } + + SyncCursorPair(final long lowerBound, final long upperBound) { + mLocalSelection = getTimeConstrainedQuery( + LOCAL_MESSAGES_SELECTION, + MessageColumns.RECEIVED_TIMESTAMP, + lowerBound, + upperBound, + null /* threadColumn */, null /* threadId */); + mRemoteSmsSelection = getTimeConstrainedQuery( + getSmsTypeSelectionSql(), + "date", + lowerBound, + upperBound, + null /* threadColumn */, null /* threadId */); + mRemoteMmsSelection = getTimeConstrainedQuery( + getMmsTypeSelectionSql(), + "date", + ((lowerBound < 0) ? lowerBound : (lowerBound + 999) / 1000), /*seconds*/ + ((upperBound < 0) ? upperBound : (upperBound + 999) / 1000), /*seconds*/ + null /* threadColumn */, null /* threadId */); + } + + SyncCursorPair(final long threadId, final String conversationId) { + mLocalSelection = getTimeConstrainedQuery( + LOCAL_MESSAGES_SELECTION, + MessageColumns.RECEIVED_TIMESTAMP, + -1L, + -1L, + MessageColumns.CONVERSATION_ID, conversationId); + // Find all SMS messages (excluding drafts) within the sync window + mRemoteSmsSelection = getTimeConstrainedQuery( + getSmsTypeSelectionSql(), + "date", + -1L, + -1L, + Sms.THREAD_ID, Long.toString(threadId)); + mRemoteMmsSelection = getTimeConstrainedQuery( + getMmsTypeSelectionSql(), + "date", + -1L, /*seconds*/ + -1L, /*seconds*/ + Mms.THREAD_ID, Long.toString(threadId)); + } + + void query(final DatabaseWrapper db) { + // Load local messages in the sync window + mLocalCursorIterator = new LocalCursorIterator(db, mLocalSelection); + // Load remote messages in the sync window + mRemoteCursorsIterator = new RemoteCursorsIterator(mRemoteSmsSelection, + mRemoteMmsSelection); + } + + boolean isSynchronized(final DatabaseWrapper db) { + return isSynchronized(db, mLocalSelection, null, mRemoteSmsSelection, + null, mRemoteMmsSelection, null); + } + + void close() { + if (mLocalCursorIterator != null) { + mLocalCursorIterator.close(); + } + if (mRemoteCursorsIterator != null) { + mRemoteCursorsIterator.close(); + } + } + + long scan(final int maxMessagesToScan, + final int maxMessagesToUpdate, final ArrayList<SmsMessage> smsToAdd, + final LongSparseArray<MmsMessage> mmsToAdd, + final ArrayList<LocalDatabaseMessage> messagesToDelete, + final SyncManager.ThreadInfoCache threadInfoCache) { + // Set of local messages matched with the timestamp of a remote message + final Set<DatabaseMessage> matchedLocalMessages = Sets.newHashSet(); + // Set of remote messages matched with the timestamp of a local message + final Set<DatabaseMessage> matchedRemoteMessages = Sets.newHashSet(); + long lastTimestampMillis = SYNC_STARTING; + // Number of messages scanned local and remote + int localCount = 0; + int remoteCount = 0; + // Seed the initial values of remote and local messages for comparison + DatabaseMessage remoteMessage = mRemoteCursorsIterator.next(); + DatabaseMessage localMessage = mLocalCursorIterator.next(); + // Iterate through messages on both sides in reverse time order + // Import messages in remote not in local, delete messages in local not in remote + while (localCount + remoteCount < maxMessagesToScan && smsToAdd.size() + + mmsToAdd.size() + messagesToDelete.size() < maxMessagesToUpdate) { + if (remoteMessage == null && localMessage == null) { + // No more message on both sides - scan complete + lastTimestampMillis = SYNC_COMPLETE; + break; + } else if ((remoteMessage == null && localMessage != null) || + (localMessage != null && remoteMessage != null && + localMessage.getTimestampInMillis() + > remoteMessage.getTimestampInMillis())) { + // Found a local message that is not in remote db + // Delete the local message + messagesToDelete.add((LocalDatabaseMessage) localMessage); + lastTimestampMillis = Math.min(lastTimestampMillis, + localMessage.getTimestampInMillis()); + // Advance to next local message + localMessage = mLocalCursorIterator.next(); + localCount += 1; + } else if ((localMessage == null && remoteMessage != null) || + (localMessage != null && remoteMessage != null && + localMessage.getTimestampInMillis() + < remoteMessage.getTimestampInMillis())) { + // Found a remote message that is not in local db + // Add the remote message + saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache); + lastTimestampMillis = Math.min(lastTimestampMillis, + remoteMessage.getTimestampInMillis()); + // Advance to next remote message + remoteMessage = mRemoteCursorsIterator.next(); + remoteCount += 1; + } else { + // Found remote and local messages at the same timestamp + final long matchedTimestamp = localMessage.getTimestampInMillis(); + lastTimestampMillis = Math.min(lastTimestampMillis, matchedTimestamp); + // Get the next local and remote messages + final DatabaseMessage remoteMessagePeek = mRemoteCursorsIterator.next(); + final DatabaseMessage localMessagePeek = mLocalCursorIterator.next(); + // Check if only one message on each side matches the current timestamp + // by looking at the next messages on both sides. If they are either null + // (meaning no more messages) or having a different timestamp. We want + // to optimize for this since this is the most common case when majority + // of the messages are in sync (so they one-to-one pair up at each timestamp), + // by not allocating the data structures required to compare a set of + // messages from both sides. + if ((remoteMessagePeek == null || + remoteMessagePeek.getTimestampInMillis() != matchedTimestamp) && + (localMessagePeek == null || + localMessagePeek.getTimestampInMillis() != matchedTimestamp)) { + // Optimize the common case where only one message on each side + // that matches the same timestamp + if (!remoteMessage.equals(localMessage)) { + // local != remote + // Delete local message + messagesToDelete.add((LocalDatabaseMessage) localMessage); + // Add remote message + saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache); + } + // Get next local and remote messages + localMessage = localMessagePeek; + remoteMessage = remoteMessagePeek; + localCount += 1; + remoteCount += 1; + } else { + // Rare case in which multiple messages are in the same timestamp + // on either or both sides + // Gather all the matched remote messages + matchedRemoteMessages.clear(); + matchedRemoteMessages.add(remoteMessage); + remoteCount += 1; + remoteMessage = remoteMessagePeek; + while (remoteMessage != null && + remoteMessage.getTimestampInMillis() == matchedTimestamp) { + Assert.isTrue(!matchedRemoteMessages.contains(remoteMessage)); + matchedRemoteMessages.add(remoteMessage); + remoteCount += 1; + remoteMessage = mRemoteCursorsIterator.next(); + } + // Gather all the matched local messages + matchedLocalMessages.clear(); + matchedLocalMessages.add(localMessage); + localCount += 1; + localMessage = localMessagePeek; + while (localMessage != null && + localMessage.getTimestampInMillis() == matchedTimestamp) { + if (matchedLocalMessages.contains(localMessage)) { + // Duplicate message is local database is deleted + messagesToDelete.add((LocalDatabaseMessage) localMessage); + } else { + matchedLocalMessages.add(localMessage); + } + localCount += 1; + localMessage = mLocalCursorIterator.next(); + } + // Delete messages local only + for (final DatabaseMessage msg : Sets.difference( + matchedLocalMessages, matchedRemoteMessages)) { + messagesToDelete.add((LocalDatabaseMessage) msg); + } + // Add messages remote only + for (final DatabaseMessage msg : Sets.difference( + matchedRemoteMessages, matchedLocalMessages)) { + saveMessageToAdd(smsToAdd, mmsToAdd, msg, threadInfoCache); + } + } + } + } + return lastTimestampMillis; + } + + DatabaseMessage getLocalMessage() { + return mLocalCursorIterator.next(); + } + + DatabaseMessage getRemoteMessage() { + return mRemoteCursorsIterator.next(); + } + + int getLocalPosition() { + return mLocalCursorIterator.getPosition(); + } + + int getRemotePosition() { + return mRemoteCursorsIterator.getPosition(); + } + + int getLocalCount() { + return mLocalCursorIterator.getCount(); + } + + int getRemoteCount() { + return mRemoteCursorsIterator.getCount(); + } + + /** + * An iterator for a database cursor + */ + interface CursorIterator { + /** + * Move to next element in the cursor + * + * @return The next element (which becomes the current) + */ + public DatabaseMessage next(); + /** + * Close the cursor + */ + public void close(); + /** + * Get the position + */ + public int getPosition(); + /** + * Get the count + */ + public int getCount(); + } + + private static final String ORDER_BY_DATE_DESC = "date DESC"; + + // A subquery that selects SMS/MMS messages in Bugle which are also in telephony + private static final String LOCAL_MESSAGES_SELECTION = String.format( + Locale.US, + "(%s NOTNULL)", + MessageColumns.SMS_MESSAGE_URI); + + private static final String ORDER_BY_TIMESTAMP_DESC = + MessageColumns.RECEIVED_TIMESTAMP + " DESC"; + + // TODO : This should move into the provider + private static class LocalMessageQuery { + private static final String[] PROJECTION = new String[] { + MessageColumns._ID, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SMS_MESSAGE_URI, + MessageColumns.PROTOCOL, + MessageColumns.CONVERSATION_ID, + }; + private static final int INDEX_MESSAGE_ID = 0; + private static final int INDEX_MESSAGE_TIMESTAMP = 1; + private static final int INDEX_SMS_MESSAGE_URI = 2; + private static final int INDEX_MESSAGE_SMS_TYPE = 3; + private static final int INDEX_CONVERSATION_ID = 4; + } + + /** + * This class provides the same DatabaseMessage interface over a local SMS db message + */ + private static LocalDatabaseMessage getLocalDatabaseMessage(final Cursor cursor) { + if (cursor == null) { + return null; + } + return new LocalDatabaseMessage( + cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_ID), + cursor.getInt(LocalMessageQuery.INDEX_MESSAGE_SMS_TYPE), + cursor.getString(LocalMessageQuery.INDEX_SMS_MESSAGE_URI), + cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_TIMESTAMP), + cursor.getString(LocalMessageQuery.INDEX_CONVERSATION_ID)); + } + + /** + * The buffered cursor iterator for local SMS + */ + private static class LocalCursorIterator implements CursorIterator { + private Cursor mCursor; + private final DatabaseWrapper mDatabase; + + LocalCursorIterator(final DatabaseWrapper database, final String selection) + throws SQLiteException { + mDatabase = database; + try { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncCursorPair: Querying for local messages; selection = " + + selection); + } + mCursor = mDatabase.query( + DatabaseHelper.MESSAGES_TABLE, + LocalMessageQuery.PROJECTION, + selection, + null /*selectionArgs*/, + null/*groupBy*/, + null/*having*/, + ORDER_BY_TIMESTAMP_DESC); + } catch (final SQLiteException e) { + LogUtil.e(TAG, "SyncCursorPair: failed to query local sms/mms", e); + // Can't query local database. So let's throw up the exception and abort sync + // because we may end up import duplicate messages. + throw e; + } + } + + @Override + public DatabaseMessage next() { + if (mCursor != null && mCursor.moveToNext()) { + return getLocalDatabaseMessage(mCursor); + } + return null; + } + + @Override + public int getCount() { + return (mCursor == null ? 0 : mCursor.getCount()); + } + + @Override + public int getPosition() { + return (mCursor == null ? 0 : mCursor.getPosition()); + } + + @Override + public void close() { + if (mCursor != null) { + mCursor.close(); + mCursor = null; + } + } + } + + /** + * The cursor iterator for remote sms. + * Since SMS and MMS are stored in different tables in telephony provider, + * this class merges the two cursors and provides a unified view of messages + * from both cursors. Note that the order is DESC. + */ + private static class RemoteCursorsIterator implements CursorIterator { + private Cursor mSmsCursor; + private Cursor mMmsCursor; + private DatabaseMessage mNextSms; + private DatabaseMessage mNextMms; + + RemoteCursorsIterator(final String smsSelection, final String mmsSelection) + throws SQLiteException { + mSmsCursor = null; + mMmsCursor = null; + try { + final Context context = Factory.get().getApplicationContext(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncCursorPair: Querying for remote SMS; selection = " + + smsSelection); + } + mSmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Sms.CONTENT_URI, + SmsMessage.getProjection(), + smsSelection, + null /* selectionArgs */, + ORDER_BY_DATE_DESC); + if (mSmsCursor == null) { + LogUtil.w(TAG, "SyncCursorPair: Remote SMS query returned null cursor; " + + "need to cancel sync"); + throw new RuntimeException("Null cursor from remote SMS query"); + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncCursorPair: Querying for remote MMS; selection = " + + mmsSelection); + } + mMmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Mms.CONTENT_URI, + DatabaseMessages.MmsMessage.getProjection(), + mmsSelection, + null /* selectionArgs */, + ORDER_BY_DATE_DESC); + if (mMmsCursor == null) { + LogUtil.w(TAG, "SyncCursorPair: Remote MMS query returned null cursor; " + + "need to cancel sync"); + throw new RuntimeException("Null cursor from remote MMS query"); + } + // Move to the first element in the combined stream from both cursors + mNextSms = getSmsCursorNext(); + mNextMms = getMmsCursorNext(); + } catch (final SQLiteException e) { + LogUtil.e(TAG, "SyncCursorPair: failed to query remote messages", e); + // If we ignore this, the following code would think there is no remote message + // and will delete all the local sms. We should be cautious here. So instead, + // let's throw the exception to the caller and abort sms sync. We do the same + // thing if either of the remote cursors is null. + throw e; + } + } + + @Override + public DatabaseMessage next() { + DatabaseMessage result = null; + if (mNextSms != null && mNextMms != null) { + if (mNextSms.getTimestampInMillis() >= mNextMms.getTimestampInMillis()) { + result = mNextSms; + mNextSms = getSmsCursorNext(); + } else { + result = mNextMms; + mNextMms = getMmsCursorNext(); + } + } else { + if (mNextSms != null) { + result = mNextSms; + mNextSms = getSmsCursorNext(); + } else { + result = mNextMms; + mNextMms = getMmsCursorNext(); + } + } + return result; + } + + private DatabaseMessage getSmsCursorNext() { + if (mSmsCursor != null && mSmsCursor.moveToNext()) { + return SmsMessage.get(mSmsCursor); + } + return null; + } + + private DatabaseMessage getMmsCursorNext() { + if (mMmsCursor != null && mMmsCursor.moveToNext()) { + return MmsMessage.get(mMmsCursor); + } + return null; + } + + @Override + // Return approximate cursor position allowing for read ahead on two cursors (hence -1) + public int getPosition() { + return (mSmsCursor == null ? 0 : mSmsCursor.getPosition()) + + (mMmsCursor == null ? 0 : mMmsCursor.getPosition()) - 1; + } + + @Override + public int getCount() { + return (mSmsCursor == null ? 0 : mSmsCursor.getCount()) + + (mMmsCursor == null ? 0 : mMmsCursor.getCount()); + } + + @Override + public void close() { + if (mSmsCursor != null) { + mSmsCursor.close(); + mSmsCursor = null; + } + if (mMmsCursor != null) { + mMmsCursor.close(); + mMmsCursor = null; + } + } + } + + /** + * Type selection for importing sms messages. Only SENT and INBOX messages are imported. + * + * @return The SQL selection for importing sms messages + */ + public static String getSmsTypeSelectionSql() { + return MmsUtils.getSmsTypeSelectionSql(); + } + + /** + * Type selection for importing mms messages. + * + * Criteria: + * MESSAGE_BOX is INBOX, SENT or OUTBOX + * MESSAGE_TYPE is SEND_REQ (sent), RETRIEVE_CONF (received) or NOTIFICATION_IND (download) + * + * @return The SQL selection for importing mms messages. This selects the message type, + * not including the selection on timestamp. + */ + public static String getMmsTypeSelectionSql() { + return MmsUtils.getMmsTypeSelectionSql(); + } + + /** + * Get a SQL selection string using an existing selection and time window limits + * The limits are not applied if the value is < 0 + * + * @param typeSelection The existing selection + * @param from The inclusive lower bound + * @param to The exclusive upper bound + * @return The created SQL selection + */ + private static String getTimeConstrainedQuery(final String typeSelection, + final String timeColumn, final long from, final long to, + final String threadColumn, final String threadId) { + final StringBuilder queryBuilder = new StringBuilder(); + queryBuilder.append(typeSelection); + if (from > 0) { + queryBuilder.append(" AND ").append(timeColumn).append(">=").append(from); + } + if (to > 0) { + queryBuilder.append(" AND ").append(timeColumn).append("<").append(to); + } + if (!TextUtils.isEmpty(threadColumn) && !TextUtils.isEmpty(threadId)) { + queryBuilder.append(" AND ").append(threadColumn).append("=").append(threadId); + } + return queryBuilder.toString(); + } + + private static final String[] COUNT_PROJECTION = new String[] { "count()" }; + + private static int getCountFromCursor(final Cursor cursor) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + // We should only return a number if we were able to read it from the cursor. + // Otherwise, we throw an exception to cancel the sync. + String cursorDesc = ""; + if (cursor == null) { + cursorDesc = "null"; + } else if (cursor.getCount() == 0) { + cursorDesc = "empty"; + } + throw new IllegalArgumentException("Cannot get count from " + cursorDesc + " cursor"); + } + + private void saveMessageToAdd(final List<SmsMessage> smsToAdd, + final LongSparseArray<MmsMessage> mmsToAdd, final DatabaseMessage message, + final ThreadInfoCache threadInfoCache) { + long threadId; + if (message.getProtocol() == MessageData.PROTOCOL_MMS) { + final MmsMessage mms = (MmsMessage) message; + mmsToAdd.append(mms.getId(), mms); + threadId = mms.mThreadId; + } else { + final SmsMessage sms = (SmsMessage) message; + smsToAdd.add(sms); + threadId = sms.mThreadId; + } + // Cache the lookup and canonicalization of the phone number outside of the transaction... + threadInfoCache.getThreadRecipients(threadId); + } + + /** + * Check if SMS has been synchronized. We compare the counts of messages on both + * sides and return true if they are equal. + * + * Note that this may not be the most reliable way to tell if messages are in sync. + * For example, the local misses one message and has one obsolete message. + * However, we have background sms sync once a while, also some other events might + * trigger a full sync. So we will eventually catch up. And this should be rare to + * happen. + * + * @return If sms is in sync with telephony sms/mms providers + */ + private static boolean isSynchronized(final DatabaseWrapper db, final String localSelection, + final String[] localSelectionArgs, final String smsSelection, + final String[] smsSelectionArgs, final String mmsSelection, + final String[] mmsSelectionArgs) { + final Context context = Factory.get().getApplicationContext(); + Cursor localCursor = null; + Cursor remoteSmsCursor = null; + Cursor remoteMmsCursor = null; + try { + localCursor = db.query( + DatabaseHelper.MESSAGES_TABLE, + COUNT_PROJECTION, + localSelection, + localSelectionArgs, + null/*groupBy*/, + null/*having*/, + null/*orderBy*/); + final int localCount = getCountFromCursor(localCursor); + remoteSmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Sms.CONTENT_URI, + COUNT_PROJECTION, + smsSelection, + smsSelectionArgs, + null/*orderBy*/); + final int smsCount = getCountFromCursor(remoteSmsCursor); + remoteMmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Mms.CONTENT_URI, + COUNT_PROJECTION, + mmsSelection, + mmsSelectionArgs, + null/*orderBy*/); + final int mmsCount = getCountFromCursor(remoteMmsCursor); + final int remoteCount = smsCount + mmsCount; + final boolean isInSync = (localCount == remoteCount); + if (isInSync) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncCursorPair: Same # of local and remote messages = " + + localCount); + } + } else { + LogUtil.i(TAG, "SyncCursorPair: Not in sync; # local messages = " + localCount + + ", # remote message = " + remoteCount); + } + return isInSync; + } catch (final Exception e) { + LogUtil.e(TAG, "SyncCursorPair: failed to query local or remote message counts", e); + // If something is wrong in querying database, assume we are synced so + // we don't retry indefinitely + } finally { + if (localCursor != null) { + localCursor.close(); + } + if (remoteSmsCursor != null) { + remoteSmsCursor.close(); + } + if (remoteMmsCursor != null) { + remoteMmsCursor.close(); + } + } + return true; + } +} diff --git a/src/com/android/messaging/datamodel/action/SyncMessageBatch.java b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java new file mode 100644 index 0000000..972d691 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java @@ -0,0 +1,383 @@ +/* + * 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; + } +} diff --git a/src/com/android/messaging/datamodel/action/SyncMessagesAction.java b/src/com/android/messaging/datamodel/action/SyncMessagesAction.java new file mode 100644 index 0000000..f4a3e1f --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SyncMessagesAction.java @@ -0,0 +1,637 @@ +/* + * 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.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.SystemClock; +import android.provider.Telephony.Mms; +import android.support.v4.util.LongSparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.sms.DatabaseMessages; +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.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Action used to sync messages from smsmms db to local database + */ +public class SyncMessagesAction extends Action implements Parcelable { + static final long SYNC_FAILED = Long.MIN_VALUE; + + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_START_TIMESTAMP = "start_timestamp"; + private static final String KEY_MAX_UPDATE = "max_update"; + private static final String KEY_LOWER_BOUND = "lower_bound"; + private static final String KEY_UPPER_BOUND = "upper_bound"; + private static final String BUNDLE_KEY_LAST_TIMESTAMP = "last_timestamp"; + private static final String BUNDLE_KEY_SMS_MESSAGES = "sms_to_add"; + private static final String BUNDLE_KEY_MMS_MESSAGES = "mms_to_add"; + private static final String BUNDLE_KEY_MESSAGES_TO_DELETE = "messages_to_delete"; + + /** + * Start a full sync (backed off a few seconds to avoid pulling sending/receiving messages). + */ + public static void fullSync() { + final BugleGservices bugleGservices = BugleGservices.get(); + final long smsSyncBackoffTimeMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS, + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); + + final long now = System.currentTimeMillis(); + // TODO: Could base this off most recent message in db but now should be okay... + final long startTimestamp = now - smsSyncBackoffTimeMillis; + + final SyncMessagesAction action = new SyncMessagesAction(-1L, startTimestamp, + 0, startTimestamp); + action.start(); + } + + /** + * Start an incremental sync to pull messages since last sync (backed off a few seconds).. + */ + public static void sync() { + final BugleGservices bugleGservices = BugleGservices.get(); + final long smsSyncBackoffTimeMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS, + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); + + final long now = System.currentTimeMillis(); + // TODO: Could base this off most recent message in db but now should be okay... + final long startTimestamp = now - smsSyncBackoffTimeMillis; + + sync(startTimestamp); + } + + /** + * Start an incremental sync when the application starts up (no back off as not yet + * sending/receiving). + */ + public static void immediateSync() { + final long now = System.currentTimeMillis(); + // TODO: Could base this off most recent message in db but now should be okay... + final long startTimestamp = now; + + sync(startTimestamp); + } + + private static void sync(final long startTimestamp) { + if (!OsUtil.hasSmsPermission()) { + // Sync requires READ_SMS permission + return; + } + + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + // Lower bound is end of previous sync + final long syncLowerBoundTimeMillis = prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME, + BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT); + + final SyncMessagesAction action = new SyncMessagesAction(syncLowerBoundTimeMillis, + startTimestamp, 0, startTimestamp); + action.start(); + } + + private SyncMessagesAction(final long lowerBound, final long upperBound, + final int maxMessagesToUpdate, final long startTimestamp) { + actionParameters.putLong(KEY_LOWER_BOUND, lowerBound); + actionParameters.putLong(KEY_UPPER_BOUND, upperBound); + actionParameters.putInt(KEY_MAX_UPDATE, maxMessagesToUpdate); + actionParameters.putLong(KEY_START_TIMESTAMP, startTimestamp); + } + + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND); + final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND); + final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE); + final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Request to sync messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis + " (start timestamp = " + + startTimestamp + ", message update limit = " + initialMaxMessagesToUpdate + + ")"); + } + + final SyncManager syncManager = DataModel.get().getSyncManager(); + if (lowerBoundTimeMillis >= 0) { + // Cursors + final SyncCursorPair cursors = new SyncCursorPair(-1L, lowerBoundTimeMillis); + final boolean inSync = cursors.isSynchronized(db); + if (!inSync) { + if (syncManager.delayUntilFullSync(startTimestamp) == 0) { + lowerBoundTimeMillis = -1; + actionParameters.putLong(KEY_LOWER_BOUND, lowerBoundTimeMillis); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Messages before " + + lowerBoundTimeMillis + " not in sync; promoting to full sync"); + } + } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Messages before " + + lowerBoundTimeMillis + " not in sync; will do incremental sync"); + } + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Messages before " + lowerBoundTimeMillis + + " are in sync"); + } + } + } + + // Check if sync allowed (can be too soon after last or one is already running) + if (syncManager.shouldSync(lowerBoundTimeMillis < 0, startTimestamp)) { + syncManager.startSyncBatch(upperBoundTimeMillis); + requestBackgroundWork(); + } + + return null; + } + + @Override + protected Bundle doBackgroundWork() { + final BugleGservices bugleGservices = BugleGservices.get(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final int maxMessagesToScan = bugleGservices.getInt( + BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN, + BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT); + + final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE); + final int smsSyncSubsequentBatchSizeMin = bugleGservices.getInt( + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN, + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN_DEFAULT); + final int smsSyncSubsequentBatchSizeMax = bugleGservices.getInt( + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX, + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX_DEFAULT); + + // Cap sync size to GServices limits + final int maxMessagesToUpdate = Math.max(smsSyncSubsequentBatchSizeMin, + Math.min(initialMaxMessagesToUpdate, smsSyncSubsequentBatchSizeMax)); + + final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND); + final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND); + + LogUtil.i(TAG, "SyncMessagesAction: Starting batch for messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis + + " (message update limit = " + maxMessagesToUpdate + ", message scan limit = " + + maxMessagesToScan + ")"); + + // Clear last change time so that we can work out if this batch is dirty when it completes + final SyncManager syncManager = DataModel.get().getSyncManager(); + + // Clear the singleton cache that maps threads to recipients and to conversations. + final SyncManager.ThreadInfoCache cache = syncManager.getThreadInfoCache(); + cache.clear(); + + // Sms messages to store + final ArrayList<SmsMessage> smsToAdd = new ArrayList<SmsMessage>(); + // Mms messages to store + final LongSparseArray<MmsMessage> mmsToAdd = new LongSparseArray<MmsMessage>(); + // List of local SMS/MMS to remove + final ArrayList<LocalDatabaseMessage> messagesToDelete = + new ArrayList<LocalDatabaseMessage>(); + + long lastTimestampMillis = SYNC_FAILED; + if (syncManager.isSyncing(upperBoundTimeMillis)) { + // Cursors + final SyncCursorPair cursors = new SyncCursorPair(lowerBoundTimeMillis, + upperBoundTimeMillis); + + // Actually compare the messages using cursor pair + lastTimestampMillis = syncCursorPair(db, cursors, smsToAdd, mmsToAdd, + messagesToDelete, maxMessagesToScan, maxMessagesToUpdate, cache); + } + final Bundle response = new Bundle(); + + // If comparison succeeds bundle up the changes for processing in ActionService + if (lastTimestampMillis > SYNC_FAILED) { + final ArrayList<MmsMessage> mmsToAddList = new ArrayList<MmsMessage>(); + for (int i = 0; i < mmsToAdd.size(); i++) { + final MmsMessage mms = mmsToAdd.valueAt(i); + mmsToAddList.add(mms); + } + + response.putParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES, smsToAdd); + response.putParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES, mmsToAddList); + response.putParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE, messagesToDelete); + } + response.putLong(BUNDLE_KEY_LAST_TIMESTAMP, lastTimestampMillis); + + return response; + } + + /** + * Compare messages based on timestamp and uri + * @param db local database wrapper + * @param cursors cursor pair holding references to local and remote messages + * @param smsToAdd newly found sms messages to add + * @param mmsToAdd newly found mms messages to add + * @param messagesToDelete messages not found needing deletion + * @param maxMessagesToScan max messages to scan for changes + * @param maxMessagesToUpdate max messages to return for updates + * @param cache cache for conversation id / thread id / recipient set mapping + * @return timestamp of the oldest message seen during the sync scan + */ + private long syncCursorPair(final DatabaseWrapper db, final SyncCursorPair cursors, + final ArrayList<SmsMessage> smsToAdd, final LongSparseArray<MmsMessage> mmsToAdd, + final ArrayList<LocalDatabaseMessage> messagesToDelete, final int maxMessagesToScan, + final int maxMessagesToUpdate, final ThreadInfoCache cache) { + long lastTimestampMillis; + final long startTimeMillis = SystemClock.elapsedRealtime(); + + // Number of messages scanned local and remote + int localPos = 0; + int remotePos = 0; + int localTotal = 0; + int remoteTotal = 0; + // Scan through the messages on both sides and prepare messages for local message table + // changes (including adding and deleting) + try { + cursors.query(db); + + localTotal = cursors.getLocalCount(); + remoteTotal = cursors.getRemoteCount(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Scanning cursors (local count = " + localTotal + + ", remote count = " + remoteTotal + ", message update limit = " + + maxMessagesToUpdate + ", message scan limit = " + maxMessagesToScan + + ")"); + } + + lastTimestampMillis = cursors.scan(maxMessagesToScan, maxMessagesToUpdate, + smsToAdd, mmsToAdd, messagesToDelete, cache); + + localPos = cursors.getLocalPosition(); + remotePos = cursors.getRemotePosition(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Scanned cursors (local position = " + localPos + + " of " + localTotal + ", remote position = " + remotePos + " of " + + remoteTotal + ")"); + } + + // Batch loading the parts of the MMS messages in this batch + loadMmsParts(mmsToAdd); + // Lookup senders for incoming mms messages + setMmsSenders(mmsToAdd, cache); + } catch (final SQLiteException e) { + LogUtil.e(TAG, "SyncMessagesAction: Database exception", e); + // Let's abort + lastTimestampMillis = SYNC_FAILED; + } catch (final Exception e) { + // We want to catch anything unexpected since this is running in a separate thread + // and any unexpected exception will just fail this thread silently. + // Let's crash for dogfooders! + LogUtil.wtf(TAG, "SyncMessagesAction: unexpected failure in scan", e); + lastTimestampMillis = SYNC_FAILED; + } finally { + if (cursors != null) { + cursors.close(); + } + } + + final long endTimeMillis = SystemClock.elapsedRealtime(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Scan complete (took " + + (endTimeMillis - startTimeMillis) + " ms). " + smsToAdd.size() + + " remote SMS to add, " + mmsToAdd.size() + " MMS to add, " + + messagesToDelete.size() + " local messages to delete. " + + "Oldest timestamp seen = " + lastTimestampMillis); + } + + return lastTimestampMillis; + } + + /** + * Perform local database updates and schedule follow on sync actions + */ + @Override + protected Object processBackgroundResponse(final Bundle response) { + final long lastTimestampMillis = response.getLong(BUNDLE_KEY_LAST_TIMESTAMP); + final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND); + final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND); + final int maxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE); + final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP); + + // Check with the sync manager if any conflicting updates have been made to databases + final SyncManager syncManager = DataModel.get().getSyncManager(); + final boolean orphan = !syncManager.isSyncing(upperBoundTimeMillis); + + // lastTimestampMillis used to indicate failure + if (orphan) { + // This batch does not match current in progress timestamp. + LogUtil.w(TAG, "SyncMessagesAction: Ignoring orphan sync batch for messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis); + } else { + final boolean dirty = syncManager.isBatchDirty(lastTimestampMillis); + if (lastTimestampMillis == SYNC_FAILED) { + LogUtil.e(TAG, "SyncMessagesAction: Sync failed - terminating"); + + // Failed - update last sync times to throttle our failure rate + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + // Save sync completion time so next sync will start from here + prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp); + // Remember last full sync so that don't start background full sync right away + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp); + + syncManager.complete(); + } else if (dirty) { + LogUtil.w(TAG, "SyncMessagesAction: Redoing dirty sync batch of messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis); + + // Redo this batch + final SyncMessagesAction nextBatch = + new SyncMessagesAction(lowerBoundTimeMillis, upperBoundTimeMillis, + maxMessagesToUpdate, startTimestamp); + + syncManager.startSyncBatch(upperBoundTimeMillis); + requestBackgroundWork(nextBatch); + } else { + // Succeeded + final ArrayList<SmsMessage> smsToAdd = + response.getParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES); + final ArrayList<MmsMessage> mmsToAdd = + response.getParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES); + final ArrayList<LocalDatabaseMessage> messagesToDelete = + response.getParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE); + + final int messagesUpdated = smsToAdd.size() + mmsToAdd.size() + + messagesToDelete.size(); + + // Perform local database changes in one transaction + long txnTimeMillis = 0; + if (messagesUpdated > 0) { + final long startTimeMillis = SystemClock.elapsedRealtime(); + final SyncMessageBatch batch = new SyncMessageBatch(smsToAdd, mmsToAdd, + messagesToDelete, syncManager.getThreadInfoCache()); + batch.updateLocalDatabase(); + final long endTimeMillis = SystemClock.elapsedRealtime(); + txnTimeMillis = endTimeMillis - startTimeMillis; + + LogUtil.i(TAG, "SyncMessagesAction: Updated local database " + + "(took " + txnTimeMillis + " ms). Added " + + smsToAdd.size() + " SMS, added " + mmsToAdd.size() + " MMS, deleted " + + messagesToDelete.size() + " messages."); + + // TODO: Investigate whether we can make this more fine-grained. + MessagingContentProvider.notifyEverythingChanged(); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: No local database updates to make"); + } + + if (!syncManager.getHasFirstSyncCompleted()) { + // If we have never completed a sync before (fresh install) and there are + // no messages, still inform the UI of a change so it can update syncing + // messages shown to the user + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyPartsChanged(); + } + } + // Determine if there are more messages that need to be scanned + if (lastTimestampMillis >= 0 && lastTimestampMillis >= lowerBoundTimeMillis) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: More messages to sync; scheduling next " + + "sync batch now."); + } + + // Include final millisecond of last sync in next sync + final long newUpperBoundTimeMillis = lastTimestampMillis + 1; + final int newMaxMessagesToUpdate = nextBatchSize(messagesUpdated, + txnTimeMillis); + + final SyncMessagesAction nextBatch = + new SyncMessagesAction(lowerBoundTimeMillis, newUpperBoundTimeMillis, + newMaxMessagesToUpdate, startTimestamp); + + // Proceed with next batch + syncManager.startSyncBatch(newUpperBoundTimeMillis); + requestBackgroundWork(nextBatch); + } else { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + // Save sync completion time so next sync will start from here + prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp); + if (lowerBoundTimeMillis < 0) { + // Remember last full sync so that don't start another full sync right away + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp); + } + + final long now = System.currentTimeMillis(); + + // After any sync check if new messages have arrived + final SyncCursorPair recents = new SyncCursorPair(startTimestamp, now); + final SyncCursorPair olders = new SyncCursorPair(-1L, startTimestamp); + final DatabaseWrapper db = DataModel.get().getDatabase(); + if (!recents.isSynchronized(db)) { + LogUtil.i(TAG, "SyncMessagesAction: Changed messages after sync; " + + "scheduling an incremental sync now."); + + // Just add a new batch for recent messages + final SyncMessagesAction nextBatch = + new SyncMessagesAction(startTimestamp, now, 0, startTimestamp); + syncManager.startSyncBatch(now); + requestBackgroundWork(nextBatch); + // After partial sync verify sync state + } else if (lowerBoundTimeMillis >= 0 && !olders.isSynchronized(db)) { + // Add a batch going back to start of time + LogUtil.w(TAG, "SyncMessagesAction: Changed messages before sync batch; " + + "scheduling a full sync now."); + + final SyncMessagesAction nextBatch = + new SyncMessagesAction(-1L, startTimestamp, 0, startTimestamp); + + syncManager.startSyncBatch(startTimestamp); + requestBackgroundWork(nextBatch); + } else { + LogUtil.i(TAG, "SyncMessagesAction: All messages now in sync"); + + // All done, in sync + syncManager.complete(); + } + } + // Either sync should be complete or we should have a follow up request + Assert.isTrue(hasBackgroundActions() || !syncManager.isSyncing()); + } + } + + return null; + } + + /** + * Decide the next batch size based on the stats we collected with past batch + * @param messagesUpdated number of messages updated in this batch + * @param txnTimeMillis time the transaction took in ms + * @return Target number of messages to sync for next batch + */ + private static int nextBatchSize(final int messagesUpdated, final long txnTimeMillis) { + final BugleGservices bugleGservices = BugleGservices.get(); + final long smsSyncSubsequentBatchTimeLimitMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS, + BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT); + + if (txnTimeMillis <= 0) { + return 0; + } + // Number of messages we can sync within the batch time limit using + // the average sync time calculated based on the stats we collected + // in previous batch + return (int) ((double) (messagesUpdated) / (double) txnTimeMillis + * smsSyncSubsequentBatchTimeLimitMillis); + } + + /** + * Batch loading MMS parts for the messages in current batch + */ + private void loadMmsParts(final LongSparseArray<MmsMessage> mmses) { + final Context context = Factory.get().getApplicationContext(); + final int totalIds = mmses.size(); + 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 != '%s' AND %s IN %s", + Mms.Part.CONTENT_TYPE, + ContentType.APP_SMIL, + Mms.Part.MSG_ID, + MmsUtils.getSqlInOperand(count)); + final String[] batchSelectionArgs = new String[count]; + for (int i = 0; i < count; i++) { + batchSelectionArgs[i] = Long.toString(mmses.valueAt(start + i).getId()); + } + final Cursor cursor = SqliteWrapper.query( + context, + context.getContentResolver(), + MmsUtils.MMS_PART_CONTENT_URI, + DatabaseMessages.MmsPart.PROJECTION, + batchSelection, + batchSelectionArgs, + null/*sortOrder*/); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + // Delay loading the media content for parsing for efficiency + // TODO: load the media and fill in the dimensions when + // we actually display it + final DatabaseMessages.MmsPart part = + DatabaseMessages.MmsPart.get(cursor, false/*loadMedia*/); + final DatabaseMessages.MmsMessage mms = mmses.get(part.mMessageId); + if (mms != null) { + mms.addPart(part); + } + } + } finally { + cursor.close(); + } + } + } + } + + /** + * Batch loading MMS sender for the messages in current batch + */ + private void setMmsSenders(final LongSparseArray<MmsMessage> mmses, + final ThreadInfoCache cache) { + // Store all the MMS messages + for (int i = 0; i < mmses.size(); i++) { + final MmsMessage mms = mmses.valueAt(i); + + final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX; + String senderId = null; + if (!isOutgoing) { + // We only need to find out sender phone number for received message + senderId = getMmsSender(mms, cache); + if (senderId == null) { + LogUtil.w(TAG, "SyncMessagesAction: Could not find sender of incoming MMS " + + "message " + mms.getUri() + "; using 'unknown sender' instead"); + senderId = ParticipantData.getUnknownSenderDestination(); + } + } + mms.setSender(senderId); + } + } + + /** + * Find out the sender of an MMS message + */ + private String getMmsSender(final MmsMessage mms, final ThreadInfoCache cache) { + final List<String> recipients = cache.getThreadRecipients(mms.mThreadId); + Assert.notNull(recipients); + Assert.isTrue(recipients.size() > 0); + + if (recipients.size() == 1 + && recipients.get(0).equals(ParticipantData.getUnknownSenderDestination())) { + LogUtil.w(TAG, "SyncMessagesAction: MMS message " + mms.mUri + " has unknown sender " + + "(thread id = " + mms.mThreadId + ")"); + } + + return MmsUtils.getMmsSender(recipients, mms.mUri); + } + + private SyncMessagesAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<SyncMessagesAction> CREATOR + = new Parcelable.Creator<SyncMessagesAction>() { + @Override + public SyncMessagesAction createFromParcel(final Parcel in) { + return new SyncMessagesAction(in); + } + + @Override + public SyncMessagesAction[] newArray(final int size) { + return new SyncMessagesAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java b/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java new file mode 100644 index 0000000..066ad74 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java @@ -0,0 +1,93 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.Assert; + +public class UpdateConversationArchiveStatusAction extends Action { + + public static void archiveConversation(final String conversationId) { + final UpdateConversationArchiveStatusAction action = + new UpdateConversationArchiveStatusAction(conversationId, true /* isArchive */); + action.start(); + } + + public static void unarchiveConversation(final String conversationId) { + final UpdateConversationArchiveStatusAction action = + new UpdateConversationArchiveStatusAction(conversationId, false /* isArchive */); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_IS_ARCHIVE = "is_archive"; + + protected UpdateConversationArchiveStatusAction( + final String conversationId, final boolean isArchive) { + Assert.isTrue(!TextUtils.isEmpty(conversationId)); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putBoolean(KEY_IS_ARCHIVE, isArchive); + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final boolean isArchived = actionParameters.getBoolean(KEY_IS_ARCHIVE); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationArchiveStatusInTransaction( + db, conversationId, isArchived); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return null; + } + + protected UpdateConversationArchiveStatusAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<UpdateConversationArchiveStatusAction> CREATOR + = new Parcelable.Creator<UpdateConversationArchiveStatusAction>() { + @Override + public UpdateConversationArchiveStatusAction createFromParcel(final Parcel in) { + return new UpdateConversationArchiveStatusAction(in); + } + + @Override + public UpdateConversationArchiveStatusAction[] newArray(final int size) { + return new UpdateConversationArchiveStatusAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java b/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java new file mode 100644 index 0000000..6c9e739 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java @@ -0,0 +1,156 @@ +/* + * 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.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.Assert; + +/** + * Action used to update conversation options such as notification settings. + */ +public class UpdateConversationOptionsAction extends Action + implements Parcelable { + /** + * Enable/disable conversation notifications. + */ + public static void enableConversationNotifications(final String conversationId, + final boolean enableNotification) { + Assert.notNull(conversationId); + + final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction( + conversationId, enableNotification, null, null); + action.start(); + } + + /** + * Sets conversation notification sound. + */ + public static void setConversationNotificationSound(final String conversationId, + final String ringtoneUri) { + Assert.notNull(conversationId); + + final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction( + conversationId, null, ringtoneUri, null); + action.start(); + } + + /** + * Enable/disable vibrations for conversation notification. + */ + public static void enableVibrationForConversationNotification(final String conversationId, + final boolean enableVibration) { + Assert.notNull(conversationId); + + final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction( + conversationId, null, null, enableVibration); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + + // Keys for all settable settings. + private static final String KEY_ENABLE_NOTIFICATION = "enable_notification"; + private static final String KEY_RINGTONE_URI = "ringtone_uri"; + private static final String KEY_ENABLE_VIBRATION = "enable_vibration"; + + protected UpdateConversationOptionsAction(final String conversationId, + final Boolean enableNotification, final String ringtoneUri, + final Boolean enableVibration) { + Assert.notNull(conversationId); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + if (enableNotification != null) { + actionParameters.putBoolean(KEY_ENABLE_NOTIFICATION, enableNotification); + } + + if (ringtoneUri != null) { + actionParameters.putString(KEY_RINGTONE_URI, ringtoneUri); + } + + if (enableVibration != null) { + actionParameters.putBoolean(KEY_ENABLE_VIBRATION, enableVibration); + } + } + + protected void putOptionValuesInTransaction(final ContentValues values, + final DatabaseWrapper dbWrapper) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + if (actionParameters.containsKey(KEY_ENABLE_NOTIFICATION)) { + values.put(ConversationColumns.NOTIFICATION_ENABLED, + actionParameters.getBoolean(KEY_ENABLE_NOTIFICATION)); + } + + if (actionParameters.containsKey(KEY_RINGTONE_URI)) { + values.put(ConversationColumns.NOTIFICATION_SOUND_URI, + actionParameters.getString(KEY_RINGTONE_URI)); + } + + if (actionParameters.containsKey(KEY_ENABLE_VIBRATION)) { + values.put(ConversationColumns.NOTIFICATION_VIBRATION, + actionParameters.getBoolean(KEY_ENABLE_VIBRATION)); + } + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + putOptionValuesInTransaction(values, db); + + BugleDatabaseOperations.updateConversationRowIfExists(db, conversationId, values); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return null; + } + + protected UpdateConversationOptionsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<UpdateConversationOptionsAction> CREATOR + = new Parcelable.Creator<UpdateConversationOptionsAction>() { + @Override + public UpdateConversationOptionsAction createFromParcel(final Parcel in) { + return new UpdateConversationOptionsAction(in); + } + + @Override + public UpdateConversationOptionsAction[] newArray(final int size) { + return new UpdateConversationOptionsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java b/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java new file mode 100644 index 0000000..c74096d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java @@ -0,0 +1,148 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.Assert; + +public class UpdateDestinationBlockedAction extends Action { + public interface UpdateDestinationBlockedActionListener { + @Assert.RunsOnMainThread + abstract void onUpdateDestinationBlockedAction(final UpdateDestinationBlockedAction action, + final boolean success, + final boolean block, + final String destination); + } + + public static class UpdateDestinationBlockedActionMonitor extends ActionMonitor + implements ActionMonitor.ActionCompletedListener { + private final UpdateDestinationBlockedActionListener mListener; + + public UpdateDestinationBlockedActionMonitor( + Object data, UpdateDestinationBlockedActionListener mListener) { + super(STATE_CREATED, generateUniqueActionKey("UpdateDestinationBlockedAction"), data); + setCompletedListener(this); + this.mListener = mListener; + } + + private void onActionDone(final boolean succeeded, + final ActionMonitor monitor, + final Action action, + final Object data, + final Object result) { + mListener.onUpdateDestinationBlockedAction( + (UpdateDestinationBlockedAction) action, + succeeded, + action.actionParameters.getBoolean(KEY_BLOCKED), + action.actionParameters.getString(KEY_DESTINATION)); + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, + final Object data, + final Object result) { + onActionDone(true, monitor, action, data, result); + } + + @Override + public void onActionFailed(final ActionMonitor monitor, + final Action action, + final Object data, + final Object result) { + onActionDone(false, monitor, action, data, result); + } + } + + + public static UpdateDestinationBlockedActionMonitor updateDestinationBlocked( + final String destination, final boolean blocked, final String conversationId, + final UpdateDestinationBlockedActionListener listener) { + Assert.notNull(listener); + final UpdateDestinationBlockedActionMonitor monitor = + new UpdateDestinationBlockedActionMonitor(null, listener); + final UpdateDestinationBlockedAction action = + new UpdateDestinationBlockedAction(destination, blocked, conversationId, + monitor.getActionKey()); + action.start(monitor); + return monitor; + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_DESTINATION = "destination"; + private static final String KEY_BLOCKED = "blocked"; + + protected UpdateDestinationBlockedAction( + final String destination, final boolean blocked, final String conversationId, + final String actionKey) { + super(actionKey); + Assert.isTrue(!TextUtils.isEmpty(destination)); + actionParameters.putString(KEY_DESTINATION, destination); + actionParameters.putBoolean(KEY_BLOCKED, blocked); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + } + + @Override + protected Object executeAction() { + final String destination = actionParameters.getString(KEY_DESTINATION); + final boolean isBlocked = actionParameters.getBoolean(KEY_BLOCKED); + String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final DatabaseWrapper db = DataModel.get().getDatabase(); + BugleDatabaseOperations.updateDestination(db, destination, isBlocked); + if (conversationId == null) { + conversationId = BugleDatabaseOperations + .getConversationFromOtherParticipantDestination(db, destination); + } + if (conversationId != null) { + if (isBlocked) { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + MessagingContentProvider.notifyParticipantsChanged(conversationId); + } + return null; + } + + protected UpdateDestinationBlockedAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<UpdateDestinationBlockedAction> CREATOR + = new Parcelable.Creator<UpdateDestinationBlockedAction>() { + @Override + public UpdateDestinationBlockedAction createFromParcel(final Parcel in) { + return new UpdateDestinationBlockedAction(in); + } + + @Override + public UpdateDestinationBlockedAction[] newArray(final int size) { + return new UpdateDestinationBlockedAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java b/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java new file mode 100644 index 0000000..94e6f3b --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java @@ -0,0 +1,63 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleNotifications; + +/** + * Updates the message notification (generally, to include voice replies we've + * made since the notification was first posted). + */ +public class UpdateMessageNotificationAction extends Action { + + public static void updateMessageNotification() { + new UpdateMessageNotificationAction().start(); + } + + private UpdateMessageNotificationAction() { + } + + @Override + protected Object executeAction() { + BugleNotifications.update(true /* silent */, BugleNotifications.UPDATE_MESSAGES); + return null; + } + + private UpdateMessageNotificationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<UpdateMessageNotificationAction> CREATOR + = new Parcelable.Creator<UpdateMessageNotificationAction>() { + @Override + public UpdateMessageNotificationAction createFromParcel(final Parcel in) { + return new UpdateMessageNotificationAction(in); + } + + @Override + public UpdateMessageNotificationAction[] newArray(final int size) { + return new UpdateMessageNotificationAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java b/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java new file mode 100644 index 0000000..273dce9 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java @@ -0,0 +1,103 @@ +/* + * 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.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.util.Assert; + +/** + * Action used to update size fields of a single part + */ +public class UpdateMessagePartSizeAction extends Action implements Parcelable { + /** + * Update size of part + */ + public static void updateSize(final String partId, final int width, final int height) { + Assert.notNull(partId); + Assert.inRange(width, 0, Integer.MAX_VALUE); + Assert.inRange(height, 0, Integer.MAX_VALUE); + + final UpdateMessagePartSizeAction action = new UpdateMessagePartSizeAction( + partId, width, height); + action.start(); + } + + private static final String KEY_PART_ID = "part_id"; + private static final String KEY_WIDTH = "width"; + private static final String KEY_HEIGHT = "height"; + + private UpdateMessagePartSizeAction(final String partId, final int width, final int height) { + actionParameters.putString(KEY_PART_ID, partId); + actionParameters.putInt(KEY_WIDTH, width); + actionParameters.putInt(KEY_HEIGHT, height); + } + + @Override + protected Object executeAction() { + final String partId = actionParameters.getString(KEY_PART_ID); + final int width = actionParameters.getInt(KEY_WIDTH); + final int height = actionParameters.getInt(KEY_HEIGHT); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + + values.put(PartColumns.WIDTH, width); + values.put(PartColumns.HEIGHT, height); + + // Part may have been deleted so allow update to fail without asserting + BugleDatabaseOperations.updateRowIfExists(db, DatabaseHelper.PARTS_TABLE, + PartColumns._ID, partId, values); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return null; + } + + private UpdateMessagePartSizeAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<UpdateMessagePartSizeAction> CREATOR + = new Parcelable.Creator<UpdateMessagePartSizeAction>() { + @Override + public UpdateMessagePartSizeAction createFromParcel(final Parcel in) { + return new UpdateMessagePartSizeAction(in); + } + + @Override + public UpdateMessagePartSizeAction[] newArray(final int size) { + return new UpdateMessagePartSizeAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java b/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java new file mode 100644 index 0000000..c1f39e1 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java @@ -0,0 +1,104 @@ +/* + * 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.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +public class WriteDraftMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Set draft message (no listener) + */ + public static void writeDraftMessage(final String conversationId, final MessageData message) { + final WriteDraftMessageAction action = new WriteDraftMessageAction(conversationId, message); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversationId"; + private static final String KEY_MESSAGE = "message"; + + private WriteDraftMessageAction(final String conversationId, final MessageData message) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putParcelable(KEY_MESSAGE, message); + } + + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + if (message.getSelfId() == null || message.getParticipantId() == null) { + // This could happen when this occurs before the draft message is loaded + // In this case, we just use the conversation's current self id as draft's + // self id and/or participant id + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + if (conversation != null) { + final String senderAndSelf = conversation.getSelfId(); + if (message.getSelfId() == null) { + message.bindSelfId(senderAndSelf); + } + if (message.getParticipantId() == null) { + message.bindParticipantId(senderAndSelf); + } + } else { + LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId + + "already deleted before saving draft message " + + message.getMessageId() + ". Aborting WriteDraftMessageAction."); + return null; + } + } + // Drafts are only kept in the local DB... + final String messageId = BugleDatabaseOperations.updateDraftMessageData( + db, conversationId, message, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return messageId; + } + + private WriteDraftMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator<WriteDraftMessageAction> CREATOR + = new Parcelable.Creator<WriteDraftMessageAction>() { + @Override + public WriteDraftMessageAction createFromParcel(final Parcel in) { + return new WriteDraftMessageAction(in); + } + + @Override + public WriteDraftMessageAction[] newArray(final int size) { + return new WriteDraftMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/binding/BindableData.java b/src/com/android/messaging/datamodel/binding/BindableData.java new file mode 100644 index 0000000..5446098 --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/BindableData.java @@ -0,0 +1,72 @@ +/* + * 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.binding; + +/** + * Base class for data objects that will be bound to a piece of the UI + */ +public abstract class BindableData { + /** + * Called by Binding during unbind to allow data to proactively unregister callbacks + * Data instance should release all listeners that may call back to the host UI + */ + protected abstract void unregisterListeners(); + + /** + * Key used to identify the piece of UI that the data is currently bound to + */ + private String mBindingId; + + /** + * Bind this data to the ui host - checks data is currently unbound + */ + public void bind(final String bindingId) { + if (isBound() || bindingId == null) { + throw new IllegalStateException(); + } + mBindingId = bindingId; + } + + /** + * Unbind this data from the ui host - checks that the data is currently bound to specified id + */ + public void unbind(final String bindingId) { + if (!isBound(bindingId)) { + throw new IllegalStateException(); + } + unregisterListeners(); + mBindingId = null; + } + + /** + * Check to see if the data is bound to anything + * + * TODO: This should be package private because it's supposed to only be used by Binding, + * however, several classes call this directly. We want the classes to track what they are + * bound to. + */ + protected boolean isBound() { + return (mBindingId != null); + } + + /** + * Check to see if data is still bound with specified bindingId before calling over to ui + */ + public boolean isBound(final String bindingId) { + return (bindingId.equals(mBindingId)); + } +} diff --git a/src/com/android/messaging/datamodel/binding/BindableOnceData.java b/src/com/android/messaging/datamodel/binding/BindableOnceData.java new file mode 100644 index 0000000..08e11da --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/BindableOnceData.java @@ -0,0 +1,42 @@ +/* + * 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.binding; + +/** + * A BindableData that's only used to be bound once. If the client needs to rebind, it needs + * to create a new instance of the BindableOnceData. + */ +public abstract class BindableOnceData extends BindableData { + private boolean boundOnce = false; + + @Override + public void bind(final String bindingId) { + // Ensures that we can't re-bind again after the first binding. + if (boundOnce) { + throw new IllegalStateException(); + } + super.bind(bindingId); + boundOnce = true; + } + + /** + * Checks if the instance is bound to anything. + */ + @Override + public boolean isBound() { + return super.isBound(); + } +} diff --git a/src/com/android/messaging/datamodel/binding/Binding.java b/src/com/android/messaging/datamodel/binding/Binding.java new file mode 100644 index 0000000..3ec01dd --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/Binding.java @@ -0,0 +1,94 @@ +/* + * 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.binding; + +import java.util.concurrent.atomic.AtomicLong; + +public class Binding<T extends BindableData> extends BindingBase<T> { + private static AtomicLong sBindingIdx = new AtomicLong(System.currentTimeMillis() * 1000); + + private String mBindingId; + private T mData; + private final Object mOwner; + private boolean mWasBound; + + /** + * Initialize a binding instance - the owner is typically the containing class + */ + Binding(final Object owner) { + mOwner = owner; + } + + @Override + public T getData() { + ensureBound(); + return mData; + } + + @Override + public boolean isBound() { + return (mData != null && mData.isBound(mBindingId)); + } + + @Override + public boolean isBound(final T data) { + return (isBound() && data == mData); + } + + @Override + public void ensureBound() { + if (!isBound()) { + throw new IllegalStateException("not bound; wasBound = " + mWasBound); + } + } + + @Override + public void ensureBound(final T data) { + if (!isBound()) { + throw new IllegalStateException("not bound; wasBound = " + mWasBound); + } else if (data != mData) { + throw new IllegalStateException("not bound to correct data " + data + " vs " + mData); + } + } + + @Override + public String getBindingId() { + return mBindingId; + } + + public void bind(final T data) { + // Check both this binding and the data not already bound + if (mData != null || data.isBound()) { + throw new IllegalStateException("already bound when binding to " + data); + } + // Generate a unique identifier for this bind call + mBindingId = Long.toHexString(sBindingIdx.getAndIncrement()); + data.bind(mBindingId); + mData = data; + mWasBound = true; + } + + public void unbind() { + // Check this binding is bound and that data is bound to this binding + if (mData == null || !mData.isBound(mBindingId)) { + throw new IllegalStateException("not bound when unbind"); + } + mData.unbind(mBindingId); + mData = null; + mBindingId = null; + } +} diff --git a/src/com/android/messaging/datamodel/binding/BindingBase.java b/src/com/android/messaging/datamodel/binding/BindingBase.java new file mode 100644 index 0000000..3d6da9b --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/BindingBase.java @@ -0,0 +1,84 @@ +/* + * 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.binding; + +/** + * The binding class keeps track of a binding between a ui component and an item of BindableData + * It allows each side to ensure that when it communicates with the other they are still bound + * together. + * NOTE: Ensure that the UI component uses the same binding instance for it's whole lifetime + * (DO NOT CREATE A NEW BINDING EACH TIME A NEW PIECE OF DATA IS BOUND)... + * + * The ui component owns the binding instance. + * It can use it [isBound(data)] to see if the binding still binds to the right piece of data + * + * Upon binding the data is informed of a unique binding key generated in this class and can use + * that to ensure that it is still issuing callbacks to the right piece of ui. + */ +public abstract class BindingBase<T extends BindableData> { + /** + * Creates a new exclusively owned binding for the owner object. + */ + public static <T extends BindableData> Binding<T> createBinding(final Object owner) { + return new Binding<T>(owner); + } + + /** + * Creates a new read-only binding referencing the source binding object. + * TODO: We may want to refcount the Binding references, so that when the binding owner + * calls unbind() when there's still outstanding references we can catch it. + */ + public static <T extends BindableData> ImmutableBindingRef<T> createBindingReference( + final BindingBase<T> srcBinding) { + return new ImmutableBindingRef<T>(srcBinding); + } + + /** + * Creates a detachable binding for the owner object. Use this if your owner object is a UI + * component that may undergo a "detached from window" -> "re-attached to window" transition. + */ + public static <T extends BindableData> DetachableBinding<T> createDetachableBinding( + final Object owner) { + return new DetachableBinding<T>(owner); + } + + public abstract T getData(); + + /** + * Check if binding connects to the specified data instance + */ + public abstract boolean isBound(); + + /** + * Check if binding connects to the specified data instance + */ + public abstract boolean isBound(final T data); + + /** + * Throw if binding connects to the specified data instance + */ + public abstract void ensureBound(); + + /** + * Throw if binding connects to the specified data instance + */ + public abstract void ensureBound(final T data); + + /** + * Return the binding id for this binding (will be null if not bound) + */ + public abstract String getBindingId(); +} diff --git a/src/com/android/messaging/datamodel/binding/DetachableBinding.java b/src/com/android/messaging/datamodel/binding/DetachableBinding.java new file mode 100644 index 0000000..a414c3b --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/DetachableBinding.java @@ -0,0 +1,56 @@ +/* + * 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.binding; + +import com.android.messaging.util.Assert; + +/** + * An extension on {@link Binding} that allows for temporary data detachment from the UI component. + * This is used when, instead of destruction or data rebinding, the owning UI undergoes a + * "detached from window" -> "re-attached to window" transition, in which case we want to + * temporarily unbind the data and remember it so that it can be rebound when the UI is re-attached + * to window later. + */ +public class DetachableBinding<T extends BindableData> extends Binding<T> { + private T mDetachedData; + + DetachableBinding(Object owner) { + super(owner); + } + + @Override + public void bind(T data) { + super.bind(data); + // Rebinding before re-attaching. Pre-emptively throw away the detached data because + // it's now stale. + mDetachedData = null; + } + + public void detach() { + Assert.isNull(mDetachedData); + Assert.isTrue(isBound()); + mDetachedData = getData(); + unbind(); + } + + public void reAttachIfPossible() { + if (mDetachedData != null) { + Assert.isFalse(isBound()); + bind(mDetachedData); + mDetachedData = null; + } + } +} diff --git a/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java b/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java new file mode 100644 index 0000000..9a0a3d6 --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java @@ -0,0 +1,83 @@ +/* + * 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.binding; + +import com.android.messaging.util.Assert; + +/** + * A immutable wrapper around a Binding object. Callers can only access readonly methods like + * getData(), isBound() and ensureBound() but not bind() and unbind(). This is used for MVC pattern + * where both the View and the Controller needs access to a centrally bound Model object. The View + * is the one that owns the bind/unbind logic of the Binding, whereas controller only serves as a + * consumer. + */ +public class ImmutableBindingRef<T extends BindableData> extends BindingBase<T> { + /** + * The referenced, read-only binding object. + */ + private final BindingBase<T> mBinding; + + /** + * Hidden ctor. + */ + ImmutableBindingRef(final BindingBase<T> binding) { + mBinding = resolveBinding(binding); + } + + @Override + public T getData() { + return mBinding.getData(); + } + + @Override + public boolean isBound() { + return mBinding.isBound(); + } + + @Override + public boolean isBound(final T data) { + return mBinding.isBound(data); + } + + @Override + public void ensureBound() { + mBinding.ensureBound(); + } + + @Override + public void ensureBound(final T data) { + mBinding.ensureBound(data); + } + + @Override + public String getBindingId() { + return mBinding.getBindingId(); + } + + /** + * Resolve the source binding to the real BindingImpl it's referencing. This avoids the + * redundancy of multiple wrapper calls when creating a binding reference from an existing + * binding reference. + */ + private BindingBase<T> resolveBinding(final BindingBase<T> binding) { + BindingBase<T> resolvedBinding = binding; + while (resolvedBinding instanceof ImmutableBindingRef<?>) { + resolvedBinding = ((ImmutableBindingRef<T>) resolvedBinding).mBinding; + } + Assert.isTrue(resolvedBinding instanceof Binding<?>); + return resolvedBinding; + } +} diff --git a/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java b/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java new file mode 100644 index 0000000..4e94ee1 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java @@ -0,0 +1,103 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; + +/** + * Services data needs for BlockedParticipantsFragment + */ +public class BlockedParticipantsData extends BindableData implements + LoaderManager.LoaderCallbacks<Cursor> { + public interface BlockedParticipantsDataListener { + public void onBlockedParticipantsCursorUpdated(final Cursor cursor); + } + private static final String BINDING_ID = "bindingId"; + private static final int BLOCKED_PARTICIPANTS_LOADER = 1; + private final Context mContext; + private LoaderManager mLoaderManager; + private BlockedParticipantsDataListener mListener; + + public BlockedParticipantsData(final Context context, + final BlockedParticipantsDataListener listener) { + mContext = context; + mListener = listener; + } + + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + Assert.isTrue(id == BLOCKED_PARTICIPANTS_LOADER); + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = MessagingContentProvider.PARTICIPANTS_URI; + return new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns.BLOCKED + "=1", null, null); + } + return null; + } + + @Override + public void onLoadFinished(final Loader<Cursor> loader, final Cursor cursor) { + Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER); + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + Assert.isTrue(isBound(cursorLoader.getBindingId())); + mListener.onBlockedParticipantsCursorUpdated(cursor); + } + + @Override + public void onLoaderReset(final Loader<Cursor> loader) { + Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER); + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + Assert.isTrue(isBound(cursorLoader.getBindingId())); + mListener.onBlockedParticipantsCursorUpdated(null); + } + + public void init(final LoaderManager loaderManager, + final BindingBase<BlockedParticipantsData> binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_LOADER); + mLoaderManager = null; + } + } + + public ParticipantListItemData createParticipantListItemData(Cursor cursor) { + return new ParticipantListItemData(ParticipantData.getFromCursor(cursor)); + } +} diff --git a/src/com/android/messaging/datamodel/data/ContactListItemData.java b/src/com/android/messaging/datamodel/data/ContactListItemData.java new file mode 100644 index 0000000..dcc7e20 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ContactListItemData.java @@ -0,0 +1,160 @@ +/* + * 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.data; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.DisplayNameSources; + +import com.android.ex.chips.RecipientEntry; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; + +/** + * Data model object used to power ContactListItemViews, which may be displayed either in + * our contact list, or in the chips UI search drop down presented by ContactDropdownLayouter. + */ +public class ContactListItemData { + // Keeps the contact data in the form of RecipientEntry that RecipientEditTextView can + // directly use. + private RecipientEntry mRecipientEntry; + + private CharSequence mStyledName; + private CharSequence mStyledDestination; + + // If this contact is the first in the list for its first letter, then this will be the + // first letter, otherwise this is null. + private String mAlphabetHeader; + + // Is the contact the only item in the list (happens when the user clicks on an + // existing chip for which we show full contact detail for the selected contact). + private boolean mSingleRecipient; + + /** + * Bind to a contact cursor in the contact list. + */ + public void bind(final Cursor cursor, final String alphabetHeader) { + final long dataId = cursor.getLong(ContactUtil.INDEX_DATA_ID); + final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + final String lookupKey = cursor.getString(ContactUtil.INDEX_LOOKUP_KEY); + final String displayName = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME); + final String photoThumbnailUri = cursor.getString(ContactUtil.INDEX_PHOTO_URI); + final String destination = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL); + final int destinationType = cursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE); + final String destinationLabel = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL); + mStyledName = null; + mStyledDestination = null; + mAlphabetHeader = alphabetHeader; + mSingleRecipient = false; + + // Check whether this contact is first level (i.e. whether it's the first entry of this + // contact in the contact list). + boolean isFirstLevel = true; + if (!cursor.isFirst() && cursor.moveToPrevious()) { + final long contactIdPrevious = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + if (contactId == contactIdPrevious) { + isFirstLevel = false; + } + cursor.moveToNext(); + } + + mRecipientEntry = ContactUtil.createRecipientEntry(displayName, + DisplayNameSources.STRUCTURED_NAME, destination, destinationType, destinationLabel, + contactId, lookupKey, dataId, photoThumbnailUri, isFirstLevel); + } + + /** + * Bind to a RecipientEntry produced by the chips text view in the search drop down, plus + * optional styled name & destination for showing bold search match. + */ + public void bind(final RecipientEntry entry, final CharSequence styledName, + final CharSequence styledDestination, final boolean singleRecipient) { + Assert.isTrue(entry.isValid()); + mRecipientEntry = entry; + mStyledName = styledName; + mStyledDestination = styledDestination; + mAlphabetHeader = null; + mSingleRecipient = singleRecipient; + } + + public CharSequence getDisplayName() { + final CharSequence displayName = mStyledName != null ? mStyledName : + ContactRecipientEntryUtils.getDisplayNameForContactList(mRecipientEntry); + return displayName == null ? "" : displayName; + } + + public Uri getPhotoThumbnailUri() { + return mRecipientEntry.getPhotoThumbnailUri() == null ? null : + mRecipientEntry.getPhotoThumbnailUri(); + } + + public CharSequence getDestination() { + final CharSequence destination = mStyledDestination != null ? + mStyledDestination : ContactRecipientEntryUtils.formatDestination(mRecipientEntry); + return destination == null ? "" : destination; + } + + public int getDestinationType() { + return mRecipientEntry.getDestinationType(); + } + + public String getDestinationLabel() { + return mRecipientEntry.getDestinationLabel(); + } + + public long getContactId() { + return mRecipientEntry.getContactId(); + } + + public String getLookupKey() { + return mRecipientEntry.getLookupKey(); + } + + /** + * Returns if this item is "first-level," i.e. whether it's the first entry of the contact + * that it represents in the list. For example, if John Smith has 3 different phone numbers, + * then the first number is considered first-level, while the other two are considered + * second-level. + */ + public boolean getIsFirstLevel() { + // Treat the item as first level if it's a top-level recipient entry, or if it's the only + // item in the list. + return mRecipientEntry.isFirstLevel() || mSingleRecipient; + } + + /** + * Returns if this item is simple, i.e. it has only avatar and a display name with phone number + * embedded so we can hide everything else. + */ + public boolean getIsSimpleContactItem() { + return ContactRecipientEntryUtils.isAvatarAndNumberOnlyContact(mRecipientEntry) || + ContactRecipientEntryUtils.isSendToDestinationContact(mRecipientEntry); + } + + public String getAlphabetHeader() { + return mAlphabetHeader; + } + + /** + * Returns a RecipientEntry instance readily usable by the RecipientEditTextView. + */ + public RecipientEntry getRecipientEntry() { + return mRecipientEntry; + } +} diff --git a/src/com/android/messaging/datamodel/data/ContactPickerData.java b/src/com/android/messaging/datamodel/data/ContactPickerData.java new file mode 100644 index 0000000..fd6fca0 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ContactPickerData.java @@ -0,0 +1,194 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.FrequentContactsCursorBuilder; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; + +/** + * Class to access phone contacts. + * The caller is responsible for ensuring that the app has READ_CONTACTS permission (see + * {@link ContactUtil#hasReadContactsPermission()}) before instantiating this class. + */ +public class ContactPickerData extends BindableData implements + LoaderManager.LoaderCallbacks<Cursor> { + public interface ContactPickerDataListener { + void onAllContactsCursorUpdated(Cursor data); + void onFrequentContactsCursorUpdated(Cursor data); + void onContactCustomColorLoaded(ContactPickerData data); + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private LoaderManager mLoaderManager; + private ContactPickerDataListener mListener; + private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder; + + public ContactPickerData(final Context context, final ContactPickerDataListener listener) { + mListener = listener; + mContext = context; + mFrequentContactsCursorBuilder = new FrequentContactsCursorBuilder(); + } + + private static final int ALL_CONTACTS_LOADER = 1; + private static final int FREQUENT_CONTACTS_LOADER = 2; + private static final int PARTICIPANT_LOADER = 3; + + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case ALL_CONTACTS_LOADER: + return ContactUtil.getPhones(mContext) + .createBoundCursorLoader(bindingId); + case FREQUENT_CONTACTS_LOADER: + return ContactUtil.getFrequentContacts(mContext) + .createBoundCursorLoader(bindingId); + case PARTICIPANT_LOADER: + return new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + default: + Assert.fail("Unknown loader id for contact picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the contacts list"); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case ALL_CONTACTS_LOADER: + mListener.onAllContactsCursorUpdated(data); + mFrequentContactsCursorBuilder.setAllContacts(data); + break; + case FREQUENT_CONTACTS_LOADER: + mFrequentContactsCursorBuilder.setFrequents(data); + break; + case PARTICIPANT_LOADER: + mListener.onContactCustomColorLoaded(this); + break; + default: + Assert.fail("Unknown loader id for contact picker!"); + break; + } + + if (loader.getId() != PARTICIPANT_LOADER) { + // The frequent contacts cursor to be used in the UI depends on results from both + // all contacts and frequent contacts loader, and we don't know which will finish + // first. Therefore, try to build the cursor and notify the listener if it's + // successfully built. + final Cursor frequentContactsCursor = mFrequentContactsCursorBuilder.build(); + if (frequentContactsCursor != null) { + mListener.onFrequentContactsCursorUpdated(frequentContactsCursor); + } + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the contacts list"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader<Cursor> loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case ALL_CONTACTS_LOADER: + mListener.onAllContactsCursorUpdated(null); + mFrequentContactsCursorBuilder.setAllContacts(null); + break; + case FREQUENT_CONTACTS_LOADER: + mListener.onFrequentContactsCursorUpdated(null); + mFrequentContactsCursorBuilder.setFrequents(null); + break; + case PARTICIPANT_LOADER: + mListener.onContactCustomColorLoaded(this); + break; + default: + Assert.fail("Unknown loader id for contact picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the contacts list"); + } + } + + public void init(final LoaderManager loaderManager, + final BindingBase<ContactPickerData> binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(ALL_CONTACTS_LOADER, args, this); + mLoaderManager.initLoader(FREQUENT_CONTACTS_LOADER, args, this); + mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER); + mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER); + mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager = null; + } + mFrequentContactsCursorBuilder.resetBuilder(); + } + + public static boolean isTooManyParticipants(final int participantCount) { + // When creating a conversation, the conversation will be created using the system's + // default SIM, so use the default MmsConfig's recipient limit. + return (participantCount > MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getRecipientLimit()); + } + + public static boolean getCanAddMoreParticipants(final int participantCount) { + // When creating a conversation, the conversation will be created using the system's + // default SIM, so use the default MmsConfig's recipient limit. + return (participantCount < MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getRecipientLimit()); + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationData.java b/src/com/android/messaging/datamodel/data/ConversationData.java new file mode 100644 index 0000000..d504928 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationData.java @@ -0,0 +1,849 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.database.sqlite.SQLiteFullException; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.common.contacts.DataUsageStatUpdater; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.datamodel.action.DeleteMessageAction; +import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.action.RedownloadMmsAction; +import com.android.messaging.datamodel.action.ResendMessageAction; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.widget.WidgetConversationProvider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class ConversationData extends BindableData { + + private static final String TAG = "bugle_datamodel"; + private static final String BINDING_ID = "bindingId"; + private static final long LAST_MESSAGE_TIMESTAMP_NaN = -1; + private static final int MESSAGE_COUNT_NaN = -1; + + /** + * Takes a conversation id and a list of message ids and computes the positions + * for each message. + */ + public List<Integer> getPositions(final String conversationId, final List<Long> ids) { + final ArrayList<Integer> result = new ArrayList<Integer>(); + + if (ids.isEmpty()) { + return result; + } + + final Cursor c = new ConversationData.ReversedCursor( + DataModel.get().getDatabase().rawQuery( + ConversationMessageData.getConversationMessageIdsQuerySql(), + new String [] { conversationId })); + if (c != null) { + try { + final Set<Long> idsSet = new HashSet<Long>(ids); + if (c.moveToLast()) { + do { + final long messageId = c.getLong(0); + if (idsSet.contains(messageId)) { + result.add(c.getPosition()); + } + } while (c.moveToPrevious()); + } + } finally { + c.close(); + } + } + Collections.sort(result); + return result; + } + + public interface ConversationDataListener { + public void onConversationMessagesCursorUpdated(ConversationData data, Cursor cursor, + @Nullable ConversationMessageData newestMessage, boolean isSync); + public void onConversationMetadataUpdated(ConversationData data); + public void closeConversation(String conversationId); + public void onConversationParticipantDataLoaded(ConversationData data); + public void onSubscriptionListDataLoaded(ConversationData data); + } + + private static class ReversedCursor extends CursorWrapper { + final int mCount; + + public ReversedCursor(final Cursor cursor) { + super(cursor); + mCount = cursor.getCount(); + } + + @Override + public boolean moveToPosition(final int position) { + return super.moveToPosition(mCount - position - 1); + } + + @Override + public int getPosition() { + return mCount - super.getPosition() - 1; + } + + @Override + public boolean isAfterLast() { + return super.isBeforeFirst(); + } + + @Override + public boolean isBeforeFirst() { + return super.isAfterLast(); + } + + @Override + public boolean isFirst() { + return super.isLast(); + } + + @Override + public boolean isLast() { + return super.isFirst(); + } + + @Override + public boolean move(final int offset) { + return super.move(-offset); + } + + @Override + public boolean moveToFirst() { + return super.moveToLast(); + } + + @Override + public boolean moveToLast() { + return super.moveToFirst(); + } + + @Override + public boolean moveToNext() { + return super.moveToPrevious(); + } + + @Override + public boolean moveToPrevious() { + return super.moveToNext(); + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class MetadataLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + Assert.equals(CONVERSATION_META_DATA_LOADER, id); + Loader<Cursor> loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = + MessagingContentProvider.buildConversationMetadataUri(mConversationId); + loader = new BoundCursorLoader(bindingId, mContext, uri, + ConversationListItemData.PROJECTION, null, null, null); + } else { + LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + if (data.moveToNext()) { + Assert.isTrue(data.getCount() == 1); + mConversationMetadata.bind(data); + mListeners.onConversationMetadataUpdated(ConversationData.this); + } else { + // Close the conversation, no meta data means conversation was deleted + LogUtil.w(TAG, "Meta data loader returned nothing for mConversationId = " + + mConversationId); + mListeners.closeConversation(mConversationId); + // Notify the widget the conversation is deleted so it can go into its + // configure state. + WidgetConversationProvider.notifyConversationDeleted( + Factory.get().getApplicationContext(), + mConversationId); + } + } else { + LogUtil.w(TAG, "Meta data loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader<Cursor> generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + // Clear the conversation meta data + mConversationMetadata = new ConversationListItemData(); + mListeners.onConversationMetadataUpdated(ConversationData.this); + } else { + LogUtil.w(TAG, "Meta data loader reset after unbinding mConversationId = " + + mConversationId); + } + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class MessagesLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + Assert.equals(CONVERSATION_MESSAGES_LOADER, id); + Loader<Cursor> loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = + MessagingContentProvider.buildConversationMessagesUri(mConversationId); + loader = new BoundCursorLoader(bindingId, mContext, uri, + ConversationMessageData.getProjection(), null, null, null); + mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + mMessageCount = MESSAGE_COUNT_NaN; + } else { + LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader<Cursor> generic, final Cursor rawData) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + // Check if we have a new message, or if we had a message sync. + ConversationMessageData newMessage = null; + boolean isSync = false; + Cursor data = null; + if (rawData != null) { + // Note that the cursor is sorted DESC so here we reverse it. + // This is a performance issue (improvement) for large cursors. + data = new ReversedCursor(rawData); + + final int messageCountOld = mMessageCount; + mMessageCount = data.getCount(); + final ConversationMessageData lastMessage = getLastMessage(data); + if (lastMessage != null) { + final long lastMessageTimestampOld = mLastMessageTimestamp; + mLastMessageTimestamp = lastMessage.getReceivedTimeStamp(); + final String lastMessageIdOld = mLastMessageId; + mLastMessageId = lastMessage.getMessageId(); + if (TextUtils.equals(lastMessageIdOld, mLastMessageId) && + messageCountOld < mMessageCount) { + // Last message stays the same (no incoming message) but message + // count increased, which means there has been a message sync. + isSync = true; + } else if (messageCountOld != MESSAGE_COUNT_NaN && // Ignore initial load + mLastMessageTimestamp != LAST_MESSAGE_TIMESTAMP_NaN && + mLastMessageTimestamp > lastMessageTimestampOld) { + newMessage = lastMessage; + } + } else { + mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + } + } else { + mMessageCount = MESSAGE_COUNT_NaN; + } + + mListeners.onConversationMessagesCursorUpdated(ConversationData.this, data, + newMessage, isSync); + } else { + LogUtil.w(TAG, "Messages loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader<Cursor> generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mListeners.onConversationMessagesCursorUpdated(ConversationData.this, null, null, + false); + mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + mMessageCount = MESSAGE_COUNT_NaN; + } else { + LogUtil.w(TAG, "Messages loader reset after unbinding mConversationId = " + + mConversationId); + } + } + + private ConversationMessageData getLastMessage(final Cursor cursor) { + if (cursor != null && cursor.getCount() > 0) { + final int position = cursor.getPosition(); + if (cursor.moveToLast()) { + final ConversationMessageData messageData = new ConversationMessageData(); + messageData.bind(cursor); + cursor.move(position); + return messageData; + } + } + return null; + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class ParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + Assert.equals(PARTICIPANT_LOADER, id); + Loader<Cursor> loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = + MessagingContentProvider.buildConversationParticipantsUri(mConversationId); + loader = new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + } else { + LogUtil.w(TAG, "Creating participant loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mParticipantData.bind(data); + mListeners.onConversationParticipantDataLoaded(ConversationData.this); + } else { + LogUtil.w(TAG, "Participant loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader<Cursor> generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mParticipantData.bind(null); + } else { + LogUtil.w(TAG, "Participant loader reset after unbinding mConversationId = " + + mConversationId); + } + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class SelfParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + Assert.equals(SELF_PARTICIPANT_LOADER, id); + Loader<Cursor> loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns.SUB_ID + " <> ?", + new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null); + } else { + LogUtil.w(TAG, "Creating self loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(data); + mSubscriptionListData.bind(mSelfParticipantsData.getSelfParticipants(true)); + mListeners.onSubscriptionListDataLoaded(ConversationData.this); + } else { + LogUtil.w(TAG, "Self loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader<Cursor> generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(null); + } else { + LogUtil.w(TAG, "Self loader reset after unbinding mConversationId = " + + mConversationId); + } + } + } + + private final ConversationDataEventDispatcher mListeners; + private final MetadataLoaderCallbacks mMetadataLoaderCallbacks; + private final MessagesLoaderCallbacks mMessagesLoaderCallbacks; + private final ParticipantLoaderCallbacks mParticipantsLoaderCallbacks; + private final SelfParticipantLoaderCallbacks mSelfParticipantLoaderCallbacks; + private final Context mContext; + private final String mConversationId; + private final ConversationParticipantsData mParticipantData; + private final SelfParticipantsData mSelfParticipantsData; + private ConversationListItemData mConversationMetadata; + private final SubscriptionListData mSubscriptionListData; + private LoaderManager mLoaderManager; + private long mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + private int mMessageCount = MESSAGE_COUNT_NaN; + private String mLastMessageId; + + public ConversationData(final Context context, final ConversationDataListener listener, + final String conversationId) { + Assert.isTrue(conversationId != null); + mContext = context; + mConversationId = conversationId; + mMetadataLoaderCallbacks = new MetadataLoaderCallbacks(); + mMessagesLoaderCallbacks = new MessagesLoaderCallbacks(); + mParticipantsLoaderCallbacks = new ParticipantLoaderCallbacks(); + mSelfParticipantLoaderCallbacks = new SelfParticipantLoaderCallbacks(); + mParticipantData = new ConversationParticipantsData(); + mConversationMetadata = new ConversationListItemData(); + mSelfParticipantsData = new SelfParticipantsData(); + mSubscriptionListData = new SubscriptionListData(context); + + mListeners = new ConversationDataEventDispatcher(); + mListeners.add(listener); + } + + @RunsOnMainThread + public void addConversationDataListener(final ConversationDataListener listener) { + Assert.isMainThread(); + mListeners.add(listener); + } + + public String getConversationName() { + return mConversationMetadata.getName(); + } + + public boolean getIsArchived() { + return mConversationMetadata.getIsArchived(); + } + + public String getIcon() { + return mConversationMetadata.getIcon(); + } + + public String getConversationId() { + return mConversationId; + } + + public void setFocus() { + DataModel.get().setFocusedConversation(mConversationId); + // As we are loading the conversation assume the user has read the messages... + // Do this late though so that it doesn't get in the way of other actions + BugleNotifications.markMessagesAsRead(mConversationId); + } + + public void unsetFocus() { + DataModel.get().setFocusedConversation(null); + } + + public boolean isFocused() { + return isBound() && DataModel.get().isFocusedConversation(mConversationId); + } + + private static final int CONVERSATION_META_DATA_LOADER = 1; + private static final int CONVERSATION_MESSAGES_LOADER = 2; + private static final int PARTICIPANT_LOADER = 3; + private static final int SELF_PARTICIPANT_LOADER = 4; + + public void init(final LoaderManager loaderManager, + final BindingBase<ConversationData> binding) { + // Remember the binding id so that loader callbacks can check if data is still bound + // to same ui component + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(CONVERSATION_META_DATA_LOADER, args, mMetadataLoaderCallbacks); + mLoaderManager.initLoader(CONVERSATION_MESSAGES_LOADER, args, mMessagesLoaderCallbacks); + mLoaderManager.initLoader(PARTICIPANT_LOADER, args, mParticipantsLoaderCallbacks); + mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, mSelfParticipantLoaderCallbacks); + } + + @Override + protected void unregisterListeners() { + mListeners.clear(); + // Make sure focus has moved away from this conversation + // TODO: May false trigger if destroy happens after "new" conversation is focused. + // Assert.isTrue(!DataModel.get().isFocusedConversation(mConversationId)); + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(CONVERSATION_META_DATA_LOADER); + mLoaderManager.destroyLoader(CONVERSATION_MESSAGES_LOADER); + mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER); + mLoaderManager = null; + } + } + + /** + * Gets the default self participant in the participant table (NOT the conversation's self). + * This is available as soon as self participant data is loaded. + */ + public ParticipantData getDefaultSelfParticipant() { + return mSelfParticipantsData.getDefaultSelfParticipant(); + } + + public List<ParticipantData> getSelfParticipants(final boolean activeOnly) { + return mSelfParticipantsData.getSelfParticipants(activeOnly); + } + + public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) { + return mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(activeOnly); + } + + public ParticipantData getSelfParticipantById(final String selfId) { + return mSelfParticipantsData.getSelfParticipantById(selfId); + } + + /** + * For a 1:1 conversation return the other (not self) participant (else null) + */ + public ParticipantData getOtherParticipant() { + return mParticipantData.getOtherParticipant(); + } + + /** + * Return true once the participants are loaded + */ + public boolean getParticipantsLoaded() { + return mParticipantData.isLoaded(); + } + + public void sendMessage(final BindingBase<ConversationData> binding, + final MessageData message) { + Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId())); + Assert.isTrue(binding.getData() == this); + + if (!OsUtil.isAtLeastL_MR1() || message.getSelfId() == null) { + InsertNewMessageAction.insertNewMessage(message); + } else { + final int systemDefaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId(); + if (systemDefaultSubId != ParticipantData.DEFAULT_SELF_SUB_ID && + mSelfParticipantsData.isDefaultSelf(message.getSelfId())) { + // Lock the sub selection to the system default SIM as soon as the user clicks on + // the send button to avoid races between this and when InsertNewMessageAction is + // actually executed on the data model thread, during which the user can potentially + // change the system default SIM in Settings. + InsertNewMessageAction.insertNewMessage(message, systemDefaultSubId); + } else { + InsertNewMessageAction.insertNewMessage(message); + } + } + // Update contacts so Frequents will reflect messaging activity. + if (!getParticipantsLoaded()) { + return; // oh well, not critical + } + final ArrayList<String> phones = new ArrayList<>(); + final ArrayList<String> emails = new ArrayList<>(); + for (final ParticipantData participant : mParticipantData) { + if (!participant.isSelf()) { + if (participant.isEmail()) { + emails.add(participant.getSendDestination()); + } else { + phones.add(participant.getSendDestination()); + } + } + } + + if (ContactUtil.hasReadContactsPermission()) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + final DataUsageStatUpdater updater = new DataUsageStatUpdater( + Factory.get().getApplicationContext()); + try { + if (!phones.isEmpty()) { + updater.updateWithPhoneNumber(phones); + } + if (!emails.isEmpty()) { + updater.updateWithAddress(emails); + } + } catch (final SQLiteFullException ex) { + LogUtil.w(TAG, "Unable to update contact", ex); + } + } + }); + } + } + + public void downloadMessage(final BindingBase<ConversationData> binding, + final String messageId) { + Assert.isTrue(binding.getData() == this); + Assert.notNull(messageId); + RedownloadMmsAction.redownloadMessage(messageId); + } + + public void resendMessage(final BindingBase<ConversationData> binding, final String messageId) { + Assert.isTrue(binding.getData() == this); + Assert.notNull(messageId); + ResendMessageAction.resendMessage(messageId); + } + + public void deleteMessage(final BindingBase<ConversationData> binding, final String messageId) { + Assert.isTrue(binding.getData() == this); + Assert.notNull(messageId); + DeleteMessageAction.deleteMessage(messageId); + } + + public void deleteConversation(final Binding<ConversationData> binding) { + Assert.isTrue(binding.getData() == this); + // If possible use timestamp of last message shown to delete only messages user is aware of + if (mConversationMetadata == null) { + DeleteConversationAction.deleteConversation(mConversationId, + System.currentTimeMillis()); + } else { + mConversationMetadata.deleteConversation(); + } + } + + public void archiveConversation(final BindingBase<ConversationData> binding) { + Assert.isTrue(binding.getData() == this); + UpdateConversationArchiveStatusAction.archiveConversation(mConversationId); + } + + public void unarchiveConversation(final BindingBase<ConversationData> binding) { + Assert.isTrue(binding.getData() == this); + UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId); + } + + public ConversationParticipantsData getParticipants() { + return mParticipantData; + } + + /** + * Returns a dialable phone number for the participant if we are in a 1-1 conversation. + * @return the participant phone number, or null if the phone number is not valid or if there + * are more than one participant. + */ + public String getParticipantPhoneNumber() { + final ParticipantData participant = this.getOtherParticipant(); + if (participant != null) { + final String phoneNumber = participant.getSendDestination(); + if (!TextUtils.isEmpty(phoneNumber) && MmsSmsUtils.isPhoneNumber(phoneNumber)) { + return phoneNumber; + } + } + return null; + } + + /** + * Create a message to be forwarded from an existing message. + */ + public MessageData createForwardedMessage(final ConversationMessageData message) { + final MessageData forwardedMessage = new MessageData(); + + final String originalSubject = + MmsUtils.cleanseMmsSubject(mContext.getResources(), message.getMmsSubject()); + if (!TextUtils.isEmpty(originalSubject)) { + forwardedMessage.setMmsSubject( + mContext.getResources().getString(R.string.message_fwd, originalSubject)); + } + + for (final MessagePartData part : message.getParts()) { + MessagePartData forwardedPart; + + // Depending on the part type, if it is text, we can directly create a text part; + // if it is attachment, then we need to create a pending attachment data out of it, so + // that we may persist the attachment locally in the scratch folder when the user picks + // a conversation to forward to. + if (part.isText()) { + forwardedPart = MessagePartData.createTextMessagePart(part.getText()); + } else { + final PendingAttachmentData pendingAttachmentData = PendingAttachmentData + .createPendingAttachmentData(part.getContentType(), part.getContentUri()); + forwardedPart = pendingAttachmentData; + } + forwardedMessage.addPart(forwardedPart); + } + return forwardedMessage; + } + + public int getNumberOfParticipantsExcludingSelf() { + return mParticipantData.getNumberOfParticipantsExcludingSelf(); + } + + /** + * Returns {@link com.android.messaging.datamodel.data.SubscriptionListData + * .SubscriptionListEntry} for a given self participant so UI can display SIM-related info + * (icon, name etc.) for multi-SIM. + */ + public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( + final String selfParticipantId, final boolean excludeDefault) { + return getSubscriptionEntryForSelfParticipant(selfParticipantId, excludeDefault, + mSubscriptionListData, mSelfParticipantsData); + } + + /** + * Returns {@link com.android.messaging.datamodel.data.SubscriptionListData + * .SubscriptionListEntry} for a given self participant so UI can display SIM-related info + * (icon, name etc.) for multi-SIM. + */ + public static SubscriptionListEntry getSubscriptionEntryForSelfParticipant( + final String selfParticipantId, final boolean excludeDefault, + final SubscriptionListData subscriptionListData, + final SelfParticipantsData selfParticipantsData) { + // SIM indicators are shown in the UI only if: + // 1. Framework has MSIM support AND + // 2. The device has had multiple *active* subscriptions. AND + // 3. The message's subscription is active. + if (OsUtil.isAtLeastL_MR1() && + selfParticipantsData.getSelfParticipantsCountExcludingDefault(true) > 1) { + return subscriptionListData.getActiveSubscriptionEntryBySelfId(selfParticipantId, + excludeDefault); + } + return null; + } + + public SubscriptionListData getSubscriptionListData() { + return mSubscriptionListData; + } + + /** + * A dummy implementation of {@link ConversationDataListener} so that subclasses may opt to + * implement some, but not all, of the interface methods. + */ + public static class SimpleConversationDataListener implements ConversationDataListener { + + @Override + public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor, + @Nullable + final + ConversationMessageData newestMessage, final boolean isSync) {} + + @Override + public void onConversationMetadataUpdated(final ConversationData data) {} + + @Override + public void closeConversation(final String conversationId) {} + + @Override + public void onConversationParticipantDataLoaded(final ConversationData data) {} + + @Override + public void onSubscriptionListDataLoaded(final ConversationData data) {} + + } + + private class ConversationDataEventDispatcher + extends ArrayList<ConversationDataListener> + implements ConversationDataListener { + + @Override + public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor, + @Nullable + final ConversationMessageData newestMessage, final boolean isSync) { + for (final ConversationDataListener listener : this) { + listener.onConversationMessagesCursorUpdated(data, cursor, newestMessage, isSync); + } + } + + @Override + public void onConversationMetadataUpdated(final ConversationData data) { + for (final ConversationDataListener listener : this) { + listener.onConversationMetadataUpdated(data); + } + } + + @Override + public void closeConversation(final String conversationId) { + for (final ConversationDataListener listener : this) { + listener.closeConversation(conversationId); + } + } + + @Override + public void onConversationParticipantDataLoaded(final ConversationData data) { + for (final ConversationDataListener listener : this) { + listener.onConversationParticipantDataLoaded(data); + } + } + + @Override + public void onSubscriptionListDataLoaded(final ConversationData data) { + for (final ConversationDataListener listener : this) { + listener.onSubscriptionListDataLoaded(data); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationListData.java b/src/com/android/messaging/datamodel/data/ConversationListData.java new file mode 100644 index 0000000..3d27ecd --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationListData.java @@ -0,0 +1,211 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns; +import com.android.messaging.receiver.SmsReceiver; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.HashSet; + +public class ConversationListData extends BindableData + implements LoaderManager.LoaderCallbacks<Cursor> { + + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final String BINDING_ID = "bindingId"; + public static final String SORT_ORDER = + ConversationListViewColumns.SORT_TIMESTAMP + " DESC"; + + private static final String WHERE_ARCHIVED = + "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)"; + public static final String WHERE_NOT_ARCHIVED = + "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)"; + + public interface ConversationListDataListener { + public void onConversationListCursorUpdated(ConversationListData data, Cursor cursor); + public void setBlockedParticipantsAvailable(boolean blockedAvailable); + } + + private ConversationListDataListener mListener; + private final Context mContext; + private final boolean mArchivedMode; + private LoaderManager mLoaderManager; + + public ConversationListData(final Context context, final ConversationListDataListener listener, + final boolean archivedMode) { + mListener = listener; + mContext = context; + mArchivedMode = archivedMode; + } + + private static final int CONVERSATION_LIST_LOADER = 1; + private static final int BLOCKED_PARTICIPANTS_AVAILABLE_LOADER = 2; + + private static final String[] BLOCKED_PARTICIPANTS_PROJECTION = new String[] { + ParticipantColumns._ID, + ParticipantColumns.NORMALIZED_DESTINATION, + }; + private static final int INDEX_BLOCKED_PARTICIPANTS_ID = 0; + private static final int INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION = 1; + + // all blocked participants + private final HashSet<String> mBlockedParticipants = new HashSet<String>(); + + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + Loader<Cursor> loader = null; + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER: + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + BLOCKED_PARTICIPANTS_PROJECTION, + ParticipantColumns.BLOCKED + "=1", null, null); + break; + case CONVERSATION_LIST_LOADER: + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.CONVERSATIONS_URI, + ConversationListItemData.PROJECTION, + mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED, + null, // selection args + SORT_ORDER); + break; + default: + Assert.fail("Unknown loader id"); + break; + } + } else { + LogUtil.w(TAG, "Creating loader after unbinding list"); + } + return loader; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + if (isBound(loader.getBindingId())) { + switch (loader.getId()) { + case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER: + mBlockedParticipants.clear(); + for (int i = 0; i < data.getCount(); i++) { + data.moveToPosition(i); + mBlockedParticipants.add(data.getString( + INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION)); + } + mListener.setBlockedParticipantsAvailable(data != null && data.getCount() > 0); + break; + case CONVERSATION_LIST_LOADER: + mListener.onConversationListCursorUpdated(this, data); + break; + default: + Assert.fail("Unknown loader id"); + break; + } + } else { + LogUtil.w(TAG, "Loader finished after unbinding list"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader<Cursor> generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + if (isBound(loader.getBindingId())) { + switch (loader.getId()) { + case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER: + mListener.setBlockedParticipantsAvailable(false); + break; + case CONVERSATION_LIST_LOADER: + mListener.onConversationListCursorUpdated(this, null); + break; + default: + Assert.fail("Unknown loader id"); + break; + } + } else { + LogUtil.w(TAG, "Loader reset after unbinding list"); + } + } + + private Bundle mArgs; + + public void init(final LoaderManager loaderManager, + final BindingBase<ConversationListData> binding) { + mArgs = new Bundle(); + mArgs.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(CONVERSATION_LIST_LOADER, mArgs, this); + mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER, mArgs, this); + } + + public void handleMessagesSeen() { + BugleNotifications.markAllMessagesAsSeen(); + + SmsReceiver.cancelSecondaryUserNotification(); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(CONVERSATION_LIST_LOADER); + mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER); + mLoaderManager = null; + } + } + + public boolean getHasFirstSyncCompleted() { + final SyncManager syncManager = DataModel.get().getSyncManager(); + return syncManager.getHasFirstSyncCompleted(); + } + + public void setScrolledToNewestConversation(boolean scrolledToNewestConversation) { + DataModel.get().setConversationListScrolledToNewestConversation( + scrolledToNewestConversation); + if (scrolledToNewestConversation) { + handleMessagesSeen(); + } + } + + public HashSet<String> getBlockedParticipants() { + return mBlockedParticipants; + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationListItemData.java b/src/com/android/messaging/datamodel/data/ConversationListItemData.java new file mode 100644 index 0000000..b2e6e1c --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationListItemData.java @@ -0,0 +1,510 @@ +/* + * 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.data; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.text.TextUtils; + +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.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Dates; +import com.google.common.base.Joiner; + +import java.util.ArrayList; +import java.util.List; + +/** + * Class wrapping the conversation list view used to display each item in conversation list + */ +public class ConversationListItemData { + private String mConversationId; + private String mName; + private String mIcon; + private boolean mIsRead; + private long mTimestamp; + private String mSnippetText; + private Uri mPreviewUri; + private String mPreviewContentType; + private long mParticipantContactId; + private String mParticipantLookupKey; + private String mOtherParticipantNormalizedDestination; + private String mSelfId; + private int mParticipantCount; + private boolean mNotificationEnabled; + private String mNotificationSoundUri; + private boolean mNotificationVibrate; + private boolean mIncludeEmailAddress; + private int mMessageStatus; + private int mMessageRawTelephonyStatus; + private boolean mShowDraft; + private Uri mDraftPreviewUri; + private String mDraftPreviewContentType; + private String mDraftSnippetText; + private boolean mIsArchived; + private String mSubject; + private String mDraftSubject; + private String mSnippetSenderFirstName; + private String mSnippetSenderDisplayDestination; + + public ConversationListItemData() { + } + + public void bind(final Cursor cursor) { + bind(cursor, false); + } + + public void bind(final Cursor cursor, final boolean ignoreDraft) { + mConversationId = cursor.getString(INDEX_ID); + mName = cursor.getString(INDEX_CONVERSATION_NAME); + mIcon = cursor.getString(INDEX_CONVERSATION_ICON); + mSnippetText = cursor.getString(INDEX_SNIPPET_TEXT); + mTimestamp = cursor.getLong(INDEX_SORT_TIMESTAMP); + mIsRead = cursor.getInt(INDEX_READ) == 1; + final String previewUriString = cursor.getString(INDEX_PREVIEW_URI); + mPreviewUri = TextUtils.isEmpty(previewUriString) ? null : Uri.parse(previewUriString); + mPreviewContentType = cursor.getString(INDEX_PREVIEW_CONTENT_TYPE); + mParticipantContactId = cursor.getLong(INDEX_PARTICIPANT_CONTACT_ID); + mParticipantLookupKey = cursor.getString(INDEX_PARTICIPANT_LOOKUP_KEY); + mOtherParticipantNormalizedDestination = cursor.getString( + INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION); + mSelfId = cursor.getString(INDEX_SELF_ID); + mParticipantCount = cursor.getInt(INDEX_PARTICIPANT_COUNT); + mNotificationEnabled = cursor.getInt(INDEX_NOTIFICATION_ENABLED) == 1; + mNotificationSoundUri = cursor.getString(INDEX_NOTIFICATION_SOUND_URI); + mNotificationVibrate = cursor.getInt(INDEX_NOTIFICATION_VIBRATION) == 1; + mIncludeEmailAddress = cursor.getInt(INDEX_INCLUDE_EMAIL_ADDRESS) == 1; + mMessageStatus = cursor.getInt(INDEX_MESSAGE_STATUS); + mMessageRawTelephonyStatus = cursor.getInt(INDEX_MESSAGE_RAW_TELEPHONY_STATUS); + if (!ignoreDraft) { + mShowDraft = cursor.getInt(INDEX_SHOW_DRAFT) == 1; + final String draftPreviewUriString = cursor.getString(INDEX_DRAFT_PREVIEW_URI); + mDraftPreviewUri = TextUtils.isEmpty(draftPreviewUriString) ? + null : Uri.parse(draftPreviewUriString); + mDraftPreviewContentType = cursor.getString(INDEX_DRAFT_PREVIEW_CONTENT_TYPE); + mDraftSnippetText = cursor.getString(INDEX_DRAFT_SNIPPET_TEXT); + mDraftSubject = cursor.getString(INDEX_DRAFT_SUBJECT_TEXT); + } else { + mShowDraft = false; + mDraftPreviewUri = null; + mDraftPreviewContentType = null; + mDraftSnippetText = null; + mDraftSubject = null; + } + + mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1; + mSubject = cursor.getString(INDEX_SUBJECT_TEXT); + mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME); + mSnippetSenderDisplayDestination = + cursor.getString(INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION); + } + + public String getConversationId() { + return mConversationId; + } + + public String getName() { + return mName; + } + + public String getIcon() { + return mIcon; + } + + public boolean getIsRead() { + return mIsRead; + } + + public String getFormattedTimestamp() { + return Dates.getConversationTimeString(mTimestamp).toString(); + } + + public long getTimestamp() { + return mTimestamp; + } + + public String getSnippetText() { + return mSnippetText; + } + + public Uri getPreviewUri() { + return mPreviewUri; + } + + public String getPreviewContentType() { + return mPreviewContentType; + } + + public long getParticipantContactId() { + return mParticipantContactId; + } + + public String getParticipantLookupKey() { + return mParticipantLookupKey; + } + + public String getOtherParticipantNormalizedDestination() { + return mOtherParticipantNormalizedDestination; + } + + public String getSelfId() { + return mSelfId; + } + + public int getParticipantCount() { + return mParticipantCount; + } + + public boolean getIsGroup() { + // Participant count excludes self + return (mParticipantCount > 1); + } + + public boolean getIncludeEmailAddress() { + return mIncludeEmailAddress; + } + + public boolean getNotificationEnabled() { + return mNotificationEnabled; + } + + public String getNotificationSoundUri() { + return mNotificationSoundUri; + } + + public boolean getNotifiationVibrate() { + return mNotificationVibrate; + } + + public final boolean getIsFailedStatus() { + return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER || + mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED || + mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE); + } + + public final boolean getIsSendRequested() { + return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_SENDING || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_RESENDING); + } + + public boolean getIsMessageTypeOutgoing() { + return !MessageData.getIsIncoming(mMessageStatus); + } + + public int getMessageRawTelephonyStatus() { + return mMessageRawTelephonyStatus; + } + + public int getMessageStatus() { + return mMessageStatus; + } + + public boolean getShowDraft() { + return mShowDraft; + } + + public String getDraftSnippetText() { + return mDraftSnippetText; + } + + public Uri getDraftPreviewUri() { + return mDraftPreviewUri; + } + + public String getDraftPreviewContentType() { + return mDraftPreviewContentType; + } + + public boolean getIsArchived() { + return mIsArchived; + } + + public String getSubject() { + return mSubject; + } + + public String getDraftSubject() { + return mDraftSubject; + } + + public String getSnippetSenderName() { + if (!TextUtils.isEmpty(mSnippetSenderFirstName)) { + return mSnippetSenderFirstName; + } + return mSnippetSenderDisplayDestination; + } + + public void deleteConversation() { + DeleteConversationAction.deleteConversation(mConversationId, mTimestamp); + } + + /** + * Get the name of the view for this data item + */ + public static final String getConversationListView() { + return CONVERSATION_LIST_VIEW; + } + + public static final String getConversationListViewSql() { + return CONVERSATION_LIST_VIEW_SQL; + } + + private static final String CONVERSATION_LIST_VIEW = "conversation_list_view"; + + private static final String CONVERSATION_LIST_VIEW_PROJECTION = + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns._ID + + " as " + ConversationListViewColumns._ID + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NAME + + " as " + ConversationListViewColumns.NAME + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.CURRENT_SELF_ID + + " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS + + " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ + + " as " + ConversationListViewColumns.READ + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON + + " as " + ConversationListViewColumns.ICON + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_CONTACT_ID + + " as " + ConversationListViewColumns.PARTICIPANT_CONTACT_ID + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_LOOKUP_KEY + + " as " + ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + + " as " + ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SORT_TIMESTAMP + + " as " + ConversationListViewColumns.SORT_TIMESTAMP + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SHOW_DRAFT + + " as " + ConversationListViewColumns.SHOW_DRAFT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SNIPPET_TEXT + + " as " + ConversationListViewColumns.DRAFT_SNIPPET_TEXT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_PREVIEW_URI + + " as " + ConversationListViewColumns.DRAFT_PREVIEW_URI + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SUBJECT_TEXT + + " as " + ConversationListViewColumns.DRAFT_SUBJECT_TEXT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + + " as " + ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_URI + + " as " + ConversationListViewColumns.PREVIEW_URI + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_CONTENT_TYPE + + " as " + ConversationListViewColumns.PREVIEW_CONTENT_TYPE + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_COUNT + + " as " + ConversationListViewColumns.PARTICIPANT_COUNT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_ENABLED + + " as " + ConversationListViewColumns.NOTIFICATION_ENABLED + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_SOUND_URI + + " as " + ConversationListViewColumns.NOTIFICATION_SOUND_URI + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_VIBRATION + + " as " + ConversationListViewColumns.NOTIFICATION_VIBRATION + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.INCLUDE_EMAIL_ADDRESS + + " as " + ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS + + " as " + ConversationListViewColumns.MESSAGE_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS + + " as " + ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + + " as " + ConversationListViewColumns.MESSAGE_ID + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME + + " as " + ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + + " as " + ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION; + + private static final String JOIN_PARTICIPANTS = + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON (" + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + DatabaseHelper.ParticipantColumns._ID + + ") "; + + // View that makes latest message read flag available with rest of conversation data. + private static final String CONVERSATION_LIST_VIEW_SQL = "CREATE VIEW " + + CONVERSATION_LIST_VIEW + " AS SELECT " + + CONVERSATION_LIST_VIEW_PROJECTION + ", " + // Snippet not part of the base projection shared with search view + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SNIPPET_TEXT + + " as " + ConversationListViewColumns.SNIPPET_TEXT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SUBJECT_TEXT + + " as " + ConversationListViewColumns.SUBJECT_TEXT + " " + + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " LEFT JOIN " + DatabaseHelper.MESSAGES_TABLE + " ON (" + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.LATEST_MESSAGE_ID + + '=' + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + ") " + + JOIN_PARTICIPANTS + + "ORDER BY " + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.SORT_TIMESTAMP + " DESC"; + + public static class ConversationListViewColumns implements BaseColumns { + public static final String _ID = ConversationColumns._ID; + static final String NAME = ConversationColumns.NAME; + static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS; + static final String READ = MessageColumns.READ; + static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP; + static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI; + static final String PREVIEW_CONTENT_TYPE = ConversationColumns.PREVIEW_CONTENT_TYPE; + static final String SNIPPET_TEXT = ConversationColumns.SNIPPET_TEXT; + static final String SUBJECT_TEXT = ConversationColumns.SUBJECT_TEXT; + static final String ICON = ConversationColumns.ICON; + static final String SHOW_DRAFT = ConversationColumns.SHOW_DRAFT; + static final String DRAFT_SUBJECT_TEXT = ConversationColumns.DRAFT_SUBJECT_TEXT; + static final String DRAFT_PREVIEW_URI = ConversationColumns.DRAFT_PREVIEW_URI; + static final String DRAFT_PREVIEW_CONTENT_TYPE = + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE; + static final String DRAFT_SNIPPET_TEXT = ConversationColumns.DRAFT_SNIPPET_TEXT; + static final String PARTICIPANT_CONTACT_ID = ConversationColumns.PARTICIPANT_CONTACT_ID; + static final String PARTICIPANT_LOOKUP_KEY = ConversationColumns.PARTICIPANT_LOOKUP_KEY; + static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION = + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION; + static final String CURRENT_SELF_ID = ConversationColumns.CURRENT_SELF_ID; + static final String PARTICIPANT_COUNT = ConversationColumns.PARTICIPANT_COUNT; + static final String NOTIFICATION_ENABLED = ConversationColumns.NOTIFICATION_ENABLED; + static final String NOTIFICATION_SOUND_URI = ConversationColumns.NOTIFICATION_SOUND_URI; + static final String NOTIFICATION_VIBRATION = ConversationColumns.NOTIFICATION_VIBRATION; + static final String INCLUDE_EMAIL_ADDRESS = + ConversationColumns.INCLUDE_EMAIL_ADDRESS; + static final String MESSAGE_STATUS = MessageColumns.STATUS; + static final String MESSAGE_RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS; + static final String MESSAGE_ID = "message_id"; + static final String SNIPPET_SENDER_FIRST_NAME = "snippet_sender_first_name"; + static final String SNIPPET_SENDER_DISPLAY_DESTINATION = + "snippet_sender_display_destination"; + } + + public static final String[] PROJECTION = { + ConversationListViewColumns._ID, + ConversationListViewColumns.NAME, + ConversationListViewColumns.ICON, + ConversationListViewColumns.SNIPPET_TEXT, + ConversationListViewColumns.SORT_TIMESTAMP, + ConversationListViewColumns.READ, + ConversationListViewColumns.PREVIEW_URI, + ConversationListViewColumns.PREVIEW_CONTENT_TYPE, + ConversationListViewColumns.PARTICIPANT_CONTACT_ID, + ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY, + ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, + ConversationListViewColumns.PARTICIPANT_COUNT, + ConversationListViewColumns.CURRENT_SELF_ID, + ConversationListViewColumns.NOTIFICATION_ENABLED, + ConversationListViewColumns.NOTIFICATION_SOUND_URI, + ConversationListViewColumns.NOTIFICATION_VIBRATION, + ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS, + ConversationListViewColumns.MESSAGE_STATUS, + ConversationListViewColumns.SHOW_DRAFT, + ConversationListViewColumns.DRAFT_PREVIEW_URI, + ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE, + ConversationListViewColumns.DRAFT_SNIPPET_TEXT, + ConversationListViewColumns.ARCHIVE_STATUS, + ConversationListViewColumns.MESSAGE_ID, + ConversationListViewColumns.SUBJECT_TEXT, + ConversationListViewColumns.DRAFT_SUBJECT_TEXT, + ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS, + ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME, + ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION, + }; + + private static final int INDEX_ID = 0; + private static final int INDEX_CONVERSATION_NAME = 1; + private static final int INDEX_CONVERSATION_ICON = 2; + private static final int INDEX_SNIPPET_TEXT = 3; + private static final int INDEX_SORT_TIMESTAMP = 4; + private static final int INDEX_READ = 5; + private static final int INDEX_PREVIEW_URI = 6; + private static final int INDEX_PREVIEW_CONTENT_TYPE = 7; + private static final int INDEX_PARTICIPANT_CONTACT_ID = 8; + private static final int INDEX_PARTICIPANT_LOOKUP_KEY = 9; + private static final int INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION = 10; + private static final int INDEX_PARTICIPANT_COUNT = 11; + private static final int INDEX_SELF_ID = 12; + private static final int INDEX_NOTIFICATION_ENABLED = 13; + private static final int INDEX_NOTIFICATION_SOUND_URI = 14; + private static final int INDEX_NOTIFICATION_VIBRATION = 15; + private static final int INDEX_INCLUDE_EMAIL_ADDRESS = 16; + private static final int INDEX_MESSAGE_STATUS = 17; + private static final int INDEX_SHOW_DRAFT = 18; + private static final int INDEX_DRAFT_PREVIEW_URI = 19; + private static final int INDEX_DRAFT_PREVIEW_CONTENT_TYPE = 20; + private static final int INDEX_DRAFT_SNIPPET_TEXT = 21; + private static final int INDEX_ARCHIVE_STATUS = 22; + private static final int INDEX_MESSAGE_ID = 23; + private static final int INDEX_SUBJECT_TEXT = 24; + private static final int INDEX_DRAFT_SUBJECT_TEXT = 25; + private static final int INDEX_MESSAGE_RAW_TELEPHONY_STATUS = 26; + private static final int INDEX_SNIPPET_SENDER_FIRST_NAME = 27; + private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 28; + + private static final String DIVIDER_TEXT = ", "; + + /** + * Get a conversation from the local DB based on the conversation id. + * + * @param dbWrapper The database + * @param conversationId The conversation Id to read + * @return The existing conversation or null + */ + public static ConversationListItemData getExistingConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + ConversationListItemData conversation = null; + + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + // TODO: Should we be able to read a row from just the conversation table? + cursor = dbWrapper.query(getConversationListView(), + PROJECTION, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + conversation = new ConversationListItemData(); + conversation.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return conversation; + } + + public static String generateConversationName(final List<ParticipantData> + participants) { + if (participants.size() == 1) { + // Prefer full name over first name for 1:1 conversation + return participants.get(0).getDisplayName(true); + } + + final ArrayList<String> participantNames = new ArrayList<String>(); + for (final ParticipantData participant : participants) { + // Prefer first name over full name for group conversation + participantNames.add(participant.getDisplayName(false)); + } + + final Joiner joiner = Joiner.on(DIVIDER_TEXT).skipNulls(); + return joiner.join(participantNames); + } + +} diff --git a/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java b/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java new file mode 100644 index 0000000..f329f46 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java @@ -0,0 +1,37 @@ +/* + * 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.data; + +import android.text.TextUtils; + +/** + * Holds data for conversation message bubble which keeps track of whether it's been bound to + * a new message. + */ +public class ConversationMessageBubbleData { + private String mMessageId; + + /** + * Binds to ConversationMessageData instance. + * @return true if we are binding to a different message, false if we are binding to the + * same message (e.g. in order to update the status text) + */ + public boolean bind(final ConversationMessageData data) { + final boolean changed = !TextUtils.equals(mMessageId, data.getMessageId()); + mMessageId = data.getMessageId(); + return changed; + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationMessageData.java b/src/com/android/messaging/datamodel/data/ConversationMessageData.java new file mode 100644 index 0000000..19e1b97 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationMessageData.java @@ -0,0 +1,917 @@ +/* + * 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.data; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.text.TextUtils; +import android.text.format.DateUtils; + +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.Dates; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Predicate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Class representing a message within a conversation sequence. The message parts + * are available via the getParts() method. + * + * TODO: See if we can delegate to MessageData for the logic that this class duplicates + * (e.g. getIsMms). + */ +public class ConversationMessageData { + private static final String TAG = LogUtil.BUGLE_TAG; + + private String mMessageId; + private String mConversationId; + private String mParticipantId; + private int mPartsCount; + private List<MessagePartData> mParts; + private long mSentTimestamp; + private long mReceivedTimestamp; + private boolean mSeen; + private boolean mRead; + private int mProtocol; + private int mStatus; + private String mSmsMessageUri; + private int mSmsPriority; + private int mSmsMessageSize; + private String mMmsSubject; + private long mMmsExpiry; + private int mRawTelephonyStatus; + private String mSenderFullName; + private String mSenderFirstName; + private String mSenderDisplayDestination; + private String mSenderNormalizedDestination; + private String mSenderProfilePhotoUri; + private long mSenderContactId; + private String mSenderContactLookupKey; + private String mSelfParticipantId; + + /** Are we similar enough to the previous/next messages that we can cluster them? */ + private boolean mCanClusterWithPreviousMessage; + private boolean mCanClusterWithNextMessage; + + public ConversationMessageData() { + } + + public void bind(final Cursor cursor) { + mMessageId = cursor.getString(INDEX_MESSAGE_ID); + mConversationId = cursor.getString(INDEX_CONVERSATION_ID); + mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID); + mPartsCount = cursor.getInt(INDEX_PARTS_COUNT); + + mParts = makeParts( + cursor.getString(INDEX_PARTS_IDS), + cursor.getString(INDEX_PARTS_CONTENT_TYPES), + cursor.getString(INDEX_PARTS_CONTENT_URIS), + cursor.getString(INDEX_PARTS_WIDTHS), + cursor.getString(INDEX_PARTS_HEIGHTS), + cursor.getString(INDEX_PARTS_TEXTS), + mPartsCount, + mMessageId); + + mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP); + mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP); + mSeen = (cursor.getInt(INDEX_SEEN) != 0); + mRead = (cursor.getInt(INDEX_READ) != 0); + mProtocol = cursor.getInt(INDEX_PROTOCOL); + mStatus = cursor.getInt(INDEX_STATUS); + mSmsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI); + mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY); + mSmsMessageSize = cursor.getInt(INDEX_SMS_MESSAGE_SIZE); + mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT); + mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY); + mRawTelephonyStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS); + mSenderFullName = cursor.getString(INDEX_SENDER_FULL_NAME); + mSenderFirstName = cursor.getString(INDEX_SENDER_FIRST_NAME); + mSenderDisplayDestination = cursor.getString(INDEX_SENDER_DISPLAY_DESTINATION); + mSenderNormalizedDestination = cursor.getString(INDEX_SENDER_NORMALIZED_DESTINATION); + mSenderProfilePhotoUri = cursor.getString(INDEX_SENDER_PROFILE_PHOTO_URI); + mSenderContactId = cursor.getLong(INDEX_SENDER_CONTACT_ID); + mSenderContactLookupKey = cursor.getString(INDEX_SENDER_CONTACT_LOOKUP_KEY); + mSelfParticipantId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID); + + if (!cursor.isFirst() && cursor.moveToPrevious()) { + mCanClusterWithPreviousMessage = canClusterWithMessage(cursor); + cursor.moveToNext(); + } else { + mCanClusterWithPreviousMessage = false; + } + if (!cursor.isLast() && cursor.moveToNext()) { + mCanClusterWithNextMessage = canClusterWithMessage(cursor); + cursor.moveToPrevious(); + } else { + mCanClusterWithNextMessage = false; + } + } + + private boolean canClusterWithMessage(final Cursor cursor) { + final String otherParticipantId = cursor.getString(INDEX_PARTICIPANT_ID); + if (!TextUtils.equals(getParticipantId(), otherParticipantId)) { + return false; + } + final int otherStatus = cursor.getInt(INDEX_STATUS); + final boolean otherIsIncoming = (otherStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING); + if (getIsIncoming() != otherIsIncoming) { + return false; + } + final long otherReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP); + final long timestampDeltaMillis = Math.abs(mReceivedTimestamp - otherReceivedTimestamp); + if (timestampDeltaMillis > DateUtils.MINUTE_IN_MILLIS) { + return false; + } + final String otherSelfId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID); + if (!TextUtils.equals(getSelfParticipantId(), otherSelfId)) { + return false; + } + return true; + } + + private static final Character QUOTE_CHAR = '\''; + private static final char DIVIDER = '|'; + + // statics to avoid unnecessary object allocation + private static final StringBuilder sUnquoteStringBuilder = new StringBuilder(); + private static final ArrayList<String> sUnquoteResults = new ArrayList<String>(); + + // this lock is used to guard access to the above statics + private static final Object sUnquoteLock = new Object(); + + private static void addResult(final ArrayList<String> results, final StringBuilder value) { + if (value.length() > 0) { + results.add(value.toString()); + } else { + results.add(EMPTY_STRING); + } + } + + @VisibleForTesting + static String[] splitUnquotedString(final String inputString) { + if (TextUtils.isEmpty(inputString)) { + return new String[0]; + } + + return inputString.split("\\" + DIVIDER); + } + + /** + * Takes a group-concated and quoted string and decomposes it into its constituent + * parts. A quoted string starts and ends with a single quote. Actual single quotes + * within the string are escaped using a second single quote. So, for example, an + * input string with 3 constituent parts might look like this: + * + * 'now is the time'|'I can''t do it'|'foo' + * + * This would be returned as an array of 3 strings as follows: + * now is the time + * I can't do it + * foo + * + * This is achieved by walking through the inputString, character by character, + * ignoring the outer quotes and the divider and replacing any pair of consecutive + * single quotes with a single single quote. + * + * @param inputString + * @return array of constituent strings + */ + @VisibleForTesting + static String[] splitQuotedString(final String inputString) { + if (TextUtils.isEmpty(inputString)) { + return new String[0]; + } + + // this method can be called from multiple threads but it uses a static + // string builder + synchronized (sUnquoteLock) { + final int length = inputString.length(); + final ArrayList<String> results = sUnquoteResults; + results.clear(); + + int characterPos = -1; + while (++characterPos < length) { + final char mustBeQuote = inputString.charAt(characterPos); + Assert.isTrue(QUOTE_CHAR == mustBeQuote); + while (++characterPos < length) { + final char currentChar = inputString.charAt(characterPos); + if (currentChar == QUOTE_CHAR) { + final char peekAhead = characterPos < length - 1 + ? inputString.charAt(characterPos + 1) : 0; + + if (peekAhead == QUOTE_CHAR) { + characterPos += 1; // skip the second quote + } else { + addResult(results, sUnquoteStringBuilder); + sUnquoteStringBuilder.setLength(0); + + Assert.isTrue((peekAhead == DIVIDER) || (peekAhead == (char) 0)); + characterPos += 1; // skip the divider + break; + } + } + sUnquoteStringBuilder.append(currentChar); + } + } + return results.toArray(new String[results.size()]); + } + } + + static MessagePartData makePartData( + final String partId, + final String contentType, + final String contentUriString, + final String contentWidth, + final String contentHeight, + final String text, + final String messageId) { + if (ContentType.isTextType(contentType)) { + final MessagePartData textPart = MessagePartData.createTextMessagePart(text); + textPart.updatePartId(partId); + textPart.updateMessageId(messageId); + return textPart; + } else { + final Uri contentUri = Uri.parse(contentUriString); + final int width = Integer.parseInt(contentWidth); + final int height = Integer.parseInt(contentHeight); + final MessagePartData attachmentPart = MessagePartData.createMediaMessagePart( + contentType, contentUri, width, height); + attachmentPart.updatePartId(partId); + attachmentPart.updateMessageId(messageId); + return attachmentPart; + } + } + + @VisibleForTesting + static List<MessagePartData> makeParts( + final String rawIds, + final String rawContentTypes, + final String rawContentUris, + final String rawWidths, + final String rawHeights, + final String rawTexts, + final int partsCount, + final String messageId) { + final List<MessagePartData> parts = new LinkedList<MessagePartData>(); + if (partsCount == 1) { + parts.add(makePartData( + rawIds, + rawContentTypes, + rawContentUris, + rawWidths, + rawHeights, + rawTexts, + messageId)); + } else { + unpackMessageParts( + parts, + splitUnquotedString(rawIds), + splitQuotedString(rawContentTypes), + splitQuotedString(rawContentUris), + splitUnquotedString(rawWidths), + splitUnquotedString(rawHeights), + splitQuotedString(rawTexts), + partsCount, + messageId); + } + return parts; + } + + @VisibleForTesting + static void unpackMessageParts( + final List<MessagePartData> parts, + final String[] ids, + final String[] contentTypes, + final String[] contentUris, + final String[] contentWidths, + final String[] contentHeights, + final String[] texts, + final int partsCount, + final String messageId) { + + Assert.equals(partsCount, ids.length); + Assert.equals(partsCount, contentTypes.length); + Assert.equals(partsCount, contentUris.length); + Assert.equals(partsCount, contentWidths.length); + Assert.equals(partsCount, contentHeights.length); + Assert.equals(partsCount, texts.length); + + for (int i = 0; i < partsCount; i++) { + parts.add(makePartData( + ids[i], + contentTypes[i], + contentUris[i], + contentWidths[i], + contentHeights[i], + texts[i], + messageId)); + } + + if (parts.size() != partsCount) { + LogUtil.wtf(TAG, "Only unpacked " + parts.size() + " parts from message (id=" + + messageId + "), expected " + partsCount + " parts"); + } + } + + public final String getMessageId() { + return mMessageId; + } + + public final String getConversationId() { + return mConversationId; + } + + public final String getParticipantId() { + return mParticipantId; + } + + public List<MessagePartData> getParts() { + return mParts; + } + + public boolean hasText() { + for (final MessagePartData part : mParts) { + if (part.isText()) { + return true; + } + } + return false; + } + + /** + * Get a concatenation of all text parts + * + * @return the text that is a concatenation of all text parts + */ + public String getText() { + // This is optimized for single text part case, which is the majority + + // For single text part, we just return the part without creating the StringBuilder + String firstTextPart = null; + boolean foundText = false; + // For multiple text parts, we need the StringBuilder and the separator for concatenation + StringBuilder sb = null; + String separator = null; + for (final MessagePartData part : mParts) { + if (part.isText()) { + if (!foundText) { + // First text part + firstTextPart = part.getText(); + foundText = true; + } else { + // Second and beyond + if (sb == null) { + // Need the StringBuilder and the separator starting from 2nd text part + sb = new StringBuilder(); + if (!TextUtils.isEmpty(firstTextPart)) { + sb.append(firstTextPart); + } + separator = BugleGservices.get().getString( + BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR, + BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT); + } + final String partText = part.getText(); + if (!TextUtils.isEmpty(partText)) { + if (!TextUtils.isEmpty(separator) && sb.length() > 0) { + sb.append(separator); + } + sb.append(partText); + } + } + } + } + if (sb == null) { + // Only one text part + return firstTextPart; + } else { + // More than one + return sb.toString(); + } + } + + public boolean hasAttachments() { + for (final MessagePartData part : mParts) { + if (part.isAttachment()) { + return true; + } + } + return false; + } + + public List<MessagePartData> getAttachments() { + return getAttachments(null); + } + + public List<MessagePartData> getAttachments(final Predicate<MessagePartData> filter) { + if (mParts.isEmpty()) { + return Collections.emptyList(); + } + final List<MessagePartData> attachmentParts = new LinkedList<>(); + for (final MessagePartData part : mParts) { + if (part.isAttachment()) { + if (filter == null || filter.apply(part)) { + attachmentParts.add(part); + } + } + } + return attachmentParts; + } + + public final long getSentTimeStamp() { + return mSentTimestamp; + } + + public final long getReceivedTimeStamp() { + return mReceivedTimestamp; + } + + public final String getFormattedReceivedTimeStamp() { + return Dates.getMessageTimeString(mReceivedTimestamp).toString(); + } + + public final boolean getIsSeen() { + return mSeen; + } + + public final boolean getIsRead() { + return mRead; + } + + public final boolean getIsMms() { + return (mProtocol == MessageData.PROTOCOL_MMS || + mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION); + } + + public final boolean getIsMmsNotification() { + return (mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION); + } + + public final boolean getIsSms() { + return mProtocol == (MessageData.PROTOCOL_SMS); + } + + final int getProtocol() { + return mProtocol; + } + + public final int getStatus() { + return mStatus; + } + + public final String getSmsMessageUri() { + return mSmsMessageUri; + } + + public final int getSmsPriority() { + return mSmsPriority; + } + + public final int getSmsMessageSize() { + return mSmsMessageSize; + } + + public final String getMmsSubject() { + return mMmsSubject; + } + + public final long getMmsExpiry() { + return mMmsExpiry; + } + + public final int getRawTelephonyStatus() { + return mRawTelephonyStatus; + } + + public final String getSelfParticipantId() { + return mSelfParticipantId; + } + + public boolean getIsIncoming() { + return (mStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING); + } + + public boolean hasIncomingErrorStatus() { + return (mStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE || + mStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED); + } + + public boolean getIsSendComplete() { + return mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE; + } + + public String getSenderFullName() { + return mSenderFullName; + } + + public String getSenderFirstName() { + return mSenderFirstName; + } + + public String getSenderDisplayDestination() { + return mSenderDisplayDestination; + } + + public String getSenderNormalizedDestination() { + return mSenderNormalizedDestination; + } + + public Uri getSenderProfilePhotoUri() { + return mSenderProfilePhotoUri == null ? null : Uri.parse(mSenderProfilePhotoUri); + } + + public long getSenderContactId() { + return mSenderContactId; + } + + public String getSenderDisplayName() { + if (!TextUtils.isEmpty(mSenderFullName)) { + return mSenderFullName; + } + if (!TextUtils.isEmpty(mSenderFirstName)) { + return mSenderFirstName; + } + return mSenderDisplayDestination; + } + + public String getSenderContactLookupKey() { + return mSenderContactLookupKey; + } + + public boolean getShowDownloadMessage() { + return MessageData.getShowDownloadMessage(mStatus); + } + + public boolean getShowResendMessage() { + return MessageData.getShowResendMessage(mStatus); + } + + public boolean getCanForwardMessage() { + // Even for outgoing messages, we only allow forwarding if the message has finished sending + // as media often has issues when send isn't complete + return (mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE || + mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE); + } + + public boolean getCanCopyMessageToClipboard() { + return (hasText() && + (!getIsIncoming() || mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE)); + } + + public boolean getOneClickResendMessage() { + return MessageData.getOneClickResendMessage(mStatus, mRawTelephonyStatus); + } + + /** + * Get sender's lookup uri. + * This method doesn't support corp contacts. + * + * @return Lookup uri of sender's contact + */ + public Uri getSenderContactLookupUri() { + if (mSenderContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(mSenderContactLookupKey)) { + return ContactsContract.Contacts.getLookupUri(mSenderContactId, + mSenderContactLookupKey); + } + return null; + } + + public boolean getCanClusterWithPreviousMessage() { + return mCanClusterWithPreviousMessage; + } + + public boolean getCanClusterWithNextMessage() { + return mCanClusterWithNextMessage; + } + + @Override + public String toString() { + return MessageData.toString(mMessageId, mParts); + } + + // Data definitions + + public static final String getConversationMessagesQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + // Inject the conversation id + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)" + + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; + } + + static final String getConversationMessageIdsQuerySql() { + return CONVERSATION_MESSAGES_IDS_QUERY_SQL + + " AND " + // Inject the conversation id + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)" + + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; + } + + public static final String getNotificationQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + + "(" + DatabaseHelper.MessageColumns.STATUS + " in (" + + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", " + + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")" + + " AND " + + DatabaseHelper.MessageColumns.SEEN + " = 0)" + + ")" + + NOTIFICATION_QUERY_SQL_GROUP_BY; + } + + public static final String getWearableQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?" + + " AND " + + DatabaseHelper.MessageColumns.STATUS + " IN (" + + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED + ", " + + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + ", " + + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ", " + + MessageData.BUGLE_STATUS_OUTGOING_SENDING + ", " + + MessageData.BUGLE_STATUS_OUTGOING_RESENDING + ", " + + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ", " + + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", " + + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")" + + ")" + + NOTIFICATION_QUERY_SQL_GROUP_BY; + } + + /* + * Generate a sqlite snippet to call the quote function on the columnName argument. + * The columnName doesn't strictly have to be a column name (e.g. it could be an + * expression). + */ + private static String quote(final String columnName) { + return "quote(" + columnName + ")"; + } + + private static String makeGroupConcatString(final String column) { + return "group_concat(" + column + ", '" + DIVIDER + "')"; + } + + private static String makeIfNullString(final String column) { + return "ifnull(" + column + "," + "''" + ")"; + } + + private static String makePartsTableColumnString(final String column) { + return DatabaseHelper.PARTS_TABLE + '.' + column; + } + + private static String makeCaseWhenString(final String column, + final boolean quote, + final String asColumn) { + final String fullColumn = makeIfNullString(makePartsTableColumnString(column)); + final String groupConcatTerm = quote + ? makeGroupConcatString(quote(fullColumn)) + : makeGroupConcatString(fullColumn); + return "CASE WHEN (" + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT + ">1) THEN " + groupConcatTerm + + " ELSE " + makePartsTableColumnString(column) + " END AS " + asColumn; + } + + private static final String CONVERSATION_MESSAGE_VIEW_PARTS_COUNT = + "count(" + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + ")"; + + private static final String EMPTY_STRING = ""; + + private static final String CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL = + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + + " as " + ConversationMessageViewColumns._ID + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID + + " as " + ConversationMessageViewColumns.CONVERSATION_ID + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + " as " + ConversationMessageViewColumns.PARTICIPANT_ID + ", " + + + makeCaseWhenString(PartColumns._ID, false, + ConversationMessageViewColumns.PARTS_IDS) + ", " + + makeCaseWhenString(PartColumns.CONTENT_TYPE, true, + ConversationMessageViewColumns.PARTS_CONTENT_TYPES) + ", " + + makeCaseWhenString(PartColumns.CONTENT_URI, true, + ConversationMessageViewColumns.PARTS_CONTENT_URIS) + ", " + + makeCaseWhenString(PartColumns.WIDTH, false, + ConversationMessageViewColumns.PARTS_WIDTHS) + ", " + + makeCaseWhenString(PartColumns.HEIGHT, false, + ConversationMessageViewColumns.PARTS_HEIGHTS) + ", " + + makeCaseWhenString(PartColumns.TEXT, true, + ConversationMessageViewColumns.PARTS_TEXTS) + ", " + + + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT + + " as " + ConversationMessageViewColumns.PARTS_COUNT + ", " + + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENT_TIMESTAMP + + " as " + ConversationMessageViewColumns.SENT_TIMESTAMP + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + + " as " + ConversationMessageViewColumns.RECEIVED_TIMESTAMP + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SEEN + + " as " + ConversationMessageViewColumns.SEEN + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ + + " as " + ConversationMessageViewColumns.READ + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.PROTOCOL + + " as " + ConversationMessageViewColumns.PROTOCOL + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS + + " as " + ConversationMessageViewColumns.STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_URI + + " as " + ConversationMessageViewColumns.SMS_MESSAGE_URI + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_PRIORITY + + " as " + ConversationMessageViewColumns.SMS_PRIORITY + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_SIZE + + " as " + ConversationMessageViewColumns.SMS_MESSAGE_SIZE + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_SUBJECT + + " as " + ConversationMessageViewColumns.MMS_SUBJECT + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_EXPIRY + + " as " + ConversationMessageViewColumns.MMS_EXPIRY + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS + + " as " + ConversationMessageViewColumns.RAW_TELEPHONY_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SELF_PARTICIPANT_ID + + " as " + ConversationMessageViewColumns.SELF_PARTICIPANT_ID + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME + + " as " + ConversationMessageViewColumns.SENDER_FULL_NAME + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME + + " as " + ConversationMessageViewColumns.SENDER_FIRST_NAME + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + + " as " + ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.NORMALIZED_DESTINATION + + " as " + ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.PROFILE_PHOTO_URI + + " as " + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.CONTACT_ID + + " as " + ConversationMessageViewColumns.SENDER_CONTACT_ID + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.LOOKUP_KEY + + " as " + ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY + " "; + + private static final String CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL = + " FROM " + DatabaseHelper.MESSAGES_TABLE + + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE + + " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID + + "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") " + + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + + " ON (" + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")" + // Exclude draft messages from main view + + " WHERE (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.STATUS + + " <> " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT; + + // This query is mostly static, except for the injection of conversation id. This is for + // performance reasons, to ensure that the query uses indices and does not trigger full scans + // of the messages table. See b/17160946 for more details. + private static final String CONVERSATION_MESSAGES_QUERY_SQL = "SELECT " + + CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL + + CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL; + + private static final String CONVERSATION_MESSAGE_IDS_PROJECTION_SQL = + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + + " as " + ConversationMessageViewColumns._ID + " "; + + private static final String CONVERSATION_MESSAGES_IDS_QUERY_SQL = "SELECT " + + CONVERSATION_MESSAGE_IDS_PROJECTION_SQL + + CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL; + + // Note that we sort DESC and ConversationData reverses the cursor. This is a performance + // issue (improvement) for large cursors. + private static final String CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY = + " GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID + + " ORDER BY " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC"; + + private static final String NOTIFICATION_QUERY_SQL_GROUP_BY = + " GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID + + " ORDER BY " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC"; + + interface ConversationMessageViewColumns extends BaseColumns { + static final String _ID = MessageColumns._ID; + static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID; + static final String PARTICIPANT_ID = MessageColumns.SENDER_PARTICIPANT_ID; + static final String PARTS_COUNT = "parts_count"; + static final String SENT_TIMESTAMP = MessageColumns.SENT_TIMESTAMP; + static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP; + static final String SEEN = MessageColumns.SEEN; + static final String READ = MessageColumns.READ; + static final String PROTOCOL = MessageColumns.PROTOCOL; + static final String STATUS = MessageColumns.STATUS; + static final String SMS_MESSAGE_URI = MessageColumns.SMS_MESSAGE_URI; + static final String SMS_PRIORITY = MessageColumns.SMS_PRIORITY; + static final String SMS_MESSAGE_SIZE = MessageColumns.SMS_MESSAGE_SIZE; + static final String MMS_SUBJECT = MessageColumns.MMS_SUBJECT; + static final String MMS_EXPIRY = MessageColumns.MMS_EXPIRY; + static final String RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS; + static final String SELF_PARTICIPANT_ID = MessageColumns.SELF_PARTICIPANT_ID; + static final String SENDER_FULL_NAME = ParticipantColumns.FULL_NAME; + static final String SENDER_FIRST_NAME = ParticipantColumns.FIRST_NAME; + static final String SENDER_DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION; + static final String SENDER_NORMALIZED_DESTINATION = + ParticipantColumns.NORMALIZED_DESTINATION; + static final String SENDER_PROFILE_PHOTO_URI = ParticipantColumns.PROFILE_PHOTO_URI; + static final String SENDER_CONTACT_ID = ParticipantColumns.CONTACT_ID; + static final String SENDER_CONTACT_LOOKUP_KEY = ParticipantColumns.LOOKUP_KEY; + static final String PARTS_IDS = "parts_ids"; + static final String PARTS_CONTENT_TYPES = "parts_content_types"; + static final String PARTS_CONTENT_URIS = "parts_content_uris"; + static final String PARTS_WIDTHS = "parts_widths"; + static final String PARTS_HEIGHTS = "parts_heights"; + static final String PARTS_TEXTS = "parts_texts"; + } + + private static int sIndexIncrementer = 0; + + private static final int INDEX_MESSAGE_ID = sIndexIncrementer++; + private static final int INDEX_CONVERSATION_ID = sIndexIncrementer++; + private static final int INDEX_PARTICIPANT_ID = sIndexIncrementer++; + + private static final int INDEX_PARTS_IDS = sIndexIncrementer++; + private static final int INDEX_PARTS_CONTENT_TYPES = sIndexIncrementer++; + private static final int INDEX_PARTS_CONTENT_URIS = sIndexIncrementer++; + private static final int INDEX_PARTS_WIDTHS = sIndexIncrementer++; + private static final int INDEX_PARTS_HEIGHTS = sIndexIncrementer++; + private static final int INDEX_PARTS_TEXTS = sIndexIncrementer++; + + private static final int INDEX_PARTS_COUNT = sIndexIncrementer++; + + private static final int INDEX_SENT_TIMESTAMP = sIndexIncrementer++; + private static final int INDEX_RECEIVED_TIMESTAMP = sIndexIncrementer++; + private static final int INDEX_SEEN = sIndexIncrementer++; + private static final int INDEX_READ = sIndexIncrementer++; + private static final int INDEX_PROTOCOL = sIndexIncrementer++; + private static final int INDEX_STATUS = sIndexIncrementer++; + private static final int INDEX_SMS_MESSAGE_URI = sIndexIncrementer++; + private static final int INDEX_SMS_PRIORITY = sIndexIncrementer++; + private static final int INDEX_SMS_MESSAGE_SIZE = sIndexIncrementer++; + private static final int INDEX_MMS_SUBJECT = sIndexIncrementer++; + private static final int INDEX_MMS_EXPIRY = sIndexIncrementer++; + private static final int INDEX_RAW_TELEPHONY_STATUS = sIndexIncrementer++; + private static final int INDEX_SELF_PARTICIPIANT_ID = sIndexIncrementer++; + private static final int INDEX_SENDER_FULL_NAME = sIndexIncrementer++; + private static final int INDEX_SENDER_FIRST_NAME = sIndexIncrementer++; + private static final int INDEX_SENDER_DISPLAY_DESTINATION = sIndexIncrementer++; + private static final int INDEX_SENDER_NORMALIZED_DESTINATION = sIndexIncrementer++; + private static final int INDEX_SENDER_PROFILE_PHOTO_URI = sIndexIncrementer++; + private static final int INDEX_SENDER_CONTACT_ID = sIndexIncrementer++; + private static final int INDEX_SENDER_CONTACT_LOOKUP_KEY = sIndexIncrementer++; + + + private static String[] sProjection = { + ConversationMessageViewColumns._ID, + ConversationMessageViewColumns.CONVERSATION_ID, + ConversationMessageViewColumns.PARTICIPANT_ID, + + ConversationMessageViewColumns.PARTS_IDS, + ConversationMessageViewColumns.PARTS_CONTENT_TYPES, + ConversationMessageViewColumns.PARTS_CONTENT_URIS, + ConversationMessageViewColumns.PARTS_WIDTHS, + ConversationMessageViewColumns.PARTS_HEIGHTS, + ConversationMessageViewColumns.PARTS_TEXTS, + + ConversationMessageViewColumns.PARTS_COUNT, + ConversationMessageViewColumns.SENT_TIMESTAMP, + ConversationMessageViewColumns.RECEIVED_TIMESTAMP, + ConversationMessageViewColumns.SEEN, + ConversationMessageViewColumns.READ, + ConversationMessageViewColumns.PROTOCOL, + ConversationMessageViewColumns.STATUS, + ConversationMessageViewColumns.SMS_MESSAGE_URI, + ConversationMessageViewColumns.SMS_PRIORITY, + ConversationMessageViewColumns.SMS_MESSAGE_SIZE, + ConversationMessageViewColumns.MMS_SUBJECT, + ConversationMessageViewColumns.MMS_EXPIRY, + ConversationMessageViewColumns.RAW_TELEPHONY_STATUS, + ConversationMessageViewColumns.SELF_PARTICIPANT_ID, + ConversationMessageViewColumns.SENDER_FULL_NAME, + ConversationMessageViewColumns.SENDER_FIRST_NAME, + ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION, + ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION, + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI, + ConversationMessageViewColumns.SENDER_CONTACT_ID, + ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY, + }; + + public static String[] getProjection() { + return sProjection; + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java b/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java new file mode 100644 index 0000000..0b5ef51 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java @@ -0,0 +1,125 @@ +/* + * 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.data; + +import android.database.Cursor; +import android.support.v4.util.SimpleArrayMap; + +import com.google.common.annotations.VisibleForTesting; + +import junit.framework.Assert; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * A class that contains the list of all participants potentially involved in a conversation. + * Includes both the participant records for each participant referenced in conversation + * participants table (i.e. "other" phone numbers) plus all participants representing self + * (i.e. one per sim recorded in the subscription manager db). + */ +public class ConversationParticipantsData implements Iterable<ParticipantData> { + // A map from a participant id to a participant + private final SimpleArrayMap<String, ParticipantData> mConversationParticipantsMap; + private int mParticipantCountExcludingSelf = 0; + + public ConversationParticipantsData() { + mConversationParticipantsMap = new SimpleArrayMap<String, ParticipantData>(); + } + + public void bind(final Cursor cursor) { + mConversationParticipantsMap.clear(); + mParticipantCountExcludingSelf = 0; + if (cursor != null) { + while (cursor.moveToNext()) { + final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor); + if (!newParticipant.isSelf()) { + mParticipantCountExcludingSelf++; + } + mConversationParticipantsMap.put(newParticipant.getId(), newParticipant); + } + } + } + + @VisibleForTesting + ParticipantData getParticipantById(final String participantId) { + return mConversationParticipantsMap.get(participantId); + } + + ArrayList<ParticipantData> getParticipantListExcludingSelf() { + final ArrayList<ParticipantData> retList = + new ArrayList<ParticipantData>(mConversationParticipantsMap.size()); + for (int i = 0; i < mConversationParticipantsMap.size(); i++) { + final ParticipantData participant = mConversationParticipantsMap.valueAt(i); + if (!participant.isSelf()) { + retList.add(participant); + } + } + return retList; + } + + /** + * For a 1:1 conversation return the other (not self) participant + */ + public ParticipantData getOtherParticipant() { + if (mParticipantCountExcludingSelf == 1) { + for (int i = 0; i < mConversationParticipantsMap.size(); i++) { + final ParticipantData participant = mConversationParticipantsMap.valueAt(i); + if (!participant.isSelf()) { + return participant; + } + } + Assert.fail(); + } + return null; + } + + public int getNumberOfParticipantsExcludingSelf() { + return mParticipantCountExcludingSelf; + } + + public boolean isLoaded() { + return !mConversationParticipantsMap.isEmpty(); + } + + @Override + public Iterator<ParticipantData> iterator() { + return new Iterator<ParticipantData>() { + private int mCurrentIndex = -1; + + @Override + public boolean hasNext() { + return mCurrentIndex < mConversationParticipantsMap.size() - 1; + } + + @Override + public ParticipantData next() { + mCurrentIndex++; + if (mCurrentIndex >= mConversationParticipantsMap.size()) { + throw new NoSuchElementException(); + } + return mConversationParticipantsMap.valueAt(mCurrentIndex); + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/src/com/android/messaging/datamodel/data/DraftMessageData.java b/src/com/android/messaging/datamodel/data/DraftMessageData.java new file mode 100644 index 0000000..7a7199a --- /dev/null +++ b/src/com/android/messaging/datamodel/data/DraftMessageData.java @@ -0,0 +1,855 @@ +/* + * 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.data; + +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.datamodel.MessageTextStats; +import com.android.messaging.datamodel.action.ReadDraftDataAction; +import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionListener; +import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionMonitor; +import com.android.messaging.datamodel.action.WriteDraftMessageAction; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +public class DraftMessageData extends BindableData implements ReadDraftDataActionListener { + + /** + * Interface for DraftMessageData listeners + */ + public interface DraftMessageDataListener { + @RunsOnMainThread + void onDraftChanged(DraftMessageData data, int changeFlags); + + @RunsOnMainThread + void onDraftAttachmentLimitReached(DraftMessageData data); + + @RunsOnMainThread + void onDraftAttachmentLoadFailed(); + } + + /** + * Interface for providing subscription-related data to DraftMessageData + */ + public interface DraftMessageSubscriptionDataProvider { + int getConversationSelfSubId(); + } + + // Flags sent to onDraftChanged to help the receiver limit the amount of work done + public static int ATTACHMENTS_CHANGED = 0x0001; + public static int MESSAGE_TEXT_CHANGED = 0x0002; + public static int MESSAGE_SUBJECT_CHANGED = 0x0004; + // Whether the self participant data has been loaded + public static int SELF_CHANGED = 0x0008; + public static int ALL_CHANGED = 0x00FF; + // ALL_CHANGED intentionally doesn't include WIDGET_CHANGED. ConversationFragment needs to + // be notified if the draft it is looking at is changed externally (by a desktop widget) so it + // can reload the draft. + public static int WIDGET_CHANGED = 0x0100; + + private final String mConversationId; + private ReadDraftDataActionMonitor mMonitor; + private final DraftMessageDataEventDispatcher mListeners; + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + private boolean mIncludeEmailAddress; + private boolean mIsGroupConversation; + private String mMessageText; + private String mMessageSubject; + private String mSelfId; + private MessageTextStats mMessageTextStats; + private boolean mSending; + + /** Keeps track of completed attachments in the message draft. This data is persisted to db */ + private final List<MessagePartData> mAttachments; + + /** A read-only wrapper on mAttachments surfaced to the UI layer for rendering */ + private final List<MessagePartData> mReadOnlyAttachments; + + /** Keeps track of pending attachments that are being loaded. The pending attachments are + * transient, because they are not persisted to the database and are dropped once we go + * to the background (after the UI calls saveToStorage) */ + private final List<PendingAttachmentData> mPendingAttachments; + + /** A read-only wrapper on mPendingAttachments surfaced to the UI layer for rendering */ + private final List<PendingAttachmentData> mReadOnlyPendingAttachments; + + /** Is the current draft a cached copy of what's been saved to the database. If so, we + * may skip loading from database if we are still bound */ + private boolean mIsDraftCachedCopy; + + /** Whether we are currently asynchronously validating the draft before sending. */ + private CheckDraftForSendTask mCheckDraftForSendTask; + + public DraftMessageData(final String conversationId) { + mConversationId = conversationId; + mAttachments = new ArrayList<MessagePartData>(); + mReadOnlyAttachments = Collections.unmodifiableList(mAttachments); + mPendingAttachments = new ArrayList<PendingAttachmentData>(); + mReadOnlyPendingAttachments = Collections.unmodifiableList(mPendingAttachments); + mListeners = new DraftMessageDataEventDispatcher(); + mMessageTextStats = new MessageTextStats(); + } + + public void addListener(final DraftMessageDataListener listener) { + mListeners.add(listener); + } + + public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + public void updateFromMessageData(final MessageData message, final String bindingId) { + // New attachments have arrived - only update if the user hasn't already edited + Assert.notNull(bindingId); + // The draft is now synced with actual MessageData and no longer a cached copy. + mIsDraftCachedCopy = false; + // Do not use the loaded draft if the user began composing a message before the draft loaded + // During config changes (orientation), the text fields preserve their data, so allow them + // to be the same and still consider the draft unchanged by the user + if (isDraftEmpty() || (TextUtils.equals(mMessageText, message.getMessageText()) && + TextUtils.equals(mMessageSubject, message.getMmsSubject()) && + mAttachments.isEmpty())) { + // No need to clear as just checked it was empty or a subset + setMessageText(message.getMessageText(), false /* notify */); + setMessageSubject(message.getMmsSubject(), false /* notify */); + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment() && getAttachmentCount() >= getAttachmentLimit()) { + dispatchAttachmentLimitReached(); + break; + } + + if (part instanceof PendingAttachmentData) { + // This is a pending attachment data from share intent (e.g. an shared image + // that we need to persist locally). + final PendingAttachmentData data = (PendingAttachmentData) part; + Assert.equals(PendingAttachmentData.STATE_PENDING, data.getCurrentState()); + addOnePendingAttachmentNoNotify(data, bindingId); + } else if (part.isAttachment()) { + addOneAttachmentNoNotify(part); + } + } + dispatchChanged(ALL_CHANGED); + } else { + // The user has started a new message so we throw out the draft message data if there + // is one but we also loaded the self metadata and need to let our listeners know. + dispatchChanged(SELF_CHANGED); + } + } + + /** + * Create a MessageData object containing a copy of all the parts in this DraftMessageData. + * + * @param clearLocalCopy whether we should clear out the in-memory copy of the parts. If we + * are simply pausing/resuming and not sending the message, then we can keep + * @return the MessageData for the draft, null if self id is not set + */ + public MessageData createMessageWithCurrentAttachments(final boolean clearLocalCopy) { + MessageData message = null; + if (getIsMms()) { + message = MessageData.createDraftMmsMessage(mConversationId, mSelfId, + mMessageText, mMessageSubject); + for (final MessagePartData attachment : mAttachments) { + message.addPart(attachment); + } + } else { + message = MessageData.createDraftSmsMessage(mConversationId, mSelfId, + mMessageText); + } + + if (clearLocalCopy) { + // The message now owns all the attachments and the text... + clearLocalDraftCopy(); + dispatchChanged(ALL_CHANGED); + } else { + // The draft message becomes a cached copy for UI. + mIsDraftCachedCopy = true; + } + return message; + } + + private void clearLocalDraftCopy() { + mIsDraftCachedCopy = false; + mAttachments.clear(); + setMessageText(""); + setMessageSubject(""); + } + + public String getConversationId() { + return mConversationId; + } + + public String getMessageText() { + return mMessageText; + } + + public String getMessageSubject() { + return mMessageSubject; + } + + public boolean getIsMms() { + final int selfSubId = getSelfSubId(); + return MmsSmsUtils.getRequireMmsForEmailAddress(mIncludeEmailAddress, selfSubId) || + (mIsGroupConversation && MmsUtils.groupMmsEnabled(selfSubId)) || + mMessageTextStats.getMessageLengthRequiresMms() || !mAttachments.isEmpty() || + !TextUtils.isEmpty(mMessageSubject); + } + + public boolean getIsGroupMmsConversation() { + return getIsMms() && mIsGroupConversation; + } + + public String getSelfId() { + return mSelfId; + } + + public int getNumMessagesToBeSent() { + return mMessageTextStats.getNumMessagesToBeSent(); + } + + public int getCodePointsRemainingInCurrentMessage() { + return mMessageTextStats.getCodePointsRemainingInCurrentMessage(); + } + + public int getSelfSubId() { + return mSubscriptionDataProvider == null ? ParticipantData.DEFAULT_SELF_SUB_ID : + mSubscriptionDataProvider.getConversationSelfSubId(); + } + + private void setMessageText(final String messageText, final boolean notify) { + mMessageText = messageText; + mMessageTextStats.updateMessageTextStats(getSelfSubId(), mMessageText); + if (notify) { + dispatchChanged(MESSAGE_TEXT_CHANGED); + } + } + + private void setMessageSubject(final String subject, final boolean notify) { + mMessageSubject = subject; + if (notify) { + dispatchChanged(MESSAGE_SUBJECT_CHANGED); + } + } + + public void setMessageText(final String messageText) { + setMessageText(messageText, false); + } + + public void setMessageSubject(final String subject) { + setMessageSubject(subject, false); + } + + public void addAttachments(final Collection<? extends MessagePartData> attachments) { + // If the incoming attachments contains a single-only attachment, we need to clear + // the existing attachments. + for (final MessagePartData data : attachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the attachment we're adding can only + // exist by itself. + destroyAttachments(); + break; + } + } + // If the existing attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + // If any of the pending attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mPendingAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + + boolean reachedLimit = false; + for (final MessagePartData data : attachments) { + // Don't break out of loop even if limit has been reached so we can destroy all + // of the over-limit attachments. + reachedLimit |= addOneAttachmentNoNotify(data); + } + if (reachedLimit) { + dispatchAttachmentLimitReached(); + } + dispatchChanged(ATTACHMENTS_CHANGED); + } + + public boolean containsAttachment(final Uri contentUri) { + for (final MessagePartData existingAttachment : mAttachments) { + if (existingAttachment.getContentUri().equals(contentUri)) { + return true; + } + } + + for (final PendingAttachmentData pendingAttachment : mPendingAttachments) { + if (pendingAttachment.getContentUri().equals(contentUri)) { + return true; + } + } + return false; + } + + /** + * Try to add one attachment to the attachment list, while guarding against duplicates and + * going over the limit. + * @return true if the attachment limit was reached, false otherwise + */ + private boolean addOneAttachmentNoNotify(final MessagePartData attachment) { + Assert.isTrue(attachment.isAttachment()); + final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit(); + if (reachedLimit || containsAttachment(attachment.getContentUri())) { + // Never go over the limit. Never add duplicated attachments. + attachment.destroyAsync(); + return reachedLimit; + } else { + addAttachment(attachment, null /*pendingAttachment*/); + return false; + } + } + + private void addAttachment(final MessagePartData attachment, + final PendingAttachmentData pendingAttachment) { + if (attachment != null && attachment.isSinglePartOnly()) { + // clear any existing attachments because the attachment we're adding can only + // exist by itself. + destroyAttachments(); + } + if (pendingAttachment != null && pendingAttachment.isSinglePartOnly()) { + // clear any existing attachments because the attachment we're adding can only + // exist by itself. + destroyAttachments(); + } + // If the existing attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + // If any of the pending attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mPendingAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + if (attachment != null) { + mAttachments.add(attachment); + } else if (pendingAttachment != null) { + mPendingAttachments.add(pendingAttachment); + } + } + + public void addPendingAttachment(final PendingAttachmentData pendingAttachment, + final BindingBase<DraftMessageData> binding) { + final boolean reachedLimit = addOnePendingAttachmentNoNotify(pendingAttachment, + binding.getBindingId()); + if (reachedLimit) { + dispatchAttachmentLimitReached(); + } + dispatchChanged(ATTACHMENTS_CHANGED); + } + + /** + * Try to add one pending attachment, while guarding against duplicates and + * going over the limit. + * @return true if the attachment limit was reached, false otherwise + */ + private boolean addOnePendingAttachmentNoNotify(final PendingAttachmentData pendingAttachment, + final String bindingId) { + final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit(); + if (reachedLimit || containsAttachment(pendingAttachment.getContentUri())) { + // Never go over the limit. Never add duplicated attachments. + pendingAttachment.destroyAsync(); + return reachedLimit; + } else { + Assert.isTrue(!mPendingAttachments.contains(pendingAttachment)); + Assert.equals(PendingAttachmentData.STATE_PENDING, pendingAttachment.getCurrentState()); + addAttachment(null /*attachment*/, pendingAttachment); + + pendingAttachment.loadAttachmentForDraft(this, bindingId); + return false; + } + } + + public void setSelfId(final String selfId, final boolean notify) { + LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: set selfId=" + selfId + + " for conversationId=" + mConversationId); + mSelfId = selfId; + if (notify) { + dispatchChanged(SELF_CHANGED); + } + } + + public boolean hasAttachments() { + return !mAttachments.isEmpty(); + } + + public boolean hasPendingAttachments() { + return !mPendingAttachments.isEmpty(); + } + + private int getAttachmentCount() { + return mAttachments.size() + mPendingAttachments.size(); + } + + private int getVideoAttachmentCount() { + int count = 0; + for (MessagePartData part : mAttachments) { + if (part.isVideo()) { + count++; + } + } + for (MessagePartData part : mPendingAttachments) { + if (part.isVideo()) { + count++; + } + } + return count; + } + + private int getAttachmentLimit() { + return BugleGservices.get().getInt( + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT, + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT); + } + + public void removeAttachment(final MessagePartData attachment) { + for (final MessagePartData existingAttachment : mAttachments) { + if (existingAttachment.getContentUri().equals(attachment.getContentUri())) { + mAttachments.remove(existingAttachment); + existingAttachment.destroyAsync(); + dispatchChanged(ATTACHMENTS_CHANGED); + break; + } + } + } + + public void removeExistingAttachments(final Set<MessagePartData> attachmentsToRemove) { + boolean removed = false; + final Iterator<MessagePartData> iterator = mAttachments.iterator(); + while (iterator.hasNext()) { + final MessagePartData existingAttachment = iterator.next(); + if (attachmentsToRemove.contains(existingAttachment)) { + iterator.remove(); + existingAttachment.destroyAsync(); + removed = true; + } + } + + if (removed) { + dispatchChanged(ATTACHMENTS_CHANGED); + } + } + + public void removePendingAttachment(final PendingAttachmentData pendingAttachment) { + for (final PendingAttachmentData existingAttachment : mPendingAttachments) { + if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) { + mPendingAttachments.remove(pendingAttachment); + pendingAttachment.destroyAsync(); + dispatchChanged(ATTACHMENTS_CHANGED); + break; + } + } + } + + public void updatePendingAttachment(final MessagePartData updatedAttachment, + final PendingAttachmentData pendingAttachment) { + for (final PendingAttachmentData existingAttachment : mPendingAttachments) { + if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) { + mPendingAttachments.remove(pendingAttachment); + if (pendingAttachment.isSinglePartOnly()) { + updatedAttachment.setSinglePartOnly(true); + } + mAttachments.add(updatedAttachment); + dispatchChanged(ATTACHMENTS_CHANGED); + return; + } + } + + // If we are here, this means the pending attachment has been dropped before the task + // to load it was completed. In this case destroy the temporarily staged file since it + // is no longer needed. + updatedAttachment.destroyAsync(); + } + + /** + * Remove the attachments from the draft and notify any listeners. + * @param flags typically this will be ATTACHMENTS_CHANGED. When attachments are cleared in a + * widget, flags will also contain WIDGET_CHANGED. + */ + public void clearAttachments(final int flags) { + destroyAttachments(); + dispatchChanged(flags); + } + + public List<MessagePartData> getReadOnlyAttachments() { + return mReadOnlyAttachments; + } + + public List<PendingAttachmentData> getReadOnlyPendingAttachments() { + return mReadOnlyPendingAttachments; + } + + public boolean loadFromStorage(final BindingBase<DraftMessageData> binding, + final MessageData optionalIncomingDraft, boolean clearLocalDraft) { + LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: " + + (optionalIncomingDraft == null ? "loading" : "setting") + + " for conversationId=" + mConversationId); + if (clearLocalDraft) { + clearLocalDraftCopy(); + } + final boolean isDraftCachedCopy = mIsDraftCachedCopy; + mIsDraftCachedCopy = false; + // Before reading message from db ensure the caller is bound to us (and knows the id) + if (mMonitor == null && !isDraftCachedCopy && isBound(binding.getBindingId())) { + mMonitor = ReadDraftDataAction.readDraftData(mConversationId, + optionalIncomingDraft, binding.getBindingId(), this); + return true; + } + return false; + } + + /** + * Saves the current draft to db. This will save the draft and drop any pending attachments + * we have. The UI typically goes into the background when this is called, and instead of + * trying to persist the state of the pending attachments (the app may be killed, the activity + * may be destroyed), we simply drop the pending attachments for consistency. + */ + public void saveToStorage(final BindingBase<DraftMessageData> binding) { + saveToStorageInternal(binding); + dropPendingAttachments(); + } + + private void saveToStorageInternal(final BindingBase<DraftMessageData> binding) { + // Create MessageData to store to db, but don't clear the in-memory copy so UI will + // continue to display it. + // If self id is null then we'll not attempt to change the conversation's self id. + final MessageData message = createMessageWithCurrentAttachments(false /* clearLocalCopy */); + // Before writing message to db ensure the caller is bound to us (and knows the id) + if (isBound(binding.getBindingId())){ + WriteDraftMessageAction.writeDraftMessage(mConversationId, message); + } + } + + /** + * Called when we are ready to send the message. This will assemble/return the MessageData for + * sending and clear the local draft data, both from memory and from DB. This will also bind + * the message data with a self Id through which the message will be sent. + * + * @param binding the binding object from our consumer. We need to make sure we are still bound + * to that binding before saving to storage. + */ + public MessageData prepareMessageForSending(final BindingBase<DraftMessageData> binding) { + // We can't send the message while there's still stuff pending. + Assert.isTrue(!hasPendingAttachments()); + mSending = true; + // Assembles the message to send and empty working draft data. + // If self id is null then message is sent with conversation's self id. + final MessageData messageToSend = + createMessageWithCurrentAttachments(true /* clearLocalCopy */); + // Note sending message will empty the draft data in DB. + mSending = false; + return messageToSend; + } + + public boolean isSending() { + return mSending; + } + + @Override // ReadDraftMessageActionListener.onReadDraftMessageSucceeded + public void onReadDraftDataSucceeded(final ReadDraftDataAction action, final Object data, + final MessageData message, final ConversationListItemData conversation) { + final String bindingId = (String) data; + + // Before passing draft message on to ui ensure the data is bound to the same bindingid + if (isBound(bindingId)) { + mSelfId = message.getSelfId(); + mIsGroupConversation = conversation.getIsGroup(); + mIncludeEmailAddress = conversation.getIncludeEmailAddress(); + updateFromMessageData(message, bindingId); + LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded. " + + "conversationId=" + mConversationId + " selfId=" + mSelfId); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded but not bound. " + + "conversationId=" + mConversationId); + } + mMonitor = null; + } + + @Override // ReadDraftMessageActionListener.onReadDraftDataFailed + public void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data) { + LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft not loaded. " + + "conversationId=" + mConversationId); + // The draft is now synced with actual MessageData and no longer a cached copy. + mIsDraftCachedCopy = false; + // Just clear the monitor - no update to draft data + mMonitor = null; + } + + /** + * Check if Bugle is default sms app + * @return + */ + public boolean getIsDefaultSmsApp() { + return PhoneUtils.getDefault().isDefaultSmsApp(); + } + + @Override //BindableData.unregisterListeners + protected void unregisterListeners() { + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + mListeners.clear(); + } + + private void destroyAttachments() { + for (final MessagePartData attachment : mAttachments) { + attachment.destroyAsync(); + } + mAttachments.clear(); + mPendingAttachments.clear(); + } + + private void dispatchChanged(final int changeFlags) { + // No change is expected to be made to the draft if it is in cached copy state. + if (mIsDraftCachedCopy) { + return; + } + // Any change in the draft will cancel any pending draft checking task, since the + // size/status of the draft may have changed. + if (mCheckDraftForSendTask != null) { + mCheckDraftForSendTask.cancel(true /* mayInterruptIfRunning */); + mCheckDraftForSendTask = null; + } + mListeners.onDraftChanged(this, changeFlags); + } + + private void dispatchAttachmentLimitReached() { + mListeners.onDraftAttachmentLimitReached(this); + } + + /** + * Drop any pending attachments that haven't finished. This is called after the UI goes to + * the background and we persist the draft data to the database. + */ + private void dropPendingAttachments() { + mPendingAttachments.clear(); + } + + private boolean isDraftEmpty() { + return TextUtils.isEmpty(mMessageText) && mAttachments.isEmpty() && + TextUtils.isEmpty(mMessageSubject); + } + + public boolean isCheckingDraft() { + return mCheckDraftForSendTask != null && !mCheckDraftForSendTask.isCancelled(); + } + + public void checkDraftForAction(final boolean checkMessageSize, final int selfSubId, + final CheckDraftTaskCallback callback, final Binding<DraftMessageData> binding) { + new CheckDraftForSendTask(checkMessageSize, selfSubId, callback, binding) + .executeOnThreadPool((Void) null); + } + + /** + * Allows us to have multiple data listeners for DraftMessageData + */ + private class DraftMessageDataEventDispatcher + extends ArrayList<DraftMessageDataListener> + implements DraftMessageDataListener { + + @Override + @RunsOnMainThread + public void onDraftChanged(DraftMessageData data, int changeFlags) { + Assert.isMainThread(); + for (final DraftMessageDataListener listener : this) { + listener.onDraftChanged(data, changeFlags); + } + } + + @Override + @RunsOnMainThread + public void onDraftAttachmentLimitReached(DraftMessageData data) { + Assert.isMainThread(); + for (final DraftMessageDataListener listener : this) { + listener.onDraftAttachmentLimitReached(data); + } + } + + @Override + @RunsOnMainThread + public void onDraftAttachmentLoadFailed() { + Assert.isMainThread(); + for (final DraftMessageDataListener listener : this) { + listener.onDraftAttachmentLoadFailed(); + } + } + } + + public interface CheckDraftTaskCallback { + void onDraftChecked(DraftMessageData data, int result); + } + + public class CheckDraftForSendTask extends SafeAsyncTask<Void, Void, Integer> { + public static final int RESULT_PASSED = 0; + public static final int RESULT_HAS_PENDING_ATTACHMENTS = 1; + public static final int RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS = 2; + public static final int RESULT_MESSAGE_OVER_LIMIT = 3; + public static final int RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED = 4; + public static final int RESULT_SIM_NOT_READY = 5; + private final boolean mCheckMessageSize; + private final int mSelfSubId; + private final CheckDraftTaskCallback mCallback; + private final String mBindingId; + private final List<MessagePartData> mAttachmentsCopy; + private int mPreExecuteResult = RESULT_PASSED; + + public CheckDraftForSendTask(final boolean checkMessageSize, final int selfSubId, + final CheckDraftTaskCallback callback, final Binding<DraftMessageData> binding) { + mCheckMessageSize = checkMessageSize; + mSelfSubId = selfSubId; + mCallback = callback; + mBindingId = binding.getBindingId(); + // Obtain an immutable copy of the attachment list so we can operate on it in the + // background thread. + mAttachmentsCopy = new ArrayList<MessagePartData>(mAttachments); + + mCheckDraftForSendTask = this; + } + + @Override + protected void onPreExecute() { + // Perform checking work that can happen on the main thread. + if (hasPendingAttachments()) { + mPreExecuteResult = RESULT_HAS_PENDING_ATTACHMENTS; + return; + } + if (getIsGroupMmsConversation()) { + try { + if (TextUtils.isEmpty(PhoneUtils.get(mSelfSubId).getSelfRawNumber(true))) { + mPreExecuteResult = RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS; + return; + } + } catch (IllegalStateException e) { + // This happens when there is no active subscription, e.g. on Nova + // when the phone switches carrier. + mPreExecuteResult = RESULT_SIM_NOT_READY; + return; + } + } + if (getVideoAttachmentCount() > MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT) { + mPreExecuteResult = RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED; + return; + } + } + + @Override + protected Integer doInBackgroundTimed(Void... params) { + if (mPreExecuteResult != RESULT_PASSED) { + return mPreExecuteResult; + } + + if (mCheckMessageSize && getIsMessageOverLimit()) { + return RESULT_MESSAGE_OVER_LIMIT; + } + return RESULT_PASSED; + } + + @Override + protected void onPostExecute(Integer result) { + mCheckDraftForSendTask = null; + // Only call back if we are bound to the original binding. + if (isBound(mBindingId) && !isCancelled()) { + mCallback.onDraftChecked(DraftMessageData.this, result); + } else { + if (!isBound(mBindingId)) { + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft not bound"); + } + if (isCancelled()) { + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft is cancelled"); + } + } + } + + @Override + protected void onCancelled() { + mCheckDraftForSendTask = null; + } + + /** + * 1. Check if the draft message contains too many attachments to send + * 2. Computes the minimum size that this message could be compressed/downsampled/encoded + * before sending and check if it meets the carrier max size for sending. + * @see MessagePartData#getMinimumSizeInBytesForSending() + */ + @DoesNotRunOnMainThread + private boolean getIsMessageOverLimit() { + Assert.isNotMainThread(); + if (mAttachmentsCopy.size() > getAttachmentLimit()) { + return true; + } + + // Aggregate the size from all the attachments. + long totalSize = 0; + for (final MessagePartData attachment : mAttachmentsCopy) { + totalSize += attachment.getMinimumSizeInBytesForSending(); + } + return totalSize > MmsConfig.get(mSelfSubId).getMaxMessageSize(); + } + } + + public void onPendingAttachmentLoadFailed(PendingAttachmentData data) { + mListeners.onDraftAttachmentLoadFailed(); + } +} diff --git a/src/com/android/messaging/datamodel/data/GalleryGridItemData.java b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java new file mode 100644 index 0000000..6649757 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java @@ -0,0 +1,128 @@ +/* + * 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.data; + +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.MediaStore.Images.Media; +import android.text.TextUtils; + +import com.android.messaging.datamodel.media.FileImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.util.Assert; + +/** + * Provides data for GalleryGridItemView + */ +public class GalleryGridItemData { + public static final String[] IMAGE_PROJECTION = new String[] { + Media._ID, + Media.DATA, + Media.WIDTH, + Media.HEIGHT, + Media.MIME_TYPE, + Media.DATE_MODIFIED}; + + public static final String[] SPECIAL_ITEM_COLUMNS = new String[] { + BaseColumns._ID + }; + + private static final int INDEX_ID = 0; + + // For local image gallery. + private static final int INDEX_DATA_PATH = 1; + private static final int INDEX_WIDTH = 2; + private static final int INDEX_HEIGHT = 3; + private static final int INDEX_MIME_TYPE = 4; + private static final int INDEX_DATE_MODIFIED = 5; + + /** A special item's id for picking images from document picker */ + public static final String ID_DOCUMENT_PICKER_ITEM = "-1"; + + private UriImageRequestDescriptor mImageData; + private String mContentType; + private boolean mIsDocumentPickerItem; + private long mDateSeconds; + + public GalleryGridItemData() { + } + + public void bind(final Cursor cursor, final int desiredWidth, final int desiredHeight) { + mIsDocumentPickerItem = TextUtils.equals(cursor.getString(INDEX_ID), + ID_DOCUMENT_PICKER_ITEM); + if (mIsDocumentPickerItem) { + mImageData = null; + mContentType = null; + } else { + int sourceWidth = cursor.getInt(INDEX_WIDTH); + int sourceHeight = cursor.getInt(INDEX_HEIGHT); + + // Guard against bad data + if (sourceWidth <= 0) { + sourceWidth = ImageRequest.UNSPECIFIED_SIZE; + } + if (sourceHeight <= 0) { + sourceHeight = ImageRequest.UNSPECIFIED_SIZE; + } + + mContentType = cursor.getString(INDEX_MIME_TYPE); + final String dateModified = cursor.getString(INDEX_DATE_MODIFIED); + mDateSeconds = !TextUtils.isEmpty(dateModified) ? Long.parseLong(dateModified) : -1; + mImageData = new FileImageRequestDescriptor( + cursor.getString(INDEX_DATA_PATH), + desiredWidth, + desiredHeight, + sourceWidth, + sourceHeight, + true /* canUseThumbnail */, + true /* allowCompression */, + true /* isStatic */); + } + } + + public boolean isDocumentPickerItem() { + return mIsDocumentPickerItem; + } + + public Uri getImageUri() { + return mImageData.uri; + } + + public UriImageRequestDescriptor getImageRequestDescriptor() { + return mImageData; + } + + public MessagePartData constructMessagePartData(final Rect startRect) { + Assert.isTrue(!mIsDocumentPickerItem); + return new MediaPickerMessagePartData(startRect, mContentType, + mImageData.uri, mImageData.sourceWidth, mImageData.sourceHeight); + } + + /** + * @return The date in seconds. This can be negative if we could not retreive date info + */ + public long getDateSeconds() { + return mDateSeconds; + } + + public String getContentType() { + return mContentType; + } +} diff --git a/src/com/android/messaging/datamodel/data/LaunchConversationData.java b/src/com/android/messaging/datamodel/data/LaunchConversationData.java new file mode 100644 index 0000000..7eea580 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/LaunchConversationData.java @@ -0,0 +1,90 @@ +/* + * 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.data; + +import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; + +public class LaunchConversationData extends BindableData implements + GetOrCreateConversationActionListener { + public interface LaunchConversationDataListener { + void onGetOrCreateNewConversation(String conversationId); + void onGetOrCreateNewConversationFailed(); + } + + private LaunchConversationDataListener mListener; + private GetOrCreateConversationActionMonitor mMonitor; + + public LaunchConversationData(final LaunchConversationDataListener listener) { + mListener = listener; + } + + @Override + protected void unregisterListeners() { + mListener = null; + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + } + + public void getOrCreateConversation(final BindingBase<LaunchConversationData> binding, + final String[] recipients) { + final String bindingId = binding.getBindingId(); + + // Start a new conversation from the list of contacts. + if (isBound(bindingId) && mMonitor == null) { + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(recipients, + bindingId, this); + } + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId) { + Assert.isTrue(monitor == mMonitor); + Assert.isTrue(conversationId != null); + + final String bindingId = (String) data; + if (isBound(bindingId) && mListener != null) { + mListener.onGetOrCreateNewConversation(conversationId); + } + + mMonitor = null; + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data) { + Assert.isTrue(monitor == mMonitor); + final String bindingId = (String) data; + if (isBound(bindingId) && mListener != null) { + mListener.onGetOrCreateNewConversationFailed(); + } + LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed"); + mMonitor = null; + } +} diff --git a/src/com/android/messaging/datamodel/data/MediaPickerData.java b/src/com/android/messaging/datamodel/data/MediaPickerData.java new file mode 100644 index 0000000..b0c8bf7 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MediaPickerData.java @@ -0,0 +1,175 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.Nullable; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.GalleryBoundCursorLoader; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.LogUtil; + +/** + * Services data needs for MediaPicker. + */ +public class MediaPickerData extends BindableData { + public interface MediaPickerDataListener { + void onMediaPickerDataUpdated(MediaPickerData mediaPickerData, Object data, int loaderId); + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private LoaderManager mLoaderManager; + private final GalleryLoaderCallbacks mGalleryLoaderCallbacks; + private MediaPickerDataListener mListener; + + public MediaPickerData(final Context context) { + mContext = context; + mGalleryLoaderCallbacks = new GalleryLoaderCallbacks(); + } + + public static final int GALLERY_IMAGE_LOADER = 1; + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class GalleryLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case GALLERY_IMAGE_LOADER: + return new GalleryBoundCursorLoader(bindingId, mContext); + + default: + Assert.fail("Unknown loader id for gallery picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the media picker"); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case GALLERY_IMAGE_LOADER: + mListener.onMediaPickerDataUpdated(MediaPickerData.this, data, + GALLERY_IMAGE_LOADER); + break; + + default: + Assert.fail("Unknown loader id for gallery picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the media picker"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader<Cursor> loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case GALLERY_IMAGE_LOADER: + mListener.onMediaPickerDataUpdated(MediaPickerData.this, null, + GALLERY_IMAGE_LOADER); + break; + + default: + Assert.fail("Unknown loader id for media picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the media picker"); + } + } + } + + + + public void startLoader(final int loaderId, final BindingBase<MediaPickerData> binding, + @Nullable Bundle args, final MediaPickerDataListener listener) { + if (args == null) { + args = new Bundle(); + } + args.putString(BINDING_ID, binding.getBindingId()); + if (loaderId == GALLERY_IMAGE_LOADER) { + mLoaderManager.initLoader(loaderId, args, mGalleryLoaderCallbacks).forceLoad(); + } else { + Assert.fail("Unsupported loader id for media picker!"); + } + mListener = listener; + } + + public void destroyLoader(final int loaderId) { + mLoaderManager.destroyLoader(loaderId); + } + + public void init(final LoaderManager loaderManager) { + mLoaderManager = loaderManager; + } + + @Override + protected void unregisterListeners() { + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(GALLERY_IMAGE_LOADER); + mLoaderManager = null; + } + } + + /** + * Gets the last selected chooser index, or -1 if no selection has been saved. + */ + public int getSelectedChooserIndex() { + return BuglePrefs.getApplicationPrefs().getInt( + BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX, + BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT); + } + + /** + * Saves the selected media chooser index. + * @param selectedIndex the selected media chooser index. + */ + public void saveSelectedChooserIndex(final int selectedIndex) { + BuglePrefs.getApplicationPrefs().putInt(BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX, + selectedIndex); + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java b/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java new file mode 100644 index 0000000..7de9166 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java @@ -0,0 +1,64 @@ +/* + * 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.data; + +import android.graphics.Rect; +import android.net.Uri; + +public class MediaPickerMessagePartData extends MessagePartData { + private final Rect mStartRect; + + public MediaPickerMessagePartData(final Rect startRect, final String contentType, + final Uri contentUri, final int width, final int height) { + this(startRect, null /* messageText */, contentType, contentUri, width, height); + } + + public MediaPickerMessagePartData(final Rect startRect, final String messageText, + final String contentType, final Uri contentUri, final int width, final int height) { + this(startRect, messageText, contentType, contentUri, width, height, + false /*onlySingleAttachment*/); + } + + public MediaPickerMessagePartData(final Rect startRect, final String contentType, + final Uri contentUri, final int width, final int height, + final boolean onlySingleAttachment) { + this(startRect, null /* messageText */, contentType, contentUri, width, height, + onlySingleAttachment); + } + + public MediaPickerMessagePartData(final Rect startRect, final String messageText, + final String contentType, final Uri contentUri, final int width, final int height, + final boolean onlySingleAttachment) { + super(messageText, contentType, contentUri, width, height, onlySingleAttachment); + mStartRect = startRect; + } + + /** + * @return The starting rect to animate the attachment preview from in order to perform a smooth + * transition + */ + public Rect getStartRect() { + return mStartRect; + } + + /** + * Modify the start rect of the attachment. + */ + public void setStartRect(final Rect startRect) { + mStartRect.set(startRect); + } +} diff --git a/src/com/android/messaging/datamodel/data/MessageData.java b/src/com/android/messaging/datamodel/data/MessageData.java new file mode 100644 index 0000000..a3698a9 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MessageData.java @@ -0,0 +1,922 @@ +/* + * 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.data; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.Dates; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MessageData implements Parcelable { + private static final String[] sProjection = { + MessageColumns._ID, + MessageColumns.CONVERSATION_ID, + MessageColumns.SENDER_PARTICIPANT_ID, + MessageColumns.SELF_PARTICIPANT_ID, + MessageColumns.SENT_TIMESTAMP, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SEEN, + MessageColumns.READ, + MessageColumns.PROTOCOL, + MessageColumns.STATUS, + MessageColumns.SMS_MESSAGE_URI, + MessageColumns.SMS_PRIORITY, + MessageColumns.SMS_MESSAGE_SIZE, + MessageColumns.MMS_SUBJECT, + MessageColumns.MMS_TRANSACTION_ID, + MessageColumns.MMS_CONTENT_LOCATION, + MessageColumns.MMS_EXPIRY, + MessageColumns.RAW_TELEPHONY_STATUS, + MessageColumns.RETRY_START_TIMESTAMP, + }; + + private static final int INDEX_ID = 0; + private static final int INDEX_CONVERSATION_ID = 1; + private static final int INDEX_PARTICIPANT_ID = 2; + private static final int INDEX_SELF_ID = 3; + private static final int INDEX_SENT_TIMESTAMP = 4; + private static final int INDEX_RECEIVED_TIMESTAMP = 5; + private static final int INDEX_SEEN = 6; + private static final int INDEX_READ = 7; + private static final int INDEX_PROTOCOL = 8; + private static final int INDEX_BUGLE_STATUS = 9; + private static final int INDEX_SMS_MESSAGE_URI = 10; + private static final int INDEX_SMS_PRIORITY = 11; + private static final int INDEX_SMS_MESSAGE_SIZE = 12; + private static final int INDEX_MMS_SUBJECT = 13; + private static final int INDEX_MMS_TRANSACTION_ID = 14; + private static final int INDEX_MMS_CONTENT_LOCATION = 15; + private static final int INDEX_MMS_EXPIRY = 16; + private static final int INDEX_RAW_TELEPHONY_STATUS = 17; + private static final int INDEX_RETRY_START_TIMESTAMP = 18; + + // SQL statement to insert a "complete" message row (columns based on the projection above). + private static final String INSERT_MESSAGE_SQL = + "INSERT INTO " + DatabaseHelper.MESSAGES_TABLE + " ( " + + TextUtils.join(", ", Arrays.copyOfRange(sProjection, 1, + INDEX_RETRY_START_TIMESTAMP + 1)) + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + private String mMessageId; + private String mConversationId; + private String mParticipantId; + private String mSelfId; + private long mSentTimestamp; + private long mReceivedTimestamp; + private boolean mSeen; + private boolean mRead; + private int mProtocol; + private Uri mSmsMessageUri; + private int mSmsPriority; + private long mSmsMessageSize; + private String mMmsSubject; + private String mMmsTransactionId; + private String mMmsContentLocation; + private long mMmsExpiry; + private int mRawStatus; + private int mStatus; + private final ArrayList<MessagePartData> mParts; + private long mRetryStartTimestamp; + + // PROTOCOL Values + public static final int PROTOCOL_UNKNOWN = -1; // Unknown type + public static final int PROTOCOL_SMS = 0; // SMS message + public static final int PROTOCOL_MMS = 1; // MMS message + public static final int PROTOCOL_MMS_PUSH_NOTIFICATION = 2; // MMS WAP push notification + + // Bugle STATUS Values + public static final int BUGLE_STATUS_UNKNOWN = 0; + + // Outgoing + public static final int BUGLE_STATUS_OUTGOING_COMPLETE = 1; + public static final int BUGLE_STATUS_OUTGOING_DELIVERED = 2; + // Transitions to either YET_TO_SEND or SEND_AFTER_PROCESSING depending attachments. + public static final int BUGLE_STATUS_OUTGOING_DRAFT = 3; + public static final int BUGLE_STATUS_OUTGOING_YET_TO_SEND = 4; + public static final int BUGLE_STATUS_OUTGOING_SENDING = 5; + public static final int BUGLE_STATUS_OUTGOING_RESENDING = 6; + public static final int BUGLE_STATUS_OUTGOING_AWAITING_RETRY = 7; + public static final int BUGLE_STATUS_OUTGOING_FAILED = 8; + public static final int BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER = 9; + + // Incoming + public static final int BUGLE_STATUS_INCOMING_COMPLETE = 100; + public static final int BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD = 101; + public static final int BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD = 102; + public static final int BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING = 103; + public static final int BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD = 104; + public static final int BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING = 105; + public static final int BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED = 106; + public static final int BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE = 107; + + public static final String getStatusDescription(int status) { + switch (status) { + case BUGLE_STATUS_UNKNOWN: + return "UNKNOWN"; + case BUGLE_STATUS_OUTGOING_COMPLETE: + return "OUTGOING_COMPLETE"; + case BUGLE_STATUS_OUTGOING_DELIVERED: + return "OUTGOING_DELIVERED"; + case BUGLE_STATUS_OUTGOING_DRAFT: + return "OUTGOING_DRAFT"; + case BUGLE_STATUS_OUTGOING_YET_TO_SEND: + return "OUTGOING_YET_TO_SEND"; + case BUGLE_STATUS_OUTGOING_SENDING: + return "OUTGOING_SENDING"; + case BUGLE_STATUS_OUTGOING_RESENDING: + return "OUTGOING_RESENDING"; + case BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + return "OUTGOING_AWAITING_RETRY"; + case BUGLE_STATUS_OUTGOING_FAILED: + return "OUTGOING_FAILED"; + case BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + return "OUTGOING_FAILED_EMERGENCY_NUMBER"; + case BUGLE_STATUS_INCOMING_COMPLETE: + return "INCOMING_COMPLETE"; + case BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + return "INCOMING_YET_TO_MANUAL_DOWNLOAD"; + case BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + return "INCOMING_RETRYING_MANUAL_DOWNLOAD"; + case BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + return "INCOMING_MANUAL_DOWNLOADING"; + case BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + return "INCOMING_RETRYING_AUTO_DOWNLOAD"; + case BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + return "INCOMING_AUTO_DOWNLOADING"; + case BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + return "INCOMING_DOWNLOAD_FAILED"; + case BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + return "INCOMING_EXPIRED_OR_NOT_AVAILABLE"; + default: + return String.valueOf(status) + " (check MessageData)"; + } + } + + // All incoming messages expect to have status >= BUGLE_STATUS_FIRST_INCOMING + public static final int BUGLE_STATUS_FIRST_INCOMING = BUGLE_STATUS_INCOMING_COMPLETE; + + // Detailed MMS failures. Most of the values are defined in PduHeaders. However, a few are + // defined here instead. These are never returned in the MMS HTTP response, but are used + // internally. The values here must not conflict with any of the existing PduHeader values. + public static final int RAW_TELEPHONY_STATUS_UNDEFINED = MmsUtils.PDU_HEADER_VALUE_UNDEFINED; + public static final int RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG = 10000; + + // Unknown result code for MMS sending/downloading. This is used as the default value + // for result code returned from platform MMS API. + public static final int UNKNOWN_RESULT_CODE = 0; + + /** + * Create an "empty" message + */ + public MessageData() { + mParts = new ArrayList<MessagePartData>(); + } + + public static String[] getProjection() { + return sProjection; + } + + /** + * Create a draft message for a particular conversation based on supplied content + */ + public static MessageData createDraftMessage(final String conversationId, + final String selfId, final MessageData content) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + message.mProtocol = PROTOCOL_UNKNOWN; + message.mConversationId = conversationId; + message.mParticipantId = selfId; + message.mReceivedTimestamp = System.currentTimeMillis(); + if (content == null) { + message.mParts.add(MessagePartData.createTextMessagePart("")); + } else { + if (!TextUtils.isEmpty(content.mParticipantId)) { + message.mParticipantId = content.mParticipantId; + } + if (!TextUtils.isEmpty(content.mMmsSubject)) { + message.mMmsSubject = content.mMmsSubject; + } + for (final MessagePartData part : content.getParts()) { + message.mParts.add(part); + } + } + message.mSelfId = selfId; + return message; + } + + /** + * Create a draft sms message for a particular conversation + */ + public static MessageData createDraftSmsMessage(final String conversationId, + final String selfId, final String messageText) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + message.mProtocol = PROTOCOL_SMS; + message.mConversationId = conversationId; + message.mParticipantId = selfId; + message.mSelfId = selfId; + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + message.mReceivedTimestamp = System.currentTimeMillis(); + return message; + } + + /** + * Create a draft mms message for a particular conversation + */ + public static MessageData createDraftMmsMessage(final String conversationId, + final String selfId, final String messageText, final String subjectText) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + message.mProtocol = PROTOCOL_MMS; + message.mConversationId = conversationId; + message.mParticipantId = selfId; + message.mSelfId = selfId; + message.mMmsSubject = subjectText; + message.mReceivedTimestamp = System.currentTimeMillis(); + if (!TextUtils.isEmpty(messageText)) { + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + } + return message; + } + + /** + * Create a message received from a particular number in a particular conversation + */ + public static MessageData createReceivedSmsMessage(final Uri uri, final String conversationId, + final String participantId, final String selfId, final String messageText, + final String subject, final long sent, final long recieved, + final boolean seen, final boolean read) { + final MessageData message = new MessageData(); + message.mSmsMessageUri = uri; + message.mConversationId = conversationId; + message.mParticipantId = participantId; + message.mSelfId = selfId; + message.mProtocol = PROTOCOL_SMS; + message.mStatus = BUGLE_STATUS_INCOMING_COMPLETE; + message.mMmsSubject = subject; + message.mReceivedTimestamp = recieved; + message.mSentTimestamp = sent; + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + message.mSeen = seen; + message.mRead = read; + return message; + } + + /** + * Create a message not yet associated with a particular conversation + */ + public static MessageData createSharedMessage(final String messageText) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + if (!TextUtils.isEmpty(messageText)) { + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + } + return message; + } + + /** + * Create a message from Sms table fields + */ + public static MessageData createSmsMessage(final String messageUri, final String participantId, + final String selfId, final String conversationId, final int bugleStatus, + final boolean seen, final boolean read, final long sent, + final long recieved, final String messageText) { + final MessageData message = new MessageData(); + message.mParticipantId = participantId; + message.mSelfId = selfId; + message.mConversationId = conversationId; + message.mSentTimestamp = sent; + message.mReceivedTimestamp = recieved; + message.mSeen = seen; + message.mRead = read; + message.mProtocol = PROTOCOL_SMS; + message.mStatus = bugleStatus; + message.mSmsMessageUri = Uri.parse(messageUri); + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + return message; + } + + /** + * Create a message from Mms table fields + */ + public static MessageData createMmsMessage(final String messageUri, final String participantId, + final String selfId, final String conversationId, final boolean isNotification, + final int bugleStatus, final String contentLocation, final String transactionId, + final int smsPriority, final String subject, final boolean seen, final boolean read, + final long size, final int rawStatus, final long expiry, final long sent, + final long received) { + final MessageData message = new MessageData(); + message.mParticipantId = participantId; + message.mSelfId = selfId; + message.mConversationId = conversationId; + message.mSentTimestamp = sent; + message.mReceivedTimestamp = received; + message.mMmsContentLocation = contentLocation; + message.mMmsTransactionId = transactionId; + message.mSeen = seen; + message.mRead = read; + message.mStatus = bugleStatus; + message.mProtocol = (isNotification ? PROTOCOL_MMS_PUSH_NOTIFICATION : PROTOCOL_MMS); + message.mSmsMessageUri = Uri.parse(messageUri); + message.mSmsPriority = smsPriority; + message.mSmsMessageSize = size; + message.mMmsSubject = subject; + message.mMmsExpiry = expiry; + message.mRawStatus = rawStatus; + if (bugleStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD || + bugleStatus == BUGLE_STATUS_OUTGOING_RESENDING) { + // Set the retry start timestamp if this message is already in process of retrying + // Either as autodownload is starting or sending already in progress (MMS update) + message.mRetryStartTimestamp = received; + } + return message; + } + + public void addPart(final MessagePartData part) { + if (part instanceof PendingAttachmentData) { + // Pending attachments may only be added to shared message data that's not associated + // with any particular conversation, in order to store shared images. + Assert.isTrue(mConversationId == null); + } + mParts.add(part); + } + + public Iterable<MessagePartData> getParts() { + return mParts; + } + + public void bind(final Cursor cursor) { + mMessageId = cursor.getString(INDEX_ID); + mConversationId = cursor.getString(INDEX_CONVERSATION_ID); + mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID); + mSelfId = cursor.getString(INDEX_SELF_ID); + mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP); + mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP); + mSeen = (cursor.getInt(INDEX_SEEN) != 0); + mRead = (cursor.getInt(INDEX_READ) != 0); + mProtocol = cursor.getInt(INDEX_PROTOCOL); + mStatus = cursor.getInt(INDEX_BUGLE_STATUS); + final String smsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI); + mSmsMessageUri = (smsMessageUri == null) ? null : Uri.parse(smsMessageUri); + mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY); + mSmsMessageSize = cursor.getLong(INDEX_SMS_MESSAGE_SIZE); + mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY); + mRawStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS); + mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT); + mMmsTransactionId = cursor.getString(INDEX_MMS_TRANSACTION_ID); + mMmsContentLocation = cursor.getString(INDEX_MMS_CONTENT_LOCATION); + mRetryStartTimestamp = cursor.getLong(INDEX_RETRY_START_TIMESTAMP); + } + + /** + * Bind to the draft message data for a conversation. The conversation's self id is used as + * the draft's self id. + */ + public void bindDraft(final Cursor cursor, final String conversationSelfId) { + bind(cursor); + mSelfId = conversationSelfId; + } + + protected static String getParticipantId(final Cursor cursor) { + return cursor.getString(INDEX_PARTICIPANT_ID); + } + + public void populate(final ContentValues values) { + values.put(MessageColumns.CONVERSATION_ID, mConversationId); + values.put(MessageColumns.SENDER_PARTICIPANT_ID, mParticipantId); + values.put(MessageColumns.SELF_PARTICIPANT_ID, mSelfId); + values.put(MessageColumns.SENT_TIMESTAMP, mSentTimestamp); + values.put(MessageColumns.RECEIVED_TIMESTAMP, mReceivedTimestamp); + values.put(MessageColumns.SEEN, mSeen ? 1 : 0); + values.put(MessageColumns.READ, mRead ? 1 : 0); + values.put(MessageColumns.PROTOCOL, mProtocol); + values.put(MessageColumns.STATUS, mStatus); + final String smsMessageUri = ((mSmsMessageUri == null) ? null : mSmsMessageUri.toString()); + values.put(MessageColumns.SMS_MESSAGE_URI, smsMessageUri); + values.put(MessageColumns.SMS_PRIORITY, mSmsPriority); + values.put(MessageColumns.SMS_MESSAGE_SIZE, mSmsMessageSize); + values.put(MessageColumns.MMS_EXPIRY, mMmsExpiry); + values.put(MessageColumns.MMS_SUBJECT, mMmsSubject); + values.put(MessageColumns.MMS_TRANSACTION_ID, mMmsTransactionId); + values.put(MessageColumns.MMS_CONTENT_LOCATION, mMmsContentLocation); + values.put(MessageColumns.RAW_TELEPHONY_STATUS, mRawStatus); + values.put(MessageColumns.RETRY_START_TIMESTAMP, mRetryStartTimestamp); + } + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + public SQLiteStatement getInsertStatement(final DatabaseWrapper db) { + final SQLiteStatement insert = db.getStatementInTransaction( + DatabaseWrapper.INDEX_INSERT_MESSAGE, INSERT_MESSAGE_SQL); + insert.clearBindings(); + insert.bindString(INDEX_CONVERSATION_ID, mConversationId); + insert.bindString(INDEX_PARTICIPANT_ID, mParticipantId); + insert.bindString(INDEX_SELF_ID, mSelfId); + insert.bindLong(INDEX_SENT_TIMESTAMP, mSentTimestamp); + insert.bindLong(INDEX_RECEIVED_TIMESTAMP, mReceivedTimestamp); + insert.bindLong(INDEX_SEEN, mSeen ? 1 : 0); + insert.bindLong(INDEX_READ, mRead ? 1 : 0); + insert.bindLong(INDEX_PROTOCOL, mProtocol); + insert.bindLong(INDEX_BUGLE_STATUS, mStatus); + if (mSmsMessageUri != null) { + insert.bindString(INDEX_SMS_MESSAGE_URI, mSmsMessageUri.toString()); + } + insert.bindLong(INDEX_SMS_PRIORITY, mSmsPriority); + insert.bindLong(INDEX_SMS_MESSAGE_SIZE, mSmsMessageSize); + insert.bindLong(INDEX_MMS_EXPIRY, mMmsExpiry); + if (mMmsSubject != null) { + insert.bindString(INDEX_MMS_SUBJECT, mMmsSubject); + } + if (mMmsTransactionId != null) { + insert.bindString(INDEX_MMS_TRANSACTION_ID, mMmsTransactionId); + } + if (mMmsContentLocation != null) { + insert.bindString(INDEX_MMS_CONTENT_LOCATION, mMmsContentLocation); + } + insert.bindLong(INDEX_RAW_TELEPHONY_STATUS, mRawStatus); + insert.bindLong(INDEX_RETRY_START_TIMESTAMP, mRetryStartTimestamp); + return insert; + } + + public final String getMessageId() { + return mMessageId; + } + + public final String getConversationId() { + return mConversationId; + } + + public final String getParticipantId() { + return mParticipantId; + } + + public final String getSelfId() { + return mSelfId; + } + + public final long getSentTimeStamp() { + return mSentTimestamp; + } + + public final long getReceivedTimeStamp() { + return mReceivedTimestamp; + } + + public final String getFormattedReceivedTimeStamp() { + return Dates.getMessageTimeString(mReceivedTimestamp).toString(); + } + + public final int getProtocol() { + return mProtocol; + } + + public final int getStatus() { + return mStatus; + } + + public final Uri getSmsMessageUri() { + return mSmsMessageUri; + } + + public final int getSmsPriority() { + return mSmsPriority; + } + + public final long getSmsMessageSize() { + return mSmsMessageSize; + } + + public final String getMmsSubject() { + return mMmsSubject; + } + + public final void setMmsSubject(final String subject) { + mMmsSubject = subject; + } + + public final String getMmsContentLocation() { + return mMmsContentLocation; + } + + public final String getMmsTransactionId() { + return mMmsTransactionId; + } + + public final boolean getMessageSeen() { + return mSeen; + } + + /** + * For incoming MMS messages this returns the retrieve-status value + * For sent MMS messages this returns the response-status value + * See PduHeaders.java for possible values + * Otherwise (SMS etc) this is RAW_TELEPHONY_STATUS_UNDEFINED + */ + public final int getRawTelephonyStatus() { + return mRawStatus; + } + + public final void setMessageSeen(final boolean hasSeen) { + mSeen = hasSeen; + } + + public final boolean getInResendWindow(final long now) { + final long maxAgeToResend = BugleGservices.get().getLong( + BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS, + BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS_DEFAULT); + final long age = now - mRetryStartTimestamp; + return age < maxAgeToResend; + } + + public final boolean getInDownloadWindow(final long now) { + final long maxAgeToRedownload = BugleGservices.get().getLong( + BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS, + BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS_DEFAULT); + final long age = now - mRetryStartTimestamp; + return age < maxAgeToRedownload; + } + + static boolean getShowDownloadMessage(final int status) { + if (OsUtil.isSecondaryUser()) { + // Secondary users can't download mms's. Mms's are downloaded by bugle running as the + // primary user. + return false; + } + // Should show option for manual download iff status is manual download or failed + return (status == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED || + status == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD || + // If debug is enabled, allow to download an expired or unavailable message. + (DebugUtils.isDebugEnabled() + && status == BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE)); + } + + public boolean canDownloadMessage() { + if (OsUtil.isSecondaryUser()) { + // Secondary users can't download mms's. Mms's are downloaded by bugle running as the + // primary user. + return false; + } + // Can download iff status is retrying auto/manual downloading + return (mStatus == BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD || + mStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD); + } + + public boolean canRedownloadMessage() { + if (OsUtil.isSecondaryUser()) { + // Secondary users can't download mms's. Mms's are downloaded by bugle running as the + // primary user. + return false; + } + // Can redownload iff status is manual download not started or download failed + return (mStatus == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED || + mStatus == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD || + // If debug is enabled, allow to download an expired or unavailable message. + (DebugUtils.isDebugEnabled() + && mStatus == BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE)); + } + + static boolean getShowResendMessage(final int status) { + // Should show option to resend iff status is failed + return (status == BUGLE_STATUS_OUTGOING_FAILED); + } + + static boolean getOneClickResendMessage(final int status, final int rawStatus) { + // Should show option to resend iff status is failed + return (status == BUGLE_STATUS_OUTGOING_FAILED + && rawStatus == RAW_TELEPHONY_STATUS_UNDEFINED); + } + + public boolean canResendMessage() { + // Manual retry allowed only from failed + return (mStatus == BUGLE_STATUS_OUTGOING_FAILED); + } + + public boolean canSendMessage() { + // Sending messages must be in yet_to_send or awaiting_retry state + return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND || + mStatus == BUGLE_STATUS_OUTGOING_AWAITING_RETRY); + } + + public final boolean getYetToSend() { + return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND); + } + + public final boolean getIsMms() { + return mProtocol == MessageData.PROTOCOL_MMS + || mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION; + } + + public static final boolean getIsMmsNotification(final int protocol) { + return (protocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION); + } + + public final boolean getIsMmsNotification() { + return getIsMmsNotification(mProtocol); + } + + public static final boolean getIsSms(final int protocol) { + return protocol == (MessageData.PROTOCOL_SMS); + } + + public final boolean getIsSms() { + return getIsSms(mProtocol); + } + + public static boolean getIsIncoming(final int status) { + return (status >= MessageData.BUGLE_STATUS_FIRST_INCOMING); + } + + public boolean getIsIncoming() { + return getIsIncoming(mStatus); + } + + public long getRetryStartTimestamp() { + return mRetryStartTimestamp; + } + + public final String getMessageText() { + final String separator = System.getProperty("line.separator"); + final StringBuilder text = new StringBuilder(); + for (final MessagePartData part : mParts) { + if (!part.isAttachment() && !TextUtils.isEmpty(part.getText())) { + if (text.length() > 0) { + text.append(separator); + } + text.append(part.getText()); + } + } + return text.toString(); + } + + /** + * Takes all captions from attachments and adds them as a prefix to the first text part or + * appends a text part + */ + public final void consolidateText() { + final String separator = System.getProperty("line.separator"); + final StringBuilder captionText = new StringBuilder(); + MessagePartData firstTextPart = null; + int firstTextPartIndex = -1; + for (int i = 0; i < mParts.size(); i++) { + final MessagePartData part = mParts.get(i); + if (firstTextPart == null && !part.isAttachment()) { + firstTextPart = part; + firstTextPartIndex = i; + } + if (part.isAttachment() && !TextUtils.isEmpty(part.getText())) { + if (captionText.length() > 0) { + captionText.append(separator); + } + captionText.append(part.getText()); + } + } + + if (captionText.length() == 0) { + // Nothing to consolidate + return; + } + + if (firstTextPart == null) { + addPart(MessagePartData.createTextMessagePart(captionText.toString())); + } else { + final String partText = firstTextPart.getText(); + if (partText.length() > 0) { + captionText.append(separator); + captionText.append(partText); + } + mParts.set(firstTextPartIndex, + MessagePartData.createTextMessagePart(captionText.toString())); + } + } + + public final MessagePartData getFirstAttachment() { + for (final MessagePartData part : mParts) { + if (part.isAttachment()) { + return part; + } + } + return null; + } + + /** + * Updates the messageId for this message. + * Can be used to reset the messageId prior to persisting (which will assign a new messageId) + * or can be called on a message that does not yet have a valid messageId to set it. + */ + public void updateMessageId(final String messageId) { + Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId)); + mMessageId = messageId; + + // TODO : This should probably also call updateMessageId on the message parts. We + // may also want to make messages effectively immutable once they have a valid message id. + } + + public final void updateSendingMessage(final String conversationId, final Uri messageUri, + final long timestamp) { + mConversationId = conversationId; + mSmsMessageUri = messageUri; + mRead = true; + mSeen = true; + mReceivedTimestamp = timestamp; + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_YET_TO_SEND; + mRetryStartTimestamp = timestamp; + } + + public final void markMessageManualResend(final long timestamp) { + // Manual send updates timestamp and transitions back to initial sending status. + mReceivedTimestamp = timestamp; + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_SENDING; + } + + public final void markMessageSending(final long timestamp) { + // Initial send + mStatus = BUGLE_STATUS_OUTGOING_SENDING; + mSentTimestamp = timestamp; + } + + public final void markMessageResending(final long timestamp) { + // Auto resend of message + mStatus = BUGLE_STATUS_OUTGOING_RESENDING; + mSentTimestamp = timestamp; + } + + public final void markMessageSent(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_COMPLETE; + } + + public final void markMessageFailed(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_FAILED; + } + + public final void markMessageFailedEmergencyNumber(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER; + } + + public final void markMessageNotSent(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_AWAITING_RETRY; + } + + public final void updateSizesForImageParts() { + for (final MessagePartData part : getParts()) { + part.decodeAndSaveSizeIfImage(false /* saveToStorage */); + } + } + + public final void setRetryStartTimestamp(final long timestamp) { + mRetryStartTimestamp = timestamp; + } + + public final void setRawTelephonyStatus(final int rawStatus) { + mRawStatus = rawStatus; + } + + public boolean hasContent() { + return !TextUtils.isEmpty(mMmsSubject) || + getFirstAttachment() != null || + !TextUtils.isEmpty(getMessageText()); + } + + public final void bindSelfId(final String selfId) { + mSelfId = selfId; + } + + public final void bindParticipantId(final String participantId) { + mParticipantId = participantId; + } + + protected MessageData(final Parcel in) { + mMessageId = in.readString(); + mConversationId = in.readString(); + mParticipantId = in.readString(); + mSelfId = in.readString(); + mSentTimestamp = in.readLong(); + mReceivedTimestamp = in.readLong(); + mSeen = (in.readInt() != 0); + mRead = (in.readInt() != 0); + mProtocol = in.readInt(); + mStatus = in.readInt(); + final String smsMessageUri = in.readString(); + mSmsMessageUri = (smsMessageUri == null ? null : Uri.parse(smsMessageUri)); + mSmsPriority = in.readInt(); + mSmsMessageSize = in.readLong(); + mMmsExpiry = in.readLong(); + mMmsSubject = in.readString(); + mMmsTransactionId = in.readString(); + mMmsContentLocation = in.readString(); + mRawStatus = in.readInt(); + mRetryStartTimestamp = in.readLong(); + + // Read parts + mParts = new ArrayList<MessagePartData>(); + final int partCount = in.readInt(); + for (int i = 0; i < partCount; i++) { + mParts.add((MessagePartData) in.readParcelable(MessagePartData.class.getClassLoader())); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(mMessageId); + dest.writeString(mConversationId); + dest.writeString(mParticipantId); + dest.writeString(mSelfId); + dest.writeLong(mSentTimestamp); + dest.writeLong(mReceivedTimestamp); + dest.writeInt(mRead ? 1 : 0); + dest.writeInt(mSeen ? 1 : 0); + dest.writeInt(mProtocol); + dest.writeInt(mStatus); + final String smsMessageUri = (mSmsMessageUri == null) ? null : mSmsMessageUri.toString(); + dest.writeString(smsMessageUri); + dest.writeInt(mSmsPriority); + dest.writeLong(mSmsMessageSize); + dest.writeLong(mMmsExpiry); + dest.writeString(mMmsSubject); + dest.writeString(mMmsTransactionId); + dest.writeString(mMmsContentLocation); + dest.writeInt(mRawStatus); + dest.writeLong(mRetryStartTimestamp); + + // Write parts + dest.writeInt(mParts.size()); + for (final MessagePartData messagePartData : mParts) { + dest.writeParcelable(messagePartData, flags); + } + } + + public static final Parcelable.Creator<MessageData> CREATOR + = new Parcelable.Creator<MessageData>() { + @Override + public MessageData createFromParcel(final Parcel in) { + return new MessageData(in); + } + + @Override + public MessageData[] newArray(final int size) { + return new MessageData[size]; + } + }; + + @Override + public String toString() { + return toString(mMessageId, mParts); + } + + public static String toString(String messageId, List<MessagePartData> parts) { + StringBuilder sb = new StringBuilder(); + if (messageId != null) { + sb.append(messageId); + sb.append(": "); + } + for (MessagePartData part : parts) { + sb.append(part.toString()); + sb.append(" "); + } + return sb.toString(); + } +} diff --git a/src/com/android/messaging/datamodel/data/MessagePartData.java b/src/com/android/messaging/datamodel/data/MessagePartData.java new file mode 100644 index 0000000..fffaca8 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MessagePartData.java @@ -0,0 +1,534 @@ +/* + * 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.data; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteStatement; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.GifTranscoder; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UriUtil; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +/** + * Represents a single message part. Messages consist of one or more parts which may contain + * either text or media. + */ +public class MessagePartData implements Parcelable { + public static final int UNSPECIFIED_SIZE = MessagingContentProvider.UNSPECIFIED_SIZE; + public static final String[] ACCEPTABLE_IMAGE_TYPES = + new String[] { ContentType.IMAGE_JPEG, ContentType.IMAGE_JPG, ContentType.IMAGE_PNG, + ContentType.IMAGE_GIF }; + + private static final String[] sProjection = { + PartColumns._ID, + PartColumns.MESSAGE_ID, + PartColumns.TEXT, + PartColumns.CONTENT_URI, + PartColumns.CONTENT_TYPE, + PartColumns.WIDTH, + PartColumns.HEIGHT, + }; + + private static final int INDEX_ID = 0; + private static final int INDEX_MESSAGE_ID = 1; + private static final int INDEX_TEXT = 2; + private static final int INDEX_CONTENT_URI = 3; + private static final int INDEX_CONTENT_TYPE = 4; + private static final int INDEX_WIDTH = 5; + private static final int INDEX_HEIGHT = 6; + // This isn't part of the projection + private static final int INDEX_CONVERSATION_ID = 7; + + // SQL statement to insert a "complete" message part row (columns based on projection above). + private static final String INSERT_MESSAGE_PART_SQL = + "INSERT INTO " + DatabaseHelper.PARTS_TABLE + " ( " + + TextUtils.join(",", Arrays.copyOfRange(sProjection, 1, INDEX_CONVERSATION_ID)) + + ", " + PartColumns.CONVERSATION_ID + + ") VALUES (?, ?, ?, ?, ?, ?, ?)"; + + // Used for stuff that's ignored or arbitrarily compressed. + private static final long NO_MINIMUM_SIZE = 0; + + private String mPartId; + private String mMessageId; + private String mText; + private Uri mContentUri; + private String mContentType; + private int mWidth; + private int mHeight; + // This kind of part can only be attached once and with no other attachment + private boolean mSinglePartOnly; + + /** Transient data: true if destroy was already called */ + private boolean mDestroyed; + + /** + * Create an "empty" message part + */ + protected MessagePartData() { + this(null, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE); + } + + /** + * Create a populated text message part + */ + protected MessagePartData(final String messageText) { + this(null, messageText, ContentType.TEXT_PLAIN, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE, + false /*singlePartOnly*/); + } + + /** + * Create a populated attachment message part + */ + protected MessagePartData(final String contentType, final Uri contentUri, + final int width, final int height) { + this(null, null, contentType, contentUri, width, height, false /*singlePartOnly*/); + } + + /** + * Create a populated attachment message part, with additional caption text + */ + protected MessagePartData(final String messageText, final String contentType, + final Uri contentUri, final int width, final int height) { + this(null, messageText, contentType, contentUri, width, height, false /*singlePartOnly*/); + } + + /** + * Create a populated attachment message part, with additional caption text, single part only + */ + protected MessagePartData(final String messageText, final String contentType, + final Uri contentUri, final int width, final int height, final boolean singlePartOnly) { + this(null, messageText, contentType, contentUri, width, height, singlePartOnly); + } + + /** + * Create a populated message part + */ + private MessagePartData(final String messageId, final String messageText, + final String contentType, final Uri contentUri, final int width, final int height, + final boolean singlePartOnly) { + mMessageId = messageId; + mText = messageText; + mContentType = contentType; + mContentUri = contentUri; + mWidth = width; + mHeight = height; + mSinglePartOnly = singlePartOnly; + } + + /** + * Create a "text" message part + */ + public static MessagePartData createTextMessagePart(final String messageText) { + return new MessagePartData(messageText); + } + + /** + * Create a "media" message part + */ + public static MessagePartData createMediaMessagePart(final String contentType, + final Uri contentUri, final int width, final int height) { + return new MessagePartData(contentType, contentUri, width, height); + } + + /** + * Create a "media" message part with caption + */ + public static MessagePartData createMediaMessagePart(final String caption, + final String contentType, final Uri contentUri, final int width, final int height) { + return new MessagePartData(null, caption, contentType, contentUri, width, height, + false /*singlePartOnly*/ + ); + } + + /** + * Create an empty "text" message part + */ + public static MessagePartData createEmptyMessagePart() { + return new MessagePartData(""); + } + + /** + * Creates a new message part reading from the cursor + */ + public static MessagePartData createFromCursor(final Cursor cursor) { + final MessagePartData part = new MessagePartData(); + part.bind(cursor); + return part; + } + + public static String[] getProjection() { + return sProjection; + } + + /** + * Updates the part id. + * Can be used to reset the partId just prior to persisting (which will assign a new partId) + * or can be called on a part that does not yet have a valid part id to set it. + */ + public void updatePartId(final String partId) { + Assert.isTrue(TextUtils.isEmpty(partId) || TextUtils.isEmpty(mPartId)); + mPartId = partId; + } + + /** + * Updates the messageId for the part. + * Can be used to reset the messageId prior to persisting (which will assign a new messageId) + * or can be called on a part that does not yet have a valid messageId to set it. + */ + public void updateMessageId(final String messageId) { + Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId)); + mMessageId = messageId; + } + + protected static String getMessageId(final Cursor cursor) { + return cursor.getString(INDEX_MESSAGE_ID); + } + + protected void bind(final Cursor cursor) { + mPartId = cursor.getString(INDEX_ID); + mMessageId = cursor.getString(INDEX_MESSAGE_ID); + mText = cursor.getString(INDEX_TEXT); + mContentUri = UriUtil.uriFromString(cursor.getString(INDEX_CONTENT_URI)); + mContentType = cursor.getString(INDEX_CONTENT_TYPE); + mWidth = cursor.getInt(INDEX_WIDTH); + mHeight = cursor.getInt(INDEX_HEIGHT); + } + + public final void populate(final ContentValues values) { + // Must have a valid messageId on a part + Assert.isTrue(!TextUtils.isEmpty(mMessageId)); + values.put(PartColumns.MESSAGE_ID, mMessageId); + values.put(PartColumns.TEXT, mText); + values.put(PartColumns.CONTENT_URI, UriUtil.stringFromUri(mContentUri)); + values.put(PartColumns.CONTENT_TYPE, mContentType); + if (mWidth != UNSPECIFIED_SIZE) { + values.put(PartColumns.WIDTH, mWidth); + } + if (mHeight != UNSPECIFIED_SIZE) { + values.put(PartColumns.HEIGHT, mHeight); + } + } + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + public SQLiteStatement getInsertStatement(final DatabaseWrapper db, + final String conversationId) { + final SQLiteStatement insert = db.getStatementInTransaction( + DatabaseWrapper.INDEX_INSERT_MESSAGE_PART, INSERT_MESSAGE_PART_SQL); + insert.clearBindings(); + insert.bindString(INDEX_MESSAGE_ID, mMessageId); + if (mText != null) { + insert.bindString(INDEX_TEXT, mText); + } + if (mContentUri != null) { + insert.bindString(INDEX_CONTENT_URI, mContentUri.toString()); + } + if (mContentType != null) { + insert.bindString(INDEX_CONTENT_TYPE, mContentType); + } + insert.bindLong(INDEX_WIDTH, mWidth); + insert.bindLong(INDEX_HEIGHT, mHeight); + insert.bindString(INDEX_CONVERSATION_ID, conversationId); + return insert; + } + + public final String getPartId() { + return mPartId; + } + + public final String getMessageId() { + return mMessageId; + } + + public final String getText() { + return mText; + } + + public final Uri getContentUri() { + return mContentUri; + } + + public boolean isAttachment() { + return mContentUri != null; + } + + public boolean isText() { + return ContentType.isTextType(mContentType); + } + + public boolean isImage() { + return ContentType.isImageType(mContentType); + } + + public boolean isMedia() { + return ContentType.isMediaType(mContentType); + } + + public boolean isVCard() { + return ContentType.isVCardType(mContentType); + } + + public boolean isAudio() { + return ContentType.isAudioType(mContentType); + } + + public boolean isVideo() { + return ContentType.isVideoType(mContentType); + } + + public final String getContentType() { + return mContentType; + } + + public final int getWidth() { + return mWidth; + } + + public final int getHeight() { + return mHeight; + } + + /** + * + * @return true if this part can only exist by itself, with no other attachments + */ + public boolean getSinglePartOnly() { + return mSinglePartOnly; + } + + @Override + public int describeContents() { + return 0; + } + + protected MessagePartData(final Parcel in) { + mMessageId = in.readString(); + mText = in.readString(); + mContentUri = UriUtil.uriFromString(in.readString()); + mContentType = in.readString(); + mWidth = in.readInt(); + mHeight = in.readInt(); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + Assert.isTrue(!mDestroyed); + dest.writeString(mMessageId); + dest.writeString(mText); + dest.writeString(UriUtil.stringFromUri(mContentUri)); + dest.writeString(mContentType); + dest.writeInt(mWidth); + dest.writeInt(mHeight); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof MessagePartData)) { + return false; + } + + MessagePartData lhs = (MessagePartData) o; + return mWidth == lhs.mWidth && mHeight == lhs.mHeight && + TextUtils.equals(mMessageId, lhs.mMessageId) && + TextUtils.equals(mText, lhs.mText) && + TextUtils.equals(mContentType, lhs.mContentType) && + (mContentUri == null ? lhs.mContentUri == null + : mContentUri.equals(lhs.mContentUri)); + } + + @Override public int hashCode() { + int result = 17; + result = 31 * result + mWidth; + result = 31 * result + mHeight; + result = 31 * result + (mMessageId == null ? 0 : mMessageId.hashCode()); + result = 31 * result + (mText == null ? 0 : mText.hashCode()); + result = 31 * result + (mContentType == null ? 0 : mContentType.hashCode()); + result = 31 * result + (mContentUri == null ? 0 : mContentUri.hashCode()); + return result; + } + + public static final Parcelable.Creator<MessagePartData> CREATOR + = new Parcelable.Creator<MessagePartData>() { + @Override + public MessagePartData createFromParcel(final Parcel in) { + return new MessagePartData(in); + } + + @Override + public MessagePartData[] newArray(final int size) { + return new MessagePartData[size]; + } + }; + + protected Uri shouldDestroy() { + // We should never double-destroy. + Assert.isTrue(!mDestroyed); + mDestroyed = true; + Uri contentUri = mContentUri; + mContentUri = null; + mContentType = null; + // Only destroy the image if it's staged in our scratch space. + if (!MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri)) { + contentUri = null; + } + return contentUri; + } + + /** + * If application owns content associated with this part delete it (on background thread) + */ + public void destroyAsync() { + final Uri contentUri = shouldDestroy(); + if (contentUri != null) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + contentUri, null, null); + } + }); + } + } + + /** + * If application owns content associated with this part delete it + */ + public void destroySync() { + final Uri contentUri = shouldDestroy(); + if (contentUri != null) { + Factory.get().getApplicationContext().getContentResolver().delete( + contentUri, null, null); + } + } + + /** + * If this is an image part, decode the image header and potentially save the size to the db. + */ + public void decodeAndSaveSizeIfImage(final boolean saveToStorage) { + if (isImage()) { + final Rect imageSize = ImageUtils.decodeImageBounds( + Factory.get().getApplicationContext(), mContentUri); + if (imageSize.width() != ImageRequest.UNSPECIFIED_SIZE && + imageSize.height() != ImageRequest.UNSPECIFIED_SIZE) { + mWidth = imageSize.width(); + mHeight = imageSize.height(); + if (saveToStorage) { + UpdateMessagePartSizeAction.updateSize(mPartId, mWidth, mHeight); + } + } + } + } + + /** + * Computes the minimum size that this MessagePartData could be compressed/downsampled/encoded + * before sending to meet the maximum message size imposed by the carriers. This is used to + * determine right before sending a message whether a message could possibly be sent. If not + * then the user is given a chance to unselect some/all of the attachments. + * + * TODO: computing the minimum size could be expensive. Should we cache the + * computed value in db to be retrieved later? + * + * @return the carrier-independent minimum size, in bytes. + */ + @DoesNotRunOnMainThread + public long getMinimumSizeInBytesForSending() { + Assert.isNotMainThread(); + if (!isAttachment()) { + // No limit is imposed on non-attachment part (i.e. plain text), so treat it as zero. + return NO_MINIMUM_SIZE; + } else if (isImage()) { + // GIFs are resized by the native transcoder (exposed by GifTranscoder). + if (ImageUtils.isGif(mContentType, mContentUri)) { + final long originalImageSize = UriUtil.getContentSize(mContentUri); + // Wish we could save the size here, but we don't have a part id yet + decodeAndSaveSizeIfImage(false /* saveToStorage */); + return GifTranscoder.canBeTranscoded(mWidth, mHeight) ? + GifTranscoder.estimateFileSizeAfterTranscode(originalImageSize) + : originalImageSize; + } + // Other images should be arbitrarily resized by ImageResizer before sending. + return MmsUtils.MIN_IMAGE_BYTE_SIZE; + } else if (isAudio()) { + // Audios are already recorded with the lowest sampling settings (AMR_NB), so just + // return the file size as the minimum size. + return UriUtil.getContentSize(mContentUri); + } else if (isVideo()) { + final int mediaDurationMs = UriUtil.getMediaDurationMs(mContentUri); + return MmsUtils.MIN_VIDEO_BYTES_PER_SECOND * mediaDurationMs + / TimeUnit.SECONDS.toMillis(1); + } else if (isVCard()) { + // We can't compress vCards. + return UriUtil.getContentSize(mContentUri); + } else { + // This is some unknown media type that we don't know how to handle. Log an error + // and try sending it anyway. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Unknown attachment type " + getContentType()); + return NO_MINIMUM_SIZE; + } + } + + @Override + public String toString() { + if (isText()) { + return LogUtil.sanitizePII(getText()); + } else { + return getContentType() + " (" + getContentUri() + ")"; + } + } + + /** + * + * @return true if this part can only exist by itself, with no other attachments + */ + public boolean isSinglePartOnly() { + return mSinglePartOnly; + } + + public void setSinglePartOnly(final boolean isSinglePartOnly) { + mSinglePartOnly = isSinglePartOnly; + } +} diff --git a/src/com/android/messaging/datamodel/data/ParticipantData.java b/src/com/android/messaging/datamodel/data/ParticipantData.java new file mode 100644 index 0000000..521c354 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ParticipantData.java @@ -0,0 +1,569 @@ +/* + * 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.data; + +import android.content.ContentValues; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v7.mms.MmsManager; +import android.telephony.SubscriptionInfo; +import android.text.TextUtils; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.TextUtil; + +/** + * A class that encapsulates all of the data for a specific participant in a conversation. + */ +public class ParticipantData implements Parcelable { + // We always use -1 as default/invalid sub id although system may give us anything negative + public static final int DEFAULT_SELF_SUB_ID = MmsManager.DEFAULT_SUB_ID; + + // This needs to be something apart from valid or DEFAULT_SELF_SUB_ID + public static final int OTHER_THAN_SELF_SUB_ID = DEFAULT_SELF_SUB_ID - 1; + + // Active slot ids are non-negative. Using -1 to designate to inactive self participants. + public static final int INVALID_SLOT_ID = -1; + + // TODO: may make sense to move this to common place? + public static final long PARTICIPANT_CONTACT_ID_NOT_RESOLVED = -1; + public static final long PARTICIPANT_CONTACT_ID_NOT_FOUND = -2; + + public static class ParticipantsQuery { + public static final String[] PROJECTION = new String[] { + ParticipantColumns._ID, + ParticipantColumns.SUB_ID, + ParticipantColumns.SIM_SLOT_ID, + ParticipantColumns.NORMALIZED_DESTINATION, + ParticipantColumns.SEND_DESTINATION, + ParticipantColumns.DISPLAY_DESTINATION, + ParticipantColumns.FULL_NAME, + ParticipantColumns.FIRST_NAME, + ParticipantColumns.PROFILE_PHOTO_URI, + ParticipantColumns.CONTACT_ID, + ParticipantColumns.LOOKUP_KEY, + ParticipantColumns.BLOCKED, + ParticipantColumns.SUBSCRIPTION_COLOR, + ParticipantColumns.SUBSCRIPTION_NAME, + ParticipantColumns.CONTACT_DESTINATION, + }; + + public static final int INDEX_ID = 0; + public static final int INDEX_SUB_ID = 1; + public static final int INDEX_SIM_SLOT_ID = 2; + public static final int INDEX_NORMALIZED_DESTINATION = 3; + public static final int INDEX_SEND_DESTINATION = 4; + public static final int INDEX_DISPLAY_DESTINATION = 5; + public static final int INDEX_FULL_NAME = 6; + public static final int INDEX_FIRST_NAME = 7; + public static final int INDEX_PROFILE_PHOTO_URI = 8; + public static final int INDEX_CONTACT_ID = 9; + public static final int INDEX_LOOKUP_KEY = 10; + public static final int INDEX_BLOCKED = 11; + public static final int INDEX_SUBSCRIPTION_COLOR = 12; + public static final int INDEX_SUBSCRIPTION_NAME = 13; + public static final int INDEX_CONTACT_DESTINATION = 14; + } + + /** + * @return The MMS unknown sender participant entity + */ + public static String getUnknownSenderDestination() { + // This is a hard coded string rather than a localized one because we don't want it to + // change when you change locale. + return "\u02BCUNKNOWN_SENDER!\u02BC"; + } + + private String mParticipantId; + private int mSubId; + private int mSlotId; + private String mNormalizedDestination; + private String mSendDestination; + private String mDisplayDestination; + private String mContactDestination; + private String mFullName; + private String mFirstName; + private String mProfilePhotoUri; + private long mContactId; + private String mLookupKey; + private int mSubscriptionColor; + private String mSubscriptionName; + private boolean mIsEmailAddress; + private boolean mBlocked; + + // Don't call constructor directly + private ParticipantData() { + } + + public static ParticipantData getFromCursor(final Cursor cursor) { + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = cursor.getString(ParticipantsQuery.INDEX_ID); + pd.mSubId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID); + pd.mSlotId = cursor.getInt(ParticipantsQuery.INDEX_SIM_SLOT_ID); + pd.mNormalizedDestination = cursor.getString( + ParticipantsQuery.INDEX_NORMALIZED_DESTINATION); + pd.mSendDestination = cursor.getString(ParticipantsQuery.INDEX_SEND_DESTINATION); + pd.mDisplayDestination = cursor.getString(ParticipantsQuery.INDEX_DISPLAY_DESTINATION); + pd.mContactDestination = cursor.getString(ParticipantsQuery.INDEX_CONTACT_DESTINATION); + pd.mFullName = cursor.getString(ParticipantsQuery.INDEX_FULL_NAME); + pd.mFirstName = cursor.getString(ParticipantsQuery.INDEX_FIRST_NAME); + pd.mProfilePhotoUri = cursor.getString(ParticipantsQuery.INDEX_PROFILE_PHOTO_URI); + pd.mContactId = cursor.getLong(ParticipantsQuery.INDEX_CONTACT_ID); + pd.mLookupKey = cursor.getString(ParticipantsQuery.INDEX_LOOKUP_KEY); + pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); + pd.mBlocked = cursor.getInt(ParticipantsQuery.INDEX_BLOCKED) != 0; + pd.mSubscriptionColor = cursor.getInt(ParticipantsQuery.INDEX_SUBSCRIPTION_COLOR); + pd.mSubscriptionName = cursor.getString(ParticipantsQuery.INDEX_SUBSCRIPTION_NAME); + pd.maybeSetupUnknownSender(); + return pd; + } + + public static ParticipantData getFromId(final DatabaseWrapper dbWrapper, + final String participantId) { + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " =?", + new String[] { participantId }, null, null, null); + + if (cursor.moveToFirst()) { + return ParticipantData.getFromCursor(cursor); + } else { + return null; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + public static ParticipantData getFromRecipientEntry(final RecipientEntry recipientEntry) { + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = null; + pd.mSubId = OTHER_THAN_SELF_SUB_ID; + pd.mSlotId = INVALID_SLOT_ID; + pd.mSendDestination = TextUtil.replaceUnicodeDigits(recipientEntry.getDestination()); + pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); + pd.mNormalizedDestination = pd.mIsEmailAddress ? + pd.mSendDestination : + PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + pd.mDisplayDestination = pd.mIsEmailAddress ? + pd.mNormalizedDestination : + PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); + pd.mFullName = recipientEntry.getDisplayName(); + pd.mFirstName = null; + pd.mProfilePhotoUri = (recipientEntry.getPhotoThumbnailUri() == null) ? null : + recipientEntry.getPhotoThumbnailUri().toString(); + pd.mContactId = recipientEntry.getContactId(); + if (pd.mContactId < 0) { + // ParticipantData only supports real contact ids (>=0) based on faith that the contacts + // provider will continue to only use non-negative ids. The UI uses contactId < 0 for + // special handling. We convert those to 'not resolved' + pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + } + pd.mLookupKey = recipientEntry.getLookupKey(); + pd.mBlocked = false; + pd.mSubscriptionColor = Color.TRANSPARENT; + pd.mSubscriptionName = null; + pd.maybeSetupUnknownSender(); + return pd; + } + + // Shared code for getFromRawPhoneBySystemLocale and getFromRawPhoneBySimLocale + private static ParticipantData getFromRawPhone(final String phoneNumber) { + Assert.isTrue(phoneNumber != null); + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = null; + pd.mSubId = OTHER_THAN_SELF_SUB_ID; + pd.mSlotId = INVALID_SLOT_ID; + pd.mSendDestination = TextUtil.replaceUnicodeDigits(phoneNumber); + pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); + pd.mFullName = null; + pd.mFirstName = null; + pd.mProfilePhotoUri = null; + pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + pd.mLookupKey = null; + pd.mBlocked = false; + pd.mSubscriptionColor = Color.TRANSPARENT; + pd.mSubscriptionName = null; + return pd; + } + + /** + * Get an instance from a raw phone number and using system locale to normalize it. + * + * Use this when creating a participant that is for displaying UI and not associated + * with a specific SIM. For example, when creating a conversation using user entered + * phone number. + * + * @param phoneNumber The raw phone number + * @return instance + */ + public static ParticipantData getFromRawPhoneBySystemLocale(final String phoneNumber) { + final ParticipantData pd = getFromRawPhone(phoneNumber); + pd.mNormalizedDestination = pd.mIsEmailAddress ? + pd.mSendDestination : + PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + pd.mDisplayDestination = pd.mIsEmailAddress ? + pd.mNormalizedDestination : + PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); + pd.maybeSetupUnknownSender(); + return pd; + } + + /** + * Get an instance from a raw phone number and using SIM or system locale to normalize it. + * + * Use this when creating a participant that is associated with a specific SIM. For example, + * the sender of a received message or the recipient of a sending message that is already + * targeted at a specific SIM. + * + * @param phoneNumber The raw phone number + * @return instance + */ + public static ParticipantData getFromRawPhoneBySimLocale( + final String phoneNumber, final int subId) { + final ParticipantData pd = getFromRawPhone(phoneNumber); + pd.mNormalizedDestination = pd.mIsEmailAddress ? + pd.mSendDestination : + PhoneUtils.get(subId).getCanonicalBySimLocale(pd.mSendDestination); + pd.mDisplayDestination = pd.mIsEmailAddress ? + pd.mNormalizedDestination : + PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); + pd.maybeSetupUnknownSender(); + return pd; + } + + public static ParticipantData getSelfParticipant(final int subId) { + Assert.isTrue(subId != OTHER_THAN_SELF_SUB_ID); + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = null; + pd.mSubId = subId; + pd.mSlotId = INVALID_SLOT_ID; + pd.mIsEmailAddress = false; + pd.mSendDestination = null; + pd.mNormalizedDestination = null; + pd.mDisplayDestination = null; + pd.mFullName = null; + pd.mFirstName = null; + pd.mProfilePhotoUri = null; + pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + pd.mLookupKey = null; + pd.mBlocked = false; + pd.mSubscriptionColor = Color.TRANSPARENT; + pd.mSubscriptionName = null; + return pd; + } + + private void maybeSetupUnknownSender() { + if (isUnknownSender()) { + // Because your locale may change, we setup the display string for the unknown sender + // on the fly rather than relying on the version in the database. + final Resources resources = Factory.get().getApplicationContext().getResources(); + mDisplayDestination = resources.getString(R.string.unknown_sender); + mFullName = mDisplayDestination; + } + } + + public String getNormalizedDestination() { + return mNormalizedDestination; + } + + public String getSendDestination() { + return mSendDestination; + } + + public String getDisplayDestination() { + return mDisplayDestination; + } + + public String getContactDestination() { + return mContactDestination; + } + + public String getFullName() { + return mFullName; + } + + public String getFirstName() { + return mFirstName; + } + + public String getDisplayName(final boolean preferFullName) { + if (preferFullName) { + // Prefer full name over first name + if (!TextUtils.isEmpty(mFullName)) { + return mFullName; + } + if (!TextUtils.isEmpty(mFirstName)) { + return mFirstName; + } + } else { + // Prefer first name over full name + if (!TextUtils.isEmpty(mFirstName)) { + return mFirstName; + } + if (!TextUtils.isEmpty(mFullName)) { + return mFullName; + } + } + + // Fallback to the display destination + if (!TextUtils.isEmpty(mDisplayDestination)) { + return mDisplayDestination; + } + + return Factory.get().getApplicationContext().getResources().getString( + R.string.unknown_sender); + } + + public String getProfilePhotoUri() { + return mProfilePhotoUri; + } + + public long getContactId() { + return mContactId; + } + + public String getLookupKey() { + return mLookupKey; + } + + public boolean updatePhoneNumberForSelfIfChanged() { + final String phoneNumber = + PhoneUtils.get(mSubId).getCanonicalForSelf(true/*allowOverride*/); + boolean changed = false; + if (isSelf() && !TextUtils.equals(phoneNumber, mNormalizedDestination)) { + mNormalizedDestination = phoneNumber; + mSendDestination = phoneNumber; + mDisplayDestination = mIsEmailAddress ? + phoneNumber : + PhoneUtils.getDefault().formatForDisplay(phoneNumber); + changed = true; + } + return changed; + } + + public boolean updateSubscriptionInfoForSelfIfChanged(final SubscriptionInfo subscriptionInfo) { + boolean changed = false; + if (isSelf()) { + if (subscriptionInfo == null) { + // The subscription is inactive. Check if the participant is still active. + if (isActiveSubscription()) { + mSlotId = INVALID_SLOT_ID; + mSubscriptionColor = Color.TRANSPARENT; + mSubscriptionName = ""; + changed = true; + } + } else { + final int slotId = subscriptionInfo.getSimSlotIndex(); + final int color = subscriptionInfo.getIconTint(); + final CharSequence name = subscriptionInfo.getDisplayName(); + if (mSlotId != slotId || mSubscriptionColor != color || mSubscriptionName != name) { + mSlotId = slotId; + mSubscriptionColor = color; + mSubscriptionName = name.toString(); + changed = true; + } + } + } + return changed; + } + + public void setFullName(final String fullName) { + mFullName = fullName; + } + + public void setFirstName(final String firstName) { + mFirstName = firstName; + } + + public void setProfilePhotoUri(final String profilePhotoUri) { + mProfilePhotoUri = profilePhotoUri; + } + + public void setContactId(final long contactId) { + mContactId = contactId; + } + + public void setLookupKey(final String lookupKey) { + mLookupKey = lookupKey; + } + + public void setSendDestination(final String destination) { + mSendDestination = destination; + } + + public void setContactDestination(final String destination) { + mContactDestination = destination; + } + + public int getSubId() { + return mSubId; + } + + /** + * @return whether this sub is active. Note that {@link ParticipantData#DEFAULT_SELF_SUB_ID} is + * is considered as active if there is any active SIM. + */ + public boolean isActiveSubscription() { + return mSlotId != INVALID_SLOT_ID; + } + + public boolean isDefaultSelf() { + return mSubId == ParticipantData.DEFAULT_SELF_SUB_ID; + } + + public int getSlotId() { + return mSlotId; + } + + /** + * Slot IDs in the subscription manager is zero-based, but we want to show it + * as 1-based in UI. + */ + public int getDisplaySlotId() { + return getSlotId() + 1; + } + + public int getSubscriptionColor() { + Assert.isTrue(isActiveSubscription()); + // Force the alpha channel to 0xff to ensure the returned color is solid. + return mSubscriptionColor | 0xff000000; + } + + public String getSubscriptionName() { + Assert.isTrue(isActiveSubscription()); + return mSubscriptionName; + } + + public String getId() { + return mParticipantId; + } + + public boolean isSelf() { + return (mSubId != OTHER_THAN_SELF_SUB_ID); + } + + public boolean isEmail() { + return mIsEmailAddress; + } + + public boolean isContactIdResolved() { + return (mContactId != PARTICIPANT_CONTACT_ID_NOT_RESOLVED); + } + + public boolean isBlocked() { + return mBlocked; + } + + public boolean isUnknownSender() { + final String unknownSender = ParticipantData.getUnknownSenderDestination(); + return (TextUtils.equals(mSendDestination, unknownSender)); + } + + public ContentValues toContentValues() { + final ContentValues values = new ContentValues(); + values.put(ParticipantColumns.SUB_ID, mSubId); + values.put(ParticipantColumns.SIM_SLOT_ID, mSlotId); + values.put(DatabaseHelper.ParticipantColumns.SEND_DESTINATION, mSendDestination); + + if (!isUnknownSender()) { + values.put(DatabaseHelper.ParticipantColumns.DISPLAY_DESTINATION, mDisplayDestination); + values.put(DatabaseHelper.ParticipantColumns.NORMALIZED_DESTINATION, + mNormalizedDestination); + values.put(ParticipantColumns.FULL_NAME, mFullName); + values.put(ParticipantColumns.FIRST_NAME, mFirstName); + } + + values.put(ParticipantColumns.PROFILE_PHOTO_URI, mProfilePhotoUri); + values.put(ParticipantColumns.CONTACT_ID, mContactId); + values.put(ParticipantColumns.LOOKUP_KEY, mLookupKey); + values.put(ParticipantColumns.BLOCKED, mBlocked); + values.put(ParticipantColumns.SUBSCRIPTION_COLOR, mSubscriptionColor); + values.put(ParticipantColumns.SUBSCRIPTION_NAME, mSubscriptionName); + return values; + } + + public ParticipantData(final Parcel in) { + mParticipantId = in.readString(); + mSubId = in.readInt(); + mSlotId = in.readInt(); + mNormalizedDestination = in.readString(); + mSendDestination = in.readString(); + mDisplayDestination = in.readString(); + mFullName = in.readString(); + mFirstName = in.readString(); + mProfilePhotoUri = in.readString(); + mContactId = in.readLong(); + mLookupKey = in.readString(); + mIsEmailAddress = in.readInt() != 0; + mBlocked = in.readInt() != 0; + mSubscriptionColor = in.readInt(); + mSubscriptionName = in.readString(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(mParticipantId); + dest.writeInt(mSubId); + dest.writeInt(mSlotId); + dest.writeString(mNormalizedDestination); + dest.writeString(mSendDestination); + dest.writeString(mDisplayDestination); + dest.writeString(mFullName); + dest.writeString(mFirstName); + dest.writeString(mProfilePhotoUri); + dest.writeLong(mContactId); + dest.writeString(mLookupKey); + dest.writeInt(mIsEmailAddress ? 1 : 0); + dest.writeInt(mBlocked ? 1 : 0); + dest.writeInt(mSubscriptionColor); + dest.writeString(mSubscriptionName); + } + + public static final Parcelable.Creator<ParticipantData> CREATOR + = new Parcelable.Creator<ParticipantData>() { + @Override + public ParticipantData createFromParcel(final Parcel in) { + return new ParticipantData(in); + } + + @Override + public ParticipantData[] newArray(final int size) { + return new ParticipantData[size]; + } + }; +} diff --git a/src/com/android/messaging/datamodel/data/ParticipantListItemData.java b/src/com/android/messaging/datamodel/data/ParticipantListItemData.java new file mode 100644 index 0000000..f6c9b5f --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ParticipantListItemData.java @@ -0,0 +1,95 @@ +/* + * 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.data; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.datamodel.action.BugleActionToasts; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.util.AvatarUriUtil; + +/** + * Helps visualize a ParticipantData in a PersonItemView + */ +public class ParticipantListItemData extends PersonItemData { + private final Uri mAvatarUri; + private final String mDisplayName; + private final String mDetails; + private final long mContactId; + private final String mLookupKey; + private final String mNormalizedDestination; + + /** + * Constructor. Takes necessary info from the incoming ParticipantData. + */ + public ParticipantListItemData(final ParticipantData participant) { + mAvatarUri = AvatarUriUtil.createAvatarUri(participant); + mContactId = participant.getContactId(); + mLookupKey = participant.getLookupKey(); + mNormalizedDestination = participant.getNormalizedDestination(); + if (TextUtils.isEmpty(participant.getFullName())) { + mDisplayName = participant.getSendDestination(); + mDetails = null; + } else { + mDisplayName = participant.getFullName(); + mDetails = (participant.isUnknownSender()) ? null : participant.getSendDestination(); + } + } + + @Override + public Uri getAvatarUri() { + return mAvatarUri; + } + + @Override + public String getDisplayName() { + return mDisplayName; + } + + @Override + public String getDetails() { + return mDetails; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return mContactId; + } + + @Override + public String getLookupKey() { + return mLookupKey; + } + + @Override + public String getNormalizedDestination() { + return mNormalizedDestination; + } + + public void unblock(final Context context) { + UpdateDestinationBlockedAction.updateDestinationBlocked( + mNormalizedDestination, false, null, + BugleActionToasts.makeUpdateDestinationBlockedActionListener(context)); + } +} diff --git a/src/com/android/messaging/datamodel/data/PendingAttachmentData.java b/src/com/android/messaging/datamodel/data/PendingAttachmentData.java new file mode 100644 index 0000000..5e079f8 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PendingAttachmentData.java @@ -0,0 +1,176 @@ +/* + * 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.data; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UriUtil; + +/** + * Represents a "pending" message part that acts as a placeholder for the actual attachment being + * loaded. It handles the task to load and persist the attachment from a Uri to local scratch + * folder. This item is not persisted to the database. + */ +public class PendingAttachmentData extends MessagePartData { + /** The pending state. This is the initial state where we haven't started loading yet */ + public static final int STATE_PENDING = 0; + + /** The state for when we are currently loading the attachment to the scratch space */ + public static final int STATE_LOADING = 1; + + /** The attachment has been successfully loaded and no longer pending */ + public static final int STATE_LOADED = 2; + + /** The attachment failed to load */ + public static final int STATE_FAILED = 3; + + private static final int LOAD_MEDIA_TIME_LIMIT_MILLIS = 60 * 1000; // 60s + + /** The current state of the pending attachment. Refer to the STATE_* states above */ + private int mCurrentState; + + /** + * Create a new instance of PendingAttachmentData with an output Uri. + * @param sourceUri the source Uri of the attachment. The Uri maybe temporary or remote, + * so we need to persist it to local storage. + */ + protected PendingAttachmentData(final String caption, final String contentType, + @NonNull final Uri sourceUri, final int width, final int height, + final boolean onlySingleAttachment) { + super(caption, contentType, sourceUri, width, height, onlySingleAttachment); + mCurrentState = STATE_PENDING; + } + + /** + * Creates a pending attachment data that is able to load from the given source uri and + * persist the media resource locally in the scratch folder. + */ + public static PendingAttachmentData createPendingAttachmentData(final String contentType, + final Uri sourceUri) { + return createPendingAttachmentData(null, contentType, sourceUri, UNSPECIFIED_SIZE, + UNSPECIFIED_SIZE); + } + + public static PendingAttachmentData createPendingAttachmentData(final String caption, + final String contentType, final Uri sourceUri, final int width, final int height) { + Assert.isTrue(ContentType.isMediaType(contentType)); + return new PendingAttachmentData(caption, contentType, sourceUri, width, height, + false /*onlySingleAttachment*/); + } + + public static PendingAttachmentData createPendingAttachmentData(final String caption, + final String contentType, final Uri sourceUri, final int width, final int height, + final boolean onlySingleAttachment) { + Assert.isTrue(ContentType.isMediaType(contentType)); + return new PendingAttachmentData(caption, contentType, sourceUri, width, height, + onlySingleAttachment); + } + + public int getCurrentState() { + return mCurrentState; + } + + public void loadAttachmentForDraft(final DraftMessageData draftMessageData, + final String bindingId) { + if (mCurrentState != STATE_PENDING) { + return; + } + mCurrentState = STATE_LOADING; + + // Kick off a SafeAsyncTask to load the content of the media and persist it locally. + // Note: we need to persist the media locally even if it's not remote, because we + // want to be able to resend the media in case the message failed to send. + new SafeAsyncTask<Void, Void, MessagePartData>(LOAD_MEDIA_TIME_LIMIT_MILLIS, + true /* cancelExecutionOnTimeout */) { + @Override + protected MessagePartData doInBackgroundTimed(final Void... params) { + final Uri contentUri = getContentUri(); + final Uri persistedUri = UriUtil.persistContentToScratchSpace(contentUri); + if (persistedUri != null) { + return MessagePartData.createMediaMessagePart( + getText(), + getContentType(), + persistedUri, + getWidth(), + getHeight()); + } + return null; + } + + @Override + protected void onCancelled() { + LogUtil.w(LogUtil.BUGLE_TAG, "Timeout while retrieving media"); + mCurrentState = STATE_FAILED; + if (draftMessageData.isBound(bindingId)) { + draftMessageData.removePendingAttachment(PendingAttachmentData.this); + } + } + + @Override + protected void onPostExecute(final MessagePartData attachment) { + if (attachment != null) { + mCurrentState = STATE_LOADED; + if (draftMessageData.isBound(bindingId)) { + draftMessageData.updatePendingAttachment(attachment, + PendingAttachmentData.this); + } else { + // The draft message data is no longer bound, drop the loaded attachment. + attachment.destroyAsync(); + } + } else { + // Media load failed. We already logged in doInBackground() so don't need to + // do that again. + mCurrentState = STATE_FAILED; + if (draftMessageData.isBound(bindingId)) { + draftMessageData.onPendingAttachmentLoadFailed(PendingAttachmentData.this); + draftMessageData.removePendingAttachment(PendingAttachmentData.this); + } + } + } + }.executeOnThreadPool(); + } + + protected PendingAttachmentData(final Parcel in) { + super(in); + mCurrentState = in.readInt(); + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + out.writeInt(mCurrentState); + } + + public static final Parcelable.Creator<PendingAttachmentData> CREATOR + = new Parcelable.Creator<PendingAttachmentData>() { + @Override + public PendingAttachmentData createFromParcel(final Parcel in) { + return new PendingAttachmentData(in); + } + + @Override + public PendingAttachmentData[] newArray(final int size) { + return new PendingAttachmentData[size]; + } + }; +} diff --git a/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java b/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java new file mode 100644 index 0000000..650a037 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java @@ -0,0 +1,210 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.BugleActionToasts; +import com.android.messaging.datamodel.action.UpdateConversationOptionsAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.List; + +/** + * Services data needs for PeopleAndOptionsFragment. + */ +public class PeopleAndOptionsData extends BindableData implements + LoaderManager.LoaderCallbacks<Cursor> { + public interface PeopleAndOptionsDataListener { + void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor); + void onParticipantsListLoaded(PeopleAndOptionsData data, + List<ParticipantData> participants); + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private final String mConversationId; + private final ConversationParticipantsData mParticipantData; + private LoaderManager mLoaderManager; + private PeopleAndOptionsDataListener mListener; + + public PeopleAndOptionsData(final String conversationId, final Context context, + final PeopleAndOptionsDataListener listener) { + mListener = listener; + mContext = context; + mConversationId = conversationId; + mParticipantData = new ConversationParticipantsData(); + } + + private static final int CONVERSATION_OPTIONS_LOADER = 1; + private static final int PARTICIPANT_LOADER = 2; + + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case CONVERSATION_OPTIONS_LOADER: { + final Uri uri = + MessagingContentProvider.buildConversationMetadataUri(mConversationId); + return new BoundCursorLoader(bindingId, mContext, uri, + PeopleOptionsItemData.PROJECTION, null, null, null); + } + + case PARTICIPANT_LOADER: { + final Uri uri = + MessagingContentProvider + .buildConversationParticipantsUri(mConversationId); + return new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + } + + default: + Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment"); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case CONVERSATION_OPTIONS_LOADER: + mListener.onOptionsCursorUpdated(this, data); + break; + + case PARTICIPANT_LOADER: + mParticipantData.bind(data); + mListener.onParticipantsListLoaded(this, + mParticipantData.getParticipantListExcludingSelf()); + break; + + default: + Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, + "Loader finished after unbinding PeopleAndOptionsFragment"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader<Cursor> loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case CONVERSATION_OPTIONS_LOADER: + mListener.onOptionsCursorUpdated(this, null); + break; + + case PARTICIPANT_LOADER: + mParticipantData.bind(null); + break; + + default: + Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment"); + } + } + + public void init(final LoaderManager loaderManager, + final BindingBase<PeopleAndOptionsData> binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this); + mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER); + mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager = null; + } + } + + public void enableConversationNotifications(final BindingBase<PeopleAndOptionsData> binding, + final boolean enable) { + final String bindingId = binding.getBindingId(); + if (isBound(bindingId)) { + UpdateConversationOptionsAction.enableConversationNotifications( + mConversationId, enable); + } + } + + public void setConversationNotificationSound(final BindingBase<PeopleAndOptionsData> binding, + final String ringtoneUri) { + final String bindingId = binding.getBindingId(); + if (isBound(bindingId)) { + UpdateConversationOptionsAction.setConversationNotificationSound(mConversationId, + ringtoneUri); + } + } + + public void enableConversationNotificationVibration( + final BindingBase<PeopleAndOptionsData> binding, final boolean enable) { + final String bindingId = binding.getBindingId(); + if (isBound(bindingId)) { + UpdateConversationOptionsAction.enableVibrationForConversationNotification( + mConversationId, enable); + } + } + + public void setDestinationBlocked(final BindingBase<PeopleAndOptionsData> binding, + final boolean blocked) { + final String bindingId = binding.getBindingId(); + final ParticipantData participantData = mParticipantData.getOtherParticipant(); + if (isBound(bindingId) && participantData != null) { + UpdateDestinationBlockedAction.updateDestinationBlocked( + participantData.getNormalizedDestination(), + blocked, mConversationId, + BugleActionToasts.makeUpdateDestinationBlockedActionListener(mContext)); + } + } +} diff --git a/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java b/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java new file mode 100644 index 0000000..5af6a30 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java @@ -0,0 +1,155 @@ +/* + * 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.data; + +import android.content.Context; +import android.database.Cursor; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns; +import com.android.messaging.util.Assert; +import com.android.messaging.util.RingtoneUtil; + +public class PeopleOptionsItemData { + public static final String[] PROJECTION = { + ConversationListViewColumns.NOTIFICATION_ENABLED, + ConversationListViewColumns.NOTIFICATION_SOUND_URI, + ConversationListViewColumns.NOTIFICATION_VIBRATION, + }; + + // Column index for query projection. + private static final int INDEX_NOTIFICATION_ENABLED = 0; + private static final int INDEX_NOTIFICATION_SOUND_URI = 1; + private static final int INDEX_NOTIFICATION_VIBRATION = 2; + + // Identification for each setting that's surfaced to the UI layer. + public static final int SETTING_NOTIFICATION_ENABLED = 0; + public static final int SETTING_NOTIFICATION_SOUND_URI = 1; + public static final int SETTING_NOTIFICATION_VIBRATION = 2; + public static final int SETTING_BLOCKED = 3; + public static final int SETTINGS_COUNT = 4; + + // Type of UI switch to show for the toggle button. + public static final int TOGGLE_TYPE_CHECKBOX = 0; + public static final int TOGGLE_TYPE_SWITCH = 1; + + private String mTitle; + private String mSubtitle; + private Uri mRingtoneUri; + private boolean mCheckable; + private boolean mChecked; + private boolean mEnabled; + private int mItemId; + private ParticipantData mOtherParticipant; + + private final Context mContext; + + public PeopleOptionsItemData(final Context context) { + mContext = context; + } + + /** + * Bind to a specific setting column on conversation metadata cursor. (Note + * that it binds to columns because it treats individual columns of the cursor as + * separate options to display for the conversation, e.g. notification settings). + */ + public void bind( + final Cursor cursor, final ParticipantData otherParticipant, final int settingType) { + mSubtitle = null; + mRingtoneUri = null; + mCheckable = true; + mEnabled = true; + mItemId = settingType; + mOtherParticipant = otherParticipant; + + final boolean notificationEnabled = cursor.getInt(INDEX_NOTIFICATION_ENABLED) == 1; + switch (settingType) { + case SETTING_NOTIFICATION_ENABLED: + mTitle = mContext.getString(R.string.notifications_enabled_conversation_pref_title); + mChecked = notificationEnabled; + break; + + case SETTING_NOTIFICATION_SOUND_URI: + mTitle = mContext.getString(R.string.notification_sound_pref_title); + final String ringtoneString = cursor.getString(INDEX_NOTIFICATION_SOUND_URI); + Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(ringtoneString); + + mSubtitle = mContext.getString(R.string.silent_ringtone); + if (ringtoneUri != null) { + final Ringtone ringtone = RingtoneManager.getRingtone(mContext, ringtoneUri); + if (ringtone != null) { + mSubtitle = ringtone.getTitle(mContext); + } + } + mCheckable = false; + mRingtoneUri = ringtoneUri; + mEnabled = notificationEnabled; + break; + + case SETTING_NOTIFICATION_VIBRATION: + mTitle = mContext.getString(R.string.notification_vibrate_pref_title); + mChecked = cursor.getInt(INDEX_NOTIFICATION_VIBRATION) == 1; + mEnabled = notificationEnabled; + break; + + case SETTING_BLOCKED: + Assert.notNull(otherParticipant); + final int resourceId = otherParticipant.isBlocked() ? + R.string.unblock_contact_title : R.string.block_contact_title; + mTitle = mContext.getString(resourceId, otherParticipant.getDisplayDestination()); + mCheckable = false; + break; + + default: + Assert.fail("Unsupported conversation option type!"); + } + } + + public String getTitle() { + return mTitle; + } + + public String getSubtitle() { + return mSubtitle; + } + + public boolean getCheckable() { + return mCheckable; + } + + public boolean getChecked() { + return mChecked; + } + + public boolean getEnabled() { + return mEnabled; + } + + public int getItemId() { + return mItemId; + } + + public Uri getRingtoneUri() { + return mRingtoneUri; + } + + public ParticipantData getOtherParticipant() { + return mOtherParticipant; + } +} diff --git a/src/com/android/messaging/datamodel/data/PersonItemData.java b/src/com/android/messaging/datamodel/data/PersonItemData.java new file mode 100644 index 0000000..a0a1ce8 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PersonItemData.java @@ -0,0 +1,67 @@ +/* + * 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.data; + +import android.content.Intent; +import android.net.Uri; + +import com.android.messaging.datamodel.binding.BindableData; + +/** + * Bridges between any particpant/contact related data and data displayed in the PersonItemView. + */ +public abstract class PersonItemData extends BindableData { + /** + * The UI component that listens for data change and update accordingly. + */ + public interface PersonItemDataListener { + void onPersonDataUpdated(PersonItemData data); + void onPersonDataFailed(PersonItemData data, Exception exception); + } + + private PersonItemDataListener mListener; + + public abstract Uri getAvatarUri(); + public abstract String getDisplayName(); + public abstract String getDetails(); + public abstract Intent getClickIntent(); + public abstract long getContactId(); + public abstract String getLookupKey(); + public abstract String getNormalizedDestination(); + + public void setListener(final PersonItemDataListener listener) { + if (isBound()) { + mListener = listener; + } + } + + protected void notifyDataUpdated() { + if (isBound() && mListener != null) { + mListener.onPersonDataUpdated(this); + } + } + + protected void notifyDataFailed(final Exception exception) { + if (isBound() && mListener != null) { + mListener.onPersonDataFailed(this, exception); + } + } + + @Override + protected void unregisterListeners() { + mListener = null; + } +} diff --git a/src/com/android/messaging/datamodel/data/SelfParticipantsData.java b/src/com/android/messaging/datamodel/data/SelfParticipantsData.java new file mode 100644 index 0000000..43302ed --- /dev/null +++ b/src/com/android/messaging/datamodel/data/SelfParticipantsData.java @@ -0,0 +1,107 @@ +/* + * 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.data; + +import android.database.Cursor; +import android.support.v4.util.ArrayMap; + +import java.util.ArrayList; +import java.util.List; + +import com.android.messaging.util.OsUtil; + +/** + * A class that contains the list of all self participants potentially involved in a conversation. + * This class contains both active/inactive self entries when there is multi-SIM support. + */ +public class SelfParticipantsData { + /** + * The map from self participant ids to self-participant data entries in the participants table. + * This includes both active, inactive and default (with subId == + * {@link ParticipantData#DEFAULT_SELF_SUB_ID}) subscriptions. + */ + private final ArrayMap<String, ParticipantData> mSelfParticipantMap; + + public SelfParticipantsData() { + mSelfParticipantMap = new ArrayMap<String, ParticipantData>(); + } + + public void bind(final Cursor cursor) { + mSelfParticipantMap.clear(); + if (cursor != null) { + while (cursor.moveToNext()) { + final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor); + mSelfParticipantMap.put(newParticipant.getId(), newParticipant); + } + } + } + + /** + * Gets the list of self participants for all subscriptions. + * @param activeOnly if set, returns active self entries only (i.e. those with SIMs plugged in). + */ + public List<ParticipantData> getSelfParticipants(final boolean activeOnly) { + List<ParticipantData> list = new ArrayList<ParticipantData>(); + for (final ParticipantData self : mSelfParticipantMap.values()) { + if (!activeOnly || self.isActiveSubscription()) { + list.add(self); + } + } + return list; + } + + /** + * Gets the self participant corresponding to the given self id. + */ + ParticipantData getSelfParticipantById(final String selfId) { + return mSelfParticipantMap.get(selfId); + } + + /** + * Returns if a given self id represents the default self. + */ + boolean isDefaultSelf(final String selfId) { + if (!OsUtil.isAtLeastL_MR1()) { + return true; + } + final ParticipantData self = getSelfParticipantById(selfId); + return self == null ? false : self.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID; + } + + public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) { + int count = 0; + for (final ParticipantData self : mSelfParticipantMap.values()) { + if (!self.isDefaultSelf() && (!activeOnly || self.isActiveSubscription())) { + count++; + } + } + return count; + } + + public ParticipantData getDefaultSelfParticipant() { + for (final ParticipantData self : mSelfParticipantMap.values()) { + if (self.isDefaultSelf()) { + return self; + } + } + return null; + } + + boolean isLoaded() { + return !mSelfParticipantMap.isEmpty(); + } +} diff --git a/src/com/android/messaging/datamodel/data/SettingsData.java b/src/com/android/messaging/datamodel/data/SettingsData.java new file mode 100644 index 0000000..7474619 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/SettingsData.java @@ -0,0 +1,223 @@ +/* + * 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.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * Services SettingsFragment's data needs for loading active self participants to display + * the list of active subscriptions. + */ +public class SettingsData extends BindableData implements + LoaderManager.LoaderCallbacks<Cursor> { + public interface SettingsDataListener { + void onSelfParticipantDataLoaded(SettingsData data); + } + + public static class SettingsItem { + public static final int TYPE_GENERAL_SETTINGS = 1; + public static final int TYPE_PER_SUBSCRIPTION_SETTINGS = 2; + + private final String mDisplayName; + private final String mDisplayDetail; + private final String mActivityTitle; + private final int mType; + private final int mSubId; + + private SettingsItem(final String displayName, final String displayDetail, + final String activityTitle, final int type, final int subId) { + mDisplayName = displayName; + mDisplayDetail = displayDetail; + mActivityTitle = activityTitle; + mType = type; + mSubId = subId; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getDisplayDetail() { + return mDisplayDetail; + } + + public int getType() { + return mType; + } + + public int getSubId() { + return mSubId; + } + + public String getActivityTitle() { + return mActivityTitle; + } + + public static SettingsItem fromSelfParticipant(final Context context, + final ParticipantData self) { + Assert.isTrue(self.isSelf()); + Assert.isTrue(self.isActiveSubscription()); + final String displayDetail = TextUtils.isEmpty(self.getDisplayDestination()) ? + context.getString(R.string.sim_settings_unknown_number) : + self.getDisplayDestination(); + final String displayName = context.getString(R.string.sim_specific_settings, + self.getSubscriptionName()); + return new SettingsItem(displayName, displayDetail, displayName, + TYPE_PER_SUBSCRIPTION_SETTINGS, self.getSubId()); + } + + public static SettingsItem createGeneralSettingsItem(final Context context) { + return new SettingsItem(context.getString(R.string.general_settings), + null, context.getString(R.string.general_settings_activity_title), + TYPE_GENERAL_SETTINGS, -1); + } + + public static SettingsItem createDefaultMmsSettingsItem(final Context context, + final int subId) { + return new SettingsItem(context.getString(R.string.advanced_settings), + null, context.getString(R.string.advanced_settings_activity_title), + TYPE_PER_SUBSCRIPTION_SETTINGS, subId); + } + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private final SelfParticipantsData mSelfParticipantsData; + private LoaderManager mLoaderManager; + private SettingsDataListener mListener; + + public SettingsData(final Context context, + final SettingsDataListener listener) { + mListener = listener; + mContext = context; + mSelfParticipantsData = new SelfParticipantsData(); + } + + private static final int SELF_PARTICIPANT_LOADER = 1; + + @Override + public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { + Assert.equals(SELF_PARTICIPANT_LOADER, id); + Loader<Cursor> loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns.SUB_ID + " <> ?", + new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Creating self loader after unbinding"); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader<Cursor> generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(data); + mListener.onSelfParticipantDataLoaded(this); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Self loader finished after unbinding"); + } + } + + @Override + public void onLoaderReset(final Loader<Cursor> generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(null); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Self loader reset after unbinding"); + } + } + + public void init(final LoaderManager loaderManager, + final BindingBase<SettingsData> binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER); + mLoaderManager = null; + } + } + + public List<SettingsItem> getSettingsItems() { + final List<ParticipantData> selfs = mSelfParticipantsData.getSelfParticipants(true); + final List<SettingsItem> settingsItems = new ArrayList<SettingsItem>(); + // First goes the general settings, followed by per-subscription settings. + settingsItems.add(SettingsItem.createGeneralSettingsItem(mContext)); + // For per-subscription settings, show the actual SIM name with phone number if the + // platorm is at least L-MR1 and there are multiple active SIMs. + final int activeSubCountExcludingDefault = + mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(true); + if (OsUtil.isAtLeastL_MR1() && activeSubCountExcludingDefault > 0) { + for (ParticipantData self : selfs) { + if (!self.isDefaultSelf()) { + if (activeSubCountExcludingDefault > 1) { + settingsItems.add(SettingsItem.fromSelfParticipant(mContext, self)); + } else { + // This is the only active non-default SIM. + settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext, + self.getSubId())); + break; + } + } + } + } else { + // Either pre-L-MR1, or there's no active SIM, so show the default MMS settings. + settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext, + ParticipantData.DEFAULT_SELF_SUB_ID)); + } + return settingsItems; + } +} diff --git a/src/com/android/messaging/datamodel/data/SubscriptionListData.java b/src/com/android/messaging/datamodel/data/SubscriptionListData.java new file mode 100644 index 0000000..b5d4e4b --- /dev/null +++ b/src/com/android/messaging/datamodel/data/SubscriptionListData.java @@ -0,0 +1,128 @@ +/* + * 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.data; + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * This is a UI facing data model component that holds a list of + * {@link SubscriptionListData.SubscriptionListEntry}'s, one for each *active* subscriptions. + * + * This is used to: + * 1) Show a list of SIMs in the SIM Selector + * 2) Show the currently selected SIM in the compose message view + * 3) Show SIM indicators on conversation message views + * + * It builds on top of SelfParticipantsData and performs additional logic such as determining + * the set of icons to use for the individual Subs. + */ +public class SubscriptionListData { + /** + * Represents a single sub that backs UI. + */ + public static class SubscriptionListEntry { + public final String selfParticipantId; + public final Uri iconUri; + public final Uri selectedIconUri; + public final String displayName; + public final int displayColor; + public final String displayDestination; + + private SubscriptionListEntry(final String selfParticipantId, final Uri iconUri, + final Uri selectedIconUri, final String displayName, final int displayColor, + final String displayDestination) { + this.selfParticipantId = selfParticipantId; + this.iconUri = iconUri; + this.selectedIconUri = selectedIconUri; + this.displayName = displayName; + this.displayColor = displayColor; + this.displayDestination = displayDestination; + } + + static SubscriptionListEntry fromSelfParticipantData( + final ParticipantData selfParticipantData, final Context context) { + Assert.isTrue(selfParticipantData.isSelf()); + Assert.isTrue(selfParticipantData.isActiveSubscription()); + final int slotId = selfParticipantData.getDisplaySlotId(); + final String iconIdentifier = String.format(Locale.getDefault(), "%d", slotId); + final String subscriptionName = selfParticipantData.getSubscriptionName(); + final String displayName = TextUtils.isEmpty(subscriptionName) ? + context.getString(R.string.sim_slot_identifier, slotId) : subscriptionName; + return new SubscriptionListEntry(selfParticipantData.getId(), + AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier, + false /* selected */, false /* incoming */), + AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier, + true /* selected */, false /* incoming */), + displayName, selfParticipantData.getSubscriptionColor(), + selfParticipantData.getDisplayDestination()); + } + } + + private final List<SubscriptionListEntry> mEntriesExcludingDefault; + private SubscriptionListEntry mDefaultEntry; + private final Context mContext; + + public SubscriptionListData(final Context context) { + mEntriesExcludingDefault = new ArrayList<SubscriptionListEntry>(); + mContext = context; + } + + public void bind(final List<ParticipantData> subs) { + mEntriesExcludingDefault.clear(); + mDefaultEntry = null; + for (final ParticipantData sub : subs) { + final SubscriptionListEntry entry = + SubscriptionListEntry.fromSelfParticipantData(sub, mContext); + if (!sub.isDefaultSelf()) { + mEntriesExcludingDefault.add(entry); + } else { + mDefaultEntry = entry; + } + } + } + + public List<SubscriptionListEntry> getActiveSubscriptionEntriesExcludingDefault() { + return mEntriesExcludingDefault; + } + + public SubscriptionListEntry getActiveSubscriptionEntryBySelfId(final String selfId, + final boolean excludeDefault) { + if (mDefaultEntry != null && TextUtils.equals(mDefaultEntry.selfParticipantId, selfId)) { + return excludeDefault ? null : mDefaultEntry; + } + + for (final SubscriptionListEntry entry : mEntriesExcludingDefault) { + if (TextUtils.equals(entry.selfParticipantId, selfId)) { + return entry; + } + } + return null; + } + + public boolean hasData() { + return !mEntriesExcludingDefault.isEmpty() || mDefaultEntry != null; + } +} diff --git a/src/com/android/messaging/datamodel/data/VCardContactItemData.java b/src/com/android/messaging/datamodel/data/VCardContactItemData.java new file mode 100644 index 0000000..8abf493 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/VCardContactItemData.java @@ -0,0 +1,185 @@ +/* + * 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.data; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.datamodel.media.VCardRequestDescriptor; +import com.android.messaging.datamodel.media.VCardResource; +import com.android.messaging.datamodel.media.VCardResourceEntry; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactUtil; + +import java.util.List; + +/** + * Data class for visualizing and loading data for a VCard contact. + */ +public class VCardContactItemData extends PersonItemData + implements MediaResourceLoadListener<VCardResource> { + private final Context mContext; + private final Uri mVCardUri; + private String mDetails; + private final Binding<BindableMediaRequest<VCardResource>> mBinding = + BindingBase.createBinding(this); + private VCardResource mVCardResource; + + private static final Uri sDefaultAvatarUri = + AvatarUriUtil.createAvatarUri(null, null, null, null); + + /** + * Constructor. This parses data from the given MessagePartData describing the vcard + */ + public VCardContactItemData(final Context context, final MessagePartData messagePartData) { + this(context, messagePartData.getContentUri()); + Assert.isTrue(messagePartData.isVCard()); + } + + /** + * Constructor. This parses data from the given VCard Uri + */ + public VCardContactItemData(final Context context, final Uri vCardUri) { + mContext = context; + mDetails = mContext.getString(R.string.loading_vcard); + mVCardUri = vCardUri; + } + + @Override + public Uri getAvatarUri() { + if (hasValidVCard()) { + final List<VCardResourceEntry> vcards = mVCardResource.getVCards(); + Assert.isTrue(vcards.size() > 0); + if (vcards.size() == 1) { + return vcards.get(0).getAvatarUri(); + } + } + return sDefaultAvatarUri; + } + + @Override + public String getDisplayName() { + if (hasValidVCard()) { + final List<VCardResourceEntry> vcards = mVCardResource.getVCards(); + Assert.isTrue(vcards.size() > 0); + if (vcards.size() == 1) { + return vcards.get(0).getDisplayName(); + } else { + return mContext.getResources().getQuantityString( + R.plurals.vcard_multiple_display_name, vcards.size(), vcards.size()); + } + } + return null; + } + + @Override + public String getDetails() { + return mDetails; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + + public VCardResource getVCardResource() { + return hasValidVCard() ? mVCardResource : null; + } + + public Uri getVCardUri() { + return hasValidVCard() ? mVCardUri : null; + } + + public boolean hasValidVCard() { + return isBound() && mVCardResource != null; + } + + @Override + public void bind(final String bindingId) { + super.bind(bindingId); + + // Bind and request the VCard from media resource manager. + mBinding.bind(new VCardRequestDescriptor(mVCardUri).buildAsyncMediaRequest(mContext, this)); + MediaResourceManager.get().requestMediaResourceAsync(mBinding.getData()); + } + + @Override + public void unbind(final String bindingId) { + super.unbind(bindingId); + mBinding.unbind(); + if (mVCardResource != null) { + mVCardResource.release(); + mVCardResource = null; + } + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof VCardContactItemData)) { + return false; + } + + final VCardContactItemData lhs = (VCardContactItemData) o; + return mVCardUri.equals(lhs.mVCardUri); + } + + @Override + public void onMediaResourceLoaded(final MediaRequest<VCardResource> request, + final VCardResource resource, final boolean isCached) { + Assert.isTrue(mVCardResource == null); + mBinding.ensureBound(); + mDetails = mContext.getString(R.string.vcard_tap_hint); + mVCardResource = resource; + mVCardResource.addRef(); + notifyDataUpdated(); + } + + @Override + public void onMediaResourceLoadError(final MediaRequest<VCardResource> request, + final Exception exception) { + mBinding.ensureBound(); + mDetails = mContext.getString(R.string.failed_loading_vcard); + notifyDataFailed(exception); + } +} diff --git a/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java new file mode 100644 index 0000000..380d93c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java @@ -0,0 +1,74 @@ +/* + * 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.media; + +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +import java.util.List; + +/** + * A mix-in style class that wraps around a normal, threading-agnostic MediaRequest object with + * functionalities offered by {@link BindableMediaRequest} to allow for async processing. + */ +class AsyncMediaRequestWrapper<T extends RefCountedMediaResource> extends BindableMediaRequest<T> { + + /** + * Create a new async media request wrapper instance given the listener. + */ + public static <T extends RefCountedMediaResource> AsyncMediaRequestWrapper<T> + createWith(final MediaRequest<T> wrappedRequest, + final MediaResourceLoadListener<T> listener) { + return new AsyncMediaRequestWrapper<T>(listener, wrappedRequest); + } + + private final MediaRequest<T> mWrappedRequest; + + private AsyncMediaRequestWrapper(final MediaResourceLoadListener<T> listener, + final MediaRequest<T> wrappedRequest) { + super(listener); + mWrappedRequest = wrappedRequest; + } + + @Override + public String getKey() { + return mWrappedRequest.getKey(); + } + + @Override + public MediaCache<T> getMediaCache() { + return mWrappedRequest.getMediaCache(); + } + + @Override + public int getRequestType() { + return mWrappedRequest.getRequestType(); + } + + @Override + public T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception { + return mWrappedRequest.loadMediaBlocking(chainedTask); + } + + @Override + public int getCacheId() { + return mWrappedRequest.getCacheId(); + } + + @Override + public MediaRequestDescriptor<T> getDescriptor() { + return mWrappedRequest.getDescriptor(); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java new file mode 100644 index 0000000..719b296 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java @@ -0,0 +1,159 @@ +/* + * 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.media; + +import android.content.Context; +import android.graphics.RectF; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AvatarGroupRequestDescriptor extends CompositeImageRequestDescriptor { + private static final int MAX_GROUP_SIZE = 4; + + public AvatarGroupRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight) { + this(convertToDescriptor(uri, desiredWidth, desiredHeight), desiredWidth, desiredHeight); + } + + public AvatarGroupRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors, + final int desiredWidth, final int desiredHeight) { + super(descriptors, desiredWidth, desiredHeight); + Assert.isTrue(descriptors.size() <= MAX_GROUP_SIZE); + } + + private static List<? extends ImageRequestDescriptor> convertToDescriptor(final Uri uri, + final int desiredWidth, final int desiredHeight) { + final List<String> participantUriStrings = AvatarUriUtil.getGroupParticipantUris(uri); + final List<AvatarRequestDescriptor> avatarDescriptors = + new ArrayList<AvatarRequestDescriptor>(participantUriStrings.size()); + for (final String uriString : participantUriStrings) { + final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor( + Uri.parse(uriString), desiredWidth, desiredHeight); + avatarDescriptors.add(descriptor); + } + return avatarDescriptors; + } + + @Override + public CompositeImageRequest<?> buildBatchImageRequest(final Context context) { + return new CompositeImageRequest<AvatarGroupRequestDescriptor>(context, this); + } + + @Override + public List<RectF> getChildRequestTargetRects() { + return Arrays.asList(generateDestRectArray()); + } + + /** + * Generates an array of {@link RectF} which represents where each of the individual avatar + * should be located in the final group avatar image. The location of each avatar depends on + * the size of the group and the size of the overall group avatar size. + */ + private RectF[] generateDestRectArray() { + final int groupSize = mDescriptors.size(); + final float width = desiredWidth; + final float height = desiredHeight; + final float halfWidth = width / 2F; + final float halfHeight = height / 2F; + final RectF[] destArray = new RectF[groupSize]; + switch (groupSize) { + case 2: + /** + * +-------+ + * | 0 | | + * +-------+ + * | | 1 | + * +-------+ + * + * We want two circles which touches in the center. To get this we know that the + * diagonal of the overall group avatar is squareRoot(2) * w We also know that the + * two circles touches the at the center of the overall group avatar and the + * distance from the center of the circle to the corner of the group avatar is + * radius * squareRoot(2). Therefore, the following emerges. + * + * w * squareRoot(2) = 2 (radius + radius * squareRoot(2)) + * Solving for radius we get: + * d = 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w + * d = (2 - squareRoot(2)) * w + */ + final float diameter = (float) ((2 - Math.sqrt(2)) * width); + destArray[0] = new RectF(0, 0, diameter, diameter); + destArray[1] = new RectF(width - diameter, height - diameter, width, height); + break; + case 3: + /** + * +-------+ + * | | 0 | | + * +-------+ + * | 1 | 2 | + * +-------+ + * i0 + * |\ + * a | \ c + * --- i2 + * b + * + * a = radius * squareRoot(3) due to the triangle being a 30-60-90 right triangle. + * b = radius of circle + * c = 2 * radius of circle + * + * All three of the images are circles and therefore image zero will not touch + * image one or image two. Move image zero down so it touches image one and image + * two. This can be done by keeping image zero in the center and moving it down + * slightly. The amount to move down can be calculated by solving a right triangle. + * We know that the center x of image two to the center x of image zero is the + * radius of the circle, this is the length of edge b. Also we know that the + * distance from image zero to image two's center is 2 * radius, edge c. From this + * we know that the distance from center y of image two to center y of image one, + * edge a, is equal to radius * squareRoot(3) due to this triangle being a 30-60-90 + * right triangle. + */ + final float quarterWidth = width / 4F; + final float threeQuarterWidth = 3 * quarterWidth; + final float radius = height / 4F; + final float imageTwoCenterY = height - radius; + final float lengthOfEdgeA = (float) (radius * Math.sqrt(3)); + final float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA; + final float imageZeroTop = imageZeroCenterY - radius; + final float imageZeroBottom = imageZeroCenterY + radius; + destArray[0] = new RectF( + quarterWidth, imageZeroTop, threeQuarterWidth, imageZeroBottom); + destArray[1] = new RectF(0, halfHeight, halfWidth, height); + destArray[2] = new RectF(halfWidth, halfHeight, width, height); + break; + default: + /** + * +-------+ + * | 0 | 1 | + * +-------+ + * | 2 | 3 | + * +-------+ + */ + destArray[0] = new RectF(0, 0, halfWidth, halfHeight); + destArray[1] = new RectF(halfWidth, 0, width, halfHeight); + destArray[2] = new RectF(0, halfHeight, halfWidth, height); + destArray[3] = new RectF(halfWidth, halfHeight, width, height); + break; + } + return destArray; + } +} diff --git a/src/com/android/messaging/datamodel/media/AvatarRequest.java b/src/com/android/messaging/datamodel/media/AvatarRequest.java new file mode 100644 index 0000000..22d5ccc --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarRequest.java @@ -0,0 +1,189 @@ +/* + * 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.media; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.media.ExifInterface; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UriUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +public class AvatarRequest extends UriImageRequest<AvatarRequestDescriptor> { + private static Bitmap sDefaultPersonBitmap; + private static Bitmap sDefaultPersonBitmapLarge; + + public AvatarRequest(final Context context, + final AvatarRequestDescriptor descriptor) { + super(context, descriptor); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + if (UriUtil.isLocalResourceUri(mDescriptor.uri)) { + return super.getInputStreamForResource(); + } else { + final Uri primaryUri = AvatarUriUtil.getPrimaryUri(mDescriptor.uri); + Assert.isTrue(UriUtil.isLocalResourceUri(primaryUri)); + return mContext.getContentResolver().openInputStream(primaryUri); + } + } + + /** + * We can load multiple types of images for avatars depending on the uri. The uri should be + * built by {@link com.android.messaging.util.AvatarUriUtil} which will decide on + * what uri to build based on the available profile photo and name. Here we will check if the + * image is a local resource (ie profile photo uri), if the resource isn't a local one we will + * generate a tile with the first letter of the name. + */ + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks) + throws IOException { + Assert.isNotMainThread(); + String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri); + Bitmap bitmap = null; + int orientation = ExifInterface.ORIENTATION_NORMAL; + final boolean isLocalResourceUri = UriUtil.isLocalResourceUri(mDescriptor.uri) || + AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI.equals(avatarType); + if (isLocalResourceUri) { + try { + ImageResource imageResource = super.loadMediaInternal(chainedTasks); + bitmap = imageResource.getBitmap(); + orientation = imageResource.mOrientation; + } catch (Exception ex) { + // If we encountered any exceptions trying to load the local avatar resource, + // fall back to generated avatar. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "AvatarRequest: failed to load local avatar " + + "resource, switching to fallback rendering", ex); + } + } + + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + // Check to see if we already got the bitmap. If not get a fallback avatar + if (bitmap == null) { + Uri generatedUri = mDescriptor.uri; + if (isLocalResourceUri) { + // If we are here, we just failed to load the local resource. Use the fallback Uri + // if possible. + generatedUri = AvatarUriUtil.getFallbackUri(mDescriptor.uri); + if (generatedUri == null) { + // No fallback Uri was provided, use the default avatar. + generatedUri = AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR; + } + } + + avatarType = AvatarUriUtil.getAvatarType(generatedUri); + if (AvatarUriUtil.TYPE_LETTER_TILE_URI.equals(avatarType)) { + final String name = AvatarUriUtil.getName(generatedUri); + bitmap = renderLetterTile(name, width, height); + } else { + bitmap = renderDefaultAvatar(width, height); + } + } + return new DecodedImageResource(getKey(), bitmap, orientation); + } + + private Bitmap renderDefaultAvatar(final int width, final int height) { + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, + getBackgroundColor()); + final Canvas canvas = new Canvas(bitmap); + + if (sDefaultPersonBitmap == null) { + final BitmapDrawable defaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_light); + sDefaultPersonBitmap = defaultPerson.getBitmap(); + } + if (sDefaultPersonBitmapLarge == null) { + final BitmapDrawable largeDefaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_light_large); + sDefaultPersonBitmapLarge = largeDefaultPerson.getBitmap(); + } + + Bitmap defaultPerson = null; + if (mDescriptor.isWearBackground) { + final BitmapDrawable wearDefaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_wear); + defaultPerson = wearDefaultPerson.getBitmap(); + } else { + final boolean isLargeDefault = (width > sDefaultPersonBitmap.getWidth()) || + (height > sDefaultPersonBitmap.getHeight()); + defaultPerson = + isLargeDefault ? sDefaultPersonBitmapLarge : sDefaultPersonBitmap; + } + + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + final Matrix matrix = new Matrix(); + final RectF source = new RectF(0, 0, defaultPerson.getWidth(), defaultPerson.getHeight()); + final RectF dest = new RectF(0, 0, width, height); + matrix.setRectToRect(source, dest, Matrix.ScaleToFit.FILL); + + canvas.drawBitmap(defaultPerson, matrix, paint); + + return bitmap; + } + + private Bitmap renderLetterTile(final String name, final int width, final int height) { + final float halfWidth = width / 2; + final float halfHeight = height / 2; + final int minOfWidthAndHeight = Math.min(width, height); + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, + getBackgroundColor()); + final Resources resources = mContext.getResources(); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL)); + paint.setColor(resources.getColor(R.color.letter_tile_font_color)); + final float letterToTileRatio = resources.getFraction(R.dimen.letter_to_tile_ratio, 1, 1); + paint.setTextSize(letterToTileRatio * minOfWidthAndHeight); + + final String firstCharString = name.substring(0, 1).toUpperCase(); + final Rect textBound = new Rect(); + paint.getTextBounds(firstCharString, 0, 1, textBound); + + final Canvas canvas = new Canvas(bitmap); + final float xOffset = halfWidth - textBound.centerX(); + final float yOffset = halfHeight - textBound.centerY(); + canvas.drawText(firstCharString, xOffset, yOffset, paint); + + return bitmap; + } + + private int getBackgroundColor() { + return mContext.getResources().getColor(R.color.primary_color); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java new file mode 100644 index 0000000..9afa9ad --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java @@ -0,0 +1,60 @@ +/* + * 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.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +public class AvatarRequestDescriptor extends UriImageRequestDescriptor { + final boolean isWearBackground; + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight) { + this(uri, desiredWidth, desiredHeight, true /* cropToCircle */); + } + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final boolean cropToCircle) { + this(uri, desiredWidth, desiredHeight, cropToCircle, false /* isWearBackground */); + } + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, boolean cropToCircle, boolean isWearBackground) { + super(uri, desiredWidth, desiredHeight, false /* allowCompression */, true /* isStatic */, + cropToCircle, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + Assert.isTrue(uri == null || UriUtil.isLocalResourceUri(uri) || + AvatarUriUtil.isAvatarUri(uri)); + this.isWearBackground = isWearBackground; + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + final String avatarType = uri == null ? null : AvatarUriUtil.getAvatarType(uri); + if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)) { + return new SimSelectorAvatarRequest(context, this); + } else { + return new AvatarRequest(context, this); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/BindableMediaRequest.java b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java new file mode 100644 index 0000000..36521d5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java @@ -0,0 +1,63 @@ +/* + * 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.media; + +import com.android.messaging.datamodel.binding.BindableOnceData; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +/** + * The {@link MediaRequest} interface is threading-model-blind, allowing the implementations to + * be processed synchronously or asynchronously. + * This is a {@link MediaRequest} implementation that includes functionalities such as binding and + * event callbacks for multi-threaded media request processing. + */ +public abstract class BindableMediaRequest<T extends RefCountedMediaResource> + extends BindableOnceData + implements MediaRequest<T>, MediaResourceLoadListener<T> { + private MediaResourceLoadListener<T> mListener; + + public BindableMediaRequest(final MediaResourceLoadListener<T> listener) { + mListener = listener; + } + + /** + * Delegates the media resource callback to the listener. Performs binding check to ensure + * the listener is still bound to this request. + */ + @Override + public void onMediaResourceLoaded(final MediaRequest<T> request, final T resource, + final boolean cached) { + if (isBound() && mListener != null) { + mListener.onMediaResourceLoaded(request, resource, cached); + } + } + + /** + * Delegates the media resource callback to the listener. Performs binding check to ensure + * the listener is still bound to this request. + */ + @Override + public void onMediaResourceLoadError(final MediaRequest<T> request, final Exception exception) { + if (isBound() && mListener != null) { + mListener.onMediaResourceLoadError(request, exception); + } + } + + @Override + protected void unregisterListeners() { + mListener = null; + } +} diff --git a/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java new file mode 100644 index 0000000..c41ba60 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java @@ -0,0 +1,54 @@ +/* + * 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.media; + +import com.android.messaging.util.Assert; + +/** + * An implementation of {@link MediaCacheManager} that creates caches specific to Bugle's needs. + * + * To create a new type of cache, add to the list of cache ids and create a new MediaCache<> + * for your cache id / media resource type in createMediaCacheById(). + */ +public class BugleMediaCacheManager extends MediaCacheManager { + // List of available cache ids. + public static final int DEFAULT_IMAGE_CACHE = 1; + public static final int AVATAR_IMAGE_CACHE = 2; + public static final int VCARD_CACHE = 3; + + // VCard cache size - we compute the size by count, not by bytes. + private static final int VCARD_CACHE_SIZE = 5; + private static final int SHARED_IMAGE_CACHE_SIZE = 1024 * 10; // 10MB + + @Override + protected MediaCache<?> createMediaCacheById(final int id) { + switch (id) { + case DEFAULT_IMAGE_CACHE: + return new PoolableImageCache(SHARED_IMAGE_CACHE_SIZE, id, "DefaultImageCache"); + + case AVATAR_IMAGE_CACHE: + return new PoolableImageCache(id, "AvatarImageCache"); + + case VCARD_CACHE: + return new MediaCache<VCardResource>(VCARD_CACHE_SIZE, id, "VCardCache"); + + default: + Assert.fail("BugleMediaCacheManager: unsupported cache id " + id); + break; + } + return null; + } +} diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequest.java b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java new file mode 100644 index 0000000..66f1bff --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java @@ -0,0 +1,109 @@ +/* + * 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.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.media.ExifInterface; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImageUtils; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.List; + +/** + * Requests a composite image resource. The composite image resource is constructed by first + * sequentially requesting a number of sub image resources specified by + * {@link CompositeImageRequestDescriptor#getChildRequestDescriptors()}. After this, the + * individual sub images are composed into the final image onto their respective target rects + * returned by {@link CompositeImageRequestDescriptor#getChildRequestTargetRects()}. + */ +public class CompositeImageRequest<D extends CompositeImageRequestDescriptor> + extends ImageRequest<D> { + private final Bitmap mBitmap; + private final Canvas mCanvas; + private final Paint mPaint; + + public CompositeImageRequest(final Context context, final D descriptor) { + super(context, descriptor); + mBitmap = getBitmapPool().createOrReuseBitmap( + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + mCanvas = new Canvas(mBitmap); + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask) { + final List<? extends ImageRequestDescriptor> descriptors = + mDescriptor.getChildRequestDescriptors(); + final List<RectF> targetRects = mDescriptor.getChildRequestTargetRects(); + Assert.equals(descriptors.size(), targetRects.size()); + Assert.isTrue(descriptors.size() > 1); + + for (int i = 0; i < descriptors.size(); i++) { + final MediaRequest<ImageResource> request = + descriptors.get(i).buildSyncMediaRequest(mContext); + // Synchronously request the child image. + final ImageResource resource = + MediaResourceManager.get().requestMediaResourceSync(request); + if (resource != null) { + try { + final RectF avatarDestOnGroup = targetRects.get(i); + + // Draw the bitmap into a smaller size with a circle mask. + final Bitmap resourceBitmap = resource.getBitmap(); + final RectF resourceRect = new RectF( + 0, 0, resourceBitmap.getWidth(), resourceBitmap.getHeight()); + final Bitmap smallCircleBitmap = getBitmapPool().createOrReuseBitmap( + Math.round(avatarDestOnGroup.width()), + Math.round(avatarDestOnGroup.height())); + final RectF smallCircleRect = new RectF( + 0, 0, smallCircleBitmap.getWidth(), smallCircleBitmap.getHeight()); + final Canvas smallCircleCanvas = new Canvas(smallCircleBitmap); + ImageUtils.drawBitmapWithCircleOnCanvas(resource.getBitmap(), smallCircleCanvas, + resourceRect, smallCircleRect, null /* bitmapPaint */, + false /* fillBackground */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + final Matrix matrix = new Matrix(); + matrix.setRectToRect(smallCircleRect, avatarDestOnGroup, + Matrix.ScaleToFit.FILL); + mCanvas.drawBitmap(smallCircleBitmap, matrix, mPaint); + } finally { + resource.release(); + } + } + } + + return new DecodedImageResource(getKey(), mBitmap, ExifInterface.ORIENTATION_NORMAL); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + throw new IllegalStateException("Composite image request doesn't support input stream!"); + } +} diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java new file mode 100644 index 0000000..071130e --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java @@ -0,0 +1,61 @@ +/* + * 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.media; + +import android.content.Context; +import android.graphics.RectF; + +import com.google.common.base.Joiner; + +import java.util.List; + +public abstract class CompositeImageRequestDescriptor extends ImageRequestDescriptor { + protected final List<? extends ImageRequestDescriptor> mDescriptors; + private final String mKey; + + public CompositeImageRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors, + final int desiredWidth, final int desiredHeight) { + super(desiredWidth, desiredHeight); + mDescriptors = descriptors; + + final String[] keyParts = new String[descriptors.size()]; + for (int i = 0; i < descriptors.size(); i++) { + keyParts[i] = descriptors.get(i).getKey(); + } + mKey = Joiner.on(",").skipNulls().join(keyParts); + } + + /** + * Gets a key that uniquely identify all the underlying image resource to be loaded (e.g. Uri or + * file path). + */ + @Override + public String getKey() { + return mKey; + } + + public List<? extends ImageRequestDescriptor> getChildRequestDescriptors(){ + return mDescriptors; + } + + public abstract List<RectF> getChildRequestTargetRects(); + public abstract CompositeImageRequest<?> buildBatchImageRequest(final Context context); + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + return buildBatchImageRequest(context); + } +} diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntry.java b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java new file mode 100644 index 0000000..aee9fdc --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java @@ -0,0 +1,48 @@ +/* + * 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.media; + +import android.accounts.Account; +import android.support.v4.util.ArrayMap; + +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardProperty; + +import java.util.Map; + +/** + * Class which extends VCardEntry to add support for unknown properties. Currently there is a TODO + * to add this in the VCardEntry code, but we have to extend it to add the needed support + */ +public class CustomVCardEntry extends VCardEntry { + // List of properties keyed by their name for easy lookup + private final Map<String, VCardProperty> mAllProperties; + + public CustomVCardEntry(int vCardType, Account account) { + super(vCardType, account); + mAllProperties = new ArrayMap<String, VCardProperty>(); + } + + @Override + public void addProperty(VCardProperty property) { + super.addProperty(property); + mAllProperties.put(property.getName(), property); + } + + public VCardProperty getProperty(String name) { + return mAllProperties.get(name); + } +} diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java new file mode 100644 index 0000000..06b10a3 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java @@ -0,0 +1,133 @@ +/* + * 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.media; + +import android.accounts.Account; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardProperty; + +import java.util.ArrayList; +import java.util.List; + +public class CustomVCardEntryConstructor implements VCardInterpreter { + + public interface EntryHandler { + /** + * Called when the parsing started. + */ + public void onStart(); + + /** + * The method called when one vCard entry is created. Children come before their parent in + * nested vCard files. + * + * e.g. + * In the following vCard, the entry for "entry2" comes before one for "entry1". + * <code> + * BEGIN:VCARD + * N:entry1 + * BEGIN:VCARD + * N:entry2 + * END:VCARD + * END:VCARD + * </code> + */ + public void onEntryCreated(final CustomVCardEntry entry); + + /** + * Called when the parsing ended. + * Able to be use this method for showing performance log, etc. + */ + public void onEnd(); + } + + /** + * Represents current stack of VCardEntry. Used to support nested vCard (vCard 2.1). + */ + private final List<CustomVCardEntry> mEntryStack = new ArrayList<CustomVCardEntry>(); + private CustomVCardEntry mCurrentEntry; + + private final int mVCardType; + private final Account mAccount; + + private final List<EntryHandler> mEntryHandlers = new ArrayList<EntryHandler>(); + + public CustomVCardEntryConstructor() { + this(VCardConfig.VCARD_TYPE_V21_GENERIC, null); + } + + public CustomVCardEntryConstructor(final int vcardType) { + this(vcardType, null); + } + + public CustomVCardEntryConstructor(final int vcardType, final Account account) { + mVCardType = vcardType; + mAccount = account; + } + + public void addEntryHandler(EntryHandler entryHandler) { + mEntryHandlers.add(entryHandler); + } + + @Override + public void onVCardStarted() { + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onStart(); + } + } + + @Override + public void onVCardEnded() { + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEnd(); + } + } + + public void clear() { + mCurrentEntry = null; + mEntryStack.clear(); + } + + @Override + public void onEntryStarted() { + mCurrentEntry = new CustomVCardEntry(mVCardType, mAccount); + mEntryStack.add(mCurrentEntry); + } + + @Override + public void onEntryEnded() { + mCurrentEntry.consolidateFields(); + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEntryCreated(mCurrentEntry); + } + + final int size = mEntryStack.size(); + if (size > 1) { + CustomVCardEntry parent = mEntryStack.get(size - 2); + parent.addChild(mCurrentEntry); + mCurrentEntry = parent; + } else { + mCurrentEntry = null; + } + mEntryStack.remove(size - 1); + } + + @Override + public void onPropertyCreated(VCardProperty property) { + mCurrentEntry.addProperty(property); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/DecodedImageResource.java b/src/com/android/messaging/datamodel/media/DecodedImageResource.java new file mode 100644 index 0000000..3627ba4 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/DecodedImageResource.java @@ -0,0 +1,254 @@ +/* + * 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.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +import com.android.messaging.ui.OrientedBitmapDrawable; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.List; + + +/** + * Container class for holding a bitmap resource used by the MediaResourceManager. This resource + * can both be cached (albeit not very storage-efficiently) and directly used by the UI. + */ +public class DecodedImageResource extends ImageResource { + private static final int BITMAP_QUALITY = 100; + private static final int COMPRESS_QUALITY = 50; + + private Bitmap mBitmap; + private final int mOrientation; + private boolean mCacheable = true; + + public DecodedImageResource(final String key, final Bitmap bitmap, int orientation) { + super(key, orientation); + mBitmap = bitmap; + mOrientation = orientation; + } + + /** + * Gets the contained bitmap. + */ + @Override + public Bitmap getBitmap() { + acquireLock(); + try { + return mBitmap; + } finally { + releaseLock(); + } + } + + /** + * Attempt to reuse the bitmap in the image resource and repurpose it for something else. + * After this, the image resource will relinquish ownership on the bitmap resource so that + * it doesn't try to recycle it when getting closed. + */ + @Override + public Bitmap reuseBitmap() { + acquireLock(); + try { + assertSingularRefCount(); + final Bitmap retBitmap = mBitmap; + mBitmap = null; + return retBitmap; + } finally { + releaseLock(); + } + } + + @Override + public boolean supportsBitmapReuse() { + return true; + } + + @Override + public byte[] getBytes() { + acquireLock(); + try { + return ImageUtils.bitmapToBytes(mBitmap, BITMAP_QUALITY); + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error trying to get the bitmap bytes " + e); + } finally { + releaseLock(); + } + return null; + } + + /** + * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants + */ + @Override + public int getOrientation() { + return mOrientation; + } + + @Override + public int getMediaSize() { + acquireLock(); + try { + Assert.notNull(mBitmap); + if (OsUtil.isAtLeastKLP()) { + return mBitmap.getAllocationByteCount(); + } else { + return mBitmap.getRowBytes() * mBitmap.getHeight(); + } + } finally { + releaseLock(); + } + } + + @Override + protected void close() { + acquireLock(); + try { + if (mBitmap != null) { + mBitmap.recycle(); + mBitmap = null; + } + } finally { + releaseLock(); + } + } + + @Override + public Drawable getDrawable(Resources resources) { + acquireLock(); + try { + Assert.notNull(mBitmap); + return OrientedBitmapDrawable.create(getOrientation(), resources, mBitmap); + } finally { + releaseLock(); + } + } + + @Override + boolean isCacheable() { + return mCacheable; + } + + public void setCacheable(final boolean cacheable) { + mCacheable = cacheable; + } + + @SuppressWarnings("unchecked") + @Override + MediaRequest<? extends RefCountedMediaResource> getMediaEncodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + Assert.isFalse(isEncoded()); + if (getBitmap().hasAlpha()) { + // We can't compress images with alpha, as JPEG encoding doesn't support this. + return null; + } + return new EncodeImageRequest((MediaRequest<ImageResource>) originalRequest); + } + + /** + * A MediaRequest that encodes the contained image resource. + */ + private class EncodeImageRequest implements MediaRequest<ImageResource> { + private final MediaRequest<ImageResource> mOriginalImageRequest; + + public EncodeImageRequest(MediaRequest<ImageResource> originalImageRequest) { + mOriginalImageRequest = originalImageRequest; + // Hold a ref onto the encoded resource before the request finishes. + DecodedImageResource.this.addRef(); + } + + @Override + public String getKey() { + return DecodedImageResource.this.getKey(); + } + + @Override + @DoesNotRunOnMainThread + public ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedRequests) + throws Exception { + Assert.isNotMainThread(); + acquireLock(); + Bitmap scaledBitmap = null; + try { + Bitmap bitmap = getBitmap(); + Assert.isFalse(bitmap.hasAlpha()); + final int bitmapWidth = bitmap.getWidth(); + final int bitmapHeight = bitmap.getHeight(); + // The original bitmap was loaded using sub-sampling which was fast in terms of + // loading speed, but not optimized for caching, encoding and rendering (since + // bitmap resizing to fit the UI image views happens on the UI thread and should + // be avoided if possible). Therefore, try to resize the bitmap to the exact desired + // size before compressing it. + if (bitmapWidth > 0 && bitmapHeight > 0 && + mOriginalImageRequest instanceof ImageRequest<?>) { + final ImageRequestDescriptor descriptor = + ((ImageRequest<?>) mOriginalImageRequest).getDescriptor(); + final float targetScale = Math.max( + (float) descriptor.desiredWidth / bitmapWidth, + (float) descriptor.desiredHeight / bitmapHeight); + final int targetWidth = (int) (bitmapWidth * targetScale); + final int targetHeight = (int) (bitmapHeight * targetScale); + // Only try to scale down the image to the desired size. + if (targetScale < 1.0f && targetWidth > 0 && targetHeight > 0 && + targetWidth != bitmapWidth && targetHeight != bitmapHeight) { + scaledBitmap = bitmap = + Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, false); + } + } + byte[] encodedBytes = ImageUtils.bitmapToBytes(bitmap, COMPRESS_QUALITY); + return new EncodedImageResource(getKey(), encodedBytes, getOrientation()); + } catch (Exception ex) { + // Something went wrong during bitmap compression, fall back to just using the + // original bitmap. + LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "Error compressing bitmap", ex); + return DecodedImageResource.this; + } finally { + if (scaledBitmap != null && scaledBitmap != getBitmap()) { + scaledBitmap.recycle(); + scaledBitmap = null; + } + releaseLock(); + release(); + } + } + + @Override + public MediaCache<ImageResource> getMediaCache() { + return mOriginalImageRequest.getMediaCache(); + } + + @Override + public int getCacheId() { + return mOriginalImageRequest.getCacheId(); + } + + @Override + public int getRequestType() { + return REQUEST_ENCODE_MEDIA; + } + + @Override + public MediaRequestDescriptor<ImageResource> getDescriptor() { + return mOriginalImageRequest.getDescriptor(); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/EncodedImageResource.java b/src/com/android/messaging/datamodel/media/EncodedImageResource.java new file mode 100644 index 0000000..0bc94e5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/EncodedImageResource.java @@ -0,0 +1,162 @@ +/* + * 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.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Drawable; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; + +import java.util.Arrays; +import java.util.List; + +/** + * A cache-facing image resource that's much more compact than the raw Bitmap objects stored in + * {@link com.android.messaging.datamodel.media.DecodedImageResource}. + * + * This resource is created from a regular Bitmap-based ImageResource before being pushed to + * {@link com.android.messaging.datamodel.media.MediaCache}, if the image request + * allows for resource encoding/compression. + * + * During resource retrieval on cache hit, + * {@link #getMediaDecodingRequest(MediaRequest)} is invoked to create a async + * decode task, which decodes the compressed byte array back to a regular image resource to + * be consumed by the UI. + */ +public class EncodedImageResource extends ImageResource { + private final byte[] mImageBytes; + + public EncodedImageResource(String key, byte[] imageBytes, int orientation) { + super(key, orientation); + mImageBytes = imageBytes; + } + + @Override + @DoesNotRunOnMainThread + public Bitmap getBitmap() { + acquireLock(); + try { + // This should only be called during the decode request. + Assert.isNotMainThread(); + return BitmapFactory.decodeByteArray(mImageBytes, 0, mImageBytes.length); + } finally { + releaseLock(); + } + } + + @Override + public byte[] getBytes() { + acquireLock(); + try { + return Arrays.copyOf(mImageBytes, mImageBytes.length); + } finally { + releaseLock(); + } + } + + @Override + public Bitmap reuseBitmap() { + return null; + } + + @Override + public boolean supportsBitmapReuse() { + return false; + } + + @Override + public int getMediaSize() { + return mImageBytes.length; + } + + @Override + protected void close() { + } + + @Override + public Drawable getDrawable(Resources resources) { + return null; + } + + @Override + boolean isEncoded() { + return true; + } + + @Override + MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + Assert.isTrue(isEncoded()); + return new DecodeImageRequest(); + } + + /** + * A MediaRequest that decodes the encoded image resource. This class is chained to the + * original media request that requested the image, so it inherits the listener and + * properties such as binding. + */ + private class DecodeImageRequest implements MediaRequest<ImageResource> { + public DecodeImageRequest() { + // Hold a ref onto the encoded resource before the request finishes. + addRef(); + } + + @Override + public String getKey() { + return EncodedImageResource.this.getKey(); + } + + @Override + @DoesNotRunOnMainThread + public ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedTask) + throws Exception { + Assert.isNotMainThread(); + acquireLock(); + try { + final Bitmap decodedBitmap = BitmapFactory.decodeByteArray(mImageBytes, 0, + mImageBytes.length); + return new DecodedImageResource(getKey(), decodedBitmap, getOrientation()); + } finally { + releaseLock(); + release(); + } + } + + @Override + public MediaCache<ImageResource> getMediaCache() { + // Decoded resource is non-cachable, it's for UI consumption only (for now at least) + return null; + } + + @Override + public int getCacheId() { + return 0; + } + + @Override + public int getRequestType() { + return REQUEST_DECODE_MEDIA; + } + + @Override + public MediaRequestDescriptor<ImageResource> getDescriptor() { + return null; + } + } +} diff --git a/src/com/android/messaging/datamodel/media/FileImageRequest.java b/src/com/android/messaging/datamodel/media/FileImageRequest.java new file mode 100644 index 0000000..31c053a --- /dev/null +++ b/src/com/android/messaging/datamodel/media/FileImageRequest.java @@ -0,0 +1,108 @@ +/* + * 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.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.ExifInterface; + +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; + +/** + * Serves file system based image requests. Since file paths can be expressed in Uri form, this + * extends regular UriImageRequest but performs additional optimizations such as loading thumbnails + * directly from Exif information. + */ +public class FileImageRequest extends UriImageRequest { + private final String mPath; + private final boolean mCanUseThumbnail; + + public FileImageRequest(final Context context, + final FileImageRequestDescriptor descriptor) { + super(context, descriptor); + mPath = descriptor.path; + mCanUseThumbnail = descriptor.canUseThumbnail; + } + + @Override + protected Bitmap loadBitmapInternal() + throws IOException { + // Before using the FileInputStream, check if the Exif has a thumbnail that we can use. + if (mCanUseThumbnail) { + byte[] thumbnail = null; + try { + final ExifInterface exif = new ExifInterface(mPath); + if (exif.hasThumbnail()) { + thumbnail = exif.getThumbnail(); + } + } catch (final IOException e) { + // Nothing to do + } + + if (thumbnail != null) { + final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool( + false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */); + // First, check dimensions of the bitmap. + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, options); + + // Calculate inSampleSize + options.inSampleSize = ImageUtils.get().calculateInSampleSize(options, + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + + options.inJustDecodeBounds = false; + + // Actually decode the bitmap, optionally using the bitmap pool. + try { + // Get the orientation. We should be able to get the orientation from + // the thumbnail itself but at least on some phones, the thumbnail + // doesn't have an orientation tag. So use the outer image's orientation + // tag and hope for the best. + mOrientation = ImageUtils.getOrientation(getInputStreamForResource()); + if (com.android.messaging.util.exif.ExifInterface. + getOrientationParams(mOrientation).invertDimensions) { + mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth); + } else { + mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight); + } + final ReusableImageResourcePool bitmapPool = getBitmapPool(); + if (bitmapPool == null) { + return BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, + options); + } else { + final int sampledWidth = options.outWidth / options.inSampleSize; + final int sampledHeight = options.outHeight / options.inSampleSize; + return bitmapPool.decodeByteArray(thumbnail, options, sampledWidth, + sampledHeight); + } + } catch (IOException ex) { + // If the thumbnail is broken due to IOException, this will + // fall back to default bitmap loading. + LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "FileImageRequest: failed to load " + + "thumbnail from Exif", ex); + } + } + } + + // Fall back to default InputStream-based loading if no thumbnails could be retrieved. + return super.loadBitmapInternal(); + } +} diff --git a/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java new file mode 100644 index 0000000..00105f5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java @@ -0,0 +1,68 @@ +/* + * 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.media; + +import android.content.Context; + +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +/** + * Holds image request info about file system based image resource. + */ +public class FileImageRequestDescriptor extends UriImageRequestDescriptor { + public final String path; + + // Can we use the thumbnail image from Exif data? + public final boolean canUseThumbnail; + + /** + * Convenience constructor for when the image file's dimensions are not known. + */ + public FileImageRequestDescriptor(final String path, final int desiredWidth, + final int desiredHeight, final boolean canUseThumbnail, final boolean canCompress, + final boolean isStatic) { + this(path, desiredWidth, desiredHeight, FileImageRequest.UNSPECIFIED_SIZE, + FileImageRequest.UNSPECIFIED_SIZE, canUseThumbnail, canCompress, isStatic); + } + + /** + * Creates a new file image request with this descriptor. Oftentimes image file metadata + * has information such as the size of the image. Provide these metrics if they are known. + */ + public FileImageRequestDescriptor(final String path, final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean canUseThumbnail, final boolean canCompress, final boolean isStatic) { + super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth, + sourceHeight, canCompress, isStatic, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + this.path = path; + this.canUseThumbnail = canUseThumbnail; + } + + @Override + public String getKey() { + final String prefixKey = super.getKey(); + return prefixKey == null ? null : new StringBuilder(prefixKey).append(KEY_PART_DELIMITER) + .append(canUseThumbnail).toString(); + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + return new FileImageRequest(context, this); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/GifImageResource.java b/src/com/android/messaging/datamodel/media/GifImageResource.java new file mode 100644 index 0000000..d50cf47 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/GifImageResource.java @@ -0,0 +1,110 @@ +/* + * 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.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.media.ExifInterface; +import android.support.rastermill.FrameSequence; +import android.support.rastermill.FrameSequenceDrawable; + +import com.android.messaging.util.Assert; + +import java.io.IOException; +import java.io.InputStream; + +public class GifImageResource extends ImageResource { + private FrameSequence mFrameSequence; + + public GifImageResource(String key, FrameSequence frameSequence) { + // GIF does not support exif tags + super(key, ExifInterface.ORIENTATION_NORMAL); + mFrameSequence = frameSequence; + } + + public static GifImageResource createGifImageResource(String key, InputStream inputStream) { + final FrameSequence frameSequence; + try { + frameSequence = FrameSequence.decodeStream(inputStream); + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // Nothing to do if we fail closing the stream + } + } + if (frameSequence == null) { + return null; + } + return new GifImageResource(key, frameSequence); + } + + @Override + public Drawable getDrawable(Resources resources) { + return new FrameSequenceDrawable(mFrameSequence); + } + + @Override + public Bitmap getBitmap() { + Assert.fail("GetBitmap() should never be called on a gif."); + return null; + } + + @Override + public byte[] getBytes() { + Assert.fail("GetBytes() should never be called on a gif."); + return null; + } + + @Override + public Bitmap reuseBitmap() { + return null; + } + + @Override + public boolean supportsBitmapReuse() { + // FrameSequenceDrawable a.) takes two bitmaps and thus does not fit into the current + // bitmap pool architecture b.) will rarely use bitmaps from one FrameSequenceDrawable to + // the next that are the same sizes since they are used by attachments. + return false; + } + + @Override + public int getMediaSize() { + Assert.fail("GifImageResource should not be used by a media cache"); + // Only used by the media cache, which this does not use. + return 0; + } + + @Override + public boolean isCacheable() { + return false; + } + + @Override + protected void close() { + acquireLock(); + try { + if (mFrameSequence != null) { + mFrameSequence = null; + } + } finally { + releaseLock(); + } + } + +} diff --git a/src/com/android/messaging/datamodel/media/ImageRequest.java b/src/com/android/messaging/datamodel/media/ImageRequest.java new file mode 100644 index 0000000..ab8880d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageRequest.java @@ -0,0 +1,258 @@ +/* + * 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.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; + +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.exif.ExifInterface; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Base class that serves an image request for resolving, retrieving and decoding bitmap resources. + * + * Subclasses may choose to load images from different medium, such as from the file system or + * from the local content resolver, by overriding the abstract getInputStreamForResource() method. + */ +public abstract class ImageRequest<D extends ImageRequestDescriptor> + implements MediaRequest<ImageResource> { + public static final int UNSPECIFIED_SIZE = MessagePartData.UNSPECIFIED_SIZE; + + protected final Context mContext; + protected final D mDescriptor; + protected int mOrientation; + + /** + * Creates a new image request with the given descriptor. + */ + public ImageRequest(final Context context, final D descriptor) { + mContext = context; + mDescriptor = descriptor; + } + + /** + * Gets a key that uniquely identify the underlying image resource to be loaded (e.g. Uri or + * file path). + */ + @Override + public String getKey() { + return mDescriptor.getKey(); + } + + /** + * Returns the image request descriptor attached to this request. + */ + @Override + public D getDescriptor() { + return mDescriptor; + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + /** + * Allows sub classes to specify that they want us to call getBitmapForResource rather than + * getInputStreamForResource + */ + protected boolean hasBitmapObject() { + return false; + } + + protected Bitmap getBitmapForResource() throws IOException { + return null; + } + + /** + * Retrieves an input stream from which image resource could be loaded. + * @throws FileNotFoundException + */ + protected abstract InputStream getInputStreamForResource() throws FileNotFoundException; + + /** + * Loads the image resource. This method is final; to override the media loading behavior + * the subclass should override {@link #loadMediaInternal(List)} + */ + @Override + public final ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedTask) + throws IOException { + Assert.isNotMainThread(); + final ImageResource loadedResource = loadMediaInternal(chainedTask); + return postProcessOnBitmapResourceLoaded(loadedResource); + } + + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask) + throws IOException { + if (!mDescriptor.isStatic() && isGif()) { + final GifImageResource gifImageResource = + GifImageResource.createGifImageResource(getKey(), getInputStreamForResource()); + if (gifImageResource == null) { + throw new RuntimeException("Error decoding gif"); + } + return gifImageResource; + } else { + final Bitmap loadedBitmap = loadBitmapInternal(); + if (loadedBitmap == null) { + throw new RuntimeException("failed decoding bitmap"); + } + return new DecodedImageResource(getKey(), loadedBitmap, mOrientation); + } + } + + protected boolean isGif() throws FileNotFoundException { + return ImageUtils.isGif(getInputStreamForResource()); + } + + /** + * The internal routine for loading the image. The caller may optionally provide the width + * and height of the source image if known so that we don't need to manually decode those. + */ + protected Bitmap loadBitmapInternal() throws IOException { + + final boolean unknownSize = mDescriptor.sourceWidth == UNSPECIFIED_SIZE || + mDescriptor.sourceHeight == UNSPECIFIED_SIZE; + + // If the ImageRequest has a Bitmap object rather than a stream, there's little to do here + if (hasBitmapObject()) { + final Bitmap bitmap = getBitmapForResource(); + if (bitmap != null && unknownSize) { + mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight()); + } + return bitmap; + } + + mOrientation = ImageUtils.getOrientation(getInputStreamForResource()); + + final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool( + false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */); + // First, check dimensions of the bitmap if not already known. + if (unknownSize) { + final InputStream inputStream = getInputStreamForResource(); + if (inputStream != null) { + try { + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(inputStream, null, options); + // This is called when dimensions of image were unknown to allow db update + if (ExifInterface.getOrientationParams(mOrientation).invertDimensions) { + mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth); + } else { + mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight); + } + } finally { + inputStream.close(); + } + } else { + throw new FileNotFoundException(); + } + } else { + options.outWidth = mDescriptor.sourceWidth; + options.outHeight = mDescriptor.sourceHeight; + } + + // Calculate inSampleSize + options.inSampleSize = ImageUtils.get().calculateInSampleSize(options, + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + Assert.isTrue(options.inSampleSize > 0); + + // Reopen the input stream and actually decode the bitmap. The initial + // BitmapFactory.decodeStream() reads the header portion of the bitmap stream and leave + // the input stream at the last read position. Since this input stream doesn't support + // mark() and reset(), the only viable way to reload the input stream is to re-open it. + // Alternatively, we could decode the bitmap into a byte array first and act on the byte + // array, but that also means the entire bitmap (for example a 10MB image from the gallery) + // without downsampling will have to be loaded into memory up front, which we don't want + // as it gives a much bigger possibility of OOM when handling big images. Therefore, the + // solution here is to close and reopen the bitmap input stream. + // For inline images the size is cached in DB and this hit is only taken once per image + final InputStream inputStream = getInputStreamForResource(); + if (inputStream != null) { + try { + options.inJustDecodeBounds = false; + + // Actually decode the bitmap, optionally using the bitmap pool. + final ReusableImageResourcePool bitmapPool = getBitmapPool(); + if (bitmapPool == null) { + return BitmapFactory.decodeStream(inputStream, null, options); + } else { + final int sampledWidth = (options.outWidth + options.inSampleSize - 1) / + options.inSampleSize; + final int sampledHeight = (options.outHeight + options.inSampleSize - 1) / + options.inSampleSize; + return bitmapPool.decodeSampledBitmapFromInputStream( + inputStream, options, sampledWidth, sampledHeight); + } + } finally { + inputStream.close(); + } + } else { + throw new FileNotFoundException(); + } + } + + private ImageResource postProcessOnBitmapResourceLoaded(final ImageResource loadedResource) { + if (mDescriptor.cropToCircle && loadedResource instanceof DecodedImageResource) { + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + final Bitmap sourceBitmap = loadedResource.getBitmap(); + final Bitmap targetBitmap = getBitmapPool().createOrReuseBitmap(width, height); + final RectF dest = new RectF(0, 0, width, height); + final RectF source = new RectF(0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight()); + final int backgroundColor = mDescriptor.circleBackgroundColor; + final int strokeColor = mDescriptor.circleStrokeColor; + ImageUtils.drawBitmapWithCircleOnCanvas(sourceBitmap, new Canvas(targetBitmap), source, + dest, null, backgroundColor == 0 ? false : true /* fillBackground */, + backgroundColor, strokeColor); + return new DecodedImageResource(getKey(), targetBitmap, + loadedResource.getOrientation()); + } + return loadedResource; + } + + /** + * Returns the bitmap pool for this image request. + */ + protected ReusableImageResourcePool getBitmapPool() { + return MediaCacheManager.get().getOrCreateBitmapPoolForCache(getCacheId()); + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache<ImageResource> getMediaCache() { + return (MediaCache<ImageResource>) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + /** + * Returns the cache id. Subclasses may override this to use a different cache. + */ + @Override + public int getCacheId() { + return BugleMediaCacheManager.DEFAULT_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java new file mode 100644 index 0000000..20cb9af --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java @@ -0,0 +1,113 @@ +/* + * 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.media; + +import android.content.Context; + +import com.android.messaging.util.Assert; + +/** + * The base ImageRequest descriptor that describes the requirement of the requested image + * resource, including the desired size. It holds request info that will be consumed by + * ImageRequest instances. Subclasses of ImageRequest are expected to take + * more descriptions such as content URI or file path. + */ +public abstract class ImageRequestDescriptor extends MediaRequestDescriptor<ImageResource> { + /** Desired size for the image (if known). This is used for bitmap downsampling */ + public final int desiredWidth; + public final int desiredHeight; + + /** Source size of the image (if known). This is used so that we don't have to manually decode + * the metrics from the image resource */ + public final int sourceWidth; + public final int sourceHeight; + + /** + * A static image resource is required, even if the image format supports animation (like Gif). + */ + public final boolean isStatic; + + /** + * The loaded image will be cropped to circular shape. + */ + public final boolean cropToCircle; + + /** + * The loaded image will be cropped to circular shape with the background color. + */ + public final int circleBackgroundColor; + + /** + * The loaded image will be cropped to circular shape with a stroke color. + */ + public final int circleStrokeColor; + + protected static final char KEY_PART_DELIMITER = '|'; + + /** + * Creates a new image request with unspecified width and height. In this case, the full + * bitmap is loaded and decoded, so unless you are sure that the image will be of + * reasonable size, you should consider limiting at least one of the two dimensions + * (for example, limiting the image width to the width of the ImageView container). + */ + public ImageRequestDescriptor() { + this(ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0); + } + + public ImageRequestDescriptor(final int desiredWidth, final int desiredHeight) { + this(desiredWidth, desiredHeight, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0); + } + + public ImageRequestDescriptor(final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean isStatic, final boolean cropToCircle, final int circleBackgroundColor, + int circleStrokeColor) { + Assert.isTrue(desiredWidth == ImageRequest.UNSPECIFIED_SIZE || desiredWidth > 0); + Assert.isTrue(desiredHeight == ImageRequest.UNSPECIFIED_SIZE || desiredHeight > 0); + Assert.isTrue(sourceWidth == ImageRequest.UNSPECIFIED_SIZE || sourceWidth > 0); + Assert.isTrue(sourceHeight == ImageRequest.UNSPECIFIED_SIZE || sourceHeight > 0); + this.desiredWidth = desiredWidth; + this.desiredHeight = desiredHeight; + this.sourceWidth = sourceWidth; + this.sourceHeight = sourceHeight; + this.isStatic = isStatic; + this.cropToCircle = cropToCircle; + this.circleBackgroundColor = circleBackgroundColor; + this.circleStrokeColor = circleStrokeColor; + } + + public String getKey() { + return new StringBuilder() + .append(desiredWidth).append(KEY_PART_DELIMITER) + .append(desiredHeight).append(KEY_PART_DELIMITER) + .append(String.valueOf(cropToCircle)).append(KEY_PART_DELIMITER) + .append(String.valueOf(circleBackgroundColor)).append(KEY_PART_DELIMITER) + .append(String.valueOf(isStatic)).toString(); + } + + public boolean isStatic() { + return isStatic; + } + + @Override + public abstract MediaRequest<ImageResource> buildSyncMediaRequest(Context context); + + // Called once source dimensions finally determined upon loading the image + public void updateSourceDimensions(final int sourceWidth, final int sourceHeight) { + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/ImageResource.java b/src/com/android/messaging/datamodel/media/ImageResource.java new file mode 100644 index 0000000..75d817d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageResource.java @@ -0,0 +1,63 @@ +/* + * 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.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +/** + * Base class for holding some form of image resource. The subclass gets to define the specific + * type of data format it's holding, whether it be Bitmap objects or compressed byte arrays. + */ +public abstract class ImageResource extends RefCountedMediaResource { + protected final int mOrientation; + + public ImageResource(final String key, int orientation) { + super(key); + mOrientation = orientation; + } + + /** + * Gets the contained image in drawable format. + */ + public abstract Drawable getDrawable(Resources resources); + + /** + * Gets the contained image in bitmap format. + */ + public abstract Bitmap getBitmap(); + + /** + * Gets the contained image in byte array format. + */ + public abstract byte[] getBytes(); + + /** + * Attempt to reuse the bitmap in the image resource and re-purpose it for something else. + * After this, the image resource will relinquish ownership on the bitmap resource so that + * it doesn't try to recycle it when getting closed. + */ + public abstract Bitmap reuseBitmap(); + public abstract boolean supportsBitmapReuse(); + + /** + * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants + */ + public int getOrientation() { + return mOrientation; + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaBytes.java b/src/com/android/messaging/datamodel/media/MediaBytes.java new file mode 100644 index 0000000..823bf27 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaBytes.java @@ -0,0 +1,42 @@ +/* + * 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.media; + + +/** + * Container class for handing around media information used by the MediaResourceManager. + */ +public class MediaBytes extends RefCountedMediaResource { + private final byte[] mBytes; + + public MediaBytes(final String key, final byte[] bytes) { + super(key); + mBytes = bytes; + } + + public byte[] getMediaBytes() { + return mBytes; + } + + @Override + public int getMediaSize() { + return mBytes.length; + } + + @Override + protected void close() { + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaCache.java b/src/com/android/messaging/datamodel/media/MediaCache.java new file mode 100644 index 0000000..510da2d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaCache.java @@ -0,0 +1,113 @@ +/* + * 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.media; + +import android.util.LruCache; + +import com.android.messaging.util.LogUtil; + +/** + * A modified LruCache that is able to hold RefCountedMediaResource instances. It releases + * ref on the entries as they are evicted from the cache, and it uses the media resource + * size in kilobytes, instead of the entry count, as the size of the cache. + * + * This class is used by the MediaResourceManager class to maintain a number of caches for + * holding different types of {@link RefCountedMediaResource} + */ +public class MediaCache<T extends RefCountedMediaResource> extends LruCache<String, T> { + private static final String TAG = LogUtil.BUGLE_IMAGE_TAG; + + // Default memory cache size in kilobytes + protected static final int DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES = 1024 * 5; // 5MB + + // Unique identifier for the cache. + private final int mId; + // Descriptive name given to the cache for debugging purposes. + private final String mName; + + // Convenience constructor that uses the default cache size. + public MediaCache(final int id, final String name) { + this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name); + } + + public MediaCache(final int maxSize, final int id, final String name) { + super(maxSize); + mId = id; + mName = name; + } + + public void destroy() { + evictAll(); + } + + public String getName() { + return mName; + } + + public int getId() { + return mId; + } + + /** + * Gets a media resource from this cache. Must use this method to get resource instead of get() + * to ensure addRef() on the resource. + */ + public synchronized T fetchResourceFromCache(final String key) { + final T ret = get(key); + if (ret != null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "cache hit in mediaCache @ " + getName() + + ", total cache hit = " + hitCount() + + ", total cache miss = " + missCount()); + } + ret.addRef(); + } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "cache miss in mediaCache @ " + getName() + + ", total cache hit = " + hitCount() + + ", total cache miss = " + missCount()); + } + return ret; + } + + /** + * Add a media resource to this cache. Must use this method to add resource instead of put() + * to ensure addRef() on the resource. + */ + public synchronized T addResourceToCache(final String key, final T mediaResource) { + mediaResource.addRef(); + return put(key, mediaResource); + } + + /** + * Notify the removed entry that is no longer being cached + */ + @Override + protected synchronized void entryRemoved(final boolean evicted, final String key, + final T oldValue, final T newValue) { + oldValue.release(); + } + + /** + * Measure item size in kilobytes rather than units which is more practical + * for a media resource cache + */ + @Override + protected int sizeOf(final String key, final T value) { + final int mediaSizeInKilobytes = value.getMediaSize() / 1024; + // Never zero-count any resource, count as at least 1KB. + return mediaSizeInKilobytes == 0 ? 1 : mediaSizeInKilobytes; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaCacheManager.java b/src/com/android/messaging/datamodel/media/MediaCacheManager.java new file mode 100644 index 0000000..6e029f2 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaCacheManager.java @@ -0,0 +1,69 @@ +/* + * 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.media; + +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache; +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; + +/** + * Manages a set of media caches by id. + */ +public abstract class MediaCacheManager implements MemoryCache { + public static MediaCacheManager get() { + return Factory.get().getMediaCacheManager(); + } + + protected final SparseArray<MediaCache<?>> mCaches; + + public MediaCacheManager() { + mCaches = new SparseArray<MediaCache<?>>(); + MemoryCacheManager.get().registerMemoryCache(this); + } + + @Override + public void reclaim() { + final int count = mCaches.size(); + for (int i = 0; i < count; i++) { + mCaches.valueAt(i).destroy(); + } + mCaches.clear(); + } + + public synchronized MediaCache<?> getOrCreateMediaCacheById(final int id) { + MediaCache<?> cache = mCaches.get(id); + if (cache == null) { + cache = createMediaCacheById(id); + if (cache != null) { + mCaches.put(id, cache); + } + } + return cache; + } + + public ReusableImageResourcePool getOrCreateBitmapPoolForCache(final int cacheId) { + final MediaCache<?> cache = getOrCreateMediaCacheById(cacheId); + if (cache != null && cache instanceof PoolableImageCache) { + return ((PoolableImageCache) cache).asReusableBitmapPool(); + } + return null; + } + + protected abstract MediaCache<?> createMediaCacheById(final int id); +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaRequest.java b/src/com/android/messaging/datamodel/media/MediaRequest.java new file mode 100644 index 0000000..703671b --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaRequest.java @@ -0,0 +1,70 @@ +/* + * 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.media; + +import java.util.List; + +/** + * Keeps track of a media loading request. MediaResourceManager uses this interface to load, encode, + * decode, and cache different types of media resource. + * + * This interface defines a media request class that's threading-model-blind. Wrapper classes + * (such as {@link AsyncMediaRequestWrapper} wraps around any base media request to offer async + * extensions). + */ +public interface MediaRequest<T extends RefCountedMediaResource> { + public static final int REQUEST_ENCODE_MEDIA = 1; + public static final int REQUEST_DECODE_MEDIA = 2; + public static final int REQUEST_LOAD_MEDIA = 3; + + /** + * Returns a unique key used for storing and looking up the MediaRequest. + */ + String getKey(); + + /** + * This method performs the heavy-lifting work of synchronously loading the media bytes for + * this MediaRequest on a single threaded executor. + * @param chainedTask subsequent tasks to be performed after this request is complete. For + * example, an image request may need to compress the image resource before putting it in the + * cache + */ + T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception; + + /** + * Returns the media cache where this MediaRequest wants to store the loaded + * media resource. + */ + MediaCache<T> getMediaCache(); + + /** + * Returns the id of the cache where this MediaRequest wants to store the loaded + * media resource. + */ + int getCacheId(); + + /** + * Returns the request type of this media request, i.e. one of {@link #REQUEST_ENCODE_MEDIA}, + * {@link #REQUEST_DECODE_MEDIA}, or {@link #REQUEST_LOAD_MEDIA}. The default is + * {@link #REQUEST_LOAD_MEDIA} + */ + int getRequestType(); + + /** + * Returns the descriptor defining the request. + */ + MediaRequestDescriptor<T> getDescriptor(); +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java new file mode 100644 index 0000000..216b2a3 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java @@ -0,0 +1,38 @@ +/* + * 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.media; + +import android.content.Context; + +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +/** + * The base data holder/builder class for constructing async/sync MediaRequest objects during + * runtime. + */ +public abstract class MediaRequestDescriptor<T extends RefCountedMediaResource> { + public abstract MediaRequest<T> buildSyncMediaRequest(Context context); + + /** + * Builds an async media request to be used with + * {@link MediaResourceManager#requestMediaResourceAsync(MediaRequest)} + */ + public BindableMediaRequest<T> buildAsyncMediaRequest(final Context context, + final MediaResourceLoadListener<T> listener) { + final MediaRequest<T> syncRequest = buildSyncMediaRequest(context); + return AsyncMediaRequestWrapper.createWith(syncRequest, listener); + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaResourceManager.java b/src/com/android/messaging/datamodel/media/MediaResourceManager.java new file mode 100644 index 0000000..13f7291 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaResourceManager.java @@ -0,0 +1,325 @@ +/* + * 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.media; + +import android.os.AsyncTask; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnAnyThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** + * <p>Loads and maintains a set of in-memory LRU caches for different types of media resources. + * Right now we don't utilize any disk cache as all media urls are expected to be resolved to + * local content.<p/> + * + * <p>The MediaResourceManager takes media loading requests through one of two ways:</p> + * + * <ol> + * <li>{@link #requestMediaResourceAsync(MediaRequest)} that takes a MediaRequest, which may be a + * regular request if the caller doesn't want to listen for events (fire-and-forget), + * or an async request wrapper if event callback is needed.</li> + * <li>{@link #requestMediaResourceSync(MediaRequest)} which takes a MediaRequest and synchronously + * returns the loaded result, or null if failed.</li> + * </ol> + * + * <p>For each media loading task, MediaResourceManager starts an AsyncTask that runs on a + * dedicated thread, which calls MediaRequest.loadMediaBlocking() to perform the actual media + * loading work. As the media resources are loaded, MediaResourceManager notifies the callers + * (which must implement the MediaResourceLoadListener interface) via onMediaResourceLoaded() + * callback. Meanwhile, MediaResourceManager also pushes the loaded resource onto its dedicated + * cache.</p> + * + * <p>The media resource caches ({@link MediaCache}) are maintained as a set of LRU caches. They are + * created on demand by the incoming MediaRequest's getCacheId() method. The implementations of + * MediaRequest (such as {@link ImageRequest}) get to determine the desired cache id. For Bugle, + * the list of available caches are in {@link BugleMediaCacheManager}</p> + * + * <p>Optionally, media loading can support on-demand media encoding and decoding. + * All {@link MediaRequest}'s can opt to chain additional {@link MediaRequest}'s to be executed + * after the completion of the main media loading task, by adding new tasks to the chained + * task list in {@link MediaRequest#loadMediaBlocking(List)}. One possible type of chained task is + * media encoding task. Loaded media will be encoded on a dedicated single threaded executor + * *after* the UI is notified of the loaded media. In this case, the encoded media resource will + * be eventually pushed to the cache, which will later be decoded before posting to the UI thread + * on cache hit.</p> + * + * <p><b>To add support for a new type of media resource,</b></p> + * + * <ol> + * <li>Create a new subclass of {@link RefCountedMediaResource} for the new resource type (example: + * {@link ImageResource} class).</li> + * + * <li>Implement the {@link MediaRequest} interface (example: {@link ImageRequest}). Perform the + * media loading work in loadMediaBlocking() and return a cache id in getCacheId().</li> + * + * <li>For the UI component that requests the media resource, let it implement + * {@link MediaResourceLoadListener} interface to listen for resource load callback. Let the + * UI component call MediaResourceManager.requestMediaResourceAsync() to request a media source. + * (example: {@link com.android.messaging.ui.ContactIconView}</li> + * </ol> + */ +public class MediaResourceManager { + private static final String TAG = LogUtil.BUGLE_TAG; + + public static MediaResourceManager get() { + return Factory.get().getMediaResourceManager(); + } + + /** + * Listener for asynchronous callback from media loading events. + */ + public interface MediaResourceLoadListener<T extends RefCountedMediaResource> { + void onMediaResourceLoaded(MediaRequest<T> request, T resource, boolean cached); + void onMediaResourceLoadError(MediaRequest<T> request, Exception exception); + } + + // We use a fixed thread pool for handling media loading tasks. Using a cached thread pool + // allows for unlimited thread creation which can lead to OOMs so we limit the threads here. + private static final Executor MEDIA_LOADING_EXECUTOR = Executors.newFixedThreadPool(10); + + // A dedicated single thread executor for performing background task after loading the resource + // on the media loading executor. This includes work such as encoding loaded media to be cached. + // These tasks are run on a single worker thread with low priority so as not to contend with the + // media loading tasks. + private static final Executor MEDIA_BACKGROUND_EXECUTOR = Executors.newSingleThreadExecutor( + new ThreadFactory() { + @Override + public Thread newThread(final Runnable runnable) { + final Thread encodingThread = new Thread(runnable); + encodingThread.setPriority(Thread.MIN_PRIORITY); + return encodingThread; + } + }); + + /** + * Requests a media resource asynchronously. Upon completion of the media loading task, + * the listener will be notified of success/failure iff it's still bound. A refcount on the + * resource is held and guaranteed for the caller for the duration of the + * {@link MediaResourceLoadListener#onMediaResourceLoaded( + * MediaRequest, RefCountedMediaResource, boolean)} callback. + * @param mediaRequest the media request. May be either an + * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media + * request for fire-and-forget type of behavior. + */ + public <T extends RefCountedMediaResource> void requestMediaResourceAsync( + final MediaRequest<T> mediaRequest) { + scheduleAsyncMediaRequest(mediaRequest, MEDIA_LOADING_EXECUTOR); + } + + /** + * Requests a media resource synchronously. + * @return the loaded resource with a refcount reserved for the caller. The caller must call + * release() on the resource once it's done using it (like with Cursors). + */ + public <T extends RefCountedMediaResource> T requestMediaResourceSync( + final MediaRequest<T> mediaRequest) { + Assert.isNotMainThread(); + // Block and load media. + MediaLoadingResult<T> loadResult = null; + try { + loadResult = processMediaRequestInternal(mediaRequest); + // The loaded resource should have at least one refcount by now reserved for the caller. + Assert.isTrue(loadResult.loadedResource.getRefCount() > 0); + return loadResult.loadedResource; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Synchronous media loading failed, key=" + + mediaRequest.getKey(), e); + return null; + } finally { + if (loadResult != null) { + // Schedule the background requests chained to the main request. + loadResult.scheduleChainedRequests(); + } + } + } + + @SuppressWarnings("unchecked") + private <T extends RefCountedMediaResource> MediaLoadingResult<T> processMediaRequestInternal( + final MediaRequest<T> mediaRequest) + throws Exception { + final List<MediaRequest<T>> chainedRequests = new ArrayList<>(); + T loadedResource = null; + // Try fetching from cache first. + final T cachedResource = loadMediaFromCache(mediaRequest); + if (cachedResource != null) { + if (cachedResource.isEncoded()) { + // The resource is encoded, issue a decoding request. + final MediaRequest<T> decodeRequest = (MediaRequest<T>) cachedResource + .getMediaDecodingRequest(mediaRequest); + Assert.notNull(decodeRequest); + cachedResource.release(); + loadedResource = loadMediaFromRequest(decodeRequest, chainedRequests); + } else { + // The resource is ready-to-use. + loadedResource = cachedResource; + } + } else { + // Actually load the media after cache miss. + loadedResource = loadMediaFromRequest(mediaRequest, chainedRequests); + } + return new MediaLoadingResult<>(loadedResource, cachedResource != null /* fromCache */, + chainedRequests); + } + + private <T extends RefCountedMediaResource> T loadMediaFromCache( + final MediaRequest<T> mediaRequest) { + if (mediaRequest.getRequestType() != MediaRequest.REQUEST_LOAD_MEDIA) { + // Only look up in the cache if we are loading media. + return null; + } + final MediaCache<T> mediaCache = mediaRequest.getMediaCache(); + if (mediaCache != null) { + final T mediaResource = mediaCache.fetchResourceFromCache(mediaRequest.getKey()); + if (mediaResource != null) { + return mediaResource; + } + } + return null; + } + + private <T extends RefCountedMediaResource> T loadMediaFromRequest( + final MediaRequest<T> mediaRequest, final List<MediaRequest<T>> chainedRequests) + throws Exception { + final T resource = mediaRequest.loadMediaBlocking(chainedRequests); + // mediaRequest.loadMediaBlocking() should never return null without + // throwing an exception. + Assert.notNull(resource); + // It's possible for the media to be evicted right after it's added to + // the cache (possibly because it's by itself too big for the cache). + // It's also possible that, after added to the cache, something else comes + // to the cache and evicts this media resource. To prevent this from + // recycling the underlying resource objects, make sure to add ref before + // adding to cache so that the caller is guaranteed a ref on the resource. + resource.addRef(); + // Don't cache the media request if it is defined as non-cacheable. + if (resource.isCacheable()) { + addResourceToMemoryCache(mediaRequest, resource); + } + return resource; + } + + /** + * Schedule an async media request on the given <code>executor</code>. + * @param mediaRequest the media request to be processed asynchronously. May be either an + * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media + * request for fire-and-forget type of behavior. + */ + private <T extends RefCountedMediaResource> void scheduleAsyncMediaRequest( + final MediaRequest<T> mediaRequest, final Executor executor) { + final BindableMediaRequest<T> bindableRequest = + (mediaRequest instanceof BindableMediaRequest<?>) ? + (BindableMediaRequest<T>) mediaRequest : null; + if (bindableRequest != null && !bindableRequest.isBound()) { + return; // Request is obsolete + } + // We don't use SafeAsyncTask here since it enforces the shared thread pool executor + // whereas we want a dedicated thread pool executor. + AsyncTask<Void, Void, MediaLoadingResult<T>> mediaLoadingTask = + new AsyncTask<Void, Void, MediaLoadingResult<T>>() { + private Exception mException; + + @Override + protected MediaLoadingResult<T> doInBackground(Void... params) { + // Double check the request is still valid by the time we start processing it + if (bindableRequest != null && !bindableRequest.isBound()) { + return null; // Request is obsolete + } + try { + return processMediaRequestInternal(mediaRequest); + } catch (Exception e) { + mException = e; + return null; + } + } + + @Override + protected void onPostExecute(final MediaLoadingResult<T> result) { + if (result != null) { + Assert.isNull(mException); + Assert.isTrue(result.loadedResource.getRefCount() > 0); + try { + if (bindableRequest != null) { + bindableRequest.onMediaResourceLoaded( + bindableRequest, result.loadedResource, result.fromCache); + } + } finally { + result.loadedResource.release(); + result.scheduleChainedRequests(); + } + } else if (mException != null) { + LogUtil.e(LogUtil.BUGLE_TAG, "Asynchronous media loading failed, key=" + + mediaRequest.getKey(), mException); + if (bindableRequest != null) { + bindableRequest.onMediaResourceLoadError(bindableRequest, mException); + } + } else { + Assert.isTrue(bindableRequest == null || !bindableRequest.isBound()); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "media request not processed, no longer bound; key=" + + LogUtil.sanitizePII(mediaRequest.getKey()) /* key with phone# */); + } + } + } + }; + mediaLoadingTask.executeOnExecutor(executor, (Void) null); + } + + @VisibleForTesting + @RunsOnAnyThread + <T extends RefCountedMediaResource> void addResourceToMemoryCache( + final MediaRequest<T> mediaRequest, final T mediaResource) { + Assert.isTrue(mediaResource != null); + final MediaCache<T> mediaCache = mediaRequest.getMediaCache(); + if (mediaCache != null) { + mediaCache.addResourceToCache(mediaRequest.getKey(), mediaResource); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "added media resource to " + mediaCache.getName() + ". key=" + + LogUtil.sanitizePII(mediaRequest.getKey()) /* key can contain phone# */); + } + } + } + + private class MediaLoadingResult<T extends RefCountedMediaResource> { + public final T loadedResource; + public final boolean fromCache; + private final List<MediaRequest<T>> mChainedRequests; + + MediaLoadingResult(final T loadedResource, final boolean fromCache, + final List<MediaRequest<T>> chainedRequests) { + this.loadedResource = loadedResource; + this.fromCache = fromCache; + mChainedRequests = chainedRequests; + } + + /** + * Asynchronously schedule a list of chained requests on the background thread. + */ + public void scheduleChainedRequests() { + for (final MediaRequest<T> mediaRequest : mChainedRequests) { + scheduleAsyncMediaRequest(mediaRequest, MEDIA_BACKGROUND_EXECUTOR); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java new file mode 100644 index 0000000..1871e66 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java @@ -0,0 +1,64 @@ +/* + * 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.media; + +import android.net.Uri; + +import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.util.ImageUtils; + +/** + * Image descriptor attached to a message part. + * Once image size is determined during loading this descriptor will update the db if necessary. + */ +public class MessagePartImageRequestDescriptor extends UriImageRequestDescriptor { + private final String mMessagePartId; + + /** + * Creates a new image request for a message part. + */ + public MessagePartImageRequestDescriptor(final MessagePartData messagePart, + final int desiredWidth, final int desiredHeight, boolean isStatic) { + // Pull image parameters out of the MessagePart record + this(messagePart.getPartId(), messagePart.getContentUri(), desiredWidth, desiredHeight, + messagePart.getWidth(), messagePart.getHeight(), isStatic); + } + + protected MessagePartImageRequestDescriptor(final String messagePartId, final Uri contentUri, + final int desiredWidth, final int desiredHeight, final int sourceWidth, + final int sourceHeight, boolean isStatic) { + super(contentUri, desiredWidth, desiredHeight, sourceWidth, sourceHeight, + true /* allowCompression */, isStatic, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMessagePartId = messagePartId; + } + + @Override + public void updateSourceDimensions(final int updatedWidth, final int updatedHeight) { + // If the dimensions of the image do not match then queue a DB update with new size. + // Don't update if we don't have a part id, which happens if this part is loaded as + // draft through actions such as share intent/message forwarding. + if (mMessagePartId != null && + updatedWidth != MessagePartData.UNSPECIFIED_SIZE && + updatedHeight != MessagePartData.UNSPECIFIED_SIZE && + updatedWidth != sourceWidth && updatedHeight != sourceHeight) { + UpdateMessagePartSizeAction.updateSize(mMessagePartId, updatedWidth, updatedHeight); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java new file mode 100644 index 0000000..ff11e92 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java @@ -0,0 +1,38 @@ +/* + * 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.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.datamodel.data.MessagePartData; + +public class MessagePartVideoThumbnailRequestDescriptor extends MessagePartImageRequestDescriptor { + public MessagePartVideoThumbnailRequestDescriptor(MessagePartData messagePart) { + super(messagePart, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false); + } + + public MessagePartVideoThumbnailRequestDescriptor(Uri uri) { + super(null, uri, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false); + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + return new VideoThumbnailRequest(context, this); + } +} diff --git a/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java new file mode 100644 index 0000000..642e947 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java @@ -0,0 +1,120 @@ +/* + * 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.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Serves network content URI based image requests. + */ +public class NetworkUriImageRequest<D extends UriImageRequestDescriptor> extends + ImageRequest<D> { + + public NetworkUriImageRequest(Context context, D descriptor) { + super(context, descriptor); + mOrientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + Assert.isNotMainThread(); + // Since we need to have an open urlConnection to get the stream, but we don't want to keep + // that connection open. There is no good way to perform this method. + return null; + } + + @Override + protected boolean isGif() throws FileNotFoundException { + Assert.isNotMainThread(); + + HttpURLConnection connection = null; + try { + final URL url = new URL(mDescriptor.uri.toString()); + connection = (HttpURLConnection) url.openConnection(); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + return ContentType.IMAGE_GIF.equalsIgnoreCase(connection.getContentType()); + } + } catch (MalformedURLException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "MalformedUrl for image with url: " + + mDescriptor.uri.toString(), e); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "IOException trying to get inputStream for image with url: " + + mDescriptor.uri.toString(), e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + return false; + } + + @SuppressWarnings("deprecation") + @Override + public Bitmap loadBitmapInternal() throws IOException { + Assert.isNotMainThread(); + + InputStream inputStream = null; + Bitmap bitmap = null; + HttpURLConnection connection = null; + try { + final URL url = new URL(mDescriptor.uri.toString()); + connection = (HttpURLConnection) url.openConnection(); + connection.setDoInput(true); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + bitmap = BitmapFactory.decodeStream(connection.getInputStream()); + } + } catch (MalformedURLException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "MalformedUrl for image with url: " + + mDescriptor.uri.toString(), e); + } catch (final OutOfMemoryError e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "OutOfMemoryError for image with url: " + + mDescriptor.uri.toString(), e); + Factory.get().reclaimMemory(); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "IOException trying to get inputStream for image with url: " + + mDescriptor.uri.toString(), e); + } finally { + if (inputStream != null) { + inputStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } + return bitmap; + } +} diff --git a/src/com/android/messaging/datamodel/media/PoolableImageCache.java b/src/com/android/messaging/datamodel/media/PoolableImageCache.java new file mode 100644 index 0000000..df814ba --- /dev/null +++ b/src/com/android/messaging/datamodel/media/PoolableImageCache.java @@ -0,0 +1,419 @@ +/* + * 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.media; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedList; + +/** + * A media cache that holds image resources, which doubles as a bitmap pool that allows the + * consumer to optionally decode image resources using unused bitmaps stored in the cache. + */ +public class PoolableImageCache extends MediaCache<ImageResource> { + private static final int MIN_TIME_IN_POOL = 5000; + + /** Encapsulates bitmap pool representation of the image cache */ + private final ReusableImageResourcePool mReusablePoolAccessor = new ReusableImageResourcePool(); + + public PoolableImageCache(final int id, final String name) { + this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name); + } + + public PoolableImageCache(final int maxSize, final int id, final String name) { + super(maxSize, id, name); + } + + /** + * Creates a new BitmapFactory.Options for using the self-contained bitmap pool. + */ + public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled, + final int inputDensity, final int targetDensity) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = scaled; + options.inDensity = inputDensity; + options.inTargetDensity = targetDensity; + options.inSampleSize = 1; + options.inJustDecodeBounds = false; + options.inMutable = true; + return options; + } + + @Override + public synchronized ImageResource addResourceToCache(final String key, + final ImageResource imageResource) { + mReusablePoolAccessor.onResourceEnterCache(imageResource); + return super.addResourceToCache(key, imageResource); + } + + @Override + protected synchronized void entryRemoved(final boolean evicted, final String key, + final ImageResource oldValue, final ImageResource newValue) { + mReusablePoolAccessor.onResourceLeaveCache(oldValue); + super.entryRemoved(evicted, key, oldValue, newValue); + } + + /** + * Returns a representation of the image cache as a reusable bitmap pool. + */ + public ReusableImageResourcePool asReusableBitmapPool() { + return mReusablePoolAccessor; + } + + /** + * A bitmap pool representation built on top of the image cache. It treats the image resources + * stored in the image cache as a self-contained bitmap pool and is able to create or + * reclaim bitmap resource as needed. + */ + public class ReusableImageResourcePool { + private static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF; + private static final int INVALID_POOL_KEY = 0; + + /** + * Number of reuse failures to skip before reporting. + * For debugging purposes, change to a lower number for more frequent reporting. + */ + private static final int FAILED_REPORTING_FREQUENCY = 100; + + /** + * Count of reuse failures which have occurred. + */ + private volatile int mFailedBitmapReuseCount = 0; + + /** + * Count of reuse successes which have occurred. + */ + private volatile int mSucceededBitmapReuseCount = 0; + + /** + * A sparse array from bitmap size to a list of image cache entries that match the + * given size. This map is used to quickly retrieve a usable bitmap to be reused by an + * incoming ImageRequest. We need to ensure that this sparse array always contains only + * elements currently in the image cache with no other consumer. + */ + private final SparseArray<LinkedList<ImageResource>> mImageListSparseArray; + + public ReusableImageResourcePool() { + mImageListSparseArray = new SparseArray<LinkedList<ImageResource>>(); + } + + /** + * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce + * memory turnover. + * @param inputStream InputStream load. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). + * Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + * @throws IOException + */ + public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream, + @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) throws IOException { + if (width <= 0 || height <= 0) { + // This is an invalid / corrupted image of zero size. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " + + "invalid size"); + throw new IOException("Invalid size / corrupted image"); + } + Assert.notNull(inputStream); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + mSucceededBitmapReuseCount++; + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap.recycle(); + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + onFailedToReuse(); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream"); + Factory.get().reclaimMemory(); + } + return b; + } + + /** + * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce + * memory turnover. + * @param bytes Encoded bytes to draw on the bitmap. Cannot be null. + * @param optionsTmp The bitmap will set here and the input should be generated from + * getBitmapOptionsForPool(). Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return A Bitmap with the encoded bytes drawn in it. + * @throws IOException + */ + public Bitmap decodeByteArray(@NonNull final byte[] bytes, + @NonNull final BitmapFactory.Options optionsTmp, final int width, + final int height) throws OutOfMemoryError, IOException { + if (width <= 0 || height <= 0) { + // This is an invalid / corrupted image of zero size. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " + + "invalid size"); + throw new IOException("Invalid size / corrupted image"); + } + Assert.notNull(bytes); + Assert.notNull(optionsTmp); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + mSucceededBitmapReuseCount++; + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + // (i.e. without the bitmap from the pool) + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap.recycle(); + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + onFailedToReuse(); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream"); + Factory.get().reclaimMemory(); + } + return b; + } + + /** + * Called when a new image resource is added to the cache. We add the resource to the + * pool so it's properly keyed into the pool structure. + */ + void onResourceEnterCache(final ImageResource imageResource) { + if (getPoolKey(imageResource) != INVALID_POOL_KEY) { + addResourceToPool(imageResource); + } + } + + /** + * Called when an image resource is evicted from the cache. Bitmap pool's entries are + * strictly tied to their presence in the image cache. Once an image is evicted from the + * cache, it should be removed from the pool. + */ + void onResourceLeaveCache(final ImageResource imageResource) { + if (getPoolKey(imageResource) != INVALID_POOL_KEY) { + removeResourceFromPool(imageResource); + } + } + + private void addResourceToPool(final ImageResource imageResource) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(imageResource); + Assert.isTrue(poolKey != INVALID_POOL_KEY); + LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey); + if (imageList == null) { + imageList = new LinkedList<ImageResource>(); + mImageListSparseArray.put(poolKey, imageList); + } + imageList.addLast(imageResource); + } + } + + private void removeResourceFromPool(final ImageResource imageResource) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(imageResource); + Assert.isTrue(poolKey != INVALID_POOL_KEY); + final LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey); + if (imageList != null) { + imageList.remove(imageResource); + } + } + } + + /** + * Try to get a reusable bitmap from the pool with the given width and height. As a + * result of this call, the caller will assume ownership of the returned bitmap. + */ + private Bitmap getReusableBitmapFromPool(final int width, final int height) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(width, height); + if (poolKey != INVALID_POOL_KEY) { + final LinkedList<ImageResource> images = mImageListSparseArray.get(poolKey); + if (images != null && images.size() > 0) { + // Try to reuse the first available bitmap from the pool list. We start from + // the least recently added cache entry of the given size. + ImageResource imageToUse = null; + for (int i = 0; i < images.size(); i++) { + final ImageResource image = images.get(i); + if (image.getRefCount() == 1) { + image.acquireLock(); + if (image.getRefCount() == 1) { + // The image is only used by the cache, so it's reusable. + imageToUse = images.remove(i); + break; + } else { + // Logically, this shouldn't happen, because as soon as the + // cache is the only user of this resource, it will not be + // used by anyone else until the next cache access, but we + // currently hold on to the cache lock. But technically + // future changes may violate this assumption, so warn about + // this. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Image refCount changed " + + "from 1 in getReusableBitmapFromPool()"); + image.releaseLock(); + } + } + } + + if (imageToUse == null) { + return null; + } + + try { + imageToUse.assertLockHeldByCurrentThread(); + + // Only reuse the bitmap if the last time we use was greater than 5s. + // This allows the cache a chance to reuse instead of always taking the + // oldest. + final long timeSinceLastRef = SystemClock.elapsedRealtime() - + imageToUse.getLastRefAddTimestamp(); + if (timeSinceLastRef < MIN_TIME_IN_POOL) { + if (LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "Not reusing reusing " + + "first available bitmap from the pool because it " + + "has not been in the pool long enough. " + + "timeSinceLastRef=" + timeSinceLastRef); + } + // Put back the image and return no reuseable bitmap. + images.addLast(imageToUse); + return null; + } + + // Add a temp ref on the image resource so it won't be GC'd after + // being removed from the cache. + imageToUse.addRef(); + + // Remove the image resource from the image cache. + final ImageResource removed = remove(imageToUse.getKey()); + Assert.isTrue(removed == imageToUse); + + // Try to reuse the bitmap from the image resource. This will transfer + // ownership of the bitmap object to the caller of this method. + final Bitmap reusableBitmap = imageToUse.reuseBitmap(); + + imageToUse.release(); + return reusableBitmap; + } finally { + // We are either done with the reuse operation, or decided not to use + // the image. Either way, release the lock. + imageToUse.releaseLock(); + } + } + } + } + return null; + } + + /** + * Try to locate and return a reusable bitmap from the pool, or create a new bitmap. + * @param width desired bitmap width + * @param height desired bitmap height + * @return the created or reused mutable bitmap that has its background cleared to + * {@value Color#TRANSPARENT} + */ + public Bitmap createOrReuseBitmap(final int width, final int height) { + return createOrReuseBitmap(width, height, Color.TRANSPARENT); + } + + /** + * Try to locate and return a reusable bitmap from the pool, or create a new bitmap. + * @param width desired bitmap width + * @param height desired bitmap height + * @param backgroundColor the background color for the returned bitmap + * @return the created or reused mutable bitmap with the requested background color + */ + public Bitmap createOrReuseBitmap(final int width, final int height, + final int backgroundColor) { + Bitmap retBitmap = null; + try { + final Bitmap poolBitmap = getReusableBitmapFromPool(width, height); + retBitmap = (poolBitmap != null) ? poolBitmap : + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + retBitmap.eraseColor(backgroundColor); + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache:try to createOrReuseBitmap"); + Factory.get().reclaimMemory(); + } + return retBitmap; + } + + private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width, + final int height) { + if (optionsTmp.inJustDecodeBounds) { + return; + } + optionsTmp.inBitmap = getReusableBitmapFromPool(width, height); + } + + /** + * @return The pool key for the provided image dimensions or 0 if either width or height is + * greater than the max supported image dimension. + */ + private int getPoolKey(final int width, final int height) { + if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) { + return INVALID_POOL_KEY; + } + return (width << 16) | height; + } + + /** + * @return the pool key for a given image resource. + */ + private int getPoolKey(final ImageResource imageResource) { + if (imageResource.supportsBitmapReuse()) { + final Bitmap bitmap = imageResource.getBitmap(); + if (bitmap != null && bitmap.isMutable()) { + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + if (width > 0 && height > 0) { + return getPoolKey(width, height); + } + } + } + return INVALID_POOL_KEY; + } + + /** + * Called when bitmap reuse fails. Conditionally report the failure with statistics. + */ + private void onFailedToReuse() { + mFailedBitmapReuseCount++; + if (mFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, + "Pooled bitmap consistently not being reused. Failure count = " + + mFailedBitmapReuseCount + ", success count = " + + mSucceededBitmapReuseCount); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java new file mode 100644 index 0000000..c21f477 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java @@ -0,0 +1,164 @@ +/* + * 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.media; + +import android.os.SystemClock; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.base.Throwables; + +import java.util.ArrayList; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A ref-counted class that holds loaded media resource, be it bitmaps or media bytes. + * Subclasses must implement the close() method to release any resources (such as bitmaps) + * when it's no longer used. + * + * Instances of the subclasses are: + * 1. Loaded by their corresponding MediaRequest classes. + * 2. Maintained by MediaResourceManager in its MediaCache pool. + * 3. Used by the UI (such as ContactIconViews) to present the content. + * + * Note: all synchronized methods in this class (e.g. addRef()) should not attempt to make outgoing + * calls that could potentially acquire media cache locks due to the potential deadlock this can + * cause. To synchronize read/write access to shared resource, {@link #acquireLock()} and + * {@link #releaseLock()} must be used, instead of using synchronized keyword. + */ +public abstract class RefCountedMediaResource { + private final String mKey; + private int mRef = 0; + private long mLastRefAddTimestamp; + + // Set DEBUG to true to enable detailed stack trace for each addRef() and release() operation + // to find out where each ref change happens. + private static final boolean DEBUG = false; + private static final String TAG = "bugle_media_ref_history"; + private final ArrayList<String> mRefHistory = new ArrayList<String>(); + + // A lock that guards access to shared members in this class (and all its subclasses). + private final ReentrantLock mLock = new ReentrantLock(); + + public RefCountedMediaResource(final String key) { + mKey = key; + } + + public String getKey() { + return mKey; + } + + public void addRef() { + acquireLock(); + try { + if (DEBUG) { + mRefHistory.add("Added ref current ref = " + mRef); + mRefHistory.add(Throwables.getStackTraceAsString(new Exception())); + } + + mRef++; + mLastRefAddTimestamp = SystemClock.elapsedRealtime(); + } finally { + releaseLock(); + } + } + + public void release() { + acquireLock(); + try { + if (DEBUG) { + mRefHistory.add("Released ref current ref = " + mRef); + mRefHistory.add(Throwables.getStackTraceAsString(new Exception())); + } + + mRef--; + if (mRef == 0) { + close(); + } else if (mRef < 0) { + if (DEBUG) { + LogUtil.i(TAG, "Unwinding ref count history for RefCountedMediaResource " + + this); + for (final String ref : mRefHistory) { + LogUtil.i(TAG, ref); + } + } + Assert.fail("RefCountedMediaResource has unbalanced ref. Refcount=" + mRef); + } + } finally { + releaseLock(); + } + } + + public int getRefCount() { + acquireLock(); + try { + return mRef; + } finally { + releaseLock(); + } + } + + public long getLastRefAddTimestamp() { + acquireLock(); + try { + return mLastRefAddTimestamp; + } finally { + releaseLock(); + } + } + + public void assertSingularRefCount() { + acquireLock(); + try { + Assert.equals(1, mRef); + } finally { + releaseLock(); + } + } + + void acquireLock() { + mLock.lock(); + } + + void releaseLock() { + mLock.unlock(); + } + + void assertLockHeldByCurrentThread() { + Assert.isTrue(mLock.isHeldByCurrentThread()); + } + + boolean isEncoded() { + return false; + } + + boolean isCacheable() { + return true; + } + + MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + return null; + } + + MediaRequest<? extends RefCountedMediaResource> getMediaEncodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + return null; + } + + public abstract int getMediaSize(); + protected abstract void close(); +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java new file mode 100644 index 0000000..e4f0334 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java @@ -0,0 +1,117 @@ +/* + * 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.media; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.media.ExifInterface; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.io.IOException; +import java.util.List; + +public class SimSelectorAvatarRequest extends AvatarRequest { + private static Bitmap sRegularSimIcon; + + public SimSelectorAvatarRequest(final Context context, + final AvatarRequestDescriptor descriptor) { + super(context, descriptor); + } + + /** + * {@inheritDoc} + */ + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks) + throws IOException { + Assert.isNotMainThread(); + final String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri); + if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)){ + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + final String identifier = AvatarUriUtil.getIdentifier(mDescriptor.uri); + final boolean simSelected = AvatarUriUtil.getSimSelected(mDescriptor.uri); + final int simColor = AvatarUriUtil.getSimColor(mDescriptor.uri); + final boolean incoming = AvatarUriUtil.getSimIncoming(mDescriptor.uri); + return renderSimAvatarInternal(identifier, width, height, simColor, simSelected, + incoming); + } + return super.loadMediaInternal(chainedTasks); + } + + private ImageResource renderSimAvatarInternal(final String identifier, final int width, + final int height, final int subColor, final boolean selected, final boolean incoming) { + final Resources resources = mContext.getResources(); + final float halfWidth = width / 2; + final float halfHeight = height / 2; + final int minOfWidthAndHeight = Math.min(width, height); + final int backgroundColor = selected ? subColor : Color.WHITE; + final int textColor = selected ? subColor : Color.WHITE; + final int simColor = selected ? Color.WHITE : subColor; + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, backgroundColor); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + final Canvas canvas = new Canvas(bitmap); + + if (sRegularSimIcon == null) { + final BitmapDrawable regularSim = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_sim_card_send); + sRegularSimIcon = regularSim.getBitmap(); + } + + paint.setColorFilter(new PorterDuffColorFilter(simColor, PorterDuff.Mode.SRC_ATOP)); + paint.setAlpha(0xff); + canvas.drawBitmap(sRegularSimIcon, halfWidth - sRegularSimIcon.getWidth() / 2, + halfHeight - sRegularSimIcon.getHeight() / 2, paint); + paint.setColorFilter(null); + paint.setAlpha(0xff); + + if (!TextUtils.isEmpty(identifier)) { + paint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + paint.setColor(textColor); + final float letterToTileRatio = + resources.getFraction(R.dimen.sim_identifier_to_tile_ratio, 1, 1); + paint.setTextSize(letterToTileRatio * minOfWidthAndHeight); + + final String firstCharString = identifier.substring(0, 1).toUpperCase(); + final Rect textBound = new Rect(); + paint.getTextBounds(firstCharString, 0, 1, textBound); + + final float xOffset = halfWidth - textBound.centerX(); + final float yOffset = halfHeight - textBound.centerY(); + canvas.drawText(firstCharString, xOffset, yOffset, paint); + } + + return new DecodedImageResource(getKey(), bitmap, ExifInterface.ORIENTATION_NORMAL); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/UriImageRequest.java b/src/com/android/messaging/datamodel/media/UriImageRequest.java new file mode 100644 index 0000000..b4934ca --- /dev/null +++ b/src/com/android/messaging/datamodel/media/UriImageRequest.java @@ -0,0 +1,58 @@ +/* + * 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.media; + +import android.content.Context; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Serves local content URI based image requests. + */ +public class UriImageRequest<D extends UriImageRequestDescriptor> extends ImageRequest<D> { + public UriImageRequest(final Context context, final D descriptor) { + super(context, descriptor); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + return mContext.getContentResolver().openInputStream(mDescriptor.uri); + } + + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks) + throws IOException { + final ImageResource resource = super.loadMediaInternal(chainedTasks); + // Check if the caller asked for compression. If so, chain an encoding task if possible. + if (mDescriptor.allowCompression && chainedTasks != null) { + @SuppressWarnings("unchecked") + final MediaRequest<ImageResource> chainedTask = (MediaRequest<ImageResource>) + resource.getMediaEncodingRequest(this); + if (chainedTask != null) { + chainedTasks.add(chainedTask); + // Don't cache decoded image resource since we'll perform compression and cache + // the compressed resource. + if (resource instanceof DecodedImageResource) { + ((DecodedImageResource) resource).setCacheable(false); + } + } + } + return resource; + } +} diff --git a/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java new file mode 100644 index 0000000..c5685d1 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java @@ -0,0 +1,95 @@ +/* + * 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.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.UriUtil; + +public class UriImageRequestDescriptor extends ImageRequestDescriptor { + public final Uri uri; + public final boolean allowCompression; + + public UriImageRequestDescriptor(final Uri uri) { + this(uri, UriImageRequest.UNSPECIFIED_SIZE, UriImageRequest.UNSPECIFIED_SIZE, false, false, + false, 0, 0); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight) + { + this(uri, desiredWidth, desiredHeight, false, false, false, 0, 0); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight, + final boolean cropToCircle, final int circleBackgroundColor, int circleStrokeColor) + { + this(uri, desiredWidth, desiredHeight, false, + false, cropToCircle, circleBackgroundColor, circleStrokeColor); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final boolean allowCompression, boolean isStatic, + boolean cropToCircle, int circleBackgroundColor, int circleStrokeColor) { + this(uri, desiredWidth, desiredHeight, UriImageRequest.UNSPECIFIED_SIZE, + UriImageRequest.UNSPECIFIED_SIZE, allowCompression, isStatic, cropToCircle, + circleBackgroundColor, circleStrokeColor); + } + + /** + * Creates a new Uri-based image request. + * @param uri the content Uri. Currently Bugle only supports local resources Uri (i.e. it has + * to begin with content: or android.resource: + * @param circleStrokeColor + */ + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean allowCompression, final boolean isStatic, final boolean cropToCircle, + final int circleBackgroundColor, int circleStrokeColor) { + super(desiredWidth, desiredHeight, sourceWidth, sourceHeight, isStatic, + cropToCircle, circleBackgroundColor, circleStrokeColor); + this.uri = uri; + this.allowCompression = allowCompression; + } + + @Override + public String getKey() { + if (uri != null) { + final String key = super.getKey(); + if (key != null) { + return new StringBuilder() + .append(uri).append(KEY_PART_DELIMITER) + .append(String.valueOf(allowCompression)).append(KEY_PART_DELIMITER) + .append(key).toString(); + } + } + return null; + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + if (uri == null || UriUtil.isLocalUri(uri)) { + return new UriImageRequest<UriImageRequestDescriptor>(context, this); + } else { + return new NetworkUriImageRequest<UriImageRequestDescriptor>(context, this); + } + } + + /** ID of the resource in MediaStore or null if this resource didn't come from MediaStore */ + public Long getMediaStoreId() { + return null; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/VCardRequest.java b/src/com/android/messaging/datamodel/media/VCardRequest.java new file mode 100644 index 0000000..d6e992c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardRequest.java @@ -0,0 +1,328 @@ +/* + * 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.media; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UriUtil; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryCounter; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardParser_V21; +import com.android.vcard.VCardParser_V30; +import com.android.vcard.VCardSourceDetector; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardNestedException; +import com.android.vcard.exception.VCardNotSupportedException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Requests and parses VCard data. In Bugle, we need to display VCard details in the conversation + * view such as avatar icon and name, which can be expensive if we parse VCard every time. + * Therefore, we'd like to load the vcard once and cache it in our media cache using the + * MediaResourceManager component. To load the VCard, we use framework's VCard support to + * interpret the VCard content, which gives us information such as phone and email list, which + * we'll put in VCardResource object to be cached. + * + * Some particular attention is needed for the avatar icon. If the VCard contains avatar icon, + * it's in byte array form that can't easily be cached/persisted. Therefore, we persist the + * image bytes to the scratch directory and generate a content Uri for it, so that ContactIconView + * may use this Uri to display and cache the image if needed. + */ +public class VCardRequest implements MediaRequest<VCardResource> { + private final Context mContext; + private final VCardRequestDescriptor mDescriptor; + private final List<VCardResourceEntry> mLoadedVCards; + private VCardResource mLoadedResource; + private static final int VCARD_LOADING_TIMEOUT_MILLIS = 10000; // 10s + private static final String DEFAULT_VCARD_TYPE = "default"; + + VCardRequest(final Context context, final VCardRequestDescriptor descriptor) { + mDescriptor = descriptor; + mContext = context; + mLoadedVCards = new ArrayList<VCardResourceEntry>(); + } + + @Override + public String getKey() { + return mDescriptor.vCardUri.toString(); + } + + @Override + @DoesNotRunOnMainThread + public VCardResource loadMediaBlocking(List<MediaRequest<VCardResource>> chainedTask) + throws Exception { + Assert.isNotMainThread(); + Assert.isTrue(mLoadedResource == null); + Assert.equals(0, mLoadedVCards.size()); + + // The VCard library doesn't support synchronously loading the media resource. Therefore, + // We have to burn the thread waiting for the result to come back. + final CountDownLatch signal = new CountDownLatch(1); + if (!parseVCard(mDescriptor.vCardUri, signal)) { + // Directly fail without actually going through the interpreter, return immediately. + throw new VCardException("Invalid vcard"); + } + + signal.await(VCARD_LOADING_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + if (mLoadedResource == null) { + // Maybe null if failed or timeout. + throw new VCardException("Failure or timeout loading vcard"); + } + return mLoadedResource; + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.VCARD_CACHE; + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache<VCardResource> getMediaCache() { + return (MediaCache<VCardResource>) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + @DoesNotRunOnMainThread + private boolean parseVCard(final Uri targetUri, final CountDownLatch signal) { + Assert.isNotMainThread(); + final VCardEntryCounter counter = new VCardEntryCounter(); + final VCardSourceDetector detector = new VCardSourceDetector(); + boolean result; + try { + // We don't know which type should be used to parse the Uri. + // It is possible to misinterpret the vCard, but we expect the parser + // lets VCardSourceDetector detect the type before the misinterpretation. + result = readOneVCardFile(targetUri, VCardConfig.VCARD_TYPE_UNKNOWN, + detector, true, null); + } catch (final VCardNestedException e) { + try { + final int estimatedVCardType = detector.getEstimatedType(); + // Assume that VCardSourceDetector was able to detect the source. + // Try again with the detector. + result = readOneVCardFile(targetUri, estimatedVCardType, + counter, false, null); + } catch (final VCardNestedException e2) { + result = false; + LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e2); + } + } + + if (!result) { + // Load failure. + return false; + } + + return doActuallyReadOneVCard(targetUri, true, detector, null, signal); + } + + @DoesNotRunOnMainThread + private boolean doActuallyReadOneVCard(final Uri uri, final boolean showEntryParseProgress, + final VCardSourceDetector detector, final List<String> errorFileNameList, + final CountDownLatch signal) { + Assert.isNotMainThread(); + int vcardType = detector.getEstimatedType(); + if (vcardType == VCardConfig.VCARD_TYPE_UNKNOWN) { + vcardType = VCardConfig.getVCardTypeFromString(DEFAULT_VCARD_TYPE); + } + final CustomVCardEntryConstructor builder = + new CustomVCardEntryConstructor(vcardType, null); + builder.addEntryHandler(new ContactVCardEntryHandler(signal)); + + try { + if (!readOneVCardFile(uri, vcardType, builder, false, null)) { + return false; + } + } catch (final VCardNestedException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e); + return false; + } + return true; + } + + @DoesNotRunOnMainThread + private boolean readOneVCardFile(final Uri uri, final int vcardType, + final VCardInterpreter interpreter, + final boolean throwNestedException, final List<String> errorFileNameList) + throws VCardNestedException { + Assert.isNotMainThread(); + final ContentResolver resolver = mContext.getContentResolver(); + VCardParser vCardParser; + InputStream is; + try { + is = resolver.openInputStream(uri); + vCardParser = new VCardParser_V21(vcardType); + vCardParser.addInterpreter(interpreter); + + try { + vCardParser.parse(is); + } catch (final VCardVersionException e1) { + try { + is.close(); + } catch (final IOException e) { + // Do nothing. + } + if (interpreter instanceof CustomVCardEntryConstructor) { + // Let the object clean up internal temporal objects, + ((CustomVCardEntryConstructor) interpreter).clear(); + } + + is = resolver.openInputStream(uri); + + try { + vCardParser = new VCardParser_V30(vcardType); + vCardParser.addInterpreter(interpreter); + vCardParser.parse(is); + } catch (final VCardVersionException e2) { + throw new VCardException("vCard with unspported version."); + } + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "IOException was emitted: " + e.getMessage()); + + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } catch (final VCardNotSupportedException e) { + if ((e instanceof VCardNestedException) && throwNestedException) { + throw (VCardNestedException) e; + } + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } catch (final VCardException e) { + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } + return true; + } + + class ContactVCardEntryHandler implements CustomVCardEntryConstructor.EntryHandler { + final CountDownLatch mSignal; + + public ContactVCardEntryHandler(final CountDownLatch signal) { + mSignal = signal; + } + + @Override + public void onStart() { + } + + @Override + @DoesNotRunOnMainThread + public void onEntryCreated(final CustomVCardEntry entry) { + Assert.isNotMainThread(); + final String displayName = entry.getDisplayName(); + final List<VCardEntry.PhotoData> photos = entry.getPhotoList(); + Uri avatarUri = null; + if (photos != null && photos.size() > 0) { + // The photo data is in bytes form, so we need to persist it in our temp directory + // so that ContactIconView can load it and display it later + // (and cache it, of course). + for (final VCardEntry.PhotoData photo : photos) { + final byte[] photoBytes = photo.getBytes(); + if (photoBytes != null) { + final InputStream inputStream = new ByteArrayInputStream(photoBytes); + try { + avatarUri = UriUtil.persistContentToScratchSpace(inputStream); + if (avatarUri != null) { + // Just load the first avatar and be done. Want more? wait for V2. + break; + } + } finally { + try { + inputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + } + + // Fall back to generated avatar. + if (avatarUri == null) { + String destination = null; + final List<VCardEntry.PhoneData> phones = entry.getPhoneList(); + if (phones != null && phones.size() > 0) { + destination = PhoneUtils.getDefault().getCanonicalBySystemLocale( + phones.get(0).getNumber()); + } + + if (destination == null) { + final List<VCardEntry.EmailData> emails = entry.getEmailList(); + if (emails != null && emails.size() > 0) { + destination = emails.get(0).getAddress(); + } + } + avatarUri = AvatarUriUtil.createAvatarUri(null, displayName, destination, null); + } + + // Add the loaded vcard to the list. + mLoadedVCards.add(new VCardResourceEntry(entry, avatarUri)); + } + + @Override + public void onEnd() { + // Finished loading all vCard entries, signal the loading thread to proceed with the + // result. + if (mLoadedVCards.size() > 0) { + mLoadedResource = new VCardResource(getKey(), mLoadedVCards); + } + mSignal.countDown(); + } + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + @Override + public MediaRequestDescriptor<VCardResource> getDescriptor() { + return mDescriptor; + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java new file mode 100644 index 0000000..4084851 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java @@ -0,0 +1,35 @@ +/* + * 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.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; + +public class VCardRequestDescriptor extends MediaRequestDescriptor<VCardResource> { + public final Uri vCardUri; + + public VCardRequestDescriptor(final Uri vCardUri) { + Assert.notNull(vCardUri); + this.vCardUri = vCardUri; + } + + @Override + public MediaRequest<VCardResource> buildSyncMediaRequest(Context context) { + return new VCardRequest(context, this); + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardResource.java b/src/com/android/messaging/datamodel/media/VCardResource.java new file mode 100644 index 0000000..edf5e88 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardResource.java @@ -0,0 +1,52 @@ +/* + * 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.media; + +import java.util.List; + +/** + * Holds cached information of VCard contact info. + * The temporarily persisted avatar icon Uri is tied to the VCardResource. As a result, whenever + * the VCardResource is no longer used (i.e. close() is called), we need to asynchronously + * delete the avatar image from temp storage since no one will have reference to the avatar Uri + * again. The next time the same VCard is displayed, since the old resource has been evicted from + * the memory cache, we'll load and persist the avatar icon again. + */ +public class VCardResource extends RefCountedMediaResource { + private final List<VCardResourceEntry> mVCards; + + public VCardResource(final String key, final List<VCardResourceEntry> vcards) { + super(key); + mVCards = vcards; + } + + public List<VCardResourceEntry> getVCards() { + return mVCards; + } + + @Override + public int getMediaSize() { + // Instead of track VCards by size in kilobytes, we track them by count. + return 0; + } + + @Override + protected void close() { + for (final VCardResourceEntry vcard : mVCards) { + vcard.close(); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardResourceEntry.java b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java new file mode 100644 index 0000000..f76b796 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java @@ -0,0 +1,389 @@ +/* + * 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.media; + +import android.content.Intent; +import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.ArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntry.EmailData; +import com.android.vcard.VCardEntry.ImData; +import com.android.vcard.VCardEntry.NoteData; +import com.android.vcard.VCardEntry.OrganizationData; +import com.android.vcard.VCardEntry.PhoneData; +import com.android.vcard.VCardEntry.PostalData; +import com.android.vcard.VCardEntry.WebsiteData; +import com.android.vcard.VCardProperty; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/** + * Holds one entry item (i.e. a single contact) within a VCard resource. It is able to take + * a VCardEntry and extract relevant information from it. + */ +public class VCardResourceEntry { + public static final String PROPERTY_KIND = "KIND"; + + public static final String KIND_LOCATION = "location"; + + private final List<VCardResourceEntry.VCardResourceEntryDestinationItem> mContactInfo; + private final Uri mAvatarUri; + private final String mDisplayName; + private final CustomVCardEntry mVCard; + + public VCardResourceEntry(final CustomVCardEntry vcard, final Uri avatarUri) { + mContactInfo = getContactInfoFromVCardEntry(vcard); + mDisplayName = getDisplayNameFromVCardEntry(vcard); + mAvatarUri = avatarUri; + mVCard = vcard; + } + + void close() { + // If the avatar image was temporarily saved in the scratch folder, remove that. + if (MediaScratchFileProvider.isMediaScratchSpaceUri(mAvatarUri)) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + mAvatarUri, null, null); + } + }); + } + } + + public String getKind() { + VCardProperty kindProperty = mVCard.getProperty(PROPERTY_KIND); + return kindProperty == null ? null : kindProperty.getRawValue(); + } + + public Uri getAvatarUri() { + return mAvatarUri; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getDisplayAddress() { + List<PostalData> postalList = mVCard.getPostalList(); + if (postalList == null || postalList.size() < 1) { + return null; + } + + return formatAddress(postalList.get(0)); + } + + public String getNotes() { + List<NoteData> notes = mVCard.getNotes(); + if (notes == null || notes.size() == 0) { + return null; + } + StringBuilder noteBuilder = new StringBuilder(); + for (NoteData note : notes) { + noteBuilder.append(note.getNote()); + } + return noteBuilder.toString(); + } + + /** + * Returns a UI-facing representation that can be bound and consumed by the UI layer to display + * this VCard resource entry. + */ + public PersonItemData getDisplayItem() { + return new PersonItemData() { + @Override + public Uri getAvatarUri() { + return VCardResourceEntry.this.getAvatarUri(); + } + + @Override + public String getDisplayName() { + return VCardResourceEntry.this.getDisplayName(); + } + + @Override + public String getDetails() { + return null; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + } + + public List<VCardResourceEntry.VCardResourceEntryDestinationItem> getContactInfo() { + return mContactInfo; + } + + private static List<VCardResourceEntryDestinationItem> getContactInfoFromVCardEntry( + final VCardEntry vcard) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + final List<VCardResourceEntry.VCardResourceEntryDestinationItem> retList = + new ArrayList<VCardResourceEntry.VCardResourceEntryDestinationItem>(); + if (vcard.getPhoneList() != null) { + for (final PhoneData phone : vcard.getPhoneList()) { + final Intent intent = new Intent(Intent.ACTION_DIAL); + intent.setData(Uri.parse("tel:" + phone.getNumber())); + retList.add(new VCardResourceEntryDestinationItem(phone.getNumber(), + Phone.getTypeLabel(resources, phone.getType(), phone.getLabel()).toString(), + intent)); + } + } + + if (vcard.getEmailList() != null) { + for (final EmailData email : vcard.getEmailList()) { + final Intent intent = new Intent(Intent.ACTION_SENDTO); + intent.setData(Uri.parse("mailto:")); + intent.putExtra(Intent.EXTRA_EMAIL, new String[] { email.getAddress() }); + retList.add(new VCardResourceEntryDestinationItem(email.getAddress(), + Phone.getTypeLabel(resources, email.getType(), + email.getLabel()).toString(), intent)); + } + } + + if (vcard.getPostalList() != null) { + for (final PostalData postalData : vcard.getPostalList()) { + String type; + try { + type = resources. + getStringArray(android.R.array.postalAddressTypes) + [postalData.getType() - 1]; + } catch (final NotFoundException ex) { + type = resources.getStringArray(android.R.array.postalAddressTypes)[2]; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem postal Exception:" + e); + type = resources.getStringArray(android.R.array.postalAddressTypes)[2]; + } + Intent intent = new Intent(Intent.ACTION_VIEW); + final String address = formatAddress(postalData); + try { + intent.setData(Uri.parse("geo:0,0?q=" + URLEncoder.encode(address, "UTF-8"))); + } catch (UnsupportedEncodingException e) { + intent = null; + } + + retList.add(new VCardResourceEntryDestinationItem(address, type, intent)); + } + } + + if (vcard.getImList() != null) { + for (final ImData imData : vcard.getImList()) { + String type = null; + try { + type = resources. + getString(Im.getProtocolLabelResource(imData.getProtocol())); + } catch (final NotFoundException ex) { + // Do nothing since this implies an empty label. + } + retList.add(new VCardResourceEntryDestinationItem(imData.getAddress(), type, null)); + } + } + + if (vcard.getOrganizationList() != null) { + for (final OrganizationData organtization : vcard.getOrganizationList()) { + String type = null; + try { + type = resources.getString(Organization.getTypeLabelResource( + organtization.getType())); + } catch (final NotFoundException ex) { + //set other kind as "other" + type = resources.getStringArray(android.R.array.organizationTypes)[1]; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem org Exception:" + e); + type = resources.getStringArray(android.R.array.organizationTypes)[1]; + } + retList.add(new VCardResourceEntryDestinationItem( + organtization.getOrganizationName(), type, null)); + } + } + + if (vcard.getWebsiteList() != null) { + for (final WebsiteData web : vcard.getWebsiteList()) { + if (web != null && TextUtils.isGraphic(web.getWebsite())){ + String website = web.getWebsite(); + if (!website.startsWith("http://") && !website.startsWith("https://")) { + // Prefix required for parsing to end up with a scheme and result in + // navigation + website = "http://" + website; + } + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(website)); + retList.add(new VCardResourceEntryDestinationItem(web.getWebsite(), null, + intent)); + } + } + } + + if (vcard.getBirthday() != null) { + final String birthday = vcard.getBirthday(); + if (TextUtils.isGraphic(birthday)){ + retList.add(new VCardResourceEntryDestinationItem(birthday, + resources.getString(R.string.vcard_detail_birthday_label), null)); + } + } + + if (vcard.getNotes() != null) { + for (final NoteData note : vcard.getNotes()) { + final ArrayMap<String, String> curChildMap = new ArrayMap<String, String>(); + if (TextUtils.isGraphic(note.getNote())){ + retList.add(new VCardResourceEntryDestinationItem(note.getNote(), + resources.getString(R.string.vcard_detail_notes_label), null)); + } + } + } + return retList; + } + + private static String formatAddress(final PostalData postalData) { + final StringBuilder sb = new StringBuilder(); + final String poBox = postalData.getPobox(); + if (!TextUtils.isEmpty(poBox)) { + sb.append(poBox); + sb.append(" "); + } + final String extendedAddress = postalData.getExtendedAddress(); + if (!TextUtils.isEmpty(extendedAddress)) { + sb.append(extendedAddress); + sb.append(" "); + } + final String street = postalData.getStreet(); + if (!TextUtils.isEmpty(street)) { + sb.append(street); + sb.append(" "); + } + final String localty = postalData.getLocalty(); + if (!TextUtils.isEmpty(localty)) { + sb.append(localty); + sb.append(" "); + } + final String region = postalData.getRegion(); + if (!TextUtils.isEmpty(region)) { + sb.append(region); + sb.append(" "); + } + final String postalCode = postalData.getPostalCode(); + if (!TextUtils.isEmpty(postalCode)) { + sb.append(postalCode); + sb.append(" "); + } + final String country = postalData.getCountry(); + if (!TextUtils.isEmpty(country)) { + sb.append(country); + } + return sb.toString(); + } + + private static String getDisplayNameFromVCardEntry(final VCardEntry vcard) { + String name = vcard.getDisplayName(); + if (name == null) { + vcard.consolidateFields(); + name = vcard.getDisplayName(); + } + return name; + } + + /** + * Represents one entry line (e.g. phone number and phone label) for a single contact. Each + * VCardResourceEntry may hold one or more VCardResourceEntryDestinationItem's. + */ + public static class VCardResourceEntryDestinationItem { + private final String mDisplayDestination; + private final String mDestinationType; + private final Intent mClickIntent; + public VCardResourceEntryDestinationItem(final String displayDestination, + final String destinationType, final Intent clickIntent) { + mDisplayDestination = displayDestination; + mDestinationType = destinationType; + mClickIntent = clickIntent; + } + + /** + * Returns a UI-facing representation that can be bound and consumed by the UI layer to + * display this VCard resource destination entry. + */ + public PersonItemData getDisplayItem() { + return new PersonItemData() { + @Override + public Uri getAvatarUri() { + return null; + } + + @Override + public String getDisplayName() { + return mDisplayDestination; + } + + @Override + public String getDetails() { + return mDestinationType; + } + + @Override + public Intent getClickIntent() { + return mClickIntent; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + } + } +} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java new file mode 100644 index 0000000..f17591c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java @@ -0,0 +1,78 @@ +/* + * 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.media; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.provider.MediaStore.Video.Thumbnails; + +import com.android.messaging.Factory; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; +import com.android.messaging.util.OsUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Class to request a video thumbnail. + * Users of this class as responsible for checking {@link #shouldShowIncomingVideoThumbnails} + */ +public class VideoThumbnailRequest extends ImageRequest<UriImageRequestDescriptor> { + + public VideoThumbnailRequest(final Context context, + final UriImageRequestDescriptor descriptor) { + super(context, descriptor); + } + + public static boolean shouldShowIncomingVideoThumbnails() { + return OsUtil.isAtLeastM(); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + return null; + } + + @Override + protected boolean hasBitmapObject() { + return true; + } + + @Override + protected Bitmap getBitmapForResource() throws IOException { + final Long mediaId = mDescriptor.getMediaStoreId(); + Bitmap bitmap = null; + if (mediaId != null) { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + bitmap = Thumbnails.getThumbnail(cr, mediaId, Thumbnails.MICRO_KIND, null); + } else { + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(mDescriptor.uri); + bitmap = retriever.getFrameAtTime(); + } finally { + retriever.release(); + } + } + if (bitmap != null) { + mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight()); + } + return bitmap; + } +} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java new file mode 100644 index 0000000..907bb8f --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java @@ -0,0 +1,44 @@ +/* + * 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.media; + +import android.content.Context; + +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +public class VideoThumbnailRequestDescriptor extends UriImageRequestDescriptor { + protected final long mMediaId; + public VideoThumbnailRequestDescriptor(final long id, String path, int desiredWidth, + int desiredHeight, int sourceWidth, int sourceHeight) { + super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth, + sourceHeight, false /* canCompress */, false /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMediaId = id; + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(Context context) { + return new VideoThumbnailRequest(context, this); + } + + @Override + public Long getMediaStoreId() { + return mMediaId; + } +} |