diff --git a/ground/build.gradle b/ground/build.gradle index d3783d7492..0de593a435 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:5211a4f@jar" + groundProtoJar "com.github.google:ground-platform:7420132@jar" } protobuf { 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 new file mode 100644 index 0000000000..8c2372292b --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExt.kt @@ -0,0 +1,127 @@ +/* + * 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.remote.firebase.protobuf + +import com.google.android.ground.model.User +import com.google.android.ground.model.geometry.Coordinates +import com.google.android.ground.model.geometry.Geometry +import com.google.android.ground.model.geometry.LineString +import com.google.android.ground.model.geometry.LinearRing +import com.google.android.ground.model.geometry.MultiPolygon +import com.google.android.ground.model.geometry.Point +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.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.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.protobuf.timestamp +import java.util.Date +import kotlinx.collections.immutable.toImmutableMap + +fun LocationOfInterestMutation.createLoiMessage(user: User) = locationOfInterest { + assert(userId == user.id) { "UserId doesn't match: expected $userId, found ${user.id}" } + + val me = this@createLoiMessage + id = locationOfInterestId + jobId = me.jobId + submissionCount = me.submissionCount + ownerId = me.userId + customTag = me.customId + + properties.putAll(me.properties.toMessageMap()) + + me.geometry?.toMessage()?.let { geometry = it } + + val auditInfo = createAuditInfoMessage(user, clientTimestamp) + + when (type) { + Mutation.Type.CREATE -> { + created = auditInfo + lastModified = auditInfo + source = + if (isPredefined == null) Source.SOURCE_UNSPECIFIED + else if (isPredefined) Source.IMPORTED else Source.FIELD_DATA + } + Mutation.Type.UPDATE -> { + lastModified = auditInfo + } + Mutation.Type.DELETE, + Mutation.Type.UNKNOWN -> { + throw UnsupportedOperationException() + } + } +} + +private fun createAuditInfoMessage(user: User, timestamp: Date) = auditInfo { + userId = user.id + displayName = user.displayName + photoUrl = user.photoUrl ?: photoUrl + clientTimestamp = timestamp.toMessage() + serverTimestamp = timestamp.toMessage() +} + +private fun Date.toMessage() = timestamp { seconds = time * 1000 } + +private fun Geometry.toMessage() = + when (this) { + is Point -> geometry { point = toMessage() } + is MultiPolygon -> geometry { multiPolygon = toMessage() } + is Polygon -> geometry { polygon = toMessage() } + is LineString, + is LinearRing -> throw UnsupportedOperationException("Unsupported type $this") + } + +private fun Coordinates.toMessage() = coordinates { + latitude = lat + longitude = lng +} + +private fun Point.toMessage() = point { coordinates = this@toMessage.coordinates.toMessage() } + +private fun LinearRing.toMessage(): LinearRingProto = + LinearRingProto.newBuilder().addAllCoordinates(coordinates.map { it.toMessage() }).build() + +private fun Polygon.toMessage(): PolygonProto = + PolygonProto.newBuilder() + .setShell(shell.toMessage()) + .addAllHoles(holes.map { it.toMessage() }) + .build() + +private fun MultiPolygon.toMessage(): MultiPolygonProto = + MultiPolygonProto.newBuilder().addAllPolygons(polygons.map { it.toMessage() }).build() + +private fun LoiProperties.toMessageMap(): Map { + val propertiesBuilder = mutableMapOf() + for ((key, value) in this) { + propertiesBuilder[key] = + when (value) { + is String -> property { stringValue = value } + is Number -> property { numericValue = value.toDouble() } + else -> throw UnsupportedOperationException("Unknown type $value") + } + } + return propertiesBuilder.toImmutableMap() +} diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExt.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExt.kt index 69da2354da..bf4ae757cf 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExt.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExt.kt @@ -69,6 +69,7 @@ private fun Message.hasValue(property: KProperty<*>): Boolean { private fun MessageValue.toFirestoreValue(): FirestoreValue = // TODO(#1748): Convert enums and other types. when (this) { + is List<*> -> map { it?.toFirestoreValue() } is Message -> toFirestoreMap() is Map<*, *> -> mapValues { it.value?.toFirestoreValue() } is String, diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiConverter.kt index 5a6a648dcb..41f74ea1ba 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiConverter.kt @@ -25,17 +25,9 @@ import com.google.firebase.firestore.DocumentSnapshot /** Converts between Firestore documents and [LocationOfInterest] instances. */ object LoiConverter { // TODO(#2392): Define field names on DocumentReference objects, not converters. - const val JOB_ID = "jobId" - const val CREATED = "created" - const val LAST_MODIFIED = "lastModified" + private const val JOB_ID = "jobId" const val GEOMETRY_TYPE = "type" - const val POINT_TYPE = "Point" const val POLYGON_TYPE = "Polygon" - const val GEOMETRY_COORDINATES = "coordinates" - const val GEOMETRY = "geometry" - const val SUBMISSION_COUNT = "submissionCount" - const val IS_PREDEFINED = "predefined" - const val PROPERTIES = "properties" fun toLoi(survey: Survey, doc: DocumentSnapshot): Result = runCatching { toLoiUnchecked(survey, doc) diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiDocumentReference.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiDocumentReference.kt index bc3aba2a78..08c4948a65 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiDocumentReference.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiDocumentReference.kt @@ -19,6 +19,8 @@ import com.google.android.ground.model.User import com.google.android.ground.model.mutation.LocationOfInterestMutation import com.google.android.ground.model.mutation.Mutation import com.google.android.ground.persistence.remote.firebase.base.FluentDocumentReference +import com.google.android.ground.persistence.remote.firebase.protobuf.createLoiMessage +import com.google.android.ground.persistence.remote.firebase.protobuf.toFirestoreMap import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.WriteBatch @@ -29,7 +31,7 @@ class LoiDocumentReference internal constructor(ref: DocumentReference) : fun addMutationToBatch(mutation: LocationOfInterestMutation, user: User, batch: WriteBatch) = when (mutation.type) { Mutation.Type.CREATE, - Mutation.Type.UPDATE -> merge(LoiMutationConverter.toMap(mutation, user), batch) + Mutation.Type.UPDATE -> merge(mutation.createLoiMessage(user).toFirestoreMap(), batch) Mutation.Type.DELETE -> // The server is expected to do a cascading delete of all submissions for the deleted LOI. delete(batch) diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiMutationConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiMutationConverter.kt deleted file mode 100644 index 748ab6009f..0000000000 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/LoiMutationConverter.kt +++ /dev/null @@ -1,86 +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.geometry.Coordinates -import com.google.android.ground.model.geometry.Point -import com.google.android.ground.model.geometry.Polygon -import com.google.android.ground.model.mutation.LocationOfInterestMutation -import com.google.android.ground.model.mutation.Mutation -import com.google.android.ground.persistence.remote.firebase.schema.AuditInfoConverter.fromMutationAndUser -import com.google.firebase.firestore.GeoPoint -import kotlinx.collections.immutable.toPersistentMap - -/** - * Converts between Firestore maps used to merge updates and [LocationOfInterestMutation] instances. - */ -internal object LoiMutationConverter { - - /** - * Returns a map containing key-value pairs usable by Firestore constructed from the provided - * mutation. - */ - fun toMap(mutation: LocationOfInterestMutation, user: User): Map { - val map = mutableMapOf() - - map[LoiConverter.JOB_ID] = mutation.jobId - map[LoiConverter.SUBMISSION_COUNT] = mutation.submissionCount - - when (val geometry = mutation.geometry) { - is Point -> - map.addGeometryCoordinates(geometry.coordinates.toGeoPoint(), LoiConverter.POINT_TYPE) - is Polygon -> - // Holes are excluded since they're not supported in the polygon drawing feature. - map[LoiConverter.GEOMETRY] = GeometryConverter.toFirestoreMap(geometry).getOrThrow() - else -> {} - } - - if (mutation.properties.isNotEmpty()) { - map[LoiConverter.PROPERTIES] = mutation.properties - } - - val auditInfo = fromMutationAndUser(mutation, user) - when (mutation.type) { - Mutation.Type.CREATE -> { - map[LoiConverter.CREATED] = auditInfo - map[LoiConverter.LAST_MODIFIED] = auditInfo - map[LoiConverter.IS_PREDEFINED] = mutation.isPredefined ?: false - } - Mutation.Type.UPDATE -> { - map[LoiConverter.LAST_MODIFIED] = auditInfo - } - Mutation.Type.DELETE, - Mutation.Type.UNKNOWN -> { - throw UnsupportedOperationException() - } - } - return map.toPersistentMap() - } - - private fun MutableMap.addGeometryCoordinates( - geometryCoordinates: Any, - geometryType: String, - ) { - val geometryMap: MutableMap = HashMap() - geometryMap[LoiConverter.GEOMETRY_COORDINATES] = geometryCoordinates - geometryMap[LoiConverter.GEOMETRY_TYPE] = geometryType - this[LoiConverter.GEOMETRY] = geometryMap - } - - private fun Coordinates.toGeoPoint() = GeoPoint(lat, lng) -} diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/LoiMutationConverterTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/LoiMutationConverterTest.kt new file mode 100644 index 0000000000..4f41177a87 --- /dev/null +++ b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/LoiMutationConverterTest.kt @@ -0,0 +1,219 @@ +/* + * 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.remote.firebase.protobuf + +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.locationofinterest.LOI_NAME_PROPERTY +import com.google.android.ground.model.mutation.LocationOfInterestMutation +import com.google.android.ground.model.mutation.Mutation +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.LocationOfInterest.CREATED_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.GEOMETRY_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.JOB_ID_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.LAST_MODIFIED_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.PROPERTIES_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.Property.STRING_VALUE_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.SOURCE_FIELD_NUMBER +import com.google.android.ground.proto.LocationOfInterest.SUBMISSION_COUNT_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.common.truth.Truth.assertThat +import com.sharedtest.FakeData +import com.sharedtest.FakeData.LOCATION_OF_INTEREST_NAME +import java.time.Instant +import java.util.Date +import org.junit.Assert.assertThrows +import org.junit.Test + +class LoiMutationConverterTest { + @Test + fun `toMap() retains job ID and submission count`() { + val mutation = newLoiMutation() + + val map = mutation.createLoiMessage(TEST_USER).toFirestoreMap() + + assertThat(map[JOB_ID_FIELD_NUMBER.toString()]).isEqualTo(mutation.jobId) + assertThat(map[SUBMISSION_COUNT_FIELD_NUMBER.toString()]).isEqualTo(10) + } + + @Test + fun `toMap() retains point geometry data`() { + val mutation = newLoiMutation() + + val map = mutation.createLoiMessage(TEST_USER).toFirestoreMap() + + val geometry = map[GEOMETRY_FIELD_NUMBER.toString()] as MutableMap<*, *> + assertThat(geometry[POINT_FIELD_NUMBER.toString()]) + .isEqualTo( + mapOf( + COORDINATES_FIELD_NUMBER.toString() to + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 88.0, + LONGITUDE_FIELD_NUMBER.toString() to -23.1, + ) + ) + ) + } + + @Test + fun `toMap() retains polygon geometry data`() { + val mutation = newAoiMutation() + + val map = mutation.createLoiMessage(TEST_USER).toFirestoreMap() + + val geometry = map[GEOMETRY_FIELD_NUMBER.toString()] as MutableMap<*, *> + assertThat(geometry[POLYGON_FIELD_NUMBER.toString()]) + .isEqualTo( + mapOf( + SHELL_FIELD_NUMBER.toString() to + mapOf( + COORDINATES_FIELD_NUMBER.toString() to + listOf( + mapOf(LONGITUDE_FIELD_NUMBER.toString() to 1.0), + mapOf( + LATITUDE_FIELD_NUMBER.toString() to 1.0, + LONGITUDE_FIELD_NUMBER.toString() to 1.0, + ), + mapOf(LONGITUDE_FIELD_NUMBER.toString() to 1.0), + ) + ) + ) + ) + } + + @Test + fun `toMap() retains properties`() { + val mutation = newLoiMutation() + + val map = mutation.createLoiMessage(TEST_USER).toFirestoreMap() + + assertThat(map[PROPERTIES_FIELD_NUMBER.toString()]) + .isEqualTo( + mapOf( + LOI_NAME_PROPERTY to + mapOf(STRING_VALUE_FIELD_NUMBER.toString() to LOCATION_OF_INTEREST_NAME) + ) + ) + } + + @Test + fun `toMap() converts CREATE mutation to map`() { + val mutation = newLoiMutation(mutationType = Mutation.Type.CREATE) + + val map = mutation.createLoiMessage(TEST_USER).toFirestoreMap() + + assertThat(map[CREATED_FIELD_NUMBER.toString()]) + .isEqualTo( + mapOf( + USER_ID_FIELD_NUMBER.toString() to TEST_USER.id, + DISPLAY_NAME_FIELD_NUMBER.toString() to TEST_USER.displayName, + CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 1000000L), + SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 1000000L), + ) + ) + assertThat(map[CREATED_FIELD_NUMBER.toString()]) + .isEqualTo(map[LAST_MODIFIED_FIELD_NUMBER.toString()]) + assertThat(map[SOURCE_FIELD_NUMBER.toString()]).isNull() + } + + @Test + fun `toMap() converts UPDATE mutation to map`() { + val mutation = newLoiMutation(mutationType = Mutation.Type.UPDATE) + + val map = mutation.createLoiMessage(TEST_USER).toFirestoreMap() + + assertThat(map[CREATED_FIELD_NUMBER.toString()]) + .isNotEqualTo(map[LAST_MODIFIED_FIELD_NUMBER.toString()]) + assertThat(map[LAST_MODIFIED_FIELD_NUMBER.toString()]) + .isEqualTo( + mapOf( + USER_ID_FIELD_NUMBER.toString() to TEST_USER.id, + DISPLAY_NAME_FIELD_NUMBER.toString() to TEST_USER.displayName, + CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 1000000L), + SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 1000000L), + ) + ) + } + + @Test + fun `toMap() throws an error for DELETE and UNKOWN mutation`() { + val deleteMutation = newLoiMutation(mutationType = Mutation.Type.DELETE) + assertThrows(UnsupportedOperationException::class.java) { + deleteMutation.createLoiMessage(TEST_USER).toFirestoreMap() + } + val unknownMutation = newLoiMutation(mutationType = Mutation.Type.UNKNOWN) + assertThrows(UnsupportedOperationException::class.java) { + unknownMutation.createLoiMessage(TEST_USER).toFirestoreMap() + } + } + + companion object { + private val TEST_USER = FakeData.USER + private val TEST_POINT = Point(Coordinates(88.0, -23.1)) + private val TEST_POLYGON = + Polygon( + LinearRing(listOf(Coordinates(0.0, 1.0), Coordinates(1.0, 1.0), Coordinates(0.0, 1.0))) + ) + + fun newLoiMutation( + point: Point = TEST_POINT, + mutationType: Mutation.Type = Mutation.Type.CREATE, + syncStatus: Mutation.SyncStatus = Mutation.SyncStatus.PENDING, + ) = + LocationOfInterestMutation( + jobId = "jobId", + geometry = point, + id = 1L, + locationOfInterestId = "loiId", + type = mutationType, + syncStatus = syncStatus, + userId = TEST_USER.id, + surveyId = "surveyId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + submissionCount = 10, + properties = mapOf(LOI_NAME_PROPERTY to LOCATION_OF_INTEREST_NAME), + ) + + fun newAoiMutation( + polygon: Polygon = TEST_POLYGON, + mutationType: Mutation.Type = Mutation.Type.CREATE, + syncStatus: Mutation.SyncStatus = Mutation.SyncStatus.PENDING, + ) = + LocationOfInterestMutation( + jobId = "jobId", + geometry = polygon, + id = 1L, + locationOfInterestId = "loiId", + type = mutationType, + syncStatus = syncStatus, + userId = TEST_USER.id, + surveyId = "surveyId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + properties = mapOf(LOI_NAME_PROPERTY to LOCATION_OF_INTEREST_NAME), + ) + } +} diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExtKtTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExtKtTest.kt new file mode 100644 index 0000000000..8f7a18bde2 --- /dev/null +++ b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ModelToProtoExtKtTest.kt @@ -0,0 +1,351 @@ +/* + * 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.remote.firebase.protobuf + +import com.google.android.ground.model.User +import com.google.android.ground.model.geometry.Coordinates +import com.google.android.ground.model.geometry.Point +import com.google.android.ground.model.locationofinterest.generateProperties +import com.google.android.ground.model.mutation.LocationOfInterestMutation +import com.google.android.ground.model.mutation.Mutation +import com.google.android.ground.proto.LocationOfInterest +import com.google.android.ground.proto.LocationOfInterestKt.property +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.common.truth.Truth.assertThat +import com.google.protobuf.timestamp +import java.time.Instant +import java.util.Date +import org.junit.Assert.assertThrows +import org.junit.Test + +class ModelToProtoExtKtTest { + + @Test + fun `createLoiMessage() when geometry is null`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.CREATE, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = null, + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = false, + ) + + val output = mutation.createLoiMessage(user) + + assertThat(output) + .isEqualTo( + locationOfInterest { + id = "loiId" + jobId = "jobId" + submissionCount = 1 + ownerId = "userId" + created = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + lastModified = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + customTag = "customId" + source = LocationOfInterest.Source.FIELD_DATA + properties.putAll(mapOf("name" to property { stringValue = "loiName" })) + } + ) + } + + @Test + fun `createLoiMessage() when isPredefined is null and type is CREATE`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.CREATE, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = Point(Coordinates(10.0, 20.0)), + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = null, + ) + + val output = mutation.createLoiMessage(user) + + assertThat(output) + .isEqualTo( + locationOfInterest { + id = "loiId" + jobId = "jobId" + submissionCount = 1 + ownerId = "userId" + created = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + lastModified = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + customTag = "customId" + geometry = geometry { + point = point { + coordinates = coordinates { + latitude = 10.0 + longitude = 20.0 + } + } + } + properties.putAll(mapOf("name" to property { stringValue = "loiName" })) + } + ) + } + + @Test + fun `createLoiMessage() when isPredefined is true and type is CREATE`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.CREATE, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = Point(Coordinates(10.0, 20.0)), + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = true, + ) + + val output = mutation.createLoiMessage(user) + + assertThat(output) + .isEqualTo( + locationOfInterest { + id = "loiId" + jobId = "jobId" + submissionCount = 1 + ownerId = "userId" + created = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + lastModified = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + customTag = "customId" + geometry = geometry { + point = point { + coordinates = coordinates { + latitude = 10.0 + longitude = 20.0 + } + } + } + source = LocationOfInterest.Source.IMPORTED + properties.putAll(mapOf("name" to property { stringValue = "loiName" })) + } + ) + } + + @Test + fun `createLoiMessage() when isPredefined is false and type is CREATE`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.CREATE, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = Point(Coordinates(10.0, 20.0)), + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = false, + ) + + val output = mutation.createLoiMessage(user) + + assertThat(output) + .isEqualTo( + locationOfInterest { + id = "loiId" + jobId = "jobId" + submissionCount = 1 + ownerId = "userId" + created = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + lastModified = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + customTag = "customId" + geometry = geometry { + point = point { + coordinates = coordinates { + latitude = 10.0 + longitude = 20.0 + } + } + } + source = LocationOfInterest.Source.FIELD_DATA + properties.putAll(mapOf("name" to property { stringValue = "loiName" })) + } + ) + } + + @Test + fun `createLoiMessage() when type is UPDATE`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.UPDATE, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = Point(Coordinates(10.0, 20.0)), + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = false, + ) + + val output = mutation.createLoiMessage(user) + + assertThat(output) + .isEqualTo( + locationOfInterest { + id = "loiId" + jobId = "jobId" + submissionCount = 1 + ownerId = "userId" + lastModified = auditInfo { + userId = "userId" + displayName = "User" + photoUrl = "" + clientTimestamp = timestamp { seconds = 1000000L } + serverTimestamp = timestamp { seconds = 1000000L } + } + customTag = "customId" + geometry = geometry { + point = point { + coordinates = coordinates { + latitude = 10.0 + longitude = 20.0 + } + } + } + properties.putAll(mapOf("name" to property { stringValue = "loiName" })) + } + ) + } + + @Test + fun `createLoiMessage() when type is DELETE throws error`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.DELETE, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = Point(Coordinates(10.0, 20.0)), + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = false, + ) + + assertThrows(UnsupportedOperationException::class.java) { mutation.createLoiMessage(user) } + } + + @Test + fun `createLoiMessage() when type is UNKNOWN throws error`() { + val user = User("userId", "user@gmail.com", "User") + val mutation = + LocationOfInterestMutation( + type = Mutation.Type.UNKNOWN, + syncStatus = Mutation.SyncStatus.PENDING, // this field is ignored + surveyId = "surveyId", // this field is ignored + locationOfInterestId = "loiId", + userId = "userId", + jobId = "jobId", + customId = "customId", + clientTimestamp = Date.from(Instant.ofEpochMilli(1000)), + geometry = Point(Coordinates(10.0, 20.0)), + submissionCount = 1, + properties = generateProperties("loiName"), + isPredefined = false, + ) + + assertThrows(UnsupportedOperationException::class.java) { mutation.createLoiMessage(user) } + } +} diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExtTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExtTest.kt index 0841c5a078..2211e14a38 100644 --- a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExtTest.kt +++ b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/protobuf/ProtobufToFirestoreExtTest.kt @@ -19,7 +19,7 @@ package com.google.android.ground.persistence.remote.firebase.protobuf import com.google.android.ground.proto.Survey import com.google.android.ground.proto.Task import com.google.android.ground.proto.Task.DateTimeQuestion.Type.BOTH_DATE_AND_TIME -import com.google.android.ground.proto.Task.DateTimeQuestion.Type.UNSPECIFIED_DATE_TIME_QUESTION_TYPE +import com.google.android.ground.proto.Task.DateTimeQuestion.Type.TYPE_UNSPECIFIED import com.google.android.ground.proto.TaskKt.dateTimeQuestion import com.google.android.ground.proto.TaskKt.multipleChoiceQuestion import com.google.android.ground.proto.survey @@ -91,7 +91,7 @@ class ProtobufToFirestoreExtTest( ), testCase( desc = "skips enum value 0", - input = dateTimeQuestion { type = UNSPECIFIED_DATE_TIME_QUESTION_TYPE }, + input = dateTimeQuestion { type = TYPE_UNSPECIFIED }, expected = mapOf(), ), testCase(desc = "skips unspecified enum value", input = task {}, expected = mapOf()), diff --git a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/LoiMutationConverterTest.kt b/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/LoiMutationConverterTest.kt deleted file mode 100644 index 8165a4cd63..0000000000 --- a/ground/src/test/java/com/google/android/ground/persistence/remote/firebase/schema/LoiMutationConverterTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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.remote.firebase.schema - -import com.google.android.ground.model.geometry.Coordinates -import com.google.android.ground.model.geometry.Point -import com.google.android.ground.model.mutation.Mutation -import com.google.common.truth.Truth.assertThat -import com.google.firebase.firestore.GeoPoint -import com.sharedtest.FakeData -import kotlin.test.fail -import org.junit.Assert.assertThrows -import org.junit.Test - -class LoiMutationConverterTest { - @Test - fun `toMap() retains job ID and submission count`() { - with(LoiConverter) { - val mutation = FakeData.newLoiMutation(TEST_POINT) - val map = LoiMutationConverter.toMap(mutation, TEST_USER) - assertThat(map[JOB_ID]).isEqualTo(mutation.jobId) - assertThat(map[SUBMISSION_COUNT]).isEqualTo(mutation.submissionCount) - } - } - - @Test - fun `toMap() retains point geometry data`() { - with(LoiConverter) { - val mutation = FakeData.newLoiMutation(TEST_POINT) - val map = LoiMutationConverter.toMap(mutation, TEST_USER) - val geometry = map[GEOMETRY] - if (geometry is MutableMap<*, *>) { - assertThat(geometry[GEOMETRY_TYPE]).isEqualTo(POINT_TYPE) - assertThat(geometry[GEOMETRY_COORDINATES]).isEqualTo(GeoPoint(88.0, -23.1)) - } else { - fail("GEOMETRY field, $geometry, is not a map.") - } - } - } - - @Test - fun `toMap() retains polygon geometry data`() { - with(LoiConverter) { - val mutation = FakeData.newAoiMutation(TEST_POLYGON) - val map = LoiMutationConverter.toMap(mutation, TEST_USER) - val geometry = map[GEOMETRY] - if (geometry is MutableMap<*, *>) { - assertThat(geometry[GEOMETRY_TYPE]).isEqualTo(POLYGON_TYPE) - assertThat((geometry[GEOMETRY_COORDINATES] as MutableMap<*, *>).values.size).isEqualTo(1) - val coordinates = - ((geometry[GEOMETRY_COORDINATES] as MutableMap<*, *>)["0"] as MutableMap<*, *>).toList() - assertThat(coordinates.size).isEqualTo(3) - assertThat(coordinates[0]).isEqualTo("0" to GeoPoint(0.0, 1.0)) - assertThat(coordinates[1]).isEqualTo("1" to GeoPoint(1.0, 1.0)) - assertThat(coordinates[2]).isEqualTo("2" to GeoPoint(0.0, 1.0)) - } else { - fail("GEOMETRY field, $geometry, is not a map.") - } - } - } - - @Test - fun `toMap() retains properties`() { - with(LoiConverter) { - val mutation = FakeData.newLoiMutation(TEST_POINT) - val map = LoiMutationConverter.toMap(mutation, TEST_USER) - assertThat(map[PROPERTIES]).isEqualTo(mutation.properties) - } - } - - @Test - fun `toMap() converts CREATE mutation to map`() { - with(LoiConverter) { - val mutation = FakeData.newLoiMutation(TEST_POINT, mutationType = Mutation.Type.CREATE) - val map = LoiMutationConverter.toMap(mutation, TEST_USER) - assertThat(map[CREATED]).isEqualTo(map[LAST_MODIFIED]) - assertThat(map[CREATED]) - .isEqualTo(AuditInfoConverter.fromMutationAndUser(mutation, TEST_USER)) - assertThat(map[IS_PREDEFINED]).isEqualTo(false) - } - } - - @Test - fun `toMap() converts UPDATE mutation to map`() { - with(LoiConverter) { - val mutation = FakeData.newLoiMutation(TEST_POINT, mutationType = Mutation.Type.UPDATE) - val map = LoiMutationConverter.toMap(mutation, TEST_USER) - assertThat(map[CREATED]).isNotEqualTo(map[LAST_MODIFIED]) - assertThat(map[LAST_MODIFIED]) - .isEqualTo(AuditInfoConverter.fromMutationAndUser(mutation, TEST_USER)) - } - } - - @Test - fun `toMap() throws an error for DELETE and UNKOWN mutation`() { - val deleteMutation = FakeData.newLoiMutation(TEST_POINT, mutationType = Mutation.Type.DELETE) - assertThrows(UnsupportedOperationException::class.java) { - LoiMutationConverter.toMap(deleteMutation, TEST_USER) - } - val unknownMutation = FakeData.newLoiMutation(TEST_POINT, mutationType = Mutation.Type.UNKNOWN) - assertThrows(UnsupportedOperationException::class.java) { - LoiMutationConverter.toMap(unknownMutation, TEST_USER) - } - } - - companion object { - private val TEST_USER = FakeData.USER - private val TEST_POINT = Point(Coordinates(88.0, -23.1)) - private val TEST_POLYGON = - listOf(Coordinates(0.0, 1.0), Coordinates(1.0, 1.0), Coordinates(0.0, 1.0)) - } -}