Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import org.groundplatform.android.data.local.room.fields.EntityDeletionState
import org.groundplatform.android.model.locationofinterest.LoiProperties
import org.groundplatform.domain.model.locationofinterest.LoiProperties

/**
* Defines how Room persists LOIs in the local db. By default, Room uses the name of object fields
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import org.groundplatform.android.data.local.room.fields.MutationEntitySyncStatus
import org.groundplatform.android.data.local.room.fields.MutationEntityType
import org.groundplatform.android.model.locationofinterest.LoiProperties
import org.groundplatform.domain.model.locationofinterest.LoiProperties

/**
* Defines how Room persists LOI mutations for remote sync in the local db. By default, Room uses
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import com.google.protobuf.timestamp
import java.util.Date
import kotlinx.collections.immutable.toImmutableMap
import org.groundplatform.android.model.User
import org.groundplatform.android.model.locationofinterest.LoiProperties
import org.groundplatform.android.model.mutation.LocationOfInterestMutation
import org.groundplatform.android.model.mutation.Mutation
import org.groundplatform.android.model.mutation.SubmissionMutation
Expand Down Expand Up @@ -59,6 +58,7 @@ 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
import org.groundplatform.domain.model.locationofinterest.LoiProperties

fun SubmissionMutation.createSubmissionMessage(user: User) = submission {
assert(userId == user.id) { "UserId doesn't match: expected $userId, found ${user.id}" }
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/org/groundplatform/android/di/DomainModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.di

import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.groundplatform.android.repository.LoiDataProvider
import org.groundplatform.domain.usecases.GetLoiReportUseCase
import org.groundplatform.domain.usecases.LoiDataProviderInterface

@InstallIn(SingletonComponent::class)
@Module
internal abstract class DomainModule {
@Binds abstract fun bindLoiDataProvider(impl: LoiDataProvider): LoiDataProviderInterface

companion object {
@Provides
fun provideGetLoiReportUseCase(loiDataProvider: LoiDataProviderInterface) =
GetLoiReportUseCase(loiDataProvider)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,7 @@ import org.groundplatform.android.model.mutation.LocationOfInterestMutation
import org.groundplatform.android.model.mutation.Mutation
import org.groundplatform.android.model.mutation.Mutation.SyncStatus
import org.groundplatform.domain.model.geometry.Geometry

/** Alias for a map of properties with string names. */
typealias LoiProperties = Map<String, Any>

const val LOI_NAME_PROPERTY = "name"

fun generateProperties(loiName: String? = null): LoiProperties =
loiName?.let { mapOf(LOI_NAME_PROPERTY to it) } ?: mapOf()
import org.groundplatform.domain.model.locationofinterest.LoiProperties

/** User-defined locations of interest (LOI) shown on the map. */
data class LocationOfInterest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
package org.groundplatform.android.model.mutation

import java.util.Date
import org.groundplatform.android.model.locationofinterest.LoiProperties
import org.groundplatform.domain.model.geometry.Geometry
import org.groundplatform.domain.model.locationofinterest.LoiProperties

