diff --git a/ground/build.gradle b/ground/build.gradle index 0de593a435..648192e903 100644 --- a/ground/build.gradle +++ b/ground/build.gradle @@ -349,7 +349,7 @@ dependencies { api("com.google.protobuf:protobuf-kotlin-lite:4.26.1") // Pulls protodefs from the specified commit in the ground-platform repo. - groundProtoJar "com.github.google:ground-platform:7420132@jar" + groundProtoJar "com.github.google:ground-platform:3e9162a@jar" } protobuf { diff --git a/ground/src/main/java/com/google/android/ground/model/submission/MultipleChoiceTaskData.kt b/ground/src/main/java/com/google/android/ground/model/submission/MultipleChoiceTaskData.kt index 07e1f03bdd..6f954abffc 100644 --- a/ground/src/main/java/com/google/android/ground/model/submission/MultipleChoiceTaskData.kt +++ b/ground/src/main/java/com/google/android/ground/model/submission/MultipleChoiceTaskData.kt @@ -26,6 +26,14 @@ class MultipleChoiceTaskData( val selectedOptionIds: List, ) : TaskData { + // TODO: Reuse the key value here and in the view model + fun hasOtherText(): Boolean = selectedOptionIds.contains("OTHER_ID") + + fun getOtherText(): String { + val otherId = selectedOptionIds.firstOrNull { it == "OTHER_ID" } ?: return "" + return multipleChoice?.getOptionById(otherId)?.label ?: "" + } + // TODO: Make these inner classes non-static and access Task directly. override fun getDetailsText(): String = selectedOptionIds diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ValueJsonConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ValueJsonConverter.kt index 6f8edfa49d..7dbb1ed264 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ValueJsonConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ValueJsonConverter.kt @@ -83,6 +83,7 @@ internal object ValueJsonConverter { private fun toJsonArray(response: MultipleChoiceTaskData): JSONArray = JSONArray().apply { response.selectedOptionIds.forEach { this.put(it) } } + // TODO: Replace with proto conversion logic if this is still necessary fun toResponse(task: Task, obj: Any): TaskData? { if (JSONObject.NULL === obj) { return null diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExt.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExt.kt index 8c2372292b..9ee9f873a0 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExt.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExt.kt @@ -26,21 +26,74 @@ import com.google.android.ground.model.geometry.Polygon import com.google.android.ground.model.locationofinterest.LoiProperties 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.CaptureLocationTaskData +import com.google.android.ground.model.submission.DateTaskData +import com.google.android.ground.model.submission.GeometryTaskData +import com.google.android.ground.model.submission.MultipleChoiceTaskData +import com.google.android.ground.model.submission.NumberTaskData +import com.google.android.ground.model.submission.PhotoTaskData +import com.google.android.ground.model.submission.TextTaskData +import com.google.android.ground.model.submission.TimeTaskData +import com.google.android.ground.model.submission.ValueDelta +import com.google.android.ground.model.submission.isNotNullOrEmpty +import com.google.android.ground.model.task.Task import com.google.android.ground.proto.LinearRing as LinearRingProto import com.google.android.ground.proto.LocationOfInterest.Property import com.google.android.ground.proto.LocationOfInterest.Source import com.google.android.ground.proto.LocationOfInterestKt.property import com.google.android.ground.proto.MultiPolygon as MultiPolygonProto import com.google.android.ground.proto.Polygon as PolygonProto +import com.google.android.ground.proto.TaskDataKt.captureLocationResult +import com.google.android.ground.proto.TaskDataKt.dateTimeResponse +import com.google.android.ground.proto.TaskDataKt.drawGeometryResult +import com.google.android.ground.proto.TaskDataKt.multipleChoiceResponses +import com.google.android.ground.proto.TaskDataKt.numberResponse +import com.google.android.ground.proto.TaskDataKt.takePhotoResult +import com.google.android.ground.proto.TaskDataKt.textResponse import com.google.android.ground.proto.auditInfo import com.google.android.ground.proto.coordinates import com.google.android.ground.proto.geometry import com.google.android.ground.proto.locationOfInterest import com.google.android.ground.proto.point +import com.google.android.ground.proto.submission +import com.google.android.ground.proto.taskData import com.google.protobuf.timestamp import java.util.Date import kotlinx.collections.immutable.toImmutableMap +// TODO: Add test coverage +fun SubmissionMutation.createSubmissionMessage(user: User) = submission { + assert(userId == user.id) { "UserId doesn't match: expected $userId, found ${user.id}" } + + val me = this@createSubmissionMessage + id = submissionId + loiId = locationOfInterestId + jobId = job.id + ownerId = me.userId + + deltas.forEach { + if (it.newTaskData.isNotNullOrEmpty()) { + taskData.add(it.toMessage()) + } + } + + val auditInfo = createAuditInfoMessage(user, clientTimestamp) + when (type) { + Mutation.Type.CREATE -> { + created = auditInfo + lastModified = auditInfo + } + Mutation.Type.UPDATE -> { + lastModified = auditInfo + } + Mutation.Type.DELETE, + Mutation.Type.UNKNOWN -> { + throw UnsupportedOperationException() + } + } +} + fun LocationOfInterestMutation.createLoiMessage(user: User) = locationOfInterest { assert(userId == user.id) { "UserId doesn't match: expected $userId, found ${user.id}" } @@ -75,6 +128,50 @@ fun LocationOfInterestMutation.createLoiMessage(user: User) = locationOfInterest } } +private fun ValueDelta.toMessage() = taskData { + val me = this@toMessage + // TODO: What should be the ID? + taskId = me.taskId + // TODO: Add "skipped" field + when (taskType) { + Task.Type.TEXT -> textResponse = textResponse { text = (newTaskData as TextTaskData).text } + Task.Type.NUMBER -> numberResponse = numberResponse { + number = (newTaskData as NumberTaskData).value + } + // TODO: Ensure the dates are always converted to UTC time zone. + Task.Type.DATE -> dateTimeResponse = dateTimeResponse { + dateTime = timestamp { seconds = (newTaskData as DateTaskData).date.time * 1000 } + } + // TODO: Ensure the dates are always converted to UTC time zone. + Task.Type.TIME -> dateTimeResponse = dateTimeResponse { + dateTime = timestamp { seconds = (newTaskData as TimeTaskData).time.time * 1000 } + } + Task.Type.MULTIPLE_CHOICE -> multipleChoiceResponses = multipleChoiceResponses { + (newTaskData as MultipleChoiceTaskData).selectedOptionIds.forEach { + selectedOptionIds.add(it) + } + if (newTaskData.hasOtherText()) { + otherText = newTaskData.getOtherText() + } + } + Task.Type.DROP_PIN, + Task.Type.DRAW_AREA -> drawGeometryResult = drawGeometryResult { + geometry = (newTaskData as GeometryTaskData).geometry.toMessage() + } + Task.Type.CAPTURE_LOCATION -> captureLocationResult = captureLocationResult { + val data = newTaskData as CaptureLocationTaskData + data.altitude?.let { altitude = it } + data.accuracy?.let { accuracy = it } + coordinates = data.location.coordinates.toMessage() + // TODO: Add timestamp + } + Task.Type.PHOTO -> takePhotoResult = takePhotoResult { + photoPath = (newTaskData as PhotoTaskData).path + } + Task.Type.UNKNOWN -> error("Unknown task type") + } +} + private fun createAuditInfoMessage(user: User, timestamp: Date) = auditInfo { userId = user.id displayName = user.displayName diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/AuditInfoConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/AuditInfoConverter.kt index 846171d091..ee21180dc1 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/AuditInfoConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/AuditInfoConverter.kt @@ -17,10 +17,7 @@ package com.google.android.ground.persistence.remote.firebase.schema import com.google.android.ground.model.AuditInfo -import com.google.android.ground.model.User -import com.google.android.ground.model.mutation.Mutation import com.google.android.ground.persistence.remote.DataStoreException -import com.google.firebase.Timestamp /** Converts between Firestore nested objects and [AuditInfo] instances. */ internal object AuditInfoConverter { @@ -34,12 +31,4 @@ internal object AuditInfoConverter { doc.serverTimestamp?.toDate(), ) } - - @JvmStatic - fun fromMutationAndUser(mutation: Mutation, user: User): AuditInfoNestedObject = - AuditInfoNestedObject( - UserConverter.toNestedObject(user), - Timestamp(mutation.clientTimestamp), - null, - ) } diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/CaptureLocationResultConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/CaptureLocationResultConverter.kt index 1d2ecba870..be7dabec4e 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/CaptureLocationResultConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/CaptureLocationResultConverter.kt @@ -15,7 +15,6 @@ */ package com.google.android.ground.persistence.remote.firebase.schema -import com.google.android.ground.model.geometry.Point import com.google.android.ground.model.submission.CaptureLocationTaskData /** Converts between [CaptureLocationTaskData] and its equivalent remote representation. */ @@ -23,21 +22,4 @@ object CaptureLocationResultConverter { const val ACCURACY_KEY = "accuracy" const val ALTITUDE_KEY = "altitude" const val GEOMETRY_KEY = "geometry" - - fun toFirestoreMap(result: CaptureLocationTaskData): Result> = - Result.runCatching { - mapOf( - ACCURACY_KEY to result.accuracy!!, - ALTITUDE_KEY to result.altitude!!, - GEOMETRY_KEY to GeometryConverter.toFirestoreMap(result.geometry).getOrThrow(), - ) - } - - fun fromFirestoreMap(map: Map?): Result = - Result.runCatching { - val accuracy = map?.get(ACCURACY_KEY) as? Double - val altitude = map?.get(ALTITUDE_KEY) as? Double - val geometry = GeometryConverter.fromFirestoreMap(map?.get(GEOMETRY_KEY) as? Map) - CaptureLocationTaskData(geometry.getOrThrow() as Point, altitude, accuracy) - } } diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionConverter.kt deleted file mode 100644 index fba22ec648..0000000000 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionConverter.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2020 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.remote.firebase.schema - -import com.google.android.ground.model.geometry.Point -import com.google.android.ground.model.geometry.Polygon -import com.google.android.ground.model.job.Job -import com.google.android.ground.model.locationofinterest.LocationOfInterest -import com.google.android.ground.model.submission.DateTaskData -import com.google.android.ground.model.submission.DrawAreaTaskData -import com.google.android.ground.model.submission.DropPinTaskData -import com.google.android.ground.model.submission.MultipleChoiceTaskData -import com.google.android.ground.model.submission.NumberTaskData -import com.google.android.ground.model.submission.Submission -import com.google.android.ground.model.submission.SubmissionData -import com.google.android.ground.model.submission.TaskData -import com.google.android.ground.model.submission.TextTaskData -import com.google.android.ground.model.submission.TimeTaskData -import com.google.android.ground.model.task.MultipleChoice -import com.google.android.ground.model.task.Task -import com.google.android.ground.persistence.remote.DataStoreException -import com.google.firebase.Timestamp -import com.google.firebase.firestore.DocumentSnapshot -import kotlinx.collections.immutable.toPersistentMap -import timber.log.Timber - -/** Converts between Firestore documents and [Submission] instances. */ -internal object SubmissionConverter { - - fun toSubmission(loi: LocationOfInterest, snapshot: DocumentSnapshot): Submission { - if (!snapshot.exists()) throw DataStoreException("Missing submission") - val doc = snapshot.toObject(SubmissionDocument::class.java) - val loiId = DataStoreException.checkNotNull(doc!!.loiId, "loiId") - if (loi.id != loiId) { - throw DataStoreException("Submission doc featureId doesn't match specified loiId") - } - // Degrade gracefully when audit info missing in remote db. - val created = doc.created ?: AuditInfoNestedObject.FALLBACK_VALUE - val lastModified = doc.lastModified ?: created - val job = loi.job - return Submission( - snapshot.id, - loi.surveyId, - loi, - job, - AuditInfoConverter.toAuditInfo(created), - AuditInfoConverter.toAuditInfo(lastModified), - // TODO(#2058): Remove reference to `responses` once dev dbs updated or reset. - toSubmissionData(snapshot.id, job, doc.data ?: doc.responses), - ) - } - - private fun toSubmissionData( - submissionId: String, - job: Job, - firestoreMap: Map?, - ): SubmissionData { - if (firestoreMap == null) { - return SubmissionData() - } - val data = mutableMapOf() - for ((taskId, value) in firestoreMap) { - try { - putValue(taskId, job, value, data) - } catch (e: DataStoreException) { - Timber.e(e, "Task $taskId in remote db in submission $submissionId") - } - } - return SubmissionData(data.toPersistentMap()) - } - - private fun putValue(taskId: String, job: Job, obj: Any, data: MutableMap) { - try { - val task = job.getTask(taskId) - when (task.type) { - Task.Type.PHOTO, - Task.Type.TEXT -> putTextResponse(taskId, obj, data) - Task.Type.MULTIPLE_CHOICE -> - putMultipleChoiceResponse(taskId, task.multipleChoice, obj, data) - Task.Type.NUMBER -> putNumberResponse(taskId, obj, data) - Task.Type.DATE -> putDateResponse(taskId, obj, data) - Task.Type.TIME -> putTimeResponse(taskId, obj, data) - Task.Type.DROP_PIN -> putDropPinTaskResult(taskId, obj, data) - Task.Type.DRAW_AREA -> putDrawAreaTaskResult(taskId, obj, data) - Task.Type.CAPTURE_LOCATION -> putCaptureLocationResult(taskId, obj, data) - else -> throw DataStoreException("Unknown type " + task.type) - } - } catch (e: Job.TaskNotFoundException) { - Timber.d(e, "cannot put value for unknown task") - } - } - - private fun putNumberResponse(taskId: String, obj: Any, data: MutableMap) { - val value = DataStoreException.checkType(Double::class.java, obj) as Double - NumberTaskData.fromNumber(value.toString())?.let { r: TaskData -> data[taskId] = r } - } - - private fun putTextResponse(taskId: String, obj: Any, data: MutableMap) { - val value = DataStoreException.checkType(String::class.java, obj) as String - TextTaskData.fromString(value.trim { it <= ' ' })?.let { r: TaskData -> data[taskId] = r } - } - - private fun putDateResponse(taskId: String, obj: Any, data: MutableMap) { - val value = DataStoreException.checkType(Timestamp::class.java, obj) as Timestamp - DateTaskData.fromDate(value.toDate())?.let { r: TaskData -> data[taskId] = r } - } - - private fun putTimeResponse(taskId: String, obj: Any, data: MutableMap) { - val value = DataStoreException.checkType(Timestamp::class.java, obj) as Timestamp - TimeTaskData.fromDate(value.toDate())?.let { r: TaskData -> data[taskId] = r } - } - - private fun putDropPinTaskResult(taskId: String, obj: Any, data: MutableMap) { - val map = obj as HashMap - check(map["type"] == "Point") - val geometry = GeometryConverter.fromFirestoreMap(map).getOrNull() - DataStoreException.checkNotNull(geometry, "Drop pin geometry null in remote db") - DataStoreException.checkType(Point::class.java, geometry!!) - data[taskId] = DropPinTaskData(geometry as Point) - } - - private fun putDrawAreaTaskResult(taskId: String, obj: Any, data: MutableMap) { - val map = obj as HashMap - check(map["type"] == "Polygon") - val geometry = GeometryConverter.fromFirestoreMap(map).getOrNull() - DataStoreException.checkNotNull(geometry, "Drop pin geometry null in remote db") - DataStoreException.checkType(Polygon::class.java, geometry!!) - data[taskId] = DrawAreaTaskData(geometry as Polygon) - } - - private fun putCaptureLocationResult( - taskId: String, - obj: Any, - data: MutableMap, - ) = - CaptureLocationResultConverter.fromFirestoreMap(obj as Map).onSuccess { - data[taskId] = it - } - - private fun putMultipleChoiceResponse( - taskId: String, - multipleChoice: MultipleChoice?, - obj: Any, - data: MutableMap, - ) { - val values = DataStoreException.checkType(MutableList::class.java, obj) as List<*> - values.forEach { DataStoreException.checkType(String::class.java, it as Any) } - MultipleChoiceTaskData.fromList(multipleChoice, values as List)?.let { - data[taskId] = it - } - } -} diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionDocumentReference.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionDocumentReference.kt index 44a0286038..e801ea0796 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionDocumentReference.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionDocumentReference.kt @@ -20,6 +20,8 @@ import com.google.android.ground.model.User import com.google.android.ground.model.mutation.Mutation import com.google.android.ground.model.mutation.SubmissionMutation import com.google.android.ground.persistence.remote.firebase.base.FluentDocumentReference +import com.google.android.ground.persistence.remote.firebase.protobuf.createSubmissionMessage +import com.google.android.ground.persistence.remote.firebase.protobuf.toFirestoreMap import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.WriteBatch @@ -30,7 +32,7 @@ class SubmissionDocumentReference internal constructor(ref: DocumentReference) : fun addMutationToBatch(mutation: SubmissionMutation, user: User, batch: WriteBatch) { when (mutation.type) { Mutation.Type.CREATE, - Mutation.Type.UPDATE -> merge(SubmissionMutationConverter.toMap(mutation, user), batch) + Mutation.Type.UPDATE -> merge(mutation.createSubmissionMessage(user).toFirestoreMap(), batch) Mutation.Type.DELETE -> delete(batch) else -> throw IllegalArgumentException("Unknown mutation type ${mutation.type}") } diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverter.kt deleted file mode 100644 index 64a73bedff..0000000000 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverter.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2020 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.remote.firebase.schema - -import com.google.android.ground.model.User -import com.google.android.ground.model.mutation.Mutation -import com.google.android.ground.model.mutation.SubmissionMutation -import com.google.android.ground.model.submission.* -import com.google.android.ground.persistence.remote.DataStoreException -import com.google.android.ground.persistence.remote.firebase.schema.AuditInfoConverter.fromMutationAndUser -import com.google.firebase.firestore.FieldValue -import kotlinx.collections.immutable.toPersistentMap -import timber.log.Timber - -/** Converts between Firestore maps used to merge updates and [SubmissionMutation] instances. */ -internal object SubmissionMutationConverter { - - private const val LOI_ID = "loiId" - private const val JOB_ID = "jobId" - private const val DATA = "data" - private const val CREATED = "created" - private const val LAST_MODIFIED = "lastModified" - - @Throws(DataStoreException::class) - fun toMap(mutation: SubmissionMutation, user: User): Map { - val map = mutableMapOf() - val auditInfo = fromMutationAndUser(mutation, user) - when (mutation.type) { - Mutation.Type.CREATE -> { - map[CREATED] = auditInfo - map[LAST_MODIFIED] = auditInfo - } - Mutation.Type.UPDATE -> { - map[LAST_MODIFIED] = auditInfo - } - Mutation.Type.DELETE, - Mutation.Type.UNKNOWN -> { - throw DataStoreException("Unsupported mutation type: ${mutation.type}") - } - } - map[LOI_ID] = mutation.locationOfInterestId - map[JOB_ID] = mutation.job.id - map[DATA] = toMap(mutation.deltas) - return map.toPersistentMap() - } - - private fun toMap(deltas: List): Map { - val map = mutableMapOf() - for (delta in deltas) { - map[delta.taskId] = toObject(delta.newTaskData) ?: FieldValue.delete() - } - return map.toPersistentMap() - } - - private fun toObject(taskData: TaskData?): Any? = - when (taskData) { - is TextTaskData -> { - taskData.text - } - is MultipleChoiceTaskData -> { - taskData.selectedOptionIds - } - is NumberTaskData -> { - taskData.value - } - is TimeTaskData -> { - taskData.time - } - is DateTaskData -> { - taskData.date - } - is CaptureLocationTaskData -> { - CaptureLocationResultConverter.toFirestoreMap(taskData).getOrThrow() - } - is GeometryTaskData -> { - GeometryConverter.toFirestoreMap(taskData.geometry).getOrThrow() - } - else -> { - Timber.e("Unknown value type: %s", taskData?.javaClass?.name) - null - } - } -} diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionConverterTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionConverterTest.kt deleted file mode 100644 index a1bba0eeb9..0000000000 --- a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionConverterTest.kt +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright 2021 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.remote.firebase.schema - -import com.google.android.ground.model.AuditInfo -import com.google.android.ground.model.User -import com.google.android.ground.model.geometry.Coordinates -import com.google.android.ground.model.geometry.LinearRing -import com.google.android.ground.model.geometry.Point -import com.google.android.ground.model.geometry.Polygon -import com.google.android.ground.model.job.Job -import com.google.android.ground.model.job.Style -import com.google.android.ground.model.locationofinterest.LocationOfInterest -import com.google.android.ground.model.submission.DrawAreaTaskData -import com.google.android.ground.model.submission.DropPinTaskData -import com.google.android.ground.model.submission.MultipleChoiceTaskData -import com.google.android.ground.model.submission.Submission -import com.google.android.ground.model.submission.SubmissionData -import com.google.android.ground.model.submission.TextTaskData -import com.google.android.ground.model.task.MultipleChoice -import com.google.android.ground.model.task.Task -import com.google.android.ground.persistence.remote.DataStoreException -import com.google.android.ground.persistence.remote.firebase.schema.SubmissionConverter.toSubmission -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Timestamp -import com.google.firebase.firestore.DocumentSnapshot -import com.google.firebase.firestore.GeoPoint -import com.sharedtest.FakeData -import com.sharedtest.FakeData.newTask -import java.util.* -import kotlinx.collections.immutable.persistentListOf -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.whenever - -@RunWith(MockitoJUnitRunner::class) -class SubmissionConverterTest { - @Mock private lateinit var submissionDocumentSnapshot: DocumentSnapshot - - private lateinit var job: Job - private lateinit var locationOfInterest: LocationOfInterest - - @Test - fun testToSubmission() { - setUpTestSurvey( - "job001", - "loi001", - newTask("task1"), - newTask( - "task2", - Task.Type.MULTIPLE_CHOICE, - MultipleChoice(persistentListOf(), MultipleChoice.Cardinality.SELECT_ONE), - ), - newTask("task3", Task.Type.MULTIPLE_CHOICE), - newTask("task4", Task.Type.PHOTO), - newTask("task5", Task.Type.DROP_PIN), - newTask("task6", Task.Type.DRAW_AREA), - ) - mockSubmissionDocumentSnapshot(SUBMISSION_ID, TEST_SUBMISSION_DOCUMENT) - - assertThat(toSubmission()) - .isEqualTo( - Submission( - SUBMISSION_ID, - TEST_SURVEY_ID, - locationOfInterest, - job, - AUDIT_INFO_1, - AUDIT_INFO_2, - TEST_SUBMISSION_DATA, - ) - ) - } - - @Test - fun testToSubmission_mismatchedLoiId() { - setUpTestSurvey("job001", "loi001", newTask("task1")) - mockSubmissionDocumentSnapshot( - SUBMISSION_ID, - SubmissionDocument( - "loi999", - "task001", - AUDIT_INFO_1_NESTED_OBJECT, - AUDIT_INFO_2_NESTED_OBJECT, - mapOf(Pair("task1", "")), - ), - ) - Assert.assertThrows(DataStoreException::class.java) { this.toSubmission() } - } - - @Test - fun testToSubmission_nullData() { - setUpTestSurvey("job001", "loi001", newTask("task1")) - mockSubmissionDocumentSnapshot( - SUBMISSION_ID, - SubmissionDocument( - "loi001", - "task001", - AUDIT_INFO_1_NESTED_OBJECT, - AUDIT_INFO_2_NESTED_OBJECT, - null, - ), - ) - assertThat(toSubmission()) - .isEqualTo( - Submission( - SUBMISSION_ID, - TEST_SURVEY_ID, - locationOfInterest, - job, - AUDIT_INFO_1, - AUDIT_INFO_2, - ) - ) - } - - @Test - fun testToSubmission_emptyTextResponse() { - setUpTestSurvey("job001", "loi001", newTask("task1")) - mockSubmissionDocumentSnapshot( - SUBMISSION_ID, - SubmissionDocument( - "loi001", - "task001", - AUDIT_INFO_1_NESTED_OBJECT, - AUDIT_INFO_2_NESTED_OBJECT, - mapOf(Pair("task1", "")), - ), - ) - assertThat(toSubmission()) - .isEqualTo( - Submission( - SUBMISSION_ID, - TEST_SURVEY_ID, - locationOfInterest, - job, - AUDIT_INFO_1, - AUDIT_INFO_2, - ) - ) - } - - @Test - fun testToSubmission_emptyMultipleChoiceResponse() { - setUpTestSurvey("job001", "loi001", newTask("task1")) - mockSubmissionDocumentSnapshot( - SUBMISSION_ID, - SubmissionDocument( - "loi001", - "task001", - AUDIT_INFO_1_NESTED_OBJECT, - AUDIT_INFO_2_NESTED_OBJECT, - mapOf(Pair("task1", listOf())), - ), - ) - assertThat(toSubmission()) - .isEqualTo( - Submission( - SUBMISSION_ID, - TEST_SURVEY_ID, - locationOfInterest, - job, - AUDIT_INFO_1, - AUDIT_INFO_2, - ) - ) - } - - @Test - fun testToSubmission_unknownFieldType() { - setUpTestSurvey("job001", "loi001", newTask("task1", Task.Type.UNKNOWN), newTask("task2")) - mockSubmissionDocumentSnapshot( - SUBMISSION_ID, - SubmissionDocument( - "loi001", - "task001", - AUDIT_INFO_1_NESTED_OBJECT, - AUDIT_INFO_2_NESTED_OBJECT, - mapOf(Pair("task1", "Unknown"), Pair("task2", "Text response")), - ), - ) - assertThat(toSubmission()) - .isEqualTo( - Submission( - SUBMISSION_ID, - TEST_SURVEY_ID, - locationOfInterest, - job, - AUDIT_INFO_1, - AUDIT_INFO_2, - // Field "task1" with unknown field type ignored. - SubmissionData(mapOf(Pair("task2", TextTaskData("Text response")))), - ) - ) - } - - private fun setUpTestSurvey(jobId: String, loiId: String, vararg tasks: Task) { - val taskMap = tasks.associateBy { it.id } - job = Job(jobId, TEST_STYLE, "JOB_NAME", taskMap) - locationOfInterest = - FakeData.LOCATION_OF_INTEREST.copy(id = loiId, surveyId = TEST_SURVEY_ID, job = job) - } - - /** Mock submission document snapshot to return the specified id and object representation. */ - private fun mockSubmissionDocumentSnapshot(id: String, doc: SubmissionDocument) { - whenever(submissionDocumentSnapshot.id).thenReturn(id) - whenever(submissionDocumentSnapshot.toObject(SubmissionDocument::class.java)).thenReturn(doc) - whenever(submissionDocumentSnapshot.exists()).thenReturn(true) - } - - private fun toSubmission(): Submission = - toSubmission(locationOfInterest, submissionDocumentSnapshot) - - companion object { - private val AUDIT_INFO_1 = AuditInfo(User("user1", "", ""), Date(100), Date(101)) - private val AUDIT_INFO_2 = AuditInfo(User("user2", "", ""), Date(200), Date(201)) - private val AUDIT_INFO_1_NESTED_OBJECT = - AuditInfoNestedObject( - UserNestedObject("user1", null, null), - Timestamp(Date(100)), - Timestamp(Date(101)), - ) - private val AUDIT_INFO_2_NESTED_OBJECT = - AuditInfoNestedObject( - UserNestedObject("user2", null, null), - Timestamp(Date(200)), - Timestamp(Date(201)), - ) - private const val SUBMISSION_ID = "submission123" - private const val TEST_SURVEY_ID = "survey001" - private val TEST_STYLE = Style("#112233") - private val TEST_SUBMISSION_DATA = - SubmissionData( - mapOf( - "task1" to TextTaskData("Text response"), - "task2" to - MultipleChoiceTaskData( - MultipleChoice(persistentListOf(), MultipleChoice.Cardinality.SELECT_ONE), - listOf("option2"), - ), - "task3" to - MultipleChoiceTaskData( - MultipleChoice(persistentListOf(), MultipleChoice.Cardinality.SELECT_ONE), - listOf("optionA", "optionB"), - ), - "task4" to TextTaskData("Photo URL"), - "task5" to DropPinTaskData(Point(Coordinates(10.0, 20.0))), - "task6" to - DrawAreaTaskData( - Polygon( - LinearRing( - listOf( - Coordinates(10.0, 20.0), - Coordinates(20.0, 30.0), - Coordinates(30.0, 20.0), - Coordinates(10.0, 20.0), - ) - ) - ) - ), - ) - ) - private val TEST_SUBMISSION_DOCUMENT = - SubmissionDocument( - "loi001", - "task001", - AUDIT_INFO_1_NESTED_OBJECT, - AUDIT_INFO_2_NESTED_OBJECT, - mapOf( - "task1" to "Text response", - "task2" to listOf("option2"), - "task3" to listOf("optionA", "optionB"), - "task4" to "Photo URL", - "task5" to mapOf("type" to "Point", "coordinates" to GeoPoint(10.0, 20.0)), - "task6" to - mapOf( - "type" to "Polygon", - "coordinates" to - mapOf( - "0" to - mapOf( - "0" to GeoPoint(10.0, 20.0), - "1" to GeoPoint(20.0, 30.0), - "2" to GeoPoint(30.0, 20.0), - "3" to GeoPoint(10.0, 20.0), - ) - ), - ), - ), - ) - } -} diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverterTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverterTest.kt index c9de753b71..41e3d10cf6 100644 --- a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverterTest.kt +++ b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/SubmissionMutationConverterTest.kt @@ -33,9 +33,40 @@ import com.google.android.ground.model.submission.ValueDelta 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.remote.DataStoreException +import com.google.android.ground.persistence.remote.firebase.protobuf.createSubmissionMessage +import com.google.android.ground.persistence.remote.firebase.protobuf.toFirestoreMap +import com.google.android.ground.proto.AuditInfo.CLIENT_TIMESTAMP_FIELD_NUMBER +import com.google.android.ground.proto.AuditInfo.DISPLAY_NAME_FIELD_NUMBER +import com.google.android.ground.proto.AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER +import com.google.android.ground.proto.AuditInfo.USER_ID_FIELD_NUMBER +import com.google.android.ground.proto.Coordinates.LATITUDE_FIELD_NUMBER +import com.google.android.ground.proto.Coordinates.LONGITUDE_FIELD_NUMBER +import com.google.android.ground.proto.Geometry.POINT_FIELD_NUMBER +import com.google.android.ground.proto.Geometry.POLYGON_FIELD_NUMBER +import com.google.android.ground.proto.Point.COORDINATES_FIELD_NUMBER +import com.google.android.ground.proto.Polygon.SHELL_FIELD_NUMBER +import com.google.android.ground.proto.Submission.CREATED_FIELD_NUMBER +import com.google.android.ground.proto.Submission.ID_FIELD_NUMBER +import com.google.android.ground.proto.Submission.JOB_ID_FIELD_NUMBER +import com.google.android.ground.proto.Submission.LAST_MODIFIED_FIELD_NUMBER +import com.google.android.ground.proto.Submission.LOI_ID_FIELD_NUMBER +import com.google.android.ground.proto.Submission.OWNER_ID_FIELD_NUMBER +import com.google.android.ground.proto.Submission.TASK_DATA_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.CAPTURE_LOCATION_RESULT_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.CaptureLocationResult.ACCURACY_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.CaptureLocationResult.ALTITUDE_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.DATE_TIME_RESPONSE_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.DRAW_GEOMETRY_RESULT_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.DateTimeResponse.DATE_TIME_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.DrawGeometryResult.GEOMETRY_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.MULTIPLE_CHOICE_RESPONSES_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.MultipleChoiceResponses.SELECTED_OPTION_IDS_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.NUMBER_RESPONSE_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.NumberResponse.NUMBER_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.TASK_ID_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.TEXT_RESPONSE_FIELD_NUMBER +import com.google.android.ground.proto.TaskData.TextResponse.TEXT_FIELD_NUMBER import com.google.common.truth.Truth.assertThat -import com.google.firebase.firestore.GeoPoint import com.sharedtest.FakeData import java.time.Instant import java.util.Date @@ -51,10 +82,11 @@ class SubmissionMutationConverterTest { private val user = FakeData.USER.copy(id = "user_id_1") private val job = FakeData.JOB private val loiId = "loi_id_1" - private val clientTimestamp = Date() + private val clientTimestamp = Date.from(Instant.ofEpochMilli(1000)) private val textTaskData = TextTaskData.fromString("some data") + // TODO: Add test coverage for "other" value private val singleChoiceResponse = MultipleChoiceTaskData.fromList( MultipleChoice( @@ -67,6 +99,7 @@ class SubmissionMutationConverterTest { ids = listOf("option id 1"), ) + // TODO: Add test coverage for "other" value private val multipleChoiceTaskData = MultipleChoiceTaskData.fromList( MultipleChoice( @@ -104,13 +137,14 @@ class SubmissionMutationConverterTest { altitude = 112.31, ) - private val dateTaskResult = DateTaskData(Date.from(Instant.EPOCH)) + private val dateTaskResult = DateTaskData(Date.from(Instant.ofEpochMilli(2000))) - private val timeTaskResult = TimeTaskData(Date.from(Instant.EPOCH)) + private val timeTaskResult = TimeTaskData(Date.from(Instant.ofEpochMilli(3000))) private val submissionMutation = SubmissionMutation( id = 1, + submissionId = "submission_id", surveyId = "id_1", locationOfInterestId = loiId, userId = user.id, @@ -155,86 +189,153 @@ class SubmissionMutationConverterTest { ) private val expected = - mapOf( - "text_task" to "some data", - "single_choice_task" to listOf("option id 1"), - "multiple_choice_task" to listOf("option id 1", "option id 2"), - "number_task" to 123.0, - "drop_pin_task" to mapOf("type" to "Point", "coordinates" to GeoPoint(10.0, 20.0)), - "draw_area_task" to - mapOf( - "type" to "Polygon", - "coordinates" to - mapOf( - "0" to - mapOf( - "0" to GeoPoint(10.0, 20.0), - "1" to GeoPoint(20.0, 30.0), - "2" to GeoPoint(30.0, 20.0), - "3" to GeoPoint(10.0, 20.0), - ) - ), - ), - "capture_location" to - mapOf( - "accuracy" to 80.8, - "altitude" to 112.31, - "geometry" to mapOf("type" to "Point", "coordinates" to GeoPoint(10.0, 20.0)), - ), - "date_task" to Date.from(Instant.EPOCH), - "time_task" to Date.from(Instant.EPOCH), + listOf( + mapOf( + TEXT_RESPONSE_FIELD_NUMBER.toString() to mapOf(TEXT_FIELD_NUMBER.toString() to "some data"), + TASK_ID_FIELD_NUMBER.toString() to "text_task", + ), + mapOf( + MULTIPLE_CHOICE_RESPONSES_FIELD_NUMBER.toString() to + mapOf(SELECTED_OPTION_IDS_FIELD_NUMBER.toString() to listOf("option id 1")), + TASK_ID_FIELD_NUMBER.toString() to "single_choice_task", + ), + mapOf( + MULTIPLE_CHOICE_RESPONSES_FIELD_NUMBER.toString() to + mapOf( + SELECTED_OPTION_IDS_FIELD_NUMBER.toString() to listOf("option id 1", "option id 2") + ), + TASK_ID_FIELD_NUMBER.toString() to "multiple_choice_task", + ), + mapOf( + NUMBER_RESPONSE_FIELD_NUMBER.toString() to mapOf(NUMBER_FIELD_NUMBER.toString() to 123.0), + TASK_ID_FIELD_NUMBER.toString() to "number_task", + ), + mapOf( + DRAW_GEOMETRY_RESULT_FIELD_NUMBER.toString() to + mapOf( + GEOMETRY_FIELD_NUMBER.toString() to + mapOf( + POINT_FIELD_NUMBER.toString() to + mapOf( + COORDINATES_FIELD_NUMBER.toString() to + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 10.0, + LONGITUDE_FIELD_NUMBER.toString() to 20.0, + ) + ) + ) + ), + TASK_ID_FIELD_NUMBER.toString() to "drop_pin_task", + ), + mapOf( + DRAW_GEOMETRY_RESULT_FIELD_NUMBER.toString() to + mapOf( + GEOMETRY_FIELD_NUMBER.toString() to + mapOf( + POLYGON_FIELD_NUMBER.toString() to + mapOf( + SHELL_FIELD_NUMBER.toString() to + mapOf( + COORDINATES_FIELD_NUMBER.toString() to + listOf( + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 10.0, + LONGITUDE_FIELD_NUMBER.toString() to 20.0, + ), + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 20.0, + LONGITUDE_FIELD_NUMBER.toString() to 30.0, + ), + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 30.0, + LONGITUDE_FIELD_NUMBER.toString() to 20.0, + ), + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 10.0, + LONGITUDE_FIELD_NUMBER.toString() to 20.0, + ), + ) + ) + ) + ) + ), + TASK_ID_FIELD_NUMBER.toString() to "draw_area_task", + ), + mapOf( + CAPTURE_LOCATION_RESULT_FIELD_NUMBER.toString() to + mapOf( + COORDINATES_FIELD_NUMBER.toString() to + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 10.0, + LONGITUDE_FIELD_NUMBER.toString() to 20.0, + ), + ACCURACY_FIELD_NUMBER.toString() to 80.8, + ALTITUDE_FIELD_NUMBER.toString() to 112.31, + ), + TASK_ID_FIELD_NUMBER.toString() to "capture_location", + ), + mapOf( + DATE_TIME_RESPONSE_FIELD_NUMBER.toString() to + mapOf(DATE_TIME_FIELD_NUMBER.toString() to mapOf("1" to 2000000L)), + TASK_ID_FIELD_NUMBER.toString() to "date_task", + ), + mapOf( + DATE_TIME_RESPONSE_FIELD_NUMBER.toString() to + mapOf(DATE_TIME_FIELD_NUMBER.toString() to mapOf("1" to 3000000L)), + TASK_ID_FIELD_NUMBER.toString() to "time_task", + ), ) - private val auditInfoObject = AuditInfoConverter.fromMutationAndUser(submissionMutation, user) + private val auditInfoObject = + mapOf( + USER_ID_FIELD_NUMBER.toString() to user.id, + DISPLAY_NAME_FIELD_NUMBER.toString() to user.displayName, + CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 1000000L), + SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 1000000L), + ) @Test fun testToMap_create() { - assertThat( - SubmissionMutationConverter.toMap( - submissionMutation.copy(type = Mutation.Type.CREATE), - user, - ) - ) - .isEqualTo( - mapOf( - "created" to auditInfoObject, - "lastModified" to auditInfoObject, - "loiId" to loiId, - "jobId" to job.id, - "data" to expected, - ) - ) + val submissionMutation = submissionMutation.copy(type = Mutation.Type.CREATE) + + val map = submissionMutation.createSubmissionMessage(user).toFirestoreMap() + + assertThat(map[ID_FIELD_NUMBER.toString()]).isEqualTo(submissionMutation.submissionId) + assertThat(map[LOI_ID_FIELD_NUMBER.toString()]) + .isEqualTo(submissionMutation.locationOfInterestId) + assertThat(map[JOB_ID_FIELD_NUMBER.toString()]).isEqualTo(submissionMutation.job.id) + assertThat(map[OWNER_ID_FIELD_NUMBER.toString()]).isEqualTo(submissionMutation.userId) + assertThat(map[CREATED_FIELD_NUMBER.toString()]).isEqualTo(auditInfoObject) + assertThat(map[LAST_MODIFIED_FIELD_NUMBER.toString()]).isEqualTo(auditInfoObject) + assertThat(map[TASK_DATA_FIELD_NUMBER.toString()]).isEqualTo(expected) } @Test fun testToMap_update() { - assertThat( - SubmissionMutationConverter.toMap( - submissionMutation.copy(type = Mutation.Type.UPDATE), - user, - ) - ) - .isEqualTo( - mapOf( - "lastModified" to auditInfoObject, - "loiId" to loiId, - "jobId" to job.id, - "data" to expected, - ) - ) + val submissionMutation = submissionMutation.copy(type = Mutation.Type.UPDATE) + + val map = submissionMutation.createSubmissionMessage(user).toFirestoreMap() + + assertThat(map[ID_FIELD_NUMBER.toString()]).isEqualTo(submissionMutation.submissionId) + assertThat(map[LOI_ID_FIELD_NUMBER.toString()]) + .isEqualTo(submissionMutation.locationOfInterestId) + assertThat(map[JOB_ID_FIELD_NUMBER.toString()]).isEqualTo(submissionMutation.job.id) + assertThat(map[OWNER_ID_FIELD_NUMBER.toString()]).isEqualTo(submissionMutation.userId) + assertThat(map[LAST_MODIFIED_FIELD_NUMBER.toString()]).isEqualTo(auditInfoObject) + assertThat(map[TASK_DATA_FIELD_NUMBER.toString()]).isEqualTo(expected) } @Test fun testToMap_delete() { - assertThrows("Unsupported mutation type", DataStoreException::class.java) { - SubmissionMutationConverter.toMap(submissionMutation.copy(type = Mutation.Type.DELETE), user) + assertThrows("Unsupported mutation type", UnsupportedOperationException::class.java) { + submissionMutation.copy(type = Mutation.Type.DELETE).createSubmissionMessage(user) } } @Test fun testToMap_unknown() { - assertThrows("Unsupported mutation type", DataStoreException::class.java) { - SubmissionMutationConverter.toMap(submissionMutation.copy(type = Mutation.Type.UNKNOWN), user) + assertThrows("Unsupported mutation type", UnsupportedOperationException::class.java) { + submissionMutation.copy(type = Mutation.Type.UNKNOWN).createSubmissionMessage(user) } } }