diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/CompositeConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/CompositeConversationListAdapter.java deleted file mode 100644 index db66b49f72..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/CompositeConversationListAdapter.java +++ /dev/null @@ -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 pinnedConversations) { - pinnedAdapter.submitList(pinnedConversations); - } - - public void submitUnpinnedList(@NonNull PagedList unpinnedConversations) { - unpinnedAdapter.submitList(unpinnedConversations); - } - - public void setTypingThreads(@NonNull Set threads) { - pinnedAdapter.setTypingThreads(threads); - unpinnedAdapter.setTypingThreads(threads); - } - - public @NonNull Set getBatchSelectionIds() { - HashSet 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 getBatchSelection() { - Set 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(); - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java index d95b71c14b..d3dbf34b76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java @@ -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 { +class ConversationListAdapter extends PagedListAdapter { 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 batchSet = Collections.synchronizedMap(new HashMap<>()); private boolean batchMode = false; private final Set 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 { - 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 { - 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 payloads) { + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List 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 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= 0) { batchSet.put(conversation.getThreadRecord().getThreadId(), conversation); } } @@ -239,26 +222,12 @@ class ConversationListAdapter extends PagedListAdapter 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 conversations = new ArrayList<>(params.loadSize); List 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 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 create() { - return ConversationListDataSource.create(context, invalidator, isPinned, isArchived); + return ConversationListDataSource.create(context, invalidator, isArchived); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index afd89fa417..9df7f0356a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index 83d97a17fe..987e8fb7fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -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; private final MutableLiveData searchResult; - private final LiveData> pinnedList; private final LiveData conversationList; private final SearchRepository searchRepository; private final MegaphoneRepository megaphoneRepository; @@ -65,7 +66,7 @@ class ConversationListViewModel extends ViewModel { } }; - DataSource.Factory factory = new ConversationListDataSource.Factory(application, invalidator, false, isArchived); + DataSource.Factory 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 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 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 hasNoConversations() { - return LiveDataUtil.combineLatest(getPinnedConversations(), - getConversationList(), - (pinned, unpinned) -> pinned.isEmpty() && unpinned.isEmpty()); - } - - @NonNull LiveData> getPinnedConversations() { - return pinnedList; + return Transformations.map(getConversationList(), ConversationList::isEmpty); } @NonNull LiveData 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 conversations; private final int archivedCount; + private final int pinnedCount; - ConversationList(PagedList conversations, int archivedCount) { + ConversationList(PagedList conversations, int archivedCount, int pinnedCount) { this.conversations = conversations; this.archivedCount = archivedCount; + this.pinnedCount = pinnedCount; } PagedList getConversations() { @@ -203,6 +195,10 @@ class ConversationListViewModel extends ViewModel { return archivedCount; } + public int getPinnedCount() { + return pinnedCount; + } + boolean isEmpty() { return conversations.isEmpty() && archivedCount == 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java index c93b268cae..0183b66df1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java @@ -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 + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java new file mode 100644 index 0000000000..d1eebac4b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 0aa9f70802..85ed4a244a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 4f0931cbb0..a53a1befb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -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 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() { diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java new file mode 100644 index 0000000000..ad32c1ea9b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java @@ -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); + } +} \ No newline at end of file