data class LocationOfInterestMutation(
override val id: Long? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import org.groundplatform.android.model.Role
import org.groundplatform.android.model.Survey
import org.groundplatform.android.model.job.Job
import org.groundplatform.android.model.locationofinterest.LocationOfInterest
import org.groundplatform.android.model.locationofinterest.generateProperties
import org.groundplatform.android.model.map.Bounds
import org.groundplatform.android.model.mutation.LocationOfInterestMutation
import org.groundplatform.android.model.mutation.Mutation
Expand All @@ -42,6 +41,7 @@ import org.groundplatform.android.proto.Survey.DataVisibility
import org.groundplatform.android.system.auth.AuthenticationManager
import org.groundplatform.android.ui.map.gms.GmsExt.contains
import org.groundplatform.domain.model.geometry.Geometry
import org.groundplatform.domain.model.locationofinterest.generateProperties
import timber.log.Timber

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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.repository

import javax.inject.Inject
import org.groundplatform.domain.usecases.LoiDataProviderInterface

class LoiDataProvider
@Inject
constructor(private val locationOfInterestRepository: LocationOfInterestRepository) :
LoiDataProviderInterface {
override suspend fun get(surveyId: String, loiId: String): LoiDataProviderInterface.LoiData? {
val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId)
return loi?.let {
LoiDataProviderInterface.LoiData(geometry = it.geometry, properties = it.properties)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ package org.groundplatform.android.ui.common
import android.content.res.Resources
import javax.inject.Inject
import org.groundplatform.android.R
import org.groundplatform.android.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.android.model.locationofinterest.LocationOfInterest
import org.groundplatform.domain.model.geometry.Geometry
import org.groundplatform.domain.model.geometry.MultiPolygon
import org.groundplatform.domain.model.geometry.Point
import org.groundplatform.domain.model.geometry.Polygon
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY

/** Helper class for creating user-visible text. */
class LocationOfInterestHelper @Inject internal constructor(private val resources: Resources) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.groundplatform.android.R
import org.groundplatform.android.databinding.BasemapLayoutBinding
import org.groundplatform.android.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.android.proto.Survey.DataSharingTerms
import org.groundplatform.android.ui.common.AbstractMapContainerFragment
import org.groundplatform.android.ui.common.BaseMapViewModel
Expand All @@ -44,6 +43,7 @@ import org.groundplatform.android.ui.map.MapFragment
import org.groundplatform.android.usecases.datasharingterms.GetDataSharingTermsUseCase
import org.groundplatform.android.util.renderComposableDialog
import org.groundplatform.android.util.setComposableContent
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY
import timber.log.Timber

/** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */
Expand Down
2 changes: 1 addition & 1 deletion app/src/test/java/org/groundplatform/android/FakeData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import org.groundplatform.android.model.User
import org.groundplatform.android.model.imagery.OfflineArea
import org.groundplatform.android.model.job.Job
import org.groundplatform.android.model.job.Style
import org.groundplatform.android.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.android.model.locationofinterest.LocationOfInterest
import org.groundplatform.android.model.map.Bounds
import org.groundplatform.android.model.mutation.LocationOfInterestMutation
Expand All @@ -42,6 +41,7 @@ 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
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY

/**
* Shared test data constants. Tests are expected to override existing or set missing values when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import java.time.Instant
import java.util.Date
import org.groundplatform.android.FakeData
import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_NAME
import org.groundplatform.android.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.android.model.mutation.LocationOfInterestMutation
import org.groundplatform.android.model.mutation.Mutation
import org.groundplatform.android.proto.AuditInfo.CLIENT_TIMESTAMP_FIELD_NUMBER
Expand All @@ -47,6 +46,7 @@ import org.groundplatform.domain.model.geometry.Coordinates
import org.groundplatform.domain.model.geometry.LinearRing
import org.groundplatform.domain.model.geometry.Point
import org.groundplatform.domain.model.geometry.Polygon
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY
import org.junit.Assert.assertThrows
import org.junit.Test

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import com.google.protobuf.timestamp
import java.time.Instant
import java.util.Date
import org.groundplatform.android.model.User
import org.groundplatform.android.model.locationofinterest.generateProperties
import org.groundplatform.android.model.mutation.LocationOfInterestMutation
import org.groundplatform.android.model.mutation.Mutation
import org.groundplatform.android.proto.LocationOfInterest
Expand All @@ -32,6 +31,7 @@ import org.groundplatform.android.proto.locationOfInterest
import org.groundplatform.android.proto.point
import org.groundplatform.domain.model.geometry.Coordinates
import org.groundplatform.domain.model.geometry.Point
import org.groundplatform.domain.model.locationofinterest.generateProperties
import org.junit.Assert.assertThrows
import org.junit.Test

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import org.groundplatform.android.BaseHiltTest
import org.groundplatform.android.FakeData
import org.groundplatform.android.model.locationofinterest.LOI_NAME_PROPERTY
import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
Expand Down
7 changes: 6 additions & 1 deletion core/domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ kotlin {
}
}

commonTest { dependencies { implementation(libs.kotlin.test) } }
commonTest {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.domain.model.locationofinterest

/** Alias for a map of properties with string names. */
typealias LoiProperties = Map<String, Any>

const val LOI_NAME_PROPERTY = "name"

fun generateProperties(loiName: String? = null): LoiProperties =
loiName?.let { mapOf(LOI_NAME_PROPERTY to it) } ?: mapOf()
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.domain.model
package org.groundplatform.domain.model.locationofinterest

import kotlinx.serialization.json.JsonObject

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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.domain.usecases

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.groundplatform.domain.model.geometry.Coordinates
import org.groundplatform.domain.model.geometry.Geometry
import org.groundplatform.domain.model.geometry.LineString
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
import org.groundplatform.domain.model.locationofinterest.LoiProperties
import org.groundplatform.domain.model.locationofinterest.LoiReport

/**
* Use case that generates a [LoiReport] containing the LOI geometry and properties as a GeoJSON.
*
* Supported geometry types: [Point], [LineString], [Polygon], [MultiPolygon].
*/
class GetLoiReportUseCase(private val loiGeometryProvider: LoiDataProviderInterface) {
/**
* Returns a [LoiReport] for the given LOI, or `null` if it does not exist.
*
* @param loiId the identifier of the location of interest.
* @param surveyId the identifier of the survey the LOI belongs to.
* @throws IllegalStateException if the LOI geometry is a bare [LinearRing].
*/
suspend operator fun invoke(loiId: String, surveyId: String): LoiReport? {
val loiData = loiGeometryProvider.get(surveyId, loiId)
return loiData?.let { LoiReport(it.geometry.toGeoJson(it.properties)) }
}

/**
* Converts a [Geometry] to its GeoJSON representation as defined by
* [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946).
*/
private fun Geometry.toGeoJson(loiProperties: LoiProperties): JsonObject {
val geometryJson =
when (this) {
is Point -> geoJsonObject(TYPE_POINT, coordinatesToPosition(coordinates))
is LineString -> geoJsonObject(TYPE_LINE_STRING, coordinatesToPositions(coordinates))
is LinearRing ->
error(
"LinearRing cannot be exported as GeoJSON. They are only used inside Polygon coordinates."
)
is Polygon -> geoJsonObject(TYPE_POLYGON, polygonToCoordinates(this))
is MultiPolygon ->
geoJsonObject(TYPE_MULTI_POLYGON, JsonArray(polygons.map { polygonToCoordinates(it) }))
}
return JsonObject(
mapOf(
KEY_TYPE to JsonPrimitive(TYPE_FEATURE),
KEY_PROPERTIES to JsonObject(loiProperties.mapValues { it.value.toJsonPrimitive() }),
KEY_GEOMETRY to geometryJson,
)
)
}

private fun Any.toJsonPrimitive(): JsonPrimitive =
when (this) {
is String -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is Boolean -> JsonPrimitive(this)
else -> JsonPrimitive(this.toString())
}

private fun geoJsonObject(type: String, coordinates: JsonElement): JsonObject =
JsonObject(mapOf(KEY_TYPE to JsonPrimitive(type), KEY_COORDINATES to coordinates))

/** Converts a single [Coordinates] to a GeoJSON position: [lng, lat]. */
private fun coordinatesToPosition(coordinates: Coordinates): JsonArray =
JsonArray(listOf(JsonPrimitive(coordinates.lng), JsonPrimitive(coordinates.lat)))

/** Converts a list of [Coordinates] to a GeoJSON array of positions. */
private fun coordinatesToPositions(coordinates: List<Coordinates>): JsonArray =
JsonArray(coordinates.map { coordinatesToPosition(it) })

/** Converts a [Polygon] to its GeoJSON coordinates (shell + holes as ring arrays). */
private fun polygonToCoordinates(polygon: Polygon): JsonArray {
val rings = mutableListOf(coordinatesToPositions(polygon.shell.coordinates))
polygon.holes.forEach { rings.add(coordinatesToPositions(it.coordinates)) }
return JsonArray(rings)
}

private companion object {
const val KEY_TYPE = "type"
const val TYPE_FEATURE = "Feature"
const val KEY_PROPERTIES = "properties"
const val KEY_GEOMETRY = "geometry"
const val KEY_COORDINATES = "coordinates"
const val TYPE_POINT = "Point"
const val TYPE_LINE_STRING = "LineString"
const val TYPE_POLYGON = "Polygon"
const val TYPE_MULTI_POLYGON = "MultiPolygon"
}
}
Loading
Loading