Add chat filter support behind a flag.

This commit is contained in:
Alex Hart 2022-11-08 15:09:21 -04:00
parent 3e2ecdaaa9
commit bba1315906
26 changed files with 681 additions and 237 deletions

View file

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.appbar.AppBarLayout
import org.thoughtcrime.securesms.util.FeatureFlags
class ConversationFilterBehavior(context: Context, attributeSet: AttributeSet) : AppBarLayout.Behavior(context, attributeSet) {
override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View, target: View, nestedScrollAxes: Int, type: Int): Boolean {
if (type == ViewCompat.TYPE_NON_TOUCH || !FeatureFlags.chatFilters()) {
return false
} else {
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
}
}
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: AppBarLayout, target: View, type: Int) {
super.onStopNestedScroll(coordinatorLayout, child, target, type)
child.setExpanded(false, true)
}
}

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.conversationlist
/**
* Small state machine that describes moving and triggering actions
* based off pulling down the conversation filter.
*/
enum class ConversationFilterLatch {
SET,
RESET;
}

View file

@ -30,10 +30,13 @@ import java.util.Set;
class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.ViewHolder> { class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.ViewHolder> {
private static final int TYPE_THREAD = 1; private static final int TYPE_THREAD = 1;
private static final int TYPE_ACTION = 2; private static final int TYPE_ACTION = 2;
private static final int TYPE_PLACEHOLDER = 3; private static final int TYPE_PLACEHOLDER = 3;
private static final int TYPE_HEADER = 4; private static final int TYPE_HEADER = 4;
private static final int TYPE_EMPTY = 5;
private static final int TYPE_CLEAR_FILTER_FOOTER = 6;
private static final int TYPE_CLEAR_FILTER_EMPTY = 7;
private enum Payload { private enum Payload {
TYPING_INDICATOR, TYPING_INDICATOR,
@ -43,6 +46,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
private final LifecycleOwner lifecycleOwner; private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests; private final GlideRequests glideRequests;
private final OnConversationClickListener onConversationClickListener; private final OnConversationClickListener onConversationClickListener;
private final OnClearFilterClickListener onClearFilterClicked;
private ConversationSet selectedConversations = new ConversationSet(); private ConversationSet selectedConversations = new ConversationSet();
private final Set<Long> typingSet = new HashSet<>(); private final Set<Long> typingSet = new HashSet<>();
@ -50,13 +54,15 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner, protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests, @NonNull GlideRequests glideRequests,
@NonNull OnConversationClickListener onConversationClickListener) @NonNull OnConversationClickListener onConversationClickListener,
@NonNull OnClearFilterClickListener onClearFilterClicked)
{ {
super(new ConversationDiffCallback()); super(new ConversationDiffCallback());
this.lifecycleOwner = lifecycleOwner; this.lifecycleOwner = lifecycleOwner;
this.glideRequests = glideRequests; this.glideRequests = glideRequests;
this.onConversationClickListener = onConversationClickListener; this.onConversationClickListener = onConversationClickListener;
this.onClearFilterClicked = onClearFilterClicked;
} }
@Override @Override
@ -101,6 +107,15 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
} else if (viewType == TYPE_HEADER) { } else if (viewType == TYPE_HEADER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.dsl_section_header, parent, false); View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.dsl_section_header, parent, false);
return new HeaderViewHolder(v); return new HeaderViewHolder(v);
} else if (viewType == TYPE_EMPTY) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_empty_state, parent, false);
return new HeaderViewHolder(v);
} else if (viewType == TYPE_CLEAR_FILTER_FOOTER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else if (viewType == TYPE_CLEAR_FILTER_EMPTY) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter_empty, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else { } else {
throw new IllegalStateException("Unknown type! " + viewType); throw new IllegalStateException("Unknown type! " + viewType);
} }
@ -197,8 +212,14 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
return TYPE_HEADER; return TYPE_HEADER;
case ARCHIVED_FOOTER: case ARCHIVED_FOOTER:
return TYPE_ACTION; return TYPE_ACTION;
case CONVERSATION_FILTER_FOOTER:
return TYPE_CLEAR_FILTER_FOOTER;
case CONVERSATION_FILTER_EMPTY:
return TYPE_CLEAR_FILTER_EMPTY;
case THREAD: case THREAD:
return TYPE_THREAD; return TYPE_THREAD;
case EMPTY:
return TYPE_EMPTY;
default: default:
throw new IllegalArgumentException(); throw new IllegalArgumentException();
} }
@ -247,9 +268,22 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
} }
} }
static class ClearFilterViewHolder extends RecyclerView.ViewHolder {
ClearFilterViewHolder(@NonNull View itemView, OnClearFilterClickListener listener) {
super(itemView);
itemView.findViewById(R.id.clear_filter).setOnClickListener(v -> {
listener.onClearFilterClick();
});
}
}
interface OnConversationClickListener { interface OnConversationClickListener {
void onConversationClick(@NonNull Conversation conversation); void onConversationClick(@NonNull Conversation conversation);
boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view); boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view);
void onShowArchiveClick(); void onShowArchiveClick();
} }
interface OnClearFilterClickListener {
void onClearFilterClick();
}
} }

