From 338e782cb594ed521d2ee47bf7e77143d076b1e2 Mon Sep 17 00:00:00 2001 From: sssnake Date: Sun, 12 Jul 2026 11:34:51 -0700 Subject: [PATCH] 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. --- app/src/main/AndroidManifest.xml | 3 +- .../cellguard/data/LocationTracker.kt | 67 +++++ .../com/seteclabs/cellguard/data/SurveyDb.kt | 233 ++++++++++++++++++ .../com/seteclabs/cellguard/model/Models.kt | 5 +- .../cellguard/service/DetectorService.kt | 167 +++++++++++-- .../com/seteclabs/cellguard/ui/MainScreen.kt | 96 +++++--- .../seteclabs/cellguard/ui/SurveyScreen.kt | 226 +++++++++++++++++ 7 files changed, 735 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/seteclabs/cellguard/data/LocationTracker.kt create mode 100644 app/src/main/java/com/seteclabs/cellguard/data/SurveyDb.kt create mode 100644 app/src/main/java/com/seteclabs/cellguard/ui/SurveyScreen.kt 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 "—"