summaryrefslogtreecommitdiffstats
path: root/src/com/android/messaging/datamodel
diff options
context:
space:
mode:
authorMike Dodd <mdodd@google.com>2015-08-11 11:16:59 -0700
committerMike Dodd <mdodd@google.com>2015-08-12 12:47:26 -0700
commitd3b009ae55651f1e60950342468e3c37fdeb0796 (patch)
treebc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/datamodel
parentef8c7abbcfc9c770385d6609a4b4bc70240ebdc4 (diff)
downloadpackages_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')
-rw-r--r--src/com/android/messaging/datamodel/BitmapPool.java364
-rw-r--r--src/com/android/messaging/datamodel/BoundCursorLoader.java46
-rw-r--r--src/com/android/messaging/datamodel/BugleDatabaseOperations.java1919
-rw-r--r--src/com/android/messaging/datamodel/BugleNotifications.java1221
-rw-r--r--src/com/android/messaging/datamodel/BugleRecipientEntry.java64
-rw-r--r--src/com/android/messaging/datamodel/ConversationImagePartsView.java120
-rw-r--r--src/com/android/messaging/datamodel/CursorQueryData.java82
-rw-r--r--src/com/android/messaging/datamodel/DataModel.java158
-rw-r--r--src/com/android/messaging/datamodel/DataModelException.java101
-rw-r--r--src/com/android/messaging/datamodel/DataModelImpl.java236
-rw-r--r--src/com/android/messaging/datamodel/DatabaseHelper.java813
-rw-r--r--src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java42
-rw-r--r--src/com/android/messaging/datamodel/DatabaseWrapper.java482
-rw-r--r--src/com/android/messaging/datamodel/FileProvider.java151
-rw-r--r--src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java179
-rw-r--r--src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java114
-rw-r--r--src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java49
-rw-r--r--src/com/android/messaging/datamodel/MediaScratchFileProvider.java132
-rw-r--r--src/com/android/messaging/datamodel/MemoryCacheManager.java75
-rw-r--r--src/com/android/messaging/datamodel/MessageNotificationState.java1342
-rw-r--r--src/com/android/messaging/datamodel/MessageTextStats.java92
-rw-r--r--src/com/android/messaging/datamodel/MessagingContentProvider.java476
-rw-r--r--src/com/android/messaging/datamodel/MmsFileProvider.java69
-rw-r--r--src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java148
-rw-r--r--src/com/android/messaging/datamodel/NotificationState.java149
-rw-r--r--src/com/android/messaging/datamodel/ParticipantRefresh.java738
-rw-r--r--src/com/android/messaging/datamodel/SyncManager.java478
-rw-r--r--src/com/android/messaging/datamodel/action/Action.java295
-rw-r--r--src/com/android/messaging/datamodel/action/ActionMonitor.java477
-rw-r--r--src/com/android/messaging/datamodel/action/ActionService.java63
-rw-r--r--src/com/android/messaging/datamodel/action/ActionServiceImpl.java341
-rw-r--r--src/com/android/messaging/datamodel/action/BackgroundWorker.java32
-rw-r--r--src/com/android/messaging/datamodel/action/BackgroundWorkerService.java168
-rw-r--r--src/com/android/messaging/datamodel/action/BugleActionToasts.java172
-rw-r--r--src/com/android/messaging/datamodel/action/DeleteConversationAction.java205
-rw-r--r--src/com/android/messaging/datamodel/action/DeleteMessageAction.java135
-rw-r--r--src/com/android/messaging/datamodel/action/DownloadMmsAction.java340
-rw-r--r--src/com/android/messaging/datamodel/action/DumpDatabaseAction.java124
-rw-r--r--src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java114
-rw-r--r--src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java173
-rw-r--r--src/com/android/messaging/datamodel/action/HandleLowStorageAction.java94
-rw-r--r--src/com/android/messaging/datamodel/action/InsertNewMessageAction.java480
-rw-r--r--src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java153
-rw-r--r--src/com/android/messaging/datamodel/action/MarkAsReadAction.java113
-rw-r--r--src/com/android/messaging/datamodel/action/MarkAsSeenAction.java126
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java122
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java573
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java470
-rw-r--r--src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java310
-rw-r--r--src/com/android/messaging/datamodel/action/ReadDraftDataAction.java166
-rw-r--r--src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java197
-rw-r--r--src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java198
-rw-r--r--src/com/android/messaging/datamodel/action/RedownloadMmsAction.java128
-rw-r--r--src/com/android/messaging/datamodel/action/ResendMessageAction.java128
-rw-r--r--src/com/android/messaging/datamodel/action/SendMessageAction.java447
-rw-r--r--src/com/android/messaging/datamodel/action/SyncCursorPair.java712
-rw-r--r--src/com/android/messaging/datamodel/action/SyncMessageBatch.java383
-rw-r--r--src/com/android/messaging/datamodel/action/SyncMessagesAction.java637
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java93
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java156
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java148
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java63
-rw-r--r--src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java103
-rw-r--r--src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java104
-rw-r--r--src/com/android/messaging/datamodel/binding/BindableData.java72
-rw-r--r--src/com/android/messaging/datamodel/binding/BindableOnceData.java42
-rw-r--r--src/com/android/messaging/datamodel/binding/Binding.java94
-rw-r--r--src/com/android/messaging/datamodel/binding/BindingBase.java84
-rw-r--r--src/com/android/messaging/datamodel/binding/DetachableBinding.java56
-rw-r--r--src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java83
-rw-r--r--src/com/android/messaging/datamodel/data/BlockedParticipantsData.java103
-rw-r--r--src/com/android/messaging/datamodel/data/ContactListItemData.java160
-rw-r--r--src/com/android/messaging/datamodel/data/ContactPickerData.java194
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationData.java849
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationListData.java211
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationListItemData.java510
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java37
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationMessageData.java917
-rw-r--r--src/com/android/messaging/datamodel/data/ConversationParticipantsData.java125
-rw-r--r--src/com/android/messaging/datamodel/data/DraftMessageData.java855
-rw-r--r--src/com/android/messaging/datamodel/data/GalleryGridItemData.java128
-rw-r--r--src/com/android/messaging/datamodel/data/LaunchConversationData.java90
-rw-r--r--src/com/android/messaging/datamodel/data/MediaPickerData.java175
-rw-r--r--src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java64
-rw-r--r--src/com/android/messaging/datamodel/data/MessageData.java922
-rw-r--r--src/com/android/messaging/datamodel/data/MessagePartData.java534
-rw-r--r--src/com/android/messaging/datamodel/data/ParticipantData.java569
-rw-r--r--src/com/android/messaging/datamodel/data/ParticipantListItemData.java95
-rw-r--r--src/com/android/messaging/datamodel/data/PendingAttachmentData.java176
-rw-r--r--src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java210
-rw-r--r--src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java155
-rw-r--r--src/com/android/messaging/datamodel/data/PersonItemData.java67
-rw-r--r--src/com/android/messaging/datamodel/data/SelfParticipantsData.java107
-rw-r--r--src/com/android/messaging/datamodel/data/SettingsData.java223
-rw-r--r--src/com/android/messaging/datamodel/data/SubscriptionListData.java128
-rw-r--r--src/com/android/messaging/datamodel/data/VCardContactItemData.java185
-rw-r--r--src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java74
-rw-r--r--src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java159
-rw-r--r--src/com/android/messaging/datamodel/media/AvatarRequest.java189
-rw-r--r--src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java60
-rw-r--r--src/com/android/messaging/datamodel/media/BindableMediaRequest.java63
-rw-r--r--src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java54
-rw-r--r--src/com/android/messaging/datamodel/media/CompositeImageRequest.java109
-rw-r--r--src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java61
-rw-r--r--src/com/android/messaging/datamodel/media/CustomVCardEntry.java48
-rw-r--r--src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java133
-rw-r--r--src/com/android/messaging/datamodel/media/DecodedImageResource.java254
-rw-r--r--src/com/android/messaging/datamodel/media/EncodedImageResource.java162
-rw-r--r--src/com/android/messaging/datamodel/media/FileImageRequest.java108
-rw-r--r--src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java68
-rw-r--r--src/com/android/messaging/datamodel/media/GifImageResource.java110
-rw-r--r--src/com/android/messaging/datamodel/media/ImageRequest.java258
-rw-r--r--src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java113
-rw-r--r--src/com/android/messaging/datamodel/media/ImageResource.java63
-rw-r--r--src/com/android/messaging/datamodel/media/MediaBytes.java42
-rw-r--r--src/com/android/messaging/datamodel/media/MediaCache.java113
-rw-r--r--src/com/android/messaging/datamodel/media/MediaCacheManager.java69
-rw-r--r--src/com/android/messaging/datamodel/media/MediaRequest.java70
-rw-r--r--src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java38
-rw-r--r--src/com/android/messaging/datamodel/media/MediaResourceManager.java325
-rw-r--r--src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java64
-rw-r--r--src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java38
-rw-r--r--src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java120
-rw-r--r--src/com/android/messaging/datamodel/media/PoolableImageCache.java419
-rw-r--r--src/com/android/messaging/datamodel/media/RefCountedMediaResource.java164
-rw-r--r--src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java117
-rw-r--r--src/com/android/messaging/datamodel/media/UriImageRequest.java58
-rw-r--r--src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java95
-rw-r--r--src/com/android/messaging/datamodel/media/VCardRequest.java328
-rw-r--r--src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java35
-rw-r--r--src/com/android/messaging/datamodel/media/VCardResource.java52
-rw-r--r--src/com/android/messaging/datamodel/media/VCardResourceEntry.java389
-rw-r--r--src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java78
-rw-r--r--src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java44
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;
+ }
+}