View file

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversationlist; package org.thoughtcrime.securesms.conversationlist;
import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.database.MatrixCursor; import android.database.MatrixCursor;
import android.database.MergeCursor; import android.database.MergeCursor;
@ -9,9 +8,11 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.signal.paging.PagedDataSource; import org.signal.paging.PagedDataSource;
import org.thoughtcrime.securesms.conversationlist.model.Conversation; import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader; import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
@ -22,9 +23,9 @@ import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.util.Stopwatch;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -35,15 +36,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private static final String TAG = Log.tag(ConversationListDataSource.class); private static final String TAG = Log.tag(ConversationListDataSource.class);
protected final ThreadDatabase threadDatabase; protected final ThreadDatabase threadDatabase;
protected final ConversationFilter conversationFilter;
protected ConversationListDataSource(@NonNull Context context) { protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
this.threadDatabase = SignalDatabase.threads(); this.threadDatabase = SignalDatabase.threads();
this.conversationFilter = conversationFilter;
} }
public static ConversationListDataSource create(@NonNull Context context, boolean isArchived) { public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived) {
if (!isArchived) return new UnarchivedConversationListDataSource(context); if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter);
else return new ArchivedConversationListDataSource(context); else return new ArchivedConversationListDataSource(conversationFilter);
} }
@Override @Override
@ -51,13 +54,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
int count = getTotalCount(); int count = getTotalCount();
Log.d(TAG, "[size(), " + getClass().getSimpleName() + "] " + (System.currentTimeMillis() - startTime) + " ms"); if (conversationFilter != ConversationFilter.OFF) {
return count; count += 1;
}
Log.d(TAG, "[size(), " + getClass().getSimpleName() + ", " + conversationFilter + "] " + (System.currentTimeMillis() - startTime) + " ms");
return Math.max(1, count);
} }
@Override @Override
public @NonNull List<Conversation> load(int start, int length, @NonNull CancellationSignal cancellationSignal) { public @NonNull List<Conversation> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName()); Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName() + ", " + conversationFilter);
List<Conversation> conversations = new ArrayList<>(length); List<Conversation> conversations = new ArrayList<>(length);
List<Recipient> recipients = new LinkedList<>(); List<Recipient> recipients = new LinkedList<>();
@ -89,7 +96,15 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
stopwatch.stop(TAG); stopwatch.stop(TAG);
return conversations; if (conversations.isEmpty() && start == 0 && length == 1) {
if (conversationFilter == ConversationFilter.OFF) {
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.EMPTY, 0)));
} else {
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.CONVERSATION_FILTER_EMPTY, 0)));
}
} else {
return conversations;
}
} }
@Override @Override
@ -107,18 +122,31 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private static class ArchivedConversationListDataSource extends ConversationListDataSource { private static class ArchivedConversationListDataSource extends ConversationListDataSource {
ArchivedConversationListDataSource(@NonNull Context context) { private int totalCount;
super(context);
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
super(conversationFilter);
} }
@Override @Override
protected int getTotalCount() { protected int getTotalCount() {
return threadDatabase.getArchivedConversationListCount(); totalCount = threadDatabase.getArchivedConversationListCount(conversationFilter);
return totalCount;
} }
@Override @Override
protected Cursor getCursor(long offset, long limit) { protected Cursor getCursor(long offset, long limit) {
return threadDatabase.getArchivedConversationList(offset, limit); List<Cursor> cursors = new ArrayList<>(2);
Cursor cursor = threadDatabase.getArchivedConversationList(conversationFilter, offset, limit);
cursors.add(cursor);
if (offset + limit >= totalCount && totalCount > 0 && conversationFilter != ConversationFilter.OFF) {
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
cursors.add(conversationFilterFooter);
}
return new MergeCursor(cursors.toArray(new Cursor[]{}));
} }
} }
@ -130,16 +158,16 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private int archivedCount; private int archivedCount;
private int unpinnedCount; private int unpinnedCount;
UnarchivedConversationListDataSource(@NonNull Context context) { UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
super(context); super(conversationFilter);
} }
@Override @Override
protected int getTotalCount() { protected int getTotalCount() {
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount(); int unarchivedCount = threadDatabase.getUnarchivedConversationListCount(conversationFilter);
pinnedCount = threadDatabase.getPinnedConversationListCount(); pinnedCount = threadDatabase.getPinnedConversationListCount(conversationFilter);
archivedCount = threadDatabase.getArchivedConversationListCount(); archivedCount = threadDatabase.getArchivedConversationListCount(conversationFilter);
unpinnedCount = unarchivedCount - pinnedCount; unpinnedCount = unarchivedCount - pinnedCount;
totalCount = unarchivedCount; totalCount = unarchivedCount;
@ -170,7 +198,7 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
limit--; limit--;
} }
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(true, offset, limit); Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(conversationFilter, true, offset, limit);
cursors.add(pinnedCursor); cursors.add(pinnedCursor);
limit -= pinnedCursor.getCount(); limit -= pinnedCursor.getCount();
@ -182,15 +210,23 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
} }
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset()); long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(false, unpinnedOffset, limit); Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit);
cursors.add(unpinnedCursor); cursors.add(unpinnedCursor);
if (offset + originalLimit >= totalCount && hasArchivedFooter()) { boolean shouldInsertConversationFilterFooter = offset + originalLimit >= totalCount && hasConversationFilterFooter();
boolean shouldInsertArchivedFooter = offset + originalLimit >= totalCount - (shouldInsertConversationFilterFooter ? 1 : 0) && hasArchivedFooter();
if (shouldInsertArchivedFooter) {
MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS); MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS);
archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount)); archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount));
cursors.add(archivedFooterCursor); cursors.add(archivedFooterCursor);
} }
if (shouldInsertConversationFilterFooter) {
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
cursors.add(conversationFilterFooter);
}
return new MergeCursor(cursors.toArray(new Cursor[]{})); return new MergeCursor(cursors.toArray(new Cursor[]{}));
} }
@ -213,5 +249,9 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
boolean hasArchivedFooter() { boolean hasArchivedFooter() {
return archivedCount != 0; return archivedCount != 0;
} }
boolean hasConversationFilterFooter() {
return totalCount > 1 && conversationFilter != ConversationFilter.OFF;
}
} }
} }

View file

@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
/**
* Encapsulates the push / pull latch for enabling and disabling
* filters into a convenient view.
*/
class ConversationListFilterPullView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val colorPull = ContextCompat.getColor(context, R.color.signal_colorSurface1)
private val colorRelease = ContextCompat.getColor(context, R.color.signal_colorSecondaryContainer)
private var state: State = State.PULL
init {
inflate(context, R.layout.conversation_list_filter_pull_view, this)
setBackgroundColor(colorPull)
}
private val binding = ConversationListFilterPullViewBinding.bind(this)
fun setToPull() {
if (state == State.PULL) {
return
}
state = State.PULL
setBackgroundColor(colorPull)
binding.arrow.setImageResource(R.drawable.ic_arrow_down)
binding.text.setText(R.string.ConversationListFilterPullView__pull_down_to_filter)
}
fun setToRelease() {
if (state == State.RELEASE) {
return
}
if (Settings.System.getInt(context.contentResolver, Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) {
performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP)
}
state = State.RELEASE
setBackgroundColor(colorRelease)
binding.arrow.setImageResource(R.drawable.ic_arrow_up_16)
binding.text.setText(R.string.ConversationListFilterPullView__release_to_filter)
}
enum class State {
RELEASE,
PULL
}
}

View file

