2

I am following the approach described in this post (the corresponding repository can be found here) to use the Paging library in order to partially load data from my Firestore database. Instead of making use of a different activity (as is done in the original post), I put the code in a Fragment.

In my DataSource, the loadInitial function gets called in which we subscribe to the results. Once the results are available, calling callback.onResult with as argument the newly retrieved data does not reach the adapter. After stepping in and through the callback.onResult function, the following exception occurs in the onNext method:

java.lang.IllegalStateException: callback.onResult already called, cannot call again.

However, if I press the back button, login again, the invocation of callback.Onresult does reach the adapter.

Initially, this made me believe that I was doing something wrong that was related to the lifecycle of the activity/fragment, however changing the place at which the adapter is initialized with the activity context did not change the result.

This post also mentions something about loading a fragment twice in order to get things working, which might explain the observed result when pressing the back button and returning.

EDIT: Added more relevant (updated) code

EDIT 2: Added extra Dagger annotations and refactored code in WorkManager to make use of RxJava2

EDIT 3: I completely stripped all of the Dagger 2 usages and now the code works, so the problem is Dagger related

EDIT 4: Problem solved, see my post below

The relevant code is as follows:

Fragment

class TradeRequestFragment : Fragment() {

    private val auth: FirebaseAuth by lazy { FirebaseAuth.getInstance() }
    private lateinit var rootView: View

    @SuppressLint("RestrictedApi")
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        rootView = inflater.inflate(R.layout.fragment_trade_requests, container, false)
        return rootView
    }

    private fun initAdapter() {
        // Setup the RecyclerView for the User's trade requests.
        // 1. get a reference to recyclerView
        val tradeRequestRecyclerView = rootView.findViewById<RecyclerView>(R.id.trade_requests_recycler_view)
        // 2. set LayoutManager
        tradeRequestRecyclerView.layoutManager = LinearLayoutManager(activity?.applicationContext)
        val tradeRequestAdapter = TradeRequestAdapter(activity?.applicationContext)

        // Dagger 2 injection
        val component = DaggerTradeRequestFragment_TradeRequestComponent.builder().tradeRequestModule(TradeRequestModule()).build()
        val tradeRequestViewModel = TradeRequestViewModel(component.getTradeRequestDataProvider())

        // 3. Set Adapter
        tradeRequestRecyclerView.adapter = tradeRequestAdapter
        // 4. Set the item animator
        tradeRequestRecyclerView.addItemDecoration(DividerItemDecoration(activity?.applicationContext, LinearLayoutManager.VERTICAL))
        tradeRequestViewModel.getTradeRequests()?.observe(viewLifecycleOwner, Observer(tradeRequestAdapter::submitList))
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        initAdapter()
    }

    @Module
    inner class TradeRequestModule {

        @Provides
        @Singleton
        fun provideDataSource(): TradeRequestsDataSource {
            return TradeRequestsDataSource(auth.currentUser?.uid
                    ?: "")
        }

        @Provides
        @Singleton
        fun provideUserId(): String {
            return auth.currentUser?.uid ?: ""
        }
    }

    @Singleton
    @Component(modules = [TradeRequestModule::class])
    interface TradeRequestComponent {
        fun getTradeRequestDataProvider(): TradeRequestDataProvider
    }
}

TradeRequestManager

class TradeRequestManager private constructor(userID: String) {
    companion object : Utils.Singleton<TradeRequestManager, String>(::TradeRequestManager)


    private val TRADE_REQUEST_ROUTE = "tradeRequests"
    private val userIDKey = "userId"
    private val userTradeRequestsAdapterInvalidation = PublishSubject.create<Any>()
    private val database = FirebaseFirestore.getInstance()
    private val databaseRef: Query? by lazy {
        try {
            database.collection(TRADE_REQUEST_ROUTE).whereEqualTo(userIDKey, userID)
        } catch (e: Exception) {
            Log.e(this.TAG(), "Could not retrieve the user's trade requests", e.cause)
            null
        }
    }

