Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ object Constants {
const val DB_NAME = "ground.db"

// Firebase Cloud Firestore settings.
const val FIRESTORE_LOGGING_ENABLED = true
val FIRESTORE_LOGGING_ENABLED = !isReleaseBuild()

// Photos
const val PHOTO_EXT = ".jpg"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,27 @@ typealias MessageBuilder = GeneratedMessageLite.Builder<*, *>
* This implementation is tightly bound to the implementation of code generated by
* protobuf-kotlin-lite. Future versions of the library may require changes to this util.
*/
@Suppress("UNCHECKED_CAST")
fun <T : Message> KClass<T>.parseFrom(
documentSnapshot: DocumentSnapshot,
idFieldNumber: MessageFieldNumber? = null,
): T = parseFrom(documentSnapshot.id, documentSnapshot.data, idFieldNumber)

/**
* Returns a new instance of the specified [Message] populated with [documentId] and [data].
*
* Allows callers to skip fields that require custom processing before mapping.
*/
@Suppress("UNCHECKED_CAST")
fun <T : Message> KClass<T>.parseFrom(
documentId: String,
data: Map<String, Any>?,
idFieldNumber: MessageFieldNumber? = null,
): T {
val builder = newBuilderForType()
if (idFieldNumber != null) {
builder.setOrLog(getFieldName(idFieldNumber), documentSnapshot.id)
builder.setOrLog(getFieldName(idFieldNumber), documentId)
}
documentSnapshot.data.copyInto(builder)
data.copyInto(builder)
return builder.build() as T
}

Expand Down Expand Up @@ -101,8 +112,10 @@ private fun FirestoreValue.toMessageValue(
(this as FirestoreMap).toMessageMap(builderType.getMapValueType(fieldName))
} else if (fieldType.isSubclassOf(List::class)) {
val elementType = builderType.getListElementFieldTypeByName(fieldName)
// Resolved once rather than per element, since repeated fields can be long.
val isMessageElement = elementType.isSubclassOf(GeneratedMessageLite::class)
(this as List<FirestoreValue>).map {
if (elementType.isSubclassOf(GeneratedMessageLite::class)) {
if (isMessageElement) {
(elementType as KClass<Message>).parseFrom(it as FirestoreMap)
} else {
it.toMessageValue(elementType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package org.groundplatform.android.data.remote.firebase.protobuf

import com.google.protobuf.GeneratedMessageLite
import com.google.protobuf.Internal.EnumLite
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KProperty
Expand All @@ -27,6 +29,7 @@ import kotlin.reflect.full.declaredFunctions
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.isAccessible
import kotlin.reflect.jvm.javaMethod
import timber.log.Timber

/** A key used in a document or a nested object in Firestore. */
Expand Down Expand Up @@ -86,26 +89,54 @@ fun <T : MessageBuilder> KClass<T>.getListElementFieldTypeByName(fieldName: Stri
java.getDeclaredMethod("get${fieldName.toUpperCamelCase()}", Int::class.java).returnType?.kotlin
?: throw UnsupportedOperationException("Getter not found for field $fieldName")

private fun MessageBuilder.getSetterByFieldName(fieldName: String): KFunction<*> =
// Message fields generated two setters; ignore the Builder's setter in favor of the
// message setter.
this::class.declaredFunctions.find {
it.name == "set${fieldName.toUpperCamelCase()}" && !it.parameters[1].type.isBuilder()
} ?: throw UnsupportedOperationException("Setter not found for field $fieldName")
/**
* Builder methods already looked up, keyed by builder class and method name.
*
* Finding one uses `declaredFunctions`, which is slow and remembers nothing, so it runs once per
* method rather than once per field of every document.
*/
private val methodCache = ConcurrentHashMap<MemberKey, Method>()

/** Identifies a single method of a class, for use as a cache key. */
private data class MemberKey(val declaringClass: Class<*>, val methodName: String)

/** Returns the method named [name], calling [resolve] to find it the first time only. */
private fun MessageBuilder.cachedMethod(name: String, resolve: () -> KFunction<*>): Method {
val key = MemberKey(javaClass, name)
return methodCache[key]
?: resolve().javaMethod!!.apply { isAccessible = true }.also { methodCache[key] = it }
}

private fun MessageBuilder.getSetterByFieldName(fieldName: String): Method {
val name = "set${fieldName.toUpperCamelCase()}"
return cachedMethod(name) {
// Message fields generated two setters; ignore the Builder's setter in favor of the
// message setter.
this::class.declaredFunctions.find { it.name == name && !it.parameters[1].type.isBuilder() }
?: throw UnsupportedOperationException("Setter not found for field $fieldName")
}
}

private fun MessageBuilder.getAddAllByFieldName(fieldName: String): KFunction<*> =
// Message fields generated two setters; ignore the Builder's setter in favor of the
// message setter.
this::class.declaredFunctions.find {
it.name == "addAll${fieldName.toUpperCamelCase()}" && !it.parameters[1].type.isBuilder()
} ?: throw UnsupportedOperationException("addAll not found for field $fieldName")
private fun MessageBuilder.getAddAllByFieldName(fieldName: String): Method {
val name = "addAll${fieldName.toUpperCamelCase()}"
return cachedMethod(name) {
// Message fields generated two setters; ignore the Builder's setter in favor of the
// message setter.
this::class.declaredFunctions.find { it.name == name && !it.parameters[1].type.isBuilder() }
?: throw UnsupportedOperationException("addAll not found for field $fieldName")
}
}

private fun KType.isBuilder() =
(classifier as KClass<*>).isSubclassOf(GeneratedMessageLite.Builder::class)

private fun MessageBuilder.getPutAllByFieldName(fieldName: String): KFunction<*> =
this::class.declaredFunctions.find { it.name == "putAll${fieldName.toUpperCamelCase()}" }
?: throw UnsupportedOperationException("Putter not found for field $fieldName")
private fun MessageBuilder.getPutAllByFieldName(fieldName: String): Method {
val name = "putAll${fieldName.toUpperCamelCase()}"
return cachedMethod(name) {
this::class.declaredFunctions.find { it.name == name }
?: throw UnsupportedOperationException("Putter not found for field $fieldName")
}
}

fun <T : Message> KClass<T>.newBuilderForType() =
java.getDeclaredMethod("newBuilder").invoke(null) as MessageBuilder
Expand Down Expand Up @@ -180,15 +211,15 @@ private fun String.toUpperCamelCase(): String =
toCamelCase().replaceFirstChar { it.uppercaseChar() }

private fun MessageBuilder.set(fieldName: MessageFieldName, value: MessageValue) {
getSetterByFieldName(fieldName).call(this, value)
getSetterByFieldName(fieldName).invoke(this, value)
}

private fun MessageBuilder.addAll(fieldName: MessageFieldName, value: MessageValue) {
getAddAllByFieldName(fieldName).call(this, value)
getAddAllByFieldName(fieldName).invoke(this, value)
}

private fun MessageBuilder.putAll(fieldName: MessageFieldName, value: MessageMap) {
getPutAllByFieldName(fieldName).call(this, value)
getPutAllByFieldName(fieldName).invoke(this, value)
}

fun <T : Message> KClass<T>.getFieldProperties(): List<KProperty<*>> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,23 @@ package org.groundplatform.android.data.remote.firebase.schema
import com.google.firebase.firestore.DocumentSnapshot
import org.groundplatform.android.data.remote.DataStoreException
import org.groundplatform.android.data.remote.firebase.protobuf.parseFrom
import org.groundplatform.android.data.remote.firebase.schema.GeometryConverter.toGeometry
import org.groundplatform.android.proto.LocationOfInterest as LocationOfInterestProto
import org.groundplatform.android.proto.LocationOfInterest.Source
import org.groundplatform.domain.model.Survey
import org.groundplatform.domain.model.locationofinterest.LOI_ID_PROPERTY
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.domain.model.locationofinterest.LocationOfInterest

/** Converts between Firestore documents and [LocationOfInterest] instances. */
object LoiConverter {
// TODO: Define field names on DocumentReference objects, not converters.
// Issue URL: https://github.com/google/ground-android/issues/2375
const val GEOMETRY_TYPE = "type"
const val POLYGON_TYPE = "Polygon"
private val GEOMETRY_FIELD = LocationOfInterestProto.GEOMETRY_FIELD_NUMBER.toString()
private val PROPERTIES_FIELD = LocationOfInterestProto.PROPERTIES_FIELD_NUMBER.toString()
private val PROPERTY_STRING_VALUE =
LocationOfInterestProto.Property.STRING_VALUE_FIELD_NUMBER.toString()
private val PROPERTY_NUMERIC_VALUE =
LocationOfInterestProto.Property.NUMERIC_VALUE_FIELD_NUMBER.toString()

private val RETAINED_PROPERTIES = listOf(LOI_NAME_PROPERTY, LOI_ID_PROPERTY)

fun toLoi(survey: Survey, doc: DocumentSnapshot): Result<LocationOfInterest> = runCatching {
toLoiUnchecked(survey, doc)
Expand All @@ -39,8 +44,11 @@ object LoiConverter {
private fun toLoiUnchecked(survey: Survey, doc: DocumentSnapshot): LocationOfInterest {
if (!doc.exists()) throw DataStoreException("LOI missing")
val loiId = doc.id
val loiProto = LocationOfInterestProto::class.parseFrom(doc, 1)
val geometry = loiProto.geometry.toGeometry()
val data = doc.data.orEmpty()
val geometry = LoiGeometryConverter.toGeometry(data[GEOMETRY_FIELD])
val properties = pruneUnusedProperties(data[PROPERTIES_FIELD])
val loiProto =
LocationOfInterestProto::class.parseFrom(loiId, data - GEOMETRY_FIELD - PROPERTIES_FIELD, 1)
val jobId = loiProto.jobId
val job = DataStoreException.checkNotNull(survey.getJob(jobId), "job $jobId")
// Degrade gracefully when audit info missing in remote db.
Expand All @@ -53,16 +61,6 @@ object LoiConverter {
}
val submissionCount = loiProto.submissionCount

val properties =
loiProto.propertiesMap.entries.associate {
val propertyValue =
if (it.value.hasNumericValue()) {
it.value.numericValue
} else {
it.value.stringValue
}
it.key to propertyValue
}
val isPredefined = loiProto.source == Source.IMPORTED
return LocationOfInterest(
id = loiId,
Expand All @@ -71,12 +69,22 @@ object LoiConverter {
job = job,
created = created,
lastModified = lastModified,
// TODO: Set geometry once LOI has been updated to use our own model.
// Issue URL: https://github.com/google/ground-android/issues/929
geometry = geometry,
submissionCount = submissionCount,
properties = properties,
isPredefined = isPredefined,
)
}

private fun pruneUnusedProperties(value: Any?): Map<String, Any> {
val properties = value as? Map<*, *> ?: return mapOf()
return RETAINED_PROPERTIES.mapNotNull { key ->
(properties[key] as? Map<*, *>)?.let { property ->
val numeric = property[PROPERTY_NUMERIC_VALUE] as? Number
val text = property[PROPERTY_STRING_VALUE] as? String
(numeric ?: text)?.let { key to it }
}
}
.toMap()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2026 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 org.groundplatform.android.data.remote.firebase.schema

import org.groundplatform.android.data.remote.DataStoreException
import org.groundplatform.android.proto.Coordinates as CoordinatesProto
import org.groundplatform.android.proto.Geometry as GeometryProto
import org.groundplatform.android.proto.LinearRing as LinearRingProto
import org.groundplatform.android.proto.MultiPolygon as MultiPolygonProto
import org.groundplatform.android.proto.Point as PointProto
import org.groundplatform.android.proto.Polygon as PolygonProto
import org.groundplatform.domain.model.geometry.Coordinates
import org.groundplatform.domain.model.geometry.Geometry
import org.groundplatform.domain.model.geometry.LinearRing
import org.groundplatform.domain.model.geometry.MultiPolygon
import org.groundplatform.domain.model.geometry.Point
import org.groundplatform.domain.model.geometry.Polygon

// Keys are proto field numbers, as stored in Firestore. Derived from the generated constants so
// they stay correct if the schema is renumbered.
private val POINT = GeometryProto.POINT_FIELD_NUMBER.toString()
private val POLYGON = GeometryProto.POLYGON_FIELD_NUMBER.toString()
private val MULTI_POLYGON = GeometryProto.MULTI_POLYGON_FIELD_NUMBER.toString()
private val LATITUDE = CoordinatesProto.LATITUDE_FIELD_NUMBER.toString()
private val LONGITUDE = CoordinatesProto.LONGITUDE_FIELD_NUMBER.toString()
private val POINT_COORDINATES = PointProto.COORDINATES_FIELD_NUMBER.toString()
private val RING_COORDINATES = LinearRingProto.COORDINATES_FIELD_NUMBER.toString()
private val SHELL = PolygonProto.SHELL_FIELD_NUMBER.toString()
private val HOLES = PolygonProto.HOLES_FIELD_NUMBER.toString()
private val POLYGONS = MultiPolygonProto.POLYGONS_FIELD_NUMBER.toString()

/**
* Builds [Geometry] straight from the nested maps of a Firestore document. Direct Firestore
* geometry parsing avoids expensive reflection overhead.
*/
internal object LoiGeometryConverter {

/** Converts the value of an LOI's geometry field. Throws [DataStoreException] if malformed. */
fun toGeometry(value: Any?): Geometry {
val geometry = value.orThrow<Map<*, *>>()
val point = geometry[POINT]
val polygon = geometry[POLYGON]
val multiPolygon = geometry[MULTI_POLYGON]
return when {
point != null -> Point(point.orThrow<Map<*, *>>()[POINT_COORDINATES].toCoordinates())
polygon != null -> polygon.toPolygon()
multiPolygon != null ->
MultiPolygon(
multiPolygon.orThrow<Map<*, *>>()[POLYGONS].orThrow<List<*>>().map { it.toPolygon() }
)
else -> throw DataStoreException("Unrecognized geometry type: ${geometry.keys}")
}
}

private fun Any?.toPolygon(): Polygon {
val polygon = orThrow<Map<*, *>>()
return Polygon(
polygon[SHELL].toLinearRing(),
polygon[HOLES]?.orThrow<List<*>>()?.map { it.toLinearRing() } ?: listOf(),
)
}

private fun Any?.toLinearRing() =
LinearRing(orThrow<Map<*, *>>()[RING_COORDINATES].orThrow<List<*>>().map { it.toCoordinates() })

private fun Any?.toCoordinates(): Coordinates {
val coordinates = orThrow<Map<*, *>>()
return Coordinates(
coordinates[LATITUDE].orThrow<Number>().toDouble(),
coordinates[LONGITUDE].orThrow<Number>().toDouble(),
)
}

private inline fun <reified T> Any?.orThrow(): T =
this as? T ?: throw DataStoreException("Expected ${T::class.simpleName} but got $this")
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import org.groundplatform.android.proto.Task.DateTimeQuestion.Type.BOTH_DATE_AND
import org.groundplatform.android.proto.Task.MultipleChoiceQuestion.Type.SELECT_MULTIPLE
import org.groundplatform.android.proto.TaskKt.dateTimeQuestion
import org.groundplatform.android.proto.TaskKt.multipleChoiceQuestion
import org.groundplatform.android.proto.coordinates
import org.groundplatform.android.proto.geometry
import org.groundplatform.android.proto.linearRing
import org.groundplatform.android.proto.polygon
import org.groundplatform.android.proto.survey
import org.groundplatform.android.proto.task
import org.groundplatform.android.test.deeplyNestedTestObject
Expand All @@ -51,6 +55,25 @@ class FirestoreToProtobufExtTest(

companion object {
@get:ClassRule @JvmStatic var timberRule = TimberTestRule()
/** A message carrying a repeated nested message: a ring of coordinates. */
private val REPEATED_MESSAGE_PROTO = geometry {
polygon = polygon {
shell = linearRing {
coordinates.add(
coordinates {
latitude = 1.0
longitude = 2.0
}
)
coordinates.add(
coordinates {
latitude = 3.0
longitude = 4.0
}
)
}
}
}

@JvmStatic
@Parameterized.Parameters(name = "{0}")
Expand Down Expand Up @@ -126,6 +149,12 @@ class FirestoreToProtobufExtTest(
),
testCase(desc = "skips enum value 0", input = mapOf("3" to 0), expected = task {}),
testCase(desc = "skips an unspecified enum value", input = mapOf(), expected = task {}),
// Exercises the repeated-message branch, whose per-element type resolution is cached.
testCase(
desc = "converts repeated messages",
input = REPEATED_MESSAGE_PROTO.toFirestoreMap(),
expected = REPEATED_MESSAGE_PROTO,
),
testCase(
desc = "converts oneof messages",
input = mapOf("10" to mapOf("1" to 2)),
Expand Down
Loading
Loading