Position Service — Data Layer
The position service handles GPS position tracking: fetching the latest device positions, querying position history over a time range, and deleting historical records. It follows the standard data layer pattern — a Retrofit API interface, a domain repository interface, a concrete implementation, DTOs, and a mapper.
PositionApi
Retrofit interface at data/remote/api/PositionApi.kt. Three endpoints covering latest positions, history queries, and history deletion.
interface PositionApi {
@GET("api/positions/latest")
suspend fun getLatestPositions(): List<PositionDto>
@GET("api/positions")
suspend fun getPositionHistory(
@Query("deviceId") deviceId: Int,
@Query("from") from: String,
@Query("to") to: String
): List<PositionDto>
@DELETE("api/positions")
suspend fun deleteHistory(
@Query("deviceId") deviceId: Int,
@Query("from") from: String,
@Query("to") to: String
)
}
| Method | HTTP | Path | Parameters | Returns |
|---|---|---|---|---|
getLatestPositions() | GET | api/positions/latest | — | List<PositionDto> |
getPositionHistory(deviceId, from, to) | GET | api/positions | deviceId: Int, from: String, to: String (ISO-8601) | List<PositionDto> |
deleteHistory(deviceId, from, to) | DELETE | api/positions | deviceId: Int, from: String, to: String (ISO-8601) | Unit |
PositionRepository
Domain interface at domain/repositories/PositionRepository.kt. Exposes position operations using domain types (Position, PositionHistoryQuery, Instant) — no DTOs leak upward.
interface PositionRepository {
suspend fun getLatestPosition(deviceId: Int): Position?
suspend fun getLatestPositions(): Map<Int, Position>
suspend fun getHistory(query: PositionHistoryQuery): List<Position>
suspend fun deleteHistory(deviceId: Int, from: java.time.Instant, to: java.time.Instant)
}
| Method | Parameters | Returns | Description |
|---|---|---|---|
getLatestPosition | deviceId: Int | Position? | Latest position for a single device, or null if not found |
getLatestPositions | — | Map<Int, Position> | Latest positions for all devices, keyed by deviceId |
getHistory | query: PositionHistoryQuery | List<Position> | Position history for a device over a time range |
deleteHistory | deviceId: Int, from: Instant, to: Instant | Unit | Delete position history for a device in a time range |
PositionRepositoryImpl
Implementation at data/repositories/PositionRepositoryImpl.kt. Annotated @Singleton and injected via Hilt.
Constructor
@Singleton
class PositionRepositoryImpl @Inject constructor(
private val positionApi: PositionApi,
private val positionMapper: PositionMapper
) : PositionRepository
Method Implementations
getLatestPosition — Fetches all latest positions from the API, then filters client-side to find the one matching deviceId. Returns null if no match.
override suspend fun getLatestPosition(deviceId: Int): Position? = try {
val positions = positionApi.getLatestPositions()
positions.find { it.deviceId == deviceId }?.let { positionMapper.toDomain(it) }
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error fetching latest position", e)
}
getLatestPositions — Fetches all latest positions and returns them as a Map<Int, Position> keyed by deviceId.
override suspend fun getLatestPositions(): Map<Int, Position> = try {
val positions = positionApi.getLatestPositions()
positions.associate { it.deviceId to positionMapper.toDomain(it) }
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error fetching latest positions", e)
}
getHistory — Converts PositionHistoryQuery fields to ISO-8601 strings, calls the API, and maps the result to domain entities.
override suspend fun getHistory(query: PositionHistoryQuery): List<Position> = try {
val positions = positionApi.getPositionHistory(
deviceId = query.deviceId,
from = formatInstant(query.from),
to = formatInstant(query.to)
)
positionMapper.toDomainList(positions)
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error fetching position history", e)
}
deleteHistory — Formats the time range and delegates to the API. Returns Unit.
override suspend fun deleteHistory(deviceId: Int, from: Instant, to: Instant) {
try {
positionApi.deleteHistory(
deviceId = deviceId,
from = formatInstant(from),
to = formatInstant(to)
)
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error deleting history", e)
}
}
Private Helpers
private fun formatInstant(instant: Instant): String = DateTimeFormatter.ISO_INSTANT.format(instant)
private fun mapHttpException(e: HttpException): Exception = NetworkException("HTTP error: ${e.code()}", e)
All HTTP errors are wrapped in NetworkException. IO errors (no connectivity, timeouts) are also wrapped with a descriptive message.
Position DTOs
PositionDto (Response)
Located at data/remote/dto/PositionDto.kt. Represents a GPS position as returned by the API. Nullable fields with sensible defaults handle partial responses.
data class PositionDto(
val id: Int,
val deviceId: Int,
val protocol: String? = null,
val serverTime: String,
val deviceTime: String,
val fixTime: String,
val outdated: Boolean = false,
val valid: Boolean = true,
val latitude: Double? = null,
val longitude: Double? = null,
val altitude: Double? = null,
val speed: Double = 0.0,
val course: Double = 0.0,
val address: String? = null,
val accuracy: Double? = null,
val satellites: Int? = null,
val ignition: Boolean? = null,
val motion: Boolean? = null,
val blocked: Boolean? = null,
val charging: Boolean? = null,
val batteryLevel: Double? = null,
val battery: Double? = null,
val power: Double? = null,
val rssi: Int? = null,
val odometer: Double? = null,
val distance: Double? = null,
val totalDistance: Double? = null,
val alarm: String? = null,
val alarms: List<String>? = null,
val event: String? = null,
val status: String? = null,
val attributes: Map<String, Any>? = null
)
Position (Domain Entity)
Located at domain/entities/Position.kt. Timestamps are Instant instead of raw strings. Includes computed properties for display.
data class Position(
val id: Int,
val deviceId: Int,
val protocol: String?,
val serverTime: Instant,
val deviceTime: Instant,
val fixTime: Instant,
val outdated: Boolean = false,
val valid: Boolean,
val latitude: Double? = null,
val longitude: Double? = null,
val altitude: Double?,
val speed: Double,
val course: Double,
val address: String?,
val accuracy: Double?,
val satellites: Int? = null,
val ignition: Boolean? = null,
val motion: Boolean? = null,
val blocked: Boolean? = null,
val charging: Boolean? = null,
val batteryLevel: Double? = null,
val battery: Double? = null,
val power: Double? = null,
val rssi: Int? = null,
val odometer: Double? = null,
val distance: Double? = null,
val totalDistance: Double? = null,
val alarm: String? = null,
val alarms: List<String>? = null,
val event: String? = null,
val status: String? = null,
val attributes: Map<String, Any> = emptyMap()
) {
val speedKmh: Double get() = speed * 1.852 // API returns knots
val formattedSpeed: String get() = String.format(Locale.US, "%.0f km/h", speedKmh)
val formattedAltitude: String get() = altitude?.let { String.format(Locale.US, "%.0f m", it) } ?: "N/A"
val hasValidCoordinates: Boolean get() = latitude != null && longitude != null
}
PositionHistoryQuery
Also in domain/entities/Position.kt. Encapsulates history query parameters.
data class PositionHistoryQuery(val deviceId: Int, val from: Instant, val to: Instant)
DTO → Domain Field Mapping
DTO Field (PositionDto) | Domain Field (Position) | Transformation |
|---|---|---|
serverTime: String | serverTime: Instant | Instant.parse() with fallback to Instant.now() |
deviceTime: String | deviceTime: Instant | Instant.parse() with fallback to Instant.now() |
fixTime: String | fixTime: Instant | Instant.parse() with fallback to Instant.now() |
attributes: Map<String, Any>? | attributes: Map<String, Any> | ?: emptyMap() (null coalescing) |
| All other fields | Same name and type | Direct copy |
PositionMapper
Located at data/mappers/PositionMapper.kt. A @Singleton Hilt-injected class that converts between DTO and domain representations.
@Singleton
class PositionMapper @Inject constructor() {
fun toDomain(dto: PositionDto): Position = Position(
id = dto.id,
deviceId = dto.deviceId,
protocol = dto.protocol,
serverTime = parseInstant(dto.serverTime),
deviceTime = parseInstant(dto.deviceTime),
fixTime = parseInstant(dto.fixTime),
outdated = dto.outdated,
valid = dto.valid,
latitude = dto.latitude,
longitude = dto.longitude,
altitude = dto.altitude,
speed = dto.speed,
course = dto.course,
address = dto.address,
accuracy = dto.accuracy,
satellites = dto.satellites,
ignition = dto.ignition,
motion = dto.motion,
blocked = dto.blocked,
charging = dto.charging,
batteryLevel = dto.batteryLevel,
battery = dto.battery,
power = dto.power,
rssi = dto.rssi,
odometer = dto.odometer,
distance = dto.distance,
totalDistance = dto.totalDistance,
alarm = dto.alarm,
alarms = dto.alarms,
event = dto.event,
status = dto.status,
attributes = dto.attributes ?: emptyMap()
)
fun toDomainList(dtos: List<PositionDto>): List<Position> = dtos.map { toDomain(it) }
fun toDomainMap(dtos: List<PositionDto>): Map<Int, Position> = dtos.associate { it.deviceId to toDomain(it) }
private fun parseInstant(dateString: String): Instant = try {
Instant.parse(dateString)
} catch (e: Exception) {
Logger.warn("PositionMapper: Failed to parse instant: $dateString", mapOf("error" to (e.message ?: "unknown")))
Instant.now()
}
}
| Method | Signature | Description |
|---|---|---|
toDomain | (PositionDto) → Position | Maps a single DTO to domain entity |
toDomainList | (List<PositionDto>) → List<Position> | Maps a list of DTOs |
toDomainMap | (List<PositionDto>) → Map<Int, Position> | Maps DTOs to a map keyed by deviceId |
parseInstant | (String) → Instant (private) | Parses ISO-8601 strings; falls back to Instant.now() on failure with a warning log |
Error Handling in parseInstant
When Instant.parse() fails (malformed date string), the mapper logs a warning via Logger.warn and returns Instant.now() as a fallback. This prevents a single bad timestamp from crashing the entire position list mapping.