Migrate ConversationList to paging library and apply abstractions to conversation.

This commit is contained in:
Alex Hart 2020-06-12 15:11:36 -03:00 committed by Greyson Parrelli
parent ce940235b0
commit 49f75d7036
25 changed files with 1212 additions and 622 deletions

View File

@ -14,4 +14,7 @@ public interface BindableConversationListItem extends Unbindable {
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads, boolean batchMode);
void setBatchMode(boolean batchMode);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
}

View File

@ -479,7 +479,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
return headerView != null;
}
private boolean hasFooter() {
public boolean hasFooter() {
return footerView != null;
}

View File

@ -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;
}
}

View File

@ -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<MessageRecord> {
}
if (!isInvalid()) {
SizeFixResult result = ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
SizeFixResult<MessageRecord> 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<MessageRecord> {
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
}
private static @NonNull SizeFixResult ensureMultipleOfPageSize(@NonNull List<MessageRecord> 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<MessageRecord> messages;
final int total;
private SizeFixResult(@NonNull List<MessageRecord> 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<Integer, MessageRecord> {
private final Context context;

View File

@ -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,8 +884,12 @@ public class ConversationFragment extends Fragment {
return;
}
Runnable afterScroll = () -> {
if (FeatureFlags.messageRequests()) {
adapter.setFooterView(conversationBanner);
if (!conversation.isMessageRequestAccepted()) {
snapToTopDataObserver.requestScrollPosition(adapter.getItemCount() - 1);
}
} else {
adapter.setFooterView(null);
}
@ -902,29 +907,29 @@ public class ConversationFragment extends Fragment {
}
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.");
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();
}
}
}
}

View File

@ -36,6 +36,7 @@ class ConversationRepository {
private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) {
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);
}
}

View File

@ -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<Long> distinctThread = Transformations.distinctUntilChanged(threadId);
LiveData<Long> distinctThread = Transformations.distinctUntilChanged(Transformations.map(messagesForThreadId, Pair::first));
conversationMetadata = Transformations.switchMap(distinctThread, thread -> metadata);
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<ConversationListAdapter.ViewHolder> {
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<Long, ThreadRecord> batchSet = Collections.synchronizedMap(new HashMap<>());
private boolean batchMode = false;
private final Set<Long> typingSet = new HashSet<>();
protected static class ViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationListItem> 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<Long> 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<Long> getBatchSelectionIds() {
return batchSet.keySet();
}
@NonNull Set<ThreadRecord> 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();
}
}

View File

@ -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<Cursor>, ActionMode.Callback, ItemClickListener
public class ConversationListArchiveFragment extends ConversationListFragment implements ActionMode.Callback
{
private RecyclerView list;
private View emptyState;
@ -86,18 +78,18 @@ public class ConversationListArchiveFragment extends ConversationListFragment
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
return new ConversationListLoader(getActivity(), null, true);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> arg0, Cursor cursor) {
super.onLoadFinished(arg0, cursor);
protected void onSubmitList(@NonNull PagedList<Conversation> 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;

View File

@ -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<Conversation> {
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<Conversation> callback) {
long start = System.currentTimeMillis();
List<Conversation> 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<Conversation> 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<Conversation> callback) {
long start = System.currentTimeMillis();
List<Conversation> 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<Integer, Conversation> {
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<Integer, Conversation> create() {
return ConversationListDataSource.create(context, invalidator, isArchived);
}
}
}

View File

@ -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<Cursor>,
ActionMode.Callback,
ItemClickListener,
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationPagedListAdapter.OnConversationClickListener,
ConversationListSearchAdapter.EventListener,
MainNavigator.BackHandler,
MegaphoneActionController
@ -170,10 +167,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
private View toolbarShadow;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
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<Cursor> onCreateLoader(int arg0, Bundle arg1) {
return new ConversationListLoader(getActivity(), null, false);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> arg0, Cursor cursor) {
if (cursor == null || cursor.getCount() <= 0) {
protected void onSubmitList(@NonNull PagedList<Conversation> 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<Cursor> 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);

View File

@ -174,16 +174,8 @@ 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.setVisibility(VISIBLE);
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
if (thread.getGroupAddedBy() != null) {
@ -194,7 +186,6 @@ public class ConversationListItem extends RelativeLayout
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());
@ -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<Long> 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();
}

View File

@ -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<Long> typingThreads) {
}
}

View File

@ -49,4 +49,14 @@ public class ConversationListItemInboxZero extends LinearLayout implements Binda
{
}
@Override
public void setBatchMode(boolean batchMode) {
}
@Override
public void updateTypingIndicator(@NonNull Set<Long> typingThreads) {
}
}

View File

@ -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> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final LiveData<PagedList<Conversation>> conversationList;
private final MutableLiveData<Integer> 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<Integer, Conversation> 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<PagedList<Conversation>> getConversationList() {
return conversationList;
}
@NonNull LiveData<Integer> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository()));
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(), isArchived));
}
}
}

View File

@ -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<Conversation, ConversationPagedListAdapter.ConversationViewHolder> {
private enum Payload {
TYPING_INDICATOR,
SELECTION
}
private final GlideRequests glideRequests;
private final OnConversationClickListener onConversationClickListener;
private final Map<Long, Conversation> batchSet = Collections.synchronizedMap(new HashMap<>());
private boolean batchMode = false;
private final Set<Long> 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<Object> 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<Long> 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<Conversation> 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<Long> 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<Conversation> {
@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();
}
}

View File

@ -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);
}
}

View File

@ -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() {
return getConversationListCount(true);
}
private int getConversationListCount(boolean archived) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, ARCHIVED + " = ?",
new String[] {"1"}, null, null, null);
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;
}

View File

@ -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<Cursor> 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<String> numbers = ContactAccessor.getInstance().getNumbersForThreadSearchFilter(context, filter);
List<RecipientId> recipientIds = new LinkedList<>();
for (String number : numbers) {
recipientIds.add(Recipient.external(context, number).getId());
}
return DatabaseFactory.getThreadDatabase(context).getFilteredConversationList(recipientIds);
}
}

View File

@ -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;

View File

@ -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();
}
}
}

View File

@ -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:
*
* <ul>
* <li>If the user is currently scrolled to some position, then we will not snap.</li>
* <li>If the user is currently dragging, then we will not snap.</li>
* <li>If the user has requested a scroll position, then we will only snap to that position.</li>
* </ul>
*/
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);
}
}
}

View File

@ -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;
}
}

View File

@ -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<T> {
private static final String TAG = Log.tag(SizeFixResult.class);
final List<T> items;
final int total;
private SizeFixResult(@NonNull List<T> items, int total) {
this.items = items;
this.total = total;
}
public List<T> getItems() {
return items;
}
public int getTotal() {
return total;
}
public static @NonNull <T> SizeFixResult<T> ensureMultipleOfPageSize(@NonNull List<T> 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);
}
}

View File

@ -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);
}
}