Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ package org.groundplatform.android.data.remote.firebase.schema

import com.google.firebase.firestore.CollectionReference
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.snapshots
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.tasks.await
Comment thread
rfontanarosa marked this conversation as resolved.
Resolved
import org.groundplatform.android.data.remote.firebase.base.FluentCollectionReference
import org.groundplatform.android.proto.Role
import org.groundplatform.android.proto.Survey as SurveyProto
Expand All @@ -39,27 +39,38 @@ class SurveysCollectionReference internal constructor(ref: CollectionReference)

fun survey(id: String) = SurveyDocumentReference(reference().document(id))

/**
* Emits the surveys readable by [user] once per collection. Deliberately a one-shot read rather
* than a realtime listener, which bills every document again on each reconnect.
*/
fun getReadable(user: User): Flow<List<Survey>> {
val references =
reference()
.whereIn(STATE, ALLOWED_STATES)
.whereIn(FieldPath.of(ACL_FIELD, user.email), ALLOWED_ROLES)

return references.snapshots().map { snapshot ->
snapshot.documents
.mapNotNull { SurveyConverter.toSurvey(it) }
.filter { survey ->
survey.generalAccess == Survey.GeneralAccess.GENERAL_ACCESS_UNSPECIFIED ||
survey.generalAccess == Survey.GeneralAccess.RESTRICTED ||
survey.generalAccess == Survey.GeneralAccess.UNLISTED
}
return flow {
val snapshot = references.get().await()
val surveys =
snapshot.documents
.mapNotNull { SurveyConverter.toSurvey(it) }
.filter { survey ->
survey.generalAccess == Survey.GeneralAccess.GENERAL_ACCESS_UNSPECIFIED ||
survey.generalAccess == Survey.GeneralAccess.RESTRICTED ||
survey.generalAccess == Survey.GeneralAccess.UNLISTED
}
emit(surveys)
}
Comment thread
rfontanarosa marked this conversation as resolved.
Resolved
Comment on lines +52 to 63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Switching from snapshots() to get().await() changes the retrieval behavior from a real-time stream to a one-shot query. While this successfully reduces long-lived listener costs, get() by default attempts to reach the server first before falling back to the cache. This can introduce noticeable UI latency (hanging on a loading spinner) on slow or intermittent networks, whereas snapshots() would have emitted cached data instantly.\n\nAdditionally, since this is a cold flow, every new collection/subscription will trigger a new network request. Please ensure that the callers (e.g., ViewModels or Repositories) share or cache this flow (using stateIn or similar) to prevent redundant Firestore reads on configuration changes or screen navigation.

}
Comment thread
rfontanarosa marked this conversation as resolved.
Resolved

fun getPublicReadable(): Flow<List<Survey>> =
reference()
.whereEqualTo(GENERAL_ACCESS_FIELD, SurveyProto.GeneralAccess.PUBLIC_VALUE)
.whereIn(STATE, listOf(SurveyProto.State.READY_VALUE))
.snapshots()
.map { snapshot -> snapshot.documents.mapNotNull(SurveyConverter::toSurvey) }
/** Emits the public surveys once per collection. See [getReadable]. */
fun getPublicReadable(): Flow<List<Survey>> = flow {
val snapshot =
reference()
.whereEqualTo(GENERAL_ACCESS_FIELD, SurveyProto.GeneralAccess.PUBLIC_VALUE)
.whereIn(STATE, listOf(SurveyProto.State.READY_VALUE))
.get()
.await()
emit(snapshot.documents.mapNotNull(SurveyConverter::toSurvey))
}
Comment thread
rfontanarosa marked this conversation as resolved.
Resolved
Comment thread
rfontanarosa marked this conversation as resolved.
Resolved
Comment thread
rfontanarosa marked this conversation as resolved.
Resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ class SurveySyncService @Inject constructor(private val workManager: WorkManager
companion object {
/**
* How long to wait before syncing. A single change often triggers several updates in quick
* succession, and each run re-reads the whole survey, so it is cheaper to let them settle.
* succession, and each run re-reads the whole survey, so it is cheaper to let them settle. Kept
* long to bound Firestore reads when many data collectors share a survey, as every change
* notifies each of their devices.
*/
private const val SYNC_DELAY_MILLIS = 10_000L
private const val SYNC_DELAY_MILLIS = 30 * 60 * 1000L

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

A static delay of 30 minutes (30 * 60 * 1000L) is extremely long for syncing survey updates, which might cause data collectors to work with stale configurations for a significant period.\n\nFurthermore, if multiple devices receive a sync notification simultaneously (e.g., via FCM), they will all schedule their sync with the exact same 30-minute delay. This creates a "thundering herd" problem where all devices will still query Firestore at the exact same time, causing a spike in reads.\n\nTo mitigate this, consider introducing a randomized jitter to the initial delay in enqueueSync to spread the load over time. For example, you can add a random offset (e.g., up to 5 minutes) to SYNC_DELAY_MILLIS when building the work request.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,13 @@

package org.groundplatform.android.data.remote.firebase.schema

import com.google.android.gms.tasks.Tasks
import com.google.common.truth.Truth.assertThat
import com.google.firebase.firestore.CollectionReference
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.EventListener
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.ListenerRegistration
import com.google.firebase.firestore.MetadataChanges
import com.google.firebase.firestore.Query
import com.google.firebase.firestore.QuerySnapshot
import java.util.concurrent.Executor
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.groundplatform.android.FakeData
Expand Down Expand Up @@ -62,16 +59,11 @@ class SurveysCollectionReferenceTest {

whenever(mockCollectionReference.whereIn(any<String>(), any())).thenReturn(mockQuery)
whenever(mockCollectionReference.whereIn(any<FieldPath>(), any())).thenReturn(mockQuery)
whenever(mockCollectionReference.whereEqualTo(any<String>(), any())).thenReturn(mockQuery)
whenever(mockQuery.whereIn(any<String>(), any())).thenReturn(mockQuery)
whenever(mockQuery.whereIn(any<FieldPath>(), any())).thenReturn(mockQuery)

val mockRegistration: ListenerRegistration = mock()
whenever(mockQuery.addSnapshotListener(any<Executor>(), any<MetadataChanges>(), any()))
.thenAnswer { invocation ->
val listener = invocation.getArgument<EventListener<QuerySnapshot>>(2)
listener.onEvent(mockQuerySnapshot, null)
mockRegistration
}
whenever(mockQuery.get()).thenReturn(Tasks.forResult(mockQuerySnapshot))

surveysCollectionReference = SurveysCollectionReference(mockCollectionReference)
}
Expand Down Expand Up @@ -142,6 +134,52 @@ class SurveysCollectionReferenceTest {
)
}

@Test
fun `getPublicReadable returns public surveys`() = runTest {
val doc = createDocumentSnapshot("public", SurveyProto.GeneralAccess.PUBLIC)
whenever(mockQuerySnapshot.documents).thenReturn(listOf(doc))

val result = surveysCollectionReference.getPublicReadable().first()

assertThat(result.map { it.id }).containsExactly("public")
}

@Test
fun `getPublicReadable returns empty list when there are no public surveys`() = runTest {
whenever(mockQuerySnapshot.documents).thenReturn(emptyList())

val result = surveysCollectionReference.getPublicReadable().first()

assertThat(result).isEmpty()
}

@Test
fun `getPublicReadable queries for public access and ready state`() = runTest {
whenever(mockQuerySnapshot.documents).thenReturn(emptyList())

surveysCollectionReference.getPublicReadable().first()

verify(mockCollectionReference)
.whereEqualTo(
eq(SurveyProto.GENERAL_ACCESS_FIELD_NUMBER.toString()),
eq(SurveyProto.GeneralAccess.PUBLIC_VALUE),
)
verify(mockQuery)
.whereIn(
eq(SurveyProto.STATE_FIELD_NUMBER.toString()),
eq(listOf(SurveyProto.State.READY_VALUE)),
)
}

@Test
fun `getPublicReadable reads once instead of listening for changes`() = runTest {
whenever(mockQuerySnapshot.documents).thenReturn(emptyList())

surveysCollectionReference.getPublicReadable().first()

verify(mockQuery).get()
}

private fun createDocumentSnapshot(
id: String,
generalAccess: SurveyProto.GeneralAccess,
Expand Down
Loading