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:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
233
app/src/main/java/com/seteclabs/cellguard/data/SurveyDb.kt
Normal file
233
app/src/main/java/com/seteclabs/cellguard/data/SurveyDb.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
@@ -126,7 +148,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(
|
||||
|
||||
226
app/src/main/java/com/seteclabs/cellguard/ui/SurveyScreen.kt
Normal file
226
app/src/main/java/com/seteclabs/cellguard/ui/SurveyScreen.kt
Normal 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 "—"
|
||||
Reference in New Issue
Block a user