mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-09 05:38:33 +00:00
Migrate ConversationList to paging library and apply abstractions to conversation.
This commit is contained in:
parent
ce940235b0
commit
49f75d7036
@ -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);
|
||||
}
|
||||
|
@ -479,7 +479,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
return headerView != null;
|
||||
}
|
||||
|
||||
private boolean hasFooter() {
|
||||
public boolean hasFooter() {
|
||||
return footerView != null;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
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);
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user