Merge "Initial support for inserting separators between paginated items" into androidx-master-dev
diff --git a/paging/common/api/3.0.0-alpha01.txt b/paging/common/api/3.0.0-alpha01.txt
index 8378693..6474103 100644
--- a/paging/common/api/3.0.0-alpha01.txt
+++ b/paging/common/api/3.0.0-alpha01.txt
@@ -95,6 +95,10 @@
     enum_constant public static final androidx.paging.LoadType START;
   }
 
+  public final class PageEventKt {
+    ctor public PageEventKt();
+  }
+
   public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
     ctor public PageKeyedDataSource();
     method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key> params, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value> callback);
@@ -312,6 +316,10 @@
     field public final int startPosition;
   }
 
+  public final class SeparatorsKt {
+    ctor public SeparatorsKt();
+  }
+
 }
 
 package androidx.paging.futures {
diff --git a/paging/common/api/current.txt b/paging/common/api/current.txt
index 8378693..6474103 100644
--- a/paging/common/api/current.txt
+++ b/paging/common/api/current.txt
@@ -95,6 +95,10 @@
     enum_constant public static final androidx.paging.LoadType START;
   }
 
+  public final class PageEventKt {
+    ctor public PageEventKt();
+  }
+
   public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
     ctor public PageKeyedDataSource();
     method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key> params, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value> callback);
@@ -312,6 +316,10 @@
     field public final int startPosition;
   }
 
+  public final class SeparatorsKt {
+    ctor public SeparatorsKt();
+  }
+
 }
 
 package androidx.paging.futures {
diff --git a/paging/common/api/public_plus_experimental_3.0.0-alpha01.txt b/paging/common/api/public_plus_experimental_3.0.0-alpha01.txt
index 8378693..6474103 100644
--- a/paging/common/api/public_plus_experimental_3.0.0-alpha01.txt
+++ b/paging/common/api/public_plus_experimental_3.0.0-alpha01.txt
@@ -95,6 +95,10 @@
     enum_constant public static final androidx.paging.LoadType START;
   }
 
+  public final class PageEventKt {
+    ctor public PageEventKt();
+  }
+
   public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
     ctor public PageKeyedDataSource();
     method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key> params, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value> callback);
@@ -312,6 +316,10 @@
     field public final int startPosition;
   }
 
+  public final class SeparatorsKt {
+    ctor public SeparatorsKt();
+  }
+
 }
 
 package androidx.paging.futures {
diff --git a/paging/common/api/public_plus_experimental_current.txt b/paging/common/api/public_plus_experimental_current.txt
index 8378693..6474103 100644
--- a/paging/common/api/public_plus_experimental_current.txt
+++ b/paging/common/api/public_plus_experimental_current.txt
@@ -95,6 +95,10 @@
     enum_constant public static final androidx.paging.LoadType START;
   }
 
+  public final class PageEventKt {
+    ctor public PageEventKt();
+  }
+
   public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
     ctor public PageKeyedDataSource();
     method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key> params, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value> callback);
@@ -312,6 +316,10 @@
     field public final int startPosition;
   }
 
+  public final class SeparatorsKt {
+    ctor public SeparatorsKt();
+  }
+
 }
 
 package androidx.paging.futures {
diff --git a/paging/common/api/restricted_3.0.0-alpha01.txt b/paging/common/api/restricted_3.0.0-alpha01.txt
index 310fe1f..ef0b418 100644
--- a/paging/common/api/restricted_3.0.0-alpha01.txt
+++ b/paging/common/api/restricted_3.0.0-alpha01.txt
@@ -113,6 +113,10 @@
     property public abstract int trailingNullCount;
   }
 
+  public final class PageEventKt {
+    ctor public PageEventKt();
+  }
+
   public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
     ctor public PageKeyedDataSource();
     method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key> params, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value> callback);
@@ -354,6 +358,10 @@
     field public final int startPosition;
   }
 
