Fix conversation list bug with pinned chats.

Co-authored-by: Alex Hart <alex@signal.org>
master
Cody Henthorne 2020-08-14 15:50:23 -04:00 committed by Greyson Parrelli
parent f84c8229de
commit e428453835
10 changed files with 540 additions and 317 deletions

View File

@ -1,148 +0,0 @@
package org.thoughtcrime.securesms.conversationlist;
import android.view.LayoutInflater;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.paging.PagedList;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapter;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
class CompositeConversationListAdapter extends RecyclerViewConcatenateAdapter {
private final FixedViewsAdapter pinnedHeaderAdapter;
private final ConversationListAdapter pinnedAdapter;
private final FixedViewsAdapter unpinnedHeaderAdapter;
private final ConversationListAdapter unpinnedAdapter;
CompositeConversationListAdapter(@NonNull RecyclerView rv,
@NonNull GlideRequests glideRequests,
@NonNull ConversationListAdapter.OnConversationClickListener onConversationClickListener)
{
TextView pinned = (TextView) LayoutInflater.from(rv.getContext()).inflate(R.layout.conversation_list_item_header, rv, false);
TextView unpinned = (TextView) LayoutInflater.from(rv.getContext()).inflate(R.layout.conversation_list_item_header, rv, false);
pinned.setText(rv.getContext().getString(R.string.conversation_list__pinned));
unpinned.setText(rv.getContext().getString(R.string.conversation_list__chats));
this.pinnedHeaderAdapter = new FixedViewsAdapter(pinned);
this.pinnedAdapter = new ConversationListAdapter(this, glideRequests, onConversationClickListener);
this.unpinnedHeaderAdapter = new FixedViewsAdapter(unpinned);
this.unpinnedAdapter = new ConversationListAdapter(this, glideRequests, onConversationClickListener);
pinnedHeaderAdapter.hide();
unpinnedHeaderAdapter.hide();
unpinnedAdapter.registerAdapterDataObserver(new UnpinnedAdapterDataObserver());
pinnedAdapter.registerAdapterDataObserver(new PinnedAdapterDataObserver());
addAdapter(pinnedHeaderAdapter);
addAdapter(pinnedAdapter);
addAdapter(unpinnedHeaderAdapter);
addAdapter(unpinnedAdapter);
}
public void submitPinnedList(@NonNull PagedList<Conversation> pinnedConversations) {
pinnedAdapter.submitList(pinnedConversations);
}
public void submitUnpinnedList(@NonNull PagedList<Conversation> unpinnedConversations) {
unpinnedAdapter.submitList(unpinnedConversations);
}
public void setTypingThreads(@NonNull Set<Long> threads) {
pinnedAdapter.setTypingThreads(threads);
unpinnedAdapter.setTypingThreads(threads);
}
public @NonNull Set<Long> getBatchSelectionIds() {
HashSet<Long> hashSet = new HashSet();
hashSet.addAll(pinnedAdapter.getBatchSelectionIds());
hashSet.addAll(unpinnedAdapter.getBatchSelectionIds());
return hashSet;
}
public void selectAllThreads() {
pinnedAdapter.selectAllThreads();
unpinnedAdapter.selectAllThreads();
}
public void updateArchived(int archivedCount) {
unpinnedAdapter.updateArchived(archivedCount);
}
public void toggleConversationInBatchSet(@NonNull Conversation conversation) {
if (conversation.getThreadRecord().isPinned()) {
pinnedAdapter.toggleConversationInBatchSet(conversation);
} else {
unpinnedAdapter.toggleConversationInBatchSet(conversation);
}
}
public void initializeBatchMode(boolean toggle) {
pinnedAdapter.initializeBatchMode(toggle);
unpinnedAdapter.initializeBatchMode(toggle);
}
public long getPinnedItemCount() {
return pinnedAdapter.getItemCount();
}
public @NonNull Collection<Conversation> getBatchSelection() {
Set<Conversation> conversations = new HashSet<>();
conversations.addAll(pinnedAdapter.getBatchSelection());
conversations.addAll(unpinnedAdapter.getBatchSelection());
return conversations;
}
private class UnpinnedAdapterDataObserver extends RecyclerView.AdapterDataObserver {
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
if (unpinnedAdapter.getItemCount() == 0) {
unpinnedHeaderAdapter.hide();
}
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
if (itemCount > 0 && pinnedAdapter.getItemCount() > 0) {
unpinnedHeaderAdapter.show();
}
}
}
private class PinnedAdapterDataObserver extends RecyclerView.AdapterDataObserver {
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
if (pinnedAdapter.getItemCount() == 0) {
pinnedHeaderAdapter.hide();
unpinnedHeaderAdapter.hide();
}
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
if (itemCount > 0) {
pinnedHeaderAdapter.show();
if (unpinnedAdapter.getItemCount() > 0) {
unpinnedHeaderAdapter.show();
}
}
}
}
}

