Skip to main content

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
)
}
MethodHTTPPathParametersReturns
getLatestPositions()GETapi/positions/latestList<PositionDto>
getPositionHistory(deviceId, from, to)GETapi/positionsdeviceId: Int, from: String, to: String (ISO-8601)List<PositionDto>
deleteHistory(deviceId, from, to)DELETEapi/positionsdeviceId: 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)
}
MethodParametersReturnsDescription
getLatestPositiondeviceId: IntPosition?Latest position for a single device, or null if not found
getLatestPositionsMap<Int, Position>Latest positions for all devices, keyed by deviceId
getHistoryquery: PositionHistoryQueryList<Position>Position history for a device over a time range
deleteHistorydeviceId: Int, from: Instant, to: InstantUnitDelete 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: StringserverTime: InstantInstant.parse() with fallback to Instant.now()
deviceTime: StringdeviceTime: InstantInstant.parse() with fallback to Instant.now()
fixTime: StringfixTime: InstantInstant.parse() with fallback to Instant.now()
attributes: Map<String, Any>?attributes: Map<String, Any>?: emptyMap() (null coalescing)
All other fieldsSame name and typeDirect 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()
}
}
MethodSignatureDescription
toDomain(PositionDto) → PositionMaps 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.