    private val tradeRequestBuilder: Moshi by lazy {
        Moshi.Builder()
                .add(ZonedDateTime::class.java, ZonedDateTimeAdapter())
                .add(CurrencyUnit::class.java, CurrencyUnitAdapter())
                .add(Money::class.java, JodaMoneyAdapter())
                .add(KotlinJsonAdapterFactory())
                .build()
    }
    private val tradeRequestAdapter: JsonAdapter<TradeRequest> by lazy { tradeRequestBuilder.adapter(TradeRequest::class.java) }


    // TODO see [here][https://leaks.wanari.com/2018/07/30/android-jetpack-paging-firebase]
    init {
        databaseRef?.addSnapshotListener(object : EventListener<QuerySnapshot> {
            override fun onEvent(snapshot: QuerySnapshot?, e: FirebaseFirestoreException?) {
                if (e != null) {
                    Log.e(this.TAG(), "listener:error", e)
                    return
                }
                if (snapshot == null) {
                    return
                }
                for (dc in snapshot.documentChanges) {
                    when (dc.type) {
                        DocumentChange.Type.ADDED -> userTradeRequestsAdapterInvalidation.onNext(true)
                        DocumentChange.Type.MODIFIED -> userTradeRequestsAdapterInvalidation.onNext(true)
                        DocumentChange.Type.REMOVED -> userTradeRequestsAdapterInvalidation.onNext(true)
                    }
                }
            }
        })
    }

    fun getUserTradeRequestsChangeSubject(): PublishSubject<Any>? {
        return userTradeRequestsAdapterInvalidation
    }

    // https://stackoverflow.com/questions/45420829/group-data-with-rxjava-2-add-element-to-each-group-and-output-one-list
    fun getTradeRequests(count: Int): Single<List<TradeRequest?>> {
        if (databaseRef == null) {
            return Observable.empty<List<TradeRequest?>>().singleOrError()
        }
        // By default, we order by 'creationDate' descending
        // If the field by which we order does not exists, no results are returned
        return RxFirestore.observeQueryRef(databaseRef!!.orderBy("creationDate", Query.Direction.DESCENDING).limit(count.toLong()))
                .firstElement()
                .toSingle()
                .flattenAsObservable { list -> list.documents }
                .flatMap { doc -> Observable.just(doc.data as? Map<String, String>) }
                .map { json -> tryOrNull { tradeRequestAdapter.fromJsonValue(json) } }
                .filter { tradeRequest -> tradeRequest != null }
                .toList()
    }

    fun getTradeRequestsAfter(key: String, value: String, count: Int, order: Query.Direction): Single<Pair<List<TradeRequest?>, String>> {
        if (databaseRef == null) {
            return Observable.empty<Pair<List<TradeRequest?>, String>>().singleOrError()
        }
        val result = RxFirestore.observeQueryRef(databaseRef!!.whereGreaterThanOrEqualTo(key, value).limit(count.toLong()).orderBy(key, order))
                .firstElement()
                .toSingle()
                .flattenAsObservable { list -> list.documents }
                .flatMap { doc -> Observable.just(doc.data as? Map<String, String>) }
                .map { json -> tryOrNull { tradeRequestAdapter.fromJsonValue(json) } }
                .filter { tradeRequest -> tradeRequest != null }
                .toList()

        val tradeRequests = result.blockingGet()

        // FIXME determine next filter value
        var newFilterValue = ""
        if (tradeRequests.size == count) {
            // Either the number of elements is capped or exactly "count" elements matched
            newFilterValue = ""
        }
        // END FIXME
        return Observable.just(Pair(tradeRequests, newFilterValue)).singleOrError()
    }

