From 075227cb6f2c75f37a1d3dcaad2b5b4eedd4702b Mon Sep 17 00:00:00 2001 From: abond Date: Tue, 12 Mar 2019 13:38:25 +0300 Subject: [PATCH 1/8] Kotlin support added. --- firestore/build.gradle.kts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/firestore/build.gradle.kts b/firestore/build.gradle.kts index 4686e0114..bdd18a4e9 100644 --- a/firestore/build.gradle.kts +++ b/firestore/build.gradle.kts @@ -1,3 +1,4 @@ +val kotlin_version: String = "1.3.21" tasks.named("check").configure { dependsOn("compileDebugAndroidTestJavaWithJavac") } android { @@ -31,4 +32,12 @@ dependencies { androidTestImplementation(Config.Libs.Test.rules) androidTestImplementation(Config.Libs.Test.mockito) androidTestImplementation(Config.Libs.Arch.paging) + implementation(kotlin("stdlib-jdk7", kotlin_version)) } +apply { + plugin("kotlin-android") + plugin("kotlin-android-extensions") +} +repositories { + mavenCentral() +} \ No newline at end of file From 177d9b268170209247f5fa9987a77db89197b101 Mon Sep 17 00:00:00 2001 From: abond Date: Tue, 12 Mar 2019 13:39:26 +0300 Subject: [PATCH 2/8] FirestoreItemKeyedDataSource implementation added. --- .../paging/FirestoreItemKeyedDataSource.kt | 235 ++++++++++++++++ .../ui/firestore/paging/QueryFactory.kt | 261 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt create mode 100644 firestore/src/main/java/com/firebase/ui/firestore/paging/QueryFactory.kt diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt new file mode 100644 index 000000000..e25218961 --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt @@ -0,0 +1,235 @@ +package com.firebase.ui.firestore.paging + + +import android.arch.paging.DataSource +import android.arch.paging.ItemKeyedDataSource +import android.arch.paging.PagedList +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.firebase.firestore.* +import com.google.firebase.firestore.Source.CACHE +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Implementation of [ItemKeyedDataSource] for Firebase Firestore. + * + * [errorCallback] is optional and if not provided the [defaultErrorCallback] will be used. + * + * Note: If user provide [errorCallback] he should control data source invalidation manually. + * + * @property directQuery The direct query. + * @property reverseQuery The reverse query. + * @property errorCallback The error callback. + */ +@Suppress("NOTHING_TO_INLINE") +class FirestoreItemKeyedDataSource( + private val directQuery: Query, + private val reverseQuery: Query, + private val errorCallback: ErrorCallback? +) : ItemKeyedDataSource() { + + private val registrations = mutableListOf() + + /** + * Factory for [FirestoreItemKeyedDataSource]. + * + * @property queryFactory The query factory. + * @property errorCallback The error callback. + */ + class Factory @JvmOverloads constructor( + private val queryFactory: QueryFactory, + private val errorCallback: ErrorCallback? = null + ) : DataSource.Factory() { + + /** + * Creates and returns [FirestoreItemKeyedDataSource]. + * + * @return The created data source. + */ + override fun create(): DataSource = + FirestoreItemKeyedDataSource(queryFactory.create(), queryFactory.createReverse(), errorCallback) + } + + override fun loadInitial( + params: LoadInitialParams, + callback: LoadInitialCallback + ) { + val key = params.requestedInitialKey + val loadSize = params.requestedLoadSize.toLong() + + if (key == null) { // Initial data load could be asynchronous. + loadFromBeginning(loadSize, callback) + } else { // Stale data refresh. Should be synchronous to prevent RecyclerView items disappearing and loss of current position. + loadAround(key, loadSize, callback).await() + } + } + + /** + * Tries to load some items before and after provided [key]. + * + * @param key The key to load around. + * @param loadSize Requested number of items to load. + * + * @return A Task that will be resolved with the results of the Query. + */ + private fun loadAround( + key: DocumentSnapshot, + loadSize: Long, + callback: LoadInitialCallback + ) = tryGetPriorKey(key, loadSize / 2).continueWithTask { previousTask -> + loadStartAt(previousTask.result!!, loadSize, callback) + } + + /** + * Tries to get key before provided [key] to start load page from it. + * + * @param key The key to load before. + * @param preloadSize Requested number of items to preload. + * + * @return A Task that will be resolved with the results of the Query. + */ + private fun tryGetPriorKey( + key: DocumentSnapshot, + preloadSize: Long + ): Task = getItemsBeforeKey(key, preloadSize).continueWith { task -> + if (task.isSuccessful) { + val documents = task.result!!.documents + documents.getOrElse(documents.lastIndex) { key } + } else { + key + } + } + + private fun getItemsBeforeKey( + key: DocumentSnapshot, + preloadSize: Long + ) = endAtQuery(key, preloadSize).get(CACHE) + + private fun loadStartAt( + key: DocumentSnapshot, + loadSize: Long, + callback: LoadInitialCallback + ): Task = resultsListener(callback).also { listener -> + register(startAtQuery(key, loadSize), listener) + }.firstEventFiredTask + + private fun loadFromBeginning( + loadSize: Long, + callback: LoadInitialCallback + ) { + register(startFromBeginningQuery(loadSize), resultsListener(callback)) + } + + override fun loadAfter( + params: LoadParams, + callback: LoadCallback + ) { + register(startAfterQuery(params), resultsListener(callback)) + } + + override fun loadBefore( + params: LoadParams, + callback: LoadCallback + ) { + register(startBeforeQuery(params), reversedResultsListener(callback)) + } + + override fun getKey(item: DocumentSnapshot) = item + + override fun invalidate() { + super.invalidate() + registrations.clear() + } + + private fun register( + query: Query, + resultListener: ResultsListener + ) { + registrations.add(query.addSnapshotListener(resultListener)) + } + + private fun startFromBeginningQuery(loadSize: Long) = directQuery.limit(loadSize) + + private fun startAtQuery(key: DocumentSnapshot, loadSize: Long) = directQuery.startAt(key).limit(loadSize) + + private fun startAfterQuery(params: LoadParams) = + directQuery.startAfter(params.key).limit(params.requestedLoadSize.toLong()) + + private fun startBeforeQuery(params: LoadParams) = + reverseQuery.startAfter(params.key).limit(params.requestedLoadSize.toLong()) + + private fun endAtQuery(key: DocumentSnapshot, loadSize: Long) = reverseQuery.startAt(key).limit(loadSize) + + private fun resultsListener(callback: LoadCallback) = ResultsListener(callback) + private fun reversedResultsListener(callback: LoadCallback) = ResultsListener(callback, true) + + /** + * Implementation of [EventListener]. + * + * Listen for [DocumentSnapshot] for the requested page to load. + * The first received result is treated as page data. The subsequent results treated as data change and invoke + * data source invalidation. + * + * @property callback The callback to pass data to [PagedList]. + * @property isReversedResults The flag that tells that this listener will receive reversed result. + */ + private inner class ResultsListener( + private val callback: LoadCallback, + private val isReversedResults: Boolean = false + ) : EventListener { + + private val isFirstEvent = AtomicBoolean(true) + private val taskCompletionSource = TaskCompletionSource() + + /** + * A [Task] that will be resolved when this listener get first result or error. + * Used to load initial data synchronously on stale data refresh. + */ + val firstEventFiredTask get() = taskCompletionSource.task + + override fun onEvent(snapshot: QuerySnapshot?, exception: FirebaseFirestoreException?) { + if (isFirstEvent()) { + + snapshot?.documents?.also { documents -> + callback.onResult(if (isReversedResults) documents.reversed() else documents) + } ?: notifyError(exception!!) + + taskCompletionSource.setResult(Unit) + } else { + invalidate() + } + } + + private inline fun isFirstEvent() = isFirstEvent.compareAndSet(true, false) + } + + private fun notifyError(exception: FirebaseFirestoreException) { + getErrorCallback().onError(exception) + } + + private inline fun getErrorCallback() = errorCallback ?: defaultErrorCallback + + /** + * Callback to listen to data loading errors. + */ + interface ErrorCallback { + fun onError(exception: FirebaseFirestoreException) + } + + private inline fun Task.await() { + try { + Tasks.await(this) + } catch (e: Throwable) { } + } + + /** + * Used if user do not provide [errorCallback]. + * Simply invalidates this data source if loading error occurred. + */ + private val defaultErrorCallback = object : ErrorCallback { + override fun onError(exception: FirebaseFirestoreException) { + invalidate() + } + } +} \ No newline at end of file diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/QueryFactory.kt b/firestore/src/main/java/com/firebase/ui/firestore/paging/QueryFactory.kt new file mode 100644 index 000000000..7f5c862f2 --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/QueryFactory.kt @@ -0,0 +1,261 @@ +package com.firebase.ui.firestore.paging + +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.FieldPath +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.Query.Direction.* + +/** + * Factory for direct and reverse [Query]. + * + * @property collectionReference The collection to query. + * @property filters The filters to apply. + */ +class QueryFactory private constructor( + private val collectionReference: CollectionReference, + private val filters: List +) { + /** + * Creates and returns a new direct [Query] that's filtered by the specified filters. + * + * @return The created [Query]. + */ + fun create(): Query = filters.fold(collectionReference as Query) { query, filter -> filter.apply(query) } + + /** + * Creates and returns a new reverse [Query] that's filtered by the specified filters. + * + * @return The created [Query]. + */ + fun createReverse(): Query = + filters.fold(collectionReference as Query) { query, filter -> filter.applyReverse(query) } + + /** + * Base class for filters. + * + * @property fieldPath The field to sort by. + */ + private abstract class Filter( + protected val fieldPath: FieldPath + ) { + /** + * Creates and returns a new direct [Query] by applying direct filter. + * + * @param query The [Query] to apply filter. + * @return The created [Query]. + */ + abstract fun apply(query: Query): Query + + /** + * Creates and returns a new direct [Query] by applying direct filter. + * + * @param query The [Query] to apply filter. + * @return The created [Query]. + */ + abstract fun applyReverse(query: Query): Query + + /** + * Implementation of [Filter] that sorts [Query] by provided [fieldPath] with specified [direction]. + * + * @param fieldPath The field to sort by. + * @property direction The sort direction. + */ + private class OrderByFilter( + fieldPath: FieldPath, + private val direction: Query.Direction + ) : Filter(fieldPath) { + + private val reverseDirection = if (direction == ASCENDING) DESCENDING else ASCENDING + + override fun apply(query: Query): Query = query.orderBy(fieldPath, direction) + override fun applyReverse(query: Query): Query = query.orderBy(fieldPath, reverseDirection) + } + + /** + * Implementation of [Filter] that filters [Query] by provided [fieldPath] with specified [value]. + * + * @param fieldPath The field name to compare. + * @property value The value for comparison. + * @property filter The filter function. + */ + private class ByValueFilter( + fieldPath: FieldPath, + private val value: Any, + private val filter: Query.(FieldPath, Any) -> Query + ) : Filter(fieldPath) { + override fun apply(query: Query): Query = query.filter(fieldPath, value) + override fun applyReverse(query: Query): Query = apply(query) + } + + companion object { + @JvmStatic + fun orderBy(fieldPath: FieldPath, direction: Query.Direction): Filter = OrderByFilter(fieldPath, direction) + + @JvmStatic + fun whereEqualTo(fieldPath: FieldPath, value: Any): Filter = + ByValueFilter(fieldPath, value) { p, v -> whereEqualTo(p, v) } + + @JvmStatic + fun whereGreaterThan(fieldPath: FieldPath, value: Any): Filter = + ByValueFilter(fieldPath, value) { p, v -> whereGreaterThan(p, v) } + + @JvmStatic + fun whereGreaterThanOrEqualTo(fieldPath: FieldPath, value: Any): Filter = + ByValueFilter(fieldPath, value) { p, v -> whereGreaterThanOrEqualTo(p, v) } + + @JvmStatic + fun whereLessThan(fieldPath: FieldPath, value: Any): Filter = + ByValueFilter(fieldPath, value) { p, v -> whereLessThan(p, v) } + + @JvmStatic + fun whereLessThanOrEqualTo(fieldPath: FieldPath, value: Any): Filter = + ByValueFilter(fieldPath, value) { p, v -> whereLessThanOrEqualTo(p, v) } + + @JvmStatic + fun whereArrayContains(fieldPath: FieldPath, value: Any): Filter = + ByValueFilter(fieldPath, value) { p, v -> whereArrayContains(p, v) } + } + } + + /** + * Builder for [QueryFactory]. + * + * [Query.limit], [Query.startAt], [Query.startAfter], [Query.endAt] and [Query.endBefore] applied + * by [FirestoreItemKeyedDataSource] to paginate data and not available in [Builder]. + * + * @property collection The collection for query. + * @property filters The filters to apply. + */ + @Suppress("MemberVisibilityCanBePrivate", "unused") + class Builder(private val collection: CollectionReference) { + private val filters = mutableListOf() + + /** + * Creates and returns [QueryFactory] for provided [collection] and [filters]. + * + * @return The created [QueryFactory]. + */ + fun build() = QueryFactory(collection, filters) + + /** + * Adds [Query.orderBy] filter with specified field name and direction. + * + * @param field The field name. + * @param direction The sort direction. + */ + @JvmOverloads + fun orderBy(field: String, direction: Query.Direction = ASCENDING) = orderBy(FieldPath.of(field), direction) + + /** + * Adds [Query.orderBy] filter with specified field name and direction. + * + * @param fieldPath The field name. + * @param direction The sort direction. + */ + @JvmOverloads + fun orderBy(fieldPath: FieldPath, direction: Query.Direction = ASCENDING) = + apply { filters.add(Filter.orderBy(fieldPath, direction)) } + + /** + * Adds [Query.whereEqualTo] filter with specified field name and value. + * + * @param field The field name. + * @param value The value for comparison. + */ + fun whereEqualTo(field: String, value: Any) = whereEqualTo(FieldPath.of(field), value) + + /** + * Adds [Query.whereEqualTo] filter with specified field name and value. + * + * @param fieldPath The field name. + * @param value The value for comparison. + */ + fun whereEqualTo(fieldPath: FieldPath, value: Any) = + apply { filters.add(Filter.whereEqualTo(fieldPath, value)) } + + /** + * Adds [Query.whereGreaterThan] filter with specified field name and value. + * + * @param field The field name. + * @param value The value for comparison. + */ + fun whereGreaterThan(field: String, value: Any) = whereGreaterThan(FieldPath.of(field), value) + + /** + * Adds [Query.whereGreaterThan] filter with specified field name and value. + * + * @param fieldPath The field name. + * @param value The value for comparison. + */ + fun whereGreaterThan(fieldPath: FieldPath, value: Any) = + apply { filters.add(Filter.whereGreaterThan(fieldPath, value)) } + + /** + * Adds [Query.whereGreaterThanOrEqualTo] filter with specified field name and value. + * + * @param field The field name. + * @param value The value for comparison. + */ + fun whereGreaterThanOrEqualTo(field: String, value: Any) = whereGreaterThanOrEqualTo(FieldPath.of(field), value) + + /** + * Adds [Query.whereGreaterThanOrEqualTo] filter with specified field name and value. + * + * @param fieldPath The field name. + * @param value The value for comparison. + */ + fun whereGreaterThanOrEqualTo(fieldPath: FieldPath, value: Any) = + apply { filters.add(Filter.whereGreaterThanOrEqualTo(fieldPath, value)) } + + /** + * Adds [Query.whereLessThan] filter with specified field name and value. + * + * @param field The field name. + * @param value The value for comparison. + */ + fun whereLessThan(field: String, value: Any) = whereLessThan(FieldPath.of(field), value) + + /** + * Adds [Query.whereLessThan] filter with specified field name and value. + * + * @param fieldPath The field name. + * @param value The value for comparison. + */ + fun whereLessThan(fieldPath: FieldPath, value: Any) = + apply { filters.add(Filter.whereLessThan(fieldPath, value)) } + + /** + * Adds [Query.whereLessThanOrEqualTo] filter with specified field name and value. + * + * @param field The field name. + * @param value The value for comparison. + */ + fun whereLessThanOrEqualTo(field: String, value: Any) = whereLessThanOrEqualTo(FieldPath.of(field), value) + + /** + * Adds [Query.whereLessThanOrEqualTo] filter with specified field name and value. + * + * @param fieldPath The field name. + * @param value The value for comparison. + */ + fun whereLessThanOrEqualTo(fieldPath: FieldPath, value: Any) = + apply { filters.add(Filter.whereLessThanOrEqualTo(fieldPath, value)) } + + /** + * Adds [Query.whereArrayContains] filter with specified field name and value. + * + * @param field The field name. + * @param value The value for comparison. + */ + fun whereArrayContains(field: String, value: Any) = whereArrayContains(FieldPath.of(field), value) + + /** + * Adds [Query.whereArrayContains] filter with specified field name and value. + * + * @param fieldPath The field name. + * @param value The value for comparison. + */ + fun whereArrayContains(fieldPath: FieldPath, value: Any) = + apply { filters.add(Filter.whereArrayContains(fieldPath, value)) } + } +} \ No newline at end of file From 53ace3bef839ff64ba17998b198052fb52820d1d Mon Sep 17 00:00:00 2001 From: abond Date: Tue, 12 Mar 2019 16:15:27 +0300 Subject: [PATCH 3/8] Documentation for FirestoreItemKeyedDataSource. --- firestore/README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/firestore/README.md b/firestore/README.md index 7ee7b17da..6f335ff97 100644 --- a/firestore/README.md +++ b/firestore/README.md @@ -19,6 +19,7 @@ Before using this library, you should be familiar with the following topics: 1. [Using the FirestorePagingAdapter](#using-the-firestorepagingadapter) 1. [Adapter lifecyle](#firestorepagingadapter-lifecycle) 1. [Events](#paging-events) + 1. [Using the FirestoreItemKeyedDataSource](#using-the-firestoreitemkeyeddatasource) ## Data model @@ -392,6 +393,77 @@ FirestorePagingAdapter adapter = }; ``` +### Using the `FirestoreItemKeyedDataSource` + +The `FirestoreItemKeyedDataSource` loads documents in pages. It also automatically applied updates +to your UI in real time when documents are added, removed, or change. + +The `FirestoreItemKeyedDataSource` is built on top of the [Android Paging Support Library][paging-support]. +Before using the adapter in your application, you must add a dependency on the support library: + +```groovy +implementation 'android.arch.paging:runtime:1.x.x' +``` + +The detailed explanation of how to use `DataSource` you can find in the [Android Paging Support Library][paging-support]. + +Here is a short example of how `FirestoreItemKeyedDataSource`. + +The following code snippet shows how to create a new instance of `LiveData` in your app's +`ViewModel` class : + +```java + +public class MyViewModel extends ViewModel { + + CollectionReference collection = FirebaseFirestore.getInstance().collection("cities"); + + FirestoreItemKeyedDataSource.Factory factory = FirestoreItemKeyedDataSource.Factory( + QueryFactory.Builder(collection) + .orderBy("name") + .build(), + errorListener + ); + + LiveData> items = LivePagedListBuilder(factory, /* page size*/ 10).build(); + + FirestoreItemKeyedDataSource.ErrorCallback errorListener = new FirestoreItemKeyedDataSource.ErrorCallback() { + @Override + public void onError(@NotNull FirebaseFirestoreException exception) { + // handle error + + items.getValue().getDataSource().invalidate(); + } + }; + +} + +``` + +You can connect an instance of `LiveData` to a `PagedListAdapter`, as shown in the following code snippet: + +```java + +public class MytActivity extends AppCompatActivity { + private MyAdapter adapter = new MyAdapter(); + private MyViewModel viewModel; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = ViewModelProviders.of(this).get(MyViewModel.class); + viewModel.items.observe(this, adapter::submitList); + } +} + +``` + + +**Note**: your implementation of `PagedListAdapter` should indicate that each item in the data set + can be represented with a unique identifier by calling `setHasStableIds(true)` and implement + `getItemId(position: Int)`. + Otherwise `RecyclerView` will be loosing current position after each data change. + [firestore-docs]: https://firebase.google.com/docs/firestore/ [firestore-custom-objects]: https://firebase.google.com/docs/firestore/manage-data/add-data#custom_objects [recyclerview]: https://developer.android.com/reference/android/support/v7/widget/RecyclerView.html From 36a291f7c743d54220ebc66a1bfd426e712550a0 Mon Sep 17 00:00:00 2001 From: abond Date: Tue, 12 Mar 2019 16:15:27 +0300 Subject: [PATCH 4/8] Documentation for FirestoreItemKeyedDataSource. --- firestore/README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/firestore/README.md b/firestore/README.md index 7ee7b17da..64774166d 100644 --- a/firestore/README.md +++ b/firestore/README.md @@ -19,6 +19,7 @@ Before using this library, you should be familiar with the following topics: 1. [Using the FirestorePagingAdapter](#using-the-firestorepagingadapter) 1. [Adapter lifecyle](#firestorepagingadapter-lifecycle) 1. [Events](#paging-events) + 1. [Using the FirestoreItemKeyedDataSource](#using-the-firestoreitemkeyeddatasource) ## Data model @@ -392,6 +393,76 @@ FirestorePagingAdapter adapter = }; ``` +### Using the `FirestoreItemKeyedDataSource` + +The `FirestoreItemKeyedDataSource` loads documents in pages. It also automatically applied updates +to your UI in real time when documents are added, removed, or change. + +The `FirestoreItemKeyedDataSource` is built on top of the [Android Paging Support Library][paging-support]. +Before using the adapter in your application, you must add a dependency on the support library: + +```groovy +implementation 'android.arch.paging:runtime:1.x.x' +``` + +The detailed explanation of how to use `DataSource` you can find in the [Android Paging Support Library][paging-support]. + +Here is a short example of how to use `FirestoreItemKeyedDataSource`. + +The following code snippet shows how to create a new instance of `LiveData` in your app's +`ViewModel` class : + +```java + +public class MyViewModel extends ViewModel { + + CollectionReference collection = FirebaseFirestore.getInstance().collection("cities"); + + FirestoreItemKeyedDataSource.Factory factory = FirestoreItemKeyedDataSource.Factory( + QueryFactory.Builder(collection) + .orderBy("name") + .build(), + errorListener + ); + + LiveData> items = LivePagedListBuilder(factory, /* page size*/ 10).build(); + + FirestoreItemKeyedDataSource.ErrorCallback errorListener = new FirestoreItemKeyedDataSource.ErrorCallback() { + @Override + public void onError(@NotNull FirebaseFirestoreException exception) { + // handle error + + items.getValue().getDataSource().invalidate(); + } + }; + +} + +``` + +You can connect an instance of `LiveData` to a `PagedListAdapter`, as shown in the following code snippet: + +```java + +public class MytActivity extends AppCompatActivity { + private MyAdapter adapter = new MyAdapter(); + private MyViewModel viewModel; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = ViewModelProviders.of(this).get(MyViewModel.class); + viewModel.items.observe(this, adapter::submitList); + } +} + +``` + + +**Note**: your implementation of `PagedListAdapter` should indicate that each item in the data set +can be represented with a unique identifier by calling `setHasStableIds(true)` and implement `getItemId(position: Int)`. +Otherwise `RecyclerView` will loose current position after each data change. + [firestore-docs]: https://firebase.google.com/docs/firestore/ [firestore-custom-objects]: https://firebase.google.com/docs/firestore/manage-data/add-data#custom_objects [recyclerview]: https://developer.android.com/reference/android/support/v7/widget/RecyclerView.html From 41b98b00eb50b4f91322c2895830b8fe3e652793 Mon Sep 17 00:00:00 2001 From: abond Date: Wed, 13 Mar 2019 08:38:40 +0300 Subject: [PATCH 5/8] kotlin-kapt plugin added to resolve javadoc generation errors when run ./gradlew check. --- firestore/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/firestore/build.gradle.kts b/firestore/build.gradle.kts index bdd18a4e9..3f99cf486 100644 --- a/firestore/build.gradle.kts +++ b/firestore/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { } apply { plugin("kotlin-android") + plugin("kotlin-kapt") plugin("kotlin-android-extensions") } repositories { From 3b671045ed472762cd7faeba34f9feb48f1b41d8 Mon Sep 17 00:00:00 2001 From: abond Date: Wed, 13 Mar 2019 10:48:56 +0300 Subject: [PATCH 6/8] Exclude kotlin files from javadoc task. --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 172d00e39..eb4ee439c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -140,6 +140,7 @@ fun Project.setupPublishing() { val javadoc = tasks.register("javadoc") { setSource(project.the().sourceSets["main"].java.srcDirs) classpath += files(project.the().bootClasspath) + exclude("**/*.kt") project.the().libraryVariants.configureEach { dependsOn(assemble) From cae4d353467797c45efb90d526b798453eba7ebd Mon Sep 17 00:00:00 2001 From: abond Date: Thu, 14 Mar 2019 16:44:59 +0300 Subject: [PATCH 7/8] Unregister listeners on DataSource invalidation. --- .../firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt index e25218961..45370cc54 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt @@ -139,6 +139,7 @@ class FirestoreItemKeyedDataSource( override fun invalidate() { super.invalidate() + registrations.forEach { it.remove() } registrations.clear() } From c343620f46ff3265e635aa5a565a235959a438e7 Mon Sep 17 00:00:00 2001 From: abond Date: Fri, 15 Mar 2019 11:49:44 +0300 Subject: [PATCH 8/8] Some rename refactorings and additions to the comments to increase readability. --- .../paging/FirestoreItemKeyedDataSource.kt | 141 +++++++++++------- 1 file changed, 84 insertions(+), 57 deletions(-) diff --git a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt index 45370cc54..114fad979 100644 --- a/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt @@ -55,154 +55,168 @@ class FirestoreItemKeyedDataSource( params: LoadInitialParams, callback: LoadInitialCallback ) { - val key = params.requestedInitialKey - val loadSize = params.requestedLoadSize.toLong() + val lastSeenDocument = params.requestedInitialKey + val requestedLoadSize = params.requestedLoadSize.toLong() - if (key == null) { // Initial data load could be asynchronous. - loadFromBeginning(loadSize, callback) + if (lastSeenDocument == null) { // Initial data load could be asynchronous. + loadFromBeginning(requestedLoadSize, callback) } else { // Stale data refresh. Should be synchronous to prevent RecyclerView items disappearing and loss of current position. - loadAround(key, loadSize, callback).await() + loadAround(lastSeenDocument, requestedLoadSize, callback).await() } } /** - * Tries to load some items before and after provided [key]. + * Tries to load half of [loadSize] documents before [document] and half of [loadSize] documents starting at provided [document]. * - * @param key The key to load around. + * @param document The document to load around. * @param loadSize Requested number of items to load. * * @return A Task that will be resolved with the results of the Query. */ private fun loadAround( - key: DocumentSnapshot, + document: DocumentSnapshot, loadSize: Long, callback: LoadInitialCallback - ) = tryGetPriorKey(key, loadSize / 2).continueWithTask { previousTask -> + ) = tryGetDocumentPrior(document, loadSize / 2).continueWithTask { previousTask -> loadStartAt(previousTask.result!!, loadSize, callback) } /** - * Tries to get key before provided [key] to start load page from it. + * Returns document before [document] maximum by [preloadSize] or [document] if there is no documents before. * - * @param key The key to load before. + * @param document The document to load before. * @param preloadSize Requested number of items to preload. * * @return A Task that will be resolved with the results of the Query. */ - private fun tryGetPriorKey( - key: DocumentSnapshot, + private fun tryGetDocumentPrior( + document: DocumentSnapshot, preloadSize: Long - ): Task = getItemsBeforeKey(key, preloadSize).continueWith { task -> + ): Task = getItemsBefore(document, preloadSize).continueWith { task -> if (task.isSuccessful) { - val documents = task.result!!.documents - documents.getOrElse(documents.lastIndex) { key } + task.result!!.documents.lastOrNull() ?: document } else { - key + document } } - private fun getItemsBeforeKey( - key: DocumentSnapshot, - preloadSize: Long - ) = endAtQuery(key, preloadSize).get(CACHE) + private fun getItemsBefore( + document: DocumentSnapshot, + loadSize: Long + ) = queryEndAt(document, loadSize).get(CACHE) private fun loadStartAt( - key: DocumentSnapshot, + document: DocumentSnapshot, loadSize: Long, callback: LoadInitialCallback - ): Task = resultsListener(callback).also { listener -> - register(startAtQuery(key, loadSize), listener) - }.firstEventFiredTask + ) = PageLoader(callback).also { pageLoader -> + addPageLoader(queryStartAt(document, loadSize), pageLoader) + }.task private fun loadFromBeginning( loadSize: Long, callback: LoadInitialCallback ) { - register(startFromBeginningQuery(loadSize), resultsListener(callback)) + addPageLoader( + queryStartFromBeginning(loadSize), + PageLoader(callback) + ) } override fun loadAfter( params: LoadParams, callback: LoadCallback ) { - register(startAfterQuery(params), resultsListener(callback)) + addPageLoader( + queryStartAfter(params.key, params.requestedLoadSize.toLong()), + PageLoader(callback) + ) } override fun loadBefore( params: LoadParams, callback: LoadCallback ) { - register(startBeforeQuery(params), reversedResultsListener(callback)) + addPageLoader( + queryEndBefore(params.key, params.requestedLoadSize.toLong()), + PageLoader(callback.reversedResults()) + ) } override fun getKey(item: DocumentSnapshot) = item override fun invalidate() { super.invalidate() + removeAllPageLoaders() + } + + private fun removeAllPageLoaders() { registrations.forEach { it.remove() } registrations.clear() } - private fun register( + private fun addPageLoader( query: Query, - resultListener: ResultsListener + pageLoader: PageLoader ) { - registrations.add(query.addSnapshotListener(resultListener)) + registrations.add(query.addSnapshotListener(pageLoader)) } - private fun startFromBeginningQuery(loadSize: Long) = directQuery.limit(loadSize) + private fun queryStartFromBeginning(loadSize: Long) = directQuery.limit(loadSize) - private fun startAtQuery(key: DocumentSnapshot, loadSize: Long) = directQuery.startAt(key).limit(loadSize) + private fun queryStartAt(document: DocumentSnapshot, loadSize: Long) = directQuery.startAt(document).limit(loadSize) - private fun startAfterQuery(params: LoadParams) = - directQuery.startAfter(params.key).limit(params.requestedLoadSize.toLong()) + private fun queryStartAfter(document: DocumentSnapshot, loadSize: Long) = directQuery.startAfter(document).limit(loadSize) - private fun startBeforeQuery(params: LoadParams) = - reverseQuery.startAfter(params.key).limit(params.requestedLoadSize.toLong()) + private fun queryEndAt(document: DocumentSnapshot, loadSize: Long) = reverseQuery.startAt(document).limit(loadSize) - private fun endAtQuery(key: DocumentSnapshot, loadSize: Long) = reverseQuery.startAt(key).limit(loadSize) - - private fun resultsListener(callback: LoadCallback) = ResultsListener(callback) - private fun reversedResultsListener(callback: LoadCallback) = ResultsListener(callback, true) + private fun queryEndBefore(document: DocumentSnapshot, loadSize: Long) = reverseQuery.startAfter(document).limit(loadSize) /** * Implementation of [EventListener]. * - * Listen for [DocumentSnapshot] for the requested page to load. + * Listen for loading of the requested page. + * * The first received result is treated as page data. The subsequent results treated as data change and invoke * data source invalidation. * * @property callback The callback to pass data to [PagedList]. - * @property isReversedResults The flag that tells that this listener will receive reversed result. */ - private inner class ResultsListener( - private val callback: LoadCallback, - private val isReversedResults: Boolean = false + private inner class PageLoader( + private val callback: LoadCallback ) : EventListener { - - private val isFirstEvent = AtomicBoolean(true) - private val taskCompletionSource = TaskCompletionSource() + private val isWaitingForPage = AtomicBoolean(true) + private val taskCompletionSource = TaskCompletionSource>() /** - * A [Task] that will be resolved when this listener get first result or error. + * A [Task] that will be resolved when this page loader loads page or fails. * Used to load initial data synchronously on stale data refresh. */ - val firstEventFiredTask get() = taskCompletionSource.task + val task get() = taskCompletionSource.task override fun onEvent(snapshot: QuerySnapshot?, exception: FirebaseFirestoreException?) { - if (isFirstEvent()) { + if (isWaitingForPage()) { - snapshot?.documents?.also { documents -> - callback.onResult(if (isReversedResults) documents.reversed() else documents) - } ?: notifyError(exception!!) + snapshot?.documents?.also { result -> + submit(result) + } ?: submit(exception!!) - taskCompletionSource.setResult(Unit) } else { invalidate() } } - private inline fun isFirstEvent() = isFirstEvent.compareAndSet(true, false) + private fun submit(result: MutableList) { + callback.onResult(result) + taskCompletionSource.setResult(result) + } + + private fun submit(exception: FirebaseFirestoreException) { + notifyError(exception) + taskCompletionSource.setException(exception) + } + + private inline fun isWaitingForPage() = isWaitingForPage.compareAndSet(true, false) } private fun notifyError(exception: FirebaseFirestoreException) { @@ -218,6 +232,19 @@ class FirestoreItemKeyedDataSource( fun onError(exception: FirebaseFirestoreException) } + /** + * Wraps [LoadCallback] with new one that reverses the results list. + */ + private fun LoadCallback.reversedResults() = object : LoadCallback() { + override fun onResult(data: MutableList) { + this@reversedResults.onResult(data.reversed()) + } + } + + /** + * Waits for [Task] to resolve ignoring whether it succeeded or failed. + * Used to load initial page synchronously on stale data refresh. + */ private inline fun Task.await() { try { Tasks.await(this)