Geofencing & Device Commands
This document covers the geofencing and device command systems in the Visla GPS Android app β from domain entities and use cases through data layer pipelines to the Compose UI.
Related docs:
- Geofence Data Layer β API, DTOs, mapper, repository, DataStore
- Command Data Layer β API, DTOs, mapper, repository
- User Flows β geofence creation flow
Geofencingβ
Geofence Typesβ
The app supports two geofence shapes, defined in domain/entities/Geofence.kt:
enum class GeofenceType {
CIRCLE,
POLYGON;
companion object {
fun fromString(value: String?): GeofenceType = when (value?.lowercase()) {
"circle" -> CIRCLE
"polygon" -> POLYGON
else -> CIRCLE
}
}
}
| Type | Shape definition | UI control |
|---|---|---|
CIRCLE | Center coordinate + radius (meters) | Tap map to place center, slider for radius (50β5 000 m) |
POLYGON | Ordered list of β₯ 3 vertices | Tap map to add vertices, undo/clear controls |
The domain entity carries both representations:
data class Geofence(
val id: Int,
val userId: Int,
val name: String,
val description: String?,
val type: GeofenceType = GeofenceType.CIRCLE,
val center: Coordinate? = null,
val radius: Double? = null,
val alertOnEnter: Boolean = true,
val alertOnExit: Boolean = true,
val deviceIds: List<Int> = emptyList(),
val color: String? = null,
val area: String? = null, // WKT geometry string
val attributes: Map<String, Any> = emptyMap()
)
WKT Geometry Formatβ
Geofence areas are stored as Well-Known Text (WKT) strings. The WktBuilder utility (core/geometry/WktBuilder.kt) produces the correct format:
// Circle β extended WKT (not standard, used by the Visla backend)
WktBuilder.circle(45.4642, 9.1900, 500)
// β "CIRCLE(45.4642 9.19, 500)"
// Polygon β standard WKT, longitude-latitude order, auto-closed
WktBuilder.polygon(listOf(LatLng(45.46, 9.18), LatLng(45.47, 9.19), LatLng(45.46, 9.20)))
// β "POLYGON((9.18 45.46, 9.19 45.47, 9.2 45.46, 9.18 45.46))"
The CreateGeofenceUseCase validates the area string before submission:
private fun validateGeofenceArea(area: String) {
if (area.isBlank()) {
throw InvalidGeofenceAreaException("Geofence area is required")
}
val upperArea = area.uppercase()
if (!upperArea.startsWith("CIRCLE") &&
!upperArea.startsWith("POLYGON") &&
!upperArea.startsWith("LINESTRING")
) {
throw InvalidGeofenceAreaException(
"Invalid area format. Must be CIRCLE, POLYGON, or LINESTRING"
)
}
}
Geofence Creation & Editingβ
The editor uses a two-phase flow managed by GeofenceEditorViewModel:
Phase 1: DRAW_SHAPE β Phase 2: ENTER_DETAILS β Save
Phase 1 β Draw Shape (DrawShapePhase composable):
- User selects circle or polygon via
ShapeTypeButtontoggle. - Map taps dispatch
SetCircleCenterorAddPolygonPointintents. - For circles: a
Slidercontrols the radius (50β5 000 m). - For polygons: undo/clear buttons manage vertices; minimum 3 points required.
- The
canConfirmShapeguard ensures validity before advancing.
Phase 2 β Enter Details (EnterDetailsPhase composable):
- Name (required) and description (optional) text fields.
- Alert toggles for entry and exit notifications.
- Save button enabled when
canSaveis true.
The UI state is captured in a single immutable data class:
data class GeofenceEditorUiState(
val shapeType: GeofenceShapeType = GeofenceShapeType.CIRCLE,
val phase: EditorPhase = EditorPhase.DRAW_SHAPE,
val centerLatitude: String = "",
val centerLongitude: String = "",
val radius: Float = 200f,
val polygonPoints: List<PolygonPoint> = emptyList(),
val name: String = "",
val description: String = "",
val alertOnEnter: Boolean = true,
val alertOnExit: Boolean = true,
val devicePosition: Position? = null,
val deviceIcon: String? = null,
val deviceCategory: String? = null,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val errorMessage: String? = null,
val isEditMode: Boolean = false
)
On save, the ViewModel builds the WKT area string and dispatches a create or update:
private fun buildWktArea(state: GeofenceEditorUiState): String {
return when (state.shapeType) {
GeofenceShapeType.CIRCLE -> {
val lat = state.centerLatitude.toDoubleOrNull() ?: 0.0
val lng = state.centerLongitude.toDoubleOrNull() ?: 0.0
WktBuilder.circle(lat, lng, state.radius.toInt())
}
GeofenceShapeType.POLYGON -> {
val latLngPoints = state.polygonPoints.map { LatLng(it.latitude, it.longitude) }
WktBuilder.polygon(latLngPoints)
}
}
}
In edit mode, the ViewModel loads the existing geofence, populates the state, and skips directly to ENTER_DETAILS. In create mode, it loads the device's last known position to center the map.
Alert Rulesβ
Each geofence has two independent boolean triggers:
| Field | Default | Behavior |
|---|---|---|
alertOnEnter | true | Notification when device enters the zone |
alertOnExit | true | Notification when device leaves the zone |
These are set during creation/editing and persisted via GeofenceUpdateData:
data class GeofenceUpdateData(
val name: String? = null,
val description: String? = null,
val area: String? = null,
val alertOnEnter: Boolean? = null,
val alertOnExit: Boolean? = null,
val attributes: Map<String, Any>? = null
)
The list screen renders entry/exit icons per geofence as visual indicators.
Per-Device Assignment & Batch Geofencingβ
Geofences are linked to devices through the deviceIds list on the Geofence entity.
Single-device creation: The GeofenceCreateData.deviceId field links the new geofence to the device being viewed:
val createData = GeofenceCreateData(
name = state.name,
area = wktArea,
description = state.description.ifBlank { null },
deviceId = if (deviceId > 0) deviceId else null
)
Backend enrichment note: The backend links the device after creation but returns the geofence before linking, so the repository patches the response:
val enriched = if (data.deviceId != null && !geofence.deviceIds.contains(data.deviceId)) {
geofence.copy(deviceIds = geofence.deviceIds + data.deviceId)
} else {
geofence
}
Multiple-device filtering: The GeofencesListViewModel filters the global geofence store by device:
geofenceDataStore.geofences
.map { allGeofences -> allGeofences.filter { it.deviceIds.contains(deviceId) } }
.collect { deviceGeofences -> /* update UI state */ }
The GeofenceRepository provides both global and per-device queries:
interface GeofenceRepository {
suspend fun getAll(): List<Geofence>
suspend fun getByDeviceId(deviceId: Int): List<Geofence>
suspend fun getById(id: Int): Geofence
suspend fun create(data: GeofenceCreateData): Geofence
suspend fun update(id: Int, data: GeofenceUpdateData): Geofence
suspend fun delete(id: Int)
}
GeofenceDataStore β Reactive Stateβ
The GeofenceDataStore (core/data/GeofenceDataStore.kt) is the single source of truth for geofence data, backed by a StateFlow. It eliminates stale lists across screens:
HTTP API β Repository β GeofenceDataStore (StateFlow) β ViewModels
Key behaviors:
- Per-device lazy loading: Tracks which devices have been fetched via
loadedDeviceIds. - Automatic UI sync: When a geofence is created/deleted in
GeofenceEditorViewModel, the list screen updates automatically through the sharedStateFlow. - Thread-safe mutations: All writes use a
Mutexto prevent concurrent modification. - Invalidation:
invalidateDevice(deviceId)forces a re-fetch;clear()resets on logout.
Device Commandsβ
Command Categoriesβ
Commands are organized into four categories, defined in domain/entities/Command.kt:
enum class CommandCategory(val key: String) {
SECURITY("security"),
TRACKING("tracking"),
DIAGNOSTICS("diagnostics"),
OTHER("other");
}
The SendCommandBottomSheet renders categories in a fixed display order with distinct colors:
| Category | Color | Typical commands |
|---|---|---|
| Security | Orange (#FF9500) | Relay on/off, vibration alarm |
| Tracking | Cyan (#00BCD4) | Position request, frequency changes |
| Diagnostics | Blue (#2196F3) | Status check, reboot |
| Other | Gray | Uncategorized commands |
Command Definitionsβ
Each command available for a device model is described by a CommandDefinition:
data class CommandDefinition(
val id: Int?,
val model: String?,
val type: String, // e.g. "engineStop", "positionSingle"
val label: String, // human-readable name
val description: String?,
val defaultValue: String?,
val hasParams: Boolean,
val active: Boolean,
val category: CommandCategory,
val paramConfig: ParamConfig?,
val channels: List<String>, // e.g. ["tcp", "sms"]
val parameters: List<CommandParameter>
)
Definitions are fetched per device model:
class GetCommandDefinitionsUseCase @Inject constructor(
private val commandRepository: CommandRepository
) {
suspend operator fun invoke(model: String): List<CommandDefinition> {
if (model.isBlank()) {
throw ValidationException("Device model is required", "model")
}
return commandRepository.getDefinitions(model.trim())
}
}
The SendCommandViewModel filters to active definitions and groups them by category:
val activeDefinitions = definitions.filter { it.active }
val grouped = activeDefinitions.groupBy { it.category }
Device Model-Specific Availabilityβ
Commands vary by GPS tracker model. The API returns different definitions per model string:
// CommandApi
@GET("api/commands/definitions")
suspend fun getDefinitions(@Query("model") model: String): List<CommandDefinitionDto>
// CommandRepository
suspend fun getCommandTypes(deviceId: Int): CommandTypesResponse
CommandTypesResponse returns the available command type strings for a specific device, while getDefinitions(model) returns full definitions with metadata for all commands a model supports. The CommandsHistoryScreen passes the device model to load the correct definitions:
LaunchedEffect(model) {
model?.let {
sendViewModel.handle(SendCommandIntent.LoadDefinitions(it))
}
}
Parameterized Commandsβ
Commands may accept configuration values. The parameter system supports three configuration patterns:
1. ParamConfig with fields β multi-field input forms:
data class ParamField(
val name: String,
val label: String,
val type: String?, // "number", "select", or text
val min: Double?,
val max: Double?,
val unit: String?,
val options: List<String>?,
val defaultValue: String?,
val placeholder: String?
)
2. ParamConfig without fields β single-parameter input:
data class ParamConfig(
val type: String?, // "number", "select", "phone", or text
val min: Double?,
val max: Double?,
val step: Double?,
val unit: String?,
val options: List<String>?,
val placeholder: String?,
val fields: List<ParamField>?
)
3. Legacy parameters β fallback list of key/value parameters:
data class CommandParameter(
val key: String,
val label: String,
val type: String, // "number", "int", "phone", etc.
val required: Boolean,
val defaultValue: Any?
)
The CommandParameterSheet composable renders the appropriate input UI based on which configuration is present:
when {
paramConfig?.fields?.isNotEmpty() == true -> {
// Multi-field input
paramConfig.fields.forEach { field ->
ParameterFieldInput(field = field, ...)
}
}
paramConfig != null -> {
// Single parameter input (slider, select, phone, or text)
SingleParameterInput(paramConfig = paramConfig, ...)
}
else -> {
// Legacy parameters fallback
definition.parameters.forEach { param ->
LegacyParameterInput(label = param.label, ...)
}
}
}
Numeric parameters with bounded min/max render as sliders; unbounded numeric or text fields render as text inputs.
Sending Commandsβ
The send flow goes through the use case β repository β API:
class SendCommandUseCase @Inject constructor(
private val commandRepository: CommandRepository
) {
suspend operator fun invoke(
deviceId: Int,
type: String,
attributes: Map<String, Any>? = null
): CommandSendResult {
if (deviceId <= 0) throw ValidationException("Invalid device ID", "deviceId")
if (type.isBlank()) throw ValidationException("Command type is required", "type")
return commandRepository.send(deviceId, type.trim(), attributes)
}
}
The API request includes a default tcp channel:
data class SendCommandRequest(
val deviceId: Int,
val type: String,
val attributes: Map<String, Any>? = null,
val channel: String = "tcp"
)
The result includes an entryId for status tracking:
data class CommandSendResult(
val entryId: String,
val status: CommandStatus,
val queuedAt: Instant?
)
Command Status Trackingβ
Commands progress through a lifecycle represented by CommandStatus:
enum class CommandStatus {
PENDING,
SENT,
QUEUED,
DELIVERED,
FAILED,
UNKNOWN;
}
The lifecycle flow:
PENDING β QUEUED β SENT β DELIVERED
β FAILED
| Status | Meaning | UI indicator |
|---|---|---|
PENDING | Awaiting processing | β± Schedule icon, warning color |
QUEUED | Queued for delivery | β± Schedule icon, warning color |
SENT | Sent to device | β CheckCircle icon, success color |
DELIVERED | Confirmed by device | β CheckCircle icon, success color |
FAILED | Delivery failed | β Error icon, error color |
UNKNOWN | Status not available | β± Schedule icon, muted color |
Status can be polled via:
suspend fun getStatus(entryId: String): CommandStatusResult
Command Historyβ
The CommandsHistoryScreen shows a chronological list of commands sent to a device. Each entry contains:
data class CommandHistoryEntry(
val id: Int,
val entryId: String?,
val commandType: String,
val rawCommand: String?, // the actual protocol command sent
val status: CommandStatus,
val responseData: String?, // device response if available
val createdAt: Instant,
val updatedAt: Instant?
)
The screen integrates both history viewing and command sending β the FAB opens a SendCommandBottomSheet, and after a successful send the history is automatically refreshed.
Command Icon Mappingβ
The UI maps command type strings to Material icons based on keyword matching:
fun getCommandIcon(commandType: String): ImageVector {
val type = commandType.lowercase()
return when {
type.contains("vibration") && type.contains("alarm") -> Icons.Default.NotificationsActive
type.contains("relay") && !type.contains("off") -> Icons.Default.Lock
type.contains("relay") && type.contains("off") -> Icons.Default.LockOpen
type.contains("position") || type.contains("address") -> Icons.Default.LocationOn
type.contains("status") -> Icons.Default.Info
type.contains("freq") -> Icons.Default.CellTower
type.contains("reboot") -> Icons.Default.Refresh
type.contains("alarm") -> Icons.Default.NotificationsActive
else -> Icons.Default.Terminal
}
}
Error Handlingβ
Both systems use domain-specific exceptions from domain/errors/DomainExceptions.kt:
Geofence errors:
GeofenceNotFoundExceptionβ geofence ID not found (HTTP 404)InvalidGeofenceAreaExceptionβ invalid WKT area formatValidationExceptionβ empty name, invalid ID
Command errors:
CommandNotSupportedExceptionβ command type not available for deviceCommandMappingExceptionβ DTO-to-domain mapping failureCommandFailedExceptionβ command execution failedValidationExceptionβ empty type, invalid device ID
Both repositories wrap HttpException and IOException into NetworkException for consistent error handling in the UI layer.
Design Decisionsβ
Two-Phase Geofence Editorβ
The editor separates shape drawing from metadata entry. This avoids cluttering the map UI with form fields and lets the map consume the full screen during the drawing phase. The EditorPhase enum makes the state machine explicit and prevents invalid transitions (e.g., saving without a shape).
WKT as the Geometry Wire Formatβ
Using WKT strings for the area field means the app doesn't need to serialize complex geometric structures β a single string carries the full shape definition. The WktBuilder centralizes formatting, preventing common mistakes like wrong coordinate order (WKT uses longitude-latitude, not latitude-longitude).
GeofenceDataStore as Single Source of Truthβ
Rather than each screen independently fetching and caching geofences, the GeofenceDataStore provides a shared StateFlow that all ViewModels observe. This means a geofence created in the editor screen appears immediately in the list screen without a manual refresh. The per-device lazy loading (loadedDeviceIds) avoids fetching geofences for devices the user hasn't viewed.
Model-Based Command Definitionsβ
Commands are resolved by device model string rather than device ID. This allows the backend to define command sets once per tracker model and reuse them across all devices of that type. The CommandDefinitionDto.resolvedType getter handles backward compatibility where the type field can appear as either commandType or type:
data class CommandDefinitionDto(...) {
val resolvedType: String get() = commandType ?: type ?: ""
}
Three-Tier Parameter Configurationβ
The parameter system supports three levels of configuration (ParamConfig with fields, ParamConfig without fields, legacy parameters) for backward compatibility. The CommandParameterSheet checks them in priority order, ensuring new multi-field configurations work while older single-parameter and legacy definitions remain functional.
Backend Device Linking Enrichmentβ
The GeofenceRepositoryImpl.create() method patches the response to include the creating device's ID. This compensates for a backend race condition where the geofence-device link is established asynchronously after creation. Without this patch, the newly created geofence would appear unlinked until the next fetch.
Optimistic Cache with Server Authorityβ
The GeofenceDataStore updates optimistically after successful API calls (add/update/remove), but the server remains authoritative. Pull-to-refresh and invalidateDevice() force re-fetching from the API when needed. The Mutex guard ensures all mutations are serialized even under concurrent coroutine access.