View File

@ -4,6 +4,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.paging.PagedListAdapter;
@ -13,12 +14,9 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapter;
import java.util.Collection;
import java.util.Collections;
@ -30,11 +28,12 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
class ConversationListAdapter extends PagedListAdapter<Conversation, ConversationListAdapter.BaseViewHolder> {
class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerView.ViewHolder> {
private static final int TYPE_THREAD = 1;
private static final int TYPE_ACTION = 2;
private static final int TYPE_PLACEHOLDER = 3;
private static final int TYPE_HEADER = 4;
private enum Payload {
TYPING_INDICATOR,
@ -46,26 +45,21 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
private final Map<Long, Conversation> batchSet = Collections.synchronizedMap(new HashMap<>());
private boolean batchMode = false;
private final Set<Long> typingSet = new HashSet<>();
private int archived;
private final RecyclerViewConcatenateAdapter parent;
protected ConversationListAdapter(@NonNull RecyclerViewConcatenateAdapter parent,
@NonNull GlideRequests glideRequests,
protected ConversationListAdapter(@NonNull GlideRequests glideRequests,
@NonNull OnConversationClickListener onConversationClickListener)
{
super(new ConversationDiffCallback());
this.parent = parent;
this.glideRequests = glideRequests;
this.onConversationClickListener = onConversationClickListener;
}
@Override
public @NonNull BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == TYPE_ACTION) {
ConversationViewHolder holder = new ConversationViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_action, parent, false), viewType);
.inflate(R.layout.conversation_list_item_action, parent, false));
holder.itemView.setOnClickListener(v -> {
if (holder.getAdapterPosition() != RecyclerView.NO_POSITION) {
@ -76,10 +70,10 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
return holder;
} else if (viewType == TYPE_THREAD) {
ConversationViewHolder holder = new ConversationViewHolder(CachedInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false), viewType);
.inflate(R.layout.conversation_list_item_view, parent, false));
holder.itemView.setOnClickListener(v -> {
int position = this.parent.getLocalPosition(holder.getAdapterPosition()).getLocalPosition();
int position = holder.getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
onConversationClickListener.onConversationClick(getItem(position));
@ -87,7 +81,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
});
holder.itemView.setOnLongClickListener(v -> {
int position = this.parent.getLocalPosition(holder.getAdapterPosition()).getLocalPosition();
int position = holder.getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
return onConversationClickListener.onConversationLongClick(getItem(position));
@ -100,16 +94,19 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
View v = new FrameLayout(parent.getContext());
v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100)));
return new PlaceholderViewHolder(v);
} else if (viewType == TYPE_HEADER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_header, parent, false);
return new HeaderViewHolder(v);
} else {
throw new IllegalStateException("Unknown type! " + viewType);
}
}
@Override
public void onBindViewHolder(@NonNull BaseViewHolder holder, int position, @NonNull List<Object> payloads) {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
} else if (holder instanceof ConversationViewHolder) {
for (Object payloadObject : payloads) {
if (payloadObject instanceof Payload) {
Payload payload = (Payload) payloadObject;
@ -125,22 +122,8 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
}
@Override
public void onBindViewHolder(@NonNull BaseViewHolder holder, int position) {
if (holder.getLocalViewType() == TYPE_ACTION) {
ConversationViewHolder casted = (ConversationViewHolder) holder;
casted.getConversationListItem().bind(new ThreadRecord.Builder(100)
.setBody("")
.setDate(100)
.setRecipient(Recipient.UNKNOWN)
.setCount(archived)
.build(),
glideRequests,
Locale.getDefault(),
typingSet,
getBatchSelectionIds(),
batchMode);
} else if (holder.getLocalViewType() == TYPE_THREAD) {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder.getItemViewType() == TYPE_ACTION || holder.getItemViewType() == TYPE_THREAD) {
ConversationViewHolder casted = (ConversationViewHolder) holder;
Conversation conversation = Objects.requireNonNull(getItem(position));
@ -150,11 +133,24 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
typingSet,
getBatchSelectionIds(),
batchMode);
} else if (holder.getItemViewType() == TYPE_HEADER) {
HeaderViewHolder casted = (HeaderViewHolder) holder;
Conversation conversation = Objects.requireNonNull(getItem(position));
switch (conversation.getType()) {
case PINNED_HEADER:
casted.headerText.setText(R.string.conversation_list__pinned);
break;
case UNPINNED_HEADER:
casted.headerText.setText(R.string.conversation_list__chats);
break;
default:
throw new IllegalArgumentException();
}
}
}
@Override
public void onViewRecycled(@NonNull BaseViewHolder holder) {
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
if (holder instanceof ConversationViewHolder) {
((ConversationViewHolder) holder).getConversationListItem().unbind();
}
@ -181,35 +177,22 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
return batchSet.values();
}
void updateArchived(int archived) {
int oldArchived = this.archived;
this.archived = archived;
if (oldArchived != archived) {
if (archived == 0) {
notifyItemRemoved(getItemCount());
} else if (oldArchived == 0) {
notifyItemInserted(getItemCount() - 1);
} else {
notifyItemChanged(getItemCount() - 1);
}
}
}
@Override
public int getItemCount() {
return (archived > 0 ? 1 : 0) + super.getItemCount();
}
@Override
public int getItemViewType(int position) {
if (archived > 0 && position == getItemCount() - 1) {
return TYPE_ACTION;
} else if (getItem(position) == null) {
Conversation conversation = getItem(position);
if (conversation == null) {
return TYPE_PLACEHOLDER;
} else {
return TYPE_THREAD;
}
switch (conversation.getType()) {
case PINNED_HEADER:
case UNPINNED_HEADER:
return TYPE_HEADER;
case ARCHIVED_FOOTER:
return TYPE_ACTION;
case THREAD:
return TYPE_THREAD;
default:
throw new IllegalArgumentException();
}
}
@ -220,7 +203,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
void selectAllThreads() {
for (int i = 0; i < super.getItemCount(); i++) {
Conversation conversation = getItem(i);
if (conversation != null && conversation.getThreadRecord().getThreadId() != -1) {
if (conversation != null && conversation.getThreadRecord().getThreadId() >= 0) {
batchSet.put(conversation.getThreadRecord().getThreadId(), conversation);
}
}
@ -239,26 +222,12 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION);
}
static class BaseViewHolder extends RecyclerView.ViewHolder {
private final int viewType;
public BaseViewHolder(@NonNull View itemView, int viewType) {
super(itemView);
this.viewType = viewType;
}
public int getLocalViewType() {
return viewType;
}
}
static final class ConversationViewHolder extends BaseViewHolder {
static final class ConversationViewHolder extends RecyclerView.ViewHolder {
private final BindableConversationListItem conversationListItem;
ConversationViewHolder(@NonNull View itemView, int viewType) {
super(itemView, viewType);
ConversationViewHolder(@NonNull View itemView) {
super(itemView);
conversationListItem = (BindableConversationListItem) itemView;
}
@ -281,9 +250,18 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, Conversatio
}
}
private static class PlaceholderViewHolder extends BaseViewHolder {
private static class PlaceholderViewHolder extends RecyclerView.ViewHolder {
PlaceholderViewHolder(@NonNull View itemView) {
super(itemView, TYPE_PLACEHOLDER);
super(itemView);
}
}
private static class HeaderViewHolder extends RecyclerView.ViewHolder {
private TextView headerText;
public HeaderViewHolder(@NonNull View itemView) {
super(itemView);
headerText = (TextView) itemView;
}
}

View File

@ -3,12 +3,16 @@ package org.thoughtcrime.securesms.conversationlist;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MergeCursor;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.paging.DataSource;
import androidx.paging.PositionalDataSource;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
@ -57,10 +61,9 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
context.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, contentObserver);
}
private static ConversationListDataSource create(@NonNull Context context, @NonNull Invalidator invalidator, boolean isPinned, boolean isArchived) {
if (isPinned) return new PinnedConversationListDataSource(context, invalidator);
else if (!isArchived) return new UnarchivedConversationListDataSource(context, invalidator);
else return new ArchivedConversationListDataSource(context, invalidator);
private static ConversationListDataSource create(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) {
if (!isArchived) return new UnarchivedConversationListDataSource(context, invalidator);
else return new ArchivedConversationListDataSource(context, invalidator);
}
@Override
@ -72,7 +75,7 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
int effectiveCount = params.requestedStartPosition;
List<Recipient> recipients = new LinkedList<>();
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(getCursor(params.requestedStartPosition, params.requestedLoadSize))) {
try (ConversationReader reader = new ConversationReader(getCursor(params.requestedStartPosition, params.requestedLoadSize))) {
ThreadRecord record;
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
conversations.add(new Conversation(record));
@ -99,7 +102,7 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
List<Conversation> conversations = new ArrayList<>(params.loadSize);
List<Recipient> recipients = new LinkedList<>();
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(getCursor(params.startPosition, params.loadSize))) {
try (ConversationReader reader = new ConversationReader(getCursor(params.startPosition, params.loadSize))) {
ThreadRecord record;
while ((record = reader.getNext()) != null && !isInvalid()) {
conversations.add(new Conversation(record));
@ -134,7 +137,13 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
}
}
private static class UnarchivedConversationListDataSource extends ConversationListDataSource {
@VisibleForTesting
static class UnarchivedConversationListDataSource extends ConversationListDataSource {
private int totalCount;
private int pinnedCount;
private int archivedCount;
private int unpinnedCount;
UnarchivedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
super(context, invalidator);
@ -142,29 +151,69 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
@Override
protected int getTotalCount() {
return threadDatabase.getUnpinnedConversationListCount();
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount();
pinnedCount = threadDatabase.getPinnedConversationListCount();
archivedCount = threadDatabase.getArchivedConversationListCount();
unpinnedCount = unarchivedCount - pinnedCount;
totalCount = unarchivedCount + (archivedCount != 0 ? 1 : 0) + (pinnedCount != 0 ? (unpinnedCount != 0 ? 2 : 1) : 0);
return totalCount;
}
@Override
protected Cursor getCursor(long offset, long limit) {
return threadDatabase.getUnpinnedConversationList(offset, limit);
}
}
List<Cursor> cursors = new ArrayList<>(5);
private static class PinnedConversationListDataSource extends ConversationListDataSource {
if (offset == 0 && hasPinnedHeader()) {
MatrixCursor pinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
pinnedHeaderCursor.addRow(ConversationReader.PINNED_HEADER);
cursors.add(pinnedHeaderCursor);
limit--;
}
protected PinnedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
super(context, invalidator);
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(true, offset, limit);
cursors.add(pinnedCursor);
limit -= pinnedCursor.getCount();
if (offset == 0 && hasUnpinnedHeader()) {
MatrixCursor unpinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
unpinnedHeaderCursor.addRow(ConversationReader.UNPINNED_HEADER);
cursors.add(unpinnedHeaderCursor);
limit--;
}
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(false, unpinnedOffset, limit);
cursors.add(unpinnedCursor);
if (offset + limit >= totalCount && hasArchivedFooter()) {
MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS);
archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount));
cursors.add(archivedFooterCursor);
}
return new MergeCursor(cursors.toArray(new Cursor[]{}));
}
@Override
protected int getTotalCount() {
return threadDatabase.getPinnedConversationListCount();
@VisibleForTesting
int getHeaderOffset() {
return (hasPinnedHeader() ? 1 : 0) + (hasUnpinnedHeader() ? 1 : 0);
}
@Override
protected Cursor getCursor(long offset, long limit) {
return threadDatabase.getPinnedConversationList(offset, limit);
@VisibleForTesting
boolean hasPinnedHeader() {
return pinnedCount != 0;
}
@VisibleForTesting
boolean hasUnpinnedHeader() {
return hasPinnedHeader() && unpinnedCount != 0;
}
@VisibleForTesting
boolean hasArchivedFooter() {
return archivedCount != 0;
}
}
@ -172,19 +221,17 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
private final Context context;
private final Invalidator invalidator;
private final boolean isPinned;
private final boolean isArchived;
public Factory(@NonNull Context context, @NonNull Invalidator invalidator, boolean isPinned, boolean isArchived) {
public Factory(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) {
this.context = context;
this.invalidator = invalidator;
this.isPinned = isPinned;
this.isArchived = isArchived;
}
@Override
public @NonNull DataSource<Integer, Conversation> create() {
return ConversationListDataSource.create(context, invalidator, isPinned, isArchived);
return ConversationListDataSource.create(context, invalidator, isArchived);
}
}
}

View File

@ -168,7 +168,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private View toolbarShadow;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private CompositeConversationListAdapter defaultAdapter;
private ConversationListAdapter defaultAdapter;
private ConversationListSearchAdapter searchAdapter;
private StickyHeaderDecoration searchAdapterDecoration;
private ViewGroup megaphoneContainer;
@ -213,7 +213,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
reminderView.setOnDismissListener(this::updateReminders);
list.setHasFixedSize(true);
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
list.setItemAnimator(new DeleteItemAnimator());
list.addOnScrollListener(new ScrollListener());
@ -458,7 +457,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void initializeListAdapters() {
defaultAdapter = new CompositeConversationListAdapter(list, GlideApp.with(this), this);
defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this);
searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault());
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false);
@ -503,8 +502,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged);
viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged);
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitUnpinnedList);
viewModel.getPinnedConversations().observe(getViewLifecycleOwner(), this::onSubmitPinnedList);
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitList);
viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState);
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
@ -749,7 +747,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
.map(conversation -> conversation.getThreadRecord().getThreadId())
.toList());
if (toPin.size() + defaultAdapter.getPinnedItemCount() > MAXIMUM_PINNED_CONVERSATIONS) {
if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) {
Snackbar.make(fab,
getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS),
Snackbar.LENGTH_LONG)
@ -789,13 +787,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
}
private void onSubmitPinnedList(@NonNull PagedList<Conversation> pinnedConversations) {
defaultAdapter.submitPinnedList(pinnedConversations);
}
private void onSubmitUnpinnedList(@NonNull ConversationListViewModel.ConversationList conversationList) {
defaultAdapter.submitUnpinnedList(conversationList.getConversations());
defaultAdapter.updateArchived(conversationList.getArchivedCount());
private void onSubmitList(@NonNull ConversationListViewModel.ConversationList conversationList) {
defaultAdapter.submitList(conversationList.getConversations());
onPostSubmitList();
}
@ -926,7 +919,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void setCorrectMenuVisibility(@NonNull Menu menu) {
boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead());
boolean hasUnpinned = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isPinned());
boolean canPin = defaultAdapter.getPinnedItemCount() < MAXIMUM_PINNED_CONVERSATIONS;
boolean canPin = viewModel.getPinnedCount() < MAXIMUM_PINNED_CONVERSATIONS;
if (hasUnread) {
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);

