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) 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 diff --git a/firestore/build.gradle.kts b/firestore/build.gradle.kts index 4686e0114..3f99cf486 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,13 @@ 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-kapt") + plugin("kotlin-android-extensions") +} +repositories { + mavenCentral() +} \ No newline at end of file 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..114fad979 --- /dev/null +++ b/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestoreItemKeyedDataSource.kt @@ -0,0 +1,263 @@ +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 lastSeenDocument = params.requestedInitialKey + val requestedLoadSize = params.requestedLoadSize.toLong() + + 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(lastSeenDocument, requestedLoadSize, callback).await() + } + } + + /** + * Tries to load half of [loadSize] documents before [document] and half of [loadSize] documents starting at provided [document]. + * + * @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( + document: DocumentSnapshot, + loadSize: Long, + callback: LoadInitialCallback + ) = tryGetDocumentPrior(document, loadSize / 2).continueWithTask { previousTask -> + loadStartAt(previousTask.result!!, loadSize, callback) + } + + /** + * Returns document before [document] maximum by [preloadSize] or [document] if there is no documents 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 tryGetDocumentPrior( + document: DocumentSnapshot, + preloadSize: Long + ): Task = getItemsBefore(document, preloadSize).continueWith { task -> + if (task.isSuccessful) { + task.result!!.documents.lastOrNull() ?: document + } else { + document + } + } + + private fun getItemsBefore( + document: DocumentSnapshot, + loadSize: Long + ) = queryEndAt(document, loadSize).get(CACHE) + + private fun loadStartAt( + document: DocumentSnapshot, + loadSize: Long, + callback: LoadInitialCallback + ) = PageLoader(callback).also { pageLoader -> + addPageLoader(queryStartAt(document, loadSize), pageLoader) + }.task + + private fun loadFromBeginning( + loadSize: Long, + callback: LoadInitialCallback + ) { + addPageLoader( + queryStartFromBeginning(loadSize), + PageLoader(callback) + ) + } + + override fun loadAfter( + params: LoadParams, + callback: LoadCallback + ) { + addPageLoader( + queryStartAfter(params.key, params.requestedLoadSize.toLong()), + PageLoader(callback) + ) + } + + override fun loadBefore( + params: LoadParams, + callback: LoadCallback + ) { + 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 addPageLoader( + query: Query, + pageLoader: PageLoader + ) { + registrations.add(query.addSnapshotListener(pageLoader)) + } + + private fun queryStartFromBeginning(loadSize: Long) = directQuery.limit(loadSize) + + private fun queryStartAt(document: DocumentSnapshot, loadSize: Long) = directQuery.startAt(document).limit(loadSize) + + private fun queryStartAfter(document: DocumentSnapshot, loadSize: Long) = directQuery.startAfter(document).limit(loadSize) + + private fun queryEndAt(document: DocumentSnapshot, loadSize: Long) = reverseQuery.startAt(document).limit(loadSize) + + private fun queryEndBefore(document: DocumentSnapshot, loadSize: Long) = reverseQuery.startAfter(document).limit(loadSize) + + /** + * Implementation of [EventListener]. + * + * 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]. + */ + private inner class PageLoader( + private val callback: LoadCallback + ) : EventListener { + private val isWaitingForPage = AtomicBoolean(true) + private val taskCompletionSource = TaskCompletionSource>() + + /** + * 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 task get() = taskCompletionSource.task + + override fun onEvent(snapshot: QuerySnapshot?, exception: FirebaseFirestoreException?) { + if (isWaitingForPage()) { + + snapshot?.documents?.also { result -> + submit(result) + } ?: submit(exception!!) + + } else { + invalidate() + } + } + + 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) { + getErrorCallback().onError(exception) + } + + private inline fun getErrorCallback() = errorCallback ?: defaultErrorCallback + + /** + * Callback to listen to data loading errors. + */ + interface ErrorCallback { + 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) + } 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