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 10f45bb180..859191cc1d 100644 --- a/ground/src/main/java/com/google/android/ground/Config.kt +++ b/ground/src/main/java/com/google/android/ground/Config.kt @@ -26,12 +26,15 @@ 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. const val FIRESTORE_LOGGING_ENABLED = true + // Tasks. + const val LOI_TASK_ID_PREFIX = "\$addLoi" + // Photos const val PHOTO_EXT = ".jpg" diff --git a/ground/src/main/java/com/google/android/ground/model/job/Job.kt b/ground/src/main/java/com/google/android/ground/model/job/Job.kt index 08458b9e55..a7ad1cee28 100644 --- a/ground/src/main/java/com/google/android/ground/model/job/Job.kt +++ b/ground/src/main/java/com/google/android/ground/model/job/Job.kt @@ -36,7 +36,7 @@ data class Job( class TaskNotFoundException(taskId: String) : Throwable(message = "unknown task $taskId") val canDataCollectorsAddLois: Boolean - get() = strategy != DataCollectionStrategy.PREDEFINED + get() = strategy != DataCollectionStrategy.PREDEFINED && getAddLoiTask() != null val tasksSorted: List get() = tasks.values.sortedBy { it.index } @@ -48,7 +48,7 @@ data class Job( /** Job must contain at-most 1 `AddLoiTask`. */ fun getAddLoiTask(): Task? = tasks.values - .filter { it.isAddLoiTask } + .filter { it.isAddLoiTask() } .apply { check(size <= 1) { "Expected 0 or 1, found $size AddLoiTasks" } } .firstOrNull() diff --git a/ground/src/main/java/com/google/android/ground/model/task/Task.kt b/ground/src/main/java/com/google/android/ground/model/task/Task.kt index 98d8cfd0c5..5067a2f00e 100644 --- a/ground/src/main/java/com/google/android/ground/model/task/Task.kt +++ b/ground/src/main/java/com/google/android/ground/model/task/Task.kt @@ -15,6 +15,8 @@ */ package com.google.android.ground.model.task +import com.google.android.ground.Config.LOI_TASK_ID_PREFIX + /** * Describes a user-defined task. * @@ -31,7 +33,6 @@ constructor( val label: String, val isRequired: Boolean, val multipleChoice: MultipleChoice? = null, - val isAddLoiTask: Boolean = false ) { /** @@ -50,4 +51,6 @@ constructor( DRAW_AREA, CAPTURE_LOCATION } + + fun isAddLoiTask(): Boolean = id.startsWith(LOI_TASK_ID_PREFIX) } 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 a4a308d2d0..0644037d3b 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 @@ -41,11 +41,11 @@ import com.google.android.ground.persistence.local.room.relations.TaskEntityAndR 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 +113,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,10 +163,10 @@ 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}" - ) + survey.getJob(jobId = jobId) + ?: throw LocalDataConsistencyException( + "Unknown jobId ${this.jobId} in location of interest ${this.id}" + ) ) } @@ -236,8 +236,8 @@ fun MultipleChoiceEntity.toModelObject(optionEntities: List): Mult return MultipleChoice(options.toPersistentList(), this.type.toCardinality()) } -fun MultipleChoice.toLocalDataStoreObject(taskId: String): MultipleChoiceEntity = - MultipleChoiceEntity(taskId, MultipleChoiceEntityType.fromCardinality(this.cardinality)) +fun MultipleChoice.toLocalDataStoreObject(taskId: String, jobId: String): MultipleChoiceEntity = + MultipleChoiceEntity(taskId, jobId, MultipleChoiceEntityType.fromCardinality(this.cardinality)) private fun OfflineAreaEntityState.toModelObject() = when (this) { @@ -282,8 +282,8 @@ fun OfflineAreaEntity.toModelObject(): OfflineArea { ) } -fun Option.toLocalDataStoreObject(taskId: String) = - OptionEntity(id = this.id, code = this.code, label = this.label, taskId = taskId) +fun Option.toLocalDataStoreObject(taskId: String, jobId: String) = + OptionEntity(id = this.id, code = this.code, label = this.label, taskId = taskId, jobId = jobId) fun OptionEntity.toModelObject() = Option(id = this.id, code = this.code, label = this.label) @@ -400,7 +400,7 @@ fun Survey.toLocalDataStoreObject() = acl = JSONObject(acl as Map<*, *>) ) -fun Task.toLocalDataStoreObject(jobId: String?) = +fun Task.toLocalDataStoreObject(jobId: String) = TaskEntity( id = id, jobId = jobId, @@ -408,7 +408,8 @@ fun Task.toLocalDataStoreObject(jobId: String?) = label = label, isRequired = isRequired, taskType = TaskEntityType.fromTaskType(type), - isAddLoiTask = isAddLoiTask + // Deprecated. + isAddLoiTask = false, ) fun TaskEntityAndRelations.toModelObject(): Task { @@ -429,7 +430,6 @@ fun TaskEntityAndRelations.toModelObject(): Task { taskEntity.label!!, taskEntity.isRequired, multipleChoice, - taskEntity.isAddLoiTask ) } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/MultipleChoiceEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/MultipleChoiceEntity.kt index f4e2b32ac4..89fe497406 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/MultipleChoiceEntity.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/MultipleChoiceEntity.kt @@ -15,23 +15,28 @@ */ 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 com.google.android.ground.persistence.local.room.fields.MultipleChoiceEntityType @Entity( tableName = "multiple_choice", foreignKeys = - [ - ForeignKey( - entity = TaskEntity::class, - parentColumns = ["id"], - childColumns = ["task_id"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("task_id")] + [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id", "job_id"], + childColumns = ["task_id", "job_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("task_id"), Index("job_id")], + primaryKeys = ["task_id", "job_id"], ) data class MultipleChoiceEntity( - @ColumnInfo(name = "task_id") @PrimaryKey val taskId: String, + @ColumnInfo(name = "task_id") val taskId: String, + @ColumnInfo(name = "job_id") val jobId: String, @ColumnInfo(name = "type") val type: MultipleChoiceEntityType ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OptionEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OptionEntity.kt index e5f8304411..4026b50cee 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OptionEntity.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OptionEntity.kt @@ -15,25 +15,29 @@ */ 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 @Entity( tableName = "option", foreignKeys = - [ - ForeignKey( - entity = TaskEntity::class, - parentColumns = ["id"], - childColumns = ["task_id"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("task_id")], - primaryKeys = ["id"] + [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id", "job_id"], + childColumns = ["task_id", "job_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("task_id"), Index("job_id")], + primaryKeys = ["task_id", "job_id"], ) data class OptionEntity( @ColumnInfo(name = "id") val id: String, @ColumnInfo(name = "code") val code: String, @ColumnInfo(name = "label") val label: String, - @ColumnInfo(name = "task_id") val taskId: String + @ColumnInfo(name = "task_id") val taskId: String, + @ColumnInfo(name = "job_id") val jobId: 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..4a065a75af 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 @@ -15,28 +15,33 @@ */ 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 com.google.android.ground.persistence.local.room.fields.TaskEntityType @Entity( tableName = "task", foreignKeys = - [ - ForeignKey( - entity = JobEntity::class, - parentColumns = ["id"], - childColumns = ["job_id"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("job_id")] + [ + ForeignKey( + entity = JobEntity::class, + parentColumns = ["id"], + childColumns = ["job_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("id"), Index("job_id")], + primaryKeys = ["id", "job_id"], ) data class TaskEntity( - @ColumnInfo(name = "id") @PrimaryKey val id: String, + @ColumnInfo(name = "id") val id: String, @ColumnInfo(name = "index") val index: Int, @ColumnInfo(name = "task_type") val taskType: TaskEntityType, @ColumnInfo(name = "label") val label: String?, @ColumnInfo(name = "is_required") val isRequired: Boolean, - @ColumnInfo(name = "job_id") val jobId: String?, + @ColumnInfo(name = "job_id") val jobId: String, + // Deprecated. @ColumnInfo(name = "is_add_loi_task") val isAddLoiTask: Boolean ) 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..55fcfab1f6 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 @@ -30,20 +30,31 @@ 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 override val surveys: Flow> get() = surveyDao.getAll().map { surveyEntities -> surveyEntities.map { it.toModelObject() } } @@ -73,22 +84,26 @@ class RoomSurveyStore @Inject internal constructor() : LocalSurveyStore { override suspend fun deleteSurvey(survey: Survey) = surveyDao.delete(survey.toLocalDataStoreObject()) - private suspend fun insertOrUpdateOption(taskId: String, option: Option) = - optionDao.insertOrUpdate(option.toLocalDataStoreObject(taskId)) + private suspend fun insertOrUpdateOption(taskId: String, jobId: String, option: Option) = + optionDao.insertOrUpdate(option.toLocalDataStoreObject(taskId, jobId)) - private suspend fun insertOrUpdateOptions(taskId: String, options: List