@ -32,6 +32,8 @@ import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.provider.Settings;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
@ -40,7 +42,6 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodManager;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedCallback;
@ -68,6 +69,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.airbnb.lottie.SimpleColorFilter; import com.airbnb.lottie.SimpleColorFilter;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.google.android.material.animation.ArgbEvaluatorCompat; import com.google.android.material.animation.ArgbEvaluatorCompat;
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
@ -136,8 +138,6 @@ import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile; import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -191,7 +191,7 @@ import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback, public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener, ConversationListAdapter.OnConversationClickListener,
ConversationListSearchAdapter.EventListener, ConversationListSearchAdapter.EventListener,
MegaphoneActionController MegaphoneActionController, ConversationListAdapter.OnClearFilterClickListener
{ {
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562; public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
public static final short SMS_ROLE_REQUEST_CODE = 32563; public static final short SMS_ROLE_REQUEST_CODE = 32563;
@ -207,8 +207,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private RecyclerView list; private RecyclerView list;
private Stub<ReminderView> reminderView; private Stub<ReminderView> reminderView;
private Stub<UnreadPaymentsView> paymentNotificationView; private Stub<UnreadPaymentsView> paymentNotificationView;
private Stub<ViewGroup> emptyState;
private TextView searchEmptyState;
private PulsingFloatingActionButton fab; private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab; private PulsingFloatingActionButton cameraFab;
private ConversationListViewModel viewModel; private ConversationListViewModel viewModel;
@ -263,10 +261,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
coordinator = view.findViewById(R.id.coordinator); coordinator = view.findViewById(R.id.coordinator);
list = view.findViewById(R.id.list); list = view.findViewById(R.id.list);
searchEmptyState = view.findViewById(R.id.search_no_results);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar); bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
reminderView = new Stub<>(view.findViewById(R.id.reminder)); reminderView = new Stub<>(view.findViewById(R.id.reminder));
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container)); megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification)); paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player)); voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
@ -276,6 +272,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode
fab.setVisibility(View.VISIBLE); fab.setVisibility(View.VISIBLE);
cameraFab.setVisibility(View.VISIBLE); cameraFab.setVisibility(View.VISIBLE);
ConversationListFilterPullView pullView = view.findViewById(R.id.pull_view);
AppBarLayout appBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar);
appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
if (verticalOffset == 0) {
viewModel.setConversationFilterLatch(ConversationFilterLatch.SET);
pullView.setToRelease();
} else if (verticalOffset == -layout.getHeight()) {
viewModel.setConversationFilterLatch(ConversationFilterLatch.RESET);
pullView.setToPull();
}
});
fab.show(); fab.show();
cameraFab.show(); cameraFab.show();
@ -345,10 +354,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onDestroyView() { public void onDestroyView() {
coordinator = null; coordinator = null;
list = null; list = null;
searchEmptyState = null;
bottomActionBar = null; bottomActionBar = null;
reminderView = null; reminderView = null;
emptyState = null;
megaphoneContainer = null; megaphoneContainer = null;
paymentNotificationView = null; paymentNotificationView = null;
voiceNotePlayerViewStub = null; voiceNotePlayerViewStub = null;
@ -486,6 +493,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
handleInsights(); return true; handleInsights(); return true;
case R.id.menu_notification_profile: case R.id.menu_notification_profile:
handleNotificationProfile(); return true; handleNotificationProfile(); return true;
case R.id.menu_filter_unread_chats:
handleFilterUnreadChats(); return true;
} }
return false; return false;
@ -691,7 +700,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeListAdapters() { private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this); defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, this);
searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault()); searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault());
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0); searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0);
@ -727,7 +736,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
} }
if (adapter instanceof ConversationListAdapter) { if (adapter instanceof ConversationListAdapter) {
((ConversationListAdapter) adapter).setPagingController(viewModel.getPagingController()); viewModel.getPagingController()
.observe(getViewLifecycleOwner(),
controller -> ((ConversationListAdapter) adapter).setPagingController(controller));
} }
list.setAdapter(adapter); list.setAdapter(adapter);
@ -826,13 +837,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void onSearchResultChanged(@Nullable SearchResult result) { private void onSearchResultChanged(@Nullable SearchResult result) {
result = result != null ? result : SearchResult.EMPTY; result = result != null ? result : SearchResult.EMPTY;
searchAdapter.updateResults(result); searchAdapter.updateResults(result);
if (result.isEmpty() && activeAdapter == searchAdapter) {
searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery()));
searchEmptyState.setVisibility(View.VISIBLE);
} else {
searchEmptyState.setVisibility(View.GONE);
}
} }
private void onMegaphoneChanged(@Nullable Megaphone megaphone) { private void onMegaphoneChanged(@Nullable Megaphone megaphone) {
@ -966,6 +970,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
NotificationProfileSelectionFragment.show(getParentFragmentManager()); NotificationProfileSelectionFragment.show(getParentFragmentManager());
} }
private void handleFilterUnreadChats() {
viewModel.toggleUnreadChatsFilter();
}
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
private void handleArchive(@NonNull Collection<Long> ids, boolean showProgress) { private void handleArchive(@NonNull Collection<Long> ids, boolean showProgress) {
Set<Long> selectedConversations = new HashSet<>(ids); Set<Long> selectedConversations = new HashSet<>(ids);
@ -1173,21 +1181,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
void updateEmptyState(boolean isConversationEmpty) { void updateEmptyState(boolean isConversationEmpty) {
if (isConversationEmpty) { if (isConversationEmpty) {
Log.i(TAG, "Received an empty data set."); Log.i(TAG, "Received an empty data set.");
list.setVisibility(View.INVISIBLE);
emptyState.get().setVisibility(View.VISIBLE);
fab.startPulse(3 * 1000); fab.startPulse(3 * 1000);
cameraFab.startPulse(3 * 1000); cameraFab.startPulse(3 * 1000);
SignalStore.onboarding().setShowNewGroup(true); SignalStore.onboarding().setShowNewGroup(true);
SignalStore.onboarding().setShowInviteFriends(true); SignalStore.onboarding().setShowInviteFriends(true);
} else { } else {
list.setVisibility(View.VISIBLE);
fab.stopPulse(); fab.stopPulse();
cameraFab.stopPulse(); cameraFab.stopPulse();
if (emptyState.resolved()) {
emptyState.get().setVisibility(View.GONE);
}
} }
} }
@ -1456,6 +1457,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}.executeOnExecutor(SignalExecutors.BOUNDED, threadId); }.executeOnExecutor(SignalExecutors.BOUNDED, threadId);
} }
@Override
public void onClearFilterClick() {
viewModel.toggleUnreadChatsFilter();
}
private class PaymentNotificationListener implements UnreadPaymentsView.Listener { private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
private final UnreadPayments unreadPayments; private final UnreadPayments unreadPayments;

View file

@ -22,9 +22,12 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import java.util.Collections; import java.util.Collections;
import java.util.Locale; import java.util.Locale;
class ConversationListSearchAdapter extends RecyclerView.Adapter<ConversationListSearchAdapter.SearchResultViewHolder> class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationListSearchAdapter.HeaderViewHolder> implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationListSearchAdapter.HeaderViewHolder>
{ {
private static final int VIEW_TYPE_EMPTY = 0;
private static final int VIEW_TYPE_NON_EMPTY = 1;
private static final int TYPE_CONVERSATIONS = 1; private static final int TYPE_CONVERSATIONS = 1;
private static final int TYPE_CONTACTS = 2; private static final int TYPE_CONTACTS = 2;
private static final int TYPE_MESSAGES = 3; private static final int TYPE_MESSAGES = 3;
@ -49,47 +52,69 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<Conversation
} }
@Override @Override
public @NonNull SearchResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext()) if (viewType == VIEW_TYPE_EMPTY) {
.inflate(R.layout.conversation_list_item_view, parent, false)); return new EmptyViewHolder(LayoutInflater.from(parent.getContext())
} .inflate(R.layout.conversation_list_empty_search_state, parent, false));
} else {
@Override return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) { .inflate(R.layout.conversation_list_item_view, parent, false));
ThreadRecord conversationResult = getConversationResult(position);
if (conversationResult != null) {
holder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
Recipient contactResult = getContactResult(position);
if (contactResult != null) {
holder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
MessageResult messageResult = getMessageResult(position);
if (messageResult != null) {
holder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
} }
} }
@Override @Override
public void onViewRecycled(@NonNull SearchResultViewHolder holder) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
holder.recycle(); if (holder instanceof SearchResultViewHolder) {
SearchResultViewHolder viewHolder = (SearchResultViewHolder) holder;
ThreadRecord conversationResult = getConversationResult(position);
if (conversationResult != null) {
viewHolder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
Recipient contactResult = getContactResult(position);
if (contactResult != null) {
viewHolder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
MessageResult messageResult = getMessageResult(position);
if (messageResult != null) {
viewHolder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
}
} else if (holder instanceof EmptyViewHolder) {
EmptyViewHolder viewHolder = (EmptyViewHolder) holder;
viewHolder.bind(searchResult.getQuery());
}
}
@Override
public int getItemViewType(int position) {
if (searchResult.isEmpty()) {
return VIEW_TYPE_EMPTY;
} else {
return VIEW_TYPE_NON_EMPTY;
}
}
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
if (holder instanceof SearchResultViewHolder) {
((SearchResultViewHolder) holder).recycle();
}
} }
@Override @Override
public int getItemCount() { public int getItemCount() {
return searchResult.size(); return searchResult.isEmpty() ? 1 : searchResult.size();
} }
@Override @Override
public long getHeaderId(int position) { public long getHeaderId(int position) {
if (position < 0) { if (position < 0 || searchResult.isEmpty()) {
return StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID; return StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID;
} else if (getConversationResult(position) != null) { } else if (getConversationResult(position) != null) {
return TYPE_CONVERSATIONS; return TYPE_CONVERSATIONS;
@ -154,6 +179,20 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<Conversation
void onMessageClicked(@NonNull MessageResult message); void onMessageClicked(@NonNull MessageResult message);
} }
static class EmptyViewHolder extends RecyclerView.ViewHolder {
private final TextView textView;
public EmptyViewHolder(@NonNull View itemView) {
super(itemView);
textView = itemView.findViewById(R.id.search_no_results);
}
public void bind(@NonNull String query) {
textView.setText(textView.getContext().getString(R.string.SearchFragment_no_results, query));
}
}
static class SearchResultViewHolder extends RecyclerView.ViewHolder { static class SearchResultViewHolder extends RecyclerView.ViewHolder {
private final ConversationListItem root; private final ConversationListItem root;

View file

@ -1,12 +1,12 @@
package org.thoughtcrime.securesms.conversationlist; package org.thoughtcrime.securesms.conversationlist;
import android.app.Application;
import android.text.TextUtils; import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams; import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
@ -17,6 +17,7 @@ import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController; import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository; import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
import org.thoughtcrime.securesms.conversationlist.model.Conversation; import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet; import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments; import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData; import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
@ -40,6 +41,7 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -50,6 +52,7 @@ import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import kotlin.Pair;
class ConversationListViewModel extends ViewModel { class ConversationListViewModel extends ViewModel {
@ -57,30 +60,32 @@ class ConversationListViewModel extends ViewModel {
private static boolean coldStart = true; private static boolean coldStart = true;
private final MutableLiveData<Megaphone> megaphone; private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult; private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations; private final MutableLiveData<ConversationSet> selectedConversations;
private final Set<Conversation> internalSelection; private final MutableLiveData<ConversationFilter> conversationFilter;
private final ConversationListDataSource conversationListDataSource; private final LiveData<ConversationListDataSource> conversationListDataSource;
private final LivePagedData<Long, Conversation> pagedData; private final Set<Conversation> internalSelection;
private final LiveData<Boolean> hasNoConversations; private final LiveData<LivePagedData<Long, Conversation>> pagedData;
private final SearchRepository searchRepository; private final LiveData<Boolean> hasNoConversations;
private final MegaphoneRepository megaphoneRepository; private final SearchRepository searchRepository;
private final Debouncer messageSearchDebouncer; private final MegaphoneRepository megaphoneRepository;
private final Debouncer contactSearchDebouncer; private final Debouncer messageSearchDebouncer;
private final ThrottledDebouncer updateDebouncer; private final Debouncer contactSearchDebouncer;
private final DatabaseObserver.Observer observer; private final ThrottledDebouncer updateDebouncer;
private final Invalidator invalidator; private final DatabaseObserver.Observer observer;
private final CompositeDisposable disposables; private final Invalidator invalidator;
private final UnreadPaymentsLiveData unreadPaymentsLiveData; private final CompositeDisposable disposables;
private final UnreadPaymentsRepository unreadPaymentsRepository; private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final NotificationProfilesRepository notificationProfilesRepository; private final UnreadPaymentsRepository unreadPaymentsRepository;
private final NotificationProfilesRepository notificationProfilesRepository;
private String activeQuery; private String activeQuery;
private SearchResult activeSearchResult; private SearchResult activeSearchResult;
private int pinnedCount; private int pinnedCount;
private ConversationFilterLatch conversationFilterLatch;
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) { private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
this.megaphone = new MutableLiveData<>(); this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>(); this.searchResult = new MutableLiveData<>();
this.internalSelection = new HashSet<>(); this.internalSelection = new HashSet<>();
@ -95,29 +100,37 @@ class ConversationListViewModel extends ViewModel {
this.activeSearchResult = SearchResult.EMPTY; this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator(); this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable(); this.disposables = new CompositeDisposable();
this.conversationListDataSource = ConversationListDataSource.create(application, isArchived); this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
this.pagedData = PagedData.createForLiveData(conversationListDataSource, this.conversationFilterLatch = ConversationFilterLatch.RESET;
new PagingConfig.Builder() this.conversationListDataSource = Transformations.map(conversationFilter, filter -> ConversationListDataSource.create(filter, isArchived));
.setPageSize(15) this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
.setBufferPages(2) new PagingConfig.Builder()
.build()); .setPageSize(15)
.setBufferPages(2)
.build()));
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData(); this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> { this.observer = () -> {
updateDebouncer.publish(() -> { updateDebouncer.publish(() -> {
if (!TextUtils.isEmpty(activeQuery)) { if (!TextUtils.isEmpty(activeQuery)) {
onSearchQueryUpdated(activeQuery); onSearchQueryUpdated(activeQuery);
} }
pagedData.getController().onDataInvalidated();
LivePagedData<Long, Conversation> data = pagedData.getValue();
if (data == null) {
return;
}
data.getController().onDataInvalidated();
}); });
}; };
this.hasNoConversations = LiveDataUtil.mapAsync(pagedData.getData(), conversations -> { this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilter, getConversationList(), Pair::new), filterAndData -> {
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(); pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(ConversationFilter.OFF);
if (conversations.size() > 0) { if (filterAndData.getSecond().size() > 0) {
return false; return false;
} else { } else {
return SignalDatabase.threads().getArchivedConversationListCount() == 0; return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst()) == 0;
} }
}); });
@ -137,11 +150,11 @@ class ConversationListViewModel extends ViewModel {
} }
@NonNull LiveData<List<Conversation>> getConversationList() { @NonNull LiveData<List<Conversation>> getConversationList() {
return pagedData.getData(); return Transformations.switchMap(pagedData, LivePagedData::getData);
} }
@NonNull PagingController getPagingController() { @NonNull LiveData<PagingController<Long>> getPagingController() {
return pagedData.getController(); return Transformations.map(pagedData, LivePagedData::getController);
} }
@NonNull LiveData<List<NotificationProfile>> getNotificationProfiles() { @NonNull LiveData<List<NotificationProfile>> getNotificationProfiles() {
@ -199,6 +212,25 @@ class ConversationListViewModel extends ViewModel {
setSelection(newSelection); setSelection(newSelection);
} }
void setConversationFilterLatch(@NonNull ConversationFilterLatch latch) {
ConversationFilterLatch previous = conversationFilterLatch;
conversationFilterLatch = latch;
if (previous != latch && latch == ConversationFilterLatch.RESET) {
toggleUnreadChatsFilter();
}
}
public void toggleUnreadChatsFilter() {
ConversationFilter filter = Objects.requireNonNull(conversationFilter.getValue());
if (filter == ConversationFilter.UNREAD) {
Log.d(TAG, "Setting filter to OFF");
conversationFilter.setValue(ConversationFilter.OFF);
} else {
Log.d(TAG, "Setting filter to UNREAD");
conversationFilter.setValue(ConversationFilter.UNREAD);
}
}
private void setSelection(@NonNull Collection<Conversation> newSelection) { private void setSelection(@NonNull Collection<Conversation> newSelection) {
internalSelection.clear(); internalSelection.clear();
internalSelection.addAll(newSelection); internalSelection.addAll(newSelection);
@ -206,8 +238,13 @@ class ConversationListViewModel extends ViewModel {
} }
void onSelectAllClick() { void onSelectAllClick() {
ConversationListDataSource dataSource = conversationListDataSource.getValue();
if (dataSource == null) {
return;
}
disposables.add( disposables.add(
Single.fromCallable(() -> conversationListDataSource.load(0, conversationListDataSource.size(), disposables::isDisposed)) Single.fromCallable(() -> dataSource.load(0, dataSource.size(), disposables::isDisposed))
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(this::setSelection) .subscribe(this::setSelection)
@ -301,7 +338,7 @@ class ConversationListViewModel extends ViewModel {
@Override @Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) { public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions //noinspection ConstantConditions
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(noteToSelfTitle), isArchived)); return modelClass.cast(new ConversationListViewModel(new SearchRepository(noteToSelfTitle), isArchived));
} }
} }
} }

View file

@ -42,6 +42,9 @@ public class Conversation {
THREAD, THREAD,
PINNED_HEADER, PINNED_HEADER,
UNPINNED_HEADER, UNPINNED_HEADER,
ARCHIVED_FOOTER ARCHIVED_FOOTER,
CONVERSATION_FILTER_FOOTER,
CONVERSATION_FILTER_EMPTY,
EMPTY
} }
} }

