diff --git a/app/build.gradle b/app/build.gradle index fd426310b..b0164708a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index bad8da4f6..74b42a2e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -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 + extends ListAdapter implements StickyHeaderDecoration.StickyHeaderAdapter { @@ -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() { + @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 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 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 { - @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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index c70f8b90b..3571e1d21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -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 { +class ConversationDataSource implements PagedDataSource { 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 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 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 result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount); - - List 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 callback) { - long start = System.currentTimeMillis(); - + public @NonNull List load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId); MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); - List records = new ArrayList<>(params.loadSize); + List 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 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 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 { return messageIdToMentions.get(id); } } - - static class Factory extends DataSource.Factory { - - 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 create() { - return new ConversationDataSource(context, threadId, invalidator); - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 164fa4eed..df6d4ac71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -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) -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index 389c34372..ac41e9e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -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> recentMedia; - private final MutableLiveData threadId; - private final LiveData> messages; - private final LiveData conversationMetadata; - private final Invalidator invalidator; - private final MutableLiveData showScrollButtons; - private final MutableLiveData hasUnreadMentions; - private final LiveData canShowAsBubble; + private final Application context; + private final MediaRepository mediaRepository; + private final ConversationRepository conversationRepository; + private final MutableLiveData> recentMedia; + private final MutableLiveData threadId; + private final LiveData> messages; + private final LiveData conversationMetadata; + private final MutableLiveData showScrollButtons; + private final MutableLiveData hasUnreadMentions; + private final LiveData 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 metadata = Transformations.switchMap(threadId, thread -> { LiveData conversationData = conversationRepository.getConversationData(thread, jumpToPosition); @@ -62,13 +72,7 @@ class ConversationViewModel extends ViewModel { return conversationData; }); - LiveData>> messagesForThreadId = Transformations.switchMap(metadata, data -> { - DataSource.Factory factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator); - PagedList.Config config = new PagedList.Config.Builder() - .setPageSize(25) - .setInitialLoadSizeHint(25) - .build(); - + LiveData>> 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 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> getMessages() { + @NonNull LiveData> 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()); - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java b/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java index b38dc9a05..f1a911b0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SnapToTopDataObserver.java @@ -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; } diff --git a/build.gradle b/build.gradle index 6bf79a96b..f3ece2ba6 100644 --- a/build.gradle +++ b/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" diff --git a/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy b/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy index fcd8d43dc..4f3680de2 100644 --- a/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy +++ b/buildSrc/src/main/groovy/org/whispersystems/witness/WitnessPlugin.groovy @@ -78,6 +78,15 @@ class WitnessPlugin implements Plugin { 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 + } } } \ No newline at end of file diff --git a/flavors.gradle b/flavors.gradle new file mode 100644 index 000000000..ad10ae678 --- /dev/null +++ b/flavors.gradle @@ -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 +} diff --git a/libsignal/service/build.gradle b/libsignal/service/build.gradle index ea635c35f..a522c091f 100644 --- a/libsignal/service/build.gradle +++ b/libsignal/service/build.gradle @@ -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' diff --git a/paging/app/build.gradle b/paging/app/build.gradle new file mode 100644 index 000000000..d557e2a71 --- /dev/null +++ b/paging/app/build.gradle @@ -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') +} \ No newline at end of file diff --git a/paging/app/src/main/AndroidManifest.xml b/paging/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f6cde326c --- /dev/null +++ b/paging/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paging/app/src/main/java/org/signal/pagingtest/MainActivity.java b/paging/app/src/main/java/org/signal/pagingtest/MainActivity.java new file mode 100644 index 000000000..04b39087c --- /dev/null +++ b/paging/app/src/main/java/org/signal/pagingtest/MainActivity.java @@ -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 { + + private final static int TYPE_NORMAL = 1; + private final static int TYPE_PLACEHOLDER = -1; + + private PagingController controller; + + private final List 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 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); + } + } +} \ No newline at end of file diff --git a/paging/app/src/main/java/org/signal/pagingtest/MainViewModel.java b/paging/app/src/main/java/org/signal/pagingtest/MainViewModel.java new file mode 100644 index 000000000..df232323b --- /dev/null +++ b/paging/app/src/main/java/org/signal/pagingtest/MainViewModel.java @@ -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 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> 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 { + + 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 load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + List data = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + data.add(String.valueOf(start + i) + " (" + System.currentTimeMillis() + ")"); + } + + return data; + } + } +} diff --git a/paging/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/paging/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..18c056585 --- /dev/null +++ b/paging/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/paging/app/src/main/res/drawable/ic_launcher_background.xml b/paging/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..c71b77f4d --- /dev/null +++ b/paging/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/paging/app/src/main/res/layout/activity_main.xml b/paging/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..b9fd5fd99 --- /dev/null +++ b/paging/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/paging/app/src/main/res/layout/item.xml b/paging/app/src/main/res/layout/item.xml new file mode 100644 index 000000000..6c82903b6 --- /dev/null +++ b/paging/app/src/main/res/layout/item.xml @@ -0,0 +1,29 @@ + + + + + + + + + + \ No newline at end of file diff --git a/paging/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/paging/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..924424826 --- /dev/null +++ b/paging/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/paging/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/paging/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..924424826 --- /dev/null +++ b/paging/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/paging/app/src/main/res/mipmap-hdpi/ic_launcher.png b/paging/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a571e6009 Binary files /dev/null and b/paging/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/paging/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/paging/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..61da551c5 Binary files /dev/null and b/paging/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/paging/app/src/main/res/mipmap-mdpi/ic_launcher.png b/paging/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c41dd2853 Binary files /dev/null and b/paging/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/paging/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/paging/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..db5080a75 Binary files /dev/null and b/paging/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/paging/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/paging/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..6dba46dab Binary files /dev/null and b/paging/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/paging/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/paging/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..da31a871c Binary files /dev/null and b/paging/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/paging/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/paging/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..15ac68172 Binary files /dev/null and b/paging/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/paging/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/paging/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..b216f2d31 Binary files /dev/null and b/paging/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..f25a41974 Binary files /dev/null and b/paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..e96783ccc Binary files /dev/null and b/paging/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/paging/app/src/main/res/values-night/themes.xml b/paging/app/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..6c4404c52 --- /dev/null +++ b/paging/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/paging/app/src/main/res/values/colors.xml b/paging/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..09837df62 --- /dev/null +++ b/paging/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/paging/app/src/main/res/values/strings.xml b/paging/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..abb445c61 --- /dev/null +++ b/paging/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + PagingTest + \ No newline at end of file diff --git a/paging/app/src/main/res/values/themes.xml b/paging/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..c17f427bd --- /dev/null +++ b/paging/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/paging/lib/build.gradle b/paging/lib/build.gradle new file mode 100644 index 000000000..f966859d9 --- /dev/null +++ b/paging/lib/build.gradle @@ -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' +} \ No newline at end of file diff --git a/paging/lib/src/main/AndroidManifest.xml b/paging/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e72d78efc --- /dev/null +++ b/paging/lib/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/paging/lib/src/main/java/org/signal/paging/BufferedPagingController.java b/paging/lib/src/main/java/org/signal/paging/BufferedPagingController.java new file mode 100644 index 000000000..6cb66efe3 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/BufferedPagingController.java @@ -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 implements PagingController { + + private final PagedDataSource dataSource; + private final PagingConfig config; + private final MutableLiveData> liveData; + private final Executor serializationExecutor; + + private PagingController activeController; + private int lastRequestedIndex; + + BufferedPagingController(PagedDataSource dataSource, PagingConfig config, @NonNull MutableLiveData> 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); + }); + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/CompressedList.java b/paging/lib/src/main/java/org/signal/paging/CompressedList.java new file mode 100644 index 000000000..7bfba4f84 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/CompressedList.java @@ -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 extends AbstractList { + + private final List wrapped; + + public CompressedList(@NonNull List 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); + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/DataStatus.java b/paging/lib/src/main/java/org/signal/paging/DataStatus.java new file mode 100644 index 000000000..af0dc8940 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/DataStatus.java @@ -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 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); + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java b/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java new file mode 100644 index 000000000..1606c2efa --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java @@ -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 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 dataSource; + private final PagingConfig config; + private final MutableLiveData> liveData; + private final DataStatus loadState; + + private List data; + + private volatile boolean invalidated; + + FixedSizePagingController(@NonNull PagedDataSource dataSource, + @NonNull PagingConfig config, + @NonNull MutableLiveData> 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 loaded = dataSource.load(loadStart, loadEnd - loadStart, () -> invalidated); + + if (invalidated) { + Log.w(TAG, buildLog(aroundIndex, "Invalidated! Just after data was loaded.")); + return; + } + + List 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; + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/PagedData.java b/paging/lib/src/main/java/org/signal/paging/PagedData.java new file mode 100644 index 000000000..92eb92604 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/PagedData.java @@ -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 { + + private final LiveData> data; + private final PagingController controller; + + @AnyThread + public static PagedData create(@NonNull PagedDataSource dataSource, @NonNull PagingConfig config) { + MutableLiveData> liveData = new MutableLiveData<>(); + PagingController controller = new BufferedPagingController<>(dataSource, config, liveData); + + return new PagedData<>(liveData, controller); + } + + private PagedData(@NonNull LiveData> data, @NonNull PagingController controller) { + this.data = data; + this.controller = controller; + } + + @AnyThread + public @NonNull LiveData> getData() { + return data; + } + + @AnyThread + public @NonNull PagingController getController() { + return controller; + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java b/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java new file mode 100644 index 000000000..22efa4382 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java @@ -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 { + /** + * @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 load(int start, int length, @NonNull CancellationSignal cancellationSignal); + + interface CancellationSignal { + /** + * @return True if the operation has been canceled, otherwise false. + */ + boolean isCanceled(); + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/PagingConfig.java b/paging/lib/src/main/java/org/signal/paging/PagingConfig.java new file mode 100644 index 000000000..260072792 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/PagingConfig.java @@ -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); + } + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/PagingController.java b/paging/lib/src/main/java/org/signal/paging/PagingController.java new file mode 100644 index 000000000..db2399fa8 --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/PagingController.java @@ -0,0 +1,7 @@ +package org.signal.paging; + + +public interface PagingController { + void onDataNeededAroundIndex(int aroundIndex); + void onDataInvalidated(); +} diff --git a/paging/lib/src/main/java/org/signal/paging/ProxyPagingController.java b/paging/lib/src/main/java/org/signal/paging/ProxyPagingController.java new file mode 100644 index 000000000..16b9edccb --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/ProxyPagingController.java @@ -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; + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/util/LinkedBlockingLifoQueue.java b/paging/lib/src/main/java/org/signal/paging/util/LinkedBlockingLifoQueue.java new file mode 100644 index 000000000..50c8b95fd --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/util/LinkedBlockingLifoQueue.java @@ -0,0 +1,23 @@ +package org.signal.paging.util; + + +import java.util.concurrent.LinkedBlockingDeque; + +public class LinkedBlockingLifoQueue extends LinkedBlockingDeque { + @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; + } +} diff --git a/paging/lib/src/main/java/org/signal/paging/util/Util.java b/paging/lib/src/main/java/org/signal/paging/util/Util.java new file mode 100644 index 000000000..341e3500a --- /dev/null +++ b/paging/lib/src/main/java/org/signal/paging/util/Util.java @@ -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; + } +} diff --git a/paging/lib/witness-verifications.gradle b/paging/lib/witness-verifications.gradle new file mode 100644 index 000000000..f26b14ea9 --- /dev/null +++ b/paging/lib/witness-verifications.gradle @@ -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'], + ] +} diff --git a/settings.gradle b/settings.gradle index 789eb3211..11ab4be1f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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')