Use our own homemade paging library for conversation paging.
I made the lib, and Alan made the build actually work. Co-authored-by: Alan Evans <alan@signal.org>
|
@ -2,26 +2,6 @@ import org.signal.signing.ApkSignerUtil
|
|||
|
||||
import java.security.MessageDigest
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
maven {
|
||||
url "https://repo1.maven.org/maven2"
|
||||
}
|
||||
jcenter {
|
||||
content {
|
||||
includeVersion 'org.jetbrains.trove4j', 'trove4j', '20160824'
|
||||
includeGroupByRegex "com\\.archinamon.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.1.1'
|
||||
classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.1.0'
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.google.protobuf'
|
||||
apply plugin: 'androidx.navigation.safeargs'
|
||||
|
@ -94,9 +74,10 @@ def abiPostFix = ['universal' : 0,
|
|||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||
|
||||
android {
|
||||
buildToolsVersion BUILD_TOOL_VERSION
|
||||
compileSdkVersion COMPILE_SDK
|
||||
|
||||
flavorDimensions 'distribution', 'environment'
|
||||
compileSdkVersion 30
|
||||
buildToolsVersion '30.0.2'
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
|
||||
dexOptions {
|
||||
|
@ -118,8 +99,9 @@ android {
|
|||
versionCode canonicalVersionCode * postFixSize
|
||||
versionName canonicalVersionName
|
||||
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 30
|
||||
minSdkVersion MINIMUM_SDK
|
||||
targetSdkVersion TARGET_SDK
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
@ -167,8 +149,8 @@ android {
|
|||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
|
@ -218,6 +200,7 @@ android {
|
|||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
|
@ -350,6 +333,7 @@ dependencies {
|
|||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
||||
|
||||
implementation project(':libsignal-service')
|
||||
implementation project(':paging')
|
||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.1.5'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
|
||||
|
|
|
@ -31,8 +31,10 @@ import androidx.lifecycle.LifecycleOwner;
|
|||
import androidx.paging.PagedList;
|
||||
import androidx.paging.PagedListAdapter;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
|
@ -66,7 +68,7 @@ import java.util.Set;
|
|||
* the "footer" is at the top, and we refer to the "next" record as having a lower index.
|
||||
*/
|
||||
public class ConversationAdapter
|
||||
extends PagedListAdapter<ConversationMessage, RecyclerView.ViewHolder>
|
||||
extends ListAdapter<ConversationMessage, RecyclerView.ViewHolder>
|
||||
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>
|
||||
{
|
||||
|
||||
|
@ -100,6 +102,7 @@ public class ConversationAdapter
|
|||
private ConversationMessage recordToPulse;
|
||||
private View headerView;
|
||||
private View footerView;
|
||||
private PagingController pagingController;
|
||||
|
||||
ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
|
@ -107,7 +110,18 @@ public class ConversationAdapter
|
|||
@Nullable ItemClickListener clickListener,
|
||||
@NonNull Recipient recipient)
|
||||
{
|
||||
super(new DiffCallback());
|
||||
super(new DiffUtil.ItemCallback<ConversationMessage>() {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
|
||||
return oldItem.getMessageRecord().getId() == newItem.getMessageRecord().getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.lifecycleOwner = lifecycleOwner;
|
||||
|
||||
this.glideRequests = glideRequests;
|
||||
|
@ -244,26 +258,6 @@ public class ConversationAdapter
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void submitList(@Nullable PagedList<ConversationMessage> pagedList) {
|
||||
cleanFastRecords();
|
||||
super.submitList(pagedList);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ConversationMessage getItem(int position) {
|
||||
position = hasHeader() ? position - 1 : position;
|
||||
|
||||
if (position == -1) {
|
||||
return null;
|
||||
} else if (position < fastRecords.size()) {
|
||||
return fastRecords.get(position);
|
||||
} else {
|
||||
int correctedPosition = position - fastRecords.size();
|
||||
return super.getItem(correctedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
boolean hasHeader = headerView != null;
|
||||
|
@ -306,12 +300,37 @@ public class ConversationAdapter
|
|||
viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateReceived()));
|
||||
}
|
||||
|
||||
public @Nullable ConversationMessage getItem(int position) {
|
||||
position = hasHeader() ? position - 1 : position;
|
||||
|
||||
if (position == -1) {
|
||||
return null;
|
||||
} else if (position < fastRecords.size()) {
|
||||
return fastRecords.get(position);
|
||||
} else {
|
||||
int correctedPosition = position - fastRecords.size();
|
||||
if (pagingController != null) {
|
||||
pagingController.onDataNeededAroundIndex(correctedPosition);
|
||||
}
|
||||
return super.getItem(correctedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
public void submitList(@Nullable List<ConversationMessage> pagedList) {
|
||||
cleanFastRecords();
|
||||
super.submitList(pagedList);
|
||||
}
|
||||
|
||||
public void setPagingController(@Nullable PagingController pagingController) {
|
||||
this.pagingController = pagingController;
|
||||
}
|
||||
|
||||
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
||||
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
|
||||
}
|
||||
|
||||
boolean hasNoConversationMessages() {
|
||||
return super.getItemCount() + fastRecords.size() == 0;
|
||||
return getItemCount() + fastRecords.size() == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -576,19 +595,6 @@ public class ConversationAdapter
|
|||
}
|
||||
}
|
||||
|
||||
private static class DiffCallback extends DiffUtil.ItemCallback<ConversationMessage> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
|
||||
return oldItem.getMessageRecord().isMms() == newItem.getMessageRecord().isMms() && oldItem.getMessageRecord().getId() == newItem.getMessageRecord().getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
|
||||
// Corner rounding is not part of the model, so we can't use this yet
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ItemClickListener extends BindableConversationItem.EventListener {
|
||||
void onItemClick(ConversationMessage item);
|
||||
void onItemLongClick(View maskTarget, ConversationMessage item);
|
||||
|
|
|
@ -1,135 +1,85 @@
|
|||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.PositionalDataSource;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.paging.PagedDataSource;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.tracing.Trace;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Core data source for loading an individual conversation.
|
||||
*/
|
||||
@Trace
|
||||
class ConversationDataSource extends PositionalDataSource<ConversationMessage> {
|
||||
class ConversationDataSource implements PagedDataSource<ConversationMessage> {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationDataSource.class);
|
||||
|
||||
public static final Executor EXECUTOR = SignalExecutors.newFixedLifoThreadExecutor("signal-conversation", 1, 1);
|
||||
|
||||
private final Context context;
|
||||
private final long threadId;
|
||||
|
||||
private ConversationDataSource(@NonNull Context context,
|
||||
long threadId,
|
||||
@NonNull Invalidator invalidator)
|
||||
{
|
||||
this.context = context;
|
||||
this.threadId = threadId;
|
||||
|
||||
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.Conversation.getUriForThread(threadId), true, contentObserver);
|
||||
ConversationDataSource(@NonNull Context context, long threadId) {
|
||||
this.context = context;
|
||||
this.threadId = threadId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<ConversationMessage> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
public int size() {
|
||||
long startTime = System.currentTimeMillis();
|
||||
int size = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId);
|
||||
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
List<MessageRecord> records = new ArrayList<>(params.requestedLoadSize);
|
||||
int totalCount = db.getConversationCount(threadId);
|
||||
int effectiveCount = params.requestedStartPosition;
|
||||
Log.d(TAG, "size() for thread " + threadId + ": " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
|
||||
MentionHelper mentionHelper = new MentionHelper();
|
||||
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
|
||||
records.add(record);
|
||||
mentionHelper.add(record);
|
||||
effectiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
long mentionStart = System.currentTimeMillis();
|
||||
|
||||
mentionHelper.fetchMentions(context);
|
||||
|
||||
if (!isInvalid()) {
|
||||
SizeFixResult<MessageRecord> result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
|
||||
|
||||
List<ConversationMessage> items = Stream.of(result.getItems())
|
||||
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
|
||||
.toList();
|
||||
|
||||
callback.onResult(items, params.requestedStartPosition, result.getTotal());
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms (mentions: " + (System.currentTimeMillis() - mentionStart) + " ms) | thread: " + threadId + ", start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", actualSize: " + result.getItems().size() + ", totalCount: " + result.getTotal());
|
||||
} else {
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", totalCount: " + totalCount + " -- invalidated");
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<ConversationMessage> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
public @NonNull List<ConversationMessage> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
|
||||
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId);
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
List<MessageRecord> records = new ArrayList<>(params.loadSize);
|
||||
List<MessageRecord> records = new ArrayList<>(length);
|
||||
MentionHelper mentionHelper = new MentionHelper();
|
||||
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) {
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, start, length))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null && !isInvalid()) {
|
||||
while ((record = reader.getNext()) != null && !cancellationSignal.isCanceled()) {
|
||||
records.add(record);
|
||||
mentionHelper.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
long mentionStart = System.currentTimeMillis();
|
||||
stopwatch.split("messages");
|
||||
|
||||
mentionHelper.fetchMentions(context);
|
||||
|
||||
List<ConversationMessage> items = Stream.of(records)
|
||||
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
|
||||
.toList();
|
||||
callback.onResult(items);
|
||||
stopwatch.split("mentions");
|
||||
|
||||
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms (mentions: " + (System.currentTimeMillis() - mentionStart) + " ms) | thread: " + threadId + ", start: " + params.startPosition + ", size: " + params.loadSize + (isInvalid() ? " -- invalidated" : ""));
|
||||
List<ConversationMessage> messages = Stream.of(records)
|
||||
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
|
||||
.toList();
|
||||
|
||||
stopwatch.split("conversion");
|
||||
stopwatch.stop(TAG);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static class MentionHelper {
|
||||
|
@ -151,22 +101,4 @@ class ConversationDataSource extends PositionalDataSource<ConversationMessage> {
|
|||
return messageIdToMentions.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends DataSource.Factory<Integer, ConversationMessage> {
|
||||
|
||||
private final Context context;
|
||||
private final long threadId;
|
||||
private final Invalidator invalidator;
|
||||
|
||||
Factory(Context context, long threadId, @NonNull Invalidator invalidator) {
|
||||
this.context = context;
|
||||
this.threadId = threadId;
|
||||
this.invalidator = invalidator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull DataSource<Integer, ConversationMessage> create() {
|
||||
return new ConversationDataSource(context, threadId, invalidator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -250,13 +250,9 @@ public class ConversationFragment extends LoggingFragment {
|
|||
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
|
||||
conversationViewModel.getMessages().observe(this, list -> {
|
||||
if (getListAdapter() != null && !list.getDataSource().isInvalid()) {
|
||||
Log.i(TAG, "submitList");
|
||||
getListAdapter().submitList(list);
|
||||
} else if (list.getDataSource().isInvalid()) {
|
||||
Log.i(TAG, "submitList skipped an invalid list");
|
||||
}
|
||||
getListAdapter().submitList(list);
|
||||
});
|
||||
|
||||
conversationViewModel.getConversationMetadata().observe(this, this::presentConversationMetadata);
|
||||
|
||||
conversationViewModel.getShowMentionsButton().observe(this, shouldShow -> {
|
||||
|
@ -506,6 +502,7 @@ public class ConversationFragment extends LoggingFragment {
|
|||
if (this.recipient != null && this.threadId != -1) {
|
||||
Log.d(TAG, "Initializing adapter for " + recipient.getId());
|
||||
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get());
|
||||
adapter.setPagingController(conversationViewModel.getPagingController());
|
||||
list.setAdapter(adapter);
|
||||
setStickyHeaderDecoration(adapter);
|
||||
ConversationAdapter.initializePool(list.getRecycledViewPool());
|
||||
|
@ -1006,6 +1003,7 @@ public class ConversationFragment extends LoggingFragment {
|
|||
}
|
||||
|
||||
private void moveToPosition(int position, @Nullable Runnable onMessageNotFound) {
|
||||
Log.d(TAG, "moveToPosition(" + position + ")");
|
||||
conversationViewModel.onConversationDataAvailable(threadId, position);
|
||||
snapToTopDataObserver.buildScrollPosition(position)
|
||||
.withOnPerformScroll(((layoutManager, p) ->
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.app.Application;
|
||||
import android.database.ContentObserver;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
@ -10,16 +11,17 @@ import androidx.lifecycle.MutableLiveData;
|
|||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.LivePagedListBuilder;
|
||||
import androidx.paging.PagedList;
|
||||
|
||||
import org.signal.paging.ProxyPagingController;
|
||||
import org.signal.paging.PagedData;
|
||||
import org.signal.paging.PagingConfig;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.List;
|
||||
|
@ -29,20 +31,22 @@ class ConversationViewModel extends ViewModel {
|
|||
|
||||
private static final String TAG = Log.tag(ConversationViewModel.class);
|
||||
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final MutableLiveData<Long> threadId;
|
||||
private final LiveData<PagedList<ConversationMessage>> messages;
|
||||
private final LiveData<ConversationData> conversationMetadata;
|
||||
private final Invalidator invalidator;
|
||||
private final MutableLiveData<Boolean> showScrollButtons;
|
||||
private final MutableLiveData<Boolean> hasUnreadMentions;
|
||||
private final LiveData<Boolean> canShowAsBubble;
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final MutableLiveData<Long> threadId;
|
||||
private final LiveData<List<ConversationMessage>> messages;
|
||||
private final LiveData<ConversationData> conversationMetadata;
|
||||
private final MutableLiveData<Boolean> showScrollButtons;
|
||||
private final MutableLiveData<Boolean> hasUnreadMentions;
|
||||
private final LiveData<Boolean> canShowAsBubble;
|
||||
private final ProxyPagingController pagingController;
|
||||
private final ContentObserver messageObserver;
|
||||
|
||||
private ConversationIntents.Args args;
|
||||
private int jumpToPosition;
|
||||
private int jumpToPosition;
|
||||
private boolean hasRegisteredObserver;
|
||||
|
||||
private ConversationViewModel() {
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
|
@ -50,9 +54,15 @@ class ConversationViewModel extends ViewModel {
|
|||
this.conversationRepository = new ConversationRepository();
|
||||
this.recentMedia = new MutableLiveData<>();
|
||||
this.threadId = new MutableLiveData<>();
|
||||
this.invalidator = new Invalidator();
|
||||
this.showScrollButtons = new MutableLiveData<>(false);
|
||||
this.hasUnreadMentions = new MutableLiveData<>(false);
|
||||
this.pagingController = new ProxyPagingController();
|
||||
this.messageObserver = new ContentObserver(null) {
|
||||
@Override
|
||||
public void onChange(boolean selfChange) {
|
||||
pagingController.onDataInvalidated();
|
||||
}
|
||||
};
|
||||
|
||||
LiveData<ConversationData> metadata = Transformations.switchMap(threadId, thread -> {
|
||||
LiveData<ConversationData> conversationData = conversationRepository.getConversationData(thread, jumpToPosition);
|
||||
|
@ -62,13 +72,7 @@ class ConversationViewModel extends ViewModel {
|
|||
return conversationData;
|
||||
});
|
||||
|
||||
LiveData<Pair<Long, PagedList<ConversationMessage>>> messagesForThreadId = Transformations.switchMap(metadata, data -> {
|
||||
DataSource.Factory<Integer, ConversationMessage> factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator);
|
||||
PagedList.Config config = new PagedList.Config.Builder()
|
||||
.setPageSize(25)
|
||||
.setInitialLoadSizeHint(25)
|
||||
.build();
|
||||
|
||||
LiveData<Pair<Long, PagedData<ConversationMessage>>> pagedDataForThreadId = Transformations.map(metadata, data -> {
|
||||
final int startPosition;
|
||||
if (data.shouldJumpToMessage()) {
|
||||
startPosition = data.getJumpToPosition();
|
||||
|
@ -80,23 +84,31 @@ class ConversationViewModel extends ViewModel {
|
|||
startPosition = data.getThreadSize();
|
||||
}
|
||||
|
||||
Log.d(TAG, "Starting at position startPosition: " + startPosition + " jumpToPosition: " + jumpToPosition + " lastSeenPosition: " + data.getLastSeenPosition() + " lastScrolledPosition: " + data.getLastScrolledPosition());
|
||||
if (hasRegisteredObserver) {
|
||||
context.getContentResolver().unregisterContentObserver(messageObserver);
|
||||
}
|
||||
|
||||
return Transformations.map(new LivePagedListBuilder<>(factory, config).setFetchExecutor(ConversationDataSource.EXECUTOR)
|
||||
.setInitialLoadKey(Math.max(startPosition, 0))
|
||||
.build(),
|
||||
input -> new Pair<>(data.getThreadId(), input));
|
||||
context.getContentResolver().registerContentObserver(DatabaseContentProviders.Conversation.getUriForThread(data.getThreadId()), true, messageObserver);
|
||||
hasRegisteredObserver = true;
|
||||
|
||||
ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId());
|
||||
PagingConfig config = new PagingConfig.Builder()
|
||||
.setPageSize(25)
|
||||
.setBufferPages(1)
|
||||
.setStartIndex(Math.max(startPosition, 0))
|
||||
.build();
|
||||
|
||||
Log.d(TAG, "Starting at position: " + startPosition + " || jumpToPosition: " + data.getJumpToPosition() + ", lastSeenPosition: " + data.getLastSeenPosition() + ", lastScrolledPosition: " + data.getLastScrolledPosition());
|
||||
return new Pair<>(data.getThreadId(), PagedData.create(dataSource, config));
|
||||
});
|
||||
|
||||
this.messages = Transformations.map(messagesForThreadId, Pair::second);
|
||||
this.messages = Transformations.switchMap(pagedDataForThreadId, pair -> {
|
||||
pagingController.set(pair.second().getController());
|
||||
return pair.second().getData();
|
||||
});
|
||||
|
||||
LiveData<DistinctConversationDataByThreadId> distinctData = LiveDataUtil.combineLatest(messagesForThreadId,
|
||||
metadata,
|
||||
(m, data) -> new DistinctConversationDataByThreadId(data));
|
||||
|
||||
conversationMetadata = Transformations.map(Transformations.distinctUntilChanged(distinctData), DistinctConversationDataByThreadId::getConversationData);
|
||||
|
||||
canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble);
|
||||
conversationMetadata = Transformations.switchMap(messages, m -> metadata);
|
||||
canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble);
|
||||
}
|
||||
|
||||
void onAttachmentKeyboardOpen() {
|
||||
|
@ -144,10 +156,14 @@ class ConversationViewModel extends ViewModel {
|
|||
return conversationMetadata;
|
||||
}
|
||||
|
||||
@NonNull LiveData<PagedList<ConversationMessage>> getMessages() {
|
||||
@NonNull LiveData<List<ConversationMessage>> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
@NonNull PagingController getPagingController() {
|
||||
return pagingController;
|
||||
}
|
||||
|
||||
long getLastSeen() {
|
||||
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0;
|
||||
}
|
||||
|
@ -167,7 +183,7 @@ class ConversationViewModel extends ViewModel {
|
|||
@Override
|
||||
protected void onCleared() {
|
||||
super.onCleared();
|
||||
invalidator.invalidate();
|
||||
context.getContentResolver().unregisterContentObserver(messageObserver);
|
||||
}
|
||||
|
||||
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
|
@ -177,29 +193,4 @@ class ConversationViewModel extends ViewModel {
|
|||
return modelClass.cast(new ConversationViewModel());
|
||||
}
|
||||
}
|
||||
|
||||
private static class DistinctConversationDataByThreadId {
|
||||
private final ConversationData conversationData;
|
||||
|
||||
private DistinctConversationDataByThreadId(@NonNull ConversationData conversationData) {
|
||||
this.conversationData = conversationData;
|
||||
}
|
||||
|
||||
public @NonNull ConversationData getConversationData() {
|
||||
return conversationData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
DistinctConversationDataByThreadId that = (DistinctConversationDataByThreadId) o;
|
||||
return Objects.equals(conversationData.getThreadId(), that.conversationData.getThreadId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(conversationData.getThreadId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import androidx.annotation.Nullable;
|
|||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
|
@ -20,6 +22,8 @@ import java.util.Objects;
|
|||
*/
|
||||
public class SnapToTopDataObserver extends RecyclerView.AdapterDataObserver {
|
||||
|
||||
private static final String TAG = Log.tag(SnapToTopDataObserver.class);
|
||||
|
||||
private final RecyclerView recyclerView;
|
||||
private final LinearLayoutManager layoutManager;
|
||||
private final Deferred deferred;
|
||||
|
@ -83,13 +87,19 @@ public class SnapToTopDataObserver extends RecyclerView.AdapterDataObserver {
|
|||
Objects.requireNonNull(scrollRequestValidator, "Cannot request positions when SnapToTopObserver was initialized without a validator.");
|
||||
|
||||
if (!scrollRequestValidator.isPositionStillValid(position)) {
|
||||
Log.d(TAG, "requestScrollPositionInternal(" + position + ") Invalid");
|
||||
onInvalidPosition.run();
|
||||
} else if (scrollRequestValidator.isItemAtPositionLoaded(position)) {
|
||||
onPerformScroll.onPerformScroll(layoutManager, position);
|
||||
onScrollRequestComplete.run();
|
||||
Log.d(TAG, "requestScrollPositionInternal(" + position + ") Scrolling");
|
||||
onPerformScroll.onPerformScroll(layoutManager, position);
|
||||
onScrollRequestComplete.run();
|
||||
} else {
|
||||
Log.d(TAG, "requestScrollPositionInternal(" + position + ") Deferring");
|
||||
deferred.setDeferred(true);
|
||||
deferred.defer(() -> requestScrollPositionInternal(position, onPerformScroll, onScrollRequestComplete, onInvalidPosition));
|
||||
deferred.defer(() -> {
|
||||
Log.d(TAG, "requestScrollPositionInternal(" + position + ") Executing deferred");
|
||||
requestScrollPositionInternal(position, onPerformScroll, onScrollRequestComplete, onInvalidPosition);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,7 +121,8 @@ public class SnapToTopDataObserver extends RecyclerView.AdapterDataObserver {
|
|||
|
||||
if (newItemPosition != 0 ||
|
||||
recyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE ||
|
||||
recyclerView.canScrollVertically(layoutManager.getReverseLayout() ? 1 : -1)) {
|
||||
recyclerView.canScrollVertically(layoutManager.getReverseLayout() ? 1 : -1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
35
build.gradle
|
@ -1,7 +1,42 @@
|
|||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
jcenter {
|
||||
content {
|
||||
includeVersion 'org.jetbrains.trove4j', 'trove4j', '20160824'
|
||||
includeGroupByRegex "com\\.archinamon.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.1.1'
|
||||
classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.1.0'
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
|
||||
}
|
||||
}
|
||||
|
||||
ext {
|
||||
BUILD_TOOL_VERSION = '30.0.2'
|
||||
|
||||
COMPILE_SDK = 30
|
||||
TARGET_SDK = 30
|
||||
MINIMUM_SDK = 19
|
||||
|
||||
JAVA_VERSION = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
wrapper {
|
||||
distributionType = Wrapper.DistributionType.ALL
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
ext.lib_signal_service_version_number = "2.15.3"
|
||||
ext.lib_signal_service_group_info = "org.whispersystems"
|
||||
|
|
|
@ -78,6 +78,15 @@ class WitnessPlugin implements Plugin<Project> {
|
|||
def configurationName = project.dependencyVerification.configuration
|
||||
project.configurations
|
||||
.findAll { config -> config.name =~ configurationName }
|
||||
.collectMany { it.resolvedConfiguration.resolvedArtifacts }
|
||||
.collectMany {
|
||||
it.resolvedConfiguration.lenientConfiguration.allModuleDependencies
|
||||
}
|
||||
.findAll {
|
||||
// Exclude locally built modules
|
||||
it.module.id.group != 'Signal'
|
||||
}
|
||||
.collectMany {
|
||||
it.allModuleArtifacts
|
||||
}
|
||||
}
|
||||
}
|
66
flavors.gradle
Normal file
|
@ -0,0 +1,66 @@
|
|||
ext.flavorConfig = {
|
||||
flavorDimensions 'distribution', 'environment'
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
dimension 'distribution'
|
||||
isDefault true
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
}
|
||||
|
||||
website {
|
||||
dimension 'distribution'
|
||||
ext.websiteUpdateUrl = "https://updates.signal.org/android"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
||||
}
|
||||
|
||||
internal {
|
||||
dimension 'distribution'
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "int", "TRACE_EVENT_MAX", "30_000"
|
||||
}
|
||||
|
||||
prod {
|
||||
dimension 'environment'
|
||||
|
||||
isDefault true
|
||||
}
|
||||
|
||||
staging {
|
||||
dimension 'environment'
|
||||
|
||||
myApplicationIdSuffix ".staging"
|
||||
|
||||
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\""
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
||||
"\"038c40bbbacdc873caa81ac793bb75afde6dfe436a99ab1f15e3f0cbb7434ced\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
|
||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors.all { flavor ->
|
||||
if (flavor.hasProperty('myApplicationIdSuffix') && isApplicationProject()) {
|
||||
flavor.applicationIdSuffix = flavor.myApplicationIdSuffix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def isApplicationProject() {
|
||||
return project.android.class.simpleName.startsWith('BaseAppModuleExtension')
|
||||
// in AGP 3.1.x with library modules instead of feature modules:
|
||||
// return project.android instanceof com.android.build.gradle.AppExtension
|
||||
}
|
|
@ -1,14 +1,3 @@
|
|||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'java-library'
|
||||
apply plugin: 'com.google.protobuf'
|
||||
apply plugin: 'maven'
|
||||
|
|
30
paging/app/build.gradle
Normal file
|
@ -0,0 +1,30 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
buildToolsVersion BUILD_TOOL_VERSION
|
||||
compileSdkVersion COMPILE_SDK
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.signal.pagingtest"
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
minSdkVersion MINIMUM_SDK
|
||||
targetSdkVersion TARGET_SDK
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.2.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
|
||||
implementation project(':paging')
|
||||
}
|
22
paging/app/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.signal.pagingtest">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PagingTest">
|
||||
<activity android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
135
paging/app/src/main/java/org/signal/pagingtest/MainActivity.java
Normal file
|
@ -0,0 +1,135 @@
|
|||
package org.signal.pagingtest;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.signal.paging.PagingController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
MyAdapter adapter = new MyAdapter();
|
||||
RecyclerView list = findViewById(R.id.list);
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
|
||||
|
||||
list.setAdapter(adapter);
|
||||
list.setLayoutManager(layoutManager);
|
||||
|
||||
MainViewModel viewModel = new ViewModelProvider(this, new ViewModelProvider.NewInstanceFactory()).get(MainViewModel.class);
|
||||
adapter.setPagingController(viewModel.getPagingController());
|
||||
viewModel.getList().observe(this, newList -> {
|
||||
adapter.submitList(newList);
|
||||
});
|
||||
|
||||
findViewById(R.id.invalidate_btn).setOnClickListener(v -> {
|
||||
viewModel.getPagingController().onDataInvalidated();
|
||||
});
|
||||
|
||||
findViewById(R.id.down250_btn).setOnClickListener(v -> {
|
||||
int target = Math.min(adapter.getItemCount() - 1, layoutManager.findFirstVisibleItemPosition() + 250);
|
||||
layoutManager.scrollToPosition(target);
|
||||
});
|
||||
|
||||
findViewById(R.id.up250_btn).setOnClickListener(v -> {
|
||||
int target = Math.max(0, layoutManager.findFirstVisibleItemPosition() - 250);
|
||||
layoutManager.scrollToPosition(target);
|
||||
});
|
||||
|
||||
findViewById(R.id.append_btn).setOnClickListener(v -> {
|
||||
viewModel.appendItems();
|
||||
});
|
||||
}
|
||||
|
||||
static class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
|
||||
|
||||
private final static int TYPE_NORMAL = 1;
|
||||
private final static int TYPE_PLACEHOLDER = -1;
|
||||
|
||||
private PagingController controller;
|
||||
|
||||
private final List<String> data = new ArrayList<>();
|
||||
|
||||
public MyAdapter() {
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
return getItem(position) == null ? TYPE_PLACEHOLDER : TYPE_NORMAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return data.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
switch (viewType) {
|
||||
case TYPE_NORMAL:
|
||||
return new MyViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item, parent, false));
|
||||
case TYPE_PLACEHOLDER:
|
||||
return new MyViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item, parent, false));
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
|
||||
holder.bind(getItem(position));
|
||||
}
|
||||
|
||||
private String getItem(int index) {
|
||||
if (controller != null) {
|
||||
controller.onDataNeededAroundIndex(index);
|
||||
}
|
||||
return data.get(index);
|
||||
}
|
||||
|
||||
void setPagingController(PagingController pagingController) {
|
||||
this.controller = pagingController;
|
||||
}
|
||||
|
||||
void submitList(List<String> list) {
|
||||
data.clear();
|
||||
data.addAll(list);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
static class MyViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
TextView textView;
|
||||
|
||||
public MyViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
textView = itemView.findViewById(R.id.text);
|
||||
}
|
||||
|
||||
void bind(@NonNull String s) {
|
||||
textView.setText(s == null ? "PLACEHOLDER" : s);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package org.signal.pagingtest;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.signal.paging.PagedDataSource;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.signal.paging.PagingConfig;
|
||||
import org.signal.paging.PagedData;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class MainViewModel extends ViewModel {
|
||||
|
||||
private final PagedData<String> pagedData;
|
||||
private final MyDataSource dataSource;
|
||||
|
||||
public MainViewModel() {
|
||||
this.dataSource = new MyDataSource(1000);
|
||||
this.pagedData = PagedData.create(dataSource, new PagingConfig.Builder().setBufferPages(3)
|
||||
.setPageSize(25)
|
||||
.build());
|
||||
}
|
||||
|
||||
public @NonNull LiveData<List<String>> getList() {
|
||||
return pagedData.getData();
|
||||
}
|
||||
|
||||
public @NonNull PagingController getPagingController() {
|
||||
return pagedData.getController();
|
||||
}
|
||||
|
||||
public void appendItems() {
|
||||
dataSource.setSize(dataSource.size() + 1);
|
||||
pagedData.getController().onDataInvalidated();
|
||||
}
|
||||
|
||||
private static class MyDataSource implements PagedDataSource<String> {
|
||||
|
||||
private int size;
|
||||
|
||||
MyDataSource(int size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public void setSize(int size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
List<String> data = new ArrayList<>(length);
|
||||
|
||||
for (int i = 0; i < length; i++) {
|
||||
data.add(String.valueOf(start + i) + " (" + System.currentTimeMillis() + ")");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
171
paging/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
@ -0,0 +1,171 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
72
paging/app/src/main/res/layout/activity_main.xml
Normal file
|
@ -0,0 +1,72 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/purple_200"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:elevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/invalidate_btn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Invalidate" />
|
||||
|
||||
<Space
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/down250_btn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="250 ↓" />
|
||||
|
||||
<Space
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/up250_btn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="250 ↑" />
|
||||
|
||||
<Space
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/append_btn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Append" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</HorizontalScrollView>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/buttons"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
29
paging/app/src/main/res/layout/item.xml
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="5dp"
|
||||
android:clipToPadding="false"
|
||||
android:clipChildren="false">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardElevation="5dp"
|
||||
app:cardCornerRadius="5dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
tools:text="Spider-Man"/>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
BIN
paging/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
paging/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
paging/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
paging/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
paging/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
paging/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
paging/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
paging/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 16 KiB |
16
paging/app/src/main/res/values-night/themes.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.PagingTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
10
paging/app/src/main/res/values/colors.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
3
paging/app/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">PagingTest</string>
|
||||
</resources>
|
16
paging/app/src/main/res/values/themes.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.PagingTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
29
paging/lib/build.gradle
Normal file
|
@ -0,0 +1,29 @@
|
|||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'witness'
|
||||
apply from: 'witness-verifications.gradle'
|
||||
|
||||
android {
|
||||
buildToolsVersion BUILD_TOOL_VERSION
|
||||
compileSdkVersion COMPILE_SDK
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion MINIMUM_SDK
|
||||
targetSdkVersion TARGET_SDK
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
dependencyVerification {
|
||||
configuration = '(debug|release)RuntimeClasspath'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.2.1'
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
}
|
6
paging/lib/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.signal.paging">
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,64 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* We have a bit of a threading problem -- we want our controller to have a fixed size so that it
|
||||
* can keep track of which ranges of requests are in flight, but it needs to make a blocking call
|
||||
* to find out the size of the dataset first!
|
||||
*
|
||||
* So what this controller does is use a serial executor so that it can buffer calls to a secondary
|
||||
* controller. The first task on the executor creates the first controller, so all future calls to
|
||||
* {@link #onDataNeededAroundIndex(int)} are guaranteed to have an active controller.
|
||||
*
|
||||
* It's also worth noting that this controller has lifecycle that matches the {@link PagedData} that
|
||||
* contains it. When invalidations come in, this class will just swap out the active controller with
|
||||
* a new one.
|
||||
*/
|
||||
class BufferedPagingController<E> implements PagingController {
|
||||
|
||||
private final PagedDataSource<E> dataSource;
|
||||
private final PagingConfig config;
|
||||
private final MutableLiveData<List<E>> liveData;
|
||||
private final Executor serializationExecutor;
|
||||
|
||||
private PagingController activeController;
|
||||
private int lastRequestedIndex;
|
||||
|
||||
BufferedPagingController(PagedDataSource<E> dataSource, PagingConfig config, @NonNull MutableLiveData<List<E>> liveData) {
|
||||
this.dataSource = dataSource;
|
||||
this.config = config;
|
||||
this.liveData = liveData;
|
||||
this.serializationExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
this.activeController = null;
|
||||
this.lastRequestedIndex = config.startIndex();
|
||||
|
||||
onDataInvalidated();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataNeededAroundIndex(int aroundIndex) {
|
||||
serializationExecutor.execute(() -> {
|
||||
lastRequestedIndex = aroundIndex;
|
||||
activeController.onDataNeededAroundIndex(aroundIndex);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataInvalidated() {
|
||||
serializationExecutor.execute(() -> {
|
||||
if (activeController != null) {
|
||||
activeController.onDataInvalidated();
|
||||
}
|
||||
|
||||
activeController = new FixedSizePagingController<>(dataSource, config, liveData, dataSource.size());
|
||||
activeController.onDataNeededAroundIndex(lastRequestedIndex);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.AbstractList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A placeholder class for efficiently storing lists that are mostly empty space.
|
||||
* TODO [greyson][paging]
|
||||
*/
|
||||
public class CompressedList<E> extends AbstractList<E> {
|
||||
|
||||
private final List<E> wrapped;
|
||||
|
||||
public CompressedList(@NonNull List<E> source) {
|
||||
this.wrapped = new ArrayList<>(source);
|
||||
}
|
||||
|
||||
public CompressedList(int totalSize) {
|
||||
this.wrapped = new ArrayList<>(totalSize);
|
||||
|
||||
for (int i = 0; i < totalSize; i++) {
|
||||
wrapped.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return wrapped.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public E get(int index) {
|
||||
return wrapped.get(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public E set(int globalIndex, E element) {
|
||||
return wrapped.set(globalIndex, element);
|
||||
}
|
||||
}
|
64
paging/lib/src/main/java/org/signal/paging/DataStatus.java
Normal file
|
@ -0,0 +1,64 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Pools;
|
||||
|
||||
import java.util.BitSet;
|
||||
|
||||
/**
|
||||
* Keeps track of what data is empty vs filled with an emphasis on doing so in a space-efficient way.
|
||||
*/
|
||||
class DataStatus {
|
||||
|
||||
private static final Pools.Pool<BitSet> POOL = new Pools.SynchronizedPool<>(1);
|
||||
|
||||
private final BitSet state;
|
||||
private final int size;
|
||||
|
||||
public static DataStatus obtain(int size) {
|
||||
BitSet bitset = POOL.acquire();
|
||||
if (bitset == null) {
|
||||
bitset = new BitSet(size);
|
||||
} else {
|
||||
bitset.clear();
|
||||
}
|
||||
|
||||
return new DataStatus(size, bitset);
|
||||
}
|
||||
|
||||
|
||||
private DataStatus(int size, @NonNull BitSet bitset) {
|
||||
this.size = size;
|
||||
this.state = bitset;
|
||||
}
|
||||
|
||||
void markRange(int startInclusive, int endExclusive) {
|
||||
state.set(startInclusive, endExclusive, true);
|
||||
}
|
||||
|
||||
int getEarliestUnmarkedIndexInRange(int startInclusive, int endExclusive) {
|
||||
for (int i = startInclusive; i < endExclusive; i++) {
|
||||
if (!state.get(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int getLatestUnmarkedIndexInRange(int startInclusive, int endExclusive) {
|
||||
for (int i = endExclusive - 1; i >= startInclusive; i--) {
|
||||
if (!state.get(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
void recycle() {
|
||||
POOL.release(state);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import org.signal.paging.util.LinkedBlockingLifoQueue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* The workhorse of managing page requests.
|
||||
*
|
||||
* A controller whose life focuses around one invalidation cycle of a data set, and therefore has
|
||||
* a fixed size throughout. It assumes that all interface methods are called on a single thread,
|
||||
* which allows it to keep track of pending requests in a thread-safe way, while spinning off
|
||||
* tasks to fetch data on its own executor.
|
||||
*/
|
||||
class FixedSizePagingController<E> implements PagingController {
|
||||
|
||||
private static final String TAG = FixedSizePagingController.class.getSimpleName();
|
||||
|
||||
private static final Executor FETCH_EXECUTOR = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new LinkedBlockingLifoQueue<>(), r -> new Thread(r, "signal-FixedSizedPagingController"));
|
||||
private static final boolean DEBUG = false;
|
||||
|
||||
private final PagedDataSource<E> dataSource;
|
||||
private final PagingConfig config;
|
||||
private final MutableLiveData<List<E>> liveData;
|
||||
private final DataStatus loadState;
|
||||
|
||||
private List<E> data;
|
||||
|
||||
private volatile boolean invalidated;
|
||||
|
||||
FixedSizePagingController(@NonNull PagedDataSource<E> dataSource,
|
||||
@NonNull PagingConfig config,
|
||||
@NonNull MutableLiveData<List<E>> liveData,
|
||||
int size)
|
||||
{
|
||||
this.dataSource = dataSource;
|
||||
this.config = config;
|
||||
this.liveData = liveData;
|
||||
this.loadState = DataStatus.obtain(size);
|
||||
this.data = new CompressedList<>(loadState.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* We assume this method is always called on the same thread, so we can read our
|
||||
* {@code loadState} and construct the parameters of a fetch request. That fetch request can
|
||||
* then be performed on separate single-thread LIFO executor.
|
||||
*/
|
||||
@Override
|
||||
public void onDataNeededAroundIndex(int aroundIndex) {
|
||||
if (invalidated) {
|
||||
Log.w(TAG, buildLog(aroundIndex, "Invalidated! At very beginning."));
|
||||
return;
|
||||
}
|
||||
|
||||
int leftPageBoundary = (aroundIndex / config.pageSize()) * config.pageSize();
|
||||
int rightPageBoundary = leftPageBoundary + config.pageSize();
|
||||
int buffer = config.bufferPages() * config.pageSize();
|
||||
|
||||
int leftLoadBoundary = Math.max(0, leftPageBoundary - buffer);
|
||||
int rightLoadBoundary = Math.min(loadState.size(), rightPageBoundary + buffer);
|
||||
|
||||
int loadStart = loadState.getEarliestUnmarkedIndexInRange(leftLoadBoundary, rightLoadBoundary);
|
||||
|
||||
if (loadStart < 0) {
|
||||
if (DEBUG) Log.i(TAG, buildLog(aroundIndex, "loadStart < 0"));
|
||||
return;
|
||||
}
|
||||
|
||||
int loadEnd = loadState.getLatestUnmarkedIndexInRange(Math.max(leftLoadBoundary, loadStart), rightLoadBoundary) + 1;
|
||||
|
||||
if (loadEnd <= loadStart) {
|
||||
if (DEBUG) Log.i(TAG, buildLog(aroundIndex, "loadEnd <= loadStart, loadEnd: " + loadEnd + ", loadStart: " + loadStart));
|
||||
return;
|
||||
}
|
||||
|
||||
int totalSize = loadState.size();
|
||||
|
||||
loadState.markRange(loadStart, loadEnd);
|
||||
|
||||
if (DEBUG) Log.i(TAG, buildLog(aroundIndex, "start: " + loadStart + ", end: " + loadEnd + ", totalSize: " + totalSize));
|
||||
|
||||
FETCH_EXECUTOR.execute(() -> {
|
||||
if (invalidated) {
|
||||
Log.w(TAG, buildLog(aroundIndex, "Invalidated! At beginning of load task."));
|
||||
return;
|
||||
}
|
||||
|
||||
List<E> loaded = dataSource.load(loadStart, loadEnd - loadStart, () -> invalidated);
|
||||
|
||||
if (invalidated) {
|
||||
Log.w(TAG, buildLog(aroundIndex, "Invalidated! Just after data was loaded."));
|
||||
return;
|
||||
}
|
||||
|
||||
List<E> updated = new CompressedList<>(data);
|
||||
|
||||
for (int i = 0, len = Math.min(loaded.size(), data.size() - loadStart); i < len; i++) {
|
||||
updated.set(loadStart + i, loaded.get(i));
|
||||
}
|
||||
|
||||
data = updated;
|
||||
liveData.postValue(updated);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataInvalidated() {
|
||||
if (invalidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
invalidated = true;
|
||||
loadState.recycle();
|
||||
}
|
||||
|
||||
private static String buildLog(int aroundIndex, String message) {
|
||||
return "onDataNeededAroundIndex(" + aroundIndex + ") " + message;
|
||||
}
|
||||
}
|
40
paging/lib/src/main/java/org/signal/paging/PagedData.java
Normal file
|
@ -0,0 +1,40 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The primary entry point for creating paged data.
|
||||
*/
|
||||
public final class PagedData<E> {
|
||||
|
||||
private final LiveData<List<E>> data;
|
||||
private final PagingController controller;
|
||||
|
||||
@AnyThread
|
||||
public static <E> PagedData<E> create(@NonNull PagedDataSource<E> dataSource, @NonNull PagingConfig config) {
|
||||
MutableLiveData<List<E>> liveData = new MutableLiveData<>();
|
||||
PagingController controller = new BufferedPagingController<>(dataSource, config, liveData);
|
||||
|
||||
return new PagedData<>(liveData, controller);
|
||||
}
|
||||
|
||||
private PagedData(@NonNull LiveData<List<E>> data, @NonNull PagingController controller) {
|
||||
this.data = data;
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull LiveData<List<E>> getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull PagingController getController() {
|
||||
return controller;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a source of data that can be queried.
|
||||
*/
|
||||
public interface PagedDataSource<T> {
|
||||
/**
|
||||
* @return The total size of the data set.
|
||||
*/
|
||||
@WorkerThread
|
||||
int size();
|
||||
|
||||
/**
|
||||
* @param start The index of the first item that should be included in your results.
|
||||
* @param length The total number of items you should return.
|
||||
* @param cancellationSignal An object that you can check to see if the load operation was canceled.
|
||||
*
|
||||
* @return A list of length {@code length} that represents the data starting at {@code start}.
|
||||
* If you don't have the full range, just populate what you can.
|
||||
*/
|
||||
@WorkerThread
|
||||
@NonNull List<T> load(int start, int length, @NonNull CancellationSignal cancellationSignal);
|
||||
|
||||
interface CancellationSignal {
|
||||
/**
|
||||
* @return True if the operation has been canceled, otherwise false.
|
||||
*/
|
||||
boolean isCanceled();
|
||||
}
|
||||
}
|
79
paging/lib/src/main/java/org/signal/paging/PagingConfig.java
Normal file
|
@ -0,0 +1,79 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Describes various properties of how you'd like paging to be handled.
|
||||
*/
|
||||
public final class PagingConfig {
|
||||
|
||||
private final int bufferPages;
|
||||
private final int startIndex;
|
||||
private final int pageSize;
|
||||
|
||||
private PagingConfig(@NonNull Builder builder) {
|
||||
this.bufferPages = builder.bufferPages;
|
||||
this.startIndex = builder.startIndex;
|
||||
this.pageSize = builder.pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return How many pages of 'buffer' you want ahead of and behind the active position. i.e. if
|
||||
* the {@code pageSize()} is 10 and you specify 2 buffer pages, then there will always be
|
||||
* at least 20 items ahead of and behind the current position.
|
||||
*/
|
||||
int bufferPages() {
|
||||
return bufferPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return How much data to load at a time when paging data.
|
||||
*/
|
||||
int pageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return What position to start loading at
|
||||
*/
|
||||
int startIndex() {
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private int bufferPages = 1;
|
||||
private int startIndex = 0;
|
||||
private int pageSize = 50;
|
||||
|
||||
public @NonNull Builder setBufferPages(int bufferPages) {
|
||||
if (bufferPages < 1) {
|
||||
throw new IllegalArgumentException("You must have at least one buffer page! Requested: " + bufferPages);
|
||||
}
|
||||
|
||||
this.bufferPages = bufferPages;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setPageSize(int pageSize) {
|
||||
if (pageSize < 1) {
|
||||
throw new IllegalArgumentException("You must have a page size of at least one! Requested: " + pageSize);
|
||||
}
|
||||
|
||||
this.pageSize = pageSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setStartIndex(int startIndex) {
|
||||
if (startIndex < 0) {
|
||||
throw new IndexOutOfBoundsException("Requested: " + startIndex);
|
||||
}
|
||||
|
||||
this.startIndex = startIndex;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull PagingConfig build() {
|
||||
return new PagingConfig(this);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package org.signal.paging;
|
||||
|
||||
|
||||
public interface PagingController {
|
||||
void onDataNeededAroundIndex(int aroundIndex);
|
||||
void onDataInvalidated();
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package org.signal.paging;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* A controller that forwards calls to a secondary, proxied controller. This is useful when you want
|
||||
* to keep a single, static controller, even when the true controller may be changing due to data
|
||||
* source changes.
|
||||
*/
|
||||
public class ProxyPagingController implements PagingController {
|
||||
|
||||
private PagingController proxied;
|
||||
|
||||
@Override
|
||||
public synchronized void onDataNeededAroundIndex(int aroundIndex) {
|
||||
if (proxied != null) {
|
||||
proxied.onDataNeededAroundIndex(aroundIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onDataInvalidated() {
|
||||
if (proxied != null) {
|
||||
proxied.onDataInvalidated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the underlying controller to the one specified.
|
||||
*/
|
||||
public synchronized void set(@Nullable PagingController bound) {
|
||||
this.proxied = bound;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package org.signal.paging.util;
|
||||
|
||||
|
||||
import java.util.concurrent.LinkedBlockingDeque;
|
||||
|
||||
public class LinkedBlockingLifoQueue<E> extends LinkedBlockingDeque<E> {
|
||||
@Override
|
||||
public void put(E runnable) throws InterruptedException {
|
||||
super.putFirst(runnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean add(E runnable) {
|
||||
super.addFirst(runnable);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean offer(E runnable) {
|
||||
super.addFirst(runnable);
|
||||
return true;
|
||||
}
|
||||
}
|
55
paging/lib/src/main/java/org/signal/paging/util/Util.java
Normal file
|
@ -0,0 +1,55 @@
|
|||
package org.signal.paging.util;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
public final class Util {
|
||||
|
||||
private static volatile Handler handler;
|
||||
|
||||
private Util() {}
|
||||
|
||||
public static void runOnMain(final @NonNull Runnable runnable) {
|
||||
if (isMainThread()) runnable.run();
|
||||
else getHandler().post(runnable);
|
||||
}
|
||||
|
||||
public static void runOnMainSync(final @NonNull Runnable runnable) {
|
||||
if (isMainThread()) {
|
||||
runnable.run();
|
||||
} else {
|
||||
final CountDownLatch sync = new CountDownLatch(1);
|
||||
runOnMain(() -> {
|
||||
try {
|
||||
runnable.run();
|
||||
} finally {
|
||||
sync.countDown();
|
||||
}
|
||||
});
|
||||
try {
|
||||
sync.await();
|
||||
} catch (InterruptedException ie) {
|
||||
throw new AssertionError(ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isMainThread() {
|
||||
return Looper.myLooper() == Looper.getMainLooper();
|
||||
}
|
||||
|
||||
private static Handler getHandler() {
|
||||
if (handler == null) {
|
||||
synchronized (Util.class) {
|
||||
if (handler == null) {
|
||||
handler = new Handler(Looper.getMainLooper());
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
}
|
99
paging/lib/witness-verifications.gradle
Normal file
|
@ -0,0 +1,99 @@
|
|||
// Auto-generated, use ./gradlew calculateChecksums to regenerate
|
||||
|
||||
dependencyVerification {
|
||||
verify = [
|
||||
|
||||
['androidx.activity:activity:1.0.0',
|
||||
'd1bc9842455c2e534415d88c44df4d52413b478db9093a1ba36324f705f44c3d'],
|
||||
|
||||
['androidx.annotation:annotation-experimental:1.0.0',
|
||||
'b219d2b568e7e4ba534e09f8c2fd242343df6ccbdfbbe938846f5d740e6b0b11'],
|
||||
|
||||
['androidx.annotation:annotation:1.1.0',
|
||||
'd38d63edb30f1467818d50aaf05f8a692dea8b31392a049bfa991b159ad5b692'],
|
||||
|
||||
['androidx.appcompat:appcompat-resources:1.2.0',
|
||||
'c470297c03ff3de1c3d15dacf0be0cae63abc10b52f021dd07ae28daa3100fe5'],
|
||||
|
||||
['androidx.appcompat:appcompat:1.2.0',
|
||||
'3d2131a55a61a777322e2126e0018011efa6339e53b44153eb651b16020cca70'],
|
||||
|
||||
['androidx.arch.core:core-common:2.1.0',
|
||||
'fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889'],
|
||||
|
||||
['androidx.arch.core:core-runtime:2.0.0',
|
||||
'87e65fc767c712b437649c7cee2431ebb4bed6daef82e501d4125b3ed3f65f8e'],
|
||||
|
||||
['androidx.cardview:cardview:1.0.0',
|
||||
'1193c04c22a3d6b5946dae9f4e8c59d6adde6a71b6bd5d87fb99d82dda1afec7'],
|
||||
|
||||
['androidx.collection:collection:1.1.0',
|
||||
'632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72'],
|
||||
|
||||
['androidx.coordinatorlayout:coordinatorlayout:1.1.0',
|
||||
'44a9e30abf56af1025c52a0af506fee9c4131aa55efda52f9fd9451211c5e8cb'],
|
||||
|
||||
['androidx.core:core:1.3.0',
|
||||
'1c6b6626f15185d8f4bc7caac759412a1ab6e851ecf7526387d9b9fadcabdb63'],
|
||||
|
||||
['androidx.cursoradapter:cursoradapter:1.0.0',
|
||||
'a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564'],
|
||||
|
||||
['androidx.customview:customview:1.0.0',
|
||||
'20e5b8f6526a34595a604f56718da81167c0b40a7a94a57daa355663f2594df2'],
|
||||
|
||||
['androidx.drawerlayout:drawerlayout:1.0.0',
|
||||
'9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1'],
|
||||
|
||||
['androidx.fragment:fragment:1.1.0',
|
||||
'a14c8b8f2153f128e800fbd266a6beab1c283982a29ec570d2cc05d307d81496'],
|
||||
|
||||
['androidx.interpolator:interpolator:1.0.0',
|
||||
'33193135a64fe21fa2c35eec6688f1a76e512606c0fc83dc1b689e37add7732a'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-common:2.1.0',
|
||||
'76db6be533bd730fb361c2feb12a2c26d9952824746847da82601ef81f082643'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-livedata-core:2.0.0',
|
||||
'fde334ec7e22744c0f5bfe7caf1a84c9d717327044400577bdf9bd921ec4f7bc'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-livedata:2.0.0',
|
||||
'c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-runtime:2.1.0',
|
||||
'e5173897b965e870651e83d9d5af1742d3f532d58863223a390ce3a194c8312b'],
|
||||
|
||||
['androidx.lifecycle:lifecycle-viewmodel:2.1.0',
|
||||
'ba55fb7ac1b2828d5327cda8acf7085d990b2b4c43ef336caa67686249b8523d'],
|
||||
|
||||
['androidx.loader:loader:1.0.0',
|
||||
'11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025'],
|
||||
|
||||
['androidx.recyclerview:recyclerview:1.1.0',
|
||||
'f0d2b5a67d0a91ee1b1c73ef2b636a81f3563925ddd15a1d4e1c41ec28de7a4f'],
|
||||
|
||||
['androidx.savedstate:savedstate:1.0.0',
|
||||
'2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83'],
|
||||
|
||||
['androidx.transition:transition:1.2.0',
|
||||
'a1e059b3bc0b43a58dec0efecdcaa89c82d2bca552ea5bacf6656c46e853157e'],
|
||||
|
||||
['androidx.vectordrawable:vectordrawable-animated:1.1.0',
|
||||
'76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8'],
|
||||
|
||||
['androidx.vectordrawable:vectordrawable:1.1.0',
|
||||
'46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26'],
|
||||
|
||||
['androidx.versionedparcelable:versionedparcelable:1.1.0',
|
||||
'9a1d77140ac222b7866b5054ee7d159bc1800987ed2d46dd6afdd145abb710c1'],
|
||||
|
||||
['androidx.viewpager2:viewpager2:1.0.0',
|
||||
'e95c0031d4cc247cd48196c6287e58d2cee54d9c79b85afea7c90920330275af'],
|
||||
|
||||
['androidx.viewpager:viewpager:1.0.0',
|
||||
'147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682'],
|
||||
|
||||
['com.google.android.material:material:1.2.1',
|
||||
'd3d0cc776f2341da8e572586c7d390a5b356ce39a0deb2768071dc40b364ac80'],
|
||||
]
|
||||
}
|
|
@ -1,8 +1,12 @@
|
|||
include ':app'
|
||||
include ':libsignal-service'
|
||||
include ':lintchecks'
|
||||
include ':paging'
|
||||
include ':paging-app'
|
||||
|
||||
project(':app').name = 'Signal-Android'
|
||||
project(':paging').projectDir = file('paging/lib')
|
||||
project(':paging-app').projectDir = file('paging/app')
|
||||
|
||||
project(':libsignal-service').projectDir = file('libsignal/service')
|
||||
|
||||
|
|