View file

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.conversationlist.model
/**
* Describes what conversations should display in the
* conversation list.
*/
enum class ConversationFilter {
/**
* No filtering is applied to the conversation list
*/
OFF,
/**
* Only unread chats will be displayed in the conversation list
*/
UNREAD,
/**
* Only muted chats will be displayed in the conversation list
*/
MUTED,
/**
* Only group chats will be displayed in the conversation list
*/
GROUPS
}

View file

@ -12,10 +12,11 @@ import org.signal.core.util.CursorUtil;
public class ConversationReader extends ThreadDatabase.StaticReader { public class ConversationReader extends ThreadDatabase.StaticReader {
public static final String[] HEADER_COLUMN = {"header"}; public static final String[] HEADER_COLUMN = { "header" };
public static final String[] ARCHIVED_COLUMNS = {"header", "count"}; public static final String[] ARCHIVED_COLUMNS = { "header", "count" };
public static final String[] PINNED_HEADER = {Conversation.Type.PINNED_HEADER.toString()}; public static final String[] PINNED_HEADER = { Conversation.Type.PINNED_HEADER.toString() };
public static final String[] UNPINNED_HEADER = {Conversation.Type.UNPINNED_HEADER.toString()}; public static final String[] UNPINNED_HEADER = { Conversation.Type.UNPINNED_HEADER.toString() };
public static final String[] CONVERSATION_FILTER_FOOTER = { Conversation.Type.CONVERSATION_FILTER_FOOTER.toString() };
private final Cursor cursor; private final Cursor cursor;
@ -43,11 +44,16 @@ public class ConversationReader extends ThreadDatabase.StaticReader {
if (type == Conversation.Type.ARCHIVED_FOOTER) { if (type == Conversation.Type.ARCHIVED_FOOTER) {
count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]); count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]);
} }
return buildThreadRecordForType(type, count);
}
public static ThreadRecord buildThreadRecordForType(@NonNull Conversation.Type type, int count) {
return new ThreadRecord.Builder(-(100 + type.ordinal())) return new ThreadRecord.Builder(-(100 + type.ordinal()))
.setBody(type.toString()) .setBody(type.toString())
.setDate(100) .setDate(100)
.setRecipient(Recipient.UNKNOWN) .setRecipient(Recipient.UNKNOWN)
.setUnreadCount(count) .setUnreadCount(count)
.build(); .build();
} }
} }

