Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -100,8 +111,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,10 +18,11 @@ 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. */
Expand All @@ -31,6 +32,29 @@ object LoiConverter {
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()

/** Keys within a `Property`, which holds one of a string or a numeric value. */
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)

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()
}

fun toLoi(survey: Survey, doc: DocumentSnapshot): Result<LocationOfInterest> = runCatching {
toLoiUnchecked(survey, doc)
}
Expand All @@ -39,8 +63,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 +80,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 Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class LoiConverterTest {
source = Source.IMPORTED
properties.put("property1", property { stringValue = "value1" })
properties.put("property2", property { numericValue = 123.0 })
properties.put("name", property { stringValue = "a plot" })
properties.put("id", property { stringValue = "plot-7" })
}

@Test
Expand All @@ -113,7 +115,7 @@ class LoiConverterTest {
lastModified = AuditInfo(user = USER, 987654321L * 1000, 9876543210L * 1000),
geometry = Point(coordinates = Coordinates(1.0, 2.0)),
submissionCount = 1,
properties = mapOf("property1" to "value1", "property2" to 123.0),
properties = mapOf("name" to "a plot", "id" to "plot-7"),
isPredefined = true,
),
toLocationOfInterest(),
Expand Down
Loading
Loading