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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<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_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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".service.DetectorService"
|
android:name=".service.DetectorService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse">
|
android:foregroundServiceType="specialUse|location">
|
||||||
<property
|
<property
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
android:value="Continuous baseband cell-capture monitoring and IMSI-catcher detection" />
|
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 detectEnabled: Boolean,
|
||||||
val ratLock: Boolean,
|
val ratLock: Boolean,
|
||||||
val suspect: List<String>,
|
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 val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private var pollJob: Job? = null
|
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 onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createChannel()
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
// Enter the foreground immediately with a baseline notification so the
|
// Enter the foreground immediately with a baseline notification so the
|
||||||
// start-foreground deadline is met before any root I/O runs.
|
// start-foreground deadline is met before any root I/O runs.
|
||||||
startForegroundState(state.value)
|
startForegroundState(state.value)
|
||||||
|
runCatching { locator.start() }
|
||||||
|
|
||||||
if (pollJob?.isActive != true) {
|
if (pollJob?.isActive != true) {
|
||||||
pollJob = scope.launch { pollLoop() }
|
pollJob = scope.launch { pollLoop() }
|
||||||
@@ -74,6 +79,7 @@ class DetectorService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
runCatching { locator.stop() }
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +90,7 @@ class DetectorService : Service() {
|
|||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
val next = runCatching { buildState() }.getOrElse { state.value }
|
val next = runCatching { buildState() }.getOrElse { state.value }
|
||||||
state.value = next
|
state.value = next
|
||||||
|
runCatching { saveState(next) }
|
||||||
updateNotification(next)
|
updateNotification(next)
|
||||||
handleAlert(next)
|
handleAlert(next)
|
||||||
delay(POLL_MS)
|
delay(POLL_MS)
|
||||||
@@ -96,6 +103,10 @@ class DetectorService : Service() {
|
|||||||
val alertsJson = runCatching { RootBridge.ctlAlerts() }.getOrDefault("")
|
val alertsJson = runCatching { RootBridge.ctlAlerts() }.getOrDefault("")
|
||||||
|
|
||||||
val status = RootBridge.parseStatus(statusJson)
|
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
|
// Framework cell info (real CI/PCI/TAC/EARFCN + neighbors + signal when
|
||||||
// ACCESS_FINE_LOCATION is granted) is the primary tower source; also asks
|
// 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 fwTowers = CellSource.read(this)
|
||||||
val frameTowers = FrameDecoder.decodeFrames(framesText)
|
val frameTowers = FrameDecoder.decodeFrames(framesText)
|
||||||
val towers = if (fwTowers.isNotEmpty()) fwTowers else frameTowers
|
val towers = if (fwTowers.isNotEmpty()) fwTowers else frameTowers
|
||||||
val serving = towers.firstOrNull { it.registered } ?: towers.firstOrNull()
|
val freshServing = towers.firstOrNull { it.registered } ?: towers.firstOrNull()
|
||||||
val currentNeighbors = towers.filter { it !== serving && !it.registered }.distinct()
|
val serving = freshServing ?: prev.serving
|
||||||
|
val currentNeighbors = towers.filter { it !== freshServing && !it.registered }.distinct()
|
||||||
val neighbors = mergeNeighbors(currentNeighbors, serving)
|
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 != "?" }
|
val servingPlmn = status.plmn.takeIf { it.isNotBlank() && it != "?" }
|
||||||
?: FrameDecoder.firstPlmn(framesText)
|
?: FrameDecoder.firstPlmn(framesText)
|
||||||
|
|
||||||
val suspect = buildSuspects(towers, servingPlmn, statusJson)
|
val suspect = buildSuspects(towers, servingPlmn, statusJson)
|
||||||
val alerts = parseAlerts(alertsJson)
|
val alerts = if (alertsJson.isBlank()) prev.alerts else parseAlerts(alertsJson)
|
||||||
|
|
||||||
val plmn = servingPlmn
|
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 != "?" }
|
val operator = status.operator.takeIf { it.isNotBlank() && it != "?" }
|
||||||
?: serving?.op.orEmpty()
|
?: freshServing?.op?.takeIf { it.isNotBlank() }
|
||||||
val rat = serving?.rat
|
?: prev.operator
|
||||||
|
val rat = freshServing?.rat
|
||||||
?: status.rat.takeIf { it.isNotBlank() && it != "?" }
|
?: status.rat.takeIf { it.isNotBlank() && it != "?" }
|
||||||
?: ""
|
?: prev.rat
|
||||||
|
|
||||||
return CellGuardState(
|
return CellGuardState(
|
||||||
serving = serving,
|
serving = serving,
|
||||||
@@ -129,17 +150,21 @@ class DetectorService : Service() {
|
|||||||
plmn = plmn,
|
plmn = plmn,
|
||||||
operator = operator,
|
operator = operator,
|
||||||
rat = rat,
|
rat = rat,
|
||||||
frames = parseTotal(framesText) ?: status.frames,
|
frames = parseTotal(framesText) ?: status.frames.takeIf { it > 0 } ?: prev.frames,
|
||||||
blockEnabled = status.block,
|
blockEnabled = if (rootOk) status.block else prev.blockEnabled,
|
||||||
detectEnabled = status.detect,
|
detectEnabled = if (rootOk) status.detect else prev.detectEnabled,
|
||||||
ratLock = status.ratLock,
|
ratLock = if (rootOk) status.ratLock else prev.ratLock,
|
||||||
suspect = suspect,
|
suspect = suspect,
|
||||||
alerts = alerts,
|
alerts = alerts,
|
||||||
|
traveling = runCatching { locator.traveling }.getOrDefault(false),
|
||||||
|
areas = areaCount,
|
||||||
|
loggedTowers = loggedTowers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class SeenTower(val tower: Tower, val lastSeen: Long)
|
private data class SeenTower(val tower: Tower, val lastSeen: Long)
|
||||||
private val neighborCache = LinkedHashMap<String, SeenTower>()
|
private val neighborCache = LinkedHashMap<String, SeenTower>()
|
||||||
|
private val stateStore by lazy { java.io.File(filesDir, "cgstate.json") }
|
||||||
|
|
||||||
private fun towerKey(t: Tower): String =
|
private fun towerKey(t: Tower): String =
|
||||||
"${t.rat}|${t.mcc}${t.mnc}|${t.ci}|${t.pci}|${t.earfcn}"
|
"${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
|
* Neighbor towers arrive per-poll from getAllCellInfo(), which frequently
|
||||||
* reports only the serving cell on any given scan. Publishing the raw per-poll
|
* 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
|
* list makes the table blink empty every 4s, and losing it entirely whenever
|
||||||
* keyed by identity and age them out after [NEIGHBOR_TTL_MS], so a tower stays
|
* the app process is killed and reopened. Instead the seen neighbors are kept
|
||||||
* listed (with its most recent signal) until it is genuinely gone.
|
* 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> {
|
private fun mergeNeighbors(current: List<Tower>, serving: Tower?): List<Tower> {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -161,6 +189,91 @@ class DetectorService : Service() {
|
|||||||
return neighborCache.values.sortedByDescending { it.lastSeen }.map { it.tower }
|
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. */
|
/** True live capture count from the `# total=N` header line of /proc/cgcap/frames. */
|
||||||
private fun parseTotal(framesText: String): Long? {
|
private fun parseTotal(framesText: String): Long? {
|
||||||
val line = framesText.lineSequence().firstOrNull { it.startsWith("# total=") } ?: return null
|
val line = framesText.lineSequence().firstOrNull { it.startsWith("# total=") } ?: return null
|
||||||
@@ -294,18 +407,22 @@ class DetectorService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundState(s: CellGuardState) {
|
private fun startForegroundState(s: CellGuardState) {
|
||||||
ServiceCompat.startForeground(
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
this,
|
var t = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
NOTIF_ID,
|
// Adding the location FGS type without the runtime permission granted
|
||||||
buildNotification(s),
|
// makes Android 14+ reject startForeground, so only claim it when held.
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (hasLocationPermission()) t = t or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
t
|
||||||
} else {
|
} else {
|
||||||
0
|
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) {
|
private fun updateNotification(s: CellGuardState) {
|
||||||
if (!hasNotificationPermission()) return
|
if (!hasNotificationPermission()) return
|
||||||
NotificationManagerCompat.from(this).notify(NOTIF_ID, buildNotification(s))
|
NotificationManagerCompat.from(this).notify(NOTIF_ID, buildNotification(s))
|
||||||
|
|||||||
@@ -30,10 +30,15 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
@@ -88,47 +93,63 @@ private fun towerLabel(t: Tower): String = t.op.ifBlank {
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(vm: CellGuardViewModel) {
|
fun MainScreen(vm: CellGuardViewModel) {
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
|
var tab by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { inner ->
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { inner ->
|
||||||
LazyColumn(
|
Column(Modifier.fillMaxWidth().padding(inner)) {
|
||||||
modifier = Modifier
|
TabRow(
|
||||||
.fillMaxWidth()
|
selectedTabIndex = tab,
|
||||||
.padding(inner),
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentPadding = PaddingValues(16.dp),
|
) {
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
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 { HeaderBar(state) }
|
|
||||||
item {
|
|
||||||
ToggleRow(
|
|
||||||
title = "Block 2G/3G",
|
|
||||||
subtitle = "Force LTE/5G, refuse legacy downgrade",
|
|
||||||
checked = state.blockEnabled,
|
|
||||||
onCheckedChange = vm::setBlock,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
item {
|
when (tab) {
|
||||||
ToggleRow(
|
0 -> MonitorTab(state, vm)
|
||||||
title = "IMSI-catcher detection",
|
else -> SurveyScreen()
|
||||||
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)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 -----------------------------------------------------------------
|
// ---- header -----------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -161,10 +182,15 @@ private fun HeaderBar(state: CellGuardState) {
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
)
|
)
|
||||||
|
val sub = buildString {
|
||||||
|
append(if (state.traveling) "traveling · " else "baseband monitor · ")
|
||||||
|
append("${state.areas} area(s) · ${state.loggedTowers} logged")
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"baseband monitor",
|
sub,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (state.traveling) CgCyan else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
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