View file

@ -24,6 +24,7 @@ import org.signal.core.util.update
import org.signal.core.util.withinTransaction import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts
@ -132,7 +133,8 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
val CREATE_INDEXS = arrayOf( val CREATE_INDEXS = arrayOf(
"CREATE INDEX IF NOT EXISTS thread_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);", "CREATE INDEX IF NOT EXISTS thread_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);",
"CREATE INDEX IF NOT EXISTS archived_count_index ON $TABLE_NAME ($ARCHIVED, $MEANINGFUL_MESSAGES);", "CREATE INDEX IF NOT EXISTS archived_count_index ON $TABLE_NAME ($ARCHIVED, $MEANINGFUL_MESSAGES);",
"CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);" "CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);",
"CREATE INDEX IF NOT EXISTS thread_read ON $TABLE_NAME ($READ);"
) )
private val THREAD_PROJECTION = arrayOf( private val THREAD_PROJECTION = arrayOf(
@ -754,7 +756,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
} }
fun getArchivedRecipients(): Set<RecipientId> { fun getArchivedRecipients(): Set<RecipientId> {
return getArchivedConversationList().readToList { cursor -> return getArchivedConversationList(ConversationFilter.OFF).readToList { cursor ->
RecipientId.from(cursor.requireLong(RECIPIENT_ID)) RecipientId.from(cursor.requireLong(RECIPIENT_ID))
}.toSet() }.toSet()
} }
@ -775,16 +777,18 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
return positions return positions
} }
fun getArchivedConversationList(offset: Long = 0, limit: Long = 0): Cursor { fun getArchivedConversationList(conversationFilter: ConversationFilter, offset: Long = 0, limit: Long = 0): Cursor {
val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0", offset, limit, preferPinned = false) val filterQuery = conversationFilter.toQuery()
val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0 $filterQuery", offset, limit, preferPinned = false)
return readableDatabase.rawQuery(query, arrayOf("1")) return readableDatabase.rawQuery(query, arrayOf("1"))
} }
fun getUnarchivedConversationList(pinned: Boolean, offset: Long, limit: Long): Cursor { fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor {
val filterQuery = conversationFilter.toQuery()
val where = if (pinned) { val where = if (pinned) {
"$ARCHIVED = 0 AND $PINNED != 0" "$ARCHIVED = 0 AND $PINNED != 0 $filterQuery"
} else { } else {
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0" "$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery"
} }
val query = if (pinned) { val query = if (pinned) {
@ -796,11 +800,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
return readableDatabase.rawQuery(query, null) return readableDatabase.rawQuery(query, null)
} }
fun getArchivedConversationListCount(): Int { fun getArchivedConversationListCount(conversationFilter: ConversationFilter): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase return readableDatabase
.select("COUNT(*)") .select("COUNT(*)")
.from(TABLE_NAME) .from(TABLE_NAME)
.where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0") .where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0 $filterQuery")
.run() .run()
.use { cursor -> .use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
@ -811,11 +816,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
} }
} }
fun getPinnedConversationListCount(): Int { fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase return readableDatabase
.select("COUNT(*)") .select("COUNT(*)")
.from(TABLE_NAME) .from(TABLE_NAME)
.where("$ARCHIVED = 0 AND $PINNED != 0") .where("$ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
.run() .run()
.use { cursor -> .use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
@ -826,11 +832,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
} }
} }
fun getUnarchivedConversationListCount(): Int { fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase return readableDatabase
.select("COUNT(*)") .select("COUNT(*)")
.from(TABLE_NAME) .from(TABLE_NAME)
.where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)") .where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
.run() .run()
.use { cursor -> .use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
@ -888,7 +895,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
.run() .run()
} }
var pinnedCount = getPinnedConversationListCount() var pinnedCount = getPinnedConversationListCount(ConversationFilter.OFF)
for (threadId in threadIds) { for (threadId in threadIds) {
pinnedCount++ pinnedCount++
@ -1587,6 +1594,15 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
return this.trimIndent().split("\n").joinToString(separator = " ") return this.trimIndent().split("\n").joinToString(separator = " ")
} }
private fun ConversationFilter.toQuery(): String {
return when (this) {
ConversationFilter.OFF -> ""
ConversationFilter.UNREAD -> " AND $READ != ${ReadStatus.READ.serialize()}"
ConversationFilter.MUTED -> error("This filter selection isn't supported yet.")
ConversationFilter.GROUPS -> error("This filter selection isn't supported yet.")
}
}
object DistributionTypes { object DistributionTypes {
const val DEFAULT = 2 const val DEFAULT = 2
const val BROADCAST = 1 const val BROADCAST = 1

View file

@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V160_SmsMmsExported
import org.thoughtcrime.securesms.database.helpers.migration.V161_StorySendMessageIdIndex import org.thoughtcrime.securesms.database.helpers.migration.V161_StorySendMessageIdIndex
import org.thoughtcrime.securesms.database.helpers.migration.V162_ThreadUnreadSelfMentionCountFixup import org.thoughtcrime.securesms.database.helpers.migration.V162_ThreadUnreadSelfMentionCountFixup
import org.thoughtcrime.securesms.database.helpers.migration.V163_RemoteMegaphoneSnoozeSupportMigration import org.thoughtcrime.securesms.database.helpers.migration.V163_RemoteMegaphoneSnoozeSupportMigration
import org.thoughtcrime.securesms.database.helpers.migration.V164_ThreadDatabaseReadIndexMigration
/** /**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -27,7 +28,7 @@ object SignalDatabaseMigrations {
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass) val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
const val DATABASE_VERSION = 163 const val DATABASE_VERSION = 164
@JvmStatic @JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -90,6 +91,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 163) { if (oldVersion < 163) {
V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion) V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion)
} }
if (oldVersion < 164) {
V164_ThreadDatabaseReadIndexMigration.migrate(context, db, oldVersion, newVersion)
}
} }
@JvmStatic @JvmStatic

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
object V164_ThreadDatabaseReadIndexMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("CREATE INDEX IF NOT EXISTS thread_read ON thread (read);")
}
}

View file

@ -9,6 +9,7 @@ import org.signal.core.util.Hex
import org.signal.core.util.ThreadUtil import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.MessageDatabase import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
@ -150,7 +151,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
} }
if (!values.hasMetConversationRequirement) { if (!values.hasMetConversationRequirement) {
if ((SignalDatabase.threads.getArchivedConversationListCount() + SignalDatabase.threads.getUnarchivedConversationListCount()) < 6) { if ((SignalDatabase.threads.getArchivedConversationListCount(ConversationFilter.OFF) + SignalDatabase.threads.getUnarchivedConversationListCount(ConversationFilter.OFF)) < 6) {
Log.i(TAG, "User does not have enough conversations to show release channel") Log.i(TAG, "User does not have enough conversations to show release channel")
values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY
return return

View file

@ -13,6 +13,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.MainActivity; import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.NewConversationActivity; import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Data;
@ -76,8 +77,8 @@ public class UserNotificationMigrationJob extends MigrationJob {
ThreadDatabase threadDatabase = SignalDatabase.threads(); ThreadDatabase threadDatabase = SignalDatabase.threads();
int threadCount = threadDatabase.getUnarchivedConversationListCount() + int threadCount = threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF) +
threadDatabase.getArchivedConversationListCount(); threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF);
if (threadCount >= 3) { if (threadCount >= 3) {
Log.w(TAG, "Already have 3 or more threads. Skipping."); Log.w(TAG, "Already have 3 or more threads. Skipping.");

View file

@ -108,6 +108,7 @@ public final class FeatureFlags {
public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions"; public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions";
private static final String CDS_HARD_LIMIT = "android.cds.hardLimit"; private static final String CDS_HARD_LIMIT = "android.cds.hardLimit";
private static final String PAYMENTS_IN_CHAT_MESSAGES = "android.payments.inChatMessages"; private static final String PAYMENTS_IN_CHAT_MESSAGES = "android.payments.inChatMessages";
private static final String CHAT_FILTERS = "android.chat.filters";
/** /**
* We will only store remote values for flags in this set. If you want a flag to be controllable * We will only store remote values for flags in this set. If you want a flag to be controllable
@ -168,7 +169,8 @@ public final class FeatureFlags {
PAYPAL_DISABLED_REGIONS, PAYPAL_DISABLED_REGIONS,
KEEP_MUTED_CHATS_ARCHIVED, KEEP_MUTED_CHATS_ARCHIVED,
CDS_HARD_LIMIT, CDS_HARD_LIMIT,
PAYMENTS_IN_CHAT_MESSAGES PAYMENTS_IN_CHAT_MESSAGES,
CHAT_FILTERS
); );
@VisibleForTesting @VisibleForTesting
@ -608,6 +610,13 @@ public final class FeatureFlags {
return getInteger(CDS_HARD_LIMIT, 50_000); return getInteger(CDS_HARD_LIMIT, 50_000);
} }
/**
* Enables chat filters. Note that this UI is incomplete.
*/
public static boolean chatFilters() {
return getBoolean(CHAT_FILTERS, false);
}
/** Only for rendering debug info. */ /** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() { public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES); return new TreeMap<>(REMOTE_VALUES);

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/search_no_results"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
tools:text="@string/SearchFragment_no_results" />

View file

@ -1,19 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/empty_state" android:id="@+id/empty_state"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginTop="32dp" android:layout_marginTop="32dp"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone" tools:viewBindingIgnore="true">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_barrier"
tools:visibility="visible">
<TextView <TextView
style="@style/Signal.Text.Body" style="@style/Signal.Text.Body"
@ -26,4 +19,4 @@
android:gravity="center" android:gravity="center"
android:text="@string/conversation_list_fragment__no_chats_yet_get_started_by_messaging_a_friend" /> android:text="@string/conversation_list_fragment__no_chats_yet_get_started_by_messaging_a_friend" />
</LinearLayout> </LinearLayout>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="android.widget.FrameLayout">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginBottom="11dp"
android:text="@string/ConversationListFilterPullView__pull_down_to_filter"
android:textAppearance="@style/Signal.Text.LabelLarge" />
<ImageView
android:id="@+id/arrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="8dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_arrow_down"
app:tint="@color/signal_colorOnSurface" />
</merge>

View file

@ -2,12 +2,12 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/constraint_layout" android:id="@+id/constraint_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?android:windowBackground"> android:background="?android:windowBackground"
tools:viewBindingIgnore="true">
<ViewStub <ViewStub
android:id="@+id/voice_note_player" android:id="@+id/voice_note_player"
@ -17,29 +17,6 @@
android:layout="@layout/voice_note_player_stub" android:layout="@layout/voice_note_player_stub"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/search_no_results"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/signal_background_primary"
android:gravity="center"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/SearchFragment_no_results" />
<ViewStub
android:id="@+id/empty_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:layout="@layout/conversation_list_empty_state"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ViewStub <ViewStub
android:id="@+id/reminder" android:id="@+id/reminder"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -63,19 +40,43 @@
app:barrierDirection="bottom" app:barrierDirection="bottom"
app:constraint_referenced_ids="reminder,payments_notification,voice_note_player" /> app:constraint_referenced_ids="reminder,payments_notification,voice_note_player" />
<androidx.recyclerview.widget.RecyclerView <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false"
android:nextFocusDown="@+id/fab"
android:nextFocusForward="@+id/fab"
android:paddingBottom="160dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_barrier" app:layout_constraintTop_toBottomOf="@id/banner_barrier">
tools:listitem="@layout/conversation_list_item_view"
tools:visibility="visible" /> <com.google.android.material.appbar.AppBarLayout
android:id="@+id/recycler_coordinator_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:expanded="false"
app:layout_behavior="org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior">
<org.thoughtcrime.securesms.conversationlist.ConversationListFilterPullView
android:id="@+id/pull_view"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="@color/signal_colorSurface1"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed"
app:layout_scrollInterpolator="@android:anim/decelerate_interpolator" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/signal_colorBackground"
android:clipToPadding="false"
android:nextFocusDown="@+id/fab"
android:nextFocusForward="@+id/fab"
android:paddingBottom="160dp"
android:scrollbars="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/conversation_list_item_view"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator" android:id="@+id/coordinator"

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="88dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/clear_filter"
style="@style/Signal.Widget.Button.Small.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/ConversationListFragment__clear_filter"
android:textColor="@color/signal_colorOnSurfaceVariant" />
</FrameLayout>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/ConversationListFragment__no_unread_chats"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textColor="@color/signal_colorOnSurfaceVariant" />
<com.google.android.material.button.MaterialButton
android:id="@+id/clear_filter"
style="@style/Signal.Widget.Button.Small.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/ConversationListFragment__clear_filter"
android:textColor="@color/signal_colorOnSurfaceVariant" />
</LinearLayout>

View file

@ -13,6 +13,9 @@
<item android:title="@string/text_secure_normal__invite_friends" <item android:title="@string/text_secure_normal__invite_friends"
android:id="@+id/menu_invite" /> android:id="@+id/menu_invite" />
<item android:title="@string/text_secure_normal__filter_unread_chats"
android:id="@+id/menu_filter_unread_chats" />
<item android:title="@string/text_secure_normal__menu_settings" <item android:title="@string/text_secure_normal__menu_settings"
android:id="@+id/menu_settings" /> android:id="@+id/menu_settings" />

View file

@ -486,7 +486,10 @@
<string name="ConversationFragment__cancel">Cancel</string> <string name="ConversationFragment__cancel">Cancel</string>
<!-- Message shown after successfully blocking join requests for a user --> <!-- Message shown after successfully blocking join requests for a user -->
<string name="ConversationFragment__blocked">Blocked</string> <string name="ConversationFragment__blocked">Blocked</string>
<!-- Label for a button displayed in conversation list to clear the chat filter -->
<string name="ConversationListFragment__clear_filter">Clear filter</string>
<!-- Notice on chat list when no unread chats are available, centered on display -->
<string name="ConversationListFragment__no_unread_chats">No unread chats</string>
<plurals name="ConversationListFragment_delete_selected_conversations"> <plurals name="ConversationListFragment_delete_selected_conversations">
<item quantity="one">Delete selected conversation?</item> <item quantity="one">Delete selected conversation?</item>
<item quantity="other">Delete selected conversations?</item> <item quantity="other">Delete selected conversations?</item>
@ -572,6 +575,12 @@
<!-- CreateGroupActivity --> <!-- CreateGroupActivity -->
<string name="CreateGroupActivity__select_members">Select members</string> <string name="CreateGroupActivity__select_members">Select members</string>
<!-- ConversationListFilterPullView -->
<!-- Note in revealable view before fully revealed -->
<string name="ConversationListFilterPullView__pull_down_to_filter">Pull down to filter</string>
<!-- Note in revealable view after fully revealed -->
<string name="ConversationListFilterPullView__release_to_filter">Release to filter</string>
<!-- CreateProfileActivity --> <!-- CreateProfileActivity -->
<string name="CreateProfileActivity__profile">Profile</string> <string name="CreateProfileActivity__profile">Profile</string>
<string name="CreateProfileActivity_error_setting_profile_photo">Error setting profile photo</string> <string name="CreateProfileActivity_error_setting_profile_photo">Error setting profile photo</string>
@ -3197,6 +3206,8 @@
<string name="text_secure_normal__menu_clear_passphrase">Lock</string> <string name="text_secure_normal__menu_clear_passphrase">Lock</string>
<string name="text_secure_normal__mark_all_as_read">Mark all read</string> <string name="text_secure_normal__mark_all_as_read">Mark all read</string>
<string name="text_secure_normal__invite_friends">Invite friends</string> <string name="text_secure_normal__invite_friends">Invite friends</string>
<!-- Overflow menu entry to filter unread chats -->
<string name="text_secure_normal__filter_unread_chats">Filter unread chats</string>
<!-- verify_display_fragment --> <!-- verify_display_fragment -->
<string name="verify_display_fragment_context_menu__copy_to_clipboard">Copy to clipboard</string> <string name="verify_display_fragment_context_menu__copy_to_clipboard">Copy to clipboard</string>

View file

@ -13,6 +13,7 @@ import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule; import org.mockito.junit.MockitoRule;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader; import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
import org.thoughtcrime.securesms.database.DatabaseObserver; import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
@ -22,6 +23,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@ -52,7 +54,7 @@ public class UnarchivedConversationListDataSourceTest {
when(SignalDatabase.threads()).thenReturn(threadDatabase); when(SignalDatabase.threads()).thenReturn(threadDatabase);
when(ApplicationDependencies.getDatabaseObserver()).thenReturn(mock(DatabaseObserver.class)); when(ApplicationDependencies.getDatabaseObserver()).thenReturn(mock(DatabaseObserver.class));
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(mock(Application.class)); testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.OFF);
} }
@Test @Test
@ -65,13 +67,14 @@ public class UnarchivedConversationListDataSourceTest {
assertEquals(0, testSubject.getHeaderOffset()); assertEquals(0, testSubject.getHeaderOffset());
assertFalse(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasConversationFilterFooter());
assertFalse(testSubject.hasArchivedFooter()); assertFalse(testSubject.hasArchivedFooter());
} }
@Test @Test
public void givenArchivedConversations_whenIGetTotalCount_thenIExpectOne() { public void givenArchivedConversations_whenIGetTotalCount_thenIExpectOne() {
// GIVEN // GIVEN
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
// WHEN // WHEN
int result = testSubject.getTotalCount(); int result = testSubject.getTotalCount();
@ -81,15 +84,16 @@ public class UnarchivedConversationListDataSourceTest {
assertEquals(0, testSubject.getHeaderOffset()); assertEquals(0, testSubject.getHeaderOffset());
assertFalse(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasConversationFilterFooter());
assertTrue(testSubject.hasArchivedFooter()); assertTrue(testSubject.hasArchivedFooter());
} }
@Test @Test
public void givenSinglePinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectThree() { public void givenSinglePinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectThree() {
// GIVEN // GIVEN
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
// WHEN // WHEN
int result = testSubject.getTotalCount(); int result = testSubject.getTotalCount();
@ -99,14 +103,15 @@ public class UnarchivedConversationListDataSourceTest {
assertEquals(1, testSubject.getHeaderOffset()); assertEquals(1, testSubject.getHeaderOffset());
assertTrue(testSubject.hasPinnedHeader()); assertTrue(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasConversationFilterFooter());
assertTrue(testSubject.hasArchivedFooter()); assertTrue(testSubject.hasArchivedFooter());
} }
@Test @Test
public void givenSingleUnpinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() { public void givenSingleUnpinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() {
// GIVEN // GIVEN
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
// WHEN // WHEN
int result = testSubject.getTotalCount(); int result = testSubject.getTotalCount();
@ -116,14 +121,15 @@ public class UnarchivedConversationListDataSourceTest {
assertEquals(0, testSubject.getHeaderOffset()); assertEquals(0, testSubject.getHeaderOffset());
assertFalse(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasPinnedHeader());
assertFalse(testSubject.hasUnpinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasConversationFilterFooter());
assertTrue(testSubject.hasArchivedFooter()); assertTrue(testSubject.hasArchivedFooter());
} }
@Test @Test
public void givenSinglePinnedAndSingleUnpinned_whenIGetTotalCount_thenIExpectFour() { public void givenSinglePinnedAndSingleUnpinned_whenIGetTotalCount_thenIExpectFour() {
// GIVEN // GIVEN
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(2); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(2);
// WHEN // WHEN
int result = testSubject.getTotalCount(); int result = testSubject.getTotalCount();
@ -133,6 +139,7 @@ public class UnarchivedConversationListDataSourceTest {
assertEquals(2, testSubject.getHeaderOffset()); assertEquals(2, testSubject.getHeaderOffset());
assertTrue(testSubject.hasPinnedHeader()); assertTrue(testSubject.hasPinnedHeader());
assertTrue(testSubject.hasUnpinnedHeader()); assertTrue(testSubject.hasUnpinnedHeader());
assertFalse(testSubject.hasConversationFilterFooter());
assertFalse(testSubject.hasArchivedFooter()); assertFalse(testSubject.hasArchivedFooter());
} }
@ -145,8 +152,8 @@ public class UnarchivedConversationListDataSourceTest {
Cursor cursor = testSubject.getCursor(0, 100); Cursor cursor = testSubject.getCursor(0, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100);
assertEquals(0, cursor.getCount()); assertEquals(0, cursor.getCount());
} }
@ -154,15 +161,15 @@ public class UnarchivedConversationListDataSourceTest {
public void givenArchivedConversations_whenIGetCursor_thenIExpectOne() { public void givenArchivedConversations_whenIGetCursor_thenIExpectOne() {
// GIVEN // GIVEN
setupThreadDatabaseCursors(0, 0); setupThreadDatabaseCursors(0, 0);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
testSubject.getTotalCount(); testSubject.getTotalCount();
// WHEN // WHEN
Cursor cursor = testSubject.getCursor(0, 100); Cursor cursor = testSubject.getCursor(0, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100);
assertEquals(1, cursor.getCount()); assertEquals(1, cursor.getCount());
} }
@ -170,17 +177,17 @@ public class UnarchivedConversationListDataSourceTest {
public void givenSinglePinnedAndArchivedConversations_whenIGetCursor_thenIExpectThree() { public void givenSinglePinnedAndArchivedConversations_whenIGetCursor_thenIExpectThree() {
// GIVEN // GIVEN
setupThreadDatabaseCursors(1, 0); setupThreadDatabaseCursors(1, 0);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
testSubject.getTotalCount(); testSubject.getTotalCount();
// WHEN // WHEN
Cursor cursor = testSubject.getCursor(0, 100); Cursor cursor = testSubject.getCursor(0, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 99); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 99);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 98); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 98);
assertEquals(3, cursor.getCount()); assertEquals(3, cursor.getCount());
} }
@ -188,16 +195,16 @@ public class UnarchivedConversationListDataSourceTest {
public void givenSingleUnpinnedAndArchivedConversations_whenIGetCursor_thenIExpectTwo() { public void givenSingleUnpinnedAndArchivedConversations_whenIGetCursor_thenIExpectTwo() {
// GIVEN // GIVEN
setupThreadDatabaseCursors(0, 1); setupThreadDatabaseCursors(0, 1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
testSubject.getTotalCount(); testSubject.getTotalCount();
// WHEN // WHEN
Cursor cursor = testSubject.getCursor(0, 100); Cursor cursor = testSubject.getCursor(0, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100);
assertEquals(2, cursor.getCount()); assertEquals(2, cursor.getCount());
} }
@ -205,16 +212,16 @@ public class UnarchivedConversationListDataSourceTest {
public void givenSinglePinnedAndSingleUnpinned_whenIGetCursor_thenIExpectFour() { public void givenSinglePinnedAndSingleUnpinned_whenIGetCursor_thenIExpectFour() {
// GIVEN // GIVEN
setupThreadDatabaseCursors(1, 1); setupThreadDatabaseCursors(1, 1);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(2); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(2);
testSubject.getTotalCount(); testSubject.getTotalCount();
// WHEN // WHEN
Cursor cursor = testSubject.getCursor(0, 100); Cursor cursor = testSubject.getCursor(0, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 0, 99); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 99);
verify(threadDatabase).getUnarchivedConversationList(false, 0, 97); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 97);
assertEquals(4, cursor.getCount()); assertEquals(4, cursor.getCount());
} }
@ -222,16 +229,16 @@ public class UnarchivedConversationListDataSourceTest {
public void givenLoadingSecondPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() { public void givenLoadingSecondPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() {
// GIVEN // GIVEN
setupThreadDatabaseCursors(0, 100); setupThreadDatabaseCursors(0, 100);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(4); when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(4);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(104); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(104);
testSubject.getTotalCount(); testSubject.getTotalCount();
// WHEN // WHEN
Cursor cursor = testSubject.getCursor(50, 100); Cursor cursor = testSubject.getCursor(50, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 50, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 44, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 44, 100);
assertEquals(100, cursor.getCount()); assertEquals(100, cursor.getCount());
} }
@ -239,23 +246,44 @@ public class UnarchivedConversationListDataSourceTest {
public void givenHasArchivedAndLoadingLastPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() { public void givenHasArchivedAndLoadingLastPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() {
// GIVEN // GIVEN
setupThreadDatabaseCursors(0, 99); setupThreadDatabaseCursors(0, 99);
when(threadDatabase.getPinnedConversationListCount()).thenReturn(4); when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(4);
when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(103); when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(103);
when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
testSubject.getTotalCount(); testSubject.getTotalCount();
// WHEN // WHEN
Cursor cursor = testSubject.getCursor(50, 100); Cursor cursor = testSubject.getCursor(50, 100);
// THEN // THEN
verify(threadDatabase).getUnarchivedConversationList(true, 50, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100);
verify(threadDatabase).getUnarchivedConversationList(false, 44, 100); verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 44, 100);
assertEquals(100, cursor.getCount()); assertEquals(100, cursor.getCount());
cursor.moveToLast(); cursor.moveToLast();
assertEquals(0, cursor.getColumnIndex(ConversationReader.HEADER_COLUMN[0])); assertEquals(0, cursor.getColumnIndex(ConversationReader.HEADER_COLUMN[0]));
} }
@Test
public void givenHasNoArchivedAndIsFiltered_whenIGetCursor_thenIExpectConversationFilterFooter() {
// GIVEN
ConversationListDataSource.UnarchivedConversationListDataSource testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.UNREAD);
setupThreadDatabaseCursors(0, 3);
when(threadDatabase.getPinnedConversationListCount(ConversationFilter.UNREAD)).thenReturn(0);
when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.UNREAD)).thenReturn(3);
when(threadDatabase.getArchivedConversationListCount(ConversationFilter.UNREAD)).thenReturn(0);
testSubject.getTotalCount();
// WHEN
Cursor cursor = testSubject.getCursor(0, 5);
// THEN
assertEquals(4, cursor.getCount());
assertTrue(testSubject.hasConversationFilterFooter());
cursor.moveToLast();
assertEquals(0, cursor.getColumnIndex(ConversationReader.HEADER_COLUMN[0]));
}
private void setupThreadDatabaseCursors(int pinned, int unpinned) { private void setupThreadDatabaseCursors(int pinned, int unpinned) {
Cursor pinnedCursor = mock(Cursor.class); Cursor pinnedCursor = mock(Cursor.class);
@ -264,7 +292,7 @@ public class UnarchivedConversationListDataSourceTest {
Cursor unpinnedCursor = mock(Cursor.class); Cursor unpinnedCursor = mock(Cursor.class);
when(unpinnedCursor.getCount()).thenReturn(unpinned); when(unpinnedCursor.getCount()).thenReturn(unpinned);
when(threadDatabase.getUnarchivedConversationList(eq(true), anyLong(), anyLong())).thenReturn(pinnedCursor); when(threadDatabase.getUnarchivedConversationList(any(), eq(true), anyLong(), anyLong())).thenReturn(pinnedCursor);
when(threadDatabase.getUnarchivedConversationList(eq(false), anyLong(), anyLong())).thenReturn(unpinnedCursor); when(threadDatabase.getUnarchivedConversationList(any(), eq(false), anyLong(), anyLong())).thenReturn(unpinnedCursor);
} }
} }