diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index baffa0c..584af73 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
@@ -30,7 +31,7 @@
+ android:foregroundServiceType="specialUse|location">
diff --git a/app/src/main/java/com/seteclabs/cellguard/data/LocationTracker.kt b/app/src/main/java/com/seteclabs/cellguard/data/LocationTracker.kt
new file mode 100644
index 0000000..cef8056
--- /dev/null
+++ b/app/src/main/java/com/seteclabs/cellguard/data/LocationTracker.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/com/seteclabs/cellguard/data/SurveyDb.kt b/app/src/main/java/com/seteclabs/cellguard/data/SurveyDb.kt
new file mode 100644
index 0000000..d08e71b
--- /dev/null
+++ b/app/src/main/java/com/seteclabs/cellguard/data/SurveyDb.kt
@@ -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, 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(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(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 {
+ val out = ArrayList()
+ 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 {
+ val out = ArrayList()
+ 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
+ }
+}
diff --git a/app/src/main/java/com/seteclabs/cellguard/model/Models.kt b/app/src/main/java/com/seteclabs/cellguard/model/Models.kt
index 9872e32..9345aac 100644
--- a/app/src/main/java/com/seteclabs/cellguard/model/Models.kt
+++ b/app/src/main/java/com/seteclabs/cellguard/model/Models.kt
@@ -31,5 +31,8 @@ data class CellGuardState(
val detectEnabled: Boolean,
val ratLock: Boolean,
val suspect: List,
- val alerts: List
+ val alerts: List,
+ val traveling: Boolean = false,
+ val areas: Int = 0,
+ val loggedTowers: Int = 0
)
diff --git a/app/src/main/java/com/seteclabs/cellguard/service/DetectorService.kt b/app/src/main/java/com/seteclabs/cellguard/service/DetectorService.kt
index 19e83a6..9508dd5 100644
--- a/app/src/main/java/com/seteclabs/cellguard/service/DetectorService.kt
+++ b/app/src/main/java/com/seteclabs/cellguard/service/DetectorService.kt
@@ -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()
+ 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, serving: Tower?): List {
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,18 +407,22 @@ 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
- } else {
- 0
- },
- )
+ 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
NotificationManagerCompat.from(this).notify(NOTIF_ID, buildNotification(s))
diff --git a/app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt b/app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt
index e91a742..f8b6a06 100644
--- a/app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt
+++ b/app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt
@@ -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,47 +93,63 @@ 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 ->
- LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .padding(inner),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- item { HeaderBar(state) }
- item {
- ToggleRow(
- title = "Block 2G/3G",
- subtitle = "Force LTE/5G, refuse legacy downgrade",
- checked = state.blockEnabled,
- onCheckedChange = vm::setBlock,
- )
+ 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) })
}
- item {
- ToggleRow(
- title = "IMSI-catcher detection",
- subtitle = "Scan baseband frames for CSS signatures",
- checked = state.detectEnabled,
- onCheckedChange = vm::setDetect,
- )
+ when (tab) {
+ 0 -> MonitorTab(state, vm)
+ else -> SurveyScreen()
}
- item { ServingCard(state) }
- item { NeighborTable(state.neighbors) }
- item {
- SectionLabel("ALERTS", count = state.alerts.size)
- }
- if (state.alerts.isEmpty()) {
- item { EmptyHint("No alerts. Baseband looks clean.") }
- } else {
- items(state.alerts) { AlertRow(it) }
- }
- item { Spacer(Modifier.height(8.dp)) }
}
}
}
+@Composable
+private fun MonitorTab(state: CellGuardState, vm: CellGuardViewModel) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ item { HeaderBar(state) }
+ item {
+ ToggleRow(
+ title = "Block 2G/3G",
+ subtitle = "Force LTE/5G, refuse legacy downgrade",
+ checked = state.blockEnabled,
+ onCheckedChange = vm::setBlock,
+ )
+ }
+ item {
+ ToggleRow(
+ title = "IMSI-catcher detection",
+ subtitle = "Scan baseband frames for CSS signatures",
+ checked = state.detectEnabled,
+ onCheckedChange = vm::setDetect,
+ )
+ }
+ item { ServingCard(state) }
+ item { NeighborTable(state.neighbors) }
+ item {
+ SectionLabel("ALERTS", count = state.alerts.size)
+ }
+ if (state.alerts.isEmpty()) {
+ item { EmptyHint("No alerts. Baseband looks clean.") }
+ } else {
+ items(state.alerts) { AlertRow(it) }
+ }
+ item { Spacer(Modifier.height(8.dp)) }
+ }
+}
+
// ---- header -----------------------------------------------------------------
@Composable
@@ -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(
diff --git a/app/src/main/java/com/seteclabs/cellguard/ui/SurveyScreen.kt b/app/src/main/java/com/seteclabs/cellguard/ui/SurveyScreen.kt
new file mode 100644
index 0000000..8369863
--- /dev/null
+++ b/app/src/main/java/com/seteclabs/cellguard/ui/SurveyScreen.kt
@@ -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>(emptyList()) }
+ var expanded by remember { mutableStateOf(-1L) }
+ var towers by remember { mutableStateOf>(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, 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 "—"