Add location-tagged tower survey log + full-state persistence

- Persist the entire CellGuardState (serving, neighbors, PLMN, frames,
  alerts) to disk and reload on start; carry forward last-known values so
  no field blanks on a transient poll miss and the app never opens empty.
- Log every observed tower with GPS position, timestamp and signal to a
  framework-SQLite database (SurveyDb), clustering sightings into 2.5-mile
  areas; travelling past 2.5 mi starts a new area. Framework LocationManager
  only (no Google Play Services); GPS speed drives a 'traveling' indicator.
- New SURVEY LOG tab lists areas and the towers seen in each. Declare the
  location foreground-service type, claimed only when the permission is held.
This commit is contained in:
sssnake
2026-07-12 11:34:51 -07:00
parent 340df5a05c
commit 338e782cb5
7 changed files with 735 additions and 62 deletions

View File

@@ -0,0 +1,226 @@
package com.seteclabs.cellguard.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.seteclabs.cellguard.data.AreaRow
import com.seteclabs.cellguard.data.SurveyDb
import com.seteclabs.cellguard.data.TowerRow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val stamp = SimpleDateFormat("MMM d, HH:mm", Locale.US)
/**
* The location-tagged survey log: the 2.5-mile areas the device has passed
* through, each expandable to the towers seen there. Reads [SurveyDb] directly
* (the detector service writes it) and refreshes on a short tick.
*/
@Composable
fun SurveyScreen() {
val ctx = LocalContext.current
val db = remember { SurveyDb(ctx) }
var areas by remember { mutableStateOf<List<AreaRow>>(emptyList()) }
var expanded by remember { mutableStateOf(-1L) }
var towers by remember { mutableStateOf<List<TowerRow>>(emptyList()) }
LaunchedEffect(Unit) {
while (true) {
areas = withContext(Dispatchers.IO) { runCatching { db.areas() }.getOrDefault(emptyList()) }
if (expanded >= 0) {
towers = withContext(Dispatchers.IO) { runCatching { db.towers(expanded) }.getOrDefault(emptyList()) }
}
delay(4_000)
}
}
DisposableEffect(Unit) { onDispose { runCatching { db.close() } } }
if (areas.isEmpty()) {
Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"No survey data yet.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
)
Spacer(Modifier.height(6.dp))
Text(
"Towers are logged with your GPS position as you move. Grant location and drive around to build the map.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
}
return
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text(
"SURVEY LOG · ${areas.size} area(s)",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
}
items(areas, key = { it.id }) { a ->
AreaCard(
area = a,
isOpen = a.id == expanded,
towers = if (a.id == expanded) towers else emptyList(),
onClick = { expanded = if (expanded == a.id) -1L else a.id },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
@Composable
private fun AreaCard(area: AreaRow, isOpen: Boolean, towers: List<TowerRow>, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(Modifier.padding(14.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
"AREA #${area.id}",
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
Text(
"${area.towerCount} tower(s)",
color = CgCyan,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(4.dp))
Text(
"%.5f, %.5f".format(area.lat, area.lon),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
Text(
"${stamp.format(Date(area.firstMs))}${stamp.format(Date(area.lastMs))}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
if (isOpen) {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(Modifier.height(8.dp))
if (towers.isEmpty()) {
Text(
"loading…",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
} else {
towers.forEach { TowerLogRow(it) }
}
}
}
}
}
@Composable
private fun TowerLogRow(t: TowerRow) {
val label = t.op.ifBlank { (t.mcc + t.mnc).ifBlank { if (t.ci >= 0) t.ci.toString() else "" } }
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RatChip(t.rat, t.registered)
Spacer(Modifier.width(8.dp))
Text(
label,
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Spacer(Modifier.weight(1f))
Text(
t.rsrp?.let { "$it dBm" } ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
}
Text(
"CI ${dashL(t.ci)} · PCI ${dashI(t.pci)} · TAC ${dashI(t.tac)} · EARFCN ${dashI(t.earfcn)} · seen ${t.count}×",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
)
}
}
@Composable
private fun RatChip(rat: String, registered: Boolean) {
val c = when (rat.uppercase()) {
"LTE" -> CgGreen
"NR", "5G" -> CgCyan
"WCDMA", "UMTS", "TDSCDMA" -> CgAmber
"GSM", "CDMA", "2G" -> CgRed
else -> CgGray
}
Text(
if (registered) "$rat" else rat,
color = Color.Black,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c).padding(horizontal = 6.dp, vertical = 1.dp),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
)
}
private fun dashL(v: Long): String = if (v >= 0) v.toString() else ""
private fun dashI(v: Int): String = if (v >= 0) v.toString() else ""