/* * Copyright (C) 2015 Open Whisper Systems * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.thoughtcrime.securesms.conversationlist; import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.FrameLayout; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.annotation.WorkerThread; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.TooltipCompat; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; import androidx.fragment.app.DialogFragment; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; 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.appbar.CollapsingToolbarLayout; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.snackbar.Snackbar; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.DimensionUnit; import org.signal.core.util.Stopwatch; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.MainFragment; import org.thoughtcrime.securesms.MainNavigator; import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.NewConversationActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.badges.self.expired.CantProcessSubscriptionPaymentBottomSheetDialogFragment; import org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment; import org.thoughtcrime.securesms.components.Material3SearchToolbar; import org.thoughtcrime.securesms.components.RatingManager; import org.thoughtcrime.securesms.components.SignalProgressDialog; import org.thoughtcrime.securesms.components.UnreadPaymentsView; import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar; import org.thoughtcrime.securesms.components.menu.SignalContextMenu; import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; import org.thoughtcrime.securesms.components.reminder.CdsPermanentErrorReminder; import org.thoughtcrime.securesms.components.reminder.CdsTemporyErrorReminder; import org.thoughtcrime.securesms.components.reminder.DozeReminder; import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; import org.thoughtcrime.securesms.components.reminder.OutdatedBuildReminder; import org.thoughtcrime.securesms.components.reminder.PushRegistrationReminder; import org.thoughtcrime.securesms.components.reminder.Reminder; import org.thoughtcrime.securesms.components.reminder.ReminderView; import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder; import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment; import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter; import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration; import org.thoughtcrime.securesms.contacts.paged.ContactSearchData; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator; import org.thoughtcrime.securesms.contacts.paged.ContactSearchState; import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet; import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet; import org.thoughtcrime.securesms.conversation.ConversationFragment; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView; import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp; import org.thoughtcrime.securesms.conversationlist.model.Conversation; import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter; import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments; import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs; import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder; import org.thoughtcrime.securesms.main.SearchBinder; import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity; import org.thoughtcrime.securesms.megaphone.Megaphone; import org.thoughtcrime.securesms.megaphone.MegaphoneActionController; import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder; import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.megaphone.SmsExportMegaphoneActivity; 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.permissions.Permissions; import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity; import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.search.MessageResult; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.stories.tabs.ConversationListTab; import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppStartup; import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.PlayStoreUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalProxyUtil; import org.thoughtcrime.securesms.util.SnapToTopDataObserver; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.WindowUtil; import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter; import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.thoughtcrime.securesms.util.views.Stub; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import kotlin.Unit; import static android.app.Activity.RESULT_CANCELED; import static android.app.Activity.RESULT_OK; public class ConversationListFragment extends MainFragment implements ActionMode.Callback, ConversationListAdapter.OnConversationClickListener, MegaphoneActionController, ClearFilterViewHolder.OnClearFilterClickListener { public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562; public static final short SMS_ROLE_REQUEST_CODE = 32563; private static final int LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD = 25; private static final String TAG = Log.tag(ConversationListFragment.class); private static final int MAXIMUM_PINNED_CONVERSATIONS = 4; private static final int MAX_CHATS_ABOVE_FOLD = 7; private static final int MAX_CONTACTS_ABOVE_FOLD = 5; private static final int MAX_GROUP_MEMBERSHIPS_ABOVE_FOLD = 5; private ActionMode actionMode; private View coordinator; private RecyclerView list; private Stub reminderView; private Stub paymentNotificationView; private PulsingFloatingActionButton fab; private PulsingFloatingActionButton cameraFab; private ConversationListFilterPullView pullView; private AppBarLayout pullViewAppBarLayout; private ConversationListViewModel viewModel; private RecyclerView.Adapter activeAdapter; private ConversationListAdapter defaultAdapter; private PagingMappingAdapter searchAdapter; private Stub megaphoneContainer; private SnapToTopDataObserver snapToTopDataObserver; private Drawable archiveDrawable; private AppForegroundObserver.Listener appForegroundObserver; private VoiceNoteMediaControllerOwner mediaControllerOwner; private Stub voiceNotePlayerViewStub; private VoiceNotePlayerView voiceNotePlayerView; private SignalBottomActionBar bottomActionBar; private SignalContextMenu activeContextMenu; private LifecycleDisposable lifecycleDisposable; protected ConversationListArchiveItemDecoration archiveDecoration; protected ConversationListItemAnimator itemAnimator; private Stopwatch startupStopwatch; private ConversationListTabsViewModel conversationListTabsViewModel; private ContactSearchMediator contactSearchMediator; public static ConversationListFragment newInstance() { return new ConversationListFragment(); } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); if (context instanceof VoiceNoteMediaControllerOwner) { mediaControllerOwner = (VoiceNoteMediaControllerOwner) context; } else { throw new ClassCastException("Expected context to be a Listener"); } } @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setHasOptionsMenu(true); startupStopwatch = new Stopwatch("startup"); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { return inflater.inflate(R.layout.conversation_list_fragment, container, false); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { coordinator = view.findViewById(R.id.coordinator); list = view.findViewById(R.id.list); bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar); reminderView = new Stub<>(view.findViewById(R.id.reminder)); 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)); fab = view.findViewById(R.id.fab); cameraFab = view.findViewById(R.id.camera_fab); pullView = view.findViewById(R.id.pull_view); pullViewAppBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar); fab.setVisibility(View.VISIBLE); cameraFab.setVisibility(View.VISIBLE); contactSearchMediator = new ContactSearchMediator(this, Collections.emptySet(), SelectionLimits.NO_LIMITS, false, ContactSearchAdapter.DisplaySmsTag.DEFAULT, ContactSearchAdapter.DisplaySecondaryInformation.NEVER, this::mapSearchStateToConfiguration, new ContactSearchMediator.SimpleCallbacks(), false, (context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks ) -> { //noinspection CodeBlock2Expr return new ConversationListSearchAdapter( context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, new ContactSearchClickCallbacks(callbacks), longClickCallbacks, storyContextMenuCallbacks, getViewLifecycleOwner(), GlideApp.with(this) ); }, new ConversationListSearchAdapter.ChatFilterRepository() ); searchAdapter = contactSearchMediator.getAdapter(); CollapsingToolbarLayout collapsingToolbarLayout = view.findViewById(R.id.collapsing_toolbar); int openHeight = (int) DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT); pullView.setOnFilterStateChanged((state, source) -> { switch (state) { case CLOSING: viewModel.setFiltered(false, source); break; case OPENING: ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight); viewModel.setFiltered(true, source); break; case OPEN_APEX: if (source == ConversationFilterSource.DRAG) { SignalStore.uiHints().incrementNeverDisplayPullToFilterTip(); } break; case CLOSE_APEX: ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0); break; } }); pullView.setOnCloseClicked(this::onClearFilterClick); ConversationFilterBehavior conversationFilterBehavior = Objects.requireNonNull((ConversationFilterBehavior) ((CoordinatorLayout.LayoutParams) pullViewAppBarLayout.getLayoutParams()).getBehavior()); conversationFilterBehavior.setCallback(new ConversationFilterBehavior.Callback() { @Override public void onStopNestedScroll() { pullView.onUserDragFinished(); } @Override public boolean canStartNestedScroll() { return !isSearchOpen() || pullView.isCloseable(); } }); pullViewAppBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { float progress = 1 - ((float) verticalOffset) / (-layout.getHeight()); pullView.onUserDrag(progress); }); fab.show(); cameraFab.show(); archiveDecoration = new ConversationListArchiveItemDecoration(new ColorDrawable(getResources().getColor(R.color.conversation_list_archive_background_end))); itemAnimator = new ConversationListItemAnimator(); list.setLayoutManager(new LinearLayoutManager(requireActivity())); list.setItemAnimator(itemAnimator); list.addItemDecoration(archiveDecoration); CachedInflater.from(list.getContext()).cacheUntilLimit(R.layout.conversation_list_item_view, list, 10); snapToTopDataObserver = new SnapToTopDataObserver(list); new ItemTouchHelper(new ArchiveListenerCallback(getResources().getColor(R.color.conversation_list_archive_background_start), getResources().getColor(R.color.conversation_list_archive_background_end))).attachToRecyclerView(list); fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class))); cameraFab.setOnClickListener(v -> { Permissions.with(this) .request(Manifest.permission.CAMERA) .ifNecessary() .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.symbol_camera_24) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) .onAllGranted(() -> startActivity(MediaSelectionActivity.camera(requireContext()))) .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show()) .execute(); }); initializeViewModel(); initializeListAdapters(); initializeTypingObserver(); initializeVoiceNotePlayer(); RatingManager.showRatingDialogIfNecessary(requireContext()); TooltipCompat.setTooltipText(requireCallback().getSearchAction(), getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages)); requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { if (!closeSearchIfOpen()) { if (!NavHostFragment.findNavController(ConversationListFragment.this).popBackStack()) { requireActivity().finish(); } } } }); lifecycleDisposable = new LifecycleDisposable(); conversationListTabsViewModel = new ViewModelProvider(requireActivity()).get(ConversationListTabsViewModel.class); lifecycleDisposable.bindTo(getViewLifecycleOwner()); lifecycleDisposable.add(conversationListTabsViewModel.getTabClickEvents().filter(tab -> tab == ConversationListTab.CHATS) .subscribe(unused -> { LinearLayoutManager layoutManager = (LinearLayoutManager) list.getLayoutManager(); int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition(); if (firstVisibleItemPosition <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) { list.smoothScrollToPosition(0); } else { list.scrollToPosition(0); } })); requireCallback().bindScrollHelper(list); } @Override public void onDestroyView() { coordinator = null; list = null; bottomActionBar = null; reminderView = null; megaphoneContainer = null; paymentNotificationView = null; voiceNotePlayerViewStub = null; fab = null; cameraFab = null; snapToTopDataObserver = null; itemAnimator = null; activeAdapter = null; defaultAdapter = null; searchAdapter = null; super.onDestroyView(); } @Override public void onResume() { super.onResume(); initializeSearchListener(); updateReminders(); EventBus.getDefault().register(this); itemAnimator.disable(); if (Util.isDefaultSmsProvider(requireContext())) { InsightsLauncher.showInsightsModal(requireContext(), requireFragmentManager()); } if ((!requireCallback().getSearchToolbar().resolved() || !(requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE)) && list.getAdapter() != defaultAdapter) { setAdapter(defaultAdapter); } if (activeAdapter != null) { activeAdapter.notifyItemRangeChanged(0, activeAdapter.getItemCount()); } SignalProxyUtil.startListeningToWebsocket(); if (SignalStore.rateLimit().needsRecaptcha()) { Log.i(TAG, "Recaptcha required."); RecaptchaProofBottomSheetFragment.show(getChildFragmentManager()); } Badge expiredBadge = SignalStore.donationsValues().getExpiredBadge(); String subscriptionCancellationReason = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationReason(); UnexpectedSubscriptionCancellation unexpectedSubscriptionCancellation = UnexpectedSubscriptionCancellation.fromStatus(subscriptionCancellationReason); boolean isDisplayingSubscriptionFailure = false; long subscriptionFailureTimestamp = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationTimestamp(); long subscriptionFailureWatermark = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationWatermark(); boolean isWatermarkPriorToTimestamp = subscriptionFailureWatermark < subscriptionFailureTimestamp; if (unexpectedSubscriptionCancellation != null && !SignalStore.donationsValues().isUserManuallyCancelled() && SignalStore.donationsValues().showCantProcessDialog() && isWatermarkPriorToTimestamp) { Log.w(TAG, "Displaying bottom sheet for unexpected cancellation: " + unexpectedSubscriptionCancellation, true); new CantProcessSubscriptionPaymentBottomSheetDialogFragment().show(getChildFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationWatermark(subscriptionFailureTimestamp); isDisplayingSubscriptionFailure = true; } else if (unexpectedSubscriptionCancellation != null && SignalStore.donationsValues().isUserManuallyCancelled()) { Log.w(TAG, "Unexpected cancellation detected but not displaying dialog because user manually cancelled their subscription: " + unexpectedSubscriptionCancellation, true); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationWatermark(subscriptionFailureTimestamp); } else if (unexpectedSubscriptionCancellation != null && !SignalStore.donationsValues().showCantProcessDialog()) { Log.w(TAG, "Unexpected cancellation detected but not displaying dialog because user has silenced it.", true); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationWatermark(subscriptionFailureTimestamp); } if (expiredBadge != null && !isDisplayingSubscriptionFailure) { SignalStore.donationsValues().setExpiredBadge(null); if (expiredBadge.isBoost() || !SignalStore.donationsValues().isUserManuallyCancelled()) { Log.w(TAG, "Displaying bottom sheet for an expired badge", true); ExpiredBadgeBottomSheetDialogFragment.show( expiredBadge, unexpectedSubscriptionCancellation, SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure(), getParentFragmentManager()); } } } @Override public void onStart() { super.onStart(); ApplicationDependencies.getAppForegroundObserver().addListener(appForegroundObserver); itemAnimator.disable(); } @Override public void onPause() { super.onPause(); requireCallback().getSearchAction().setOnClickListener(null); fab.stopPulse(); cameraFab.stopPulse(); EventBus.getDefault().unregister(this); } @Override public void onStop() { super.onStop(); ApplicationDependencies.getAppForegroundObserver().removeListener(appForegroundObserver); } @Override public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { menu.clear(); inflater.inflate(R.menu.text_secure_normal, menu); } @Override public void onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.menu_insights).setVisible(Util.isDefaultSmsProvider(requireContext())); menu.findItem(R.id.menu_clear_passphrase).setVisible(!TextSecurePreferences.isPasswordDisabled(requireContext())); ConversationFilterRequest request = viewModel.getConversationFilterRequest().getValue(); boolean isChatFilterEnabled = request != null && request.getFilter() == ConversationFilter.UNREAD; menu.findItem(R.id.menu_filter_unread_chats).setVisible(FeatureFlags.chatFilters() && !isChatFilterEnabled); menu.findItem(R.id.menu_clear_unread_filter).setVisible(FeatureFlags.chatFilters() && isChatFilterEnabled); } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { super.onOptionsItemSelected(item); int itemId = item.getItemId(); if (itemId == R.id.menu_new_group) { handleCreateGroup(); return true; } else if (itemId == R.id.menu_settings) { handleDisplaySettings(); return true; } else if (itemId == R.id.menu_clear_passphrase) { handleClearPassphrase(); return true; } else if (itemId == R.id.menu_mark_all_read) { handleMarkAllRead(); return true; } else if (itemId == R.id.menu_invite) { handleInvite(); return true; } else if (itemId == R.id.menu_insights) { handleInsights(); return true; } else if (itemId == R.id.menu_notification_profile) { handleNotificationProfile(); return true; } else if (itemId == R.id.menu_filter_unread_chats) { handleFilterUnreadChats(); return true; } else if (itemId == R.id.menu_clear_unread_filter) { onClearFilterClick(); return true; } else { return false; } } @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); onMegaphoneChanged(viewModel.getMegaphone().getValue()); } private ContactSearchConfiguration mapSearchStateToConfiguration(@NonNull ContactSearchState state) { if (TextUtils.isEmpty(state.getQuery())) { return ContactSearchConfiguration.build(b -> Unit.INSTANCE); } else { return ContactSearchConfiguration.build(builder -> { ConversationFilterRequest conversationFilterRequest = state.getConversationFilterRequest(); boolean unreadOnly = conversationFilterRequest != null && conversationFilterRequest.getFilter() == ConversationFilter.UNREAD; builder.setQuery(state.getQuery()); builder.addSection(new ContactSearchConfiguration.Section.Chats( unreadOnly, true, new ContactSearchConfiguration.ExpandConfig( state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.CHATS), (a) -> MAX_CHATS_ABOVE_FOLD ) )); if (!unreadOnly) { builder.addSection(new ContactSearchConfiguration.Section.GroupsWithMembers( true, new ContactSearchConfiguration.ExpandConfig( state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS), (a) -> MAX_GROUP_MEMBERSHIPS_ABOVE_FOLD ) )); builder.addSection(new ContactSearchConfiguration.Section.ContactsWithoutThreads( true, new ContactSearchConfiguration.ExpandConfig( state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS), (a) -> MAX_CONTACTS_ABOVE_FOLD ) )); builder.addSection(new ContactSearchConfiguration.Section.Messages( true, null )); builder.setHasEmptyState(true); } else { builder.arbitrary( conversationFilterRequest.getSource() == ConversationFilterSource.DRAG ? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode() : ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode() ); } return Unit.INSTANCE; }); } } private boolean isSearchOpen() { return isSearchVisible() || activeAdapter == searchAdapter; } private boolean isSearchVisible() { return (requireCallback().getSearchToolbar().resolved() && requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE); } private boolean closeSearchIfOpen() { if (isSearchOpen()) { setAdapter(defaultAdapter); requireCallback().getSearchToolbar().get().collapse(); requireCallback().onSearchClosed(); return true; } return false; } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { if (requestCode == SmsExportMegaphoneActivity.REQUEST_CODE && SignalStore.misc().getSmsExportPhase().isFullscreen()) { ApplicationDependencies.getMegaphoneRepository().markSeen(Megaphones.Event.SMS_EXPORT); if (resultCode == RESULT_CANCELED) { Snackbar.make(fab, R.string.ConversationActivity__you_will_be_reminded_again_soon, Snackbar.LENGTH_LONG).show(); } else { SmsExportDialogs.showSmsRemovalDialog(requireContext(), fab); } } if (resultCode == RESULT_OK && requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN) { Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show(); viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL); } } private void onConversationClicked(@NonNull ThreadRecord threadRecord) { hideKeyboard(); getNavigator().goToConversation(threadRecord.getRecipient().getId(), threadRecord.getThreadId(), threadRecord.getDistributionType(), -1); } @Override public void onShowArchiveClick() { if (viewModel.currentSelectedConversations().isEmpty()) { NavHostFragment.findNavController(this) .navigate(ConversationListFragmentDirections.actionConversationListFragmentToConversationListArchiveFragment()); } } private void onContactClicked(@NonNull Recipient contact) { SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { return SignalDatabase.threads().getThreadIdIfExistsFor(contact.getId()); }, threadId -> { hideKeyboard(); getNavigator().goToConversation(contact.getId(), threadId, ThreadTable.DistributionTypes.DEFAULT, -1); }); } private void onMessageClicked(@NonNull MessageResult message) { SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { int startingPosition = SignalDatabase.messages().getMessagePositionInConversation(message.getThreadId(), message.getReceivedTimestampMs()); return Math.max(0, startingPosition); }, startingPosition -> { hideKeyboard(); getNavigator().goToConversation(message.getConversationRecipient().getId(), message.getThreadId(), ThreadTable.DistributionTypes.DEFAULT, startingPosition); }); } @Override public void onMegaphoneNavigationRequested(@NonNull Intent intent) { startActivity(intent); } @Override public void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode) { startActivityForResult(intent, requestCode); } @Override public void onMegaphoneToastRequested(@NonNull String string) { Snackbar.make(fab, string, Snackbar.LENGTH_LONG).show(); } @Override public @NonNull Activity getMegaphoneActivity() { return requireActivity(); } @Override public void onMegaphoneSnooze(@NonNull Megaphones.Event event) { viewModel.onMegaphoneSnoozed(event); } @Override public void onMegaphoneCompleted(@NonNull Megaphones.Event event) { viewModel.onMegaphoneCompleted(event); } @Override public void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment) { dialogFragment.show(getChildFragmentManager(), "megaphone_dialog"); } private void initializeReminderView() { reminderView.get().setOnDismissListener(this::updateReminders); reminderView.get().setOnActionClickListener(this::onReminderAction); } private void onReminderAction(@IdRes int reminderActionId) { if (reminderActionId == R.id.reminder_action_update_now) { PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()); } else if (reminderActionId == R.id.reminder_action_cds_temporary_error_learn_more) { CdsTemporaryErrorBottomSheet.show(getChildFragmentManager()); } else if (reminderActionId == R.id.reminder_action_cds_permanent_error_learn_more) { CdsPermanentErrorBottomSheet.show(getChildFragmentManager()); } else if (reminderActionId == R.id.reminder_action_fix_username) { startActivity(ManageProfileActivity.getIntentForUsernameEdit(requireContext())); } } private void hideKeyboard() { InputMethodManager imm = ServiceUtil.getInputMethodManager(requireContext()); imm.hideSoftInputFromWindow(requireView().getWindowToken(), 0); } private void initializeSearchListener() { viewModel.getConversationFilterRequest().observe(getViewLifecycleOwner(), this::updateSearchToolbarHint); viewModel.getConversationFilterRequest().observe(getViewLifecycleOwner(), contactSearchMediator::onConversationFilterRequestChanged); requireCallback().getSearchAction().setOnClickListener(v -> { fadeOutButtonsAndMegaphone(250); requireCallback().onSearchOpened(); requireCallback().getSearchToolbar().get().setListener(new Material3SearchToolbar.Listener() { @Override public void onSearchTextChange(String text) { String trimmed = text.trim(); contactSearchMediator.onFilterChanged(trimmed); if (trimmed.length() > 0) { if (activeAdapter != searchAdapter && list != null) { setAdapter(searchAdapter); } } else { if (activeAdapter != defaultAdapter) { if (list != null) { setAdapter(defaultAdapter); } } } } @Override public void onSearchClosed() { if (list != null) { setAdapter(defaultAdapter); } requireCallback().onSearchClosed(); fadeInButtonsAndMegaphone(250); } }); updateSearchToolbarHint(Objects.requireNonNull(viewModel.getConversationFilterRequest().getValue())); }); } private void updateSearchToolbarHint(@NonNull ConversationFilterRequest conversationFilterRequest) { requireCallback().getSearchToolbar().get().setSearchInputHint( conversationFilterRequest.getFilter() == ConversationFilter.OFF ? R.string.SearchToolbar_search : R.string.SearchToolbar_search_unread_chats ); } private void initializeVoiceNotePlayer() { mediaControllerOwner.getVoiceNoteMediaController().getVoiceNotePlayerViewState().observe(getViewLifecycleOwner(), state -> { if (state.isPresent()) { requireVoiceNotePlayerView().setState(state.get()); requireVoiceNotePlayerView().show(); } else if (voiceNotePlayerViewStub.resolved()) { requireVoiceNotePlayerView().hide(); } }); } private @NonNull VoiceNotePlayerView requireVoiceNotePlayerView() { if (voiceNotePlayerView == null) { voiceNotePlayerView = voiceNotePlayerViewStub.get().findViewById(R.id.voice_note_player_view); voiceNotePlayerView.setListener(new VoiceNotePlayerViewListener()); } return voiceNotePlayerView; } private void initializeListAdapters() { defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, this); setAdapter(defaultAdapter); defaultAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { @Override public void onItemRangeInserted(int positionStart, int itemCount) { startupStopwatch.split("data-set"); SignalLocalMetrics.ColdStart.onConversationListDataLoaded(); defaultAdapter.unregisterAdapterDataObserver(this); list.post(() -> { AppStartup.getInstance().onCriticalRenderEventEnd(); startupStopwatch.split("first-render"); startupStopwatch.stop(TAG); mediaControllerOwner.getVoiceNoteMediaController().finishPostpone(); if (getContext() != null) { ConversationFragment.prepare(getContext()); } }); } }); } @SuppressWarnings("rawtypes") private void setAdapter(@NonNull RecyclerView.Adapter adapter) { RecyclerView.Adapter oldAdapter = activeAdapter; activeAdapter = adapter; if (oldAdapter == activeAdapter) { return; } if (adapter instanceof ConversationListAdapter) { viewModel.getPagingController() .observe(getViewLifecycleOwner(), controller -> ((ConversationListAdapter) adapter).setPagingController(controller)); } list.setAdapter(adapter); if (adapter == defaultAdapter) { defaultAdapter.registerAdapterDataObserver(snapToTopDataObserver); } else { defaultAdapter.unregisterAdapterDataObserver(snapToTopDataObserver); } } private void initializeTypingObserver() { ApplicationDependencies.getTypingStatusRepository().getTypingThreads().observe(getViewLifecycleOwner(), threadIds -> { if (threadIds == null) { threadIds = Collections.emptySet(); } defaultAdapter.setTypingThreads(threadIds); }); } protected boolean isArchived() { return false; } private void initializeViewModel() { ConversationListViewModel.Factory viewModelFactory = new ConversationListViewModel.Factory(isArchived()); viewModel = new ViewModelProvider(this, (ViewModelProvider.Factory) viewModelFactory).get(ConversationListViewModel.class); viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged); viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onConversationListChanged); viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState); viewModel.getNotificationProfiles().observe(getViewLifecycleOwner(), profiles -> requireCallback().updateNotificationProfileStatus(profiles)); viewModel.getPipeState().observe(getViewLifecycleOwner(), pipeState -> requireCallback().updateProxyStatus(pipeState)); appForegroundObserver = new AppForegroundObserver.Listener() { @Override public void onForeground() { viewModel.onVisible(); } @Override public void onBackground() {} }; viewModel.getUnreadPaymentsLiveData().observe(getViewLifecycleOwner(), this::onUnreadPaymentsChanged); viewModel.getSelectedConversations().observe(getViewLifecycleOwner(), conversations -> { defaultAdapter.setSelectedConversations(conversations); updateMultiSelectState(); }); } private void onConversationListChanged(@NonNull List conversations) { LinearLayoutManager layoutManager = (LinearLayoutManager) list.getLayoutManager(); int firstVisibleItem = layoutManager != null ? layoutManager.findFirstCompletelyVisibleItemPosition() : -1; defaultAdapter.submitList(conversations, () -> { if (list == null) { return; } if (firstVisibleItem == 0) { list.scrollToPosition(0); } onPostSubmitList(conversations.size()); }); } private void onUnreadPaymentsChanged(@NonNull Optional unreadPayments) { if (unreadPayments.isPresent()) { paymentNotificationView.get().setListener(new PaymentNotificationListener(unreadPayments.get())); paymentNotificationView.get().setUnreadPayments(unreadPayments.get()); animatePaymentUnreadStatusIn(); } else { animatePaymentUnreadStatusOut(); } } private void animatePaymentUnreadStatusIn() { paymentNotificationView.get().setVisibility(View.VISIBLE); requireCallback().getUnreadPaymentsDot().animate().alpha(1); } private void animatePaymentUnreadStatusOut() { if (paymentNotificationView.resolved()) { paymentNotificationView.get().setVisibility(View.GONE); } requireCallback().getUnreadPaymentsDot().animate().alpha(0); } private void onMegaphoneChanged(@Nullable Megaphone megaphone) { if (megaphone == null || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { if (megaphoneContainer.resolved()) { megaphoneContainer.get().setVisibility(View.GONE); megaphoneContainer.get().removeAllViews(); } return; } View view = MegaphoneViewBuilder.build(requireContext(), megaphone, this); megaphoneContainer.get().removeAllViews(); if (view != null) { megaphoneContainer.get().addView(view); if (isSearchOpen() || actionMode != null) { megaphoneContainer.get().setVisibility(View.GONE); } else { megaphoneContainer.get().setVisibility(View.VISIBLE); } } else { megaphoneContainer.get().setVisibility(View.GONE); if (megaphone.getOnVisibleListener() != null) { megaphone.getOnVisibleListener().onEvent(megaphone, this); } } viewModel.onMegaphoneVisible(megaphone); } private void updateReminders() { Context context = requireContext(); SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { if (UnauthorizedReminder.isEligible(context)) { return Optional.of(new UnauthorizedReminder(context)); } else if (ExpiredBuildReminder.isEligible()) { return Optional.of(new ExpiredBuildReminder(context)); } else if (ServiceOutageReminder.isEligible(context)) { ApplicationDependencies.getJobManager().add(new ServiceOutageDetectionJob()); return Optional.of(new ServiceOutageReminder(context)); } else if (OutdatedBuildReminder.isEligible()) { return Optional.of(new OutdatedBuildReminder(context)); } else if (PushRegistrationReminder.isEligible(context)) { return Optional.of((new PushRegistrationReminder(context))); } else if (DozeReminder.isEligible(context)) { return Optional.of(new DozeReminder(context)); } else if (CdsTemporyErrorReminder.isEligible()) { return Optional.of(new CdsTemporyErrorReminder(context)); } else if (CdsPermanentErrorReminder.isEligible()) { return Optional.of(new CdsPermanentErrorReminder(context)); } else if (UsernameOutOfSyncReminder.isEligible()) { return Optional.of(new UsernameOutOfSyncReminder(context)); } else { return Optional.empty(); } }, reminder -> { if (reminder.isPresent() && getActivity() != null && !isRemoving()) { if (!reminderView.resolved()) { initializeReminderView(); } reminderView.get().showReminder(reminder.get()); } else if (reminderView.resolved() && !reminder.isPresent()) { reminderView.get().hide(); } }); } private void handleCreateGroup() { getNavigator().goToGroupCreation(); } private void handleDisplaySettings() { getNavigator().goToAppSettings(); } private void handleClearPassphrase() { Intent intent = new Intent(requireActivity(), KeyCachingService.class); intent.setAction(KeyCachingService.CLEAR_KEY_ACTION); requireActivity().startService(intent); } private void handleMarkAllRead() { Context context = requireContext(); SignalExecutors.BOUNDED.execute(() -> { List messageIds = SignalDatabase.threads().setAllThreadsRead(); ApplicationDependencies.getMessageNotifier().updateNotification(context); MarkReadReceiver.process(context, messageIds); }); } private void handleMarkAsRead(@NonNull Collection ids) { Context context = requireContext(); Stopwatch stopwatch = new Stopwatch("mark-read"); SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { stopwatch.split("task-start"); List messageIds = SignalDatabase.threads().setRead(ids, false); stopwatch.split("db"); ApplicationDependencies.getMessageNotifier().updateNotification(context); stopwatch.split("notification"); MarkReadReceiver.process(context, messageIds); stopwatch.split("process"); return null; }, none -> { endActionModeIfActive(); stopwatch.stop(TAG); }); } private void handleMarkAsUnread(@NonNull Collection ids) { Context context = requireContext(); SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { SignalDatabase.threads().setForcedUnread(ids); StorageSyncHelper.scheduleSyncForDataChange(); return null; }, none -> { endActionModeIfActive(); }); } private void handleInvite() { getNavigator().goToInvite(); } private void handleInsights() { getNavigator().goToInsights(); } private void handleNotificationProfile() { NotificationProfileSelectionFragment.show(getParentFragmentManager()); } private void handleFilterUnreadChats() { pullView.toggle(); pullViewAppBarLayout.setExpanded(false, true); } @SuppressLint("StaticFieldLeak") private void handleArchive(@NonNull Collection ids, boolean showProgress) { Set selectedConversations = new HashSet<>(ids); int count = selectedConversations.size(); String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count); new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), coordinator, snackBarTitle, getString(R.string.ConversationListFragment_undo), getResources().getColor(R.color.amber_500), Snackbar.LENGTH_LONG, showProgress) { @Override protected void onPostExecute(Void result) { super.onPostExecute(result); endActionModeIfActive(); } @Override protected void executeAction(@Nullable Void parameter) { archiveThreads(selectedConversations); } @Override protected void reverseAction(@Nullable Void parameter) { reverseArchiveThreads(selectedConversations); } }.executeOnExecutor(SignalExecutors.BOUNDED); } @SuppressLint("StaticFieldLeak") private void handleDelete(@NonNull Collection ids) { int conversationsCount = ids.size(); MaterialAlertDialogBuilder alert = new MaterialAlertDialogBuilder(requireActivity()); Context context = requireContext(); alert.setTitle(context.getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations, conversationsCount, conversationsCount)); alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations, conversationsCount, conversationsCount)); alert.setCancelable(true); alert.setPositiveButton(R.string.delete, (dialog, which) -> { final Set selectedConversations = new HashSet<>(ids); if (!selectedConversations.isEmpty()) { new AsyncTask() { private SignalProgressDialog dialog; @Override protected void onPreExecute() { dialog = SignalProgressDialog.show(requireActivity(), context.getString(R.string.ConversationListFragment_deleting), context.getString(R.string.ConversationListFragment_deleting_selected_conversations), true, false); } @Override protected Void doInBackground(Void... params) { SignalDatabase.threads().deleteConversations(selectedConversations); ApplicationDependencies.getMessageNotifier().updateNotification(requireActivity()); return null; } @Override protected void onPostExecute(Void result) { dialog.dismiss(); endActionModeIfActive(); } }.executeOnExecutor(SignalExecutors.BOUNDED); } }); alert.setNegativeButton(android.R.string.cancel, null); alert.show(); } private void handlePin(@NonNull Collection conversations) { final Set toPin = new LinkedHashSet<>(Stream.of(conversations) .filterNot(conversation -> conversation.getThreadRecord().isPinned()) .map(conversation -> conversation.getThreadRecord().getThreadId()) .toList()); if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) { Snackbar.make(fab, getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), Snackbar.LENGTH_LONG) .show(); endActionModeIfActive(); return; } SimpleTask.run(SignalExecutors.BOUNDED, () -> { ThreadTable db = SignalDatabase.threads(); db.pinConversations(toPin); ConversationUtil.refreshRecipientShortcuts(); return null; }, unused -> { endActionModeIfActive(); }); } private void handleUnpin(@NonNull Collection ids) { SimpleTask.run(SignalExecutors.BOUNDED, () -> { ThreadTable db = SignalDatabase.threads(); db.unpinConversations(ids); ConversationUtil.refreshRecipientShortcuts(); return null; }, unused -> { endActionModeIfActive(); }); } private void handleMute(@NonNull Collection conversations) { MuteDialog.show(requireContext(), until -> { updateMute(conversations, until); }); } private void handleUnmute(@NonNull Collection conversations) { updateMute(conversations, 0); } private void updateMute(@NonNull Collection conversations, long until) { SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(requireContext(), 250, 250); SimpleTask.run(SignalExecutors.BOUNDED, () -> { List recipientIds = conversations.stream() .map(conversation -> conversation.getThreadRecord().getRecipient().live().get()) .filter(r -> r.getMuteUntil() != until) .map(Recipient::getId) .collect(Collectors.toList()); SignalDatabase.recipients().setMuted(recipientIds, until); return null; }, unused -> { endActionModeIfActive(); dialog.dismiss(); }); } private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) { SimpleTask.run(getLifecycle(), () -> { ChatWallpaper wallpaper = recipient.resolve().getWallpaper(); if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) { Log.w(TAG, "Failed to prefetch wallpaper."); } return null; }, (nothing) -> { getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1); }); } private void fadeOutButtonsAndMegaphone(int fadeDuration) { if (fab != null) { ViewUtil.fadeOut(fab, fadeDuration); } if (cameraFab != null) { ViewUtil.fadeOut(cameraFab, fadeDuration); } if (megaphoneContainer.resolved()) { ViewUtil.fadeOut(megaphoneContainer.get(), fadeDuration); } } private void fadeInButtonsAndMegaphone(int fadeDuration) { if (fab != null) { ViewUtil.fadeIn(fab, fadeDuration); } if (cameraFab != null) { ViewUtil.fadeIn(cameraFab, fadeDuration); } if (megaphoneContainer.resolved()) { ViewUtil.fadeIn(megaphoneContainer.get(), fadeDuration); } } private void startActionMode() { actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this); ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation()); ViewUtil.fadeOut(fab, 250); ViewUtil.fadeOut(cameraFab, 250); if (megaphoneContainer.resolved()) { ViewUtil.fadeOut(megaphoneContainer.get(), 250); } requireCallback().onMultiSelectStarted(); } private void endActionModeIfActive() { if (actionMode != null) { endActionMode(); } } private void endActionMode() { actionMode.finish(); actionMode = null; ViewUtil.animateOut(bottomActionBar, bottomActionBar.getExitAnimation()); ViewUtil.fadeIn(fab, 250); ViewUtil.fadeIn(cameraFab, 250); if (megaphoneContainer.resolved()) { ViewUtil.fadeIn(megaphoneContainer.get(), 250); } requireCallback().onMultiSelectFinished(); } void updateEmptyState(boolean isConversationEmpty) { if (isConversationEmpty) { Log.i(TAG, "Received an empty data set."); fab.startPulse(3 * 1000); cameraFab.startPulse(3 * 1000); SignalStore.onboarding().setShowNewGroup(true); SignalStore.onboarding().setShowInviteFriends(true); } else { fab.stopPulse(); cameraFab.stopPulse(); } } protected void onPostSubmitList(int conversationCount) { if (conversationCount >= 6 && (SignalStore.onboarding().shouldShowInviteFriends() || SignalStore.onboarding().shouldShowNewGroup())) { SignalStore.onboarding().clearAll(); ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.ONBOARDING); } } @Override public void onConversationClick(@NonNull Conversation conversation) { if (actionMode == null) { handleCreateConversation(conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient(), conversation.getThreadRecord().getDistributionType()); } else { viewModel.toggleConversationSelected(conversation); if (viewModel.currentSelectedConversations().isEmpty()) { endActionModeIfActive(); } else { updateMultiSelectState(); } } } @Override public boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view) { if (actionMode != null) { onConversationClick(conversation); return true; } if (activeContextMenu != null) { Log.w(TAG, "Already showing a context menu."); return true; } view.setSelected(true); Collection id = Collections.singleton(conversation.getThreadRecord().getThreadId()); List items = new ArrayList<>(); if (!conversation.getThreadRecord().isArchived()) { if (conversation.getThreadRecord().isRead()) { items.add(new ActionItem(R.drawable.symbol_chat_badge_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unread_plural, 1), () -> handleMarkAsUnread(id))); } else { items.add(new ActionItem(R.drawable.symbol_chat_24, getResources().getQuantityString(R.plurals.ConversationListFragment_read_plural, 1), () -> handleMarkAsRead(id))); } if (conversation.getThreadRecord().isPinned()) { items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unpin_plural, 1), () -> handleUnpin(id))); } else { items.add(new ActionItem(R.drawable.symbol_pin_24, getResources().getQuantityString(R.plurals.ConversationListFragment_pin_plural, 1), () -> handlePin(Collections.singleton(conversation)))); } if (conversation.getThreadRecord().getRecipient().live().get().isMuted()) { items.add(new ActionItem(R.drawable.symbol_bell_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unmute_plural, 1), () -> handleUnmute(Collections.singleton(conversation)))); } else { items.add(new ActionItem(R.drawable.symbol_bell_slash_24, getResources().getQuantityString(R.plurals.ConversationListFragment_mute_plural, 1), () -> handleMute(Collections.singleton(conversation)))); } } items.add(new ActionItem(R.drawable.symbol_check_circle_24, getString(R.string.ConversationListFragment_select), () -> { viewModel.startSelection(conversation); startActionMode(); })); if (conversation.getThreadRecord().isArchived()) { items.add(new ActionItem(R.drawable.symbol_archive_android_up_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unarchive_plural, 1), () -> handleArchive(id, false))); } else { items.add(new ActionItem(R.drawable.symbol_archive_android_24, getResources().getQuantityString(R.plurals.ConversationListFragment_archive_plural, 1), () -> handleArchive(id, false))); } items.add(new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.ConversationListFragment_delete_plural, 1), () -> handleDelete(id))); activeContextMenu = new SignalContextMenu.Builder(view, list) .offsetX(ViewUtil.dpToPx(12)) .offsetY(ViewUtil.dpToPx(12)) .onDismiss(() -> { activeContextMenu = null; view.setSelected(false); list.suppressLayout(false); }) .show(items); list.suppressLayout(true); return true; } @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { mode.setTitle(requireContext().getResources().getQuantityString(R.plurals.ConversationListFragment_s_selected, 1, 1)); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { updateMultiSelectState(); return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return true; } @Override public void onDestroyActionMode(ActionMode mode) { viewModel.endSelection(); TypedArray color = getActivity().getTheme().obtainStyledAttributes(new int[] { android.R.attr.statusBarColor }); WindowUtil.setStatusBarColor(getActivity().getWindow(), color.getColor(0, Color.BLACK)); color.recycle(); if (Build.VERSION.SDK_INT >= 23) { TypedArray lightStatusBarAttr = getActivity().getTheme().obtainStyledAttributes(new int[] { android.R.attr.windowLightStatusBar }); int current = getActivity().getWindow().getDecorView().getSystemUiVisibility(); int statusBarMode = lightStatusBarAttr.getBoolean(0, false) ? current | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR : current & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; getActivity().getWindow().getDecorView().setSystemUiVisibility(statusBarMode); lightStatusBarAttr.recycle(); } endActionModeIfActive(); } @Subscribe(threadMode = ThreadMode.MAIN) public void onEvent(ReminderUpdateEvent event) { updateReminders(); } @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) public void onEvent(MessageSender.MessageSentEvent event) { EventBus.getDefault().removeStickyEvent(event); closeSearchIfOpen(); } private void updateMultiSelectState() { int count = viewModel.currentSelectedConversations().size(); boolean hasUnread = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().isRead()); boolean hasUnpinned = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().isPinned()); boolean hasUnmuted = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().getRecipient().live().get().isMuted()); boolean canPin = viewModel.getPinnedCount() < MAXIMUM_PINNED_CONVERSATIONS; if (actionMode != null) { actionMode.setTitle(requireContext().getResources().getQuantityString(R.plurals.ConversationListFragment_s_selected, count, count)); } List items = new ArrayList<>(); Set selectionIds = viewModel.currentSelectedConversations() .stream() .map(conversation -> conversation.getThreadRecord().getThreadId()) .collect(Collectors.toSet()); if (hasUnread) { items.add(new ActionItem(R.drawable.symbol_chat_24, getResources().getQuantityString(R.plurals.ConversationListFragment_read_plural, count), () -> handleMarkAsRead(selectionIds))); } else { items.add(new ActionItem(R.drawable.symbol_chat_badge_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unread_plural, count), () -> handleMarkAsUnread(selectionIds))); } if (!isArchived() && hasUnpinned && canPin) { items.add(new ActionItem(R.drawable.symbol_pin_24, getResources().getQuantityString(R.plurals.ConversationListFragment_pin_plural, count), () -> handlePin(viewModel.currentSelectedConversations()))); } else if (!isArchived() && !hasUnpinned) { items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unpin_plural, count), () -> handleUnpin(selectionIds))); } if (isArchived()) { items.add(new ActionItem(R.drawable.symbol_archive_android_up_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unarchive_plural, count), () -> handleArchive(selectionIds, true))); } else { items.add(new ActionItem(R.drawable.symbol_archive_android_24, getResources().getQuantityString(R.plurals.ConversationListFragment_archive_plural, count), () -> handleArchive(selectionIds, true))); } items.add(new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.ConversationListFragment_delete_plural, count), () -> handleDelete(selectionIds))); if (hasUnmuted) { items.add(new ActionItem(R.drawable.symbol_bell_slash_24, getResources().getQuantityString(R.plurals.ConversationListFragment_mute_plural, count), () -> handleMute(viewModel.currentSelectedConversations()))); } else { items.add(new ActionItem(R.drawable.symbol_bell_24, getResources().getQuantityString(R.plurals.ConversationListFragment_unmute_plural, count), () -> handleUnmute(viewModel.currentSelectedConversations()))); } items.add(new ActionItem(R.drawable.symbol_check_circle_24, getString(R.string.ConversationListFragment_select_all), viewModel::onSelectAllClick)); bottomActionBar.setItems(items); } protected Callback requireCallback() { return ((Callback) getParentFragment().getParentFragment()); } protected Toolbar getToolbar(@NonNull View rootView) { return requireCallback().getToolbar(); } protected @PluralsRes int getArchivedSnackbarTitleRes() { return R.plurals.ConversationListFragment_conversations_archived; } protected @DrawableRes int getArchiveIconRes() { return R.drawable.symbol_archive_android_24; } @WorkerThread protected void archiveThreads(Set threadIds) { SignalDatabase.threads().setArchived(threadIds, true); } @WorkerThread protected void reverseArchiveThreads(Set threadIds) { SignalDatabase.threads().setArchived(threadIds, false); } @SuppressLint("StaticFieldLeak") protected void onItemSwiped(long threadId, int unreadCount, int unreadSelfMentionsCount) { archiveDecoration.onArchiveStarted(); itemAnimator.enable(); new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), coordinator, getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1), getString(R.string.ConversationListFragment_undo), getResources().getColor(R.color.amber_500), Snackbar.LENGTH_LONG, false) { private final ThreadTable threadTable = SignalDatabase.threads(); private List pinnedThreadIds; @Override protected void executeAction(@Nullable Long parameter) { Context context = requireActivity(); pinnedThreadIds = threadTable.getPinnedThreadIds(); threadTable.archiveConversation(threadId); if (unreadCount > 0) { List messageIds = threadTable.setRead(threadId, false); ApplicationDependencies.getMessageNotifier().updateNotification(context); MarkReadReceiver.process(context, messageIds); } ConversationUtil.refreshRecipientShortcuts(); } @Override protected void reverseAction(@Nullable Long parameter) { Context context = requireActivity(); threadTable.unarchiveConversation(threadId); threadTable.restorePins(pinnedThreadIds); if (unreadCount > 0) { threadTable.incrementUnread(threadId, unreadCount, unreadSelfMentionsCount); ApplicationDependencies.getMessageNotifier().updateNotification(context); } ConversationUtil.refreshRecipientShortcuts(); } }.executeOnExecutor(SignalExecutors.BOUNDED, threadId); } @Override public void onClearFilterClick() { pullView.toggle(); pullViewAppBarLayout.setExpanded(false, true); } private class PaymentNotificationListener implements UnreadPaymentsView.Listener { private final UnreadPayments unreadPayments; private PaymentNotificationListener(@NonNull UnreadPayments unreadPayments) { this.unreadPayments = unreadPayments; } @Override public void onOpenPaymentsNotificationClicked() { UUID paymentId = unreadPayments.getPaymentUuid(); if (paymentId == null) { goToPaymentsHome(); } else { goToSinglePayment(paymentId); } } @Override public void onClosePaymentsNotificationClicked() { viewModel.onUnreadPaymentsClosed(); } private void goToPaymentsHome() { startActivity(new Intent(requireContext(), PaymentsActivity.class)); } private void goToSinglePayment(@NonNull UUID paymentId) { startActivity(PaymentsActivity.navigateToPaymentDetails(requireContext(), paymentId)); } } private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback { private static final long SWIPE_ANIMATION_DURATION = 175; private static final float MIN_ICON_SCALE = 0.85f; private static final float MAX_ICON_SCALE = 1f; private final int archiveColorStart; private final int archiveColorEnd; private final float ESCAPE_VELOCITY = ViewUtil.dpToPx(1000); private final float VELOCITY_THRESHOLD = ViewUtil.dpToPx(1000); private WeakReference lastTouched; ArchiveListenerCallback(@ColorInt int archiveColorStart, @ColorInt int archiveColorEnd) { super(0, ItemTouchHelper.END); this.archiveColorStart = archiveColorStart; this.archiveColorEnd = archiveColorEnd; } @Override public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { return false; } @Override public float getSwipeEscapeVelocity(float defaultValue) { return Math.min(ESCAPE_VELOCITY, VELOCITY_THRESHOLD); } @Override public float getSwipeVelocityThreshold(float defaultValue) { return VELOCITY_THRESHOLD; } @Override public int getSwipeDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { if (viewHolder.itemView instanceof ConversationListItemAction || viewHolder instanceof ConversationListAdapter.HeaderViewHolder || viewHolder instanceof ClearFilterViewHolder || actionMode != null || viewHolder.itemView.isSelected() || activeAdapter == searchAdapter) { return 0; } lastTouched = new WeakReference<>(viewHolder); return super.getSwipeDirs(recyclerView, viewHolder); } @Override public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { if (lastTouched != null) { Log.w(TAG, "Falling back to slower onSwiped() event."); onTrueSwipe(viewHolder); lastTouched = null; } } @Override public long getAnimationDuration(@NonNull RecyclerView recyclerView, int animationType, float animateDx, float animateDy) { if (animationType == ItemTouchHelper.ANIMATION_TYPE_SWIPE_SUCCESS && lastTouched != null && lastTouched.get() != null) { onTrueSwipe(lastTouched.get()); lastTouched = null; } else if (animationType == ItemTouchHelper.ANIMATION_TYPE_SWIPE_CANCEL) { lastTouched = null; } return SWIPE_ANIMATION_DURATION; } private void onTrueSwipe(RecyclerView.ViewHolder viewHolder) { ThreadRecord thread = ((ConversationListItem) viewHolder.itemView).getThread(); onItemSwiped(thread.getThreadId(), thread.getUnreadCount(), thread.getUnreadSelfMentionsCount()); } @Override public void onChildDraw(@NonNull Canvas canvas, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { float absoluteDx = Math.abs(dX); if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { Resources resources = getResources(); View itemView = viewHolder.itemView; float percentDx = absoluteDx / viewHolder.itemView.getWidth(); int color = ArgbEvaluatorCompat.getInstance().evaluate(Math.min(1f, percentDx * (1 / 0.25f)), archiveColorStart, archiveColorEnd); float scaleStartPoint = DimensionUnit.DP.toPixels(48f); float scaleEndPoint = DimensionUnit.DP.toPixels(96f); float scale; if (absoluteDx < scaleStartPoint) { scale = MIN_ICON_SCALE; } else if (absoluteDx > scaleEndPoint) { scale = MAX_ICON_SCALE; } else { scale = Math.min(MAX_ICON_SCALE, MIN_ICON_SCALE + ((absoluteDx - scaleStartPoint) / (scaleEndPoint - scaleStartPoint)) * (MAX_ICON_SCALE - MIN_ICON_SCALE)); } if (absoluteDx > 0) { if (archiveDrawable == null) { archiveDrawable = Objects.requireNonNull(AppCompatResources.getDrawable(requireContext(), getArchiveIconRes())); archiveDrawable.setColorFilter(new SimpleColorFilter(ContextCompat.getColor(requireContext(), R.color.signal_colorOnPrimary))); archiveDrawable.setBounds(0, 0, archiveDrawable.getIntrinsicWidth(), archiveDrawable.getIntrinsicHeight()); } canvas.save(); canvas.clipRect(itemView.getLeft(), itemView.getTop(), itemView.getRight(), itemView.getBottom()); canvas.drawColor(color); float gutter = resources.getDimension(R.dimen.dsl_settings_gutter); float extra = resources.getDimension(R.dimen.conversation_list_fragment_archive_padding); if (ViewUtil.isLtr(requireContext())) { canvas.translate(itemView.getLeft() + gutter + extra, itemView.getTop() + (itemView.getBottom() - itemView.getTop() - archiveDrawable.getIntrinsicHeight()) / 2f); } else { canvas.translate(itemView.getRight() - gutter - extra, itemView.getTop() + (itemView.getBottom() - itemView.getTop() - archiveDrawable.getIntrinsicHeight()) / 2f); } canvas.scale(scale, scale, archiveDrawable.getIntrinsicWidth() / 2f, archiveDrawable.getIntrinsicHeight() / 2f); archiveDrawable.draw(canvas); canvas.restore(); ViewCompat.setElevation(viewHolder.itemView, DimensionUnit.DP.toPixels(4f)); } else if (absoluteDx == 0) { ViewCompat.setElevation(viewHolder.itemView, DimensionUnit.DP.toPixels(0f)); } viewHolder.itemView.setTranslationX(dX); } else { super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); } } @Override public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); if (itemAnimator == null) { return; } ViewCompat.setElevation(viewHolder.itemView, 0); lastTouched = null; View view = getView(); if (view != null) { itemAnimator.postDisable(view.getHandler()); } else { itemAnimator.disable(); } } } private final class VoiceNotePlayerViewListener implements VoiceNotePlayerView.Listener { @Override public void onCloseRequested(@NonNull Uri uri) { if (voiceNotePlayerViewStub.resolved()) { mediaControllerOwner.getVoiceNoteMediaController().stopPlaybackAndReset(uri); } } @Override public void onSpeedChangeRequested(@NonNull Uri uri, float speed) { mediaControllerOwner.getVoiceNoteMediaController().setPlaybackSpeed(uri, speed); } @Override public void onPlay(@NonNull Uri uri, long messageId, double position) { mediaControllerOwner.getVoiceNoteMediaController().startSinglePlayback(uri, messageId, position); } @Override public void onPause(@NonNull Uri uri) { mediaControllerOwner.getVoiceNoteMediaController().pausePlayback(uri); } @Override public void onNavigateToMessage(long threadId, @NonNull RecipientId threadRecipientId, @NonNull RecipientId senderId, long messageSentAt, long messagePositionInThread) { MainNavigator.get(requireActivity()).goToConversation(threadRecipientId, threadId, ThreadTable.DistributionTypes.DEFAULT, (int) messagePositionInThread); } } private class ContactSearchClickCallbacks implements ConversationListSearchAdapter.ConversationListSearchClickCallbacks { private final ContactSearchAdapter.ClickCallbacks delegate; private ContactSearchClickCallbacks(@NonNull ContactSearchAdapter.ClickCallbacks delegate) { this.delegate = delegate; } @Override public void onThreadClicked(@NonNull View view, @NonNull ContactSearchData.Thread thread, boolean isSelected) { onConversationClicked(thread.getThreadRecord()); } @Override public void onMessageClicked(@NonNull View view, @NonNull ContactSearchData.Message thread, boolean isSelected) { ConversationListFragment.this.onMessageClicked(thread.getMessageResult()); } @Override public void onGroupWithMembersClicked(@NonNull View view, @NonNull ContactSearchData.GroupWithMembers groupWithMembers, boolean isSelected) { onContactClicked(Recipient.resolved(groupWithMembers.getGroupRecord().getRecipientId())); } @Override public void onClearFilterClicked() { onClearFilterClick(); } @Override public void onStoryClicked(@NonNull View view, @NonNull ContactSearchData.Story story, boolean isSelected) { throw new UnsupportedOperationException(); } @Override public void onKnownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) { onContactClicked(knownRecipient.getRecipient()); } @Override public void onExpandClicked(@NonNull ContactSearchData.Expand expand) { delegate.onExpandClicked(expand); } @Override public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) { throw new UnsupportedOperationException(); } } public interface Callback extends Material3OnScrollHelperBinder, SearchBinder { @NonNull Toolbar getToolbar(); @NonNull View getUnreadPaymentsDot(); @NonNull Stub getBasicToolbar(); void updateNotificationProfileStatus(@NonNull List notificationProfiles); void updateProxyStatus(@NonNull WebSocketConnectionState state); void onMultiSelectStarted(); void onMultiSelectFinished(); } }