Skip to main content

Device Service — Data Layer

Covers the full device data pipeline: Retrofit API → DTOs → Mapper → Repository → DataStore → UI.


DeviceApi

Retrofit interface at data/remote/api/DeviceApi.kt. All methods are suspend.

interface DeviceApi {

@GET("api/devices/")
suspend fun getDevices(): List<DeviceDto>

@GET("api/devices/{id}")
suspend fun getDevice(@Path("id") id: Int): DeviceDto

@GET("api/devices/models")
suspend fun getDeviceModels(): List<DeviceModelDto>

@GET("api/devices/icons")
suspend fun getDeviceIcons(@Query("lang") lang: String = "it"): List<DeviceIconCategoryDto>

@POST("api/devices/validate-token")
suspend fun validateToken(@Body request: ValidateTokenRequest): ValidateTokenResponse

@POST("api/devices/claim")
suspend fun claimDevice(@Body request: ClaimDeviceRequest): DeviceClaimResponse

@PUT("api/devices/{id}")
suspend fun updateDevice(@Path("id") id: Int, @Body request: UpdateDeviceRequest): DeviceDto

@POST("api/devices/unclaim/{id}")
suspend fun unclaimDevice(@Path("id") id: Int)

@POST("api/devices/{id}/suspend")
suspend fun suspendDevice(@Path("id") id: Int): SuspendDeviceResponse

@POST("api/devices/license/{id}/reactivate")
suspend fun reactivateDevice(@Path("id") id: Int): SuspendDeviceResponse

@POST("api/devices/{id}/mute")
suspend fun muteDevice(@Path("id") id: Int): DeviceMuteResponse

@POST("api/devices/{id}/unmute")
suspend fun unmuteDevice(@Path("id") id: Int): DeviceMuteResponse
}
MethodHTTPPathBodyReturns
getDevicesGETapi/devices/List<DeviceDto>
getDeviceGETapi/devices/{id}DeviceDto
getDeviceModelsGETapi/devices/modelsList<DeviceModelDto>
getDeviceIconsGETapi/devices/icons?lang=List<DeviceIconCategoryDto>
validateTokenPOSTapi/devices/validate-tokenValidateTokenRequestValidateTokenResponse
claimDevicePOSTapi/devices/claimClaimDeviceRequestDeviceClaimResponse
updateDevicePUTapi/devices/{id}UpdateDeviceRequestDeviceDto
unclaimDevicePOSTapi/devices/unclaim/{id}Unit
suspendDevicePOSTapi/devices/{id}/suspendSuspendDeviceResponse
reactivateDevicePOSTapi/devices/license/{id}/reactivateSuspendDeviceResponse
muteDevicePOSTapi/devices/{id}/muteDeviceMuteResponse
unmuteDevicePOSTapi/devices/{id}/unmuteDeviceMuteResponse

Device DTOs

All DTOs live in data/remote/dto/DeviceDtos.kt.

Request Models

data class ClaimDeviceRequest(val token: String)

data class ValidateTokenRequest(val token: String)

data class UpdateDeviceRequest(
val name: String? = null,
val icon: String? = null,
val color: String? = null,
val category: String? = null
)

Response Models

data class ValidateTokenResponse(
val success: Boolean,
val deviceId: Int? = null,
val protocolId: String? = null,
val model: String? = null,
val modelCode: String? = null,
val isTestDevice: Boolean? = null,
val errorCode: String? = null,
val errorMessage: String? = null
)

data class DeviceClaimResponse(
val id: Int,
val protocolId: String? = null,
val model: String? = null,
val name: String? = null,
val claimToken: String? = null,
val isTestDevice: Boolean? = null
)

data class SuspendDeviceResponse(
val message: String,
val deviceId: Int,
val suspended: Boolean
)

data class DeviceMuteResponse(
val deviceId: Int,
val isMuted: Boolean
)

Shared/Reference Models

data class DeviceModelDto(
val name: String,
val protocol: String,
val description: String? = null
)

data class DeviceIconDto(val id: String, val label: String)

data class DeviceIconCategoryDto(
val category: String,
val label: String,
val icons: List<DeviceIconDto>
)

data class DevicePermissionsDto(
val position: Boolean? = null,
val events: Boolean? = null,
val geofences: Boolean? = null,
val notifications: Boolean? = null,
val commands: Boolean? = null
)

DeviceDto (main device payload)

