Skip to content

Commit

Permalink
Draft remote / local survey list
Browse files Browse the repository at this point in the history
  • Loading branch information
gino-m committed Oct 30, 2023
1 parent 4f2a669 commit e809059
Show file tree
Hide file tree
Showing 9 changed files with 102 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import com.google.android.ground.model.User
import com.google.android.ground.model.locationofinterest.LocationOfInterest
import com.google.android.ground.model.mutation.Mutation
import com.google.android.ground.model.submission.Submission
import kotlinx.coroutines.flow.Flow

/**
* Defines API for accessing data in a remote data store. Implementations must ensure all
* subscriptions are run in a background thread (i.e., not the Android main thread).
*/
interface RemoteDataStore {
suspend fun loadSurveySummaries(user: User): List<Survey>
// TODO: Refactor SurveyItem into model class SurveyListItem and return from here.
fun getSurveyList(user: User): Flow<List<Survey>>

/**
* Loads the survey with the specified id from the remote data store. Returns `null` if the survey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import com.google.firebase.messaging.ktx.messaging
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
Expand Down Expand Up @@ -64,8 +67,9 @@ internal constructor(
override suspend fun loadTermsOfService(): TermsOfService? =
withContext(ioDispatcher) { db().termsOfService().terms().get() }

override suspend fun loadSurveySummaries(user: User): List<Survey> =
withContext(ioDispatcher) { db().surveys().getReadable(user) }
override fun getSurveyList(user: User): Flow<List<Survey>> = flow {
emitAll(db().surveys().getReadable(user))
}

override suspend fun loadLocationsOfInterest(survey: Survey) =
db().surveys().survey(survey.id).lois().locationsOfInterest(survey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import com.google.android.ground.model.Survey
import com.google.android.ground.model.User
import com.google.android.ground.persistence.remote.firebase.base.FluentCollectionReference
import com.google.firebase.firestore.CollectionReference
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.snapshots
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

private const val ACL_FIELD = "acl"

Expand All @@ -31,9 +33,8 @@ class SurveysCollectionReference internal constructor(ref: CollectionReference)

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

suspend fun getReadable(user: User): List<Survey> =
runQuery(reference().whereIn(FieldPath.of(ACL_FIELD, user.email), Role.valueStrings())) {
doc: DocumentSnapshot ->
SurveyConverter.toSurvey(doc)
fun getReadable(user: User): Flow<List<Survey>> =
reference().whereIn(FieldPath.of(ACL_FIELD, user.email), Role.valueStrings()).snapshots().map {
it.documents.map { doc -> SurveyConverter.toSurvey(doc) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,27 @@ import com.google.android.ground.persistence.local.LocalValueStore
import com.google.android.ground.persistence.local.stores.LocalSurveyStore
import com.google.android.ground.persistence.remote.RemoteDataStore
import com.google.android.ground.rx.annotations.Cold
import com.google.android.ground.system.NetworkManager
import com.google.android.ground.system.NetworkStatus
import io.reactivex.Flowable
import java8.util.Optional
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.rx2.asFlowable
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber

private const val LOAD_REMOTE_SURVEY_TIMEOUT_MILLS: Long = 15 * 1000
private const val LOAD_REMOTE_SURVEY_SUMMARIES_TIMEOUT_MILLIS: Long = 30 * 1000

/**
* Coordinates persistence and retrieval of [Survey] instances from remote, local, and in memory
Expand All @@ -54,6 +56,7 @@ constructor(
private val localSurveyStore: LocalSurveyStore,
private val remoteDataStore: RemoteDataStore,
private val localValueStore: LocalValueStore,
private val networkManager: NetworkManager,
@ApplicationScope private val externalScope: CoroutineScope
) {
private val _activeSurvey = MutableStateFlow<Survey?>(null)
Expand Down Expand Up @@ -82,7 +85,7 @@ constructor(
val activeSurveyFlowable: @Cold Flowable<Optional<Survey>> =
activeSurveyFlow.map { if (it == null) Optional.empty() else Optional.of(it) }.asFlowable()

val offlineSurveys: Flow<List<Survey>>
val localSurveysFlow: Flow<List<Survey>>
get() = localSurveyStore.surveys

var lastActiveSurveyId: String by localValueStore::lastActiveSurveyId
Expand Down Expand Up @@ -114,20 +117,27 @@ constructor(
fun clearActiveSurvey() {
activeSurvey = null
}
// offlineSurveys.any { it.id == survey.id }
fun getSurveyList(user: User): Flow<List<Pair<Survey, Boolean>>> =
@OptIn(ExperimentalCoroutinesApi::class)
networkManager.networkStatusFlow.flatMapLatest { networkStatus ->
if (networkStatus == NetworkStatus.AVAILABLE) {
getRemoteSurveyList(user)
} else {
getLocalSurveyList(user)
}
}

suspend fun getSurveySummaries(user: User): Flow<List<Survey>> =
try {
val surveys =
withTimeout(LOAD_REMOTE_SURVEY_SUMMARIES_TIMEOUT_MILLIS) {
Timber.d("Loading survey list from remote")
remoteDataStore.loadSurveySummaries(user)
}
listOf(surveys).asFlow()
} catch (e: Throwable) {
Timber.d(e, "Failed to load survey list from remote")
offlineSurveys
private fun getRemoteSurveyList(user: User): Flow<List<Pair<Survey, Boolean>>> =
remoteDataStore.getSurveyList(user).combine(localSurveysFlow) { remoteSurveys, localSurveys ->
remoteSurveys.map { remoteSurvey ->
Pair(remoteSurvey, localSurveys.any { it.id == remoteSurvey.id })
}
}

private fun getLocalSurveyList(user: User): Flow<List<Pair<Survey, Boolean>>> =
localSurveysFlow.map { localSurveys -> localSurveys.map { survey -> Pair(survey, true) } }

/** Attempts to remove the locally synced survey. Doesn't throw an error if it doesn't exist. */
suspend fun removeOfflineSurvey(surveyId: String) {
val survey = localSurveyStore.getSurveyByIdSuspend(surveyId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,49 @@ package com.google.android.ground.system

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkRequest
import androidx.annotation.RequiresPermission
import dagger.hilt.android.qualifiers.ApplicationContext
import java.net.ConnectException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow

enum class NetworkStatus {
AVAILABLE,
UNAVAILABLE
}
/** Abstracts access to network state. */
@Singleton
class NetworkManager @Inject constructor(@ApplicationContext private val context: Context) {
val networkStatusFlow = initNetworkStatusFlow()

fun initNetworkStatusFlow(): Flow<NetworkStatus> {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
return callbackFlow {
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.AVAILABLE)
}

override fun onLost(network: Network) {
trySend(NetworkStatus.UNAVAILABLE)
}
}

val request = NetworkRequest.Builder().addCapability(NET_CAPABILITY_INTERNET).build()
// Emit initial state.
trySend(if (isNetworkConnected()) NetworkStatus.AVAILABLE else NetworkStatus.UNAVAILABLE)
connectivityManager.registerNetworkCallback(request, callback)

awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}
}

/** Returns true iff the device has internet connectivity, false otherwise. */
@RequiresPermission("android.permission.ACCESS_NETWORK_STATE")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch

/** Represents view state and behaviors of the survey selector dialog. */
Expand All @@ -56,7 +56,7 @@ internal constructor(

init {
surveySummaries =
createSurveySummaries().onEach {
getSurveyList().onEach {
if (it.isEmpty()) {
setNotFound()
} else {
Expand All @@ -67,29 +67,26 @@ internal constructor(

/** Returns a flow of locally stored surveys. */
private fun offlineSurveys(): Flow<List<Survey>> =
surveyRepository.offlineSurveys.onEach { setLoading() }

/** Returns a flow of remotely stored surveys. */
private suspend fun allSurveys(): Flow<List<Survey>> =
surveyRepository.getSurveySummaries(authManager.currentUser).onEach { setLoading() }
surveyRepository.localSurveysFlow.onEach { setLoading() }

/** Returns a flow of [SurveyItem] to be displayed to the user. */
private fun createSurveySummaries(): Flow<List<SurveyItem>> =
offlineSurveys().flatMapMerge { offlineSurveys: List<Survey> ->
allSurveys().map { allSurveys: List<Survey> ->
allSurveys
.map { createSurveyItem(it, offlineSurveys) }
private fun getSurveyList(): Flow<List<SurveyItem>> =
surveyRepository
.getSurveyList(authManager.currentUser)
.onStart { setLoading() }
.map { surveys: List<Pair<Survey, Boolean>> ->
surveys
.map { createSurveyItem(it.first, it.second) }
.sortedBy { it.surveyTitle }
.sortedByDescending { it.isAvailableOffline }
}
}

private fun createSurveyItem(survey: Survey, localSurveys: List<Survey>): SurveyItem =
private fun createSurveyItem(survey: Survey, isAvailableOffline: Boolean): SurveyItem =
SurveyItem(
surveyId = survey.id,
surveyTitle = survey.title,
surveyDescription = survey.description,
isAvailableOffline = localSurveys.any { it.id == survey.id }
isAvailableOffline = isAvailableOffline
)

/** Triggers the specified survey to be loaded and activated. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class SurveyRepositoryTest : BaseHiltTest() {
advanceUntilIdle()

// Verify survey is deleted
surveyRepository.offlineSurveys.test { assertThat(expectMostRecentItem()).isEmpty() }
surveyRepository.localSurveysFlow.test { assertThat(expectMostRecentItem()).isEmpty() }
// Verify survey deactivated
assertThat(surveyRepository.activeSurvey).isNull()
}
Expand All @@ -103,7 +103,7 @@ class SurveyRepositoryTest : BaseHiltTest() {
// Verify active survey isn't cleared
assertThat(surveyRepository.activeSurvey).isEqualTo(survey1)
// Verify survey is deleted
surveyRepository.offlineSurveys.test {
surveyRepository.localSurveysFlow.test {
assertThat(expectMostRecentItem()).isEqualTo(listOf(survey1))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun created_surveysAvailable_whenNoSurveySynced() {
setAllSurveys(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setSurveyList(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setOfflineSurveys(listOf())
setUpFragment()

Expand All @@ -99,7 +99,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun created_surveysAvailable_whenOneSurveySynced() {
setAllSurveys(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setSurveyList(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setOfflineSurveys(listOf(TEST_SURVEY_2))
setUpFragment()

Expand All @@ -121,7 +121,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun click_activatesSurvey() = runWithTestDispatcher {
setAllSurveys(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setSurveyList(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setOfflineSurveys(listOf())
setUpFragment()

Expand All @@ -138,7 +138,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun shouldExitAppOnBackPress_defaultFalse() {
setAllSurveys(listOf())
setSurveyList(listOf())
setOfflineSurveys(listOf())
setUpFragment()

Expand All @@ -148,7 +148,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun shouldExitAppOnBackPress_whenArgIsPresent() {
setAllSurveys(listOf())
setSurveyList(listOf())
setOfflineSurveys(listOf())
setUpFragment(bundleOf(Pair("shouldExitApp", true)))

Expand All @@ -158,7 +158,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun `hide sign out button when survey list is not empty`() {
setAllSurveys(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setSurveyList(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setOfflineSurveys(listOf())
setUpFragment()

Expand All @@ -167,7 +167,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun `show sign out button when survey list is empty`() {
setAllSurveys(listOf())
setSurveyList(listOf())
setOfflineSurveys(listOf())
setUpFragment()

Expand All @@ -177,7 +177,7 @@ class SurveySelectorFragmentTest : BaseHiltTest() {

@Test
fun `remove offline survey on menu item click`() = runWithTestDispatcher {
setAllSurveys(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setSurveyList(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setOfflineSurveys(listOf(TEST_SURVEY_1, TEST_SURVEY_2))
setUpFragment()

Expand Down Expand Up @@ -210,13 +210,13 @@ class SurveySelectorFragmentTest : BaseHiltTest() {
}
}

private fun setAllSurveys(surveys: List<Survey>) = runWithTestDispatcher {
whenever(surveyRepository.getSurveySummaries(FakeData.USER))
private fun setSurveyList(surveys: List<Pair<Survey, Boolean>>) = runWithTestDispatcher {
whenever(surveyRepository.getSurveyList(FakeData.USER))
.thenReturn(listOf(surveys).asFlow())
}

private fun setOfflineSurveys(surveys: List<Survey>) {
whenever(surveyRepository.offlineSurveys).thenReturn(listOf(surveys).asFlow())
whenever(surveyRepository.localSurveysFlow).thenReturn(listOf(surveys).asFlow())
}

private fun getViewHolder(index: Int): SurveyListAdapter.ViewHolder {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import com.google.android.ground.model.locationofinterest.LocationOfInterest
import com.google.android.ground.model.mutation.Mutation
import com.google.android.ground.model.submission.Submission
import com.google.android.ground.persistence.remote.RemoteDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -38,7 +40,7 @@ class FakeRemoteDataStore @Inject internal constructor() : RemoteDataStore {

private val subscribedSurveyIds = mutableSetOf<String>()

override suspend fun loadSurveySummaries(user: User): List<Survey> = surveys
override suspend fun getSurveyList(user: User): Flow<List<Survey>> = flowOf(surveys)

override suspend fun loadSurvey(surveyId: String): Survey? = onLoadSurvey.invoke(surveyId)

Expand Down

0 comments on commit e809059

Please sign in to comment.