+  public final class SeparatorsKt {
+    ctor public SeparatorsKt();
+  }
+
 }
 
 package androidx.paging.futures {
diff --git a/paging/common/api/restricted_current.txt b/paging/common/api/restricted_current.txt
index 310fe1f..ef0b418 100644
--- a/paging/common/api/restricted_current.txt
+++ b/paging/common/api/restricted_current.txt
@@ -113,6 +113,10 @@
     property public abstract int trailingNullCount;
   }
 
+  public final class PageEventKt {
+    ctor public PageEventKt();
+  }
+
   public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
     ctor public PageKeyedDataSource();
     method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key> params, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value> callback);
@@ -354,6 +358,10 @@
     field public final int startPosition;
   }
 
+  public final class SeparatorsKt {
+    ctor public SeparatorsKt();
+  }
+
 }
 
 package androidx.paging.futures {
diff --git a/paging/common/src/main/kotlin/androidx/paging/PageEvent.kt b/paging/common/src/main/kotlin/androidx/paging/PageEvent.kt
index 74ae229..8d74e6b 100644
--- a/paging/common/src/main/kotlin/androidx/paging/PageEvent.kt
+++ b/paging/common/src/main/kotlin/androidx/paging/PageEvent.kt
@@ -19,6 +19,8 @@
 import androidx.paging.LoadType.END
 import androidx.paging.LoadType.REFRESH
 import androidx.paging.LoadType.START
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
 
 /**
  * Events in the stream from paging fetch logic to UI.
@@ -41,25 +43,29 @@
             }
         }
 
-        private inline fun <R : Any> mapInternal(
+        private inline fun <R : Any> mapPages(
             predicate: (TransformablePage<T>) -> TransformablePage<R>
+        ) = transformPages { it.map(predicate) }
+
+        internal inline fun <R : Any> transformPages(
+            predicate: (List<TransformablePage<T>>) -> List<TransformablePage<R>>
         ): PageEvent<R> = Insert(
             loadType = loadType,
-            pages = pages.map(predicate),
+            pages = predicate(pages),
             placeholdersStart = placeholdersStart,
             placeholdersEnd = placeholdersEnd
         )
 
-        override fun <R : Any> map(predicate: (T) -> R): PageEvent<R> = mapInternal {
+        override fun <R : Any> map(predicate: (T) -> R): PageEvent<R> = mapPages {
             TransformablePage(
                 originalPageOffset = it.originalPageOffset,
                 data = it.data.map(predicate),
-                sourcePageSize = it.sourcePageSize,
+                originalPageSize = it.originalPageSize,
                 originalIndices = it.originalIndices
             )
         }
 
-        override fun <R : Any> flatMap(transform: (T) -> Iterable<R>): PageEvent<R> = mapInternal {
+        override fun <R : Any> flatMap(transform: (T) -> Iterable<R>): PageEvent<R> = mapPages {
             val data = mutableListOf<R>()
             val originalIndices = mutableListOf<Int>()
             it.data.forEachIndexed { index, t ->
@@ -72,12 +78,12 @@
             TransformablePage(
                 originalPageOffset = it.originalPageOffset,
                 data = data,
-                sourcePageSize = it.sourcePageSize,
+                originalPageSize = it.originalPageSize,
                 originalIndices = originalIndices
             )
         }
 
-        override fun filter(predicate: (T) -> Boolean): PageEvent<T> = mapInternal {
+        override fun filter(predicate: (T) -> Boolean): PageEvent<T> = mapPages {
             val data = mutableListOf<T>()
             val originalIndices = mutableListOf<Int>()
             it.data.forEachIndexed { index, t ->
@@ -89,11 +95,26 @@
             TransformablePage(
                 originalPageOffset = it.originalPageOffset,
                 data = data,
-                sourcePageSize = it.sourcePageSize,
+                originalPageSize = it.originalPageSize,
                 originalIndices = originalIndices
             )
         }
 
+        override fun filterOutEmptyPages(
+            currentPages: MutableList<TransformablePage<T>>
+        ): PageEvent<T> {
+            // insert pre-filtered pages into list, so drop
+            // can account for pages we've filtered out here
+            applyToList(currentPages)
+
+            return if (pages.any { it.data.isEmpty() }) {
+                transformPages { pages -> pages.filter { it.data.isNotEmpty() } }
+            } else {
+                // no empty pages, can safely reuse this page
+                this
+            }
+        }
+
         companion object {
             fun <T : Any> Refresh(
                 pages: List<TransformablePage<T>>,
@@ -118,6 +139,7 @@
         val count: Int,
         val placeholdersRemaining: Int
     ) : PageEvent<T>() {
+
         init {
             require(loadType != REFRESH) { "Drop must be START or END" }
             require(count >= 0) { "Invalid count $count" }
@@ -125,6 +147,46 @@
                 "Invalid placeholdersRemaining $placeholdersRemaining"
             }
         }
+
+        /**
+         * Alter the drop event to skip dropping any empty pages, since they won't have been
+         * sent downstream.
+         */
+        override fun filterOutEmptyPages(
+            currentPages: MutableList<TransformablePage<T>>
+        ): PageEvent<T> {
+            // decrease count by number of empty pages that would have been dropped, since these
+            // haven't been sent downstream
+            var newCount = count
+            if (loadType == START) {
+                repeat(count) { i ->
+                    if (currentPages[i].data.isEmpty()) {
+                        newCount--
+                    }
+                }
+            } else {
+                repeat(count) { i ->
+                    if (currentPages[currentPages.size - i].data.isEmpty()) {
+                        newCount--
+                    }
+                }
+            }
+
+            // apply drop to currentPages after newCount is computed, so it represents loaded
+            // pages before this tranform is applied
+            applyToList(currentPages)
+
+            return if (newCount == count) {
+                // no empty pages encountered
+                this
+            } else {
+                Drop(
+                    loadType,
+                    newCount,
+                    placeholdersRemaining
+                )
+            }
+        }
     }
 
     data class StateUpdate<T : Any>(
@@ -139,4 +201,69 @@
     open fun <R : Any> flatMap(transform: (T) -> Iterable<R>): PageEvent<R> = this as PageEvent<R>
 
     open fun filter(predicate: (T) -> Boolean): PageEvent<T> = this
+
+    open fun filterOutEmptyPages(
+        currentPages: MutableList<TransformablePage<T>>
+    ): PageEvent<T> = this
+}
+
+/**
+ * TODO: optimize this per usecase, to avoid holding onto the whole page in memory
+ */
+internal fun <T : Any> PageEvent.Insert<T>.applyToList(
+    currentPages: MutableList<TransformablePage<T>>
+) {
+    when (loadType) {
+        REFRESH -> {
+            check(currentPages.isEmpty())
+            currentPages.addAll(pages)
+        }
+        START -> {
+            currentPages.addAll(0, pages)
+        }
+        END -> {
+            currentPages.addAll(currentPages.size, pages)
+        }
+    }
+}
+
+/**
+ * TODO: optimize this per usecase, to avoid holding onto the whole page in memory
+ */
+internal fun <T : Any> PageEvent.Drop<T>.applyToList(
+    currentPages: MutableList<TransformablePage<T>>
+) {
+    if (loadType == START) {
+        repeat(count) { currentPages.removeAt(0) }
+    } else {
+        repeat(count) { currentPages.removeAt(currentPages.lastIndex) }
+    }
+}
+
+/**
+ * Transforms the Flow to an output-equivalent Flow, which does not have empty pages.
+ *
+ * This can be used before accessing adjacent pages, to ensure adjacent pages have context in
+ * them.
+ */
+internal fun <T : Any> Flow<PageEvent<T>>.removeEmptyPages(): Flow<PageEvent<T>> {
+    val pages = mutableListOf<TransformablePage<T>>()
+
+    // TODO: consider dropping, or not even creating noop (empty) events entirely
+    return map { it.filterOutEmptyPages(pages) }
+}
+
+/**
+ * Transforms the Flow to include optional separators in between each pair of items in the output
+ * stream.
+ *
+ * TODO: support separator at beginning / end - requires tracking of loading state
+ *  (to know when an Insert.Start event is terminal)
+ */
+internal fun <R : Any, T : R> Flow<PageEvent<T>>.insertSeparators(
+    predicate: (T?, T?) -> R?
+): Flow<PageEvent<R>> {
+    val pages = mutableListOf<TransformablePage<T>>()
+    return removeEmptyPages()
+        .map { event -> event.insertSeparators(pages, predicate) }
 }
