From bba1315906af86be725101fc6d8c5a5205030f64 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 8 Nov 2022 15:09:21 -0400 Subject: [PATCH] Add chat filter support behind a flag. --- .../ConversationFilterBehavior.kt | 26 ++++ .../ConversationFilterLatch.kt | 10 ++ .../ConversationListAdapter.java | 44 ++++++- .../ConversationListDataSource.java | 88 ++++++++++---- .../ConversationListFilterPullView.kt | 63 ++++++++++ .../ConversationListFragment.java | 58 +++++---- .../ConversationListSearchAdapter.java | 99 ++++++++++----- .../ConversationListViewModel.java | 115 ++++++++++++------ .../conversationlist/model/Conversation.java | 5 +- .../model/ConversationFilter.kt | 27 ++++ .../model/ConversationReader.java | 24 ++-- .../securesms/database/ThreadDatabase.kt | 44 ++++--- .../helpers/SignalDatabaseMigrations.kt | 7 +- .../V164_ThreadDatabaseReadIndexMigration.kt | 10 ++ .../jobs/RetrieveRemoteAnnouncementsJob.kt | 3 +- .../UserNotificationMigrationJob.java | 5 +- .../securesms/util/FeatureFlags.java | 11 +- .../conversation_list_empty_search_state.xml | 8 ++ .../layout/conversation_list_empty_state.xml | 13 +- .../conversation_list_filter_pull_view.xml | 27 ++++ .../res/layout/conversation_list_fragment.xml | 71 +++++------ .../conversation_list_item_clear_filter.xml | 16 +++ ...versation_list_item_clear_filter_empty.xml | 24 ++++ app/src/main/res/menu/text_secure_normal.xml | 3 + app/src/main/res/values/strings.xml | 13 +- ...rchivedConversationListDataSourceTest.java | 104 ++++++++++------ 26 files changed, 681 insertions(+), 237 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterLatch.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationFilter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V164_ThreadDatabaseReadIndexMigration.kt create mode 100644 app/src/main/res/layout/conversation_list_empty_search_state.xml create mode 100644 app/src/main/res/layout/conversation_list_filter_pull_view.xml create mode 100644 app/src/main/res/layout/conversation_list_item_clear_filter.xml create mode 100644 app/src/main/res/layout/conversation_list_item_clear_filter_empty.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt new file mode 100644 index 0000000000..6537f0c74b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterLatch.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterLatch.kt new file mode 100644 index 0000000000..e67f79f201 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterLatch.kt @@ -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; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java index 86928a48dc..63428c8376 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java @@ -30,10 +30,13 @@ import java.util.Set; class ConversationListAdapter extends ListAdapter { - private static final int TYPE_THREAD = 1; - private static final int TYPE_ACTION = 2; - private static final int TYPE_PLACEHOLDER = 3; - private static final int TYPE_HEADER = 4; + private static final int TYPE_THREAD = 1; + private static final int TYPE_ACTION = 2; + private static final int TYPE_PLACEHOLDER = 3; + private static final int TYPE_HEADER = 4; + private 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 { TYPING_INDICATOR, @@ -43,6 +46,7 @@ class ConversationListAdapter extends ListAdapter typingSet = new HashSet<>(); @@ -50,13 +54,15 @@ class ConversationListAdapter extends ListAdapter { + listener.onClearFilterClick(); + }); + } + } + interface OnConversationClickListener { void onConversationClick(@NonNull Conversation conversation); boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view); void onShowArchiveClick(); } + + interface OnClearFilterClickListener { + void onClearFilterClick(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java index 6510950523..1574bfea00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversationlist; -import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; import android.database.MergeCursor; @@ -9,9 +8,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import org.signal.core.util.Stopwatch; import org.signal.core.util.logging.Log; import org.signal.paging.PagedDataSource; 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.database.SignalDatabase; 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.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.signal.core.util.Stopwatch; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; @@ -35,15 +36,17 @@ abstract class ConversationListDataSource implements PagedDataSource 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 conversations = new ArrayList<>(length); List recipients = new LinkedList<>(); @@ -89,7 +96,15 @@ abstract class ConversationListDataSource implements PagedDataSource 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= 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); archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount)); 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[]{})); } @@ -213,5 +249,9 @@ abstract class ConversationListDataSource implements PagedDataSource 1 && conversationFilter != ConversationFilter.OFF; + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt new file mode 100644 index 0000000000..eef77d43a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 04d66369ec..e965e93573 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -32,6 +32,8 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; +import android.provider.Settings; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -40,7 +42,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.FrameLayout; -import android.widget.TextView; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; @@ -68,6 +69,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.airbnb.lottie.SimpleColorFilter; import com.annimon.stream.Stream; 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.snackbar.Snackbar; @@ -136,8 +138,6 @@ import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile; 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.ratelimit.RecaptchaProofBottomSheetFragment; 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, ConversationListAdapter.OnConversationClickListener, ConversationListSearchAdapter.EventListener, - MegaphoneActionController + MegaphoneActionController, ConversationListAdapter.OnClearFilterClickListener { public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562; public static final short SMS_ROLE_REQUEST_CODE = 32563; @@ -207,8 +207,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode private RecyclerView list; private Stub reminderView; private Stub paymentNotificationView; - private Stub emptyState; - private TextView searchEmptyState; private PulsingFloatingActionButton fab; private PulsingFloatingActionButton cameraFab; private ConversationListViewModel viewModel; @@ -263,10 +261,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { coordinator = view.findViewById(R.id.coordinator); list = view.findViewById(R.id.list); - searchEmptyState = view.findViewById(R.id.search_no_results); bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar); 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)); paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification)); 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); 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(); cameraFab.show(); @@ -345,10 +354,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode public void onDestroyView() { coordinator = null; list = null; - searchEmptyState = null; bottomActionBar = null; reminderView = null; - emptyState = null; megaphoneContainer = null; paymentNotificationView = null; voiceNotePlayerViewStub = null; @@ -486,6 +493,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode handleInsights(); return true; case R.id.menu_notification_profile: handleNotificationProfile(); return true; + case R.id.menu_filter_unread_chats: + handleFilterUnreadChats(); return true; } return false; @@ -691,7 +700,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode 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()); searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0); @@ -727,7 +736,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode } if (adapter instanceof ConversationListAdapter) { - ((ConversationListAdapter) adapter).setPagingController(viewModel.getPagingController()); + viewModel.getPagingController() + .observe(getViewLifecycleOwner(), + controller -> ((ConversationListAdapter) adapter).setPagingController(controller)); } list.setAdapter(adapter); @@ -826,13 +837,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode private void onSearchResultChanged(@Nullable SearchResult result) { result = result != null ? result : SearchResult.EMPTY; 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) { @@ -966,6 +970,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode NotificationProfileSelectionFragment.show(getParentFragmentManager()); } + private void handleFilterUnreadChats() { + viewModel.toggleUnreadChatsFilter(); + } + @SuppressLint("StaticFieldLeak") private void handleArchive(@NonNull Collection ids, boolean showProgress) { Set selectedConversations = new HashSet<>(ids); @@ -1173,21 +1181,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode void updateEmptyState(boolean isConversationEmpty) { if (isConversationEmpty) { Log.i(TAG, "Received an empty data set."); - list.setVisibility(View.INVISIBLE); - emptyState.get().setVisibility(View.VISIBLE); fab.startPulse(3 * 1000); cameraFab.startPulse(3 * 1000); SignalStore.onboarding().setShowNewGroup(true); SignalStore.onboarding().setShowInviteFriends(true); } else { - list.setVisibility(View.VISIBLE); fab.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); } + @Override + public void onClearFilterClick() { + viewModel.toggleUnreadChatsFilter(); + } + private class PaymentNotificationListener implements UnreadPaymentsView.Listener { private final UnreadPayments unreadPayments; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java index 801723b083..b5583600e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java @@ -22,9 +22,12 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import java.util.Collections; import java.util.Locale; -class ConversationListSearchAdapter extends RecyclerView.Adapter +class ConversationListSearchAdapter extends RecyclerView.Adapter implements StickyHeaderDecoration.StickyHeaderAdapter { + 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_CONTACTS = 2; private static final int TYPE_MESSAGES = 3; @@ -49,47 +52,69 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter megaphone; - private final MutableLiveData searchResult; - private final MutableLiveData selectedConversations; - private final Set internalSelection; - private final ConversationListDataSource conversationListDataSource; - private final LivePagedData pagedData; - private final LiveData hasNoConversations; - private final SearchRepository searchRepository; - private final MegaphoneRepository megaphoneRepository; - private final Debouncer messageSearchDebouncer; - private final Debouncer contactSearchDebouncer; - private final ThrottledDebouncer updateDebouncer; - private final DatabaseObserver.Observer observer; - private final Invalidator invalidator; - private final CompositeDisposable disposables; - private final UnreadPaymentsLiveData unreadPaymentsLiveData; - private final UnreadPaymentsRepository unreadPaymentsRepository; - private final NotificationProfilesRepository notificationProfilesRepository; + private final MutableLiveData megaphone; + private final MutableLiveData searchResult; + private final MutableLiveData selectedConversations; + private final MutableLiveData conversationFilter; + private final LiveData conversationListDataSource; + private final Set internalSelection; + private final LiveData> pagedData; + private final LiveData hasNoConversations; + private final SearchRepository searchRepository; + private final MegaphoneRepository megaphoneRepository; + private final Debouncer messageSearchDebouncer; + private final Debouncer contactSearchDebouncer; + private final ThrottledDebouncer updateDebouncer; + private final DatabaseObserver.Observer observer; + private final Invalidator invalidator; + private final CompositeDisposable disposables; + private final UnreadPaymentsLiveData unreadPaymentsLiveData; + private final UnreadPaymentsRepository unreadPaymentsRepository; + private final NotificationProfilesRepository notificationProfilesRepository; - private String activeQuery; - private SearchResult activeSearchResult; - private int pinnedCount; + private String activeQuery; + private SearchResult activeSearchResult; + 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.searchResult = new MutableLiveData<>(); this.internalSelection = new HashSet<>(); @@ -95,29 +100,37 @@ class ConversationListViewModel extends ViewModel { this.activeSearchResult = SearchResult.EMPTY; this.invalidator = new Invalidator(); this.disposables = new CompositeDisposable(); - this.conversationListDataSource = ConversationListDataSource.create(application, isArchived); - this.pagedData = PagedData.createForLiveData(conversationListDataSource, - new PagingConfig.Builder() - .setPageSize(15) - .setBufferPages(2) - .build()); + this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF); + this.conversationFilterLatch = ConversationFilterLatch.RESET; + this.conversationListDataSource = Transformations.map(conversationFilter, filter -> ConversationListDataSource.create(filter, isArchived)); + this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source, + new PagingConfig.Builder() + .setPageSize(15) + .setBufferPages(2) + .build())); this.unreadPaymentsLiveData = new UnreadPaymentsLiveData(); this.observer = () -> { updateDebouncer.publish(() -> { if (!TextUtils.isEmpty(activeQuery)) { onSearchQueryUpdated(activeQuery); } - pagedData.getController().onDataInvalidated(); + + LivePagedData data = pagedData.getValue(); + if (data == null) { + return; + } + + data.getController().onDataInvalidated(); }); }; - this.hasNoConversations = LiveDataUtil.mapAsync(pagedData.getData(), conversations -> { - pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(); + this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilter, getConversationList(), Pair::new), filterAndData -> { + pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(ConversationFilter.OFF); - if (conversations.size() > 0) { + if (filterAndData.getSecond().size() > 0) { return false; } else { - return SignalDatabase.threads().getArchivedConversationListCount() == 0; + return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst()) == 0; } }); @@ -137,11 +150,11 @@ class ConversationListViewModel extends ViewModel { } @NonNull LiveData> getConversationList() { - return pagedData.getData(); + return Transformations.switchMap(pagedData, LivePagedData::getData); } - @NonNull PagingController getPagingController() { - return pagedData.getController(); + @NonNull LiveData> getPagingController() { + return Transformations.map(pagedData, LivePagedData::getController); } @NonNull LiveData> getNotificationProfiles() { @@ -199,6 +212,25 @@ class ConversationListViewModel extends ViewModel { 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 newSelection) { internalSelection.clear(); internalSelection.addAll(newSelection); @@ -206,8 +238,13 @@ class ConversationListViewModel extends ViewModel { } void onSelectAllClick() { + ConversationListDataSource dataSource = conversationListDataSource.getValue(); + if (dataSource == null) { + return; + } + disposables.add( - Single.fromCallable(() -> conversationListDataSource.load(0, conversationListDataSource.size(), disposables::isDisposed)) + Single.fromCallable(() -> dataSource.load(0, dataSource.size(), disposables::isDisposed)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::setSelection) @@ -301,7 +338,7 @@ class ConversationListViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions - return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(noteToSelfTitle), isArchived)); + return modelClass.cast(new ConversationListViewModel(new SearchRepository(noteToSelfTitle), isArchived)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java index 0183b66df1..42b78e5030 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java @@ -42,6 +42,9 @@ public class Conversation { THREAD, PINNED_HEADER, UNPINNED_HEADER, - ARCHIVED_FOOTER + ARCHIVED_FOOTER, + CONVERSATION_FILTER_FOOTER, + CONVERSATION_FILTER_EMPTY, + EMPTY } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationFilter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationFilter.kt new file mode 100644 index 0000000000..f9ec407d4d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationFilter.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java index d5c9540b3f..8288dc3890 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/ConversationReader.java @@ -12,10 +12,11 @@ import org.signal.core.util.CursorUtil; public class ConversationReader extends ThreadDatabase.StaticReader { - public static final String[] HEADER_COLUMN = {"header"}; - public static final String[] ARCHIVED_COLUMNS = {"header", "count"}; - public static final String[] PINNED_HEADER = {Conversation.Type.PINNED_HEADER.toString()}; - public static final String[] UNPINNED_HEADER = {Conversation.Type.UNPINNED_HEADER.toString()}; + public static final String[] HEADER_COLUMN = { "header" }; + public static final String[] ARCHIVED_COLUMNS = { "header", "count" }; + public static final String[] PINNED_HEADER = { Conversation.Type.PINNED_HEADER.toString() }; + public static final String[] UNPINNED_HEADER = { Conversation.Type.UNPINNED_HEADER.toString() }; + public static final String[] CONVERSATION_FILTER_FOOTER = { Conversation.Type.CONVERSATION_FILTER_FOOTER.toString() }; private final Cursor cursor; @@ -43,11 +44,16 @@ public class ConversationReader extends ThreadDatabase.StaticReader { if (type == Conversation.Type.ARCHIVED_FOOTER) { 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())) - .setBody(type.toString()) - .setDate(100) - .setRecipient(Recipient.UNKNOWN) - .setUnreadCount(count) - .build(); + .setBody(type.toString()) + .setDate(100) + .setRecipient(Recipient.UNKNOWN) + .setUnreadCount(count) + .build(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt index 098fe4a96b..23e13412a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt @@ -24,6 +24,7 @@ import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.signal.libsignal.zkgroup.InvalidInputException 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.SignalDatabase.Companion.attachments import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts @@ -132,7 +133,8 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas val CREATE_INDEXS = arrayOf( "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 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( @@ -754,7 +756,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas } fun getArchivedRecipients(): Set { - return getArchivedConversationList().readToList { cursor -> + return getArchivedConversationList(ConversationFilter.OFF).readToList { cursor -> RecipientId.from(cursor.requireLong(RECIPIENT_ID)) }.toSet() } @@ -775,16 +777,18 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas return positions } - fun getArchivedConversationList(offset: Long = 0, limit: Long = 0): Cursor { - val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0", offset, limit, preferPinned = false) + fun getArchivedConversationList(conversationFilter: ConversationFilter, offset: Long = 0, limit: Long = 0): Cursor { + val filterQuery = conversationFilter.toQuery() + val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0 $filterQuery", offset, limit, preferPinned = false) 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) { - "$ARCHIVED = 0 AND $PINNED != 0" + "$ARCHIVED = 0 AND $PINNED != 0 $filterQuery" } else { - "$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0" + "$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery" } val query = if (pinned) { @@ -796,11 +800,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas return readableDatabase.rawQuery(query, null) } - fun getArchivedConversationListCount(): Int { + fun getArchivedConversationListCount(conversationFilter: ConversationFilter): Int { + val filterQuery = conversationFilter.toQuery() return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) - .where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0") + .where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0 $filterQuery") .run() .use { cursor -> 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 .select("COUNT(*)") .from(TABLE_NAME) - .where("$ARCHIVED = 0 AND $PINNED != 0") + .where("$ARCHIVED = 0 AND $PINNED != 0 $filterQuery") .run() .use { cursor -> 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 .select("COUNT(*)") .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() .use { cursor -> if (cursor.moveToFirst()) { @@ -888,7 +895,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas .run() } - var pinnedCount = getPinnedConversationListCount() + var pinnedCount = getPinnedConversationListCount(ConversationFilter.OFF) for (threadId in threadIds) { pinnedCount++ @@ -1587,6 +1594,15 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas 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 { const val DEFAULT = 2 const val BROADCAST = 1 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 9eaf3a32a6..7ca286c25e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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.V162_ThreadUnreadSelfMentionCountFixup 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. @@ -27,7 +28,7 @@ object SignalDatabaseMigrations { val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass) - const val DATABASE_VERSION = 163 + const val DATABASE_VERSION = 164 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -90,6 +91,10 @@ object SignalDatabaseMigrations { if (oldVersion < 163) { V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion) } + + if (oldVersion < 164) { + V164_ThreadDatabaseReadIndexMigration.migrate(context, db, oldVersion, newVersion) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V164_ThreadDatabaseReadIndexMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V164_ThreadDatabaseReadIndexMigration.kt new file mode 100644 index 0000000000..0af825aba4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V164_ThreadDatabaseReadIndexMigration.kt @@ -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);") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt index d072cc6f7e..5cf3178e3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt @@ -9,6 +9,7 @@ import org.signal.core.util.Hex import org.signal.core.util.ThreadUtil import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter import org.thoughtcrime.securesms.database.MessageDatabase import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase import org.thoughtcrime.securesms.database.SignalDatabase @@ -150,7 +151,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool } 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") values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY return diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java index bb314b4700..dd1df80e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java @@ -13,6 +13,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.MainActivity; import org.thoughtcrime.securesms.NewConversationActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.jobmanager.Data; @@ -76,8 +77,8 @@ public class UserNotificationMigrationJob extends MigrationJob { ThreadDatabase threadDatabase = SignalDatabase.threads(); - int threadCount = threadDatabase.getUnarchivedConversationListCount() + - threadDatabase.getArchivedConversationListCount(); + int threadCount = threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF) + + threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF); if (threadCount >= 3) { Log.w(TAG, "Already have 3 or more threads. Skipping."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 763fc2cf21..19f61383f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -108,6 +108,7 @@ public final class FeatureFlags { public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions"; 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 CHAT_FILTERS = "android.chat.filters"; /** * 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, KEEP_MUTED_CHATS_ARCHIVED, CDS_HARD_LIMIT, - PAYMENTS_IN_CHAT_MESSAGES + PAYMENTS_IN_CHAT_MESSAGES, + CHAT_FILTERS ); @VisibleForTesting @@ -608,6 +610,13 @@ public final class FeatureFlags { 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. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/layout/conversation_list_empty_search_state.xml b/app/src/main/res/layout/conversation_list_empty_search_state.xml new file mode 100644 index 0000000000..973c072f8d --- /dev/null +++ b/app/src/main/res/layout/conversation_list_empty_search_state.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_empty_state.xml b/app/src/main/res/layout/conversation_list_empty_state.xml index 18023953da..b56974669f 100644 --- a/app/src/main/res/layout/conversation_list_empty_state.xml +++ b/app/src/main/res/layout/conversation_list_empty_state.xml @@ -1,19 +1,12 @@ - + tools:viewBindingIgnore="true"> - + diff --git a/app/src/main/res/layout/conversation_list_filter_pull_view.xml b/app/src/main/res/layout/conversation_list_filter_pull_view.xml new file mode 100644 index 0000000000..5d3335e4d6 --- /dev/null +++ b/app/src/main/res/layout/conversation_list_filter_pull_view.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index 5da58a4ea6..3dd2a05358 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -2,12 +2,12 @@ + android:background="?android:windowBackground" + tools:viewBindingIgnore="true"> - - - - - + app:layout_constraintTop_toBottomOf="@id/banner_barrier"> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_item_clear_filter_empty.xml b/app/src/main/res/layout/conversation_list_item_clear_filter_empty.xml new file mode 100644 index 0000000000..f74377d2da --- /dev/null +++ b/app/src/main/res/layout/conversation_list_item_clear_filter_empty.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/text_secure_normal.xml b/app/src/main/res/menu/text_secure_normal.xml index 74888eeab4..eaf59c0947 100644 --- a/app/src/main/res/menu/text_secure_normal.xml +++ b/app/src/main/res/menu/text_secure_normal.xml @@ -13,6 +13,9 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e218584e92..ec3670fcf1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -486,7 +486,10 @@ Cancel Blocked - + + Clear filter + + No unread chats Delete selected conversation? Delete selected conversations? @@ -572,6 +575,12 @@ Select members + + + Pull down to filter + + Release to filter + Profile Error setting profile photo @@ -3197,6 +3206,8 @@ Lock Mark all read Invite friends + + Filter unread chats Copy to clipboard diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java index a1b50b5753..4f0af9873b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java @@ -13,6 +13,7 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter; import org.thoughtcrime.securesms.conversationlist.model.ConversationReader; import org.thoughtcrime.securesms.database.DatabaseObserver; 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.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -52,7 +54,7 @@ public class UnarchivedConversationListDataSourceTest { when(SignalDatabase.threads()).thenReturn(threadDatabase); when(ApplicationDependencies.getDatabaseObserver()).thenReturn(mock(DatabaseObserver.class)); - testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(mock(Application.class)); + testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.OFF); } @Test @@ -65,13 +67,14 @@ public class UnarchivedConversationListDataSourceTest { assertEquals(0, testSubject.getHeaderOffset()); assertFalse(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader()); + assertFalse(testSubject.hasConversationFilterFooter()); assertFalse(testSubject.hasArchivedFooter()); } @Test public void givenArchivedConversations_whenIGetTotalCount_thenIExpectOne() { // GIVEN - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); // WHEN int result = testSubject.getTotalCount(); @@ -81,15 +84,16 @@ public class UnarchivedConversationListDataSourceTest { assertEquals(0, testSubject.getHeaderOffset()); assertFalse(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader()); + assertFalse(testSubject.hasConversationFilterFooter()); assertTrue(testSubject.hasArchivedFooter()); } @Test public void givenSinglePinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectThree() { // GIVEN - when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); // WHEN int result = testSubject.getTotalCount(); @@ -99,14 +103,15 @@ public class UnarchivedConversationListDataSourceTest { assertEquals(1, testSubject.getHeaderOffset()); assertTrue(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader()); + assertFalse(testSubject.hasConversationFilterFooter()); assertTrue(testSubject.hasArchivedFooter()); } @Test public void givenSingleUnpinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() { // GIVEN - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); // WHEN int result = testSubject.getTotalCount(); @@ -116,14 +121,15 @@ public class UnarchivedConversationListDataSourceTest { assertEquals(0, testSubject.getHeaderOffset()); assertFalse(testSubject.hasPinnedHeader()); assertFalse(testSubject.hasUnpinnedHeader()); + assertFalse(testSubject.hasConversationFilterFooter()); assertTrue(testSubject.hasArchivedFooter()); } @Test public void givenSinglePinnedAndSingleUnpinned_whenIGetTotalCount_thenIExpectFour() { // GIVEN - when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(2); + when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(2); // WHEN int result = testSubject.getTotalCount(); @@ -133,6 +139,7 @@ public class UnarchivedConversationListDataSourceTest { assertEquals(2, testSubject.getHeaderOffset()); assertTrue(testSubject.hasPinnedHeader()); assertTrue(testSubject.hasUnpinnedHeader()); + assertFalse(testSubject.hasConversationFilterFooter()); assertFalse(testSubject.hasArchivedFooter()); } @@ -145,8 +152,8 @@ public class UnarchivedConversationListDataSourceTest { Cursor cursor = testSubject.getCursor(0, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 0, 100); - verify(threadDatabase).getUnarchivedConversationList(false, 0, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100); assertEquals(0, cursor.getCount()); } @@ -154,15 +161,15 @@ public class UnarchivedConversationListDataSourceTest { public void givenArchivedConversations_whenIGetCursor_thenIExpectOne() { // GIVEN setupThreadDatabaseCursors(0, 0); - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); testSubject.getTotalCount(); // WHEN Cursor cursor = testSubject.getCursor(0, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 0, 100); - verify(threadDatabase).getUnarchivedConversationList(false, 0, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100); assertEquals(1, cursor.getCount()); } @@ -170,17 +177,17 @@ public class UnarchivedConversationListDataSourceTest { public void givenSinglePinnedAndArchivedConversations_whenIGetCursor_thenIExpectThree() { // GIVEN setupThreadDatabaseCursors(1, 0); - when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); testSubject.getTotalCount(); // WHEN Cursor cursor = testSubject.getCursor(0, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 0, 99); - verify(threadDatabase).getUnarchivedConversationList(false, 0, 98); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 99); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 98); assertEquals(3, cursor.getCount()); } @@ -188,16 +195,16 @@ public class UnarchivedConversationListDataSourceTest { public void givenSingleUnpinnedAndArchivedConversations_whenIGetCursor_thenIExpectTwo() { // GIVEN setupThreadDatabaseCursors(0, 1); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(1); - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); testSubject.getTotalCount(); // WHEN Cursor cursor = testSubject.getCursor(0, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 0, 100); - verify(threadDatabase).getUnarchivedConversationList(false, 0, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100); assertEquals(2, cursor.getCount()); } @@ -205,16 +212,16 @@ public class UnarchivedConversationListDataSourceTest { public void givenSinglePinnedAndSingleUnpinned_whenIGetCursor_thenIExpectFour() { // GIVEN setupThreadDatabaseCursors(1, 1); - when(threadDatabase.getPinnedConversationListCount()).thenReturn(1); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(2); + when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(2); testSubject.getTotalCount(); // WHEN Cursor cursor = testSubject.getCursor(0, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 0, 99); - verify(threadDatabase).getUnarchivedConversationList(false, 0, 97); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 99); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 97); assertEquals(4, cursor.getCount()); } @@ -222,16 +229,16 @@ public class UnarchivedConversationListDataSourceTest { public void givenLoadingSecondPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() { // GIVEN setupThreadDatabaseCursors(0, 100); - when(threadDatabase.getPinnedConversationListCount()).thenReturn(4); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(104); + when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(4); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(104); testSubject.getTotalCount(); // WHEN Cursor cursor = testSubject.getCursor(50, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 50, 100); - verify(threadDatabase).getUnarchivedConversationList(false, 44, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 44, 100); assertEquals(100, cursor.getCount()); } @@ -239,23 +246,44 @@ public class UnarchivedConversationListDataSourceTest { public void givenHasArchivedAndLoadingLastPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() { // GIVEN setupThreadDatabaseCursors(0, 99); - when(threadDatabase.getPinnedConversationListCount()).thenReturn(4); - when(threadDatabase.getUnarchivedConversationListCount()).thenReturn(103); - when(threadDatabase.getArchivedConversationListCount()).thenReturn(12); + when(threadDatabase.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(4); + when(threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(103); + when(threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12); testSubject.getTotalCount(); // WHEN Cursor cursor = testSubject.getCursor(50, 100); // THEN - verify(threadDatabase).getUnarchivedConversationList(true, 50, 100); - verify(threadDatabase).getUnarchivedConversationList(false, 44, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100); + verify(threadDatabase).getUnarchivedConversationList(ConversationFilter.OFF, false, 44, 100); assertEquals(100, cursor.getCount()); cursor.moveToLast(); 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) { Cursor pinnedCursor = mock(Cursor.class); @@ -264,7 +292,7 @@ public class UnarchivedConversationListDataSourceTest { Cursor unpinnedCursor = mock(Cursor.class); when(unpinnedCursor.getCount()).thenReturn(unpinned); - when(threadDatabase.getUnarchivedConversationList(eq(true), anyLong(), anyLong())).thenReturn(pinnedCursor); - when(threadDatabase.getUnarchivedConversationList(eq(false), anyLong(), anyLong())).thenReturn(unpinnedCursor); + when(threadDatabase.getUnarchivedConversationList(any(), eq(true), anyLong(), anyLong())).thenReturn(pinnedCursor); + when(threadDatabase.getUnarchivedConversationList(any(), eq(false), anyLong(), anyLong())).thenReturn(unpinnedCursor); } } \ No newline at end of file