/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 androidx.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 getPositions(final String conversationId, final List ids) { final ArrayList result = new ArrayList(); 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 idsSet = new HashSet(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 { @Override public Loader onCreateLoader(final int id, final Bundle args) { Assert.equals(CONVERSATION_META_DATA_LOADER, id); Loader 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 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 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 { @Override public Loader onCreateLoader(final int id, final Bundle args) { Assert.equals(CONVERSATION_MESSAGES_LOADER, id); Loader 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 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 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 { @Override public Loader onCreateLoader(final int id, final Bundle args) { Assert.equals(PARTICIPANT_LOADER, id); Loader 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 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 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 { @Override public Loader onCreateLoader(final int id, final Bundle args) { Assert.equals(SELF_PARTICIPANT_LOADER, id); Loader 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 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 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 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 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 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 phones = new ArrayList<>(); final ArrayList 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 binding, final String messageId) { Assert.isTrue(binding.getData() == this); Assert.notNull(messageId); RedownloadMmsAction.redownloadMessage(messageId); } public void resendMessage(final BindingBase binding, final String messageId) { Assert.isTrue(binding.getData() == this); Assert.notNull(messageId); ResendMessageAction.resendMessage(messageId); } public void deleteMessage(final BindingBase binding, final String messageId) { Assert.isTrue(binding.getData() == this); Assert.notNull(messageId); DeleteMessageAction.deleteMessage(messageId); } public void deleteConversation(final Binding 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 binding) { Assert.isTrue(binding.getData() == this); UpdateConversationArchiveStatusAction.archiveConversation(mConversationId); } public void unarchiveConversation(final BindingBase 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 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); } } } }