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

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -30,7 +31,7 @@
<service
android:name=".service.DetectorService"
android:exported="false"
android:foregroundServiceType="specialUse">
android:foregroundServiceType="specialUse|location">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Continuous baseband cell-capture monitoring and IMSI-catcher detection" />

View File

@@ -0,0 +1,67 @@
package com.seteclabs.cellguard.data
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Looper
import androidx.core.content.ContextCompat
/**
* Thin wrapper over the framework [LocationManager] (GPS + network providers,
* no Google Play Services). Holds the most recent fix and derives a coarse
* "traveling" signal from GPS speed, which the survey logger uses to know the
* user is driving around and should keep updating the location-tagged log.
*/
class LocationTracker(context: Context) {
private val appCtx = context.applicationContext
private val lm = appCtx.getSystemService(Context.LOCATION_SERVICE) as LocationManager
@Volatile
var last: Location? = null
private set
private val listener = LocationListener { loc ->
// Keep the more accurate / newer fix.
val prev = last
if (prev == null || loc.time >= prev.time) last = loc
}
private fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(appCtx, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
fun start() {
if (!hasPermission()) return
try {
val looper = Looper.getMainLooper()
for (p in listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)) {
if (lm.isProviderEnabled(p)) {
lm.requestLocationUpdates(p, MIN_TIME_MS, MIN_DIST_M, listener, looper)
lm.getLastKnownLocation(p)?.let { if (last == null || it.time > last!!.time) last = it }
}
}
} catch (_: SecurityException) {
}
}
fun stop() {
try {
lm.removeUpdates(listener)
} catch (_: SecurityException) {
}
}
/** True when GPS reports movement above walking pace (~5 mph) — i.e. driving. */
val traveling: Boolean
get() = (last?.takeIf { it.hasSpeed() }?.speed ?: 0f) >= MOVING_SPEED_MPS
companion object {
private const val MIN_TIME_MS = 4_000L
private const val MIN_DIST_M = 0f
private const val MOVING_SPEED_MPS = 2.2f // ~5 mph
}
}

View File