data class DeviceDto(
val id: Int,
val name: String,
val uniqueId: String? = null,
val protocolId: String? = null,
val status: String? = null,
val disabled: Boolean = false,
val lastUpdate: String? = null,
val positionId: Int? = null,
val phone: String? = null,
val model: String? = null,
val marketingName: String? = null,
val imei: String? = null,
val sim: String? = null,
val contact: String? = null,
val category: String? = null,
val icon: String? = null,
val color: String? = null,
val groupId: Int? = null,
val isOwner: Boolean? = null,
val isTestDevice: Boolean? = null,
val suspended: Boolean? = null,
val isMuted: Boolean? = null,
val attributes: Map<String, Any>? = null,
val permissions: DevicePermissionsDto? = null,
val lastHeartbeat: String? = null,
val lastGpsPosition: String? = null
)

DeviceMapper

data/mappers/DeviceMapper.kt@Singleton, injected with @Inject constructor().

Public Methods

MethodInputOutput
toDomain(dto: DeviceDto)DeviceDtoDevice
toDomain(dto: DeviceClaimResponse)DeviceClaimResponseDevice
toDomainList(dtos: List<DeviceDto>)List<DeviceDto>List<Device>
toDomain(dto: DeviceIconDto)DeviceIconDtoDeviceIcon
toDomain(dto: DeviceIconCategoryDto)DeviceIconCategoryDtoDeviceIconCategory
toIconCategoryList(dtos: List<DeviceIconCategoryDto>)List<DeviceIconCategoryDto>List<DeviceIconCategory>

Key Mapping Logic

toDomain(DeviceDto) — maps every DTO field to the domain Device. Notable transformations:

  • statusDeviceStatus.fromString(dto.status) (handles nullUNKNOWN)
  • uniqueId → defaults to "" when null
  • isOwner → defaults to true when null
  • isTestDevice, suspended, isMuted → default to false when null
  • attributes → defaults to emptyMap() when null
  • lastUpdate, lastHeartbeat, lastGpsPosition → parsed via Instant.parse(), returns null on failure
  • permissions → delegated to mapPermissions(dto.permissions, isOwner)

mapPermissions (private) — permission resolution:

  • If isOwnerDevicePermissions.full() (all true)
  • If DTO is nullDevicePermissions.none() (all false)
  • Otherwise → maps each field, defaulting null to false

toDomain(DeviceClaimResponse) — creates a minimal Device from a claim response. Sets status = OFFLINE, isOwner = true, permissions = DevicePermissions.full(), and uses protocolId as uniqueId.


DeviceIconCategory (Domain Entity)

domain/entities/DeviceIconCategory.kt — domain-layer representation of icon categories, created to prevent DTO leakage into the domain/presentation layers.

data class DeviceIcon(val id: String, val label: String)

data class DeviceIconCategory(
val category: String,
val label: String,
val icons: List<DeviceIcon>
)

The mapper converts DeviceIconCategoryDtoDeviceIconCategory and DeviceIconDtoDeviceIcon, keeping the data layer boundary clean.


DeviceRepository

Interface at domain/repositories/DeviceRepository.kt. Defines the domain contract.

interface DeviceRepository {

val devicesWithPositionsFlow: StateFlow<List<DeviceWithPosition>>

suspend fun getDevices(): List<Device>
suspend fun getDevice(id: Int): Device
suspend fun getDevicesWithPositions(): List<DeviceWithPosition>
suspend fun refreshDevices(): List<DeviceWithPosition>
suspend fun claimDevice(token: String): Device
suspend fun updateDevice(
id: Int,
name: String? = null,
icon: String? = null,
color: String? = null,
category: String? = null
): Device
suspend fun unclaimDevice(id: Int)
suspend fun getDeviceIcons(lang: String = "it"): List<DeviceIconCategory>
suspend fun invalidateCache()
suspend fun muteDevice(id: Int): Boolean
suspend fun unmuteDevice(id: Int): Boolean
}

A companion data class is also defined in this file:

data class DeviceUpdateData(
val name: String? = null,
val icon: String? = null,
val color: String? = null,
val category: String? = null
)

devicesWithPositionsFlow

StateFlow<List<DeviceWithPosition>> — the primary observable for device state. Updated by API fetches, WebSocket events, and user actions. ViewModels should prefer this flow over calling getDevicesWithPositions() for real-time updates.


DeviceRepositoryImpl

data/repositories/DeviceRepositoryImpl.kt@Singleton.

Constructor (5 dependencies)

@Singleton
class DeviceRepositoryImpl @Inject constructor(
private val deviceApi: DeviceApi,
private val positionApi: PositionApi,
private val deviceMapper: DeviceMapper,
private val positionMapper: PositionMapper,
private val deviceDataStore: DeviceDataStore
) : DeviceRepository

Internal Caching

private val deviceCache = MemoryCache<Int, Device>(MemoryCache.DEFAULT_TTL_MILLIS)

A MemoryCache is kept for getDevice() calls only, because the single-device endpoint returns full permissions data that the list endpoint does not include. The list-level cache has been replaced by DeviceDataStore.

Method Implementations

