Device Lifecycle
This document describes the complete lifecycle of a GPS device in the Visla Android app β from initial claiming through active tracking, configuration, suspension, and eventual unclaiming. It traces each transition through the domain layer, API calls, and local state management.
Cross-references: Data Layer β Devices for the full API/DTO/mapper pipeline Β· User Flows Β§2 for the step-by-step claiming flow Β· Billing for subscription and license details Β· Real-Time for WebSocket position/status updates.
Lifecycle Overviewβ
A device moves through five stages. Each transition is triggered by a user action, backed by an API call, and reflected in the local DeviceDataStore.
1. Device Claimingβ
Claiming associates a physical GPS device with the user's account. The user provides a claim token β either typed manually or scanned from a QR code.
1.1 Token Entryβ
The AddDeviceScreen accepts an 8-character alphanumeric token. The ViewModel normalises input on every keystroke:
// AddDeviceViewModel.kt
private fun updateToken(input: String) {
val cleaned = input.uppercase().replace("-", "").filter { it.isLetterOrDigit() }
val formatted = cleaned.take(DeviceTokenConstants.TOKEN_MAX_LENGTH)
_uiState.value = _uiState.value.copy(rawToken = formatted, errorMessage = null)
}
The UI displays the token with a dash for readability (e.g. B1G8-GNMI):
// AddDeviceUiState
val displayToken: String
get() = if (rawToken.length > 4) {
"${rawToken.substring(0, 4)}-${rawToken.substring(4)}"
} else {
rawToken
}
1.2 QR Code Scanningβ
When the user opens the scanner, ML Kit reads a QR code containing either a raw token or a URL with the token as the last path component:
// AddDeviceViewModel.kt
private fun processScannedCode(scannedCode: String) {
val code = scannedCode.uppercase()
val token = if (code.contains("/")) {
code.split("/").lastOrNull()?.replace("-", "")?.filter { it.isLetterOrDigit() } ?: ""
} else {
code.replace("-", "").filter { it.isLetterOrDigit() }
}
_uiState.value = _uiState.value.copy(
rawToken = token,
showScanner = false,
errorMessage = null
)
}
1.3 License Checkβ
Before claiming, the ViewModel checks whether the user's subscription has available slots:
// AddDeviceViewModel.kt β claimDevice()
val license = _uiState.value.licenseStatus
if (license != null && license.available <= 0) {
_uiState.value = _uiState.value.copy(showUpgradeSheet = true)
return
}
The License data class tracks slot counts:
data class License(val allowed: Int, val active: Int, val available: Int, val suspended: Int)
If no slots are available the upgrade bottom sheet is shown instead of proceeding.
1.4 Claim API Callβ
ClaimDeviceUseCase validates the token (minimum 6 characters, matching VERIFICATION_CODE_LENGTH) then delegates to the repository:
// ClaimDeviceUseCase.kt
class ClaimDeviceUseCase @Inject constructor(private val deviceRepository: DeviceRepository) {
suspend operator fun invoke(token: String): Device {
val normalizedToken = token.trim().uppercase()
if (normalizedToken.isEmpty()) {
throw ValidationException("Claim token is required", "token")
}
val minLength = ValidationConstants.VERIFICATION_CODE_LENGTH
if (normalizedToken.length < minLength) {
throw InvalidClaimTokenException("Token must be at least $minLength characters")
}
return deviceRepository.claimDevice(normalizedToken)
}
}
The repository calls POST /api/devices/claim and integrates the result into local state:
// DeviceRepositoryImpl.kt
override suspend fun claimDevice(token: String): Device = try {
val response = deviceApi.claimDevice(ClaimDeviceRequest(token))
val device = deviceMapper.toDomain(response)
deviceDataStore.addDevice(DeviceWithPosition(device, null))
deviceCache.put(device.id, device)
device
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error claiming device", e)
}
1.5 Post-Claim Setupβ
After a successful claim, the ViewModel reconnects the WebSocket so the backend starts pushing real-time updates for the new device:
// AddDeviceViewModel.kt β claimDevice()
val device = claimDeviceUseCase(token)
realTimeDataBridge.reconnect()
_event.value = AddDeviceEvent.Success(device.id)
The DeviceClaimResponse mapper sets newly claimed devices to OFFLINE with isOwner = true and full permissions, since no position data exists yet:
// DeviceMapper.kt
fun toDomain(dto: DeviceClaimResponse): Device = Device(
id = dto.id,
name = dto.name ?: "",
status = DeviceStatus.OFFLINE,
isOwner = true,
suspended = false,
permissions = DevicePermissions.full(),
// ... remaining fields default to null/empty
)
2. Device Configurationβ
Once claimed, the owner can customise the device's name, icon, and category through DeviceSettingsScreen.
2.1 Editable Fieldsβ
| Field | Validation | API Parameter |
|---|---|---|
| Name | Non-empty, max 50 chars (MAX_DEVICE_NAME_LENGTH) | name |
| Category | Server-defined categories | category |
| Icon | Fetched per-language from /api/devices/icons | icon |
| Color | Hex color string | color |
2.2 Update Flowβ
DeviceSettingsViewModel tracks original values and only sends changed fields:
// DeviceSettingsViewModel.kt β saveSettings()
updateDeviceUseCase(
deviceId = currentDeviceId,
data = DeviceUpdateData(
name = if (name != originalName) name else null,
category = if (selectedCategory != originalCategory) selectedCategory else null,
icon = if (selectedIcon != originalIcon) selectedIcon else null
)
)
UpdateDeviceUseCase validates the input then calls PUT /api/devices/{id}:
// UpdateDeviceUseCase.kt
class UpdateDeviceUseCase @Inject constructor(private val deviceRepository: DeviceRepository) {
suspend operator fun invoke(deviceId: Int, data: DeviceUpdateData): Device {
validateUpdateData(deviceId, data)
return deviceRepository.updateDevice(
id = deviceId,
name = data.name?.trim(),
icon = data.icon,
color = data.color,
category = data.category
)
}
}
The repository writes the response to both the memory cache and the DeviceDataStore, ensuring all observing ViewModels receive the update:
// DeviceRepositoryImpl.kt
override suspend fun updateDevice(id: Int, ...): Device = try {
val response = deviceApi.updateDevice(id, UpdateDeviceRequest(name, icon, color, category))
val device = deviceMapper.toDomain(response)
deviceCache.put(id, device)
deviceDataStore.updateDevice(device)
device
}
2.3 Notification Settingsβ
Notifications are managed per-device via DeviceNotificationsViewModel. Owners can toggle push, email, and phone call notifications independently, and manage notification contacts (phone numbers, email addresses):
// DeviceNotificationsIntent
sealed class DeviceNotificationsIntent {
data class LoadSettings(val deviceId: Int) : DeviceNotificationsIntent()
data class UpdatePushEnabled(val enabled: Boolean) : DeviceNotificationsIntent()
data class UpdateEmailEnabled(val enabled: Boolean) : DeviceNotificationsIntent()
data class UpdateCallEnabled(val enabled: Boolean) : DeviceNotificationsIntent()
data class AddContact(val type: String, val value: String) : DeviceNotificationsIntent()
data class DeleteContact(val contactId: Int) : DeviceNotificationsIntent()
data object ClearMessages : DeviceNotificationsIntent()
}
2.4 Muting Notificationsβ
Device-level muting silences all notifications without changing individual settings. Only owners can mute:
// Device.kt
val canMute: Boolean get() = isOwner
The API provides separate endpoints:
| Action | Endpoint | Response |
|---|---|---|
| Mute | POST /api/devices/{id}/mute | DeviceMuteResponse(deviceId, isMuted) |
| Unmute | POST /api/devices/{id}/unmute | DeviceMuteResponse(deviceId, isMuted) |
After toggling, the repository updates both the DeviceDataStore and memory cache to keep all observers in sync.
3. Active Trackingβ
While a device is active (not suspended, not disabled), the app receives real-time position and status updates.
3.1 Data Flowβ
The DeviceDataStore is the single source of truth. It receives updates from two sources:
- HTTP API β initial load and pull-to-refresh via
DeviceRepositoryImpl - WebSocket β real-time position and status via
RealTimeDataBridge
3.2 Position Updatesβ
Position updates arrive via WebSocket and are merged into the store atomically:
// DeviceDataStore.kt
suspend fun updatePosition(position: Position) {
if (position.latitude == null || position.longitude == null) {
return // Skip positions without GPS coordinates
}
mutex.withLock {
_devicesWithPositions.update { currentList ->
currentList.map { dwp ->
if (dwp.device.id == position.deviceId) {
dwp.copy(position = position)
} else {
dwp
}
}
}
}
_positionUpdates.tryEmit(position)
}
3.3 Device Status Updatesβ
Online/offline transitions are pushed as status events:
// DeviceDataStore.kt
suspend fun updateDeviceStatus(deviceId: Int, status: String) {
val deviceStatus = DeviceStatus.fromString(status)
mutex.withLock {
_devicesWithPositions.update { list ->
list.map { dwp ->
if (dwp.device.id == deviceId) {
val updatedDevice = dwp.device.copy(
status = deviceStatus,
lastUpdate = Instant.now()
)
dwp.copy(device = updatedDevice)
} else {
dwp
}
}
}
}
_deviceStatusUpdates.tryEmit(Pair(deviceId, deviceStatus))
}
3.4 Status Fieldsβ
The Position entity carries the full telemetry snapshot. Key fields for status monitoring:
| Field | Type | Description |
|---|---|---|
speed | Double | Speed in knots (use speedKmh for km/h) |
batteryLevel | Double? | Battery percentage (0β100) |
rssi | Int? | Signal strength (RSSI value) |
satellites | Int? | Number of GPS satellites in view |
ignition | Boolean? | Vehicle ignition state |
motion | Boolean? | Whether the device is in motion |
charging | Boolean? | Whether the device is charging |
altitude | Double? | Altitude in metres |
accuracy | Double? | GPS accuracy in metres |
alarm | String? | Active alarm type |
The Device entity itself tracks connectivity:
| Field | Type | Description |
|---|---|---|
status | DeviceStatus | ONLINE, OFFLINE, or UNKNOWN |
lastUpdate | Instant? | Last communication timestamp |
lastHeartbeat | Instant? | Last heartbeat from the device |
lastGpsPosition | Instant? | Timestamp of last GPS fix |
disabled | Boolean | Whether tracking is disabled |
suspended | Boolean | Whether the device is in suspended state |
The computed isOnline property provides a convenience check:
// Device.kt
val isOnline: Boolean get() = status == DeviceStatus.ONLINE
3.5 Foreground Refreshβ
DevicesViewModel automatically refreshes when the app returns to the foreground (debounced to 5 seconds):
// DevicesViewModel.kt
private val lifecycleObserver = object : DefaultLifecycleObserver {
private var isFirstStart = true
private var lastForegroundTime = 0L
override fun onStart(owner: LifecycleOwner) {
if (isFirstStart) { isFirstStart = false; return }
val now = System.currentTimeMillis()
if (now - lastForegroundTime < 5000) return
lastForegroundTime = now
handle(DeviceIntent.RefreshDevices)
}
}
3.6 Device List Sortingβ
Devices are always sorted online-first, then alphabetically:
// FetchDevicesUseCase.kt
devicesWithPositions.sortedWith(
compareBy<DeviceWithPosition> { it.device.status != DeviceStatus.ONLINE }
.thenBy { it.device.name.lowercase() }
)
4. Device Suspensionβ
Suspension pauses tracking without releasing the license slot. The device stops reporting positions but remains associated with the account.
4.1 Suspend and Reactivateβ
Both operations are handled by DeviceLifecycleRepository, a dedicated repository separate from DeviceRepository:
// DeviceLifecycleRepository.kt
interface DeviceLifecycleRepository {
suspend fun suspendDevice(id: Int)
suspend fun reactivateDevice(id: Int)
}
The implementation calls the API then applies the state change in two phases β an optimistic local update followed by a full re-sync:
// DeviceLifecycleRepositoryImpl.kt
override suspend fun suspendDevice(id: Int) {
try {
val response = deviceApi.suspendDevice(id)
applySuspendedState(id, response)
syncDeviceAfterStatusChange(id, response.suspended)
} catch (e: HttpException) {
if (e.code() == HttpStatusCode.NOT_FOUND) {
throw DeviceNotFoundException("Device $id not found")
}
throw NetworkException("HTTP error: ${e.code()}", e)
}
}
The applySuspendedState method immediately updates local state:
private suspend fun applySuspendedState(id: Int, response: SuspendDeviceResponse) {
val current = deviceDataStore.getDevice(id) ?: return
deviceDataStore.updateDevice(
current.copy(
suspended = response.suspended,
disabled = response.suspended
)
)
}
Then syncDeviceAfterStatusChange re-fetches the full device from the API to ensure consistency:
private suspend fun syncDeviceAfterStatusChange(id: Int, expectedSuspended: Boolean) {
runCatching {
val updatedDevice = deviceMapper.toDomain(deviceApi.getDevice(id)).copy(
suspended = expectedSuspended,
disabled = expectedSuspended
)
deviceDataStore.updateDevice(updatedDevice)
}
}
4.2 API Endpointsβ
| Action | Endpoint | Request | Response |
|---|---|---|---|
| Suspend | POST /api/devices/{id}/suspend | β | SuspendDeviceResponse |
| Reactivate | POST /api/devices/license/{id}/reactivate | β | SuspendDeviceResponse |
data class SuspendDeviceResponse(val message: String, val deviceId: Int, val suspended: Boolean)
4.3 UI Integrationβ
DeviceDetailViewModel drives suspend/reactivate from the device detail screen:
// DeviceDetailViewModel.kt
private fun suspendDevice() {
if (currentDeviceId <= 0 || _uiState.value.isChangingDeviceStatus) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isChangingDeviceStatus = true, errorMessage = null)
try {
deviceLifecycleRepository.suspendDevice(currentDeviceId)
_uiState.value = _uiState.value.copy(
device = _uiState.value.device?.copy(suspended = true, disabled = true),
isChangingDeviceStatus = false,
successMessage = "Dispositivo sospeso"
)
} catch (e: Exception) { /* error handling */ }
}
}
4.4 License Impactβ
Suspended devices occupy a suspended slot in the license, distinct from active. The License model reflects this:
data class License(val allowed: Int, val active: Int, val available: Int, val suspended: Int)
Suspending a device decrements active and increments suspended, freeing an available slot for a new device. Reactivating reverses this.
5. Device Unclaimingβ
Unclaiming permanently removes a device from the user's account and releases its license slot.
5.1 Unclaim Flowβ
UnclaimDeviceUseCase validates the device ID then calls the repository:
// UnclaimDeviceUseCase.kt
class UnclaimDeviceUseCase @Inject constructor(private val deviceRepository: DeviceRepository) {
suspend operator fun invoke(deviceId: Int) {
if (deviceId <= 0) {
throw ValidationException("Invalid device ID", "deviceId")
}
deviceRepository.unclaimDevice(deviceId)
}
}
The repository calls POST /api/devices/unclaim/{id}, removes the device from local state, and invalidates the cache:
// DeviceRepositoryImpl.kt
override suspend fun unclaimDevice(id: Int) {
try {
deviceApi.unclaimDevice(id)
deviceDataStore.removeDevice(id)
deviceCache.invalidate(id)
} catch (e: HttpException) {
if (e.code() == HttpStatusCode.NOT_FOUND) {
throw DeviceNotFoundException("Device $id not found")
}
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error unclaiming device", e)
}
}
5.2 Shared Device Removalβ
For shared devices (where isOwner == false), removal calls sharingRepository.leaveDevice() instead of unclaim. The DeviceDetailViewModel handles this distinction:
// DeviceDetailViewModel.kt β removeDevice()
if (currentDevice.isOwner) {
deviceRepository.unclaimDevice(currentDeviceId)
} else {
sharingRepository.leaveDevice(currentDeviceId)
}
5.3 UI Trigger Pointsβ
Unclaim can be initiated from two places:
- Device detail screen β
DeviceDetailIntent.RemoveDeviceintent - Device list screen β
DeviceIntent.UnclaimDeviceintent (swipe-to-delete or menu action)
Both update local state immediately upon success:
// DevicesViewModel.kt
private fun unclaimDevice(deviceId: Int) {
viewModelScope.launch {
try {
devicesInteractor.unclaimDevice(deviceId)
_operationResult.value = DeviceOperationResult.UnclaimSuccess
} catch (e: Exception) {
_operationResult.value = DeviceOperationResult.UnclaimError(
e.message ?: "Failed to remove device"
)
}
}
}
6. License Implications by Stageβ
| Stage | active | suspended | available | License Slot Used? |
|---|---|---|---|---|
| Unclaimed | β | β | β | No |
| Active | +1 | β | β1 | Yes (active) |
| Suspended | β1 | +1 | +1 | Yes (suspended), but frees an active slot |
| Unclaimed (after removal) | β1 | β | +1 | No |
The pre-claim license check in AddDeviceViewModel prevents claiming when available <= 0. DevicesViewModel.checkLicenseStatus() provides a secondary gate at the device list level:
// DevicesViewModel.kt
private fun checkLicenseStatus(onCanAdd: () -> Unit, onNeedUpgrade: () -> Unit) {
viewModelScope.launch {
val status = licenseInteractor.getLicenseStatus()
if (status.allowed == 0 || status.active >= status.allowed) {
onNeedUpgrade()
} else {
onCanAdd()
}
}
}
7. State Transitions and API Callsβ
| Transition | API Call | Use Case / Repository | Local State Change |
|---|---|---|---|
| Unclaimed β Active | POST /api/devices/claim | ClaimDeviceUseCase β DeviceRepository.claimDevice() | DeviceDataStore.addDevice() |
| Active β Configured | PUT /api/devices/{id} | UpdateDeviceUseCase β DeviceRepository.updateDevice() | DeviceDataStore.updateDevice() |
| Active β Suspended | POST /api/devices/{id}/suspend | DeviceLifecycleRepository.suspendDevice() | suspended=true, disabled=true |
| Suspended β Active | POST /api/devices/license/{id}/reactivate | DeviceLifecycleRepository.reactivateDevice() | suspended=false, disabled=false |
| Active β Unclaimed | POST /api/devices/unclaim/{id} | UnclaimDeviceUseCase β DeviceRepository.unclaimDevice() | DeviceDataStore.removeDevice() |
| Suspended β Unclaimed | POST /api/devices/unclaim/{id} | UnclaimDeviceUseCase β DeviceRepository.unclaimDevice() | DeviceDataStore.removeDevice() |
| Active β Muted | POST /api/devices/{id}/mute | DeviceRepository.muteDevice() | isMuted=true |
| Muted β Active | POST /api/devices/{id}/unmute | DeviceRepository.unmuteDevice() | isMuted=false |
8. Shared Device Considerationsβ
Devices can be shared with other users. The isOwner flag and DevicePermissions control what non-owners can do.
8.1 Permission Modelβ
// Device.kt
data class DevicePermissions(
val position: Boolean = true,
val events: Boolean = true,
val geofences: Boolean = true,
val notifications: Boolean = true,
val commands: Boolean = true
)
Computed properties gate features at the domain level:
val canViewPosition: Boolean get() = isOwner || permissions.position
val canViewEvents: Boolean get() = isOwner || permissions.events
val canManageGeofences: Boolean get() = isOwner || permissions.geofences
val canManageNotifications: Boolean get() = isOwner || permissions.notifications
val canSendCommands: Boolean get() = isOwner || permissions.commands
val canManageSharing: Boolean get() = isOwner
val canTransferOwnership: Boolean get() = isOwner
val canDelete: Boolean get() = isOwner
val canMute: Boolean get() = isOwner
8.2 Permission Mappingβ
Owners always get full permissions. For shared users, the mapper falls back to none() if the permissions DTO is absent:
// DeviceMapper.kt
private fun mapPermissions(dto: DevicePermissionsDto?, isOwner: Boolean): DevicePermissions {
if (isOwner) return DevicePermissions.full()
if (dto == null) return DevicePermissions.none()
return DevicePermissions(
position = dto.position ?: false,
events = dto.events ?: false,
geofences = dto.geofences ?: false,
notifications = dto.notifications ?: false,
commands = dto.commands ?: false
)
}
8.3 Owner-Only Actionsβ
The following lifecycle operations are restricted to the device owner:
- Suspend / Reactivate β managed through
DeviceLifecycleRepository - Unclaim β permanently removes the device
- Mute / Unmute β silences notifications
- Manage sharing β invite or revoke other users
- Transfer ownership
Non-owners can only leave a shared device (via sharingRepository.leaveDevice()), which removes it from their account without affecting the owner.
9. Design Decisionsβ
Separate DeviceLifecycleRepositoryβ
Suspend/reactivate operations are in DeviceLifecycleRepository rather than DeviceRepository. This separates concerns: DeviceRepository handles CRUD and real-time data flow, while DeviceLifecycleRepository handles state machine transitions that affect billing. The lifecycle repository also performs a two-phase update (optimistic local update + API re-sync) for robustness.
DeviceDataStore as Single Source of Truthβ
Instead of TTL-based caching, DeviceDataStore holds observable StateFlow<List<DeviceWithPosition>>. Both HTTP responses and WebSocket events feed into it. ViewModels observe the same store, so a WebSocket position update on the device list screen is instantly visible on the device detail screen. All mutations are protected by a Mutex for thread safety.
Claim Response Defaults to OFFLINEβ
Newly claimed devices get DeviceStatus.OFFLINE because no position data exists at claim time. The WebSocket reconnection after claiming (realTimeDataBridge.reconnect()) triggers the backend to push the actual status, which updates the device to ONLINE if it's connected.
Defensive Permission Mappingβ
The isOwner flag defaults to true when null in the API response (dto.isOwner ?: true). This is a safe default because the list endpoint doesn't include permissions β only the device detail endpoint does. Shared devices always have explicit isOwner = false from the API.
Widget Sync on Every State Changeβ
DevicesViewModel syncs to WidgetDataStore on every devicesFlow emission, ensuring the home screen widget reflects current device states (online/offline, suspended, muted) without requiring a separate refresh mechanism.