API v1

Rastreador (BackgroundService)

Android: service/BackgroundService.kt Foreground Service · type=location

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.

Escopo: Esta página cobre apenas a captura/envio de posições. O consumer que recebe os pacotes no servidor é serviço separado mantido por outro dev. O contrato entre app e consumer está descrito em Protocolo.

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çãoOrigem
isDriver == trueSharedPreferences driver/driver
ACCESS_FINE_LOCATION ou ACCESS_COARSE_LOCATIONPermissão runtime
ACCESS_BACKGROUND_LOCATION (Android 10+)Permissão runtime
cpf não vazioSharedPreferences 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().

Por que essa ordem importa: No Android 14+, chamar 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.

EstadoCritérioDecisão
1ª posiçãolastSentLocation == nullEnvia
Transição parado → movimentostoppedSince != null && !paradoAgoraEnvia imediato (transição importa)
Em movimentospeed >= 1 km/hEnvia se distância >= max(10m, accuracy)
Parado < 90 s (semáforo / congestionamento)Aplica regra de movimentoEnvia se moveu >= 10 m
Parado >= 90 s (parado real)HeartbeatEnvia 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.

Precisão de GPS: O threshold de distância é 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:

  1. formatProtocol monta a linha MONIAPP;cpf;lat;lng;...
  2. Atualiza lastSentLocation e lastSentTime antes do launch (evita disparo paralelo se outra posição chegar enquanto o socket está em andamento)
  3. serviceScope.launch { connectAndSend(protocol) } abre socket próprio, envia, faz leitura curta, fecha
  4. Falha na rede → posição vai para o cache
Desacoplado do flush: O caminho realtime não verifica 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: connectAndSend retornou false

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.

Por que o filtro vem antes: Sem ele, um veículo parado offline por 1 h gerava ~360 posições idênticas no cache (uma a cada 10 s). Com o filtro, gera ~20 posições (heartbeat a cada 3 min). Isso reduz o tempo de flush em >15×.

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)

OrdemComportamento
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).

Risco aceito: Se o socket cair no meio do flush, posições já escritas localmente podem não ter chegado ao consumer (TCP garante bytes no buffer do SO, sem ACK aplicacional). Mitigação: combinado com o filtro de movimento, o cache raramente passa de dezenas de itens. Mesmo perdendo um flush, o próximo heartbeat repõe o estado atual.

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}
CampoTipoOrigem
cpfStringDATA/cpf (SharedPreferences)
lat / lngDoubleLocation do FusedLocationProvider
timestamp_msLongSystem.currentTimeMillis()
battery_pctFloatBatteryManager.EXTRA_LEVEL * 100 / EXTRA_SCALE
speed_kmhFloatlocation.speed * 3.6 (0 se < 0.5 m/s)
os_nameString"Android ${Build.VERSION.RELEASE}"
os_versionStringBuild.VERSION.SDK_INT
device_modelStringBuild.MODEL
app_version_nameStringPackageInfo.versionName
app_version_codeStringPackageInfo.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

  1. App escreve a linha MONIAPP no socket e dá flush()
  2. App lê do socket com soTimeout = 300 ms via tryReadServerCommand
  3. Se servidor respondeu uma linha JSON, parseia em handleServerCommand
  4. 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
  5. Se vier valor inválido ou fora do range → log de warning e ignora

Exemplo de payload do servidor

{"interval": 3.5}
Janela de leitura: A leitura acontece depois de cada envio realtime e uma vez no fim do flush do cache. Atualizações de interval chegam no próximo envio, não em tempo real. Para um motorista parado, o delay máximo é o próprio interval atual.
Contrato com o consumer: O servidor deve emitir a linha JSON antes de o app fechar o socket (janela de 300 ms após o 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áriouseLocationTypeResultado
Todas as pré-validações OKtrueServiço sobe como FGS location
Faltou permissão / driver / userDatafalseFGS sobe sem tipo, stopSelf() imediato

Concorrência e thread-safety

RecursoThreadsProteção
cacheItensMain (callback GPS) + IO (flush + envio realtime)synchronized(cacheItens) em todos os acessos
intervalMain (leitura) + IO (escrita pelo servidor)@Volatile
isSendingCacheIO (apenas)Reentrância já protegida pelo próprio flag
lastSentLocation / stoppedSinceMain (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

ChaveTipoDefaultOrigem
cpfStringLogin
interval_msLong3 * 60 * 1000 (3 min)Comando JSON do servidor via socket
Chave (outro prefs)ArquivoFunção
driverdriverBoolean — só sobe o serviço se motorista

Mudanças recentes — histórico

AntesDepoisMotivo
Throttle fixo de 3 min entre enviosFiltro 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 sCache só recebe posições aprovadas pelo filtroVeículo parado offline gerava ~360 duplicatas/hora
Flush: 1 socket por posição + delay(500ms)Flush: 1 socket único, LIFO, sem delay200 posições levavam >100 s; agora drenam em segundos
Caminho realtime bloqueado por isSendingCacheRealtime tem socket próprio em paraleloPosição atual ficava engasgada atrás do flush
interval hardcoded em 3 mininterval configurável via JSON no socket + persistênciaPermitir cadências diferentes por veículo/empresa
startForeground chamado antes das validaçõesValidações antes; FGS sem tipo se faltar permissãoCrash SecurityException ao revogar permissão no Android 14+