MethodStrategy
getDevices()Returns devices from DeviceDataStore if non-empty, otherwise fetches from API and maps via deviceMapper.toDomainList(). Does not populate deviceCache (list endpoint lacks permissions).
getDevice(id)Uses deviceCache.getOrPut(). On 404 → DeviceNotFoundException.
getDevicesWithPositions()Returns DeviceDataStore value if non-empty. Otherwise fetches devices + positions from API, builds DeviceWithPosition list by joining on deviceId, stores via deviceDataStore.setDevices(). Falls back to cached data on HTTP/IO errors.
refreshDevices()Always fetches from API (skips cache check). Calls deviceDataStore.setDevices() to push updates. Used for pull-to-refresh.
claimDevice(token)POSTs ClaimDeviceRequest, maps via deviceMapper.toDomain(DeviceClaimResponse), adds to both DeviceDataStore and deviceCache.
updateDevice(id, ...)PUTs UpdateDeviceRequest, maps response, updates both deviceCache and DeviceDataStore. On 404 → DeviceNotFoundException.
unclaimDevice(id)POSTs unclaim, then removes from DeviceDataStore and invalidates deviceCache entry. On 404 → DeviceNotFoundException.
getDeviceIcons(lang)Delegates to deviceMapper.toIconCategoryList(deviceApi.getDeviceIcons(lang)).
muteDevice(id)POSTs mute, reads isMuted from response, updates DeviceDataStore and deviceCache via updateDeviceMuteStatus(). Returns isMuted.
unmuteDevice(id)Same pattern as muteDevice but POSTs to unmute endpoint.
invalidateCache()Calls deviceDataStore.invalidate().

Error Handling

All API-calling methods follow the same pattern:

  • HttpExceptionmapHttpException()NetworkException("HTTP error: {code}"), with special-case 404 → DeviceNotFoundException where applicable.
  • IOExceptionNetworkException with contextual message.
  • getDevicesWithPositions() additionally falls back to cached DeviceDataStore data on network errors.

Data Flow

API Fetch:      API → DeviceMapper → DeviceDataStore.setDevices() → ViewModels (via StateFlow)
WebSocket: WebSocket → RealTimeDataBridge → DeviceDataStore → ViewModels
User Action: ViewModel → Repository → API → DeviceDataStore.updateDevice() → all ViewModels

DeviceDataStore

core/data/DeviceDataStore.kt@Singleton, the single source of truth for device and position data. Bridges HTTP API calls and WebSocket real-time updates into a unified observable state.

State Flows

PropertyTypeDescription
devicesWithPositionsStateFlow<List<DeviceWithPosition>>Primary device list with latest positions
isInitializedStateFlow<Boolean>true after first data load; used for loading state
positionUpdatesSharedFlow<Position>Stream of position updates (buffer capacity: 64)
deviceStatusUpdatesSharedFlow<Pair<Int, DeviceStatus>>Stream of status changes (buffer capacity: 64)

Initialization Methods

suspend fun initialize(devices: List<DeviceWithPosition>)
suspend fun setDevices(devices: List<DeviceWithPosition>)
  • initialize() — first-time load from API. Sets isInitialized = true.
  • setDevices() — replaces all devices (pull-to-refresh, re-login). Also sets isInitialized = true if not already.

Real-Time Update Methods (from WebSocket)

suspend fun updatePosition(position: Position)
suspend fun updateDeviceStatus(deviceId: Int, status: String)
  • updatePosition() — merges a new position into the state. Skips positions without GPS coordinates (latitude/longitude == null). Emits to positionUpdates SharedFlow.
  • updateDeviceStatus() — converts status string via DeviceStatus.fromString(), updates device status and lastUpdate to Instant.now(). Emits to deviceStatusUpdates SharedFlow.

Mutation Methods

suspend fun updateDevice(device: Device)
suspend fun addDevice(deviceWithPosition: DeviceWithPosition)
suspend fun removeDevice(deviceId: Int)
  • updateDevice() — replaces device data in the matching DeviceWithPosition entry, preserving its position.
  • addDevice() — appends a new device. If a device with the same ID already exists, updates it instead.
  • removeDevice() — filters out the device by ID.

Query Methods (synchronous)

fun getDevice(deviceId: Int): Device?
fun getDeviceWithPosition(deviceId: Int): DeviceWithPosition?
fun getPosition(deviceId: Int): Position?

All read directly from the current _devicesWithPositions.value.

State Management

suspend fun clear()
fun invalidate()
  • clear() — empties all data and resets isInitialized to false (used on logout).
  • invalidate() — sets isInitialized to false without clearing data (used after network reconnect; signals ViewModels to refresh).

Thread Safety

All mutating methods use a Mutex to serialize writes. StateFlow.update {} ensures atomic compare-and-set on the underlying list.