diff options
author | Mike Dodd <mdodd@google.com> | 2015-08-11 11:16:59 -0700 |
---|---|---|
committer | Mike Dodd <mdodd@google.com> | 2015-08-12 08:58:28 -0700 |
commit | 461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch) | |
tree | bc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/datamodel/data | |
parent | 8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (diff) | |
download | android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.gz android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.bz2 android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.zip |
Initial checkin of AOSP Messaging app.
b/23110861
Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
Diffstat (limited to 'src/com/android/messaging/datamodel/data')
26 files changed, 7789 insertions, 0 deletions
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); + } +} |