    fun getTradeRequestsBefore(key: String, value: String, count: Int, order: Query.Direction): Single<Pair<List<TradeRequest?>, String>> {
        if (databaseRef == null) {
            return Observable.empty<Pair<List<TradeRequest?>, String>>().singleOrError()
        }
        val result = RxFirestore.observeQueryRef(databaseRef!!.whereLessThan(key, value).limit(count.toLong()).orderBy(key, order))
                .firstElement()
                .toSingle()
                .flattenAsObservable { list -> list.documents }
                .flatMap { doc -> Observable.just(doc.data as? Map<String, String>) }
                .map { json -> tryOrNull { tradeRequestAdapter.fromJsonValue(json) } }
                .filter { tradeRequest -> tradeRequest != null }
                .toList()

        val tradeRequests = result.blockingGet()

        // FIXME determine next filter value
        var newFilterValue = ""
        if (tradeRequests.size == count) {
            // Either the number of elements is capped or exactly "count" elements matched
            newFilterValue = ""
        }
        // END FIXME
        return Observable.just(Pair(tradeRequests, newFilterValue)).singleOrError()
    }

DataSource

class TradeRequestsDataSource @Inject constructor(var userId: String, var filter: Data) : ItemKeyedDataSource<String, TradeRequest>() {


    private var filterKey: String
    private var filterValue: String
    private var filterOrder: Query.Direction
    private var userTradeRequestsManager: TradeRequestManager = TradeRequestManager.getInstance(userId)

    init {
        userTradeRequestsManager.getUserTradeRequestsChangeSubject()?.observeOn(Schedulers.io())?.subscribeOn(Schedulers.computation())?.subscribe {
            invalidate()
        }

        filterKey = filter.getString("filterKey") ?: ""
        filterValue = filter.getString("filterValue") ?: ""
        filterOrder = try {
            Query.Direction.valueOf(filter.getString("filterOrder") ?: "")
        } catch (e: Exception) {
            Query.Direction.DESCENDING
        }
    }

    override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<TradeRequest>) {
        Log.i(this.TAG(), "Loading the initial items in the RecyclerView")
        userTradeRequestsManager.getTradeRequests(params.requestedLoadSize).singleElement().subscribe({ tradeRequests ->
            Log.i(this.TAG(), "We received the callback")
            callback.onResult(tradeRequests)
        }, {})
    }

    override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<TradeRequest>) {
        userTradeRequestsManager.getTradeRequestsAfter(params.key, this.filterValue, params.requestedLoadSize, this.filterOrder).singleElement().subscribe({
            this.filterValue = it.second
            callback.onResult(it.first)
        }, {})
    }

    override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<TradeRequest>) {
        userTradeRequestsManager.getTradeRequestsBefore(params.key, this.filterValue, params.requestedLoadSize, this.filterOrder).singleElement().subscribe({
            this.filterValue = it.second
            callback.onResult(it.first)
        }, {})
    }

    override fun getKey(item: TradeRequest): String {
        return filterKey
    }

Adapter

