mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-09 09:28:34 +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 GlideRequests glideRequests, @NonNull Locale locale,
|
||||||
@NonNull Set<Long> typingThreads,
|
@NonNull Set<Long> typingThreads,
|
||||||
@NonNull Set<Long> selectedThreads, boolean batchMode);
|
@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;
|
return headerView != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasFooter() {
|
public boolean hasFooter() {
|
||||||
return footerView != null;
|
return footerView != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ final class ConversationData {
|
|||||||
private final boolean isMessageRequestAccepted;
|
private final boolean isMessageRequestAccepted;
|
||||||
private final boolean hasPreMessageRequestMessages;
|
private final boolean hasPreMessageRequestMessages;
|
||||||
private final int jumpToPosition;
|
private final int jumpToPosition;
|
||||||
|
private final int threadSize;
|
||||||
|
|
||||||
ConversationData(long threadId,
|
ConversationData(long threadId,
|
||||||
long lastSeen,
|
long lastSeen,
|
||||||
@ -20,7 +21,8 @@ final class ConversationData {
|
|||||||
boolean hasSent,
|
boolean hasSent,
|
||||||
boolean isMessageRequestAccepted,
|
boolean isMessageRequestAccepted,
|
||||||
boolean hasPreMessageRequestMessages,
|
boolean hasPreMessageRequestMessages,
|
||||||
int jumpToPosition)
|
int jumpToPosition,
|
||||||
|
int threadSize)
|
||||||
{
|
{
|
||||||
this.threadId = threadId;
|
this.threadId = threadId;
|
||||||
this.lastSeen = lastSeen;
|
this.lastSeen = lastSeen;
|
||||||
@ -30,6 +32,7 @@ final class ConversationData {
|
|||||||
this.isMessageRequestAccepted = isMessageRequestAccepted;
|
this.isMessageRequestAccepted = isMessageRequestAccepted;
|
||||||
this.hasPreMessageRequestMessages = hasPreMessageRequestMessages;
|
this.hasPreMessageRequestMessages = hasPreMessageRequestMessages;
|
||||||
this.jumpToPosition = jumpToPosition;
|
this.jumpToPosition = jumpToPosition;
|
||||||
|
this.threadSize = threadSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getThreadId() {
|
public long getThreadId() {
|
||||||
@ -71,4 +74,8 @@ final class ConversationData {
|
|||||||
int getJumpToPosition() {
|
int getJumpToPosition() {
|
||||||
return jumpToPosition;
|
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.logging.Log;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
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.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -76,9 +78,9 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isInvalid()) {
|
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" : ""));
|
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" : ""));
|
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> {
|
static class Factory extends DataSource.Factory<Integer, MessageRecord> {
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
|
@ -21,7 +21,6 @@ import android.app.Activity;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.graphics.Rect;
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Build;
|
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.HtmlUtil;
|
||||||
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
|
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
|
||||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||||
|
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
|
||||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
@ -163,8 +163,7 @@ public class ConversationFragment extends Fragment {
|
|||||||
private ConversationBannerView emptyConversationBanner;
|
private ConversationBannerView emptyConversationBanner;
|
||||||
private MessageRequestViewModel messageRequestViewModel;
|
private MessageRequestViewModel messageRequestViewModel;
|
||||||
private ConversationViewModel conversationViewModel;
|
private ConversationViewModel conversationViewModel;
|
||||||
|
private SnapToTopDataObserver snapToTopDataObserver;
|
||||||
private Deferred deferred = new Deferred();
|
|
||||||
|
|
||||||
public static void prepare(@NonNull Context context) {
|
public static void prepare(@NonNull Context context) {
|
||||||
FrameLayout parent = new FrameLayout(context);
|
FrameLayout parent = new FrameLayout(context);
|
||||||
@ -198,6 +197,8 @@ public class ConversationFragment extends Fragment {
|
|||||||
list.setLayoutManager(layoutManager);
|
list.setLayoutManager(layoutManager);
|
||||||
list.setItemAnimator(null);
|
list.setItemAnimator(null);
|
||||||
|
|
||||||
|
snapToTopDataObserver = new ConversationSnapToTopDataObserver(list, new ConversationScrollRequestValidator());
|
||||||
|
|
||||||
if (FeatureFlags.messageRequests()) {
|
if (FeatureFlags.messageRequests()) {
|
||||||
conversationBanner = (ConversationBannerView) inflater.inflate(R.layout.conversation_item_banner, container, false);
|
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");
|
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;
|
return view;
|
||||||
}
|
}
|
||||||
@ -329,7 +330,7 @@ public class ConversationFragment extends Fragment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int position = getListAdapter().getAdapterPositionForMessagePosition(conversationViewModel.getLastSeenPosition());
|
int position = getListAdapter().getAdapterPositionForMessagePosition(conversationViewModel.getLastSeenPosition());
|
||||||
scrollToPosition(position);
|
snapToTopDataObserver.requestScrollPosition(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeMessageRequestViewModel() {
|
private void initializeMessageRequestViewModel() {
|
||||||
@ -423,7 +424,7 @@ public class ConversationFragment extends Fragment {
|
|||||||
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
|
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
|
||||||
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient.get(), threadId, () -> clearHeaderIfNotTyping(getListAdapter()));
|
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient.get(), threadId, () -> clearHeaderIfNotTyping(getListAdapter()));
|
||||||
|
|
||||||
deferred.setDeferred(true);
|
snapToTopDataObserver.requestScrollPosition(startingPosition);
|
||||||
conversationViewModel.onConversationDataAvailable(threadId, startingPosition);
|
conversationViewModel.onConversationDataAvailable(threadId, startingPosition);
|
||||||
|
|
||||||
OnScrollListener scrollListener = new ConversationScrollListener(getActivity());
|
OnScrollListener scrollListener = new ConversationScrollListener(getActivity());
|
||||||
@ -442,7 +443,7 @@ public class ConversationFragment extends Fragment {
|
|||||||
list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false));
|
list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false));
|
||||||
ConversationAdapter.initializePool(list.getRecycledViewPool());
|
ConversationAdapter.initializePool(list.getRecycledViewPool());
|
||||||
|
|
||||||
adapter.registerAdapterDataObserver(new DataObserver());
|
adapter.registerAdapterDataObserver(snapToTopDataObserver);
|
||||||
|
|
||||||
setLastSeen(conversationViewModel.getLastSeen());
|
setLastSeen(conversationViewModel.getLastSeen());
|
||||||
|
|
||||||
@ -563,7 +564,7 @@ public class ConversationFragment extends Fragment {
|
|||||||
this.threadId = threadId;
|
this.threadId = threadId;
|
||||||
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
|
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
|
||||||
|
|
||||||
deferred.setDeferred(true);
|
snapToTopDataObserver.requestScrollPosition(0);
|
||||||
conversationViewModel.onConversationDataAvailable(threadId, -1);
|
conversationViewModel.onConversationDataAvailable(threadId, -1);
|
||||||
initializeListAdapter();
|
initializeListAdapter();
|
||||||
}
|
}
|
||||||
@ -883,48 +884,52 @@ public class ConversationFragment extends Fragment {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FeatureFlags.messageRequests()) {
|
Runnable afterScroll = () -> {
|
||||||
adapter.setFooterView(conversationBanner);
|
if (FeatureFlags.messageRequests()) {
|
||||||
} else {
|
adapter.setFooterView(conversationBanner);
|
||||||
adapter.setFooterView(null);
|
if (!conversation.isMessageRequestAccepted()) {
|
||||||
}
|
snapToTopDataObserver.requestScrollPosition(adapter.getItemCount() - 1);
|
||||||
|
}
|
||||||
setLastSeen(conversation.getLastSeen());
|
|
||||||
|
|
||||||
if (FeatureFlags.messageRequests() && !conversation.hasPreMessageRequestMessages()) {
|
|
||||||
clearHeaderIfNotTyping(adapter);
|
|
||||||
} else {
|
|
||||||
if (!conversation.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
|
|
||||||
adapter.setHeaderView(unknownSenderView);
|
|
||||||
} else {
|
} else {
|
||||||
clearHeaderIfNotTyping(adapter);
|
adapter.setFooterView(null);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
listener.onCursorChanged();
|
setLastSeen(conversation.getLastSeen());
|
||||||
|
|
||||||
|
if (FeatureFlags.messageRequests() && !conversation.hasPreMessageRequestMessages()) {
|
||||||
|
clearHeaderIfNotTyping(adapter);
|
||||||
|
} else {
|
||||||
|
if (!conversation.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
|
||||||
|
adapter.setHeaderView(unknownSenderView);
|
||||||
|
} else {
|
||||||
|
clearHeaderIfNotTyping(adapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.onCursorChanged();
|
||||||
|
};
|
||||||
|
|
||||||
int lastSeenPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastSeenPosition());
|
int lastSeenPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastSeenPosition());
|
||||||
int lastScrolledPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastScrolledPosition());
|
int lastScrolledPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastScrolledPosition());
|
||||||
|
|
||||||
if (conversation.shouldJumpToMessage()) {
|
if (conversation.getThreadSize() == 0) {
|
||||||
scrollToStartingPosition(conversation.getJumpToPosition());
|
afterScroll.run();
|
||||||
|
} else if (conversation.shouldJumpToMessage()) {
|
||||||
|
snapToTopDataObserver.buildScrollPosition(conversation.getJumpToPosition())
|
||||||
|
.withOnScrollRequestComplete(() -> {
|
||||||
|
afterScroll.run();
|
||||||
|
getListAdapter().pulseHighlightItem(conversation.getJumpToPosition());
|
||||||
|
})
|
||||||
|
.submit();
|
||||||
} else if (conversation.isMessageRequestAccepted()) {
|
} 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()) {
|
} else if (FeatureFlags.messageRequests()) {
|
||||||
list.post(() -> getListLayoutManager().scrollToPosition(adapter.getItemCount() - 1));
|
snapToTopDataObserver.buildScrollPosition(adapter.getItemCount() - 1)
|
||||||
}
|
.withOnScrollRequestComplete(afterScroll)
|
||||||
}
|
.submit();
|
||||||
|
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -959,22 +964,16 @@ public class ConversationFragment extends Fragment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) {
|
private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) {
|
||||||
int itemCount = getListAdapter() != null ? getListAdapter().getItemCount() : 0;
|
conversationViewModel.onConversationDataAvailable(threadId, position);
|
||||||
|
snapToTopDataObserver.buildScrollPosition(position)
|
||||||
if (position >= 0 && position < itemCount) {
|
.withOnScrollRequestComplete(() -> getListAdapter().pulseHighlightItem(position))
|
||||||
if (getListAdapter().getItem(position) == null) {
|
.withOnInvalidPosition(() -> {
|
||||||
conversationViewModel.onConversationDataAvailable(threadId, position);
|
if (onMessageNotFound != null) {
|
||||||
deferred.setDeferred(true);
|
onMessageNotFound.run();
|
||||||
deferred.defer(() -> moveToMessagePosition(position, onMessageNotFound));
|
}
|
||||||
} else {
|
Log.w(TAG, "[moveToMessagePosition] Tried to navigate to message, but it wasn't found.");
|
||||||
scrollToStartingPosition(position);
|
})
|
||||||
}
|
.submit();
|
||||||
} else {
|
|
||||||
Log.w(TAG, "[moveToMessagePosition] Tried to navigate to message, but it wasn't found.");
|
|
||||||
if (onMessageNotFound != null) {
|
|
||||||
onMessageNotFound.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void maybeShowSwipeToReplyTooltip() {
|
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 {
|
private class ConversationFragmentItemClickListener implements ItemClickListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1319,6 +1280,52 @@ public class ConversationFragment extends Fragment {
|
|||||||
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
|
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 class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
|
||||||
|
|
||||||
private final MessageRecord messageRecord;
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,8 @@ class ConversationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) {
|
private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) {
|
||||||
ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId);
|
ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId);
|
||||||
|
int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId);
|
||||||
|
|
||||||
long lastSeen = metadata.getLastSeen();
|
long lastSeen = metadata.getLastSeen();
|
||||||
boolean hasSent = metadata.hasSent();
|
boolean hasSent = metadata.hasSent();
|
||||||
@ -58,6 +59,6 @@ class ConversationRepository {
|
|||||||
lastScrolledPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionOnOrAfterTimestamp(threadId, lastScrolled);
|
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 android.app.Application;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.arch.core.util.Function;
|
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MediatorLiveData;
|
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
import androidx.lifecycle.Observer;
|
|
||||||
import androidx.lifecycle.Transformations;
|
import androidx.lifecycle.Transformations;
|
||||||
import androidx.lifecycle.ViewModel;
|
import androidx.lifecycle.ViewModel;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
@ -15,19 +12,15 @@ import androidx.paging.DataSource;
|
|||||||
import androidx.paging.LivePagedListBuilder;
|
import androidx.paging.LivePagedListBuilder;
|
||||||
import androidx.paging.PagedList;
|
import androidx.paging.PagedList;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationDataSource.Invalidator;
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mediasend.Media;
|
import org.thoughtcrime.securesms.mediasend.Media;
|
||||||
import org.thoughtcrime.securesms.mediasend.MediaRepository;
|
import org.thoughtcrime.securesms.mediasend.MediaRepository;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
|
||||||
import org.whispersystems.libsignal.util.Pair;
|
import org.whispersystems.libsignal.util.Pair;
|
||||||
|
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
|
|
||||||
class ConversationViewModel extends ViewModel {
|
class ConversationViewModel extends ViewModel {
|
||||||
|
|
||||||
@ -70,10 +63,12 @@ class ConversationViewModel extends ViewModel {
|
|||||||
final int startPosition;
|
final int startPosition;
|
||||||
if (data.shouldJumpToMessage()) {
|
if (data.shouldJumpToMessage()) {
|
||||||
startPosition = data.getJumpToPosition();
|
startPosition = data.getJumpToPosition();
|
||||||
} else if (data.shouldScrollToLastSeen()) {
|
} else if (data.isMessageRequestAccepted() && data.shouldScrollToLastSeen()) {
|
||||||
startPosition = data.getLastSeenPosition();
|
startPosition = data.getLastSeenPosition();
|
||||||
} else {
|
} else if (data.isMessageRequestAccepted()) {
|
||||||
startPosition = data.getLastScrolledPosition();
|
startPosition = data.getLastScrolledPosition();
|
||||||
|
} else {
|
||||||
|
startPosition = data.getThreadSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Starting at position startPosition: " + startPosition + " jumpToPosition: " + jumpToPosition + " lastSeenPosition: " + data.getLastSeenPosition() + " lastScrolledPosition: " + data.getLastScrolledPosition());
|
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);
|
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);
|
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;
|
package org.thoughtcrime.securesms.conversationlist;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.database.Cursor;
|
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes;
|
import androidx.annotation.DrawableRes;
|
||||||
import androidx.annotation.MenuRes;
|
import androidx.annotation.MenuRes;
|
||||||
@ -35,22 +30,19 @@ import androidx.annotation.WorkerThread;
|
|||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.view.ActionMode;
|
import androidx.appcompat.view.ActionMode;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.loader.app.LoaderManager;
|
import androidx.paging.PagedList;
|
||||||
import androidx.loader.content.Loader;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
|
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.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.loaders.ConversationListLoader;
|
|
||||||
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
|
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
|
||||||
|
|
||||||
|
|
||||||
public class ConversationListArchiveFragment extends ConversationListFragment
|
public class ConversationListArchiveFragment extends ConversationListFragment implements ActionMode.Callback
|
||||||
implements LoaderManager.LoaderCallbacks<Cursor>, ActionMode.Callback, ItemClickListener
|
|
||||||
{
|
{
|
||||||
private RecyclerView list;
|
private RecyclerView list;
|
||||||
private View emptyState;
|
private View emptyState;
|
||||||
@ -71,10 +63,10 @@ public class ConversationListArchiveFragment extends ConversationListFragment
|
|||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
super.onViewCreated(view, savedInstanceState);
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
|
||||||
list = view.findViewById(R.id.list);
|
list = view.findViewById(R.id.list);
|
||||||
fab = view.findViewById(R.id.fab);
|
fab = view.findViewById(R.id.fab);
|
||||||
cameraFab = view.findViewById(R.id.camera_fab);
|
cameraFab = view.findViewById(R.id.camera_fab);
|
||||||
emptyState = view.findViewById(R.id.empty_state);
|
emptyState = view.findViewById(R.id.empty_state);
|
||||||
|
|
||||||
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||||
Toolbar toolbar = view.findViewById(R.id.toolbar_basic);
|
Toolbar toolbar = view.findViewById(R.id.toolbar_basic);
|
||||||
@ -86,18 +78,18 @@ public class ConversationListArchiveFragment extends ConversationListFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
|
protected void onSubmitList(@NonNull PagedList<Conversation> conversations) {
|
||||||
return new ConversationListLoader(getActivity(), null, true);
|
super.onSubmitList(conversations);
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoadFinished(@NonNull Loader<Cursor> arg0, Cursor cursor) {
|
|
||||||
super.onLoadFinished(arg0, cursor);
|
|
||||||
|
|
||||||
list.setVisibility(View.VISIBLE);
|
list.setVisibility(View.VISIBLE);
|
||||||
emptyState.setVisibility(View.GONE);
|
emptyState.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean isArchived() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected int getToolbarRes() {
|
protected int getToolbarRes() {
|
||||||
return R.id.toolbar_basic;
|
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.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.res.TypedArray;
|
import android.content.res.TypedArray;
|
||||||
import android.database.Cursor;
|
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.BitmapFactory;
|
import android.graphics.BitmapFactory;
|
||||||
import android.graphics.Canvas;
|
import android.graphics.Canvas;
|
||||||
@ -59,8 +58,7 @@ import androidx.lifecycle.DefaultLifecycleObserver;
|
|||||||
import androidx.lifecycle.LifecycleOwner;
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner;
|
import androidx.lifecycle.ProcessLifecycleOwner;
|
||||||
import androidx.lifecycle.ViewModelProviders;
|
import androidx.lifecycle.ViewModelProviders;
|
||||||
import androidx.loader.app.LoaderManager;
|
import androidx.paging.PagedList;
|
||||||
import androidx.loader.content.Loader;
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
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.ShareReminder;
|
||||||
import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder;
|
import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder;
|
||||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
|
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.MessageResult;
|
||||||
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||||
import org.thoughtcrime.securesms.database.loaders.ConversationListLoader;
|
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
|
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.sms.MessageSender;
|
||||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
|
||||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
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.SignalExecutors;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||||
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
|
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
|
||||||
import org.whispersystems.libsignal.util.Pair;
|
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
@ -138,9 +136,8 @@ import java.util.Set;
|
|||||||
import static android.app.Activity.RESULT_OK;
|
import static android.app.Activity.RESULT_OK;
|
||||||
|
|
||||||
|
|
||||||
public class ConversationListFragment extends MainFragment implements LoaderManager.LoaderCallbacks<Cursor>,
|
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
|
||||||
ActionMode.Callback,
|
ConversationPagedListAdapter.OnConversationClickListener,
|
||||||
ItemClickListener,
|
|
||||||
ConversationListSearchAdapter.EventListener,
|
ConversationListSearchAdapter.EventListener,
|
||||||
MainNavigator.BackHandler,
|
MainNavigator.BackHandler,
|
||||||
MegaphoneActionController
|
MegaphoneActionController
|
||||||
@ -157,23 +154,24 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
R.drawable.empty_inbox_4,
|
R.drawable.empty_inbox_4,
|
||||||
R.drawable.empty_inbox_5 };
|
R.drawable.empty_inbox_5 };
|
||||||
|
|
||||||
private ActionMode actionMode;
|
private ActionMode actionMode;
|
||||||
private RecyclerView list;
|
private RecyclerView list;
|
||||||
private ReminderView reminderView;
|
private ReminderView reminderView;
|
||||||
private View emptyState;
|
private View emptyState;
|
||||||
private ImageView emptyImage;
|
private ImageView emptyImage;
|
||||||
private TextView searchEmptyState;
|
private TextView searchEmptyState;
|
||||||
private PulsingFloatingActionButton fab;
|
private PulsingFloatingActionButton fab;
|
||||||
private PulsingFloatingActionButton cameraFab;
|
private PulsingFloatingActionButton cameraFab;
|
||||||
private SearchToolbar searchToolbar;
|
private SearchToolbar searchToolbar;
|
||||||
private ImageView searchAction;
|
private ImageView searchAction;
|
||||||
private View toolbarShadow;
|
private View toolbarShadow;
|
||||||
private ConversationListViewModel viewModel;
|
private ConversationListViewModel viewModel;
|
||||||
private RecyclerView.Adapter activeAdapter;
|
private RecyclerView.Adapter activeAdapter;
|
||||||
private ConversationListAdapter defaultAdapter;
|
private ConversationPagedListAdapter defaultAdapter;
|
||||||
private ConversationListSearchAdapter searchAdapter;
|
private ConversationListSearchAdapter searchAdapter;
|
||||||
private StickyHeaderDecoration searchAdapterDecoration;
|
private StickyHeaderDecoration searchAdapterDecoration;
|
||||||
private ViewGroup megaphoneContainer;
|
private ViewGroup megaphoneContainer;
|
||||||
|
private SnapToTopDataObserver snapToTopDataObserver;
|
||||||
|
|
||||||
public static ConversationListFragment newInstance() {
|
public static ConversationListFragment newInstance() {
|
||||||
return new ConversationListFragment();
|
return new ConversationListFragment();
|
||||||
@ -214,10 +212,14 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
reminderView.setOnDismissListener(this::updateReminders);
|
reminderView.setOnDismissListener(this::updateReminders);
|
||||||
|
|
||||||
list.setHasFixedSize(true);
|
list.setHasFixedSize(true);
|
||||||
list.setLayoutManager(new LinearLayoutManager(getActivity()));
|
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
|
||||||
list.setItemAnimator(new DeleteItemAnimator());
|
list.setItemAnimator(new DeleteItemAnimator());
|
||||||
list.addOnScrollListener(new ScrollListener());
|
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);
|
new ItemTouchHelper(new ArchiveListenerCallback()).attachToRecyclerView(list);
|
||||||
|
|
||||||
fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class)));
|
fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class)));
|
||||||
@ -247,7 +249,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
super.onResume();
|
super.onResume();
|
||||||
|
|
||||||
updateReminders();
|
updateReminders();
|
||||||
list.getAdapter().notifyDataSetChanged();
|
|
||||||
EventBus.getDefault().register(this);
|
EventBus.getDefault().register(this);
|
||||||
|
|
||||||
if (TextSecurePreferences.isSmsEnabled(requireContext())) {
|
if (TextSecurePreferences.isSmsEnabled(requireContext())) {
|
||||||
@ -257,9 +258,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
SimpleTask.run(getLifecycle(), Recipient::self, this::initializeProfileIcon);
|
SimpleTask.run(getLifecycle(), Recipient::self, this::initializeProfileIcon);
|
||||||
|
|
||||||
if (!searchToolbar.isVisible() && list.getAdapter() != defaultAdapter) {
|
if (!searchToolbar.isVisible() && list.getAdapter() != defaultAdapter) {
|
||||||
activeAdapter = defaultAdapter;
|
|
||||||
list.removeItemDecoration(searchAdapterDecoration);
|
list.removeItemDecoration(searchAdapterDecoration);
|
||||||
list.setAdapter(defaultAdapter);
|
setAdapter(defaultAdapter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,9 +314,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
|
|
||||||
private boolean closeSearchIfOpen() {
|
private boolean closeSearchIfOpen() {
|
||||||
if (searchToolbar.isVisible() || activeAdapter == searchAdapter) {
|
if (searchToolbar.isVisible() || activeAdapter == searchAdapter) {
|
||||||
activeAdapter = defaultAdapter;
|
|
||||||
list.removeItemDecoration(searchAdapterDecoration);
|
list.removeItemDecoration(searchAdapterDecoration);
|
||||||
list.setAdapter(defaultAdapter);
|
setAdapter(defaultAdapter);
|
||||||
searchToolbar.collapse();
|
searchToolbar.collapse();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -356,6 +355,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
-1);
|
-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onShowArchiveClick() {
|
||||||
|
getNavigator().goToArchiveList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContactClicked(@NonNull Recipient contact) {
|
public void onContactClicked(@NonNull Recipient contact) {
|
||||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||||
@ -440,16 +444,14 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
|
|
||||||
if (trimmed.length() > 0) {
|
if (trimmed.length() > 0) {
|
||||||
if (activeAdapter != searchAdapter) {
|
if (activeAdapter != searchAdapter) {
|
||||||
activeAdapter = searchAdapter;
|
setAdapter(searchAdapter);
|
||||||
list.setAdapter(searchAdapter);
|
|
||||||
list.removeItemDecoration(searchAdapterDecoration);
|
list.removeItemDecoration(searchAdapterDecoration);
|
||||||
list.addItemDecoration(searchAdapterDecoration);
|
list.addItemDecoration(searchAdapterDecoration);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (activeAdapter != defaultAdapter) {
|
if (activeAdapter != defaultAdapter) {
|
||||||
activeAdapter = defaultAdapter;
|
|
||||||
list.removeItemDecoration(searchAdapterDecoration);
|
list.removeItemDecoration(searchAdapterDecoration);
|
||||||
list.setAdapter(defaultAdapter);
|
setAdapter(defaultAdapter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -457,19 +459,36 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
@Override
|
@Override
|
||||||
public void onSearchClosed() {
|
public void onSearchClosed() {
|
||||||
list.removeItemDecoration(searchAdapterDecoration);
|
list.removeItemDecoration(searchAdapterDecoration);
|
||||||
list.setAdapter(defaultAdapter);
|
setAdapter(defaultAdapter);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeListAdapters() {
|
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 () );
|
searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault());
|
||||||
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false);
|
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false);
|
||||||
activeAdapter = defaultAdapter;
|
|
||||||
|
|
||||||
list.setAdapter(defaultAdapter);
|
setAdapter(defaultAdapter);
|
||||||
LoaderManager.getInstance(this).restartLoader(0, null, this);
|
}
|
||||||
|
|
||||||
|
@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() {
|
private void initializeTypingObserver() {
|
||||||
@ -482,11 +501,17 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected boolean isArchived() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void initializeViewModel() {
|
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.getSearchResult().observe(this, this::onSearchResultChanged);
|
||||||
viewModel.getMegaphone().observe(this, this::onMegaphoneChanged);
|
viewModel.getMegaphone().observe(this, this::onMegaphoneChanged);
|
||||||
|
viewModel.getConversationList().observe(this, this::onSubmitList);
|
||||||
|
viewModel.getArchivedCount().observe(this, defaultAdapter::updateArchived);
|
||||||
|
|
||||||
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
|
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
|
||||||
@Override
|
@Override
|
||||||
@ -733,14 +758,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
|
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
protected void onSubmitList(@NonNull PagedList<Conversation> pagedList) {
|
||||||
public @NonNull Loader<Cursor> onCreateLoader(int arg0, Bundle arg1) {
|
if (pagedList.size() == 0) {
|
||||||
return new ConversationListLoader(getActivity(), null, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoadFinished(@NonNull Loader<Cursor> arg0, Cursor cursor) {
|
|
||||||
if (cursor == null || cursor.getCount() <= 0) {
|
|
||||||
list.setVisibility(View.INVISIBLE);
|
list.setVisibility(View.INVISIBLE);
|
||||||
emptyState.setVisibility(View.VISIBLE);
|
emptyState.setVisibility(View.VISIBLE);
|
||||||
emptyImage.setImageResource(EMPTY_IMAGES[(int) (Math.random() * EMPTY_IMAGES.length)]);
|
emptyImage.setImageResource(EMPTY_IMAGES[(int) (Math.random() * EMPTY_IMAGES.length)]);
|
||||||
@ -753,45 +772,33 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
cameraFab.stopPulse();
|
cameraFab.stopPulse();
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultAdapter.changeCursor(cursor);
|
defaultAdapter.submitList(pagedList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onLoaderReset(@NonNull Loader<Cursor> arg0) {
|
public void onConversationClick(Conversation conversation) {
|
||||||
defaultAdapter.changeCursor(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onItemClick(ConversationListItem item) {
|
|
||||||
if (actionMode == null) {
|
if (actionMode == null) {
|
||||||
handleCreateConversation(item.getThreadId(), item.getRecipient(), item.getDistributionType());
|
handleCreateConversation(conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient(), conversation.getThreadRecord().getDistributionType());
|
||||||
} else {
|
} else {
|
||||||
ConversationListAdapter adapter = (ConversationListAdapter)list.getAdapter();
|
defaultAdapter.toggleConversationInBatchSet(conversation);
|
||||||
adapter.toggleThreadInBatchSet(item.getThread());
|
|
||||||
|
|
||||||
if (adapter.getBatchSelectionIds().size() == 0) {
|
if (defaultAdapter.getBatchSelectionIds().size() == 0) {
|
||||||
actionMode.finish();
|
actionMode.finish();
|
||||||
} else {
|
} else {
|
||||||
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
|
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
|
||||||
setCorrectMenuVisibility(actionMode.getMenu());
|
setCorrectMenuVisibility(actionMode.getMenu());
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter.notifyDataSetChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onItemLongClick(ConversationListItem item) {
|
public boolean onConversationLongClick(Conversation conversation) {
|
||||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
|
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
|
||||||
|
|
||||||
defaultAdapter.initializeBatchMode(true);
|
defaultAdapter.initializeBatchMode(true);
|
||||||
defaultAdapter.toggleThreadInBatchSet(item.getThread());
|
defaultAdapter.toggleConversationInBatchSet(conversation);
|
||||||
defaultAdapter.notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
return true;
|
||||||
public void onSwitchToArchive() {
|
|
||||||
getNavigator().goToArchiveList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -870,7 +877,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void setCorrectMenuVisibility(@NonNull Menu menu) {
|
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) {
|
if (hasUnread) {
|
||||||
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);
|
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);
|
||||||
|
@ -174,28 +174,19 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
this.fromView.setText(recipient.get(), thread.isRead());
|
this.fromView.setText(recipient.get(), thread.isRead());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typingThreads.contains(threadId)) {
|
updateTypingIndicator(typingThreads);
|
||||||
this.subjectView.setVisibility(INVISIBLE);
|
|
||||||
|
|
||||||
this.typingView.setVisibility(VISIBLE);
|
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
|
||||||
this.typingView.startAnimation();
|
|
||||||
} else {
|
|
||||||
this.typingView.setVisibility(GONE);
|
|
||||||
this.typingView.stopAnimation();
|
|
||||||
|
|
||||||
this.subjectView.setVisibility(VISIBLE);
|
if (thread.getGroupAddedBy() != null) {
|
||||||
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
|
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
|
||||||
|
groupAddedBy.observeForever(groupAddedByObserver);
|
||||||
if (thread.getGroupAddedBy() != null) {
|
|
||||||
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
|
|
||||||
groupAddedBy.observeForever(groupAddedByObserver);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
|
||||||
this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
|
|
||||||
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (thread.getDate() > 0) {
|
||||||
CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate());
|
CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate());
|
||||||
dateView.setText(date);
|
dateView.setText(date);
|
||||||
@ -291,11 +282,27 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setBatchMode(boolean batchMode) {
|
@Override
|
||||||
|
public void setBatchMode(boolean batchMode) {
|
||||||
this.batchMode = batchMode;
|
this.batchMode = batchMode;
|
||||||
setSelected(batchMode && selectedThreads.contains(thread.getThreadId()));
|
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() {
|
public Recipient getRecipient() {
|
||||||
return recipient.get();
|
return recipient.get();
|
||||||
}
|
}
|
||||||
|
@ -55,4 +55,14 @@ public class ConversationListItemAction extends LinearLayout implements Bindable
|
|||||||
public void unbind() {
|
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.MutableLiveData;
|
||||||
import androidx.lifecycle.ViewModel;
|
import androidx.lifecycle.ViewModel;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
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.conversationlist.model.SearchResult;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.megaphone.Megaphone;
|
import org.thoughtcrime.securesms.megaphone.Megaphone;
|
||||||
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
|
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.search.SearchRepository;
|
||||||
import org.thoughtcrime.securesms.util.Debouncer;
|
import org.thoughtcrime.securesms.util.Debouncer;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
|
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||||
|
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||||
|
|
||||||
class ConversationListViewModel extends ViewModel {
|
class ConversationListViewModel extends ViewModel {
|
||||||
|
|
||||||
private final Application application;
|
private final Application application;
|
||||||
private final MutableLiveData<Megaphone> megaphone;
|
private final MutableLiveData<Megaphone> megaphone;
|
||||||
private final MutableLiveData<SearchResult> searchResult;
|
private final MutableLiveData<SearchResult> searchResult;
|
||||||
private final SearchRepository searchRepository;
|
private final LiveData<PagedList<Conversation>> conversationList;
|
||||||
private final MegaphoneRepository megaphoneRepository;
|
private final MutableLiveData<Integer> archivedCount;
|
||||||
private final Debouncer debouncer;
|
private final SearchRepository searchRepository;
|
||||||
private final ContentObserver observer;
|
private final MegaphoneRepository megaphoneRepository;
|
||||||
|
private final Debouncer debouncer;
|
||||||
|
private final ContentObserver observer;
|
||||||
|
private final Invalidator invalidator;
|
||||||
|
|
||||||
private String lastQuery;
|
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.application = application;
|
||||||
this.megaphone = new MutableLiveData<>();
|
this.megaphone = new MutableLiveData<>();
|
||||||
this.searchResult = new MutableLiveData<>();
|
this.searchResult = new MutableLiveData<>();
|
||||||
|
this.archivedCount = new MutableLiveData<>();
|
||||||
this.searchRepository = searchRepository;
|
this.searchRepository = searchRepository;
|
||||||
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
|
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
|
||||||
this.debouncer = new Debouncer(300);
|
this.debouncer = new Debouncer(300);
|
||||||
|
this.invalidator = new Invalidator();
|
||||||
this.observer = new ContentObserver(new Handler()) {
|
this.observer = new ContentObserver(new Handler()) {
|
||||||
@Override
|
@Override
|
||||||
public void onChange(boolean selfChange) {
|
public void onChange(boolean selfChange) {
|
||||||
if (!TextUtils.isEmpty(getLastQuery())) {
|
if (!TextUtils.isEmpty(getLastQuery())) {
|
||||||
searchRepository.query(getLastQuery(), searchResult::postValue);
|
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);
|
application.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, observer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +91,14 @@ class ConversationListViewModel extends ViewModel {
|
|||||||
return megaphone;
|
return megaphone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull LiveData<PagedList<Conversation>> getConversationList() {
|
||||||
|
return conversationList;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull LiveData<Integer> getArchivedCount() {
|
||||||
|
return archivedCount;
|
||||||
|
}
|
||||||
|
|
||||||
void onVisible() {
|
void onVisible() {
|
||||||
megaphoneRepository.getNextMegaphone(megaphone::postValue);
|
megaphoneRepository.getNextMegaphone(megaphone::postValue);
|
||||||
}
|
}
|
||||||
@ -95,15 +134,29 @@ class ConversationListViewModel extends ViewModel {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCleared() {
|
protected void onCleared() {
|
||||||
|
invalidator.invalidate();
|
||||||
debouncer.clear();
|
debouncer.clear();
|
||||||
application.getContentResolver().unregisterContentObserver(observer);
|
application.getContentResolver().unregisterContentObserver(observer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateArchivedCount() {
|
||||||
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
|
archivedCount.postValue(DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static class Factory extends ViewModelProvider.NewInstanceFactory {
|
public static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||||
|
|
||||||
|
private final boolean isArchived;
|
||||||
|
|
||||||
|
public Factory(boolean isArchived) {
|
||||||
|
this.isArchived = isArchived;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||||
//noinspection ConstantConditions
|
//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;
|
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) {
|
private Cursor getConversationList(String archived) {
|
||||||
|
return getConversationList(archived, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cursor getConversationList(@NonNull String archived, long offset, long limit) {
|
||||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
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});
|
Cursor cursor = db.rawQuery(query, new String[]{archived});
|
||||||
|
|
||||||
setNotifyConversationListListeners(cursor);
|
setNotifyConversationListListeners(cursor);
|
||||||
@ -562,20 +574,24 @@ public class ThreadDatabase extends Database {
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getUnarchivedConversationListCount() {
|
||||||
|
return getConversationListCount(false);
|
||||||
|
}
|
||||||
|
|
||||||
public int getArchivedConversationListCount() {
|
public int getArchivedConversationListCount() {
|
||||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
return getConversationListCount(true);
|
||||||
Cursor cursor = null;
|
}
|
||||||
|
|
||||||
try {
|
private int getConversationListCount(boolean archived) {
|
||||||
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, ARCHIVED + " = ?",
|
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||||
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()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
return cursor.getInt(0);
|
return cursor.getInt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (cursor != null) cursor.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@ -854,7 +870,11 @@ public class ThreadDatabase extends Database {
|
|||||||
return null;
|
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 projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ",");
|
||||||
String query =
|
String query =
|
||||||
"SELECT " + projection + " FROM " + TABLE_NAME +
|
"SELECT " + projection + " FROM " + TABLE_NAME +
|
||||||
@ -869,6 +889,10 @@ public class ThreadDatabase extends Database {
|
|||||||
query += " LIMIT " + limit;
|
query += " LIMIT " + limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (offset > 0) {
|
||||||
|
query += " OFFSET " + offset;
|
||||||
|
}
|
||||||
|
|
||||||
return query;
|
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;
|
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 {
|
public static class Builder {
|
||||||
private long threadId;
|
private long threadId;
|
||||||
private String body;
|
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