Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make survey list reactive to local and remote changes #2030

Merged
merged 8 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ground/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,9 @@ dependencies {
testImplementation 'org.json:json:20180813'

// Firebase and related libraries.
implementation platform('com.google.firebase:firebase-bom:32.2.3')
implementation platform('com.google.firebase:firebase-bom:32.4.1')
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-firestore-ktx'
implementation 'com.google.firebase:firebase-firestore'
implementation 'com.google.firebase:firebase-functions-ktx'
implementation 'com.google.firebase:firebase-auth'
implementation 'com.google.firebase:firebase-perf'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
* limitations under the License.
*/

package com.google.android.ground.ui.surveyselector
package com.google.android.ground.model

data class SurveyItem(
val surveyId: String,
val surveyTitle: String,
val surveyDescription: String,
val isAvailableOffline: Boolean
data class SurveyListItem(
val id: String,
val title: String,
val description: String,
val availableOffline: Boolean
)

fun Survey.toListItem(availableOffline: Boolean): SurveyListItem =
SurveyListItem(id, title, description, availableOffline)
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,20 @@
package com.google.android.ground.persistence.remote

import com.google.android.ground.model.Survey
import com.google.android.ground.model.SurveyListItem
import com.google.android.ground.model.TermsOfService
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>
fun getSurveyList(user: User): Flow<List<SurveyListItem>>

/**
* 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 @@ -17,13 +17,15 @@ package com.google.android.ground.persistence.remote.firebase

import com.google.android.ground.coroutines.IoDispatcher
import com.google.android.ground.model.Survey
import com.google.android.ground.model.SurveyListItem
import com.google.android.ground.model.TermsOfService
import com.google.android.ground.model.User
import com.google.android.ground.model.locationofinterest.LocationOfInterest
import com.google.android.ground.model.mutation.LocationOfInterestMutation
import com.google.android.ground.model.mutation.Mutation
import com.google.android.ground.model.mutation.SubmissionMutation
import com.google.android.ground.model.submission.Submission
import com.google.android.ground.model.toListItem
import com.google.android.ground.persistence.remote.RemoteDataStore
import com.google.firebase.firestore.WriteBatch
import com.google.firebase.functions.FirebaseFunctions
Expand All @@ -32,6 +34,10 @@ 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.flow.map
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
Expand Down Expand Up @@ -64,8 +70,14 @@ 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<SurveyListItem>> = flow {
emitAll(
db().surveys().getReadable(user).map { list ->
// TODO(#2031): Return SurveyListItem from getReadable(), only fetch required fields.
list.map { it.toListItem(false) }
}
)
}
shobhitagarwal1612 marked this conversation as resolved.
Show resolved Hide resolved

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) }
shobhitagarwal1612 marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,34 @@ package com.google.android.ground.repository

import com.google.android.ground.coroutines.ApplicationScope
import com.google.android.ground.model.Survey
import com.google.android.ground.model.SurveyListItem
import com.google.android.ground.model.User
import com.google.android.ground.model.toListItem
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 +58,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,8 +87,8 @@ constructor(
val activeSurveyFlowable: @Cold Flowable<Optional<Survey>> =
activeSurveyFlow.map { if (it == null) Optional.empty() else Optional.of(it) }.asFlowable()

val offlineSurveys: Flow<List<Survey>>
get() = localSurveyStore.surveys
val localSurveyListFlow: Flow<List<SurveyListItem>>
get() = localSurveyStore.surveys.map { list -> list.map { it.toListItem(true) } }

var lastActiveSurveyId: String by localValueStore::lastActiveSurveyId
internal set
Expand Down Expand Up @@ -115,17 +120,22 @@ constructor(
activeSurvey = null
}

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
fun getSurveyList(user: User): Flow<List<SurveyListItem>> =
@OptIn(ExperimentalCoroutinesApi::class)
networkManager.networkStatusFlow.flatMapLatest { networkStatus ->
gino-m marked this conversation as resolved.
Show resolved Hide resolved
if (networkStatus == NetworkStatus.AVAILABLE) {
getRemoteSurveyList(user)
} else {
localSurveyListFlow
}
}

private fun getRemoteSurveyList(user: User): Flow<List<SurveyListItem>> =
remoteDataStore.getSurveyList(user).combine(localSurveyListFlow) { remoteSurveys, localSurveys
->
remoteSurveys.map { remoteSurvey ->
remoteSurvey.copy(availableOffline = localSurveys.any { it.id == remoteSurvey.id })
}
}

/** Attempts to remove the locally synced survey. Doesn't throw an error if it doesn't exist. */
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 @@ -20,28 +20,29 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.ground.databinding.SurveyCardItemBinding
import com.google.android.ground.model.SurveyListItem
import com.google.android.ground.ui.surveyselector.SurveyListAdapter.ViewHolder

/**
* An implementation of [RecyclerView.Adapter] that associates [SurveyItem] data with the
* An implementation of [RecyclerView.Adapter] that associates [SurveyListItem] data with the
* [ViewHolder] views.
*/
class SurveyListAdapter(
private val viewModel: SurveySelectorViewModel,
private val fragment: SurveySelectorFragment
) : RecyclerView.Adapter<ViewHolder>() {

private val surveys: MutableList<SurveyItem> = mutableListOf()
private val surveys: MutableList<SurveyListItem> = mutableListOf()

/** Creates a new [ViewHolder] item without any data. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = SurveyCardItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}

/** Binds [SurveyItem] data to [ViewHolder]. */
/** Binds [SurveyListItem] data to [ViewHolder]. */
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item: SurveyItem = surveys[position]
val item: SurveyListItem = surveys[position]
holder.binding.item = item
holder.binding.viewModel = viewModel
holder.binding.fragment = fragment
Expand All @@ -51,13 +52,13 @@ class SurveyListAdapter(
override fun getItemCount() = surveys.size

/** Overwrites existing cards. */
fun updateData(newItemsList: List<SurveyItem>) {
fun updateData(newItemsList: List<SurveyListItem>) {
surveys.clear()
surveys.addAll(newItemsList)
notifyDataSetChanged()
}

/** View item representing the [SurveyItem] data in the list. */
/** View item representing the [SurveyListItem] data in the list. */
class ViewHolder(internal val binding: SurveyCardItemBinding) :
RecyclerView.ViewHolder(binding.root)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import androidx.lifecycle.viewModelScope
import com.google.android.ground.coroutines.ApplicationScope
import com.google.android.ground.coroutines.IoDispatcher
import com.google.android.ground.domain.usecases.survey.ActivateSurveyUseCase
import com.google.android.ground.model.Survey
import com.google.android.ground.model.SurveyListItem
import com.google.android.ground.repository.SurveyRepository
import com.google.android.ground.repository.UserRepository
import com.google.android.ground.system.auth.AuthenticationManager
Expand All @@ -29,16 +29,14 @@ import com.google.android.ground.ui.home.HomeScreenFragmentDirections
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
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. */
@OptIn(FlowPreview::class)
class SurveySelectorViewModel
@Inject
internal constructor(
Expand All @@ -52,11 +50,11 @@ internal constructor(
) : AbstractViewModel() {

val surveyListState: MutableStateFlow<State?> = MutableStateFlow(null)
val surveySummaries: Flow<List<SurveyItem>>
val surveySummaries: Flow<List<SurveyListItem>>

init {
surveySummaries =
createSurveySummaries().onEach {
getSurveyList().onEach {
if (it.isEmpty()) {
setNotFound()
} else {
Expand All @@ -65,32 +63,12 @@ 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() }

/** 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) }
.sortedBy { it.surveyTitle }
.sortedByDescending { it.isAvailableOffline }
}
}

private fun createSurveyItem(survey: Survey, localSurveys: List<Survey>): SurveyItem =
SurveyItem(
surveyId = survey.id,
surveyTitle = survey.title,
surveyDescription = survey.description,
isAvailableOffline = localSurveys.any { it.id == survey.id }
)
/** Returns a flow of [SurveyListItem] to be displayed to the user. */
private fun getSurveyList(): Flow<List<SurveyListItem>> =
surveyRepository
.getSurveyList(authManager.currentUser)
.onStart { setLoading() }
.map { surveys -> surveys.sortedBy { it.title }.sortedByDescending { it.availableOffline } }

/** Triggers the specified survey to be loaded and activated. */
fun activateSurvey(surveyId: String) =
Expand Down
Loading