class TradeRequestAdapter(val context: Context?) : PagedListAdapter<TradeRequest, TradeRequestAdapter.TradeRequestViewHolder>(
        object : DiffUtil.ItemCallback<TradeRequest>() {
            override fun areItemsTheSame(oldItem: TradeRequest, newItem: TradeRequest): Boolean {
                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: TradeRequest, newItem: TradeRequest): Boolean {
                return oldItem.amount == newItem.amount &&
                        oldItem.baseCurrency == newItem.baseCurrency &&
                        oldItem.counterCurrency == newItem.counterCurrency &&
                        oldItem.creationDate == newItem.creationDate &&
                        oldItem.userId == newItem.userId


            }
        }) {


    private lateinit var mInflater: LayoutInflater

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TradeRequestViewHolder {
        mInflater = LayoutInflater.from(context)
        val view = mInflater.inflate(R.layout.trade_request_item, parent, false)
        return TradeRequestViewHolder(view)
    }

    override fun onBindViewHolder(holder: TradeRequestViewHolder, position: Int) {
        val tradeRequest = getItem(position)
        holder.tradeRequestBaseCurrency.text = tradeRequest?.baseCurrency.toString()
        holder.tradeRequestCounterCurrency.text = tradeRequest?.counterCurrency.toString()
        holder.tradeRequestAmount.text = tradeRequest?.amount.toString()
    }


    // Placeholder class for displaying a single TradeRequest
    class TradeRequestViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var tradeRequestBaseCurrency: TextView = itemView.trade_request_item_from_currency
        var tradeRequestCounterCurrency: TextView = itemView.trade_request_item_to_currency
        var tradeRequestAmount: TextView = itemView.trade_request_item_amount
    }
}
Tomirio
  • 149
  • 3
  • 12
  • Probably `TradeRequestManager` exposes `Flowable/Observable` instead of `Single` so you're creating subscriptions that never die. – EpicPandaForce Mar 08 '19 at 13:12
  • You are right, the __getTradeRequests function__ shows my implementation of the ``TradeRequestManager.getTradeRequests``, which indeed returns an Observable. I'm quite new to RXJava, but I found the `toSingle` method which can transform from the `Observable` to the `Single` world. However I cannot apply it to the `PublishSubject` – Tomirio Mar 08 '19 at 13:47
  • ok, but overall the source of the bug is `TradeRequestManager` being misused, but we do not see its code. – EpicPandaForce Mar 08 '19 at 14:16
  • I have added `TradeRequestManager` code and changed the subscriptions in `TradeRequestsDataSource` to make them observe `Single` using the `singleElement()` method. Assuming this is done correctly, I am still observing the same behavior – Tomirio Mar 08 '19 at 14:30
  • I do not understand why your `TradeRequestsDataSource` is not a `PositionalDataSource` that wraps the FireStore Query that supports `limit/offset` using `startAt` + `limit`. So what you'd need to do is mirror [what `Room` does](https://www.reddit.com/r/androiddev/comments/706a2o/paging_library_preview/do4h0z0/), although there is a chance that observing changes made to the FULL collection but only showing a SUBSET of the collection is tricky with Firestore. – EpicPandaForce Mar 08 '19 at 15:03
  • Maybe check what [FirestorePagingAdapter](https://github.com/firebase/FirebaseUI-Android/blob/1e86767333d78ec910b3ce8802daff8935944daa/firestore/src/main/java/com/firebase/ui/firestore/paging/FirestorePagingAdapter.java) does? Either way, firestore subscriptions aren't one-off, and if you're using Firestore you should generally try to use their real-time capabilities as it results in much easier code. – EpicPandaForce Mar 08 '19 at 15:03
  • Thanks for your answers. I want the user to be able to select a filter (e.g. date, amount, etc..) and then let the `Usermanager` retrieve the values with respect to these filters. In this case, I imagined that a key would be the filter that is currently applied (e.g. `creationDate`) and a value would indicate min/max value of the elements that will come after the user scrolls up/down. This enables me to create queries with small results in the `loadBefore` and `loadAfter` methods. Regardless of whether this is a good approach or not, it is still weird that my callback is not received. – Tomirio Mar 08 '19 at 15:47

1 Answers1

0

The cause of the problem is the dependy injection done in the DataFactory. The original code was as follows:

@Singleton
class AnimalDataFactory @Inject constructor(): DataSource.Factory<String, Animal>() {


    private var datasourceLiveData = MutableLiveData<AnimalDataSource>()


    override fun create(): AnimalDataSource {
        // CORRECT: DataSource MUST be initialized here, otherwise items will not show up on initial activity/fragment launch
        val dataSource = AnimalDataSource()
        datasourceLiveData.postValue(dataSource)
        return dataSource
    }
}

while I changed this to

@Singleton
class AnimalDataFactory @Inject constructor(var dataSource: AnimalDataSourc): DataSource.Factory<String, Animal>() {


    private var datasourceLiveData = MutableLiveData<AnimalDataSource>()


    override fun create(): AnimalDataSource {
        // WRONG
        datasourceLiveData.postValue(dataSource)
        return dataSource
    }
}

I'm not quite sure why this solves the problem, but it works.

Tomirio
  • 149
  • 3
  • 12