Signal-Android/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java

1037 lines
39 KiB
Java

/*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.conversationlist;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
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.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.IdRes;
import androidx.annotation.MenuRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.content.res.ResourcesCompat;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import com.google.android.material.snackbar.Snackbar;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.MainFragment;
import org.thoughtcrime.securesms.MainNavigator;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SearchToolbar;
import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator;
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
import org.thoughtcrime.securesms.components.reminder.DefaultSmsReminder;
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.ShareReminder;
import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
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.logging.Log;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
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.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener,
ConversationListSearchAdapter.EventListener,
MainNavigator.BackHandler,
MegaphoneActionController
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
private static final String TAG = Log.tag(ConversationListFragment.class);
private static final int[] EMPTY_IMAGES = new int[] { R.drawable.empty_inbox_1,
R.drawable.empty_inbox_2,
R.drawable.empty_inbox_3,
R.drawable.empty_inbox_4,
R.drawable.empty_inbox_5 };
private ActionMode actionMode;
private RecyclerView list;
private ReminderView reminderView;
private View emptyState;
private ImageView emptyImage;
private TextView searchEmptyState;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private SearchToolbar searchToolbar;
private ImageView searchAction;
private View toolbarShadow;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
private ConversationListSearchAdapter searchAdapter;
private StickyHeaderDecoration searchAdapterDecoration;
private ViewGroup megaphoneContainer;
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
public static ConversationListFragment newInstance() {
return new ConversationListFragment();
}
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setHasOptionsMenu(true);
}
@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) {
reminderView = view.findViewById(R.id.reminder);
list = view.findViewById(R.id.list);
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
emptyState = view.findViewById(R.id.empty_state);
emptyImage = view.findViewById(R.id.empty);
searchEmptyState = view.findViewById(R.id.search_no_results);
searchToolbar = view.findViewById(R.id.search_toolbar);
searchAction = view.findViewById(R.id.search_action);
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
megaphoneContainer = view.findViewById(R.id.megaphone_container);
Toolbar toolbar = view.findViewById(getToolbarRes());
toolbar.setVisibility(View.VISIBLE);
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
fab.show();
cameraFab.show();
reminderView.setOnDismissListener(this::updateReminders);
list.setHasFixedSize(true);
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
list.setItemAnimator(new DeleteItemAnimator());
list.addOnScrollListener(new ScrollListener());
snapToTopDataObserver = new SnapToTopDataObserver(list, null);
new ItemTouchHelper(new ArchiveListenerCallback()).attachToRecyclerView(list);
fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class)));
cameraFab.setOnClickListener(v -> {
Permissions.with(requireActivity())
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_solid_24)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(() -> startActivity(MediaSendActivity.buildCameraFirstIntent(requireActivity())))
.onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show())
.execute();
});
initializeListAdapters();
initializeViewModel();
initializeTypingObserver();
initializeSearchListener();
RatingManager.showRatingDialogIfNecessary(requireContext());
TooltipCompat.setTooltipText(searchAction, getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
}
@Override
public void onResume() {
super.onResume();
updateReminders();
EventBus.getDefault().register(this);
if (TextSecurePreferences.isSmsEnabled(requireContext())) {
InsightsLauncher.showInsightsModal(requireContext(), requireFragmentManager());
}
SimpleTask.run(getLifecycle(), Recipient::self, this::initializeProfileIcon);
if (!searchToolbar.isVisible() && list.getAdapter() != defaultAdapter) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
if (activeAdapter != null) {
activeAdapter.notifyDataSetChanged();
}
}
@Override
public void onStart() {
super.onStart();
ConversationFragment.prepare(requireContext());
}
@Override
public void onPause() {
super.onPause();
fab.stopPulse();
cameraFab.stopPulse();
EventBus.getDefault().unregister(this);
}
@Override
public void onPrepareOptionsMenu(Menu menu) {
MenuInflater inflater = requireActivity().getMenuInflater();
menu.clear();
inflater.inflate(R.menu.text_secure_normal, menu);
menu.findItem(R.id.menu_insights).setVisible(TextSecurePreferences.isSmsEnabled(requireContext()));
menu.findItem(R.id.menu_clear_passphrase).setVisible(!TextSecurePreferences.isPasswordDisabled(requireContext()));
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case R.id.menu_new_group: handleCreateGroup(); return true;
case R.id.menu_settings: handleDisplaySettings(); return true;
case R.id.menu_clear_passphrase: handleClearPassphrase(); return true;
case R.id.menu_mark_all_read: handleMarkAllRead(); return true;
case R.id.menu_invite: handleInvite(); return true;
case R.id.menu_insights: handleInsights(); return true;
}
return false;
}
@Override
public boolean onBackPressed() {
return closeSearchIfOpen();
}
private boolean closeSearchIfOpen() {
if (searchToolbar.isVisible() || activeAdapter == searchAdapter) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
searchToolbar.collapse();
return true;
}
return false;
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (resultCode != RESULT_OK) {
return;
}
if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN) {
Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).setTextColor(Color.WHITE).show();
viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL);
}
}
@Override
public void onConversationClicked(@NonNull ThreadRecord threadRecord) {
hideKeyboard();
getNavigator().goToConversation(threadRecord.getRecipient().getId(),
threadRecord.getThreadId(),
threadRecord.getDistributionType(),
-1);
}
@Override
public void onShowArchiveClick() {
getNavigator().goToArchiveList();
}
@Override
public void onContactClicked(@NonNull Recipient contact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact);
}, threadId -> {
hideKeyboard();
getNavigator().goToConversation(contact.getId(),
threadId,
ThreadDatabase.DistributionTypes.DEFAULT,
-1);
});
}
@Override
public void onMessageClicked(@NonNull MessageResult message) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.threadId, message.receivedTimestampMs);
return Math.max(0, startingPosition);
}, startingPosition -> {
hideKeyboard();
getNavigator().goToConversation(message.conversationRecipient.getId(),
message.threadId,
ThreadDatabase.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)
.setTextColor(Color.WHITE)
.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);
}
private void hideKeyboard() {
InputMethodManager imm = ServiceUtil.getInputMethodManager(requireContext());
imm.hideSoftInputFromWindow(requireView().getWindowToken(), 0);
}
private void initializeProfileIcon(@NonNull Recipient recipient) {
ImageView icon = requireView().findViewById(R.id.toolbar_icon);
AvatarUtil.loadIconIntoImageView(recipient, icon);
icon.setOnClickListener(v -> getNavigator().goToAppSettings());
}
private void initializeSearchListener() {
searchAction.setOnClickListener(v -> {
searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2.0f),
searchAction.getY() + (searchAction.getHeight() / 2.0f));
});
searchToolbar.setListener(new SearchToolbar.SearchListener() {
@Override
public void onSearchTextChange(String text) {
String trimmed = text.trim();
viewModel.updateQuery(trimmed);
if (trimmed.length() > 0) {
if (activeAdapter != searchAdapter) {
setAdapter(searchAdapter);
list.removeItemDecoration(searchAdapterDecoration);
list.addItemDecoration(searchAdapterDecoration);
}
} else {
if (activeAdapter != defaultAdapter) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
}
}
@Override
public void onSearchClosed() {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
});
}
private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this);
searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault());
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false);
setAdapter(defaultAdapter);
}
@SuppressWarnings("rawtypes")
private void setAdapter(@NonNull RecyclerView.Adapter adapter) {
RecyclerView.Adapter oldAdapter = activeAdapter;
activeAdapter = adapter;
if (oldAdapter == activeAdapter) {
return;
}
list.setAdapter(adapter);
if (adapter == defaultAdapter) {
defaultAdapter.registerAdapterDataObserver(snapToTopDataObserver);
} else {
defaultAdapter.unregisterAdapterDataObserver(snapToTopDataObserver);
}
}
private void initializeTypingObserver() {
ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().getTypingThreads().observe(this, threadIds -> {
if (threadIds == null) {
threadIds = Collections.emptySet();
}
defaultAdapter.setTypingThreads(threadIds);
});
}
protected boolean isArchived() {
return false;
}
private void initializeViewModel() {
viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class);
viewModel.getSearchResult().observe(this, this::onSearchResultChanged);
viewModel.getMegaphone().observe(this, this::onMegaphoneChanged);
viewModel.getConversationList().observe(this, this::onSubmitList);
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
@Override
public void onStart(@NonNull LifecycleOwner owner) {
viewModel.onVisible();
}
});
}
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) {
if (megaphone == null) {
megaphoneContainer.setVisibility(View.GONE);
megaphoneContainer.removeAllViews();
return;
}
View view = MegaphoneViewBuilder.build(requireContext(), megaphone, this);
megaphoneContainer.removeAllViews();
if (view != null) {
megaphoneContainer.addView(view);
megaphoneContainer.setVisibility(View.VISIBLE);
} else {
megaphoneContainer.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 (DefaultSmsReminder.isEligible(context)) {
return Optional.of(new DefaultSmsReminder(context));
} else if (Util.isDefaultSmsProvider(context) && SystemSmsImportReminder.isEligible(context)) {
return Optional.of((new SystemSmsImportReminder(context)));
} else if (PushRegistrationReminder.isEligible(context)) {
return Optional.of((new PushRegistrationReminder(context)));
} else if (ShareReminder.isEligible(context)) {
return Optional.of(new ShareReminder(context));
} else if (DozeReminder.isEligible(context)) {
return Optional.of(new DozeReminder(context));
} else {
return Optional.<Reminder>absent();
}
}, reminder -> {
if (reminder.isPresent() && getActivity() != null && !isRemoving()) {
reminderView.showReminder(reminder.get());
} else if (!reminder.isPresent()) {
reminderView.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<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setAllThreadsRead();
ApplicationDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(context, messageIds);
});
}
private void handleMarkSelectedAsRead() {
Context context = requireContext();
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(selectedConversations, false);
ApplicationDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(context, messageIds);
return null;
}, none -> {
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
});
}
private void handleMarkSelectedAsUnread() {
Context context = requireContext();
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
DatabaseFactory.getThreadDatabase(context).setForcedUnread(selectedConversations);
StorageSyncHelper.scheduleSyncForDataChange();
return null;
}, none -> {
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
});
}
private void handleInvite() {
getNavigator().goToInvite();
}
private void handleInsights() {
getNavigator().goToInsights();
}
@SuppressLint("StaticFieldLeak")
private void handleArchiveAllSelected() {
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
int count = selectedConversations.size();
String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count);
new SnackbarAsyncTask<Void>(getView(),
snackBarTitle,
getString(R.string.ConversationListFragment_undo),
getResources().getColor(R.color.amber_500),
Snackbar.LENGTH_LONG, true)
{
@Override
protected void onPostExecute(Void result) {
super.onPostExecute(result);
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
}
@Override
protected void executeAction(@Nullable Void parameter) {
for (long threadId : selectedConversations) {
archiveThread(threadId);
}
}
@Override
protected void reverseAction(@Nullable Void parameter) {
for (long threadId : selectedConversations) {
reverseArchiveThread(threadId);
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
@SuppressLint("StaticFieldLeak")
private void handleDeleteAllSelected() {
int conversationsCount = defaultAdapter.getBatchSelectionIds().size();
AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
alert.setIconAttribute(R.attr.dialog_alert_icon);
alert.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations,
conversationsCount, conversationsCount));
alert.setMessage(getActivity().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<Long> selectedConversations = defaultAdapter.getBatchSelectionIds();
if (!selectedConversations.isEmpty()) {
new AsyncTask<Void, Void, Void>() {
private ProgressDialog dialog;
@Override
protected void onPreExecute() {
dialog = ProgressDialog.show(getActivity(),
getActivity().getString(R.string.ConversationListFragment_deleting),
getActivity().getString(R.string.ConversationListFragment_deleting_selected_conversations),
true, false);
}
@Override
protected Void doInBackground(Void... params) {
DatabaseFactory.getThreadDatabase(getActivity()).deleteConversations(selectedConversations);
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
return null;
}
@Override
protected void onPostExecute(Void result) {
dialog.dismiss();
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
});
alert.setNegativeButton(android.R.string.cancel, null);
alert.show();
}
private void handleSelectAllThreads() {
defaultAdapter.selectAllThreads();
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
}
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
}
private void onSubmitList(@NonNull ConversationListViewModel.ConversationList conversationList) {
if (conversationList.isEmpty()) {
list.setVisibility(View.INVISIBLE);
emptyState.setVisibility(View.VISIBLE);
emptyImage.setImageResource(EMPTY_IMAGES[(int) (Math.random() * EMPTY_IMAGES.length)]);
fab.startPulse(3 * 1000);
cameraFab.startPulse(3 * 1000);
} else {
list.setVisibility(View.VISIBLE);
emptyState.setVisibility(View.GONE);
fab.stopPulse();
cameraFab.stopPulse();
}
defaultAdapter.submitList(conversationList.getConversations());
defaultAdapter.updateArchived(conversationList.getArchivedCount());
onPostSubmitList();
}
protected void onPostSubmitList() {
}
@Override
public void onConversationClick(Conversation conversation) {
if (actionMode == null) {
handleCreateConversation(conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient(), conversation.getThreadRecord().getDistributionType());
} else {
defaultAdapter.toggleConversationInBatchSet(conversation);
if (defaultAdapter.getBatchSelectionIds().size() == 0) {
actionMode.finish();
} else {
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
setCorrectMenuVisibility(actionMode.getMenu());
}
}
}
@Override
public boolean onConversationLongClick(Conversation conversation) {
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
defaultAdapter.initializeBatchMode(true);
defaultAdapter.toggleConversationInBatchSet(conversation);
return true;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = getActivity().getMenuInflater();
inflater.inflate(getActionModeMenuRes(), menu);
inflater.inflate(R.menu.conversation_list_batch, menu);
mode.setTitle("1");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getActivity().getWindow().setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar));
}
if (Build.VERSION.SDK_INT >= 23) {
int current = getActivity().getWindow().getDecorView().getSystemUiVisibility();
getActivity().getWindow().getDecorView().setSystemUiVisibility(current & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
setCorrectMenuVisibility(menu);
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_select_all: handleSelectAllThreads(); return true;
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true;
case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true;
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
defaultAdapter.initializeBatchMode(false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
TypedArray color = getActivity().getTheme().obtainStyledAttributes(new int[] {android.R.attr.statusBarColor});
getActivity().getWindow().setStatusBarColor(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();
}
actionMode = null;
}
@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 setCorrectMenuVisibility(@NonNull Menu menu) {
boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead());
if (hasUnread) {
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);
menu.findItem(R.id.menu_mark_as_read).setVisible(true);
} else {
menu.findItem(R.id.menu_mark_as_unread).setVisible(true);
menu.findItem(R.id.menu_mark_as_read).setVisible(false);
}
}
protected @IdRes int getToolbarRes() {
return R.id.toolbar;
}
protected @PluralsRes int getArchivedSnackbarTitleRes() {
return R.plurals.ConversationListFragment_conversations_archived;
}
protected @MenuRes int getActionModeMenuRes() {
return R.menu.conversation_list_batch_archive;
}
protected @DrawableRes int getArchiveIconRes() {
return R.drawable.ic_archive_white_36dp;
}
@WorkerThread
protected void archiveThread(long threadId) {
DatabaseFactory.getThreadDatabase(getActivity()).archiveConversation(threadId);
}
@WorkerThread
protected void reverseArchiveThread(long threadId) {
DatabaseFactory.getThreadDatabase(getActivity()).unarchiveConversation(threadId);
}
@SuppressLint("StaticFieldLeak")
protected void onItemSwiped(long threadId, int unreadCount) {
new SnackbarAsyncTask<Long>(getView(),
getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1),
getString(R.string.ConversationListFragment_undo),
getResources().getColor(R.color.amber_500),
Snackbar.LENGTH_LONG, false)
{
@Override
protected void executeAction(@Nullable Long parameter) {
DatabaseFactory.getThreadDatabase(getActivity()).archiveConversation(threadId);
if (unreadCount > 0) {
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(getActivity()).setRead(threadId, false);
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
MarkReadReceiver.process(getActivity(), messageIds);
}
}
@Override
protected void reverseAction(@Nullable Long parameter) {
DatabaseFactory.getThreadDatabase(getActivity()).unarchiveConversation(threadId);
if (unreadCount > 0) {
DatabaseFactory.getThreadDatabase(getActivity()).incrementUnread(threadId, unreadCount);
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId);
}
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
ArchiveListenerCallback() {
super(0, ItemTouchHelper.RIGHT);
}
@Override
public boolean onMove(@NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
@NonNull RecyclerView.ViewHolder target)
{
return false;
}
@Override
public int getSwipeDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder.itemView instanceof ConversationListItemAction ||
actionMode != null ||
activeAdapter == searchAdapter)
{
return 0;
}
return super.getSwipeDirs(recyclerView, viewHolder);
}
@SuppressLint("StaticFieldLeak")
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
if (viewHolder.itemView instanceof ConversationListItemInboxZero) return;
final long threadId = ((ConversationListItem)viewHolder.itemView).getThreadId();
final int unreadCount = ((ConversationListItem)viewHolder.itemView).getUnreadCount();
onItemSwiped(threadId, unreadCount);
}
@Override
public void onChildDraw(@NonNull Canvas canvas, @NonNull RecyclerView recyclerView,
@NonNull RecyclerView.ViewHolder viewHolder,
float dX, float dY, int actionState,
boolean isCurrentlyActive)
{
if (viewHolder.itemView instanceof ConversationListItemInboxZero) return;
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
View itemView = viewHolder.itemView;
float alpha = 1.0f - Math.abs(dX) / (float) viewHolder.itemView.getWidth();
if (dX > 0) {
Resources resources = getResources();
if (archiveDrawable == null) {
archiveDrawable = ResourcesCompat.getDrawable(resources, getArchiveIconRes(), requireActivity().getTheme());
Objects.requireNonNull(archiveDrawable).setBounds(0, 0, archiveDrawable.getIntrinsicWidth(), archiveDrawable.getIntrinsicHeight());
}
canvas.save();
canvas.clipRect(itemView.getLeft(), itemView.getTop(), dX, itemView.getBottom());
canvas.drawColor(alpha > 0 ? resources.getColor(R.color.green_500) : Color.WHITE);
canvas.translate(itemView.getLeft() + resources.getDimension(R.dimen.conversation_list_fragment_archive_padding),
itemView.getTop() + (itemView.getBottom() - itemView.getTop() - archiveDrawable.getIntrinsicHeight()) / 2f);
archiveDrawable.draw(canvas);
canvas.restore();
}
viewHolder.itemView.setAlpha(alpha);
viewHolder.itemView.setTranslationX(dX);
} else {
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}
}
private class ScrollListener extends RecyclerView.OnScrollListener {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (recyclerView.canScrollVertically(-1)) {
if (toolbarShadow.getVisibility() != View.VISIBLE) {
ViewUtil.fadeIn(toolbarShadow, 250);
}
} else {
if (toolbarShadow.getVisibility() != View.GONE) {
ViewUtil.fadeOut(toolbarShadow, 250);
}
}
}
}
}