From afb6907475294ca7c4425c100238bfec9cff5d21 Mon Sep 17 00:00:00 2001 From: Sufyan Abbasi Date: Mon, 26 Feb 2024 19:44:52 -0800 Subject: [PATCH] Add Condition and Expression local database tables and local support. - Also formatting etc. --- .../java/com/google/android/ground/Config.kt | 2 +- .../android/ground/model/task/Condition.kt | 26 +++--- .../persistence/local/LocalDataStoreModule.kt | 11 +++ .../persistence/local/room/LocalDatabase.kt | 48 +++++++++- .../local/room/converter/ConverterExt.kt | 75 ++++++++++++--- .../local/room/dao/ConditionDao.kt | 22 +++++ .../local/room/dao/ExpressionDao.kt | 22 +++++ .../local/room/entity/ConditionEntity.kt | 41 +++++++++ .../local/room/entity/ExpressionEntity.kt | 44 +++++++++ .../local/room/entity/TaskEntity.kt | 10 +- .../local/room/fields/ExpressionEntityType.kt | 55 +++++++++++ .../local/room/fields/MatchEntityType.kt | 55 +++++++++++ .../relations/ConditionEntityAndRelations.kt | 37 ++++++++ .../room/relations/TaskEntityAndRelations.kt | 8 +- .../local/room/stores/RoomSurveyStore.kt | 46 ++++++++-- .../firebase/schema/ConditionConverter.kt | 10 +- .../datacollection/DataCollectionFragment.kt | 42 +++++---- .../datacollection/DataCollectionViewModel.kt | 91 ++++++++++--------- .../ground/model/task/ConditionTest.kt | 20 ++-- 19 files changed, 543 insertions(+), 122 deletions(-) create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt create mode 100644 ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt diff --git a/ground/src/main/java/com/google/android/ground/Config.kt b/ground/src/main/java/com/google/android/ground/Config.kt index 882e3381cb..a59507626a 100644 --- a/ground/src/main/java/com/google/android/ground/Config.kt +++ b/ground/src/main/java/com/google/android/ground/Config.kt @@ -26,7 +26,7 @@ object Config { // Local db settings. // TODO(#128): Reset version to 1 before releasing. - const val DB_VERSION = 113 + const val DB_VERSION = 114 const val DB_NAME = "ground.db" // Firebase Cloud Firestore settings. diff --git a/ground/src/main/java/com/google/android/ground/model/task/Condition.kt b/ground/src/main/java/com/google/android/ground/model/task/Condition.kt index 6805f01979..999964e762 100644 --- a/ground/src/main/java/com/google/android/ground/model/task/Condition.kt +++ b/ground/src/main/java/com/google/android/ground/model/task/Condition.kt @@ -34,6 +34,8 @@ constructor( /** The expressions to evaluate to fulfill the condition. */ val expressions: List = listOf(), ) { + private val T.exhaustive: T + get() = this /** Match type names as they appear in the remote database. */ enum class MatchType { @@ -44,17 +46,13 @@ constructor( } /** Given the user's task selections, determine whether the condition is fulfilled. */ - fun fulfilledBy(taskSelections: TaskSelections): Boolean { - return when (matchType) { + fun fulfilledBy(taskSelections: TaskSelections) = + when (matchType) { MatchType.MATCH_ANY -> expressions.any { it.fulfilledBy(taskSelections) } MatchType.MATCH_ALL -> expressions.all { it.fulfilledBy(taskSelections) } MatchType.MATCH_ONE -> expressions.filter { it.fulfilledBy(taskSelections) }.size == 1 else -> throw IllegalArgumentException("Unknown match type: $matchType") }.exhaustive - } - - private val T.exhaustive: T - get() = this } data class Expression @@ -76,20 +74,18 @@ constructor( ONE_OF_SELECTED, } + private val T.exhaustive: T + get() = this + /** Given the selected options for this task, determine whether the expression is fulfilled. */ - fun fulfilledBy(taskSelections: TaskSelections): Boolean { - return taskSelections[this.taskId]?.let { selection -> this.fulfilled(selection) } ?: false - } + fun fulfilledBy(taskSelections: TaskSelections): Boolean = + taskSelections[this.taskId]?.let { selection -> this.fulfilled(selection) } ?: false - private fun fulfilled(selectedOptions: Set): Boolean { - return when (expressionType) { + private fun fulfilled(selectedOptions: Set): Boolean = + when (expressionType) { ExpressionType.ANY_OF_SELECTED -> optionIds.any { it in selectedOptions } ExpressionType.ALL_OF_SELECTED -> selectedOptions.containsAll(optionIds) ExpressionType.ONE_OF_SELECTED -> selectedOptions.intersect(optionIds).size == 1 else -> throw IllegalArgumentException("Unknown expression type: $expressionType") }.exhaustive - } - - private val T.exhaustive: T - get() = this } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt b/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt index 3c8dc32667..734bc6930d 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/LocalDataStoreModule.kt @@ -35,6 +35,7 @@ abstract class LocalDataStoreModule { abstract fun localLocationOfInterestStore( store: RoomLocationOfInterestStore ): LocalLocationOfInterestStore + @Binds @Singleton abstract fun offlineAreaStore(store: RoomOfflineAreaStore): LocalOfflineAreaStore @@ -102,5 +103,15 @@ abstract class LocalDataStoreModule { fun userDao(localDatabase: LocalDatabase): UserDao { return localDatabase.userDao() } + + @Provides + fun conditionDao(localDatabase: LocalDatabase): ConditionDao { + return localDatabase.conditionDao() + } + + @Provides + fun expressionDao(localDatabase: LocalDatabase): ExpressionDao { + return localDatabase.expressionDao() + } } } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt index 5e4d34ba10..cbed5da363 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/LocalDatabase.kt @@ -24,9 +24,43 @@ import com.google.android.ground.persistence.local.room.converter.JsonArrayTypeC import com.google.android.ground.persistence.local.room.converter.JsonObjectTypeConverter import com.google.android.ground.persistence.local.room.converter.LoiPropertiesMapConverter import com.google.android.ground.persistence.local.room.converter.StyleTypeConverter -import com.google.android.ground.persistence.local.room.dao.* -import com.google.android.ground.persistence.local.room.entity.* -import com.google.android.ground.persistence.local.room.fields.* +import com.google.android.ground.persistence.local.room.dao.ConditionDao +import com.google.android.ground.persistence.local.room.dao.ExpressionDao +import com.google.android.ground.persistence.local.room.dao.JobDao +import com.google.android.ground.persistence.local.room.dao.LocationOfInterestDao +import com.google.android.ground.persistence.local.room.dao.LocationOfInterestMutationDao +import com.google.android.ground.persistence.local.room.dao.MultipleChoiceDao +import com.google.android.ground.persistence.local.room.dao.OfflineAreaDao +import com.google.android.ground.persistence.local.room.dao.OptionDao +import com.google.android.ground.persistence.local.room.dao.SubmissionDao +import com.google.android.ground.persistence.local.room.dao.SubmissionMutationDao +import com.google.android.ground.persistence.local.room.dao.SurveyDao +import com.google.android.ground.persistence.local.room.dao.TaskDao +import com.google.android.ground.persistence.local.room.dao.TileSourceDao +import com.google.android.ground.persistence.local.room.dao.UserDao +import com.google.android.ground.persistence.local.room.entity.ConditionEntity +import com.google.android.ground.persistence.local.room.entity.ExpressionEntity +import com.google.android.ground.persistence.local.room.entity.JobEntity +import com.google.android.ground.persistence.local.room.entity.LocationOfInterestEntity +import com.google.android.ground.persistence.local.room.entity.LocationOfInterestMutationEntity +import com.google.android.ground.persistence.local.room.entity.MultipleChoiceEntity +import com.google.android.ground.persistence.local.room.entity.OfflineAreaEntity +import com.google.android.ground.persistence.local.room.entity.OptionEntity +import com.google.android.ground.persistence.local.room.entity.SubmissionEntity +import com.google.android.ground.persistence.local.room.entity.SubmissionMutationEntity +import com.google.android.ground.persistence.local.room.entity.SurveyEntity +import com.google.android.ground.persistence.local.room.entity.TaskEntity +import com.google.android.ground.persistence.local.room.entity.TileSourceEntity +import com.google.android.ground.persistence.local.room.entity.UserEntity +import com.google.android.ground.persistence.local.room.fields.EntityState +import com.google.android.ground.persistence.local.room.fields.ExpressionEntityType +import com.google.android.ground.persistence.local.room.fields.MatchEntityType +import com.google.android.ground.persistence.local.room.fields.MultipleChoiceEntityType +import com.google.android.ground.persistence.local.room.fields.MutationEntitySyncStatus +import com.google.android.ground.persistence.local.room.fields.MutationEntityType +import com.google.android.ground.persistence.local.room.fields.OfflineAreaEntityState +import com.google.android.ground.persistence.local.room.fields.TaskEntityType +import com.google.android.ground.persistence.local.room.fields.TileSetEntityState /** * Main entry point to local database API, exposing data access objects (DAOs) for interacting with @@ -49,7 +83,9 @@ import com.google.android.ground.persistence.local.room.fields.* SubmissionEntity::class, SubmissionMutationEntity::class, OfflineAreaEntity::class, - UserEntity::class + UserEntity::class, + ConditionEntity::class, + ExpressionEntity::class, ], version = Config.DB_VERSION, exportSchema = false @@ -57,6 +93,8 @@ import com.google.android.ground.persistence.local.room.fields.* @TypeConverters( TaskEntityType::class, MultipleChoiceEntityType::class, + MatchEntityType::class, + ExpressionEntityType::class, MutationEntityType::class, EntityState::class, GeometryWrapperTypeConverter::class, @@ -81,4 +119,6 @@ abstract class LocalDatabase : RoomDatabase() { abstract fun submissionMutationDao(): SubmissionMutationDao abstract fun offlineAreaDao(): OfflineAreaDao abstract fun userDao(): UserDao + abstract fun conditionDao(): ConditionDao + abstract fun expressionDao(): ExpressionDao } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt index 36fadd4d36..30f84a19f2 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,23 +29,27 @@ import com.google.android.ground.model.mutation.LocationOfInterestMutation import com.google.android.ground.model.mutation.SubmissionMutation import com.google.android.ground.model.submission.Submission import com.google.android.ground.model.submission.SubmissionData +import com.google.android.ground.model.task.Condition +import com.google.android.ground.model.task.Expression import com.google.android.ground.model.task.MultipleChoice import com.google.android.ground.model.task.Option import com.google.android.ground.model.task.Task +import com.google.android.ground.model.task.TaskId import com.google.android.ground.persistence.local.LocalDataConsistencyException import com.google.android.ground.persistence.local.room.entity.* import com.google.android.ground.persistence.local.room.fields.* +import com.google.android.ground.persistence.local.room.relations.ConditionEntityAndRelations import com.google.android.ground.persistence.local.room.relations.JobEntityAndRelations import com.google.android.ground.persistence.local.room.relations.SurveyEntityAndRelations import com.google.android.ground.persistence.local.room.relations.TaskEntityAndRelations import com.google.android.ground.ui.map.Bounds import com.google.common.reflect.TypeToken import com.google.gson.Gson -import java.util.* import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentMap import org.json.JSONObject import timber.log.Timber +import java.util.* fun AuditInfo.toLocalDataStoreObject(): AuditInfoEntity = AuditInfoEntity( @@ -113,14 +117,14 @@ fun JobEntityAndRelations.toModelObject(): Job { style = jobEntity.style?.toModelObject(), name = jobEntity.name, strategy = - jobEntity.strategy.let { - try { - DataCollectionStrategy.valueOf(it) - } catch (e: IllegalArgumentException) { - Timber.e("unknown data collection strategy $it") - DataCollectionStrategy.UNKNOWN - } - }, + jobEntity.strategy.let { + try { + DataCollectionStrategy.valueOf(it) + } catch (e: IllegalArgumentException) { + Timber.e("unknown data collection strategy $it") + DataCollectionStrategy.UNKNOWN + } + }, tasks = taskMap.toPersistentMap() ) } @@ -163,9 +167,9 @@ fun LocationOfInterestEntity.toModelObject(survey: Survey): LocationOfInterest = submissionCount = submissionCount, properties = properties, job = survey.getJob(jobId = jobId) - ?: throw LocalDataConsistencyException( - "Unknown jobId ${this.jobId} in location of interest ${this.id}" - ) + ?: throw LocalDataConsistencyException( + "Unknown jobId ${this.jobId} in location of interest ${this.id}" + ) ) } @@ -421,6 +425,15 @@ fun TaskEntityAndRelations.toModelObject(): Task { multipleChoice = multipleChoiceEntities[0].toModelObject(optionEntities) } + var condition: Condition? = null + + if (conditionEntityAndRelations.isNotEmpty()) { + if (conditionEntityAndRelations.size > 1) { + Timber.e("More than 1 condition found for task") + } + condition = conditionEntityAndRelations[0].toModelObject() + } + return Task( taskEntity.id, taskEntity.index, @@ -428,7 +441,8 @@ fun TaskEntityAndRelations.toModelObject(): Task { taskEntity.label!!, taskEntity.isRequired, multipleChoice, - taskEntity.isAddLoiTask + taskEntity.isAddLoiTask, + condition = condition, ) } @@ -437,3 +451,36 @@ fun User.toLocalDataStoreObject() = fun UserEntity.toModelObject() = User(id = id, email = email, displayName = displayName, photoUrl = photoUrl) + +fun Condition.toLocalDataStoreObject(parentTaskId: TaskId) = + ConditionEntity(parentTaskId = parentTaskId, matchType = MatchEntityType.fromMatchType(matchType)) + +fun ConditionEntityAndRelations.toModelObject(): Condition? { + val expressions: List? + + if (expressionEntities.isEmpty()) { + return null + } else { + expressions = expressionEntities.map { it.toModelObject() } + } + + return Condition( + conditionEntity.matchType.toMatchType(), + expressions = expressions, + ) +} + +fun Expression.toLocalDataStoreObject(parentTaskId: TaskId): ExpressionEntity = + ExpressionEntity( + parentTaskId = parentTaskId, + expressionType = ExpressionEntityType.fromExpressionType(expressionType), + taskId = taskId, + optionIds = optionIds.joinToString(",") + ) + +fun ExpressionEntity.toModelObject(): Expression = + Expression( + expressionType = expressionType.toExpressionType(), + taskId = taskId, + optionIds = optionIds?.split(',')?.toSet() ?: setOf(), + ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt new file mode 100644 index 0000000000..f13db22577 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ConditionDao.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.dao + +import androidx.room.Dao +import com.google.android.ground.persistence.local.room.entity.ConditionEntity + +@Dao +interface ConditionDao : BaseDao diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt new file mode 100644 index 0000000000..7aa5050bc2 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/ExpressionDao.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.dao + +import androidx.room.Dao +import com.google.android.ground.persistence.local.room.entity.ExpressionEntity + +@Dao +interface ExpressionDao : BaseDao diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt new file mode 100644 index 0000000000..79e0d4240d --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ConditionEntity.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.android.ground.persistence.local.room.fields.MatchEntityType + +@Entity( + tableName = "condition", + foreignKeys = + [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["parent_task_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("parent_task_id")] +) +data class ConditionEntity( + @ColumnInfo(name = "parent_task_id") @PrimaryKey val parentTaskId: String, + @ColumnInfo(name = "match_type") val matchType: MatchEntityType, +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt new file mode 100644 index 0000000000..1611b234f2 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/ExpressionEntity.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.android.ground.persistence.local.room.fields.ExpressionEntityType + +@Entity( + tableName = "expression", + foreignKeys = + [ + ForeignKey( + entity = ConditionEntity::class, + parentColumns = ["parent_task_id"], + childColumns = ["parent_task_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("parent_task_id")] +) +data class ExpressionEntity( + @ColumnInfo(name = "parent_task_id") @PrimaryKey val parentTaskId: String, + @ColumnInfo(name = "task_id") val taskId: String, + @ColumnInfo(name = "expression_type") val expressionType: ExpressionEntityType, + // CSV encoded string. + @ColumnInfo(name = "option_ids") val optionIds: String?, +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt index f2c61bb848..d55e29681f 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/TaskEntity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 Google LLC + * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,11 @@ */ package com.google.android.ground.persistence.local.room.entity -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey import com.google.android.ground.persistence.local.room.fields.TaskEntityType @Entity( @@ -38,5 +42,5 @@ data class TaskEntity( @ColumnInfo(name = "label") val label: String?, @ColumnInfo(name = "is_required") val isRequired: Boolean, @ColumnInfo(name = "job_id") val jobId: String?, - @ColumnInfo(name = "is_add_loi_task") val isAddLoiTask: Boolean + @ColumnInfo(name = "is_add_loi_task") val isAddLoiTask: Boolean, ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt new file mode 100644 index 0000000000..38448ce9d3 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/ExpressionEntityType.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.fields + +import androidx.room.TypeConverter +import com.google.android.ground.model.task.Expression +import com.google.android.ground.persistence.local.room.IntEnum + +enum class ExpressionEntityType(private val intValue: Int) : IntEnum { + UNKNOWN(0), + ANY_OF_SELECTED(1), + ALL_OF_SELECTED(2), + ONE_OF_SELECTED(3); + + override fun intValue(): Int = intValue + + fun toExpressionType(): Expression.ExpressionType = + EXPRESSION_TYPES.getOrDefault(this, Expression.ExpressionType.UNKNOWN) + + companion object { + private val EXPRESSION_TYPES: Map = + mapOf( + Pair(UNKNOWN, Expression.ExpressionType.UNKNOWN), + Pair(ANY_OF_SELECTED, Expression.ExpressionType.ANY_OF_SELECTED), + Pair(ALL_OF_SELECTED, Expression.ExpressionType.ALL_OF_SELECTED), + Pair(ONE_OF_SELECTED, Expression.ExpressionType.ONE_OF_SELECTED), + ) + private val REVERSE_EXPRESSION_TYPES: Map = + EXPRESSION_TYPES.entries.associateBy({ it.value }) { it.key } + + fun fromExpressionType(type: Expression.ExpressionType): ExpressionEntityType = + REVERSE_EXPRESSION_TYPES.getOrDefault(type, UNKNOWN) + + @JvmStatic + @TypeConverter + fun toInt(value: ExpressionEntityType?): Int = IntEnum.toInt(value, UNKNOWN) + + @JvmStatic + @TypeConverter + fun fromInt(intValue: Int): ExpressionEntityType = IntEnum.fromInt(values(), intValue, UNKNOWN) + } +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt new file mode 100644 index 0000000000..69042f4fee --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/fields/MatchEntityType.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.fields + +import androidx.room.TypeConverter +import com.google.android.ground.model.task.Condition +import com.google.android.ground.persistence.local.room.IntEnum + +enum class MatchEntityType(private val intValue: Int) : IntEnum { + UNKNOWN(0), + MATCH_ANY(1), + MATCH_ALL(2), + MATCH_ONE(3); + + override fun intValue(): Int = intValue + + fun toMatchType(): Condition.MatchType = + MATCH_TYPES.getOrDefault(this, Condition.MatchType.UNKNOWN) + + companion object { + private val MATCH_TYPES: Map = + mapOf( + Pair(UNKNOWN, Condition.MatchType.UNKNOWN), + Pair(MATCH_ANY, Condition.MatchType.MATCH_ANY), + Pair(MATCH_ALL, Condition.MatchType.MATCH_ALL), + Pair(MATCH_ONE, Condition.MatchType.MATCH_ONE), + ) + private val REVERSE_MATCH_TYPES: Map = + MATCH_TYPES.entries.associateBy({ it.value }) { it.key } + + fun fromMatchType(type: Condition.MatchType): MatchEntityType = + REVERSE_MATCH_TYPES.getOrDefault(type, UNKNOWN) + + @JvmStatic + @TypeConverter + fun toInt(value: MatchEntityType?): Int = IntEnum.toInt(value, UNKNOWN) + + @JvmStatic + @TypeConverter + fun fromInt(intValue: Int): MatchEntityType = IntEnum.fromInt(values(), intValue, UNKNOWN) + } +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt new file mode 100644 index 0000000000..8571b79c8c --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/ConditionEntityAndRelations.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.ground.persistence.local.room.relations + +import androidx.room.Embedded +import androidx.room.Relation +import com.google.android.ground.persistence.local.room.entity.ConditionEntity +import com.google.android.ground.persistence.local.room.entity.ExpressionEntity + +/** + * Represents relationship between TaskEntity, MultipleChoiceEntity, OptionEntity, and + * ConditionEntity. + * + * Querying any of the below data classes automatically loads the task annotated as @Relation. + */ +data class ConditionEntityAndRelations( + @Embedded val conditionEntity: ConditionEntity, + @Relation( + parentColumn = "parent_task_id", + entityColumn = "parent_task_id", + entity = ExpressionEntity::class + ) + val expressionEntities: List +) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt index d671e10c22..c991a02339 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/relations/TaskEntityAndRelations.kt @@ -17,12 +17,14 @@ package com.google.android.ground.persistence.local.room.relations import androidx.room.Embedded import androidx.room.Relation +import com.google.android.ground.persistence.local.room.entity.ConditionEntity import com.google.android.ground.persistence.local.room.entity.MultipleChoiceEntity import com.google.android.ground.persistence.local.room.entity.OptionEntity import com.google.android.ground.persistence.local.room.entity.TaskEntity /** - * Represents relationship between TaskEntity, MultipleChoiceEntity, and OptionEntity. + * Represents relationship between TaskEntity, MultipleChoiceEntity, OptionEntity, and + * ConditionEntity. * * Querying any of the below data classes automatically loads the task annotated as @Relation. */ @@ -31,5 +33,7 @@ data class TaskEntityAndRelations( @Relation(parentColumn = "id", entityColumn = "task_id") val multipleChoiceEntities: List, @Relation(parentColumn = "id", entityColumn = "task_id", entity = OptionEntity::class) - val optionEntities: List + val optionEntities: List, + @Relation(parentColumn = "id", entityColumn = "parent_task_id", entity = ConditionEntity::class) + val conditionEntityAndRelations: List ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt index e9386d6561..020da53fef 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomSurveyStore.kt @@ -17,11 +17,14 @@ package com.google.android.ground.persistence.local.room.stores import com.google.android.ground.model.Survey import com.google.android.ground.model.job.Job +import com.google.android.ground.model.task.Condition import com.google.android.ground.model.task.MultipleChoice import com.google.android.ground.model.task.Option import com.google.android.ground.model.task.Task import com.google.android.ground.persistence.local.room.converter.toLocalDataStoreObject import com.google.android.ground.persistence.local.room.converter.toModelObject +import com.google.android.ground.persistence.local.room.dao.ConditionDao +import com.google.android.ground.persistence.local.room.dao.ExpressionDao import com.google.android.ground.persistence.local.room.dao.JobDao import com.google.android.ground.persistence.local.room.dao.MultipleChoiceDao import com.google.android.ground.persistence.local.room.dao.OptionDao @@ -30,20 +33,37 @@ import com.google.android.ground.persistence.local.room.dao.TaskDao import com.google.android.ground.persistence.local.room.dao.TileSourceDao import com.google.android.ground.persistence.local.room.dao.insertOrUpdate import com.google.android.ground.persistence.local.stores.LocalSurveyStore -import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton /** Manages access to [Survey] objects persisted in local storage. */ @Singleton class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { - @Inject lateinit var optionDao: OptionDao - @Inject lateinit var multipleChoiceDao: MultipleChoiceDao - @Inject lateinit var taskDao: TaskDao - @Inject lateinit var jobDao: JobDao - @Inject lateinit var surveyDao: SurveyDao - @Inject lateinit var tileSourceDao: TileSourceDao + @Inject + lateinit var optionDao: OptionDao + + @Inject + lateinit var multipleChoiceDao: MultipleChoiceDao + + @Inject + lateinit var taskDao: TaskDao + + @Inject + lateinit var jobDao: JobDao + + @Inject + lateinit var surveyDao: SurveyDao + + @Inject + lateinit var tileSourceDao: TileSourceDao + + @Inject + lateinit var conditionDao: ConditionDao + + @Inject + lateinit var expressionDao: ExpressionDao override val surveys: Flow> get() = surveyDao.getAll().map { surveyEntities -> surveyEntities.map { it.toModelObject() } } @@ -80,6 +100,13 @@ class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { options.forEach { insertOrUpdateOption(taskId, it) } } + private suspend fun insertOrUpdateCondition(taskId: String, condition: Condition) { + conditionDao.insertOrUpdate(condition.toLocalDataStoreObject(parentTaskId = taskId)) + condition.expressions.forEach { + expressionDao.insertOrUpdate(it.toLocalDataStoreObject(parentTaskId = taskId)) + } + } + private suspend fun insertOrUpdateMultipleChoice(taskId: String, multipleChoice: MultipleChoice) { multipleChoiceDao.insertOrUpdate(multipleChoice.toLocalDataStoreObject(taskId)) insertOrUpdateOptions(taskId, multipleChoice.options) @@ -90,6 +117,9 @@ class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { if (task.multipleChoice != null) { insertOrUpdateMultipleChoice(task.id, task.multipleChoice) } + if (task.condition != null) { + insertOrUpdateCondition(task.id, task.condition) + } } private suspend fun insertOrUpdateTasks(jobId: String, tasks: Collection) = diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt index 89a066f5a1..6250acaff7 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/ConditionConverter.kt @@ -22,6 +22,8 @@ import timber.log.Timber /** Converts between Firestore nested objects and [Condition] instances. */ internal object ConditionConverter { + private val T.exhaustive: T + get() = this fun toCondition(em: ConditionNestedObject): Condition? { val matchType = toMatchType(em.matchType) @@ -53,11 +55,14 @@ internal object ConditionConverter { } else if (it.taskId == null) { Timber.e("Empty task ID encountered, skipping expression.") null + } else if (it.optionIds == null) { + Timber.e("Empty option IDs encountered, skipping expression.") + null } else { Expression( expressionType = expressionType, taskId = it.taskId, - optionIds = it.optionIds?.toSet() ?: setOf(), + optionIds = it.optionIds.toSet(), ) } } @@ -69,7 +74,4 @@ internal object ConditionConverter { "ONE_OF_SELECTED" -> Expression.ExpressionType.ONE_OF_SELECTED else -> Expression.ExpressionType.UNKNOWN }.exhaustive - - private val T.exhaustive: T - get() = this } diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt index d92db4a2ec..7cc1014ea3 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionFragment.kt @@ -77,17 +77,20 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { loadTasks(viewModel.tasks) lifecycleScope.launch { viewModel.currentTaskId.collect { onTaskChanged() } } - viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - - val buttonContainer = view.findViewById(R.id.action_buttons) ?: return - val anchorLocation = IntArray(2) - buttonContainer.getLocationInWindow(anchorLocation) - val guidelineTop = anchorLocation[1] - buttonContainer.rootWindowInsets.systemWindowInsetTop - guideline.setGuidelineBegin(guidelineTop) + viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + + val buttonContainer = view.findViewById(R.id.action_buttons) ?: return + val anchorLocation = IntArray(2) + buttonContainer.getLocationInWindow(anchorLocation) + val guidelineTop = + anchorLocation[1] - buttonContainer.rootWindowInsets.systemWindowInsetTop + guideline.setGuidelineBegin(guidelineTop) + } } - }) + ) } private fun loadTasks(tasks: List) { @@ -116,15 +119,16 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { progressAnimator.start() } - override fun onBack(): Boolean = if (viewPager.currentItem == 0) { - // If the user is currently looking at the first step, allow the system to handle the - // Back button. This calls finish() on this activity and pops the back stack. - false - } else { - // Otherwise, select the previous step. - viewModel.step(-1) - true - } + override fun onBack(): Boolean = + if (viewPager.currentItem == 0) { + // If the user is currently looking at the first step, allow the system to handle the + // Back button. This calls finish() on this activity and pops the back stack. + false + } else { + // Otherwise, select the previous step. + viewModel.step(-1) + true + } private companion object { private const val PROGRESS_SCALE = 100 diff --git a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt index fb70e9d8f7..849bf91607 100644 --- a/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/datacollection/DataCollectionViewModel.kt @@ -66,7 +66,8 @@ import kotlin.collections.set /** View model for the Data Collection fragment. */ @HiltViewModel class DataCollectionViewModel -@Inject internal constructor( +@Inject +internal constructor( private val viewModelFactory: ViewModelFactory, private val locationOfInterestHelper: LocationOfInterestHelper, private val popups: Provider, @@ -97,12 +98,15 @@ class DataCollectionViewModel val jobName: StateFlow = MutableStateFlow(job.name ?: "").stateIn(viewModelScope, SharingStarted.Lazily, "") - val loiName: StateFlow = (if (loiId == null) flowOf("") - else flow { - val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) - val label = locationOfInterestHelper.getLabel(loi) - emit(label) - }).stateIn(viewModelScope, SharingStarted.Lazily, "") + val loiName: StateFlow = + (if (loiId == null) flowOf("") + else + flow { + val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) + val label = locationOfInterestHelper.getLabel(loi) + emit(label) + }) + .stateIn(viewModelScope, SharingStarted.Lazily, "") private val taskViewModels: MutableStateFlow> = MutableStateFlow(mutableListOf()) @@ -176,7 +180,8 @@ class DataCollectionViewModel // Move to home screen and display a confirmation dialog after that. navigator.navigate(HomeScreenFragmentDirections.showHomeScreen()) navigator.navigate( - DataSubmissionConfirmationDialogFragmentDirections.showSubmissionConfirmationDialogFragment() + DataSubmissionConfirmationDialogFragmentDirections + .showSubmissionConfirmationDialogFragment() ) } } @@ -194,31 +199,29 @@ class DataCollectionViewModel } /** - * Retrieves the current task sequence given the inputs and conditions set on the tasks. - * Setting a start ID will always generate a sequence with the start ID as the first element, and - * if preceding is set, will generate the previous tasks from there. + * Retrieves the current task sequence given the inputs and conditions set on the tasks. Setting a + * start ID will always generate a sequence with the start ID as the first element, and if + * preceding is set, will generate the previous tasks from there. */ private fun getTaskSequence(startId: String? = null, preceding: Boolean = false): Sequence { val startIndex = tasks.indexOf(tasks.first { it.id == (startId ?: tasks[0].id) }) return if (preceding) { - tasks.subList(0, startIndex + 1) - .reversed() + tasks.subList(0, startIndex + 1).reversed() } else { tasks.subList(startIndex, tasks.size) - }.let { tasks -> - tasks.asSequence().filter { - it.condition == null || evaluateCondition(it.condition) - } } + .let { tasks -> + tasks.asSequence().filter { it.condition == null || evaluateCondition(it.condition) } + } } /** Displays the task at the relative position to the current one. Supports negative steps. */ fun step(stepCount: Int) { val reverse = stepCount < 0 - val task = getTaskSequence( - startId = currentTaskId.value, - preceding = reverse - ).take(Math.abs(stepCount) + 1).last() + val task = + getTaskSequence(startId = currentTaskId.value, preceding = reverse) + .take(Math.abs(stepCount) + 1) + .last() savedStateHandle[TASK_POSITION_ID] = task.id } @@ -231,32 +234,36 @@ class DataCollectionViewModel } == getTaskSequence().last().id /** Evaluates the task condition against the current inputs. */ - private fun evaluateCondition(condition: Condition): Boolean = condition.fulfilledBy( - data.mapNotNull { (task, value) -> - if (value is MultipleChoiceResponse) { - task.id to value.selectedOptionIds.toSet() - } else { - null - } - }.toMap() - ) + private fun evaluateCondition(condition: Condition): Boolean = + condition.fulfilledBy( + data + .mapNotNull { (task, value) -> + if (value is MultipleChoiceResponse) { + task.id to value.selectedOptionIds.toSet() + } else { + null + } + } + .toMap() + ) companion object { private const val TASK_JOB_ID_KEY = "jobId" private const val TASK_LOI_ID_KEY = "locationOfInterestId" private const val TASK_POSITION_ID = "currentTaskId" - fun getViewModelClass(taskType: Task.Type): Class = when (taskType) { - Task.Type.TEXT -> TextTaskViewModel::class.java - Task.Type.MULTIPLE_CHOICE -> MultipleChoiceTaskViewModel::class.java - Task.Type.PHOTO -> PhotoTaskViewModel::class.java - Task.Type.NUMBER -> NumberTaskViewModel::class.java - Task.Type.DATE -> DateTaskViewModel::class.java - Task.Type.TIME -> TimeTaskViewModel::class.java - Task.Type.DROP_PIN -> DropPinTaskViewModel::class.java - Task.Type.DRAW_AREA -> DrawAreaTaskViewModel::class.java - Task.Type.CAPTURE_LOCATION -> CaptureLocationTaskViewModel::class.java - Task.Type.UNKNOWN -> throw IllegalArgumentException("Unsupported task type: $taskType") - } + fun getViewModelClass(taskType: Task.Type): Class = + when (taskType) { + Task.Type.TEXT -> TextTaskViewModel::class.java + Task.Type.MULTIPLE_CHOICE -> MultipleChoiceTaskViewModel::class.java + Task.Type.PHOTO -> PhotoTaskViewModel::class.java + Task.Type.NUMBER -> NumberTaskViewModel::class.java + Task.Type.DATE -> DateTaskViewModel::class.java + Task.Type.TIME -> TimeTaskViewModel::class.java + Task.Type.DROP_PIN -> DropPinTaskViewModel::class.java + Task.Type.DRAW_AREA -> DrawAreaTaskViewModel::class.java + Task.Type.CAPTURE_LOCATION -> CaptureLocationTaskViewModel::class.java + Task.Type.UNKNOWN -> throw IllegalArgumentException("Unsupported task type: $taskType") + } } } diff --git a/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt b/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt index cc5cd94924..47328ea14f 100644 --- a/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt +++ b/ground/src/test/java/com/google/android/ground/model/task/ConditionTest.kt @@ -187,25 +187,25 @@ class ConditionTest { } private fun ConditionTestCase.test(condition: Condition) { - this.forEachIndexed { index, it -> + this.forEachIndexed { index, testCase -> val message = - "Type ${condition.matchType}, case $index: Fulfilled should be ${it.first} with ${it.second}" - if (it.first) { - assertTrue(condition.fulfilledBy(it.second), message) + "Type ${condition.matchType}, case $index: Fulfilled should be ${testCase.first} with ${testCase.second}" + if (testCase.first) { + assertTrue(condition.fulfilledBy(testCase.second), message) } else { - assertFalse(condition.fulfilledBy(it.second), message) + assertFalse(condition.fulfilledBy(testCase.second), message) } } } private fun ExpressionTestCase.test(expression: Expression) { - this.forEachIndexed { index, it -> + this.forEachIndexed { index, testCase -> val message = - "Type ${expression.expressionType}, case $index: Fulfilled should be ${it.first} with ${it.second}" - if (it.first) { - assertTrue(expression.fulfilledBy(mapOf(it.second)), message) + "Type ${expression.expressionType}, case $index: Fulfilled should be ${testCase.first} with ${testCase.second}" + if (testCase.first) { + assertTrue(expression.fulfilledBy(mapOf(testCase.second)), message) } else { - assertFalse(expression.fulfilledBy(mapOf(it.second)), message) + assertFalse(expression.fulfilledBy(mapOf(testCase.second)), message) } } }