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
}
| Method | HTTP | Path | Body | Returns |
|---|---|---|---|---|
getDevices | GET | api/devices/ | — | List<DeviceDto> |
getDevice | GET | api/devices/{id} | — | DeviceDto |
getDeviceModels | GET | api/devices/models | — | List<DeviceModelDto> |
getDeviceIcons | GET | api/devices/icons?lang= | — | List<DeviceIconCategoryDto> |
validateToken | POST | api/devices/validate-token | ValidateTokenRequest | ValidateTokenResponse |
claimDevice | POST | api/devices/claim | ClaimDeviceRequest | DeviceClaimResponse |
updateDevice | PUT | api/devices/{id} | UpdateDeviceRequest | DeviceDto |
unclaimDevice | POST | api/devices/unclaim/{id} | — | Unit |
suspendDevice | POST | api/devices/{id}/suspend | — | SuspendDeviceResponse |
reactivateDevice | POST | api/devices/license/{id}/reactivate | — | SuspendDeviceResponse |
muteDevice | POST | api/devices/{id}/mute | — | DeviceMuteResponse |
unmuteDevice | POST | api/devices/{id}/unmute | — | DeviceMuteResponse |
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
| Method | Input | Output |
|---|---|---|
toDomain(dto: DeviceDto) | DeviceDto | Device |
toDomain(dto: DeviceClaimResponse) | DeviceClaimResponse | Device |
toDomainList(dtos: List<DeviceDto>) | List<DeviceDto> | List<Device> |
toDomain(dto: DeviceIconDto) | DeviceIconDto | DeviceIcon |
toDomain(dto: DeviceIconCategoryDto) | DeviceIconCategoryDto | DeviceIconCategory |
toIconCategoryList(dtos: List<DeviceIconCategoryDto>) | List<DeviceIconCategoryDto> | List<DeviceIconCategory> |
Key Mapping Logic
toDomain(DeviceDto) — maps every DTO field to the domain Device. Notable transformations:
status→DeviceStatus.fromString(dto.status)(handlesnull→UNKNOWN)uniqueId→ defaults to""whennullisOwner→ defaults totruewhennullisTestDevice,suspended,isMuted→ default tofalsewhennullattributes→ defaults toemptyMap()whennulllastUpdate,lastHeartbeat,lastGpsPosition→ parsed viaInstant.parse(), returnsnullon failurepermissions→ delegated tomapPermissions(dto.permissions, isOwner)
mapPermissions (private) — permission resolution:
- If
isOwner→DevicePermissions.full()(alltrue) - If DTO is
null→DevicePermissions.none()(allfalse) - Otherwise → maps each field, defaulting
nulltofalse
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 DeviceIconCategoryDto → DeviceIconCategory and DeviceIconDto → DeviceIcon, 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
| Method | Strategy |
|---|---|
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:
HttpException→mapHttpException()→NetworkException("HTTP error: {code}"), with special-case 404 →DeviceNotFoundExceptionwhere applicable.IOException→NetworkExceptionwith contextual message.getDevicesWithPositions()additionally falls back to cachedDeviceDataStoredata 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
| Property | Type | Description |
|---|---|---|
devicesWithPositions | StateFlow<List<DeviceWithPosition>> | Primary device list with latest positions |
isInitialized | StateFlow<Boolean> | true after first data load; used for loading state |
positionUpdates | SharedFlow<Position> | Stream of position updates (buffer capacity: 64) |
deviceStatusUpdates | SharedFlow<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. SetsisInitialized = true.setDevices()— replaces all devices (pull-to-refresh, re-login). Also setsisInitialized = trueif 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 topositionUpdatesSharedFlow.updateDeviceStatus()— converts status string viaDeviceStatus.fromString(), updates device status andlastUpdatetoInstant.now(). Emits todeviceStatusUpdatesSharedFlow.
Mutation Methods
suspend fun updateDevice(device: Device)
suspend fun addDevice(deviceWithPosition: DeviceWithPosition)
suspend fun removeDevice(deviceId: Int)
updateDevice()— replaces device data in the matchingDeviceWithPositionentry, 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 resetsisInitializedtofalse(used on logout).invalidate()— setsisInitializedtofalsewithout 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.