\ No newline at end of file
diff --git a/paging/common/src/main/kotlin/androidx/paging/Separators.kt b/paging/common/src/main/kotlin/androidx/paging/Separators.kt
new file mode 100644
index 0000000..6ba26032
--- /dev/null
+++ b/paging/common/src/main/kotlin/androidx/paging/Separators.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2019 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.paging.LoadType.END
+import androidx.paging.LoadType.START
+import androidx.paging.PageEvent.Drop
+import androidx.paging.PageEvent.Insert
+import androidx.paging.PageEvent.StateUpdate
+
+/**
+ * Create a TransformablePage with separators inside (ignoring edges)
+ *
+ * Separators between pages are handled outside of the page, see [PageEvent.insertSeparators].
+ */
+private fun <R : Any, T : R> TransformablePage<T>.insertSeparators(
+    predicate: (T?, T?) -> R?
+): TransformablePage<R> {
+    if (data.isEmpty()) {
+        @Suppress("UNCHECKED_CAST")
+        return this as TransformablePage<R>
+    }
+
+    val initialCapacity = data.size + 4 // extra space to avoid bigger allocations
+    val outputList = ArrayList<R>(initialCapacity)
+    val outputIndices = ArrayList<Int>(initialCapacity)
+
+    outputList.add(data.first())
+    outputIndices.add(originalIndices?.first() ?: 0)
+    for (i in 1 until data.size) {
+        val item = data[i]
+        val separator = predicate(data[i - 1], item)
+        if (separator != null) {
+            outputList.add(separator)
+            outputIndices.add(i)
+        }
+        outputList.add(item)
+        outputIndices.add(i)
+    }
+    return TransformablePage(
+        originalPageOffset = originalPageOffset,
+        data = outputList,
+        originalPageSize = originalPageSize,
+        originalIndices = outputIndices
+    )
+}
+
+/**
+ * Create a TransformablePage with the given separator (or empty, if the separator is null)
+ *
+ * We create an empty page when a separator is not needed in order to simplify dropping. By
+ * ensuring there are always 2N-1 pages in the output event stream, every drop of a M pages in the
+ * input event stream can be simply transformed to a drop of 2 * M pages.
+ *
+ * TODO: consider tracking the separator pages differently, so we don't have to
+ *  allocate these empty pages.
+ */
+private fun <R : Any, T : R> separatorPage(
+    adjacentPage: TransformablePage<T>,
+    separator: R?,
+    originalIndex: Int
+): TransformablePage<R> = if (separator != null) {
+    // page with just the separator
+    TransformablePage(
+        originalPageOffset = adjacentPage.originalPageOffset,
+        data = listOf(separator),
+        originalPageSize = adjacentPage.originalPageSize,
+        originalIndices = listOf(originalIndex)
+    )
+} else {
+    // empty page
+    TransformablePage(
+        originalPageOffset = adjacentPage.originalPageOffset,
+        data = emptyList(),
+        originalPageSize = adjacentPage.originalPageSize,
+        originalIndices = null
+    )
+}
+
+internal fun <R : Any, T : R> List<TransformablePage<T>>.insertSeparators(
+    loadType: LoadType,
+    itemAtStart: T?,
+    itemAtEnd: T?,
+    predicate: (T?, T?) -> R?
+): List<TransformablePage<R>> {
+    if (isEmpty()) {
+        return emptyList()
+    }
+
+    val outList = ArrayList<TransformablePage<R>>(size)
+
+    var itemBefore = itemAtStart
+    forEachIndexed { index, page ->
+        // If page is being appended, or if we're in between pages, insert separator page
+        if (index != 0 || loadType == END) {
+            val separator = predicate(itemBefore, page.data.first())
+            outList.add(separatorPage(page, separator, originalIndex = 0))
+        }
+
+        outList.add(page.insertSeparators(predicate))
+
+        itemBefore = page.data.last()
+    }
+
+    if (loadType == START) {
+        val lastPage = last()
+        val separator = predicate(lastPage.data.last(), itemAtEnd)
+        outList.add(
+            separatorPage(lastPage, separator, originalIndex = lastPage.originalPageSize - 1)
+        )
+    }
+
+    return outList
+}
+
+/**
+ * State-tracking operation on PageEvent to insert separators
+ *
+ * State is tracked in the mutable currentPages list, shared by all events in stream
+ */
+@Suppress("UNCHECKED_CAST")
+internal fun <R : Any, T : R> PageEvent<T>.insertSeparators(
+    currentPages: MutableList<TransformablePage<T>>,
+    predicate: (T?, T?) -> R?
+): PageEvent<R> = when (this) {
+    is Insert<T> -> {
+        val newEvent = transformPages {
+            it.insertSeparators(
+                loadType = loadType,
+                itemAtStart = if (loadType == END) currentPages.last().data.last() else null,
+                itemAtEnd = if (loadType == START) currentPages.first().data.first() else null,
+                predicate = predicate
+            )
+        }
+
+        this.applyToList(currentPages)
+        newEvent
+    }
+    is Drop -> {
+        this.applyToList(currentPages)
+        Drop(
+            loadType = loadType,
+            count = count * 2,
+            placeholdersRemaining = placeholdersRemaining
+        )
+    }
+    is StateUpdate -> this as PageEvent<R>
+}
\ No newline at end of file
diff --git a/paging/common/src/main/kotlin/androidx/paging/TransformablePage.kt b/paging/common/src/main/kotlin/androidx/paging/TransformablePage.kt
index f7384dd..9889ddc 100644
--- a/paging/common/src/main/kotlin/androidx/paging/TransformablePage.kt
+++ b/paging/common/src/main/kotlin/androidx/paging/TransformablePage.kt
@@ -30,7 +30,7 @@
     /**
      * Size of the original page (pre-transformation)
      */
