diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java index 71c8faef5a..784d45cd9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java @@ -14,4 +14,7 @@ public interface BindableConversationListItem extends Unbindable { @NonNull GlideRequests glideRequests, @NonNull Locale locale, @NonNull Set typingThreads, @NonNull Set selectedThreads, boolean batchMode); + + void setBatchMode(boolean batchMode); + void updateTypingIndicator(@NonNull Set typingThreads); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index ee2182f089..a1670d3349 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -479,7 +479,7 @@ public class ConversationAdapter return headerView != null; } - private boolean hasFooter() { + public boolean hasFooter() { return footerView != null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java index 4a72eb0301..075467f242 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java @@ -12,6 +12,7 @@ final class ConversationData { private final boolean isMessageRequestAccepted; private final boolean hasPreMessageRequestMessages; private final int jumpToPosition; + private final int threadSize; ConversationData(long threadId, long lastSeen, @@ -20,7 +21,8 @@ final class ConversationData { boolean hasSent, boolean isMessageRequestAccepted, boolean hasPreMessageRequestMessages, - int jumpToPosition) + int jumpToPosition, + int threadSize) { this.threadId = threadId; this.lastSeen = lastSeen; @@ -30,6 +32,7 @@ final class ConversationData { this.isMessageRequestAccepted = isMessageRequestAccepted; this.hasPreMessageRequestMessages = hasPreMessageRequestMessages; this.jumpToPosition = jumpToPosition; + this.threadSize = threadSize; } public long getThreadId() { @@ -71,4 +74,8 @@ final class ConversationData { int getJumpToPosition() { return jumpToPosition; } + + int getThreadSize() { + return threadSize; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index ec5f22b7f9..6d8faef479 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -14,6 +14,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.paging.Invalidator; +import org.thoughtcrime.securesms.util.paging.SizeFixResult; import java.util.ArrayList; import java.util.List; @@ -76,9 +78,9 @@ class ConversationDataSource extends PositionalDataSource { } if (!isInvalid()) { - SizeFixResult result = ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount); + SizeFixResult result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount); - callback.onResult(result.messages, params.requestedStartPosition, result.total); + callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal()); } Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : "")); @@ -103,54 +105,6 @@ class ConversationDataSource extends PositionalDataSource { Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : "")); } - private static @NonNull SizeFixResult ensureMultipleOfPageSize(@NonNull List records, - int startPosition, - int pageSize, - int total) - { - if (records.size() + startPosition == total || (records.size() != 0 && records.size() % pageSize == 0)) { - return new SizeFixResult(records, total); - } - - if (records.size() < pageSize) { - Log.w(TAG, "Hit a miscalculation where we don't have the full dataset, but it's smaller than a page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total); - return new SizeFixResult(records, records.size() + startPosition); - } - - Log.w(TAG, "Hit a miscalculation where our data size isn't a multiple of the page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total); - int overflow = records.size() % pageSize; - - return new SizeFixResult(records.subList(0, records.size() - overflow), total); - } - - private static class SizeFixResult { - final List messages; - final int total; - - private SizeFixResult(@NonNull List messages, int total) { - this.messages = messages; - this.total = total; - } - } - - interface DataUpdatedCallback { - void onDataUpdated(); - } - - static class Invalidator { - private Runnable callback; - - synchronized void invalidate() { - if (callback != null) { - callback.run(); - } - } - - private synchronized void observe(@NonNull Runnable callback) { - this.callback = callback; - } - } - static class Factory extends DataSource.Factory { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index b14d1a63fc..16e21c5f2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -21,7 +21,6 @@ import android.app.Activity; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; -import android.graphics.Rect; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; @@ -113,6 +112,7 @@ import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.HtmlUtil; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.SnapToTopDataObserver; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -163,8 +163,7 @@ public class ConversationFragment extends Fragment { private ConversationBannerView emptyConversationBanner; private MessageRequestViewModel messageRequestViewModel; private ConversationViewModel conversationViewModel; - - private Deferred deferred = new Deferred(); + private SnapToTopDataObserver snapToTopDataObserver; public static void prepare(@NonNull Context context) { FrameLayout parent = new FrameLayout(context); @@ -198,6 +197,8 @@ public class ConversationFragment extends Fragment { list.setLayoutManager(layoutManager); list.setItemAnimator(null); + snapToTopDataObserver = new ConversationSnapToTopDataObserver(list, new ConversationScrollRequestValidator()); + if (FeatureFlags.messageRequests()) { conversationBanner = (ConversationBannerView) inflater.inflate(R.layout.conversation_item_banner, container, false); } @@ -226,7 +227,7 @@ public class ConversationFragment extends Fragment { Log.i(TAG, "submitList skipped an invalid list"); } }); - conversationViewModel.getConversationMetadata().observe(this, data -> deferred.defer(() -> presentConversationMetadata(data))); + conversationViewModel.getConversationMetadata().observe(this, this::presentConversationMetadata); return view; } @@ -329,7 +330,7 @@ public class ConversationFragment extends Fragment { } int position = getListAdapter().getAdapterPositionForMessagePosition(conversationViewModel.getLastSeenPosition()); - scrollToPosition(position); + snapToTopDataObserver.requestScrollPosition(position); } private void initializeMessageRequestViewModel() { @@ -423,7 +424,7 @@ public class ConversationFragment extends Fragment { this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1); this.unknownSenderView = new UnknownSenderView(getActivity(), recipient.get(), threadId, () -> clearHeaderIfNotTyping(getListAdapter())); - deferred.setDeferred(true); + snapToTopDataObserver.requestScrollPosition(startingPosition); conversationViewModel.onConversationDataAvailable(threadId, startingPosition); OnScrollListener scrollListener = new ConversationScrollListener(getActivity()); @@ -442,7 +443,7 @@ public class ConversationFragment extends Fragment { list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false)); ConversationAdapter.initializePool(list.getRecycledViewPool()); - adapter.registerAdapterDataObserver(new DataObserver()); + adapter.registerAdapterDataObserver(snapToTopDataObserver); setLastSeen(conversationViewModel.getLastSeen()); @@ -563,7 +564,7 @@ public class ConversationFragment extends Fragment { this.threadId = threadId; messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); - deferred.setDeferred(true); + snapToTopDataObserver.requestScrollPosition(0); conversationViewModel.onConversationDataAvailable(threadId, -1); initializeListAdapter(); } @@ -883,48 +884,52 @@ public class ConversationFragment extends Fragment { return; } - if (FeatureFlags.messageRequests()) { - adapter.setFooterView(conversationBanner); - } else { - adapter.setFooterView(null); - } - - setLastSeen(conversation.getLastSeen()); - - if (FeatureFlags.messageRequests() && !conversation.hasPreMessageRequestMessages()) { - clearHeaderIfNotTyping(adapter); - } else { - if (!conversation.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { - adapter.setHeaderView(unknownSenderView); + Runnable afterScroll = () -> { + if (FeatureFlags.messageRequests()) { + adapter.setFooterView(conversationBanner); + if (!conversation.isMessageRequestAccepted()) { + snapToTopDataObserver.requestScrollPosition(adapter.getItemCount() - 1); + } } else { - clearHeaderIfNotTyping(adapter); + adapter.setFooterView(null); } - } - listener.onCursorChanged(); + setLastSeen(conversation.getLastSeen()); + + if (FeatureFlags.messageRequests() && !conversation.hasPreMessageRequestMessages()) { + clearHeaderIfNotTyping(adapter); + } else { + if (!conversation.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { + adapter.setHeaderView(unknownSenderView); + } else { + clearHeaderIfNotTyping(adapter); + } + } + + listener.onCursorChanged(); + }; int lastSeenPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastSeenPosition()); int lastScrolledPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastScrolledPosition()); - if (conversation.shouldJumpToMessage()) { - scrollToStartingPosition(conversation.getJumpToPosition()); + if (conversation.getThreadSize() == 0) { + afterScroll.run(); + } else if (conversation.shouldJumpToMessage()) { + snapToTopDataObserver.buildScrollPosition(conversation.getJumpToPosition()) + .withOnScrollRequestComplete(() -> { + afterScroll.run(); + getListAdapter().pulseHighlightItem(conversation.getJumpToPosition()); + }) + .submit(); } else if (conversation.isMessageRequestAccepted()) { - scrollToPosition(conversation.shouldScrollToLastSeen() ? lastSeenPosition : lastScrolledPosition); + snapToTopDataObserver.buildScrollPosition(conversation.shouldScrollToLastSeen() ? lastSeenPosition : lastScrolledPosition) + .withOnPerformScroll((layoutManager, position) -> layoutManager.scrollToPositionWithOffset(position, list.getHeight())) + .withOnScrollRequestComplete(afterScroll) + .submit(); } else if (FeatureFlags.messageRequests()) { - list.post(() -> getListLayoutManager().scrollToPosition(adapter.getItemCount() - 1)); - } - } - - private void scrollToStartingPosition(int startingPosition) { - list.post(() -> { - list.getLayoutManager().scrollToPosition(startingPosition); - getListAdapter().pulseHighlightItem(startingPosition); - }); - } - - private void scrollToPosition(int position) { - if (position > 0) { - list.post(() -> getListLayoutManager().scrollToPositionWithOffset(position, list.getHeight())); + snapToTopDataObserver.buildScrollPosition(adapter.getItemCount() - 1) + .withOnScrollRequestComplete(afterScroll) + .submit(); } } @@ -959,22 +964,16 @@ public class ConversationFragment extends Fragment { } private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) { - int itemCount = getListAdapter() != null ? getListAdapter().getItemCount() : 0; - - if (position >= 0 && position < itemCount) { - if (getListAdapter().getItem(position) == null) { - conversationViewModel.onConversationDataAvailable(threadId, position); - deferred.setDeferred(true); - deferred.defer(() -> moveToMessagePosition(position, onMessageNotFound)); - } else { - scrollToStartingPosition(position); - } - } else { - Log.w(TAG, "[moveToMessagePosition] Tried to navigate to message, but it wasn't found."); - if (onMessageNotFound != null) { - onMessageNotFound.run(); - } - } + conversationViewModel.onConversationDataAvailable(threadId, position); + snapToTopDataObserver.buildScrollPosition(position) + .withOnScrollRequestComplete(() -> getListAdapter().pulseHighlightItem(position)) + .withOnInvalidPosition(() -> { + if (onMessageNotFound != null) { + onMessageNotFound.run(); + } + Log.w(TAG, "[moveToMessagePosition] Tried to navigate to message, but it wasn't found."); + }) + .submit(); } private void maybeShowSwipeToReplyTooltip() { @@ -1074,44 +1073,6 @@ public class ConversationFragment extends Fragment { } } - private class DataObserver extends RecyclerView.AdapterDataObserver { - - private final Rect rect = new Rect(); - - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - if (deferred.isDeferred()) { - deferred.setDeferred(false); - return; - } - - if (positionStart == 0 && itemCount == 1 && isTypingIndicatorShowing()) { - return; - } - - if (list.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) { - int firstVisibleItem = getListLayoutManager().findFirstVisibleItemPosition(); - - if (firstVisibleItem == 0) { - View view = getListLayoutManager().findViewByPosition(0); - if (view == null) { - return; - } - - view.getDrawingRect(rect); - list.offsetDescendantRectToMyCoords(view, rect); - - int bottom = rect.bottom; - list.getDrawingRect(rect); - - if (bottom <= rect.bottom) { - getListLayoutManager().scrollToPosition(0); - } - } - } - } - } - private class ConversationFragmentItemClickListener implements ItemClickListener { @Override @@ -1319,6 +1280,52 @@ public class ConversationFragment extends Fragment { actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); } + private final class ConversationSnapToTopDataObserver extends SnapToTopDataObserver { + + public ConversationSnapToTopDataObserver(@NonNull RecyclerView recyclerView, + @Nullable ScrollRequestValidator scrollRequestValidator) + { + super(recyclerView, scrollRequestValidator); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + // Do nothing. + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + if (positionStart == 0 && itemCount == 1 && isTypingIndicatorShowing()) { + return; + } + + super.onItemRangeInserted(positionStart, itemCount); + } + } + + private final class ConversationScrollRequestValidator implements SnapToTopDataObserver.ScrollRequestValidator { + + @Override + public boolean isPositionStillValid(int position) { + if (getListAdapter() == null) { + return position >= 0; + } else { + return position >= 0 && position < getListAdapter().getItemCount(); + } + } + + @Override + public boolean isItemAtPositionLoaded(int position) { + if (getListAdapter() == null) { + return false; + } else if (getListAdapter().hasFooter() && position == getListAdapter().getItemCount() - 1) { + return true; + } else { + return getListAdapter().getItem(position) != null; + } + } + } + private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener { private final MessageRecord messageRecord; @@ -1465,33 +1472,4 @@ public class ConversationFragment extends Fragment { } } - private static class Deferred { - - private Runnable deferred; - private boolean isDeferred; - - public void defer(@Nullable Runnable deferred) { - this.deferred = deferred; - executeIfNecessary(); - } - - public void setDeferred(boolean isDeferred) { - this.isDeferred = isDeferred; - executeIfNecessary(); - } - - public boolean isDeferred() { - return isDeferred; - } - - private void executeIfNecessary() { - if (deferred != null && !isDeferred) { - Runnable local = deferred; - - deferred = null; - - local.run(); - } - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index 3e71b1fccb..720d6a4bc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -35,7 +35,8 @@ class ConversationRepository { } private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) { - ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId); + ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId); + int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId); long lastSeen = metadata.getLastSeen(); boolean hasSent = metadata.hasSent(); @@ -58,6 +59,6 @@ class ConversationRepository { lastScrolledPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionOnOrAfterTimestamp(threadId, lastScrolled); } - return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, isMessageRequestAccepted, hasPreMessageRequestMessages, jumpToPosition); + return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, isMessageRequestAccepted, hasPreMessageRequestMessages, jumpToPosition, threadSize); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index cbc6a3ebf5..727f0c0140 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -3,11 +3,8 @@ package org.thoughtcrime.securesms.conversation; import android.app.Application; import androidx.annotation.NonNull; -import androidx.arch.core.util.Function; import androidx.lifecycle.LiveData; -import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Observer; import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; @@ -15,19 +12,15 @@ import androidx.paging.DataSource; import androidx.paging.LivePagedListBuilder; import androidx.paging.PagedList; -import org.thoughtcrime.securesms.conversation.ConversationDataSource.Invalidator; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaRepository; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.paging.Invalidator; import org.whispersystems.libsignal.util.Pair; -import java.util.LinkedList; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; class ConversationViewModel extends ViewModel { @@ -70,10 +63,12 @@ class ConversationViewModel extends ViewModel { final int startPosition; if (data.shouldJumpToMessage()) { startPosition = data.getJumpToPosition(); - } else if (data.shouldScrollToLastSeen()) { + } else if (data.isMessageRequestAccepted() && data.shouldScrollToLastSeen()) { startPosition = data.getLastSeenPosition(); - } else { + } else if (data.isMessageRequestAccepted()) { startPosition = data.getLastScrolledPosition(); + } else { + startPosition = data.getThreadSize(); } Log.d(TAG, "Starting at position startPosition: " + startPosition + " jumpToPosition: " + jumpToPosition + " lastSeenPosition: " + data.getLastSeenPosition() + " lastScrolledPosition: " + data.getLastScrolledPosition()); @@ -86,7 +81,7 @@ class ConversationViewModel extends ViewModel { this.messages = Transformations.map(messagesForThreadId, Pair::second); - LiveData distinctThread = Transformations.distinctUntilChanged(threadId); + LiveData distinctThread = Transformations.distinctUntilChanged(Transformations.map(messagesForThreadId, Pair::first)); conversationMetadata = Transformations.switchMap(distinctThread, thread -> metadata); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java deleted file mode 100644 index 9481efb44b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (C) 2011 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.content.Context; -import android.database.Cursor; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.BindableConversationListItem; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.ThreadRecord; -import org.thoughtcrime.securesms.mms.GlideRequests; -import org.thoughtcrime.securesms.util.Conversions; - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Locale; -import java.util.Map; -import java.util.Set; - -/** - * A CursorAdapter for building a list of conversation threads. - * - * @author Moxie Marlinspike - */ -class ConversationListAdapter extends CursorRecyclerViewAdapter { - - private static final int MESSAGE_TYPE_SWITCH_ARCHIVE = 1; - private static final int MESSAGE_TYPE_THREAD = 2; - private static final int MESSAGE_TYPE_INBOX_ZERO = 3; - - private final @NonNull ThreadDatabase threadDatabase; - private final @NonNull GlideRequests glideRequests; - private final @NonNull Locale locale; - private final @NonNull LayoutInflater inflater; - private final @Nullable ItemClickListener clickListener; - private final @NonNull MessageDigest digest; - - private final Map batchSet = Collections.synchronizedMap(new HashMap<>()); - private boolean batchMode = false; - private final Set typingSet = new HashSet<>(); - - protected static class ViewHolder extends RecyclerView.ViewHolder { - public ViewHolder(final @NonNull V itemView) - { - super(itemView); - } - - public BindableConversationListItem getItem() { - return (BindableConversationListItem)itemView; - } - } - - @Override - public long getItemId(@NonNull Cursor cursor) { - ThreadRecord record = getThreadRecord(cursor); - - return Conversions.byteArrayToLong(digest.digest(record.getRecipient().getId().serialize().getBytes())); - } - - @Override - protected long getFastAccessItemId(int position) { - return super.getFastAccessItemId(position); - } - - ConversationListAdapter(@NonNull Context context, - @NonNull GlideRequests glideRequests, - @NonNull Locale locale, - @Nullable Cursor cursor, - @Nullable ItemClickListener clickListener) - { - super(context, cursor); - try { - this.glideRequests = glideRequests; - this.threadDatabase = DatabaseFactory.getThreadDatabase(context); - this.locale = locale; - this.inflater = LayoutInflater.from(context); - this.clickListener = clickListener; - this.digest = MessageDigest.getInstance("SHA1"); - setHasStableIds(true); - } catch (NoSuchAlgorithmException nsae) { - throw new AssertionError("SHA-1 missing"); - } - } - - @Override - public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { - if (viewType == MESSAGE_TYPE_SWITCH_ARCHIVE) { - ConversationListItemAction action = (ConversationListItemAction) inflater.inflate(R.layout.conversation_list_item_action, - parent, false); - - action.setOnClickListener(v -> { - if (clickListener != null) clickListener.onSwitchToArchive(); - }); - - return new ViewHolder(action); - } else if (viewType == MESSAGE_TYPE_INBOX_ZERO) { - return new ViewHolder((ConversationListItemInboxZero)inflater.inflate(R.layout.conversation_list_item_inbox_zero, parent, false)); - } else { - final ConversationListItem item = (ConversationListItem)inflater.inflate(R.layout.conversation_list_item_view, - parent, false); - - item.setOnClickListener(view -> { - if (clickListener != null) clickListener.onItemClick(item); - }); - - item.setOnLongClickListener(view -> { - if (clickListener != null) clickListener.onItemLongClick(item); - return true; - }); - - return new ViewHolder(item); - } - } - - @Override - public void onItemViewRecycled(ViewHolder holder) { - holder.getItem().unbind(); - } - - @Override - public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) { - viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, typingSet, batchSet.keySet(), batchMode); - } - - @Override - public int getItemViewType(@NonNull Cursor cursor) { - ThreadRecord threadRecord = getThreadRecord(cursor); - - if (threadRecord.getDistributionType() == ThreadDatabase.DistributionTypes.ARCHIVE) { - return MESSAGE_TYPE_SWITCH_ARCHIVE; - } else if (threadRecord.getDistributionType() == ThreadDatabase.DistributionTypes.INBOX_ZERO) { - return MESSAGE_TYPE_INBOX_ZERO; - } else { - return MESSAGE_TYPE_THREAD; - } - } - - public void setTypingThreads(@NonNull Set threadsIds) { - typingSet.clear(); - typingSet.addAll(threadsIds); - notifyDataSetChanged(); - } - - private ThreadRecord getThreadRecord(@NonNull Cursor cursor) { - return threadDatabase.readerFor(cursor).getCurrent(); - } - - void toggleThreadInBatchSet(@NonNull ThreadRecord thread) { - if (batchSet.containsKey(thread.getThreadId())) { - batchSet.remove(thread.getThreadId()); - } else if (thread.getThreadId() != -1) { - batchSet.put(thread.getThreadId(), thread); - } - } - - @NonNull Set getBatchSelectionIds() { - return batchSet.keySet(); - } - - @NonNull Set getBatchSelection() { - return new HashSet<>(batchSet.values()); - } - - void initializeBatchMode(boolean toggle) { - this.batchMode = toggle; - unselectAllThreads(); - } - - private void unselectAllThreads() { - this.batchSet.clear(); - this.notifyDataSetChanged(); - } - - void selectAllThreads() { - for (int i = 0; i < getItemCount(); i++) { - ThreadRecord record = getThreadRecord(getCursorAtPositionOrThrow(i)); - if (record.getThreadId() != -1) { - batchSet.put(record.getThreadId(), record); - } - } - this.notifyDataSetChanged(); - } - - interface ItemClickListener { - void onItemClick(ConversationListItem item); - void onItemLongClick(ConversationListItem item); - void onSwitchToArchive(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index b6673fe02e..c882750ba3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -17,14 +17,9 @@ package org.thoughtcrime.securesms.conversationlist; import android.annotation.SuppressLint; -import android.database.Cursor; import android.os.AsyncTask; import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; import android.view.View; -import android.view.ViewGroup; import androidx.annotation.DrawableRes; import androidx.annotation.MenuRes; @@ -35,22 +30,19 @@ import androidx.annotation.WorkerThread; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; +import androidx.paging.PagedList; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.snackbar.Snackbar; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; -import org.thoughtcrime.securesms.conversationlist.ConversationListAdapter.ItemClickListener; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.loaders.ConversationListLoader; import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; -public class ConversationListArchiveFragment extends ConversationListFragment - implements LoaderManager.LoaderCallbacks, ActionMode.Callback, ItemClickListener +public class ConversationListArchiveFragment extends ConversationListFragment implements ActionMode.Callback { private RecyclerView list; private View emptyState; @@ -71,10 +63,10 @@ public class ConversationListArchiveFragment extends ConversationListFragment public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - 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); + 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); ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); Toolbar toolbar = view.findViewById(R.id.toolbar_basic); @@ -86,18 +78,18 @@ public class ConversationListArchiveFragment extends ConversationListFragment } @Override - public @NonNull Loader onCreateLoader(int arg0, Bundle arg1) { - return new ConversationListLoader(getActivity(), null, true); - } - - @Override - public void onLoadFinished(@NonNull Loader arg0, Cursor cursor) { - super.onLoadFinished(arg0, cursor); + protected void onSubmitList(@NonNull PagedList conversations) { + super.onSubmitList(conversations); list.setVisibility(View.VISIBLE); emptyState.setVisibility(View.GONE); } + @Override + protected boolean isArchived() { + return true; + } + @Override protected int getToolbarRes() { return R.id.toolbar_basic; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java new file mode 100644 index 0000000000..2003fccdcc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java @@ -0,0 +1,157 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.paging.DataSource; +import androidx.paging.PositionalDataSource; + +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.paging.Invalidator; +import org.thoughtcrime.securesms.util.paging.SizeFixResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Executor; + +abstract class ConversationListDataSource extends PositionalDataSource { + + public static final Executor EXECUTOR = SignalExecutors.newFixedLifoThreadExecutor("signal-conversation-list", 1, 1); + + private static final String TAG = Log.tag(ConversationListDataSource.class); + + protected final ThreadDatabase threadDatabase; + + protected ConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) { + this.threadDatabase = DatabaseFactory.getThreadDatabase(context); + + ContentObserver contentObserver = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + invalidate(); + context.getContentResolver().unregisterContentObserver(this); + } + }; + + invalidator.observe(() -> { + invalidate(); + context.getContentResolver().unregisterContentObserver(contentObserver); + }); + + context.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, contentObserver); + } + + private static ConversationListDataSource create(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) { + if (!isArchived) return new UnarchivedConversationListDataSource(context, invalidator); + else return new ArchivedConversationListDataSource(context, invalidator); + } + + @Override + public final void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback callback) { + long start = System.currentTimeMillis(); + + List conversations = new ArrayList<>(params.requestedLoadSize); + Locale locale = Locale.getDefault(); + int totalCount = getTotalCount(); + int effectiveCount = params.requestedStartPosition; + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(getCursor(params.requestedStartPosition, params.requestedLoadSize))) { + ThreadRecord record; + while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) { + conversations.add(new Conversation(record, locale)); + effectiveCount++; + } + } + + if (!isInvalid()) { + SizeFixResult result = SizeFixResult.ensureMultipleOfPageSize(conversations, params.requestedStartPosition, params.pageSize, totalCount); + + callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal()); + } + + Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : "")); + } + + @Override + public final void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback callback) { + long start = System.currentTimeMillis(); + + List conversations = new ArrayList<>(params.loadSize); + Locale locale = Locale.getDefault(); + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(getCursor(params.startPosition, params.loadSize))) { + ThreadRecord record; + while ((record = reader.getNext()) != null && !isInvalid()) { + conversations.add(new Conversation(record, locale)); + } + } + + callback.onResult(conversations); + + Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : "")); + } + + protected abstract int getTotalCount(); + protected abstract Cursor getCursor(long offset, long limit); + + private static class ArchivedConversationListDataSource extends ConversationListDataSource { + + ArchivedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) { + super(context, invalidator); + } + + @Override + protected int getTotalCount() { + return threadDatabase.getArchivedConversationListCount(); + } + + @Override + protected Cursor getCursor(long offset, long limit) { + return threadDatabase.getArchivedConversationList(offset, limit); + } + } + + private static class UnarchivedConversationListDataSource extends ConversationListDataSource { + + UnarchivedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) { + super(context, invalidator); + } + + @Override + protected int getTotalCount() { + return threadDatabase.getUnarchivedConversationListCount(); + } + + @Override + protected Cursor getCursor(long offset, long limit) { + return threadDatabase.getConversationList(offset, limit); + } + } + + static class Factory extends DataSource.Factory { + + private final Context context; + private final Invalidator invalidator; + private final boolean isArchived; + + public Factory(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) { + this.context = context; + this.invalidator = invalidator; + this.isArchived = isArchived; + } + + @Override + public @NonNull DataSource create() { + return ConversationListDataSource.create(context, invalidator, isArchived); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 7b9a38a08c..0392e693d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -23,7 +23,6 @@ import android.app.ProgressDialog; import android.content.Context; import android.content.Intent; import android.content.res.TypedArray; -import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; @@ -59,8 +58,7 @@ import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner; import androidx.lifecycle.ViewModelProviders; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; +import androidx.paging.PagedList; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -91,13 +89,12 @@ 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.conversationlist.ConversationListAdapter.ItemClickListener; +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.loaders.ConversationListLoader; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; @@ -118,7 +115,9 @@ 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.CachedInflater; 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; @@ -126,7 +125,6 @@ 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.Pair; import org.whispersystems.libsignal.util.guava.Optional; import java.util.Collections; @@ -138,9 +136,8 @@ import java.util.Set; import static android.app.Activity.RESULT_OK; -public class ConversationListFragment extends MainFragment implements LoaderManager.LoaderCallbacks, - ActionMode.Callback, - ItemClickListener, +public class ConversationListFragment extends MainFragment implements ActionMode.Callback, + ConversationPagedListAdapter.OnConversationClickListener, ConversationListSearchAdapter.EventListener, MainNavigator.BackHandler, MegaphoneActionController @@ -157,23 +154,24 @@ public class ConversationListFragment extends MainFragment implements LoaderMana 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 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 ConversationPagedListAdapter defaultAdapter; + private ConversationListSearchAdapter searchAdapter; + private StickyHeaderDecoration searchAdapterDecoration; + private ViewGroup megaphoneContainer; + private SnapToTopDataObserver snapToTopDataObserver; public static ConversationListFragment newInstance() { return new ConversationListFragment(); @@ -214,10 +212,14 @@ public class ConversationListFragment extends MainFragment implements LoaderMana reminderView.setOnDismissListener(this::updateReminders); list.setHasFixedSize(true); - list.setLayoutManager(new LinearLayoutManager(getActivity())); + list.setLayoutManager(new LinearLayoutManager(requireActivity())); list.setItemAnimator(new DeleteItemAnimator()); list.addOnScrollListener(new ScrollListener()); + CachedInflater.from(requireContext()).cacheUntilLimit(R.layout.conversation_list_item_view, list, 20); + + snapToTopDataObserver = new SnapToTopDataObserver(list, null); + new ItemTouchHelper(new ArchiveListenerCallback()).attachToRecyclerView(list); fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class))); @@ -247,7 +249,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana super.onResume(); updateReminders(); - list.getAdapter().notifyDataSetChanged(); EventBus.getDefault().register(this); if (TextSecurePreferences.isSmsEnabled(requireContext())) { @@ -257,9 +258,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana SimpleTask.run(getLifecycle(), Recipient::self, this::initializeProfileIcon); if (!searchToolbar.isVisible() && list.getAdapter() != defaultAdapter) { - activeAdapter = defaultAdapter; list.removeItemDecoration(searchAdapterDecoration); - list.setAdapter(defaultAdapter); + setAdapter(defaultAdapter); } } @@ -314,9 +314,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana private boolean closeSearchIfOpen() { if (searchToolbar.isVisible() || activeAdapter == searchAdapter) { - activeAdapter = defaultAdapter; list.removeItemDecoration(searchAdapterDecoration); - list.setAdapter(defaultAdapter); + setAdapter(defaultAdapter); searchToolbar.collapse(); return true; } @@ -356,6 +355,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana -1); } + @Override + public void onShowArchiveClick() { + getNavigator().goToArchiveList(); + } + @Override public void onContactClicked(@NonNull Recipient contact) { SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { @@ -440,16 +444,14 @@ public class ConversationListFragment extends MainFragment implements LoaderMana if (trimmed.length() > 0) { if (activeAdapter != searchAdapter) { - activeAdapter = searchAdapter; - list.setAdapter(searchAdapter); + setAdapter(searchAdapter); list.removeItemDecoration(searchAdapterDecoration); list.addItemDecoration(searchAdapterDecoration); } } else { if (activeAdapter != defaultAdapter) { - activeAdapter = defaultAdapter; list.removeItemDecoration(searchAdapterDecoration); - list.setAdapter(defaultAdapter); + setAdapter(defaultAdapter); } } } @@ -457,19 +459,36 @@ public class ConversationListFragment extends MainFragment implements LoaderMana @Override public void onSearchClosed() { list.removeItemDecoration(searchAdapterDecoration); - list.setAdapter(defaultAdapter); + setAdapter(defaultAdapter); } }); } private void initializeListAdapters() { - defaultAdapter = new ConversationListAdapter (requireContext(), GlideApp.with(this), Locale.getDefault(), null, this); - searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault () ); + defaultAdapter = new ConversationPagedListAdapter(GlideApp.with(this), this); + searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault()); searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false); - activeAdapter = defaultAdapter; - list.setAdapter(defaultAdapter); - LoaderManager.getInstance(this).restartLoader(0, null, this); + 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() { @@ -482,11 +501,17 @@ public class ConversationListFragment extends MainFragment implements LoaderMana }); } + protected boolean isArchived() { + return false; + } + private void initializeViewModel() { - viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory()).get(ConversationListViewModel.class); + 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); + viewModel.getArchivedCount().observe(this, defaultAdapter::updateArchived); ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() { @Override @@ -733,14 +758,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1); } - @Override - public @NonNull Loader onCreateLoader(int arg0, Bundle arg1) { - return new ConversationListLoader(getActivity(), null, false); - } - - @Override - public void onLoadFinished(@NonNull Loader arg0, Cursor cursor) { - if (cursor == null || cursor.getCount() <= 0) { + protected void onSubmitList(@NonNull PagedList pagedList) { + if (pagedList.size() == 0) { list.setVisibility(View.INVISIBLE); emptyState.setVisibility(View.VISIBLE); emptyImage.setImageResource(EMPTY_IMAGES[(int) (Math.random() * EMPTY_IMAGES.length)]); @@ -753,45 +772,33 @@ public class ConversationListFragment extends MainFragment implements LoaderMana cameraFab.stopPulse(); } - defaultAdapter.changeCursor(cursor); + defaultAdapter.submitList(pagedList); } @Override - public void onLoaderReset(@NonNull Loader arg0) { - defaultAdapter.changeCursor(null); - } - - @Override - public void onItemClick(ConversationListItem item) { + public void onConversationClick(Conversation conversation) { if (actionMode == null) { - handleCreateConversation(item.getThreadId(), item.getRecipient(), item.getDistributionType()); + handleCreateConversation(conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient(), conversation.getThreadRecord().getDistributionType()); } else { - ConversationListAdapter adapter = (ConversationListAdapter)list.getAdapter(); - adapter.toggleThreadInBatchSet(item.getThread()); + defaultAdapter.toggleConversationInBatchSet(conversation); - if (adapter.getBatchSelectionIds().size() == 0) { + if (defaultAdapter.getBatchSelectionIds().size() == 0) { actionMode.finish(); } else { actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size())); setCorrectMenuVisibility(actionMode.getMenu()); } - - adapter.notifyDataSetChanged(); } } @Override - public void onItemLongClick(ConversationListItem item) { + public boolean onConversationLongClick(Conversation conversation) { actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this); defaultAdapter.initializeBatchMode(true); - defaultAdapter.toggleThreadInBatchSet(item.getThread()); - defaultAdapter.notifyDataSetChanged(); - } + defaultAdapter.toggleConversationInBatchSet(conversation); - @Override - public void onSwitchToArchive() { - getNavigator().goToArchiveList(); + return true; } @Override @@ -870,7 +877,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana } private void setCorrectMenuVisibility(@NonNull Menu menu) { - boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(thread -> !thread.isRead()); + boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead()); if (hasUnread) { menu.findItem(R.id.menu_mark_as_unread).setVisible(false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index 2468119eb2..8c5a94df63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -174,28 +174,19 @@ public class ConversationListItem extends RelativeLayout this.fromView.setText(recipient.get(), thread.isRead()); } - if (typingThreads.contains(threadId)) { - this.subjectView.setVisibility(INVISIBLE); + updateTypingIndicator(typingThreads); - this.typingView.setVisibility(VISIBLE); - this.typingView.startAnimation(); - } else { - this.typingView.setVisibility(GONE); - this.typingView.stopAnimation(); + this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread))); - this.subjectView.setVisibility(VISIBLE); - this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread))); - - if (thread.getGroupAddedBy() != null) { - groupAddedBy = Recipient.live(thread.getGroupAddedBy()); - groupAddedBy.observeForever(groupAddedByObserver); - } - - this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE); - this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color) - : ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color)); + if (thread.getGroupAddedBy() != null) { + groupAddedBy = Recipient.live(thread.getGroupAddedBy()); + groupAddedBy.observeForever(groupAddedByObserver); } + this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE); + this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color) + : ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color)); + if (thread.getDate() > 0) { CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate()); dateView.setText(date); @@ -291,11 +282,27 @@ public class ConversationListItem extends RelativeLayout } } - private void setBatchMode(boolean batchMode) { + @Override + public void setBatchMode(boolean batchMode) { this.batchMode = batchMode; setSelected(batchMode && selectedThreads.contains(thread.getThreadId())); } + @Override + public void updateTypingIndicator(@NonNull Set typingThreads) { + if (typingThreads.contains(threadId)) { + this.subjectView.setVisibility(INVISIBLE); + + this.typingView.setVisibility(VISIBLE); + this.typingView.startAnimation(); + } else { + this.typingView.setVisibility(GONE); + this.typingView.stopAnimation(); + + this.subjectView.setVisibility(VISIBLE); + } + } + public Recipient getRecipient() { return recipient.get(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java index 0bf81c0052..c1dfba5ce7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemAction.java @@ -55,4 +55,14 @@ public class ConversationListItemAction extends LinearLayout implements Bindable public void unbind() { } + + @Override + public void setBatchMode(boolean batchMode) { + + } + + @Override + public void updateTypingIndicator(@NonNull Set typingThreads) { + + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java index 638b6905f4..2fb3b56f1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItemInboxZero.java @@ -49,4 +49,14 @@ public class ConversationListItemInboxZero extends LinearLayout implements Binda { } + + @Override + public void setBatchMode(boolean batchMode) { + + } + + @Override + public void updateTypingIndicator(@NonNull Set typingThreads) { + + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index 11c5f00d57..6d758ced74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -10,9 +10,14 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; +import androidx.paging.DataSource; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; import org.thoughtcrime.securesms.conversationlist.model.SearchResult; import org.thoughtcrime.securesms.database.DatabaseContentProviders; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.megaphone.Megaphone; import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; @@ -20,35 +25,61 @@ import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.search.SearchRepository; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.paging.Invalidator; class ConversationListViewModel extends ViewModel { - private final Application application; - private final MutableLiveData megaphone; - private final MutableLiveData searchResult; - private final SearchRepository searchRepository; - private final MegaphoneRepository megaphoneRepository; - private final Debouncer debouncer; - private final ContentObserver observer; + private final Application application; + private final MutableLiveData megaphone; + private final MutableLiveData searchResult; + private final LiveData> conversationList; + private final MutableLiveData archivedCount; + private final SearchRepository searchRepository; + private final MegaphoneRepository megaphoneRepository; + private final Debouncer debouncer; + private final ContentObserver observer; + private final Invalidator invalidator; private String lastQuery; - private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository) { + private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) { this.application = application; this.megaphone = new MutableLiveData<>(); this.searchResult = new MutableLiveData<>(); + this.archivedCount = new MutableLiveData<>(); this.searchRepository = searchRepository; this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository(); this.debouncer = new Debouncer(300); + this.invalidator = new Invalidator(); this.observer = new ContentObserver(new Handler()) { @Override public void onChange(boolean selfChange) { if (!TextUtils.isEmpty(getLastQuery())) { searchRepository.query(getLastQuery(), searchResult::postValue); } + + if (!isArchived) { + updateArchivedCount(); + } } }; + DataSource.Factory factory = new ConversationListDataSource.Factory(application, invalidator, isArchived); + PagedList.Config config = new PagedList.Config.Builder() + .setPageSize(15) + .setInitialLoadSizeHint(30) + .setEnablePlaceholders(false) + .build(); + + this.conversationList = new LivePagedListBuilder<>(factory, config).setFetchExecutor(ConversationListDataSource.EXECUTOR) + .setInitialLoadKey(0) + .build(); + + if (!isArchived) { + updateArchivedCount(); + } + application.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, observer); } @@ -60,6 +91,14 @@ class ConversationListViewModel extends ViewModel { return megaphone; } + @NonNull LiveData> getConversationList() { + return conversationList; + } + + @NonNull LiveData getArchivedCount() { + return archivedCount; + } + void onVisible() { megaphoneRepository.getNextMegaphone(megaphone::postValue); } @@ -95,15 +134,29 @@ class ConversationListViewModel extends ViewModel { @Override protected void onCleared() { + invalidator.invalidate(); debouncer.clear(); application.getContentResolver().unregisterContentObserver(observer); } + private void updateArchivedCount() { + SignalExecutors.BOUNDED.execute(() -> { + archivedCount.postValue(DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount()); + }); + } + public static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final boolean isArchived; + + public Factory(boolean isArchived) { + this.isArchived = isArchived; + } + @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions - return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository())); + return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(), isArchived)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationPagedListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationPagedListAdapter.java new file mode 100644 index 0000000000..2ae421bd5f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationPagedListAdapter.java @@ -0,0 +1,252 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.paging.PagedListAdapter; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.BindableConversationListItem; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.CachedInflater; +import org.thoughtcrime.securesms.util.Stopwatch; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +class ConversationPagedListAdapter extends PagedListAdapter { + + private enum Payload { + TYPING_INDICATOR, + SELECTION + } + + private final GlideRequests glideRequests; + private final OnConversationClickListener onConversationClickListener; + private final Map batchSet = Collections.synchronizedMap(new HashMap<>()); + private boolean batchMode = false; + private final Set typingSet = new HashSet<>(); + private int archived; + + protected ConversationPagedListAdapter(@NonNull GlideRequests glideRequests, @NonNull OnConversationClickListener onConversationClickListener) { + super(new ConversationDiffCallback()); + + this.glideRequests = glideRequests; + this.onConversationClickListener = onConversationClickListener; + } + + @Override + public @NonNull ConversationViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == R.layout.conversation_list_item_action) { + ConversationViewHolder holder = new ConversationViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(viewType, parent, false)); + + holder.itemView.setOnClickListener(v -> { + int position = holder.getAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + onConversationClickListener.onShowArchiveClick(); + } + }); + + return holder; + } else { + ConversationViewHolder holder = new ConversationViewHolder(CachedInflater.from(parent.getContext()) + .inflate(viewType, parent, false)); + + holder.itemView.setOnClickListener(v -> { + int position = holder.getAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + onConversationClickListener.onConversationClick(getItem(position)); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + int position = holder.getAdapterPosition(); + + if (position != RecyclerView.NO_POSITION) { + return onConversationClickListener.onConversationLongClick(getItem(position)); + } + + return false; + }); + return holder; + } + } + + @Override + public void onBindViewHolder(@NonNull ConversationViewHolder holder, int position, @NonNull List payloads) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position); + } else { + for (Object payloadObject : payloads) { + if (payloadObject instanceof Payload) { + Payload payload = (Payload) payloadObject; + + if (payload == Payload.SELECTION) { + holder.getConversationListItem().setBatchMode(batchMode); + } else { + holder.getConversationListItem().updateTypingIndicator(typingSet); + } + } + } + } + } + + @Override + public void onBindViewHolder(@NonNull ConversationViewHolder holder, int position) { + if (holder.getItemViewType() == R.layout.conversation_list_item_action) { + holder.getConversationListItem().bind(new ThreadRecord.Builder(100) + .setBody("") + .setDate(100) + .setRecipient(Recipient.UNKNOWN) + .setCount(archived) + .build(), + glideRequests, + Locale.getDefault(), + typingSet, + getBatchSelectionIds(), + batchMode); + } else { + Conversation conversation = Objects.requireNonNull(getItem(position)); + + holder.getConversationListItem().bind(conversation.getThreadRecord(), + glideRequests, + conversation.getLocale(), + typingSet, + getBatchSelectionIds(), + batchMode); + } + } + + @Override + public void onViewRecycled(@NonNull ConversationViewHolder holder) { + holder.getConversationListItem().unbind(); + } + + void setTypingThreads(@NonNull Set typingThreadSet) { + this.typingSet.clear(); + this.typingSet.addAll(typingThreadSet); + + notifyItemRangeChanged(0, getItemCount(), Payload.TYPING_INDICATOR); + } + + void toggleConversationInBatchSet(@NonNull Conversation conversation) { + if (batchSet.containsKey(conversation.getThreadRecord().getThreadId())) { + batchSet.remove(conversation.getThreadRecord().getThreadId()); + } else if (conversation.getThreadRecord().getThreadId() != -1) { + batchSet.put(conversation.getThreadRecord().getThreadId(), conversation); + } + + notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION); + } + + Collection getBatchSelection() { + return batchSet.values(); + } + + void updateArchived(int archived) { + int oldArchived = this.archived; + + this.archived = archived; + + if (oldArchived != archived) { + if (archived == 0) { + notifyItemRemoved(getItemCount()); + } else if (oldArchived == 0) { + notifyItemInserted(getItemCount() - 1); + } else { + notifyItemChanged(getItemCount() - 1); + } + } + } + + @Override + public int getItemCount() { + return (archived > 0 ? 1 : 0) + super.getItemCount(); + } + + @Override + public int getItemViewType(int position) { + if (archived > 0 && position == getItemCount() - 1) { + return R.layout.conversation_list_item_action; + } else { + return R.layout.conversation_list_item_view; + } + } + + @NonNull Set getBatchSelectionIds() { + return batchSet.keySet(); + } + + void selectAllThreads() { + for (int i = 0; i < getItemCount(); i++) { + Conversation conversation = getItem(i); + if (conversation != null && conversation.getThreadRecord().getThreadId() != -1) { + batchSet.put(conversation.getThreadRecord().getThreadId(), conversation); + } + } + + notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION); + } + + void initializeBatchMode(boolean toggle) { + this.batchMode = toggle; + unselectAllThreads(); + } + + private void unselectAllThreads() { + batchSet.clear(); + + notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION); + } + + static final class ConversationViewHolder extends RecyclerView.ViewHolder { + + private final BindableConversationListItem conversationListItem; + + ConversationViewHolder(@NonNull View itemView) { + super(itemView); + + conversationListItem = (BindableConversationListItem) itemView; + } + + public BindableConversationListItem getConversationListItem() { + return conversationListItem; + } + } + + private static final class ConversationDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull Conversation oldItem, @NonNull Conversation newItem) { + return oldItem.getThreadRecord().getThreadId() == newItem.getThreadRecord().getThreadId(); + } + + @Override + public boolean areContentsTheSame(@NonNull Conversation oldItem, @NonNull Conversation newItem) { + return oldItem.equals(newItem); + } + } + + interface OnConversationClickListener { + void onConversationClick(Conversation conversation); + boolean onConversationLongClick(Conversation conversation); + void onShowArchiveClick(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java new file mode 100644 index 0000000000..16f736e24f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/Conversation.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.conversationlist.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.ThreadRecord; + +import java.util.Locale; +import java.util.Objects; + +public class Conversation { + private final ThreadRecord threadRecord; + private final Locale locale; + + public Conversation(@NonNull ThreadRecord threadRecord, @NonNull Locale locale) { + this.threadRecord = threadRecord; + this.locale = locale; + } + + public @NonNull ThreadRecord getThreadRecord() { + return threadRecord; + } + + public @NonNull Locale getLocale() { + return locale; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Conversation that = (Conversation) o; + return threadRecord.equals(that.threadRecord) && + locale.equals(that.locale); + } + + @Override + public int hashCode() { + return Objects.hash(threadRecord, locale); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 6249f5d9f5..525bd13faa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -552,9 +552,21 @@ public class ThreadDatabase extends Database { return positions; } + public Cursor getConversationList(long offset, long limit) { + return getConversationList("0", offset, limit); + } + + public Cursor getArchivedConversationList(long offset, long limit) { + return getConversationList("1", offset, limit); + } + private Cursor getConversationList(String archived) { + return getConversationList(archived, 0, 0); + } + + private Cursor getConversationList(@NonNull String archived, long offset, long limit) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = createQuery(ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0", 0); + String query = createQuery(ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0", offset, limit); Cursor cursor = db.rawQuery(query, new String[]{archived}); setNotifyConversationListListeners(cursor); @@ -562,20 +574,24 @@ public class ThreadDatabase extends Database { return cursor; } + public int getUnarchivedConversationListCount() { + return getConversationListCount(false); + } + public int getArchivedConversationListCount() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; + return getConversationListCount(true); + } - try { - cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, ARCHIVED + " = ?", - new String[] {"1"}, null, null, null); + private int getConversationListCount(boolean archived) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] columns = new String[] { "COUNT(*)" }; + String query = ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0"; + String[] args = new String[] { archived ? "1" : "0" }; + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { return cursor.getInt(0); } - - } finally { - if (cursor != null) cursor.close(); } return 0; @@ -854,7 +870,11 @@ public class ThreadDatabase extends Database { return null; } - private @NonNull String createQuery(@NonNull String where, int limit) { + private @NonNull String createQuery(@NonNull String where, long limit) { + return createQuery(where, 0, limit); + } + + private @NonNull String createQuery(@NonNull String where, long offset, long limit) { String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); String query = "SELECT " + projection + " FROM " + TABLE_NAME + @@ -869,6 +889,10 @@ public class ThreadDatabase extends Database { query += " LIMIT " + limit; } + if (offset > 0) { + query += " OFFSET " + offset; + } + return query; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java deleted file mode 100644 index 2f04e1c6fd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationListLoader.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.thoughtcrime.securesms.database.loaders; - -import android.content.Context; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.database.MergeCursor; - -import org.thoughtcrime.securesms.contacts.ContactAccessor; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.AbstractCursorLoader; - -import java.util.LinkedList; -import java.util.List; - -public class ConversationListLoader extends AbstractCursorLoader { - - private final String filter; - private final boolean archived; - - public ConversationListLoader(Context context, String filter, boolean archived) { - super(context); - this.filter = filter; - this.archived = archived; - } - - @Override - public Cursor getCursor() { - if (filter != null && filter.trim().length() != 0) return getFilteredConversationList(filter); - else if (!archived) return getUnarchivedConversationList(); - else return getArchivedConversationList(); - } - - private Cursor getUnarchivedConversationList() { - List cursorList = new LinkedList<>(); - cursorList.add(DatabaseFactory.getThreadDatabase(context).getConversationList()); - - int archivedCount = DatabaseFactory.getThreadDatabase(context) - .getArchivedConversationListCount(); - - if (archivedCount > 0) { - MatrixCursor switchToArchiveCursor = new MatrixCursor(new String[] { - ThreadDatabase.ID, ThreadDatabase.DATE, ThreadDatabase.MESSAGE_COUNT, - ThreadDatabase.RECIPIENT_ID, ThreadDatabase.SNIPPET, ThreadDatabase.READ, ThreadDatabase.UNREAD_COUNT, - ThreadDatabase.TYPE, ThreadDatabase.SNIPPET_TYPE, ThreadDatabase.SNIPPET_URI, - ThreadDatabase.SNIPPET_CONTENT_TYPE, ThreadDatabase.SNIPPET_EXTRAS, - ThreadDatabase.ARCHIVED, ThreadDatabase.STATUS, ThreadDatabase.DELIVERY_RECEIPT_COUNT, - ThreadDatabase.EXPIRES_IN, ThreadDatabase.LAST_SEEN, ThreadDatabase.READ_RECEIPT_COUNT}, 1); - - - if (cursorList.get(0).getCount() <= 0) { - switchToArchiveCursor.addRow(new Object[] {-1L, System.currentTimeMillis(), archivedCount, - "-1", null, 1, 0, ThreadDatabase.DistributionTypes.INBOX_ZERO, - 0, null, null, null, 0, -1, 0, 0, 0, -1}); - } - - switchToArchiveCursor.addRow(new Object[] {-1L, System.currentTimeMillis(), archivedCount, - "-1", null, 1, 0, ThreadDatabase.DistributionTypes.ARCHIVE, - 0, null, null, null, 0, -1, 0, 0, 0, -1}); - - cursorList.add(switchToArchiveCursor); - } - - return new MergeCursor(cursorList.toArray(new Cursor[0])); - } - - private Cursor getArchivedConversationList() { - return DatabaseFactory.getThreadDatabase(context).getArchivedConversationList(); - } - - private Cursor getFilteredConversationList(String filter) { - List numbers = ContactAccessor.getInstance().getNumbersForThreadSearchFilter(context, filter); - List recipientIds = new LinkedList<>(); - - for (String number : numbers) { - recipientIds.add(Recipient.external(context, number).getId()); - } - - return DatabaseFactory.getThreadDatabase(context).getFilteredConversationList(recipientIds); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index b85ff41d83..8b38a1d3e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -187,6 +187,53 @@ public final class ThreadRecord { else return true; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ThreadRecord that = (ThreadRecord) o; + return threadId == that.threadId && + type == that.type && + date == that.date && + deliveryStatus == that.deliveryStatus && + deliveryReceiptCount == that.deliveryReceiptCount && + readReceiptCount == that.readReceiptCount && + count == that.count && + unreadCount == that.unreadCount && + forcedUnread == that.forcedUnread && + distributionType == that.distributionType && + archived == that.archived && + expiresIn == that.expiresIn && + lastSeen == that.lastSeen && + body.equals(that.body) && + recipient.equals(that.recipient) && + Objects.equals(snippetUri, that.snippetUri) && + Objects.equals(contentType, that.contentType) && + Objects.equals(extra, that.extra); + } + + @Override + public int hashCode() { + return Objects.hash(threadId, + body, + recipient, + type, + date, + deliveryStatus, + deliveryReceiptCount, + readReceiptCount, + snippetUri, + contentType, + extra, + count, + unreadCount, + forcedUnread, + distributionType, + archived, + expiresIn, + lastSeen); + } + public static class Builder { private long threadId; private String body; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Deferred.java b/app/src/main/java/org/thoughtcrime/securesms/util/Deferred.java new file mode 100644 index 0000000000..4843ca24cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Deferred.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; + +public class Deferred { + + private Runnable deferred; + private boolean isDeferred = true; + + public void defer(@Nullable Runnable deferred) { + this.deferred = deferred; + executeIfNecessary(); + } + + public void setDeferred(boolean isDeferred) { + this.isDeferred = isDeferred; + executeIfNecessary(); + } + + public boolean isDeferred() { + return isDeferred; + } + + private void executeIfNecessary() { + if (deferred != null && !isDeferred) { + Runnable local = deferred; + + deferred = null; + + local.run(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java b/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java new file mode 100644 index 0000000000..da3dd98b86 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java @@ -0,0 +1,182 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.CheckResult; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Objects; + +/** + * Helper class to scroll to the top of a RecyclerView when new data is inserted. + * This works for both newly inserted data and moved data. It applies the following rules: + * + *
    + *
  • If the user is currently scrolled to some position, then we will not snap.
  • + *
  • If the user is currently dragging, then we will not snap.
  • + *
  • If the user has requested a scroll position, then we will only snap to that position.
  • + *
+ */ +public class SnapToTopDataObserver extends RecyclerView.AdapterDataObserver { + + private final RecyclerView recyclerView; + private final LinearLayoutManager layoutManager; + private final Deferred deferred; + private final ScrollRequestValidator scrollRequestValidator; + + public SnapToTopDataObserver(@NonNull RecyclerView recyclerView, + @Nullable ScrollRequestValidator scrollRequestValidator) + { + this.recyclerView = recyclerView; + this.layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); + this.deferred = new Deferred(); + this.scrollRequestValidator = scrollRequestValidator; + } + + /** + * Requests a scroll to a specific position. This call will defer until the position is loaded or + * becomes invalid. + * + * @param position The position to scroll to. + */ + public void requestScrollPosition(int position) { + buildScrollPosition(position).submit(); + } + + /** + * Creates a ScrollRequestBuilder which can be used to customize a particular scroll request with + * different callbacks. Don't forget to call `submit()`! + * + * @param position The position to scroll to. + * @return A ScrollRequestBuilder that must be submitted once you are satisfied with it. + */ + @CheckResult(suggest = "#requestScrollPosition(int)") + public ScrollRequestBuilder buildScrollPosition(int position) { + return new ScrollRequestBuilder(position); + } + + /** + * Requests that instead of snapping to top, we should scroll to a specific position in the adapter. + * It is up to the caller to ensure that the adapter will load the appropriate data, either by + * invalidating and restarting the page load at the appropriate position or by utilizing + * PagedList#loadAround(int). + * + * @param position The position to scroll to. + * @param onPerformScroll Callback allowing the caller to perform the scroll themselves. + * @param onScrollRequestComplete Notification that the scroll has completed successfully. + * @param onInvalidPosition Notification that the requested position has become invalid. + */ + private void requestScrollPositionInternal(int position, + @NonNull OnPerformScroll onPerformScroll, + @NonNull Runnable onScrollRequestComplete, + @NonNull Runnable onInvalidPosition) + { + Objects.requireNonNull(scrollRequestValidator, "Cannot request positions when SnapToTopObserver was initialized without a validator."); + + if (!scrollRequestValidator.isPositionStillValid(position)) { + onInvalidPosition.run(); + } else if (scrollRequestValidator.isItemAtPositionLoaded(position)) { + recyclerView.post(() -> { + onPerformScroll.onPerformScroll(layoutManager, position); + onScrollRequestComplete.run(); + }); + } else { + deferred.setDeferred(true); + deferred.defer(() -> requestScrollPositionInternal(position, onPerformScroll, onScrollRequestComplete, onInvalidPosition)); + } + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + snapToTopIfNecessary(toPosition); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + snapToTopIfNecessary(positionStart); + } + + private void snapToTopIfNecessary(int newItemPosition) { + if (deferred.isDeferred()) { + deferred.setDeferred(false); + return; + } + + if (newItemPosition != 0 || + recyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE || + recyclerView.canScrollVertically(layoutManager.getReverseLayout() ? 1 : -1)) { + return; + } + + if (layoutManager.findFirstVisibleItemPosition() == 0) { + layoutManager.scrollToPosition(0); + } + } + + public interface ScrollRequestValidator { + /** + * This method is responsible for determining whether a given position is still a valid jump target. + * @param position The position to validate + * @return Whether the position is valid + */ + boolean isPositionStillValid(int position); + + /** + * This method is responsible for checking whether the desired position is available to be jumped to. + * In the case of a PagedListAdapter, it is whether getItem returns a non-null value. + * @param position The position to check for. + * @return Whether or not the data for the given position is loaded. + */ + boolean isItemAtPositionLoaded(int position); + } + + public interface OnPerformScroll { + /** + * This method is responsible for actually performing the requested scroll. It is always called + * immediately before the onScrollRequestComplete callback, and is always run via recyclerView.post(...) + * so you don't have to do this yourself. + * + * By default, SnapToTopDataObserver will utilize layoutManager.scrollToPosition. This lets you modify that + * behavior, and also gives you a chance to perform actions just before scrolling occurs. + * + * @param layoutManager The layoutManager containing your items. + * @param position The position to scroll to. + */ + void onPerformScroll(@NonNull LinearLayoutManager layoutManager, int position); + } + + public final class ScrollRequestBuilder { + private final int position; + + private OnPerformScroll onPerformScroll = LinearLayoutManager::scrollToPosition; + private Runnable onScrollRequestComplete = () -> {}; + private Runnable onInvalidPosition = () -> {}; + + public ScrollRequestBuilder(int position) { + this.position = position; + } + + @CheckResult + public ScrollRequestBuilder withOnPerformScroll(@NonNull OnPerformScroll onPerformScroll) { + this.onPerformScroll = onPerformScroll; + return this; + } + + @CheckResult + public ScrollRequestBuilder withOnScrollRequestComplete(@NonNull Runnable onScrollRequestComplete) { + this.onScrollRequestComplete = onScrollRequestComplete; + return this; + } + + @CheckResult + public ScrollRequestBuilder withOnInvalidPosition(@NonNull Runnable onInvalidPosition) { + this.onInvalidPosition = onInvalidPosition; + return this; + } + + public void submit() { + requestScrollPositionInternal(position, onPerformScroll, onScrollRequestComplete, onInvalidPosition); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/paging/Invalidator.java b/app/src/main/java/org/thoughtcrime/securesms/util/paging/Invalidator.java new file mode 100644 index 0000000000..995f41652d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/paging/Invalidator.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.util.paging; + +import androidx.annotation.NonNull; + +public class Invalidator { + private Runnable callback; + + public synchronized void invalidate() { + if (callback != null) { + callback.run(); + } + } + + public synchronized void observe(@NonNull Runnable callback) { + this.callback = callback; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/paging/SizeFixResult.java b/app/src/main/java/org/thoughtcrime/securesms/util/paging/SizeFixResult.java new file mode 100644 index 0000000000..13e34b5802 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/paging/SizeFixResult.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.util.paging; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.logging.Log; + +import java.util.List; + +public class SizeFixResult { + + private static final String TAG = Log.tag(SizeFixResult.class); + + final List items; + final int total; + + private SizeFixResult(@NonNull List items, int total) { + this.items = items; + this.total = total; + } + + public List getItems() { + return items; + } + + public int getTotal() { + return total; + } + + public static @NonNull SizeFixResult ensureMultipleOfPageSize(@NonNull List records, + int startPosition, + int pageSize, + int total) + { + if (records.size() + startPosition == total || (records.size() != 0 && records.size() % pageSize == 0)) { + return new SizeFixResult<>(records, total); + } + + if (records.size() < pageSize) { + Log.w(TAG, "Hit a miscalculation where we don't have the full dataset, but it's smaller than a page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total); + return new SizeFixResult<>(records, records.size() + startPosition); + } + + Log.w(TAG, "Hit a miscalculation where our data size isn't a multiple of the page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total); + int overflow = records.size() % pageSize; + + return new SizeFixResult<>(records.subList(0, records.size() - overflow), total); + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/DeferredTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/DeferredTest.java new file mode 100644 index 0000000000..ec48b08b54 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/DeferredTest.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.util; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class DeferredTest { + + private int accumulator = 0; + + private final Runnable incrementAccumulator = () -> accumulator++; + private final Deferred testSubject = new Deferred(); + + @Test + public void givenANullRunnable_whenISetDeferredToFalse_thenIDoNotThrow() { + // GIVEN + testSubject.defer(null); + + // WHEN + testSubject.setDeferred(false); + } + + @Test + public void givenADeferredRunnable_whenIDeferADifferentRunnableAndSetDeferredFalse_thenIExpectOnlySecondRunnableToExecute() { + // GIVEN + testSubject.defer(() -> fail("This runnable should never execute")); + + // WHEN + testSubject.defer(incrementAccumulator); + testSubject.setDeferred(false); + + // THEN + assertEquals(1, accumulator); + } + + @Test + public void givenSetDeferredFalse_whenIDeferARunnable_thenIExecuteImmediately() { + // GIVEN + testSubject.setDeferred(false); + + // WHEN + testSubject.defer(incrementAccumulator); + + // THEN + assertEquals(1, accumulator); + } + + @Test + public void givenSetDeferredFalse_whenISetToTrueAndDeferRunnable_thenIDoNotExecute() { + // GIVEN + testSubject.setDeferred(false); + + // WHEN + testSubject.setDeferred(true); + testSubject.defer(incrementAccumulator); + + // THEN + assertEquals(0, accumulator); + } + + @Test + public void givenDeferredRunnable_whenIDeferNullAndSetDeferredFalse_thenIDoNotExecute() { + // GIVEN + testSubject.defer(incrementAccumulator); + + // WHEN + testSubject.defer(null); + testSubject.setDeferred(true); + + // THEN + assertEquals(0, accumulator); + } +} \ No newline at end of file