Skip to main content

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:


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
}
}
}
TypeShape definitionUI control
CIRCLECenter coordinate + radius (meters)Tap map to place center, slider for radius (50–5 000 m)
POLYGONOrdered list of β‰₯ 3 verticesTap 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):

  1. User selects circle or polygon via ShapeTypeButton toggle.
  2. Map taps dispatch SetCircleCenter or AddPolygonPoint intents.
  3. For circles: a Slider controls the radius (50–5 000 m).
  4. For polygons: undo/clear buttons manage vertices; minimum 3 points required.
  5. The canConfirmShape guard ensures validity before advancing.

Phase 2 β€” Enter Details (EnterDetailsPhase composable):

  1. Name (required) and description (optional) text fields.
  2. Alert toggles for entry and exit notifications.
  3. Save button enabled when canSave is 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:

FieldDefaultBehavior
alertOnEntertrueNotification when device enters the zone
alertOnExittrueNotification 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 shared StateFlow.
  • Thread-safe mutations: All writes use a Mutex to 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:

CategoryColorTypical commands
SecurityOrange (#FF9500)Relay on/off, vibration alarm
TrackingCyan (#00BCD4)Position request, frequency changes
DiagnosticsBlue (#2196F3)Status check, reboot
OtherGrayUncategorized 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
StatusMeaningUI indicator
PENDINGAwaiting processing⏱ Schedule icon, warning color
QUEUEDQueued for delivery⏱ Schedule icon, warning color
SENTSent to deviceβœ… CheckCircle icon, success color
DELIVEREDConfirmed by deviceβœ… CheckCircle icon, success color
FAILEDDelivery failed❌ Error icon, error color
UNKNOWNStatus 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 format
  • ValidationException β€” empty name, invalid ID

Command errors:

  • CommandNotSupportedException β€” command type not available for device
  • CommandMappingException β€” DTO-to-domain mapping failure
  • CommandFailedException β€” command execution failed
  • ValidationException β€” 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.