-    val sourcePageSize: Int,
+    val originalPageSize: Int,
 
     /**
      * Optional lookup table for page indices.
@@ -58,10 +58,10 @@
     fun getLoadHint(relativeIndex: Int): ViewportHint {
         val indexInPage = when {
             relativeIndex < 0 -> relativeIndex
-            relativeIndex >= data.size -> relativeIndex - data.size + sourcePageSize
+            relativeIndex >= data.size -> relativeIndex - data.size + originalPageSize
             originalIndices != null -> originalIndices[relativeIndex]
             else -> relativeIndex
         }
         return ViewportHint(originalPageOffset, indexInPage)
     }
-}
\ No newline at end of file
+}
diff --git a/paging/common/src/test/kotlin/androidx/paging/PageEventTest.kt b/paging/common/src/test/kotlin/androidx/paging/PageEventTest.kt
index c4938a0..e68cce7e 100644
--- a/paging/common/src/test/kotlin/androidx/paging/PageEventTest.kt
+++ b/paging/common/src/test/kotlin/androidx/paging/PageEventTest.kt
@@ -25,12 +25,6 @@
 import kotlin.test.assertFailsWith
 import kotlin.test.assertSame
 
-@Suppress("TestFunctionName")
-private fun <T : Any> TransformablePage(data: List<T>) = TransformablePage(
-    data = data,
-    originalPageOffset = 0
-)
-
 @RunWith(JUnit4::class)
 class PageEventTest {
     @Test
@@ -136,7 +130,7 @@
                 pages = listOf(TransformablePage(
                     originalPageOffset = 0,
                     data = listOf("a", "b"),
-                    sourcePageSize = 4,
+                    originalPageSize = 4,
                     originalIndices = listOf(0, 2)
                 )),
                 placeholdersEnd = 4
@@ -145,7 +139,7 @@
                 pages = listOf(TransformablePage(
                     originalPageOffset = 0,
                     data = listOf('a', 'b'),
-                    sourcePageSize = 4,
+                    originalPageSize = 4,
                     originalIndices = listOf(0, 2)
                 )),
                 placeholdersEnd = 4
@@ -168,7 +162,7 @@
                     TransformablePage(
                         originalPageOffset = 0,
                         data = listOf('a', 'b', 'd'),
-                        sourcePageSize = 4,
+                        originalPageSize = 4,
                         originalIndices = listOf(0, 1, 3)
                     )
                 ),
@@ -184,7 +178,7 @@
                     TransformablePage(
                         originalPageOffset = 0,
                         data = listOf('b', 'd'),
-                        sourcePageSize = 4,
+                        originalPageSize = 4,
                         originalIndices = listOf(1, 3)
                     )
                 ),
@@ -211,7 +205,7 @@
                     TransformablePage(
                         originalPageOffset = 0,
                         data = listOf("a1", "a2", "b1", "b2"),
-                        sourcePageSize = 2,
+                        originalPageSize = 2,
                         originalIndices = listOf(0, 0, 1, 1)
                     )
                 ),
@@ -230,7 +224,7 @@
                     TransformablePage(
                         originalPageOffset = 0,
                         data = listOf("a1", "-", "a2", "-", "b1", "-", "b2", "-"),
-                        sourcePageSize = 2,
+                        originalPageSize = 2,
                         originalIndices = listOf(0, 0, 0, 0, 1, 1, 1, 1)
                     )
                 ),
diff --git a/paging/common/src/test/kotlin/androidx/paging/PagerTest.kt b/paging/common/src/test/kotlin/androidx/paging/PagerTest.kt
index ceb9191..4d21bff 100644
--- a/paging/common/src/test/kotlin/androidx/paging/PagerTest.kt
+++ b/paging/common/src/test/kotlin/androidx/paging/PagerTest.kt
@@ -56,7 +56,7 @@
         TransformablePage(
             originalPageOffset = pageOffset,
             data = items.slice(range),
-            sourcePageSize = range.count(),
+            originalPageSize = range.count(),
             originalIndices = null
         )
     )
diff --git a/paging/common/src/test/kotlin/androidx/paging/PagingStateTest.kt b/paging/common/src/test/kotlin/androidx/paging/PagingStateTest.kt
index 63b01c1e..82a4231 100644
--- a/paging/common/src/test/kotlin/androidx/paging/PagingStateTest.kt
+++ b/paging/common/src/test/kotlin/androidx/paging/PagingStateTest.kt
@@ -30,7 +30,7 @@
     indexOfInitialPage: Int = 0
 ) = processEvent(
     PageEvent.Insert.Refresh(
-        pages = listOfTransformablePages(pages, indexOfInitialPage),
+        pages = pages.toTransformablePages(indexOfInitialPage),
         placeholdersStart = placeholdersStart,
         placeholdersEnd = placeholdersEnd
     )
@@ -77,17 +77,6 @@
         placeholdersRemaining = placeholdersRemaining
     )
 )
-
-private fun <T : Any> listOfTransformablePages(
-    pages: List<List<T>>,
-    indexOfInitialPage: Int = 0
-) = pages.mapIndexed { index, list ->
-    TransformablePage(
-        data = list,
-        originalPageOffset = index - indexOfInitialPage
-    )
-}
-
 @Suppress("TestFunctionName")
 internal fun <T : Any> PagingState(
     pages: List<List<T>>,
@@ -101,7 +90,7 @@
 ) = PagingState(
     leadingNullCount = placeholdersStart,
     trailingNullCount = placeholdersEnd,
-    pages = listOfTransformablePages(pages, indexOfInitialPage),
+    pages = pages.toTransformablePages(indexOfInitialPage),
     loadStateRefresh = refresh,
     loadStateStart = start,
     loadStateEnd = end,
diff --git a/paging/common/src/test/kotlin/androidx/paging/SeparatorsTest.kt b/paging/common/src/test/kotlin/androidx/paging/SeparatorsTest.kt
new file mode 100644
index 0000000..eb15e5e
--- /dev/null
+++ b/paging/common/src/test/kotlin/androidx/paging/SeparatorsTest.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2019 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.paging.PageEvent.Drop
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import kotlin.test.assertEquals
+
+private fun <T : Any> assertEvent(expected: PageEvent<T>, actual: PageEvent<T>) {
+    try {
+        assertEquals(expected, actual)
+    } catch (e: Throwable) {
+        throw AssertionError(
+            e.localizedMessage
+                .replace("),", "),\n")
+                .replace("<[", "<[\n ")
+                .replace("actual", "\nactual\n")
+                .replace("Expected", "\nExpected\n")
+                .replace("pages=", "pages=\n")
+        )
+    }
+}
+
+@RunWith(JUnit4::class)
+class SeparatorsTest {
+    @Test
+    fun separatorDrop() {
+        val initialState =
+            listOf('a', 'b', 'c', 'd')
+                .map { listOf(it) }
+                .toTransformablePages()
+        val outDrop = Drop<Char>(
+            loadType = LoadType.END,
+            count = 2,
+            placeholdersRemaining = 4
+        ).insertSeparators(
+            currentPages = initialState
+        ) { _, _ -> null }
+
+        // drop count always simply doubles, because each N pages, after separators, become 2N - 1
+        assertEvent(
+            Drop(
+                loadType = LoadType.END,
+                count = 4,
+                placeholdersRemaining = 4
+            ),
+            outDrop
+        )
+    }
+
+    @Test
+    fun separatorRefresh() {
+        val initialState = mutableListOf<TransformablePage<String>>()
+        val outInsert = PageEvent.Insert.Refresh(
+            pages = listOf(
+                listOf("a2", "b1"),
+                listOf("c1", "c2")
+            ).toTransformablePages(),
+            placeholdersStart = 0,
+            placeholdersEnd = 1
+        ).insertSeparators(
+            currentPages = initialState
+        ) { before, after ->
+            if (before != null && after != null && before.first() != after.first())
+                after.first().toUpperCase().toString()
+            else null
+        }
+
+        assertEvent(
+            PageEvent.Insert.Refresh(
+                pages = listOf(
+                    TransformablePage(
+                        originalPageOffset = 0,
+                        data = listOf("a2", "B", "b1"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0, 1, 1)
+                    ),
+                    TransformablePage(
+                        originalPageOffset = 1,
+                        data = listOf("C"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0)
+                    ),
+                    TransformablePage(
+                        originalPageOffset = 1,
+                        data = listOf("c1", "c2"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0, 1)
+                    )
+                ),
+                placeholdersStart = 0,
+                placeholdersEnd = 1
+            ),
+            outInsert
+        )
+    }
+
+    @Test
+    fun separatorEnd() {
+        val initialState =
+            listOf("a1", "a2")
+                .map { listOf(it) }
+                .toTransformablePages()
+        val outInsert = PageEvent.Insert.End(
+            pages = listOf(
+                listOf("c1", "d1"),
+                listOf("d2", "d3")
+            ).toTransformablePages(-1),
+            placeholdersEnd = 1
+        ).insertSeparators(
+            currentPages = initialState
+        ) { before, after ->
+            if (before != null && after != null && before.first() != after.first())
+                after.first().toUpperCase().toString()
+            else null
+        }
+
+        assertEvent(
+            PageEvent.Insert.End(
+                pages = listOf(
+                    TransformablePage(
+                        originalPageOffset = 1,
+                        data = listOf("C"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0)
+                    ),
+                    TransformablePage(
+                        originalPageOffset = 1,
+                        data = listOf("c1", "D", "d1"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0, 1, 1)
+                    ),
+                    TransformablePage(
+                        originalPageOffset = 2,
+                        data = listOf(),
+                        originalPageSize = 2,
+                        originalIndices = null
+                    ),
+                    TransformablePage(
+                        originalPageOffset = 2,
+                        data = listOf("d2", "d3"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0, 1)
+                    )
+                ),
+                placeholdersEnd = 1
+            ),
+            outInsert
+        )
+    }
+
+    @Test
+    fun separatorStart() {
+        val initialState =
+            listOf("d1", "d2")
+                .map { listOf(it) }
+                .toTransformablePages()
+        val outInsert = PageEvent.Insert.Start(
+            pages = listOf(
+                listOf("a1", "b1"),
+                listOf("b2", "b3")
+            ).toTransformablePages(2),
+            placeholdersStart = 1
+        ).insertSeparators(
+            currentPages = initialState
+        ) { before, after ->
+            if (before != null && after != null && before.first() != after.first())
+                after.first().toUpperCase().toString()
+            else null
+        }
+
+        assertEvent(
+            PageEvent.Insert.Start(
+                pages = listOf(
+                    TransformablePage(
+                        originalPageOffset = -2,
+                        data = listOf("a1", "B", "b1"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0, 1, 1)
+                    ),
+                    TransformablePage(
+                        originalPageOffset = -1,
+                        data = listOf(),
+                        originalPageSize = 2,
+                        originalIndices = null
+                    ),
+                    TransformablePage(
+                        originalPageOffset = -1,
+                        data = listOf("b2", "b3"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(0, 1)
+                    ),
+                    TransformablePage(
+                        originalPageOffset = -1,
+                        data = listOf("D"),
+                        originalPageSize = 2,
+                        originalIndices = listOf(1) // note: using last index of 2nd page in
+                    )
+                ),
+                placeholdersStart = 1
+            ),
+            outInsert
+        )
+    }
+}
\ No newline at end of file
diff --git a/paging/common/src/test/kotlin/androidx/paging/TransformablePageTest.kt b/paging/common/src/test/kotlin/androidx/paging/TransformablePageTest.kt
index fba96e7..4e79930 100644
--- a/paging/common/src/test/kotlin/androidx/paging/TransformablePageTest.kt
+++ b/paging/common/src/test/kotlin/androidx/paging/TransformablePageTest.kt
@@ -21,6 +21,21 @@
 import org.junit.runners.JUnit4
 import kotlin.test.assertEquals
 
+@Suppress("TestFunctionName")
+internal fun <T : Any> TransformablePage(data: List<T>) = TransformablePage(
+    data = data,
+    originalPageOffset = 0
+)
+
+internal fun <T : Any> List<List<T>>.toTransformablePages(
+    indexOfInitialPage: Int = 0
+) = mapIndexed { index, list ->
+    TransformablePage(
+        data = list,
+        originalPageOffset = index - indexOfInitialPage
+    )
+}.toMutableList()
+
 @Suppress("SameParameterValue")
 @RunWith(JUnit4::class)
 class TransformablePageTest {
@@ -47,7 +62,7 @@
         val page = TransformablePage(
             data = listOf('a', 'b'),
             originalPageOffset = -4,
-            sourcePageSize = 30,
+            originalPageSize = 30,
             originalIndices = listOf(10, 20)
         )
         // negative - index pass-through