Geofence Service — Data Layer
Covers the full geofence data pipeline: Retrofit API → DTOs → Mapper → Repository → DataStore → UI.
GeofenceApi
Retrofit interface at data/remote/api/GeofenceApi.kt. All methods are suspend.
interface GeofenceApi {
@GET("api/geofences")
suspend fun getGeofences(): List<GeofenceDto>
@GET("api/geofences")
suspend fun getGeofencesByDevice(@Query("deviceId") deviceId: Int): List<GeofenceDto>
@GET("api/geofences/{id}")
suspend fun getGeofence(@Path("id") id: Int): GeofenceDto
@POST("api/geofences")
suspend fun createGeofence(@Body request: CreateGeofenceRequest): GeofenceDto
@PUT("api/geofences/{id}")
suspend fun updateGeofence(@Path("id") id: Int, @Body request: UpdateGeofenceRequest): GeofenceDto
@DELETE("api/geofences/{id}")
suspend fun deleteGeofence(@Path("id") id: Int)
}
| Method | HTTP | Path | Body | Returns |
|---|---|---|---|---|
getGeofences | GET | api/geofences | — | List<GeofenceDto> |
getGeofencesByDevice | GET | api/geofences?deviceId= | — | List<GeofenceDto> |
getGeofence | GET | api/geofences/{id} | — | GeofenceDto |
createGeofence | POST | api/geofences | CreateGeofenceRequest | GeofenceDto |
updateGeofence | PUT | api/geofences/{id} | UpdateGeofenceRequest | GeofenceDto |
deleteGeofence | DELETE | api/geofences/{id} | — | Unit |
Geofence DTOs
All DTOs live in data/remote/dto/GeofenceDtos.kt.
Request Models
data class CreateGeofenceRequest(
val name: String,
val description: String? = null,
val area: String,
val deviceId: Int? = null,
val attributes: Map<String, Any>? = null
)
data class UpdateGeofenceRequest(
val name: String? = null,
val description: String? = null,
val area: String? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val attributes: Map<String, Any>? = null
)
CreateGeofenceRequest requires name and area; all other fields are optional. UpdateGeofenceRequest is fully optional — only non-null fields are sent as a partial update.
Response Model
data class GeofenceCenterDto(val lat: Double, val lon: Double)
data class GeofenceDto(
val id: Int,
val userId: Int? = null,
val name: String,
val description: String? = null,
val type: String? = null,
val center: GeofenceCenterDto? = null,
val radius: Double? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val deviceIds: List<Int>? = null,
val color: String? = null,
val createdAt: String? = null,
val area: String? = null,
@SerializedName("calendar_id")
val calendarId: Int? = null,
val attributes: Map<String, Any>? = null
)
area and calendarId are legacy fields kept for backward compatibility. calendarId uses @SerializedName("calendar_id") for JSON mapping.
GeofenceMapper
data/mappers/GeofenceMapper.kt — @Singleton, injected with @Inject constructor().
Public Methods
| Method | Input | Output |
|---|---|---|
toDomain(dto: GeofenceDto) | GeofenceDto | Geofence |
toDomain(dtos: List<GeofenceDto>) | List<GeofenceDto> | List<Geofence> |
toCreateRequest(data: GeofenceCreateData) | GeofenceCreateData | CreateGeofenceRequest |
toUpdateRequest(data: GeofenceUpdateData) | GeofenceUpdateData | UpdateGeofenceRequest |
Key Mapping Logic
toDomain(GeofenceDto) — maps every DTO field to the domain Geofence. Notable transformations:
type→GeofenceType.fromString(dto.type)(handlesnull→CIRCLE)center→ mapped toCoordinate(lat, lon)if non-nulluserId→ defaults to0whennullalertOnEnter,alertOnExit→ default totruewhennulldeviceIds→ defaults toemptyList()whennullattributes→ defaults toemptyMap()whennull
toCreateRequest — direct field mapping from GeofenceCreateData to CreateGeofenceRequest.
toUpdateRequest — direct field mapping from GeofenceUpdateData to UpdateGeofenceRequest.
Domain Entities
Defined in domain/entities/Geofence.kt.
enum class GeofenceType {
CIRCLE,
POLYGON;
companion object {
fun fromString(value: String?): GeofenceType = when (value?.lowercase()) {
"circle" -> CIRCLE
"polygon" -> POLYGON
else -> CIRCLE
}
}
}
data class Coordinate(val latitude: Double, val longitude: Double)
data class Geofence(
val id: Int,
val userId: Int,
val name: String,
val description: String?,
val type: GeofenceType = GeofenceType.CIRCLE,
val center: Coordinate? = null,
val radius: Double? = null,
val alertOnEnter: Boolean = true,
val alertOnExit: Boolean = true,
val deviceIds: List<Int> = emptyList(),
val color: String? = null,
val area: String? = null,
val attributes: Map<String, Any> = emptyMap()
)
data class GeofenceCreateData(
val name: String,
val description: String? = null,
val area: String,
val deviceId: Int? = null,
val attributes: Map<String, Any>? = null
)
data class GeofenceUpdateData(
val name: String? = null,
val description: String? = null,
val area: String? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val attributes: Map<String, Any>? = null
)
GeofenceRepository
Interface at domain/repositories/GeofenceRepository.kt. Defines the domain contract.
interface GeofenceRepository {
suspend fun getAll(): List<Geofence>
suspend fun getByDeviceId(deviceId: Int): List<Geofence>
suspend fun getById(id: Int): Geofence
suspend fun create(data: GeofenceCreateData): Geofence
suspend fun update(id: Int, data: GeofenceUpdateData): Geofence
suspend fun delete(id: Int)
}
GeofenceRepositoryImpl
data/repositories/GeofenceRepositoryImpl.kt — @Singleton.
Constructor (3 dependencies)
@Singleton
class GeofenceRepositoryImpl @Inject constructor(
private val geofenceApi: GeofenceApi,
private val geofenceMapper: GeofenceMapper,
private val geofenceDataStore: GeofenceDataStore
) : GeofenceRepository
Extra Property
val geofencesFlow: StateFlow<List<Geofence>>
get() = geofenceDataStore.geofences
Exposes the GeofenceDataStore state flow for ViewModels to observe. Not part of the GeofenceRepository interface.
Method Implementations
| Method | Strategy |
|---|---|
getAll() | Fetches all geofences from API, maps via geofenceMapper.toDomain(), stores via geofenceDataStore.setGeofences(). |
getByDeviceId(deviceId) | Returns cached geofences from GeofenceDataStore if isDeviceLoaded(deviceId) is true. Otherwise fetches from API with ?deviceId= query, maps, and stores via setGeofencesForDevice(). |
refreshByDeviceId(deviceId) | Invalidates the device cache via geofenceDataStore.invalidateDevice(), then always fetches from API and stores. Not part of the interface — called directly on the impl. |
getById(id) | Checks geofenceDataStore.getGeofence(id) first. On cache miss, fetches from API and maps. On 404 → GeofenceNotFoundException. |
create(data) | Maps GeofenceCreateData → CreateGeofenceRequest, POSTs to API, maps response. If data.deviceId is set but missing from the response's deviceIds, enriches the result (backend links asynchronously). Adds to GeofenceDataStore. |
update(id, data) | Maps GeofenceUpdateData → UpdateGeofenceRequest, PUTs to API, maps response, updates GeofenceDataStore. On 404 → GeofenceNotFoundException. |
delete(id) | DELETEs via API, then removes from GeofenceDataStore. On 404 → GeofenceNotFoundException. |
Error Handling
All API-calling methods follow the same pattern:
HttpException→mapHttpException()→NetworkException("HTTP error: {code}"), with special-case 404 →GeofenceNotFoundExceptionwhere applicable (getById,update,delete).IOException→NetworkExceptionwith contextual message.
Data Flow
API Fetch: API → GeofenceMapper → GeofenceDataStore.setGeofences() → ViewModels (via StateFlow)
User Action: ViewModel → Repository → API → GeofenceDataStore.add/update/remove → all ViewModels
GeofenceDataStore
core/data/GeofenceDataStore.kt — @Singleton, the single source of truth for geofence data. Bridges HTTP API calls into a unified observable state, similar to DeviceDataStore.
State Flows
| Property | Type | Description |
|---|---|---|
geofences | StateFlow<List<Geofence>> | Observable list of all geofences |
isInitialized | StateFlow<Boolean> | true after first data load; used for loading state |
Per-Device Cache
An internal loadedDeviceIds: MutableSet<Int> tracks which devices have had their geofences loaded, enabling lazy per-device loading.
Initialization Methods
suspend fun setGeofences(geofences: List<Geofence>)
suspend fun setGeofencesForDevice(deviceId: Int, geofences: List<Geofence>)
fun isDeviceLoaded(deviceId: Int): Boolean
setGeofences()— replaces all geofences. SetsisInitialized = true.setGeofencesForDevice()— merges geofences for a specific device. Removes existing geofences for the device, adds the new list, marks the device as loaded, and setsisInitialized = trueif not already.isDeviceLoaded()— returns whether geofences have been loaded for a device.
CRUD Methods
suspend fun addGeofence(geofence: Geofence)
suspend fun updateGeofence(geofence: Geofence)
suspend fun removeGeofence(id: Int)
addGeofence()— appends a new geofence. If a geofence with the same ID already exists, updates it instead.updateGeofence()— replaces the geofence matching byid.removeGeofence()— filters out the geofence byid.
Query Methods (synchronous)
fun getGeofencesByDevice(deviceId: Int): List<Geofence>
fun getGeofence(id: Int): Geofence?
getGeofencesByDevice()— filters geofences wheredeviceIdscontains the givendeviceId.getGeofence()— finds a geofence byid, returnsnullif not found.
State Management
suspend fun clear()
fun invalidateDevice(deviceId: Int)
fun invalidate()
clear()— empties all data, resetsisInitializedtofalse, clearsloadedDeviceIds(used on logout).invalidateDevice()— removes a device fromloadedDeviceIds(forces re-fetch for that device).invalidate()— setsisInitializedtofalseand clearsloadedDeviceIdswithout clearing data.
Thread Safety
All mutating suspend methods use a Mutex to serialize writes. StateFlow.update {} ensures atomic compare-and-set on the underlying list.