@@ -0,0 +1,233 @@
package com.seteclabs.cellguard.data
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.location.Location
import com.seteclabs.cellguard.model.Tower
/** One 2.5-mile survey area (a cluster of tower sightings sharing a location). */
data class AreaRow(
val id: Long,
val lat: Double,
val lon: Double,
val firstMs: Long,
val lastMs: Long,
val towerCount: Int,
)
/** One tower logged within an area, with its last-seen signal and position. */
data class TowerRow(
val id: Long,
val areaId: Long,
val rat: String,
val mcc: String,
val mnc: String,
val ci: Long,
val pci: Int,
val tac: Int,
val earfcn: Int,
val op: String,
val registered: Boolean,
val firstMs: Long,
val lastMs: Long,
val count: Int,
val rsrp: Int?,
val rsrq: Int?,
val lat: Double,
val lon: Double,
)
/**
* Location-tagged tower survey log, backed by framework SQLite (no Room/KSP,
* no Google Play Services).
*
* Model (the user's spec): every tower sighting is stored with GPS + timestamp
* + signal. Sightings are grouped into **areas** — a new sighting joins the
* nearest existing area whose center is within [RADIUS_M] (2.5 mi); if none is
* that close (the user has traveled out of every known area), a new area is
* created at the current position. Within an area a tower is stored once per
* identity and upserted (count++, last-seen time + signal refreshed) rather
* than duplicated on every 4s poll, so the log stays compact while a drive
* across town naturally produces a chain of areas.
*/
class SurveyDb(context: Context) :
SQLiteOpenHelper(context.applicationContext, NAME, null, VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE areas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL, lon REAL NOT NULL,
first_ms INTEGER NOT NULL, last_ms INTEGER NOT NULL)""",
)
db.execSQL(
"""CREATE TABLE towers(
id INTEGER PRIMARY KEY AUTOINCREMENT,
area_id INTEGER NOT NULL,
rat TEXT, mcc TEXT, mnc TEXT,
ci INTEGER, pci INTEGER, tac INTEGER, earfcn INTEGER,
op TEXT, registered INTEGER,
first_ms INTEGER, last_ms INTEGER, count INTEGER,
rsrp INTEGER, rsrq INTEGER, lat REAL, lon REAL)""",
)
db.execSQL(
"""CREATE UNIQUE INDEX idx_tower_identity
ON towers(area_id, rat, mcc, mnc, ci, pci, earfcn)""",
)
db.execSQL("CREATE INDEX idx_tower_area ON towers(area_id)")
}
override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {
db.execSQL("DROP TABLE IF EXISTS towers")
db.execSQL("DROP TABLE IF EXISTS areas")
onCreate(db)
}
/**
* Log a poll's observed towers at [loc]. Returns the area id used, or -1 if
* there is no location fix yet (sightings can't be area-tagged without one).
*/
fun log(loc: Location?, towers: List<Tower>, nowMs: Long): Long {
if (loc == null || towers.isEmpty()) return -1
val db = writableDatabase
val areaId = findOrCreateArea(db, loc.latitude, loc.longitude, nowMs)
db.beginTransaction()
try {
for (t in towers) upsertTower(db, areaId, t, loc, nowMs)
db.execSQL("UPDATE areas SET last_ms=? WHERE id=?", arrayOf<Any?>(nowMs, areaId))
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
return areaId
}
private fun findOrCreateArea(db: SQLiteDatabase, lat: Double, lon: Double, nowMs: Long): Long {
var bestId = -1L
var bestDist = RADIUS_M
db.rawQuery("SELECT id, lat, lon FROM areas", null).use { c ->
val out = FloatArray(1)
while (c.moveToNext()) {
Location.distanceBetween(lat, lon, c.getDouble(1), c.getDouble(2), out)
if (out[0] <= bestDist) { bestDist = out[0]; bestId = c.getLong(0) }
}
}
if (bestId >= 0) return bestId
val v = ContentValues().apply {
put("lat", lat); put("lon", lon); put("first_ms", nowMs); put("last_ms", nowMs)
}
return db.insert("areas", null, v)
}
private fun upsertTower(db: SQLiteDatabase, areaId: Long, t: Tower, loc: Location, nowMs: Long) {
val where = "area_id=? AND rat=? AND mcc=? AND mnc=? AND ci=? AND pci=? AND earfcn=?"
val keyArgs = arrayOf<Any?>(areaId, t.rat, t.mcc, t.mnc, t.ci, t.pci, t.earfcn)
// Update the existing sighting for this identity in this area: bump the
// count, refresh last-seen time/signal/position, and latch registered once
// the tower has ever served us. COALESCE keeps the prior signal if this
// poll had none. changes() then tells us whether a row actually matched.
db.execSQL(
"""UPDATE towers SET
last_ms=?, count=count+1, tac=?, op=?,
rsrp=COALESCE(?, rsrp), rsrq=COALESCE(?, rsrq),
lat=?, lon=?, registered=MAX(registered, ?)
WHERE $where""",
arrayOf(
nowMs, t.tac, t.op, t.rsrp, t.rsrq,
loc.latitude, loc.longitude, if (t.registered) 1 else 0,
*keyArgs,
),
)
val changed = db.rawQuery("SELECT changes()", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
if (changed > 0) return
db.insert(
"towers",
null,
ContentValues().apply {
put("area_id", areaId)
put("rat", t.rat); put("mcc", t.mcc); put("mnc", t.mnc)
put("ci", t.ci); put("pci", t.pci); put("tac", t.tac); put("earfcn", t.earfcn)
put("op", t.op); put("registered", if (t.registered) 1 else 0)
put("first_ms", nowMs); put("last_ms", nowMs); put("count", 1)
t.rsrp?.let { put("rsrp", it) }; t.rsrq?.let { put("rsrq", it) }
put("lat", loc.latitude); put("lon", loc.longitude)
},
)
}
/** Areas newest-first, each with its distinct tower count. */
fun areas(): List<AreaRow> {
val out = ArrayList<AreaRow>()
readableDatabase.rawQuery(
"""SELECT a.id, a.lat, a.lon, a.first_ms, a.last_ms,
(SELECT COUNT(*) FROM towers t WHERE t.area_id=a.id)
FROM areas a ORDER BY a.last_ms DESC""",
null,
).use { c ->
while (c.moveToNext()) {
out.add(
AreaRow(c.getLong(0), c.getDouble(1), c.getDouble(2), c.getLong(3), c.getLong(4), c.getInt(5)),
)
}
}
return out
}
/** Towers logged within one area, serving first then strongest signal. */
fun towers(areaId: Long): List<TowerRow> {
val out = ArrayList<TowerRow>()
readableDatabase.rawQuery(
"SELECT * FROM towers WHERE area_id=? ORDER BY registered DESC, rsrp DESC",
arrayOf(areaId.toString()),
).use { c ->
fun i(n: String) = c.getColumnIndexOrThrow(n)
while (c.moveToNext()) {
out.add(
TowerRow(
id = c.getLong(i("id")),
areaId = c.getLong(i("area_id")),
rat = c.getString(i("rat")) ?: "",
mcc = c.getString(i("mcc")) ?: "",
mnc = c.getString(i("mnc")) ?: "",
ci = c.getLong(i("ci")),
pci = c.getInt(i("pci")),
tac = c.getInt(i("tac")),
earfcn = c.getInt(i("earfcn")),
op = c.getString(i("op")) ?: "",
registered = c.getInt(i("registered")) != 0,
firstMs = c.getLong(i("first_ms")),
lastMs = c.getLong(i("last_ms")),
count = c.getInt(i("count")),
rsrp = c.getInt(i("rsrp")).takeIf { !c.isNull(i("rsrp")) },
rsrq = c.getInt(i("rsrq")).takeIf { !c.isNull(i("rsrq")) },
lat = c.getDouble(i("lat")),
lon = c.getDouble(i("lon")),
),
)
}
}
return out
}
fun areaCount(): Int =
readableDatabase.rawQuery("SELECT COUNT(*) FROM areas", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
fun towerCount(): Int =
readableDatabase.rawQuery("SELECT COUNT(*) FROM towers", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
companion object {
private const val NAME = "cellguard-survey.db"
private const val VERSION = 1
/** 2.5 miles in meters — the area clustering / travel radius. */
const val RADIUS_M = 4023.36f
}
}

View File

@@ -31,5 +31,8 @@ data class CellGuardState(
val detectEnabled: Boolean,
val ratLock: Boolean,
val suspect: List<String>,
val alerts: List<Alert>
val alerts: List<Alert>,
val traveling: Boolean = false,
val areas: Int = 0,
val loggedTowers: Int = 0
)

View File

@@ -52,18 +52,23 @@ class DetectorService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var pollJob: Job? = null
private val survey by lazy { com.seteclabs.cellguard.data.SurveyDb(this) }
private val locator by lazy { com.seteclabs.cellguard.data.LocationTracker(this) }
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
// Show the last-known state immediately so opening the app never starts blank.
runCatching { loadState() }.getOrNull()?.let { state.value = it }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Enter the foreground immediately with a baseline notification so the
// start-foreground deadline is met before any root I/O runs.
startForegroundState(state.value)
runCatching { locator.start() }
if (pollJob?.isActive != true) {
pollJob = scope.launch { pollLoop() }
@@ -74,6 +79,7 @@ class DetectorService : Service() {
override fun onDestroy() {
pollJob?.cancel()
scope.cancel()
runCatching { locator.stop() }
super.onDestroy()
}
@@ -84,6 +90,7 @@ class DetectorService : Service() {
while (scope.isActive) {
val next = runCatching { buildState() }.getOrElse { state.value }
state.value = next
runCatching { saveState(next) }
updateNotification(next)
handleAlert(next)
delay(POLL_MS)
@@ -96,6 +103,10 @@ class DetectorService : Service() {
val alertsJson = runCatching { RootBridge.ctlAlerts() }.getOrDefault("")
val status = RootBridge.parseStatus(statusJson)
// Previous published state — used to carry forward any field the current
// poll couldn't read (transient root/framework miss), so nothing blanks out.
val prev = state.value
val rootOk = statusJson.isNotBlank()
// Framework cell info (real CI/PCI/TAC/EARFCN + neighbors + signal when
// ACCESS_FINE_LOCATION is granted) is the primary tower source; also asks
@@ -104,24 +115,34 @@ class DetectorService : Service() {
val fwTowers = CellSource.read(this)
val frameTowers = FrameDecoder.decodeFrames(framesText)
val towers = if (fwTowers.isNotEmpty()) fwTowers else frameTowers
val serving = towers.firstOrNull { it.registered } ?: towers.firstOrNull()
val currentNeighbors = towers.filter { it !== serving && !it.registered }.distinct()
val freshServing = towers.firstOrNull { it.registered } ?: towers.firstOrNull()
val serving = freshServing ?: prev.serving
val currentNeighbors = towers.filter { it !== freshServing && !it.registered }.distinct()
val neighbors = mergeNeighbors(currentNeighbors, serving)
// Location-tagged survey log: record every tower actually seen this poll
// at the current GPS fix. The DB clusters them into 2.5-mile areas and
// upserts per identity, so driving around builds a chain of areas.
val loc = locator.last
runCatching { survey.log(loc, towers, System.currentTimeMillis()) }
val areaCount = runCatching { survey.areaCount() }.getOrDefault(prev.areas)
val loggedTowers = runCatching { survey.towerCount() }.getOrDefault(prev.loggedTowers)
val servingPlmn = status.plmn.takeIf { it.isNotBlank() && it != "?" }
?: FrameDecoder.firstPlmn(framesText)
val suspect = buildSuspects(towers, servingPlmn, statusJson)
val alerts = parseAlerts(alertsJson)
val alerts = if (alertsJson.isBlank()) prev.alerts else parseAlerts(alertsJson)
val plmn = servingPlmn
?: serving?.let { it.mcc + it.mnc }?.takeIf { it.isNotBlank() }
?: ""
?: freshServing?.let { it.mcc + it.mnc }?.takeIf { it.isNotBlank() }
?: prev.plmn
val operator = status.operator.takeIf { it.isNotBlank() && it != "?" }
?: serving?.op.orEmpty()
val rat = serving?.rat
?: freshServing?.op?.takeIf { it.isNotBlank() }
?: prev.operator
val rat = freshServing?.rat
?: status.rat.takeIf { it.isNotBlank() && it != "?" }
?: ""
?: prev.rat
return CellGuardState(
serving = serving,
@@ -129,17 +150,21 @@ class DetectorService : Service() {
plmn = plmn,
operator = operator,
rat = rat,
frames = parseTotal(framesText) ?: status.frames,
blockEnabled = status.block,
detectEnabled = status.detect,
ratLock = status.ratLock,
frames = parseTotal(framesText) ?: status.frames.takeIf { it > 0 } ?: prev.frames,
blockEnabled = if (rootOk) status.block else prev.blockEnabled,
detectEnabled = if (rootOk) status.detect else prev.detectEnabled,
ratLock = if (rootOk) status.ratLock else prev.ratLock,
suspect = suspect,
alerts = alerts,
traveling = runCatching { locator.traveling }.getOrDefault(false),
areas = areaCount,
loggedTowers = loggedTowers,
)
}
private data class SeenTower(val tower: Tower, val lastSeen: Long)
private val neighborCache = LinkedHashMap<String, SeenTower>()
private val stateStore by lazy { java.io.File(filesDir, "cgstate.json") }
private fun towerKey(t: Tower): String =
"${t.rat}|${t.mcc}${t.mnc}|${t.ci}|${t.pci}|${t.earfcn}"
@@ -147,9 +172,12 @@ class DetectorService : Service() {
/**
* Neighbor towers arrive per-poll from getAllCellInfo(), which frequently
* reports only the serving cell on any given scan. Publishing the raw per-poll
* list makes the table blink empty every 4s. Instead accumulate seen neighbors
* keyed by identity and age them out after [NEIGHBOR_TTL_MS], so a tower stays
* listed (with its most recent signal) until it is genuinely gone.
* list makes the table blink empty every 4s, and losing it entirely whenever
* the app process is killed and reopened. Instead the seen neighbors are kept
* in a cache keyed by identity: each poll upserts the towers it sees (refreshing
* their signal) and ages out only those unseen for longer than [NEIGHBOR_TTL_MS].
* The cache is persisted as part of the whole-state snapshot ([saveState]) and
* reloaded on service start ([loadState]), so the table survives app restarts.
*/
private fun mergeNeighbors(current: List<Tower>, serving: Tower?): List<Tower> {
val now = System.currentTimeMillis()
@@ -161,6 +189,91 @@ class DetectorService : Service() {
return neighborCache.values.sortedByDescending { it.lastSeen }.map { it.tower }
}
// ---- whole-state persistence -----------------------------------------
// The published CellGuardState is snapshotted to disk every poll and reloaded
// on start, so opening the app shows the last-known serving cell, neighbors,
// PLMN, frame count and alerts immediately instead of an empty screen.
private fun towerToJson(t: Tower, ls: Long?): org.json.JSONObject =
org.json.JSONObject().apply {
put("rat", t.rat); put("reg", t.registered)
put("mcc", t.mcc); put("mnc", t.mnc)
put("ci", t.ci); put("pci", t.pci); put("tac", t.tac); put("earfcn", t.earfcn)
t.rsrp?.let { put("rsrp", it) }; t.rsrq?.let { put("rsrq", it) }
put("op", t.op)
ls?.let { put("ls", it) }
}
private fun towerFromJson(o: org.json.JSONObject): Tower =
Tower(
rat = o.optString("rat"),
registered = o.optBoolean("reg"),
mcc = o.optString("mcc"),
mnc = o.optString("mnc"),
ci = o.optLong("ci", -1L),
pci = o.optInt("pci", -1),
tac = o.optInt("tac", -1),
earfcn = o.optInt("earfcn", -1),
rsrp = if (o.has("rsrp")) o.optInt("rsrp") else null,
rsrq = if (o.has("rsrq")) o.optInt("rsrq") else null,
op = o.optString("op"),
)
private fun saveState(s: CellGuardState) {
val o = org.json.JSONObject()
o.put("plmn", s.plmn); o.put("operator", s.operator); o.put("rat", s.rat)
o.put("frames", s.frames)
o.put("block", s.blockEnabled); o.put("detect", s.detectEnabled); o.put("ratLock", s.ratLock)
o.put("suspect", org.json.JSONArray(s.suspect))
s.serving?.let { o.put("serving", towerToJson(it, null)) }
val na = org.json.JSONArray()
neighborCache.values.forEach { na.put(towerToJson(it.tower, it.lastSeen)) }
o.put("neighbors", na)
val aa = org.json.JSONArray()
s.alerts.forEach { a ->
aa.put(org.json.JSONObject().apply { put("ts", a.ts); put("kind", a.kind); put("detail", a.detail) })
}
o.put("alerts", aa)
runCatching { stateStore.writeText(o.toString()) }
}
/** Reload the last snapshot (repopulating [neighborCache], dropping past-TTL neighbors). */
private fun loadState(): CellGuardState? {
val txt = runCatching { stateStore.readText() }.getOrNull() ?: return null
val o = runCatching { org.json.JSONObject(txt) }.getOrNull() ?: return null
val now = System.currentTimeMillis()
neighborCache.clear()
o.optJSONArray("neighbors")?.let { na ->
for (i in 0 until na.length()) {
val no = na.optJSONObject(i) ?: continue
val ls = no.optLong("ls", 0L)
if (now - ls > NEIGHBOR_TTL_MS) continue
val t = towerFromJson(no)
neighborCache[towerKey(t)] = SeenTower(t, ls)
}
}
val suspect = o.optJSONArray("suspect")?.let { a -> (0 until a.length()).map { a.optString(it) } }
?: emptyList()
val alerts = o.optJSONArray("alerts")?.let { a ->
(0 until a.length()).mapNotNull { i ->
a.optJSONObject(i)?.let { Alert(it.optLong("ts"), it.optString("kind"), it.optString("detail")) }
}
} ?: emptyList()
return CellGuardState(
serving = o.optJSONObject("serving")?.let { towerFromJson(it) },
neighbors = neighborCache.values.sortedByDescending { it.lastSeen }.map { it.tower },
plmn = o.optString("plmn"),
operator = o.optString("operator"),
rat = o.optString("rat"),
frames = o.optLong("frames", 0L),
blockEnabled = o.optBoolean("block"),
detectEnabled = o.optBoolean("detect"),
ratLock = o.optBoolean("ratLock"),
suspect = suspect,
alerts = alerts,
)
}
/** True live capture count from the `# total=N` header line of /proc/cgcap/frames. */
private fun parseTotal(framesText: String): Long? {
val line = framesText.lineSequence().firstOrNull { it.startsWith("# total=") } ?: return null
@@ -294,17 +407,21 @@ class DetectorService : Service() {
}
private fun startForegroundState(s: CellGuardState) {
ServiceCompat.startForeground(
this,
NOTIF_ID,
buildNotification(s),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var t = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
// Adding the location FGS type without the runtime permission granted
// makes Android 14+ reject startForeground, so only claim it when held.
if (hasLocationPermission()) t = t or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
t
} else {
0
},
)
}
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(s), type)
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun updateNotification(s: CellGuardState) {
if (!hasNotificationPermission()) return

View File

@@ -30,10 +30,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.alpha
@@ -88,12 +93,29 @@ private fun towerLabel(t: Tower): String = t.op.ifBlank {
@Composable
fun MainScreen(vm: CellGuardViewModel) {
val state by vm.state.collectAsState()
var tab by remember { mutableStateOf(0) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { inner ->
Column(Modifier.fillMaxWidth().padding(inner)) {
TabRow(
selectedTabIndex = tab,
containerColor = MaterialTheme.colorScheme.background,
) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("MONITOR", fontFamily = FontFamily.Monospace, fontSize = 12.sp) })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("SURVEY LOG", fontFamily = FontFamily.Monospace, fontSize = 12.sp) })
}
when (tab) {
0 -> MonitorTab(state, vm)
else -> SurveyScreen()
}
}
}
}
@Composable
private fun MonitorTab(state: CellGuardState, vm: CellGuardViewModel) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(inner),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -127,7 +149,6 @@ fun MainScreen(vm: CellGuardViewModel) {
item { Spacer(Modifier.height(8.dp)) }
}
}
}
// ---- header -----------------------------------------------------------------
@@ -161,10 +182,15 @@ private fun HeaderBar(state: CellGuardState) {
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
val sub = buildString {
append(if (state.traveling) "traveling · " else "baseband monitor · ")
append("${state.areas} area(s) · ${state.loggedTowers} logged")
}
Text(
"baseband monitor",
color = MaterialTheme.colorScheme.onSurfaceVariant,
sub,
color = if (state.traveling) CgCyan else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
Row(

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 ""