Skip to main content

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​

FieldValidationAPI Parameter
NameNon-empty, max 50 chars (MAX_DEVICE_NAME_LENGTH)name
CategoryServer-defined categoriescategory
IconFetched per-language from /api/devices/iconsicon
ColorHex color stringcolor

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:

ActionEndpointResponse
MutePOST /api/devices/{id}/muteDeviceMuteResponse(deviceId, isMuted)
UnmutePOST /api/devices/{id}/unmuteDeviceMuteResponse(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:

  1. HTTP API β€” initial load and pull-to-refresh via DeviceRepositoryImpl
  2. 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:

FieldTypeDescription
speedDoubleSpeed in knots (use speedKmh for km/h)
batteryLevelDouble?Battery percentage (0–100)
rssiInt?Signal strength (RSSI value)
satellitesInt?Number of GPS satellites in view
ignitionBoolean?Vehicle ignition state
motionBoolean?Whether the device is in motion
chargingBoolean?Whether the device is charging
altitudeDouble?Altitude in metres
accuracyDouble?GPS accuracy in metres
alarmString?Active alarm type

The Device entity itself tracks connectivity:

FieldTypeDescription
statusDeviceStatusONLINE, OFFLINE, or UNKNOWN
lastUpdateInstant?Last communication timestamp
lastHeartbeatInstant?Last heartbeat from the device
lastGpsPositionInstant?Timestamp of last GPS fix
disabledBooleanWhether tracking is disabled
suspendedBooleanWhether 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​

ActionEndpointRequestResponse
SuspendPOST /api/devices/{id}/suspendβ€”SuspendDeviceResponse
ReactivatePOST /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:

  1. Device detail screen β€” DeviceDetailIntent.RemoveDevice intent
  2. Device list screen β€” DeviceIntent.UnclaimDevice intent (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​

StageactivesuspendedavailableLicense Slot Used?
Unclaimedβ€”β€”β€”No
Active+1β€”βˆ’1Yes (active)
Suspendedβˆ’1+1+1Yes (suspended), but frees an active slot
Unclaimed (after removal)βˆ’1β€”+1No

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​

TransitionAPI CallUse Case / RepositoryLocal State Change
Unclaimed β†’ ActivePOST /api/devices/claimClaimDeviceUseCase β†’ DeviceRepository.claimDevice()DeviceDataStore.addDevice()
Active β†’ ConfiguredPUT /api/devices/{id}UpdateDeviceUseCase β†’ DeviceRepository.updateDevice()DeviceDataStore.updateDevice()
Active β†’ SuspendedPOST /api/devices/{id}/suspendDeviceLifecycleRepository.suspendDevice()suspended=true, disabled=true
Suspended β†’ ActivePOST /api/devices/license/{id}/reactivateDeviceLifecycleRepository.reactivateDevice()suspended=false, disabled=false
Active β†’ UnclaimedPOST /api/devices/unclaim/{id}UnclaimDeviceUseCase β†’ DeviceRepository.unclaimDevice()DeviceDataStore.removeDevice()
Suspended β†’ UnclaimedPOST /api/devices/unclaim/{id}UnclaimDeviceUseCase β†’ DeviceRepository.unclaimDevice()DeviceDataStore.removeDevice()
Active β†’ MutedPOST /api/devices/{id}/muteDeviceRepository.muteDevice()isMuted=true
Muted β†’ ActivePOST /api/devices/{id}/unmuteDeviceRepository.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.