View File

@ -31,6 +31,8 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.util.paging.Invalidator;
import java.util.Objects;
class ConversationListViewModel extends ViewModel {
private static final String TAG = Log.tag(ConversationListViewModel.class);
@ -38,7 +40,6 @@ class ConversationListViewModel extends ViewModel {
private final Application application;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final LiveData<PagedList<Conversation>> pinnedList;
private final LiveData<ConversationList> conversationList;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
@ -65,7 +66,7 @@ class ConversationListViewModel extends ViewModel {
}
};
DataSource.Factory<Integer, Conversation> factory = new ConversationListDataSource.Factory(application, invalidator, false, isArchived);
DataSource.Factory<Integer, Conversation> factory = new ConversationListDataSource.Factory(application, invalidator, isArchived);
PagedList.Config config = new PagedList.Config.Builder()
.setPageSize(15)
.setInitialLoadSizeHint(30)
@ -87,36 +88,21 @@ class ConversationListViewModel extends ViewModel {
MutableLiveData<ConversationList> updated = new MutableLiveData<>();
if (isArchived) {
updated.postValue(new ConversationList(conversation, 0));
updated.postValue(new ConversationList(conversation, 0, 0));
} else {
SignalExecutors.BOUNDED.execute(() -> {
int archiveCount = DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount();
updated.postValue(new ConversationList(conversation, archiveCount));
int pinnedCount = DatabaseFactory.getThreadDatabase(application).getPinnedConversationListCount();
updated.postValue(new ConversationList(conversation, archiveCount, pinnedCount));
});
}
return updated;
});
if (!isArchived) {
DataSource.Factory<Integer, Conversation> pinnedFactory = new ConversationListDataSource.Factory(application, invalidator, true, false);
this.pinnedList = new LivePagedListBuilder<>(pinnedFactory, config).setFetchExecutor(ConversationListDataSource.EXECUTOR)
.setInitialLoadKey(0)
.build();
} else {
this.pinnedList = new MutableLiveData<>();
}
}
public LiveData<Boolean> hasNoConversations() {
return LiveDataUtil.combineLatest(getPinnedConversations(),
getConversationList(),
(pinned, unpinned) -> pinned.isEmpty() && unpinned.isEmpty());
}
@NonNull LiveData<PagedList<Conversation>> getPinnedConversations() {
return pinnedList;
return Transformations.map(getConversationList(), ConversationList::isEmpty);
}
@NonNull LiveData<SearchResult> getSearchResult() {
@ -131,6 +117,10 @@ class ConversationListViewModel extends ViewModel {
return conversationList;
}
public int getPinnedCount() {
return Objects.requireNonNull(getConversationList().getValue()).pinnedCount;
}
void onVisible() {
megaphoneRepository.getNextMegaphone(megaphone::postValue);
}
@ -189,10 +179,12 @@ class ConversationListViewModel extends ViewModel {
final static class ConversationList {
private final PagedList<Conversation> conversations;
private final int archivedCount;
private final int pinnedCount;
ConversationList(PagedList<Conversation> conversations, int archivedCount) {
ConversationList(PagedList<Conversation> conversations, int archivedCount, int pinnedCount) {
this.conversations = conversations;
this.archivedCount = archivedCount;
this.pinnedCount = pinnedCount;
}
PagedList<Conversation> getConversations() {
@ -203,6 +195,10 @@ class ConversationListViewModel extends ViewModel {
return archivedCount;
}
public int getPinnedCount() {
return pinnedCount;
}
boolean isEmpty() {
return conversations.isEmpty() && archivedCount == 0;
}

View File

@ -6,15 +6,25 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord;
public class Conversation {
private final ThreadRecord threadRecord;
private final Type type;
public Conversation(@NonNull ThreadRecord threadRecord) {
this.threadRecord = threadRecord;
if (this.threadRecord.getThreadId() < 0) {
type = Type.valueOf(this.threadRecord.getBody());
} else {
type = Type.THREAD;
}
}
public @NonNull ThreadRecord getThreadRecord() {
return threadRecord;
}
public @NonNull Type getType() {
return type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -27,4 +37,11 @@ public class Conversation {
public int hashCode() {
return threadRecord.hashCode();
}
public enum Type {
THREAD,
PINNED_HEADER,
UNPINNED_HEADER,
ARCHIVED_FOOTER
}
}

View File

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.conversationlist.model;
import android.database.Cursor;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.CursorUtil;
public class ConversationReader extends ThreadDatabase.StaticReader {
public static final String[] HEADER_COLUMN = {"header"};
public static final String[] ARCHIVED_COLUMNS = {"header", "count"};
public static final String[] PINNED_HEADER = {Conversation.Type.PINNED_HEADER.toString()};
public static final String[] UNPINNED_HEADER = {Conversation.Type.UNPINNED_HEADER.toString()};
private final Cursor cursor;
public ConversationReader(@NonNull Cursor cursor) {
super(cursor, ApplicationDependencies.getApplication());
this.cursor = cursor;
}
public static String[] createArchivedFooterRow(int archivedCount) {
return new String[]{Conversation.Type.ARCHIVED_FOOTER.toString(), String.valueOf(archivedCount)};
}
@Override
public ThreadRecord getCurrent() {
if (cursor.getColumnIndex(HEADER_COLUMN[0]) == -1) {
return super.getCurrent();
} else {
return buildThreadRecordForHeader();
}
}
private ThreadRecord buildThreadRecordForHeader() {
Conversation.Type type = Conversation.Type.valueOf(CursorUtil.requireString(cursor, HEADER_COLUMN[0]));
int count = 0;
if (type == Conversation.Type.ARCHIVED_FOOTER) {
count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]);
}
return new ThreadRecord.Builder(-(100 + type.ordinal()))
.setBody(type.toString())
.setDate(100)
.setRecipient(Recipient.UNKNOWN)
.setCount(count)
.build();
}
}

View File

@ -606,7 +606,7 @@ public final class GroupDatabase extends Database {
}
public @Nullable GroupRecord getCurrent() {
if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null) {
if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null || cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)) == 0) {
return null;
}

View File

@ -143,6 +143,8 @@ public class ThreadDatabase extends Database {
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
.toList();
private static final String ORDER_BY_DEFAULT = TABLE_NAME + "." + DATE + " DESC";
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
@ -542,7 +544,12 @@ public class ThreadDatabase extends Database {
String query = RECIPIENT_ID + " = ?";
for (Map.Entry<RecipientId, Boolean> entry : status.entrySet()) {
ContentValues values = new ContentValues(1);
ContentValues values = new ContentValues(1);
if (entry.getValue()) {
values.put(PINNED, "0");
}
values.put(ARCHIVED, entry.getValue() ? "1" : "0");
db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() });
}
@ -584,14 +591,6 @@ public class ThreadDatabase extends Database {
return positions;
}
public Cursor getPinnedConversationList(long offset, long limit) {
return getUnarchivedConversationList("1", offset, limit);
}
public Cursor getUnpinnedConversationList(long offset, long limit) {
return getUnarchivedConversationList("0", offset, limit);
}
public Cursor getArchivedConversationList(long offset, long limit) {
return getConversationList("1", offset, limit);
}
@ -600,10 +599,10 @@ public class ThreadDatabase extends Database {
return getConversationList(archived, 0, 0);
}
private Cursor getUnarchivedConversationList(@NonNull String pinned, long offset, long limit) {
public Cursor getUnarchivedConversationList(boolean pinned, long offset, long limit) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + PINNED + " = ?", offset, limit);
Cursor cursor = db.rawQuery(query, new String[]{pinned});
Cursor cursor = db.rawQuery(query, new String[]{pinned ? "1" : "0"});
setNotifyConversationListListeners(cursor);
@ -620,14 +619,6 @@ public class ThreadDatabase extends Database {
return cursor;
}
public int getPinnedConversationListCount() {
return getUnarchivedConversationListCount(true);
}
public int getUnpinnedConversationListCount() {
return getUnarchivedConversationListCount(false);
}
public int getArchivedConversationListCount() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] columns = new String[] { "COUNT(*)" };
@ -643,13 +634,26 @@ public class ThreadDatabase extends Database {
return 0;
}
private int getUnarchivedConversationListCount(boolean pinned) {
public int getPinnedConversationListCount() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] columns = new String[] { "COUNT(*)" };
String query = ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + PINNED + " = ?";
String[] args = new String[] { pinned ? "1" : "0" };
String query = ARCHIVED + " = 0 AND " + PINNED + " = 1 AND " + MESSAGE_COUNT + " != 0";
try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null)) {
try (Cursor cursor = db.query(TABLE_NAME, columns, query, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return 0;
}
public int getUnarchivedConversationListCount() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] columns = new String[] { "COUNT(*)" };
String query = ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0";
try (Cursor cursor = db.query(TABLE_NAME, columns, query, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
@ -686,6 +690,7 @@ public class ThreadDatabase extends Database {
public void archiveConversation(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1);
contentValues.put(PINNED, 0);
contentValues.put(ARCHIVED, 1);
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
@ -1110,12 +1115,20 @@ public class ThreadDatabase extends Database {
public static final int INBOX_ZERO = 4;
}
public class Reader implements Closeable {
private final Cursor cursor;
public class Reader extends StaticReader {
public Reader(Cursor cursor) {
this.cursor = cursor;
super(cursor, context);
}
}
public static class StaticReader implements Closeable {
private final Cursor cursor;
private final Context context;
public StaticReader(Cursor cursor, Context context) {
this.cursor = cursor;
this.context = context;
}
public ThreadRecord getNext() {

View File

@ -0,0 +1,274 @@
package org.thoughtcrime.securesms.conversationlist;
import android.app.Application;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.rule.PowerMockRule;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.paging.Invalidator;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.verify;
import static org.powermock.api.mockito.PowerMockito.mock;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import static org.powermock.api.mockito.PowerMockito.when;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class)
@PowerMockIgnore({ "org.powermock.*", "org.mockito.*", "org.robolectric.*", "android.*", "androidx.*" })
@PrepareForTest({ApplicationDependencies.class, DatabaseFactory.class, ThreadDatabase.class})
public class UnarchivedConversationListDataSourceTest {
@Rule
public PowerMockRule rule = new PowerMockRule();
private ConversationListDataSource.UnarchivedConversationListDataSource testSubject;
private ThreadDatabase threadDatabase;
@Before
public void setUp() {
mockStatic(ApplicationDependencies.class);
mockStatic(DatabaseFactory.class);
final Context context = mock(Context.class);
final ContentResolver contentResolver = mock(ContentResolver.class);
threadDatabase = mock(ThreadDatabase.class);
when(DatabaseFactory.getThreadDatabase(any())).thenReturn(threadDatabase);
when(context.getContentResolver()).thenReturn(contentResolver);
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(context, mock(Invalidator.class));
}
@Test
public void givenNoConversations_whenIGetTotalCount_thenIExpectZero() {
// WHEN
int result = testSubject.getTotalCount();
// THEN
assertEquals(0, result);
assertEquals(0, testSubject.getHeaderOffset());
assertFalse(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasArchivedFooter());
}
@Test
public void givenArchivedConversations_whenIGetTotalCount_thenIExpectOne() {
// GIVEN
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
// WHEN
int result = testSubject.getTotalCount();
// THEN
assertEquals(1, result);
assertEquals(0, testSubject.getHeaderOffset());
assertFalse(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader());
assertTrue(testSubject.hasArchivedFooter());
}
@Test
public void givenSinglePinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectThree() {
// GIVEN
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
// WHEN
int result = testSubject.getTotalCount();
// THEN
assertEquals(3, result);
assertEquals(1, testSubject.getHeaderOffset());
assertTrue(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader());
assertTrue(testSubject.hasArchivedFooter());
}
@Test
public void givenSingleUnpinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() {
// GIVEN
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
// WHEN
int result = testSubject.getTotalCount();
// THEN
assertEquals(2, result);
assertEquals(0, testSubject.getHeaderOffset());
assertFalse(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader());
assertTrue(testSubject.hasArchivedFooter());
}
@Test
public void givenSinglePinnedAndSingleUnpinned_whenIGetTotalCount_thenIExpectFour() {
// GIVEN
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(2);
// WHEN
int result = testSubject.getTotalCount();
// THEN
assertEquals(4, result);
assertEquals(2, testSubject.getHeaderOffset());
assertTrue(testSubject.hasPinnedHeader());
assertTrue(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasArchivedFooter());
}
@Test
public void givenNoConversations_whenIGetCursor_thenIExpectAnEmptyCursor() {
// GIVEN
setupThreadDatabaseCursors(0, 0);
// WHEN
Cursor cursor = testSubject.getCursor(0, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 100);
assertEquals(0, cursor.getCount());
}
@Test
public void givenArchivedConversations_whenIGetCursor_thenIExpectOne() {
// GIVEN
setupThreadDatabaseCursors(0, 0);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(0, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 100);
assertEquals(1, cursor.getCount());
}
@Test
public void givenSinglePinnedAndArchivedConversations_whenIGetCursor_thenIExpectThree() {
// GIVEN
setupThreadDatabaseCursors(1, 0);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(0, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 99);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 98);
assertEquals(3, cursor.getCount());
}
@Test
public void givenSingleUnpinnedAndArchivedConversations_whenIGetCursor_thenIExpectTwo() {
// GIVEN
setupThreadDatabaseCursors(0, 1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(0, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 100);
assertEquals(2, cursor.getCount());
}
@Test
public void givenSinglePinnedAndSingleUnpinned_whenIGetCursor_thenIExpectFour() {
// GIVEN
setupThreadDatabaseCursors(1, 1);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(2);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(0, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 99);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 97);
assertEquals(4, cursor.getCount());
}
@Test
public void givenLoadingSecondPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() {
// GIVEN
setupThreadDatabaseCursors(0, 100);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(4);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(104);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(50, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 50, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 44, 100);
assertEquals(100, cursor.getCount());
}
@Test
public void givenHasArchivedAndLoadingLastPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() {
// GIVEN
setupThreadDatabaseCursors(0, 99);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(4);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(103);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(50, 100);
// THEN
verify(threadDatabase).getUnarchivedConversationList(true, 50, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 44, 100);
assertEquals(100, cursor.getCount());
cursor.moveToLast();
assertEquals(0, cursor.getColumnIndex(ConversationReader.HEADER_COLUMN[0]));
}
private void setupThreadDatabaseCursors(int pinned, int unpinned) {
Cursor pinnedCursor = mock(Cursor.class);
when(pinnedCursor.getCount()).thenReturn(pinned);
Cursor unpinnedCursor = mock(Cursor.class);
when(unpinnedCursor.getCount()).thenReturn(unpinned);
when(threadDatabase.getUnarchivedConversationList(eq(true), anyLong(), anyLong())).thenReturn(pinnedCursor);
when(threadDatabase.getUnarchivedConversationList(eq(false), anyLong(), anyLong())).thenReturn(unpinnedCursor);
}
}