Rastreador (BackgroundService)
Serviço em primeiro plano responsável por capturar posições GPS do motorista e enviá-las via socket TCP para o consumer (socket.monisat.online:6001). Implementa filtro de movimento, cache offline com flush LIFO em socket único, e canal de configuração dinâmica do interval via JSON enviado pelo servidor.
Quando o serviço roda
BackgroundService só inicia se todas as condições abaixo forem verdadeiras (checadas em onStartCommand antes de qualquer chamada a startForeground):
| Condição | Origem |
|---|---|
isDriver == true | SharedPreferences driver/driver |
ACCESS_FINE_LOCATION ou ACCESS_COARSE_LOCATION | Permissão runtime |
ACCESS_BACKGROUND_LOCATION (Android 10+) | Permissão runtime |
cpf não vazio | SharedPreferences DATA/cpf |
Se algum requisito falhar, o serviço chama startForeground sem o tipo location (FOREGROUND_SERVICE_TYPE_NONE = 0) só para satisfazer o contrato de 5 s do startForegroundService, e em seguida stopSelf().
startForeground com foregroundServiceType="location" (declarado no AndroidManifest.xml) sem a permissão de localização lança SecurityException e crasha o app. A pré-validação evita isso quando o usuário revoga a permissão e o sistema tenta reiniciar via START_STICKY.
Filtro de Movimento
Toda posição passa por shouldSendLocation(location, speedInKM, currentTime) antes de qualquer envio ou cache. A regra evita encher o cache de posições redundantes quando o veículo está parado.
| Estado | Critério | Decisão |
|---|---|---|
| 1ª posição | lastSentLocation == null | Envia |
| Transição parado → movimento | stoppedSince != null && !paradoAgora | Envia imediato (transição importa) |
| Em movimento | speed >= 1 km/h | Envia se distância >= max(10m, accuracy) |
| Parado < 90 s (semáforo / congestionamento) | Aplica regra de movimento | Envia se moveu >= 10 m |
| Parado >= 90 s (parado real) | Heartbeat | Envia se tempoDesdeUltimoEnvio >= interval min |
Constantes (companion object)
private const val DISTANCIA_MINIMA_METROS = 10f private const val VELOCIDADE_PARADO_KMH = 1.0f private const val TEMPO_PARA_CONSIDERAR_PARADO_MS = 90_000L // 90 s
O heartbeat de parado usa o interval dinâmico (em minutos), não uma constante. Default 3 min, configurável remotamente — ver Interval dinâmico.
max(10m, location.accuracy). Em áreas urbanas com precisão de 15 m, o threshold efetivo vira 15 m — evita disparar "movimento fantasma" causado por oscilação de GPS parado (multipath em prédios).
Caminho de envio (realtime)
Quando o filtro aprova a posição e há rede disponível:
formatProtocolmonta a linhaMONIAPP;cpf;lat;lng;...- Atualiza
lastSentLocationelastSentTimeantes dolaunch(evita disparo paralelo se outra posição chegar enquanto o socket está em andamento) serviceScope.launch { connectAndSend(protocol) }abre socket próprio, envia, faz leitura curta, fecha- Falha na rede → posição vai para o cache
isSendingCache. Mesmo que um flush longo esteja rodando em background, a posição atual sai imediatamente em seu próprio socket. Isso evita o problema antigo onde a "posição atual" ficava engasgada atrás do cache acumulado.
Cache offline
cacheItens: MutableList<UserLocationData> guarda posições que passaram pelo filtro mas não puderam ser enviadas. Entradas no cache:
- Sem rede: filtro aprovou +
_isNetworkAvailable.value == false - Envio realtime falhou:
connectAndSendretornoufalse
Acesso ao cache é sincronizado via synchronized(cacheItens) em todas as leituras/escritas, pois o callback do GPS roda na main thread e o flush em Dispatchers.IO.
Flush do cache — socket único LIFO
Disparado por networkCallback.onAvailable quando a rede volta. sendCachedLocations abre uma única conexão TCP, dreina o cache do mais novo para o mais antigo, fecha.
Algoritmo
abre 1 socket (timeout 10s)
se falhou → mantém cache intacto, sai
writer = PrintWriter(socket.outputStream, autoFlush=true)
reader = BufferedReader(InputStreamReader(socket.inputStream))
loop:
item = synchronized(cacheItens) {
cacheItens.removeAt(size - 1) // LIFO: mais novo primeiro
}
se cache vazio → break
valida coordenadas → se inválido, descarta
formata protocolo MONIAPP;...
writer.println(protocolo)
se writer.checkError() ou exception:
synchronized(cacheItens) { cacheItens.add(item) } // devolve
break
writer.flush()
tryReadServerCommand(socket, reader) // janela de 300ms
fecha socket
Por que LIFO (do mais novo para o mais antigo)
| Ordem | Comportamento |
|---|---|
| FIFO (antigo) | Painel recebe posições antigas primeiro; a "posição atual" só aparece quando todo o cache antigo drena |
| LIFO (atual) | Painel recebe imediatamente a posição mais recente; histórico vai sendo preenchido depois |
Por que socket único
O modelo antigo abria/fechava 1 socket por posição com delay(500ms) entre cada. Em 200 posições eram 200 handshakes TCP + 100 s de delay. Com socket único: 1 handshake total e println em sequência (line-delimited é o uso natural do protocolo).
Protocolo MONIAPP
Linha única terminada em \n (escrita via PrintWriter.println):
MONIAPP;{cpf};{lat};{lng};{timestamp_ms};{battery_pct};{speed_kmh};{os_name};{os_version};{device_model};{app_version_name};{app_version_code}
| Campo | Tipo | Origem |
|---|---|---|
cpf | String | DATA/cpf (SharedPreferences) |
lat / lng | Double | Location do FusedLocationProvider |
timestamp_ms | Long | System.currentTimeMillis() |
battery_pct | Float | BatteryManager.EXTRA_LEVEL * 100 / EXTRA_SCALE |
speed_kmh | Float | location.speed * 3.6 (0 se < 0.5 m/s) |
os_name | String | "Android ${Build.VERSION.RELEASE}" |
os_version | String | Build.VERSION.SDK_INT |
device_model | String | Build.MODEL |
app_version_name | String | PackageInfo.versionName |
app_version_code | String | PackageInfo.longVersionCode (API 28+) |
Interval dinâmico (configuração remota)
O interval (em minutos) controla o heartbeat de veículo parado. Default 3.0, persistido em DATA/interval_ms (Long, milissegundos para preservar precisão de fracionários como 3.5 → 3 min 30 s).
Fluxo da configuração
- App escreve a linha MONIAPP no socket e dá
flush() - App lê do socket com
soTimeout = 300 msviatryReadServerCommand - Se servidor respondeu uma linha JSON, parseia em
handleServerCommand - Se vier
{"interval": double}no range 0.1 – 60 min:- Atualiza a var
@Volatile interval - Persiste em
DATA/interval_ms - Próximo cálculo de heartbeat já usa o novo valor
- Atualiza a var
- Se vier valor inválido ou fora do range → log de warning e ignora
Exemplo de payload do servidor
{"interval": 3.5}
interval chegam no próximo envio, não em tempo real. Para um motorista parado, o delay máximo é o próprio interval atual.
println do app). Pode ser idempotente em todo envio ou apenas quando o valor mudou.
Foreground Service Type — Android 14+
Manifesto declara:
<service
android:name=".service.BackgroundService"
android:foregroundServiceType="location"/>
O startForeground usa ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION explicitamente quando há permissão, e 0 quando não há (caminho de shutdown):
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val type = if (useLocationType) ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
startForeground(NOTIFICATION_ID, notification, type)
} else {
startForeground(NOTIFICATION_ID, notification)
}
| Cenário | useLocationType | Resultado |
|---|---|---|
| Todas as pré-validações OK | true | Serviço sobe como FGS location |
| Faltou permissão / driver / userData | false | FGS sobe sem tipo, stopSelf() imediato |
Concorrência e thread-safety
| Recurso | Threads | Proteção |
|---|---|---|
cacheItens | Main (callback GPS) + IO (flush + envio realtime) | synchronized(cacheItens) em todos os acessos |
interval | Main (leitura) + IO (escrita pelo servidor) | @Volatile |
isSendingCache | IO (apenas) | Reentrância já protegida pelo próprio flag |
lastSentLocation / stoppedSince | Main (apenas, dentro de processNewLocation) | Sem sincronização — single-threaded por design |
LocationRequest
locationRequest = LocationRequest.create().apply {
interval = 10_000 // 10 s entre samples
fastestInterval = 5_000 // mínimo 5 s
priority = LocationRequest.PRIORITY_HIGH_ACCURACY
}
O GPS amostra a cada 10 s independente do interval de envio. O filtro de movimento decide quais samples viram envio. Isso mantém precisão para detectar transições parado → movimento em até 10 s.
Reconexão de rede
ConnectivityManager.NetworkCallback mantém o flag _isNetworkAvailable sincronizado e dispara o flush quando a rede volta:
override fun onAvailable(network: Network) {
_isNetworkAvailable.value = true
if (cacheItens.isNotEmpty()) {
serviceScope.launch { sendCachedLocations() }
}
}
Configuração no SharedPreferences DATA
| Chave | Tipo | Default | Origem |
|---|---|---|---|
cpf | String | — | Login |
interval_ms | Long | 3 * 60 * 1000 (3 min) | Comando JSON do servidor via socket |
| Chave (outro prefs) | Arquivo | Função |
|---|---|---|
driver | driver | Boolean — só sobe o serviço se motorista |
Mudanças recentes — histórico
| Antes | Depois | Motivo |
|---|---|---|
| Throttle fixo de 3 min entre envios | Filtro de movimento (10 m + heartbeat configurável) | Painel atualizava com 3 min de atraso mesmo em movimento |
| Cache offline acumulava 1 posição a cada 10 s | Cache só recebe posições aprovadas pelo filtro | Veículo parado offline gerava ~360 duplicatas/hora |
Flush: 1 socket por posição + delay(500ms) | Flush: 1 socket único, LIFO, sem delay | 200 posições levavam >100 s; agora drenam em segundos |
Caminho realtime bloqueado por isSendingCache | Realtime tem socket próprio em paralelo | Posição atual ficava engasgada atrás do flush |
interval hardcoded em 3 min | interval configurável via JSON no socket + persistência | Permitir cadências diferentes por veículo/empresa |
startForeground chamado antes das validações | Validações antes; FGS sem tipo se faltar permissão | Crash SecurityException ao revogar permissão no Android 14+ |