/* * Copyright 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.paging; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.AbstractList; import java.util.ArrayList; import java.util.List; /** * Class holding the pages of data backing a PagedList, presenting sparse loaded data as a List. *

* It has two modes of operation: contiguous and non-contiguous (tiled). This class only holds * data, and does not have any notion of the ideas of async loads, or prefetching. */ final class PagedStorage extends AbstractList { /** * Lists instances are compared (with instance equality) to PLACEHOLDER_LIST to check if an item * in that position is already loading. We use a singleton placeholder list that is distinct * from Collections.emptyList() for safety. */ @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") private static final List PLACEHOLDER_LIST = new ArrayList(); // Always set private int mLeadingNullCount; /** * List of pages in storage. * * Two storage modes: * * Contiguous - all content in mPages is valid and loaded, but may return false from isTiled(). * Safe to access any item in any page. * * Non-contiguous - mPages may have nulls or a placeholder page, isTiled() always returns true. * mPages may have nulls, or placeholder (empty) pages while content is loading. */ private final ArrayList> mPages; private int mTrailingNullCount; private int mPositionOffset; /** * Number of loaded items held by {@link #mPages}. When tiling, doesn't count unloaded pages in * {@link #mPages}. If tiling is disabled, same as {@link #mStorageCount}. * * This count is the one used for trimming. */ private int mLoadedCount; /** * Number of items represented by {@link #mPages}. If tiling is enabled, unloaded items in * {@link #mPages} may be null, but this value still counts them. */ private int mStorageCount; // If mPageSize > 0, tiling is enabled, 'mPages' may have gaps, and leadingPages is set private int mPageSize; private int mNumberPrepended; private int mNumberAppended; PagedStorage() { mLeadingNullCount = 0; mPages = new ArrayList<>(); mTrailingNullCount = 0; mPositionOffset = 0; mLoadedCount = 0; mStorageCount = 0; mPageSize = 1; mNumberPrepended = 0; mNumberAppended = 0; } PagedStorage(int leadingNulls, List page, int trailingNulls) { this(); init(leadingNulls, page, trailingNulls, 0); } private PagedStorage(PagedStorage other) { mLeadingNullCount = other.mLeadingNullCount; mPages = new ArrayList<>(other.mPages); mTrailingNullCount = other.mTrailingNullCount; mPositionOffset = other.mPositionOffset; mLoadedCount = other.mLoadedCount; mStorageCount = other.mStorageCount; mPageSize = other.mPageSize; mNumberPrepended = other.mNumberPrepended; mNumberAppended = other.mNumberAppended; } PagedStorage snapshot() { return new PagedStorage<>(this); } private void init(int leadingNulls, List page, int trailingNulls, int positionOffset) { mLeadingNullCount = leadingNulls; mPages.clear(); mPages.add(page); mTrailingNullCount = trailingNulls; mPositionOffset = positionOffset; mLoadedCount = page.size(); mStorageCount = mLoadedCount; // initialized as tiled. There may be 3 nulls, 2 items, but we still call this tiled // even if it will break if nulls convert. mPageSize = page.size(); mNumberPrepended = 0; mNumberAppended = 0; } void init(int leadingNulls, @NonNull List page, int trailingNulls, int positionOffset, @NonNull Callback callback) { init(leadingNulls, page, trailingNulls, positionOffset); callback.onInitialized(size()); } @Override public T get(int i) { if (i < 0 || i >= size()) { throw new IndexOutOfBoundsException("Index: " + i + ", Size: " + size()); } // is it definitely outside 'mPages'? int localIndex = i - mLeadingNullCount; if (localIndex < 0 || localIndex >= mStorageCount) { return null; } int localPageIndex; int pageInternalIndex; if (isTiled()) { // it's inside mPages, and we're tiled. Jump to correct tile. localPageIndex = localIndex / mPageSize; pageInternalIndex = localIndex % mPageSize; } else { // it's inside mPages, but page sizes aren't regular. Walk to correct tile. // Pages can only be null while tiled, so accessing page count is safe. pageInternalIndex = localIndex; final int localPageCount = mPages.size(); for (localPageIndex = 0; localPageIndex < localPageCount; localPageIndex++) { int pageSize = mPages.get(localPageIndex).size(); if (pageSize > pageInternalIndex) { // stop, found the page break; } pageInternalIndex -= pageSize; } } List page = mPages.get(localPageIndex); if (page == null || page.size() == 0) { // can only occur in tiled case, with untouched inner/placeholder pages return null; } if (pageInternalIndex >= page.size()) { eu.faircode.email.Log.e("PageStorage pageInternalIndex=" + pageInternalIndex + "/" + page.size()); return null; } return page.get(pageInternalIndex); } /** * Returns true if all pages are the same size, except for the last, which may be smaller */ boolean isTiled() { return mPageSize > 0; } int getLeadingNullCount() { return mLeadingNullCount; } int getTrailingNullCount() { return mTrailingNullCount; } int getStorageCount() { return mStorageCount; } int getNumberAppended() { return mNumberAppended; } int getNumberPrepended() { return mNumberPrepended; } int getPageCount() { return mPages.size(); } int getLoadedCount() { return mLoadedCount; } interface Callback { void onInitialized(int count); void onPagePrepended(int leadingNulls, int changed, int added); void onPageAppended(int endPosition, int changed, int added); void onPagePlaceholderInserted(int pageIndex); void onPageInserted(int start, int count); void onPagesRemoved(int startOfDrops, int count); void onPagesSwappedToPlaceholder(int startOfDrops, int count); void onEmptyPrepend(); void onEmptyAppend(); } int getPositionOffset() { return mPositionOffset; } int getMiddleOfLoadedRange() { return mLeadingNullCount + mPositionOffset + mStorageCount / 2; } @Override public int size() { return mLeadingNullCount + mStorageCount + mTrailingNullCount; } int computeLeadingNulls() { int total = mLeadingNullCount; final int pageCount = mPages.size(); for (int i = 0; i < pageCount; i++) { List page = mPages.get(i); if (page != null && page != PLACEHOLDER_LIST) { break; } total += mPageSize; } return total; } int computeTrailingNulls() { int total = mTrailingNullCount; for (int i = mPages.size() - 1; i >= 0; i--) { List page = mPages.get(i); if (page != null && page != PLACEHOLDER_LIST) { break; } total += mPageSize; } return total; } // ---------------- Trimming API ------------------- // Trimming is always done at the beginning or end of the list, as content is loaded. // In addition to trimming pages in the storage, we also support pre-trimming pages (dropping // them just before they're added) to avoid dispatching an add followed immediately by a trim. // // Note - we avoid trimming down to a single page to reduce chances of dropping page in // viewport, since we don't strictly know the viewport. If trim is aggressively set to size of a // single page, trimming while the user can see a page boundary is dangerous. To be safe, we // just avoid trimming in these cases entirely. private boolean needsTrim(int maxSize, int requiredRemaining, int localPageIndex) { List page = mPages.get(localPageIndex); return page == null || (mLoadedCount > maxSize && mPages.size() > 2 && page != PLACEHOLDER_LIST && mLoadedCount - page.size() >= requiredRemaining); } boolean needsTrimFromFront(int maxSize, int requiredRemaining) { return needsTrim(maxSize, requiredRemaining, 0); } boolean needsTrimFromEnd(int maxSize, int requiredRemaining) { return needsTrim(maxSize, requiredRemaining, mPages.size() - 1); } boolean shouldPreTrimNewPage(int maxSize, int requiredRemaining, int countToBeAdded) { return mLoadedCount + countToBeAdded > maxSize && mPages.size() > 1 && mLoadedCount >= requiredRemaining; } boolean trimFromFront(boolean insertNulls, int maxSize, int requiredRemaining, @NonNull Callback callback) { int totalRemoved = 0; while (needsTrimFromFront(maxSize, requiredRemaining)) { List page = mPages.remove(0); int removed = (page == null) ? mPageSize : page.size(); totalRemoved += removed; mStorageCount -= removed; mLoadedCount -= (page == null) ? 0 : page.size(); } if (totalRemoved > 0) { if (insertNulls) { // replace removed items with nulls int previousLeadingNulls = mLeadingNullCount; mLeadingNullCount += totalRemoved; callback.onPagesSwappedToPlaceholder(previousLeadingNulls, totalRemoved); } else { // simply remove, and handle offset mPositionOffset += totalRemoved; callback.onPagesRemoved(mLeadingNullCount, totalRemoved); } } return totalRemoved > 0; } boolean trimFromEnd(boolean insertNulls, int maxSize, int requiredRemaining, @NonNull Callback callback) { int totalRemoved = 0; while (needsTrimFromEnd(maxSize, requiredRemaining)) { List page = mPages.remove(mPages.size() - 1); int removed = (page == null) ? mPageSize : page.size(); totalRemoved += removed; mStorageCount -= removed; mLoadedCount -= (page == null) ? 0 : page.size(); } if (totalRemoved > 0) { int newEndPosition = mLeadingNullCount + mStorageCount; if (insertNulls) { // replace removed items with nulls mTrailingNullCount += totalRemoved; callback.onPagesSwappedToPlaceholder(newEndPosition, totalRemoved); } else { // items were just removed, signal callback.onPagesRemoved(newEndPosition, totalRemoved); } } return totalRemoved > 0; } // ---------------- Contiguous API ------------------- T getFirstLoadedItem() { // safe to access first page's first item here: // If contiguous, mPages can't be empty, can't hold null Pages, and items can't be empty return mPages.get(0).get(0); } T getLastLoadedItem() { // safe to access last page's last item here: // If contiguous, mPages can't be empty, can't hold null Pages, and items can't be empty List page = mPages.get(mPages.size() - 1); return page.get(page.size() - 1); } void prependPage(@NonNull List page, @NonNull Callback callback) { final int count = page.size(); if (count == 0) { // Nothing returned from source, stop loading in this direction callback.onEmptyPrepend(); return; } if (mPageSize > 0 && count != mPageSize) { if (mPages.size() == 1 && count > mPageSize) { // prepending to a single item - update current page size to that of 'inner' page mPageSize = count; } else { // no longer tiled mPageSize = -1; } } mPages.add(0, page); mLoadedCount += count; mStorageCount += count; final int changedCount = Math.min(mLeadingNullCount, count); final int addedCount = count - changedCount; if (changedCount != 0) { mLeadingNullCount -= changedCount; } mPositionOffset -= addedCount; mNumberPrepended += count; callback.onPagePrepended(mLeadingNullCount, changedCount, addedCount); } void appendPage(@NonNull List page, @NonNull Callback callback) { final int count = page.size(); if (count == 0) { // Nothing returned from source, stop loading in this direction callback.onEmptyAppend(); return; } if (mPageSize > 0) { // if the previous page was smaller than mPageSize, // or if this page is larger than the previous, disable tiling if (mPages.get(mPages.size() - 1).size() != mPageSize || count > mPageSize) { mPageSize = -1; } } mPages.add(page); mLoadedCount += count; mStorageCount += count; final int changedCount = Math.min(mTrailingNullCount, count); final int addedCount = count - changedCount; if (changedCount != 0) { mTrailingNullCount -= changedCount; } mNumberAppended += count; callback.onPageAppended(mLeadingNullCount + mStorageCount - count, changedCount, addedCount); } // ------------------ Non-Contiguous API (tiling required) ---------------------- /** * Return true if the page at the passed position would be the first (if trimFromFront) or last * page that's currently loading. */ boolean pageWouldBeBoundary(int positionOfPage, boolean trimFromFront) { if (mPageSize < 1 || mPages.size() < 2) { throw new IllegalStateException("Trimming attempt before sufficient load"); } if (positionOfPage < mLeadingNullCount) { // position represent page in leading nulls return trimFromFront; } if (positionOfPage >= mLeadingNullCount + mStorageCount) { // position represent page in trailing nulls return !trimFromFront; } int localPageIndex = (positionOfPage - mLeadingNullCount) / mPageSize; // walk outside in, return false if we find non-placeholder page before localPageIndex if (trimFromFront) { for (int i = 0; i < localPageIndex; i++) { if (mPages.get(i) != null) { return false; } } } else { for (int i = mPages.size() - 1; i > localPageIndex; i--) { if (mPages.get(i) != null) { return false; } } } // didn't find another page, so this one would be a boundary return true; } void initAndSplit(int leadingNulls, @NonNull List multiPageList, int trailingNulls, int positionOffset, int pageSize, @NonNull Callback callback) { int pageCount = (multiPageList.size() + (pageSize - 1)) / pageSize; for (int i = 0; i < pageCount; i++) { int beginInclusive = i * pageSize; int endExclusive = Math.min(multiPageList.size(), (i + 1) * pageSize); List sublist = multiPageList.subList(beginInclusive, endExclusive); if (i == 0) { // Trailing nulls for first page includes other pages in multiPageList int initialTrailingNulls = trailingNulls + multiPageList.size() - sublist.size(); init(leadingNulls, sublist, initialTrailingNulls, positionOffset); } else { int insertPosition = leadingNulls + beginInclusive; insertPage(insertPosition, sublist, null); } } callback.onInitialized(size()); } void tryInsertPageAndTrim( int position, @NonNull List page, int lastLoad, int maxSize, int requiredRemaining, @NonNull Callback callback) { boolean trim = maxSize != PagedList.Config.MAX_SIZE_UNBOUNDED; boolean trimFromFront = lastLoad > getMiddleOfLoadedRange(); boolean pageInserted = !trim || !shouldPreTrimNewPage(maxSize, requiredRemaining, page.size()) || !pageWouldBeBoundary(position, trimFromFront); if (pageInserted) { insertPage(position, page, callback); } else { // trim would have us drop the page we just loaded - swap it to null int localPageIndex = (position - mLeadingNullCount) / mPageSize; mPages.set(localPageIndex, null); // note: we also remove it, so we don't have to guess how large a 'null' page is later mStorageCount -= page.size(); if (trimFromFront) { mPages.remove(0); mLeadingNullCount += page.size(); } else { mPages.remove(mPages.size() - 1); mTrailingNullCount += page.size(); } } if (trim) { if (trimFromFront) { trimFromFront(true, maxSize, requiredRemaining, callback); } else { trimFromEnd(true, maxSize, requiredRemaining, callback); } } } public void insertPage(int position, @NonNull List page, @Nullable Callback callback) { final int newPageSize = page.size(); if (newPageSize != mPageSize) { // differing page size is OK in 2 cases, when the page is being added: // 1) to the end (in which case, ignore new smaller size) // 2) only the last page has been added so far (in which case, adopt new bigger size) int size = size(); boolean addingLastPage = position == (size - size % mPageSize) && newPageSize < mPageSize; boolean onlyEndPagePresent = mTrailingNullCount == 0 && mPages.size() == 1 && newPageSize > mPageSize; // OK only if existing single page, and it's the last one if (!onlyEndPagePresent && !addingLastPage) { //throw new IllegalArgumentException("page introduces incorrect tiling"); eu.faircode.email.Log.e("PagedStorage tiling" + " size=" + newPageSize + "/" + mPageSize + " addingLastPage=" + addingLastPage + " onlyEndPagePresent=" + onlyEndPagePresent); return; } if (onlyEndPagePresent) { mPageSize = newPageSize; } } int pageIndex = position / mPageSize; allocatePageRange(pageIndex, pageIndex); int localPageIndex = pageIndex - mLeadingNullCount / mPageSize; List oldPage = mPages.get(localPageIndex); if (oldPage != null && oldPage != PLACEHOLDER_LIST) { throw new IllegalArgumentException( "Invalid position " + position + ": data already loaded"); } mPages.set(localPageIndex, page); mLoadedCount += newPageSize; if (callback != null) { callback.onPageInserted(position, newPageSize); } } void allocatePageRange(final int minimumPage, final int maximumPage) { int leadingNullPages = mLeadingNullCount / mPageSize; if (minimumPage < leadingNullPages) { for (int i = 0; i < leadingNullPages - minimumPage; i++) { mPages.add(0, null); } int newStorageAllocated = (leadingNullPages - minimumPage) * mPageSize; mStorageCount += newStorageAllocated; mLeadingNullCount -= newStorageAllocated; leadingNullPages = minimumPage; } if (maximumPage >= leadingNullPages + mPages.size()) { int newStorageAllocated = Math.min(mTrailingNullCount, (maximumPage + 1 - (leadingNullPages + mPages.size())) * mPageSize); for (int i = mPages.size(); i <= maximumPage - leadingNullPages; i++) { mPages.add(mPages.size(), null); } mStorageCount += newStorageAllocated; mTrailingNullCount -= newStorageAllocated; } } public void allocatePlaceholders(int index, int prefetchDistance, int pageSize, Callback callback) { if (pageSize != mPageSize) { if (pageSize < mPageSize) { throw new IllegalArgumentException("Page size cannot be reduced"); } if (mPages.size() != 1 || mTrailingNullCount != 0) { // not in single, last page allocated case - can't change page size throw new IllegalArgumentException( "Page size can change only if last page is only one present"); } mPageSize = pageSize; } final int maxPageCount = (size() + mPageSize - 1) / mPageSize; int minimumPage = Math.max((index - prefetchDistance) / mPageSize, 0); int maximumPage = Math.min((index + prefetchDistance) / mPageSize, maxPageCount - 1); allocatePageRange(minimumPage, maximumPage); int leadingNullPages = mLeadingNullCount / mPageSize; for (int pageIndex = minimumPage; pageIndex <= maximumPage; pageIndex++) { int localPageIndex = pageIndex - leadingNullPages; if (mPages.get(localPageIndex) == null) { //noinspection unchecked mPages.set(localPageIndex, PLACEHOLDER_LIST); callback.onPagePlaceholderInserted(pageIndex); } } } public boolean hasPage(int pageSize, int index) { // NOTE: we pass pageSize here to avoid in case mPageSize // not fully initialized (when last page only one loaded) int leadingNullPages = mLeadingNullCount / pageSize; if (index < leadingNullPages || index >= leadingNullPages + mPages.size()) { return false; } List page = mPages.get(index - leadingNullPages); return page != null && page != PLACEHOLDER_LIST; } @Override public String toString() { StringBuilder ret = new StringBuilder("leading " + mLeadingNullCount + ", storage " + mStorageCount + ", trailing " + getTrailingNullCount()); for (int i = 0; i < mPages.size(); i++) { ret.append(" ").append(mPages.get(i)); } return ret.toString(); } }