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>
This commit is contained in:
Greyson Parrelli 2020-12-03 13:19:03 -05:00
parent ac41f3d662
commit 31960b53a0
49 changed files with 1596 additions and 243 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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')
}

View 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>

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

View file

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

View file

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

View 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>

View 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>

View 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>

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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>

View 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>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">PagingTest</string>
</resources>

View 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
View 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'
}

View 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>

View file

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

View file

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

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

View file

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

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

View file

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

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

View file

@ -0,0 +1,7 @@
package org.signal.paging;
public interface PagingController {
void onDataNeededAroundIndex(int aroundIndex);
void onDataInvalidated();
}

View file

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

View file

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

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

View 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'],
]
}

View file

@ -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')