CellGuard app: on-device IMSI-catcher detection (Kotlin/Compose)

- foreground DetectorService: cgcap capture + on-device decode
- framework CellInfo source: real serving+neighbor towers, signal, carrier name
- FrameDecoder: 0x070F cell-info SIT decode
- block-2G/3G + detection toggles via cgctl; libsu root bridge
- silent status notification + edge-triggered alerts
This commit is contained in:
sssnake
2026-07-12 09:21:54 -07:00
commit 38719683c7
25 changed files with 2269 additions and 0 deletions

82
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,82 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.seteclabs.cellguard"
compileSdk = 35
buildToolsVersion = "35.0.0"
defaultConfig {
applicationId = "com.seteclabs.cellguard"
minSdk = 33
targetSdk = 35
versionCode = 1
versionName = "1.0"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
buildFeatures {
compose = true
}
composeOptions {
// Kotlin 1.9.23 -> Compose compiler 1.5.11
kotlinCompilerExtensionVersion = "1.5.11"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-service:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Root access via libsu (JitPack)
implementation("com.github.topjohnwu.libsu:core:5.2.2")
implementation("com.github.topjohnwu.libsu:service:5.2.2")
debugImplementation("androidx.compose.ui:ui-tooling")
}

13
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,13 @@
# CellGuard ProGuard rules
# Keep the shared data model (used by on-device decoder + UI)
-keep class com.seteclabs.cellguard.model.** { *; }
# libsu uses AIDL + reflection for its root service; keep it intact.
-keep class com.topjohnwu.superuser.** { *; }
-keep interface com.topjohnwu.superuser.** { *; }
-dontwarn com.topjohnwu.superuser.**
# Kotlin metadata / coroutines
-keepclassmembers class kotlin.Metadata { *; }
-dontwarn kotlinx.**

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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.POST_NOTIFICATIONS" />
<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_COARSE_LOCATION" />
<application
android:name=".App"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.CellGuard">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.CellGuard">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.DetectorService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Continuous baseband cell-capture monitoring and IMSI-catcher detection" />
</service>
</application>
</manifest>

View File

@@ -0,0 +1,32 @@
package com.seteclabs.cellguard
import android.app.Application
import com.topjohnwu.superuser.Shell
/**
* Application entry point. Configures libsu's default shell BEFORE any
* Shell.cmd() call so the main shell is created as root. Without this explicit
* builder the default main shell can fail to acquire root on KernelSU (the
* commands then run in the app's own untrusted_app domain and can't touch
* /proc/cgcap or /data/adb). FLAG_MOUNT_MASTER runs su in the global mount
* namespace so the module paths under /data/adb are visible.
*/
class App : Application() {
companion object {
init {
Shell.enableVerboseLogging = true
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_REDIRECT_STDERR)
.setTimeout(20)
)
}
}
override fun onCreate() {
super.onCreate()
// Warm the root shell early so the first UI/service call is fast and
// any grant prompt happens up front.
Shell.getShell { /* main shell created (root if granted) */ }
}
}

View File

@@ -0,0 +1,36 @@
package com.seteclabs.cellguard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seteclabs.cellguard.model.CellGuardState
import com.seteclabs.cellguard.root.RootBridge
import com.seteclabs.cellguard.service.DetectorService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Bridges the Compose UI to the on-device detector.
*
* The live [CellGuardState] is produced by [DetectorService], which polls the
* KSU module (cgctl.sh status/alerts) and decodes /proc/cgcap/frames on-device.
* The ViewModel simply re-exposes that flow and forwards the two control toggles
* to the module via [RootBridge]. Toggle writes run on an IO dispatcher; the new
* state is observed on the next detector poll, so we don't optimistically mutate.
*/
class CellGuardViewModel : ViewModel() {
/** Live detector state, surfaced by the foreground service. */
val state: StateFlow<CellGuardState> = DetectorService.state
/** Block 2G/3G enforcement (ratlock). The detector picks up the new
* state on its next poll, which refreshes [state]. */
fun setBlock(on: Boolean) {
viewModelScope.launch(Dispatchers.IO) { RootBridge.setBlock(on) }
}
/** IMSI-catcher detection daemon. */
fun setDetect(on: Boolean) {
viewModelScope.launch(Dispatchers.IO) { RootBridge.setDetect(on) }
}
}

View File

@@ -0,0 +1,76 @@
package com.seteclabs.cellguard
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.seteclabs.cellguard.service.DetectorService
import com.seteclabs.cellguard.ui.CellGuardTheme
import com.seteclabs.cellguard.ui.MainScreen
class MainActivity : ComponentActivity() {
private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
// The detector runs regardless; the permission only governs whether the
// ongoing notification (and alert notifications) are shown to the user.
}
// ACCESS_FINE_LOCATION is required for the framework to return REAL cell
// identity (CI/PCI/TAC/EARFCN) + neighbor cells; without it they are redacted.
private val locationPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
requestLocationPermission()
startDetector()
setContent {
CellGuardTheme {
val vm: CellGuardViewModel = viewModel()
MainScreen(vm)
}
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun requestLocationPermission() {
val granted = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
locationPermLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
private fun startDetector() {
val intent = Intent(this, DetectorService::class.java)
ContextCompat.startForegroundService(this, intent)
}
}

View File

@@ -0,0 +1,24 @@
package com.seteclabs.cellguard.cells
/** Best-effort PLMN (MCC+MNC) -> carrier name for US networks. */
object Carriers {
private val PLMN = mapOf(
// Verizon (Xfinity Mobile, Visible, Spectrum all ride Verizon)
"311480" to "Verizon", "310590" to "Verizon", "311270" to "Verizon",
"311280" to "Verizon", "310890" to "Verizon",
// T-Mobile (+ Metro, Mint, ex-Sprint)
"310260" to "T-Mobile", "310160" to "T-Mobile", "310200" to "T-Mobile",
"310210" to "T-Mobile", "310230" to "T-Mobile", "311882" to "T-Mobile",
"312530" to "T-Mobile", "310120" to "T-Mobile (Sprint)",
// AT&T (+ Cricket, FirstNet)
"310410" to "AT&T", "310150" to "AT&T", "311180" to "AT&T",
"310170" to "AT&T", "313100" to "FirstNet", "312770" to "FirstNet",
// US Cellular / Dish
"311580" to "US Cellular", "313340" to "Dish",
)
fun name(mcc: String, mnc: String): String? {
if (mcc.isBlank() || mnc.isBlank()) return null
return PLMN["$mcc$mnc"]
}
}

View File

@@ -0,0 +1,110 @@
package com.seteclabs.cellguard.cells
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.CellIdentityNr
import android.telephony.CellInfo
import android.telephony.CellInfoGsm
import android.telephony.CellInfoLte
import android.telephony.CellInfoNr
import android.telephony.CellInfoWcdma
import android.telephony.CellSignalStrengthNr
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import com.seteclabs.cellguard.model.Tower
/**
* Serving + neighbor towers from the Android telephony framework.
*
* Android redacts precise cell identity (CI/PCI/TAC/EARFCN) from any caller
* without ACCESS_FINE_LOCATION — which is why dumpsys/cgdetect only ever see
* `[****]`. With location granted, TelephonyManager.getAllCellInfo() returns the
* REAL identifiers plus all neighbor cells and signal, and requestCellInfoUpdate()
* additionally makes the modem emit a 0x070F frame (feeding the raw FrameDecoder
* path as a bonus). This is the standard field-test data source.
*/
object CellSource {
private fun tm(ctx: Context): TelephonyManager =
ctx.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
fun hasLocation(ctx: Context): Boolean =
ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
/** Ask the modem for a fresh cell-info scan (also triggers a 0x070F frame). */
fun requestUpdate(ctx: Context) {
if (!hasLocation(ctx)) return
runCatching {
tm(ctx).requestCellInfoUpdate(
ctx.mainExecutor,
object : TelephonyManager.CellInfoCallback() {
override fun onCellInfo(cellInfo: MutableList<CellInfo>) {}
override fun onError(errorCode: Int, detail: Throwable?) {}
},
)
}
}
/** Real serving + neighbor towers (empty if location not granted). */
fun read(ctx: Context): List<Tower> {
if (!hasLocation(ctx)) {
android.util.Log.w("CGCellSource", "no ACCESS_FINE_LOCATION")
return emptyList()
}
val list = runCatching { tm(ctx).allCellInfo }
.onFailure { android.util.Log.w("CGCellSource", "allCellInfo threw: $it") }
.getOrNull()
if (list == null) { android.util.Log.w("CGCellSource", "allCellInfo=null"); return emptyList() }
val netName = runCatching { tm(ctx).networkOperatorName }.getOrNull().orEmpty()
val towers = list.mapNotNull { map(it) }.map { t ->
if (t.op.isNotBlank()) t
else t.copy(
op = (if (t.registered) netName else "")
.ifBlank { Carriers.name(t.mcc, t.mnc) ?: "" },
)
}
android.util.Log.i("CGCellSource", "allCellInfo n=${list.size} towers=" +
towers.joinToString { "${it.rat}/${it.mcc}${it.mnc} ci=${it.ci} pci=${it.pci} tac=${it.tac} earfcn=${it.earfcn} reg=${it.registered}" })
return towers
}
private fun str(v: CharSequence?): String = v?.toString().orEmpty()
private fun ok(v: Int): Int? = if (v == Int.MAX_VALUE || v == Int.MIN_VALUE) null else v
private fun map(ci: CellInfo): Tower? {
val reg = ci.isRegistered
return when (ci) {
is CellInfoLte -> {
val id = ci.cellIdentity
val ss = ci.cellSignalStrength
Tower("LTE", reg, str(id.mccString), str(id.mncString),
(ok(id.ci)?.toLong()) ?: -1L, ok(id.pci) ?: -1, ok(id.tac) ?: -1,
ok(id.earfcn) ?: -1, ok(ss.rsrp), ok(ss.rsrq), str(id.operatorAlphaLong))
}
is CellInfoNr -> {
val id = ci.cellIdentity as CellIdentityNr
val ss = ci.cellSignalStrength as CellSignalStrengthNr
Tower("NR", reg, str(id.mccString), str(id.mncString),
if (id.nci == Long.MAX_VALUE) -1L else id.nci, ok(id.pci) ?: -1, ok(id.tac) ?: -1,
ok(id.nrarfcn) ?: -1, ok(ss.ssRsrp), ok(ss.ssRsrq), str(id.operatorAlphaLong))
}
is CellInfoWcdma -> {
val id = ci.cellIdentity
val ss = ci.cellSignalStrength
Tower("WCDMA", reg, str(id.mccString), str(id.mncString),
(ok(id.cid)?.toLong()) ?: -1L, ok(id.psc) ?: -1, ok(id.lac) ?: -1,
ok(id.uarfcn) ?: -1, ok(ss.dbm), null, str(id.operatorAlphaLong))
}
is CellInfoGsm -> {
val id = ci.cellIdentity
val ss = ci.cellSignalStrength
Tower("GSM", reg, str(id.mccString), str(id.mncString),
(ok(id.cid)?.toLong()) ?: -1L, -1, ok(id.lac) ?: -1,
ok(id.arfcn) ?: -1, ok(ss.dbm), null, str(id.operatorAlphaLong))
}
else -> null
}
}
}

View File

@@ -0,0 +1,231 @@
package com.seteclabs.cellguard.decode
import com.seteclabs.cellguard.model.Tower
/**
* Kotlin port of tools/cg_decode.py — decodes Shannon CP->AP frames captured by
* cgcap.ko and flags IMSI-catcher / cell-site-simulator (CSS) signatures.
*
* Frame layering (RE'd + validated against live frames):
* 0x00 u16 magic = 0xABCD 0x08 u8 channel (0xf6 = umts_ipc1 SIT)
* 0x02 u16 counter 0x0a u16 0x0000
* 0x06 u16 length --- SIT message starts at frame byte 0x0c ---
* SIT: 0x0c type | 0x0e id(hi=grp,lo=cmd) | 0x10 len | 0x12 rsvd | params @0x14
*
* Cell-info list (SIT id 0x070F, NET) params (relative to params @0x14):
* +0x00 u16 sit_len +0x04 u32 count +0x08 record[0]
* record = u8 rat | u8 registered | u8 conn_status | struct (stride incl. header)
* rat: 0=GSM 1=CDMA 2=LTE 3=WCDMA 4=TDSCDMA 5=NR
* stride LTE=0x13C NR=0x18C GSM=0xA4
*
* Pure Kotlin, no Android dependencies.
*/
object FrameDecoder {
const val MAGIC = 0xABCD
const val SIT_CELL_INFO_LIST = 0x070F
private val RAT = mapOf(
0 to "GSM", 1 to "CDMA", 2 to "LTE", 3 to "WCDMA", 4 to "TDSCDMA", 5 to "NR",
)
// stride includes the 3-byte record header (rat|registered|conn_status)
private val STRIDE = mapOf(
0 to 0xA4, 1 to 0x2B, 2 to 0x13C, 3 to 0x114, 4 to 0x110, 5 to 0x18C,
)
private val WEAK_RAT = setOf("GSM", "WCDMA", "TDSCDMA", "CDMA") // 2G/3G = downgrade risk
private val RX_LINE = Regex("""^(\d+)\s+RX\s+ch=0x([0-9a-f]+).*?\s([0-9a-f]{8,})""")
private val PLMN_RUN = Regex("""(\d{6})""")
// ---- little-endian readers (bounds-checked, matching the Python helpers) ----
private fun u16(b: ByteArray, o: Int): Int =
if (o + 1 < b.size) (b[o].toInt() and 0xFF) or ((b[o + 1].toInt() and 0xFF) shl 8) else 0
private fun u32(b: ByteArray, o: Int): Long {
if (o + 4 > b.size) return 0L
var v = 0L
for (i in 0 until 4) v = v or ((b[o + i].toLong() and 0xFF) shl (8 * i))
return v
}
private fun u64(b: ByteArray, o: Int): Long {
if (o + 8 > b.size) return 0L
var v = 0L
for (i in 0 until 8) v = v or ((b[o + i].toLong() and 0xFF) shl (8 * i))
return v
}
private fun s32(b: ByteArray, o: Int): Int {
val v = u32(b, o)
return (if (v and 0x80000000L != 0L) v - (1L shl 32) else v).toInt()
}
/** ASCII string up to first NUL, non-ASCII -> U+FFFD, '#' stripped (matches astr). */
private fun astr(b: ByteArray, o: Int, n: Int): String {
val sb = StringBuilder()
val end = minOf(o + n, b.size)
var i = o
while (i < end) {
val c = b[i].toInt() and 0xFF
i++
if (c == 0) break
if (c == '#'.code) continue
sb.append(if (c < 128) c.toChar() else '<27>')
}
return sb.toString()
}
// ---- per-RAT struct parsers (offsets relative to struct = record[+3 .. +stride]) ----
private fun parseLte(s: ByteArray, registered: Boolean): Tower = Tower(
rat = "LTE",
registered = registered,
mcc = astr(s, 0, 3),
mnc = astr(s, 3, 3),
ci = u32(s, 6),
pci = u32(s, 0xa).toInt(),
tac = u32(s, 0xe).toInt(),
earfcn = u32(s, 0x12).toInt(),
rsrp = if (s.size > 0x124) -s32(s, 0x121) else null,
rsrq = if (s.size > 0x128) s32(s, 0x125) else null,
op = astr(s, 0x1a, 32),
)
private fun parseNr(s: ByteArray, registered: Boolean): Tower = Tower(
rat = "NR",
registered = registered,
mcc = astr(s, 0, 3),
mnc = astr(s, 3, 3),
ci = u64(s, 6), // nci
pci = u32(s, 0xe).toInt(),
tac = u32(s, 0x12).toInt(),
earfcn = u32(s, 0x16).toInt(), // arfcn
rsrp = if (s.size > 0x11c) -s32(s, 0x119) else null,
rsrq = null, // NR carries SINR (@0x121), not RSRQ
op = astr(s, 0x1a, 32),
)
private fun parseGsm(s: ByteArray, registered: Boolean): Tower = Tower(
rat = "GSM",
registered = registered,
mcc = astr(s, 0, 3),
mnc = astr(s, 3, 3),
ci = u32(s, 0xa), // cid
pci = 0, // GSM has no PCI
tac = u32(s, 6).toInt(), // lac
earfcn = u32(s, 0xe).toInt(), // arfcn
rsrp = if (s.size > 0x98) s32(s, 0x95) else null, // rssi
rsrq = null,
op = astr(s, 0x13, 32),
)
private fun parseStruct(rat: Int, registered: Boolean, s: ByteArray): Tower = when (rat) {
0 -> parseGsm(s, registered)
2 -> parseLte(s, registered)
5 -> parseNr(s, registered)
else -> Tower(
rat = RAT[rat] ?: rat.toString(),
registered = registered,
mcc = "", mnc = "", ci = 0L, pci = 0, tac = 0, earfcn = 0,
rsrp = null, rsrq = null, op = "",
)
}
/** Decode a SIT 0x070F cell-info list from its params (frame[0x14:]). */
fun decodeCellInfo(params: ByteArray): List<Tower> {
val count = u32(params, 4)
val cells = ArrayList<Tower>()
var off = 8
while (off + 3 < params.size && cells.size < count && count < 64) {
val rat = params[off].toInt() and 0xFF
val registered = (params[off + 1].toInt() and 0xFF) != 0
val stride = STRIDE[rat] ?: 0
if (stride == 0) break
val struct = params.copyOfRange(off + 3, minOf(off + stride, params.size))
cells.add(parseStruct(rat, registered, struct))
off += stride
}
return cells
}
/**
* Decode a single frame's raw bytes. Returns the cell list for a 0x070F
* CellInfoList frame, or an empty list for any other (or invalid) frame.
*/
fun decodeFrame(frame: ByteArray): List<Tower> {
if (frame.size < 16 || u16(frame, 0) != MAGIC) return emptyList()
val sid = u16(frame, 0x0e)
if (sid != SIT_CELL_INFO_LIST) return emptyList()
val params = frame.copyOfRange(0x14, frame.size)
return decodeCellInfo(params)
}
/** Parse /proc/cgcap/frames text: every 0x070F CellInfoList frame -> towers. */
fun decodeFrames(text: String): List<Tower> {
val out = ArrayList<Tower>()
for (frame in loadRx(text)) out.addAll(decodeFrame(frame))
return out
}
/** Parse RX lines to raw payload byte arrays (matches load_rx). */
private fun loadRx(text: String): List<ByteArray> {
val out = ArrayList<ByteArray>()
for (line in text.lineSequence()) {
val m = RX_LINE.find(line) ?: continue
out.add(hexToBytes(m.groupValues[3]))
}
return out
}
private fun hexToBytes(hexIn: String): ByteArray {
val hex = if (hexIn.length % 2 == 0) hexIn else hexIn.substring(0, hexIn.length - 1)
val out = ByteArray(hex.length / 2)
for (i in out.indices) {
out[i] = ((hex[i * 2].digitToInt(16) shl 4) or hex[i * 2 + 1].digitToInt(16)).toByte()
}
return out
}
/**
* First 6-digit PLMN run seen across all RX frame params (deterministic
* analogue of the Python plmns set), usable as the expected serving PLMN.
*/
fun firstPlmn(text: String): String? {
for (frame in loadRx(text)) {
if (frame.size < 0x14) continue
val params = frame.copyOfRange(0x14, frame.size)
val ascii = String(CharArray(params.size) { (params[it].toInt() and 0xFF).toChar() })
PLMN_RUN.find(ascii)?.let { return it.groupValues[1] }
}
return null
}
/**
* CSS / IMSI-catcher heuristics for a cell-info snapshot. Returns human-readable
* suspect strings (matches the Python heuristics()).
*
* @param cells decoded towers for one snapshot
* @param servingPlmn expected serving PLMN (mcc+mnc), e.g. from [firstPlmn]
*/
fun heuristics(cells: List<Tower>, servingPlmn: String?): List<String> {
val alerts = ArrayList<String>()
val serving = cells.filter { it.registered }
for (c in serving) {
if (c.rat in WEAK_RAT) {
alerts.add("DOWNGRADE: serving on ${c.rat} (2G/3G) — CSS downgrade signature")
}
val pl = c.mcc + c.mnc
if (!servingPlmn.isNullOrEmpty() && pl.isNotEmpty() && pl != servingPlmn) {
alerts.add("PLMN MISMATCH: serving cell PLMN $pl != expected $servingPlmn")
}
}
// NOTE: an empty neighbor list is NOT a reliable CSS signal on its own —
// an idle modem (esp. on WiFi-calling) legitimately reports zero
// neighbors, which produced false alerts. Only real signals remain:
// active 2G/3G downgrade and serving-PLMN mismatch.
return alerts
}
}

View File

@@ -0,0 +1,35 @@
package com.seteclabs.cellguard.model
data class Tower(
val rat: String,
val registered: Boolean,
val mcc: String,
val mnc: String,
val ci: Long,
val pci: Int,
val tac: Int,
val earfcn: Int,
val rsrp: Int?,
val rsrq: Int?,
val op: String
)
data class Alert(
val ts: Long,
val kind: String,
val detail: String
)
data class CellGuardState(
val serving: Tower?,
val neighbors: List<Tower>,
val plmn: String,
val operator: String,
val rat: String,
val frames: Long,
val blockEnabled: Boolean,
val detectEnabled: Boolean,
val ratLock: Boolean,
val suspect: List<String>,
val alerts: List<Alert>
)

View File

@@ -0,0 +1,95 @@
package com.seteclabs.cellguard.root
import com.topjohnwu.superuser.Shell
import org.json.JSONObject
/**
* Root-side bridge to the CellGuard KSU module. All calls run through libsu's
* root shell (Shell.cmd(...).exec()). The kernel capture module exposes raw
* frames at /proc/cgcap/frames; the module control surface is cgctl.sh.
*/
object RootBridge {
private const val MODULE_DIR = "/data/adb/modules/cellguard"
private const val CGCTL = "$MODULE_DIR/cgctl.sh"
private const val KMOD = "$MODULE_DIR/common/kmod/cgcap.ko"
private const val FRAMES = "/proc/cgcap/frames"
/** Parsed view of `cgctl.sh status` mapped onto CellGuardState-relevant fields. */
data class Status(
val ok: Boolean,
val block: Boolean,
val detect: Boolean,
val ratLock: Boolean,
val rat: String,
val plmn: String,
val operator: String,
val frames: Long,
)
/** Run a root command and return its combined stdout as a single string. */
private fun sh(cmd: String): String =
Shell.cmd(cmd).exec().out.joinToString("\n")
/**
* Insert the capture kernel module if it is not already loaded.
* Idempotent: checks lsmod for `cgcap` first.
*/
fun ensureCapture() {
val loaded = Shell.cmd("lsmod | grep -qw cgcap").exec().isSuccess
if (!loaded) {
Shell.cmd("insmod $KMOD").exec()
}
}
/** Read the raw capture frames buffer. */
fun readFrames(): String = sh("cat $FRAMES")
/** Raw JSON line from `cgctl.sh status`. */
fun ctlStatus(): String = sh("sh $CGCTL status")
/** Raw JSON line from `cgctl.sh alerts`. */
fun ctlAlerts(): String = sh("sh $CGCTL alerts")
/** Toggle the 2G/3G block enforcement (ratlock). */
fun setBlock(on: Boolean) {
sh("sh $CGCTL block ${if (on) "on" else "off"}")
}
/** Toggle the IMSI-catcher detection daemon. */
fun setDetect(on: Boolean) {
sh("sh $CGCTL detect ${if (on) "on" else "off"}")
}
/**
* Parse a `cgctl.sh status` JSON line into a [Status]. Tolerant of missing
* keys and of numeric fields emitted as strings (cgctl.sh quotes some).
*/
fun parseStatus(json: String): Status {
val obj = try {
JSONObject(json.trim())
} catch (e: Exception) {
JSONObject()
}
return Status(
ok = obj.optBoolean("ok", false),
block = obj.optBoolean("block", boolLike(obj, "block")),
detect = obj.optBoolean("detect", boolLike(obj, "detect")),
ratLock = obj.optBoolean("lock", boolLike(obj, "lock")),
rat = obj.optString("rat", ""),
plmn = obj.optString("plmn", ""),
operator = obj.optString("operator", ""),
frames = obj.optString("frames", "0").toLongOrNull() ?: 0L,
)
}
/** Interpret a field that may be 0/1, "0"/"1", true/false. */
private fun boolLike(obj: JSONObject, key: String): Boolean {
val i = obj.optInt(key, -1)
if (i >= 0) return i != 0
return when (obj.optString(key, "").trim().lowercase()) {
"1", "true", "on", "yes" -> true
else -> false
}
}
}

View File

@@ -0,0 +1,359 @@
package com.seteclabs.cellguard.service
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.seteclabs.cellguard.cells.CellSource
import com.seteclabs.cellguard.decode.FrameDecoder
import com.seteclabs.cellguard.model.Alert
import com.seteclabs.cellguard.model.CellGuardState
import com.seteclabs.cellguard.model.Tower
import com.seteclabs.cellguard.root.RootBridge
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Foreground service: the CellGuard on-device monitor.
*
* On start it ensures the kernel capture module is loaded ([RootBridge.ensureCapture]),
* then polls every ~4s: it reads raw CP frames ([RootBridge.readFrames]) and the
* KSU module's `cgctl.sh status`/`alerts`, decodes serving/neighbor towers on-device
* via [FrameDecoder], assembles a [CellGuardState], and publishes it through the
* companion [state] flow. An ongoing notification on channel `cellguard` summarizes
* the serving RAT and suspect count; it switches to an alert style when the suspect
* list is non-empty.
*/
class DetectorService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var pollJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
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)
if (pollJob?.isActive != true) {
pollJob = scope.launch { pollLoop() }
}
return START_STICKY
}
override fun onDestroy() {
pollJob?.cancel()
scope.cancel()
super.onDestroy()
}
// ---- polling ----------------------------------------------------------
private suspend fun pollLoop() {
runCatching { RootBridge.ensureCapture() }
while (scope.isActive) {
val next = runCatching { buildState() }.getOrElse { state.value }
state.value = next
updateNotification(next)
handleAlert(next)
delay(POLL_MS)
}
}
private fun buildState(): CellGuardState {
val framesText = runCatching { RootBridge.readFrames() }.getOrDefault("")
val statusJson = runCatching { RootBridge.ctlStatus() }.getOrDefault("")
val alertsJson = runCatching { RootBridge.ctlAlerts() }.getOrDefault("")
val status = RootBridge.parseStatus(statusJson)
// Framework cell info (real CI/PCI/TAC/EARFCN + neighbors + signal when
// ACCESS_FINE_LOCATION is granted) is the primary tower source; also asks
// the modem for a fresh scan, which emits a 0x070F frame for the raw path.
CellSource.requestUpdate(this)
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 neighbors = towers.filter { it !== serving && !it.registered }.distinct()
val servingPlmn = status.plmn.takeIf { it.isNotBlank() && it != "?" }
?: FrameDecoder.firstPlmn(framesText)
val suspect = buildSuspects(towers, servingPlmn, statusJson)
val alerts = parseAlerts(alertsJson)
val plmn = servingPlmn
?: serving?.let { it.mcc + it.mnc }?.takeIf { it.isNotBlank() }
?: ""
val operator = status.operator.takeIf { it.isNotBlank() && it != "?" }
?: serving?.op.orEmpty()
val rat = serving?.rat
?: status.rat.takeIf { it.isNotBlank() && it != "?" }
?: ""
return CellGuardState(
serving = serving,
neighbors = neighbors,
plmn = plmn,
operator = operator,
rat = rat,
frames = status.frames,
blockEnabled = status.block,
detectEnabled = status.detect,
ratLock = status.ratLock,
suspect = suspect,
alerts = alerts,
)
}
/** On-device heuristics merged with the shell detector's `suspect` token. */
private fun buildSuspects(
towers: List<Tower>,
servingPlmn: String?,
statusJson: String,
): List<String> {
val out = LinkedHashSet<String>()
out.addAll(FrameDecoder.heuristics(towers, servingPlmn))
val shell = runCatching { JSONObject(statusJson.trim()).optString("suspect", "") }
.getOrDefault("")
shell.split(Regex("\\s+"))
.map { it.trim() }
.filter { it.isNotEmpty() && !it.equals("none", ignoreCase = true) }
.forEach { out.add(it) }
return out.toList()
}
private fun parseAlerts(json: String): List<Alert> {
val obj = runCatching { JSONObject(json.trim()) }.getOrNull() ?: return emptyList()
val arr = obj.optJSONArray("lines") ?: return emptyList()
val out = ArrayList<Alert>(arr.length())
for (i in 0 until arr.length()) {
val line = arr.optString(i).takeIf { it.isNotBlank() } ?: continue
out.add(parseAlertLine(line))
}
return out
}
/** Log line form: `[yyyy-MM-dd HH:mm:ss] KIND: detail` (from cgdetect.sh). */
private fun parseAlertLine(line: String): Alert {
var ts = System.currentTimeMillis()
var body = line
val close = line.indexOf(']')
if (line.startsWith("[") && close > 0) {
val stamp = line.substring(1, close)
runCatching { ALERT_FMT.parse(stamp)?.time }.getOrNull()?.let { ts = it }
body = line.substring(close + 1).trim()
}
val colon = body.indexOf(':')
val kind: String
val detail: String
if (colon > 0) {
kind = body.substring(0, colon).trim()
detail = body.substring(colon + 1).trim()
} else {
kind = "INFO"
detail = body
}
return Alert(ts = ts, kind = kind, detail = detail)
}
// ---- notification -----------------------------------------------------
private fun createChannel() {
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Ongoing status: LOW importance -> silent, no heads-up, no per-poll spam.
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "CellGuard status", NotificationManager.IMPORTANCE_LOW).apply {
description = "Ongoing serving-cell monitor (silent)"
setShowBadge(false)
},
)
}
// Real CSS alerts: HIGH, fired only when a NEW suspect appears (edge-triggered).
if (mgr.getNotificationChannel(ALERT_CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(ALERT_CHANNEL_ID, "CellGuard alerts", NotificationManager.IMPORTANCE_HIGH).apply {
description = "IMSI-catcher / downgrade alerts"
setShowBadge(true)
},
)
}
}
private fun buildNotification(s: CellGuardState): Notification {
val alerting = s.suspect.isNotEmpty()
val rat = s.rat.ifBlank { "" }
val plmn = s.plmn.ifBlank { "" }
val title: String
val text: String
val icon: Int
if (alerting) {
title = "CellGuard: ${s.suspect.size} suspect signal(s)"
text = s.suspect.joinToString(" · ")
icon = android.R.drawable.stat_sys_warning
} else {
title = "CellGuard active — $rat"
text = "PLMN $plmn · ${s.neighbors.size} neighbors · ${s.frames} frames"
icon = android.R.drawable.stat_notify_sync
}
// The ongoing notification is ALWAYS silent/LOW — it updates every poll,
// so it must never sound or heads-up. Real alerts go to the separate HIGH
// channel via handleAlert() (edge-triggered), not this one.
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(title)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
contentIntent()?.let { builder.setContentIntent(it) }
return builder.build()
}
private fun contentIntent(): PendingIntent? {
val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null
launch.setPackage(packageName)
return PendingIntent.getActivity(
this,
0,
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
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
},
)
}
private fun updateNotification(s: CellGuardState) {
if (!hasNotificationPermission()) return
NotificationManagerCompat.from(this).notify(NOTIF_ID, buildNotification(s))
}
private var lastAlertKey: String = ""
/**
* Edge-triggered CSS alert. Fires a single HIGH notification only when the
* suspect set newly appears (or changes); does nothing while it's unchanged,
* and cancels the alert once the set clears. This is what prevents spam — the
* ongoing status notification is silent and this fires at most once per event.
*/
private fun handleAlert(s: CellGuardState) {
if (!hasNotificationPermission()) return
val mgr = NotificationManagerCompat.from(this)
if (s.suspect.isEmpty()) {
if (lastAlertKey.isNotEmpty()) { mgr.cancel(ALERT_NOTIF_ID); lastAlertKey = "" }
return
}
val key = s.suspect.sorted().joinToString(",")
if (key == lastAlertKey) return
lastAlertKey = key
val n = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setContentTitle("⚠ CellGuard: ${s.suspect.size} suspect signal(s)")
.setContentText(s.suspect.joinToString(" · "))
.setStyle(NotificationCompat.BigTextStyle().bigText(s.suspect.joinToString("\n")))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setColorized(true)
.setColor(0xFFB00020.toInt())
.setAutoCancel(true)
.also { b -> contentIntent()?.let { b.setContentIntent(it) } }
.build()
mgr.notify(ALERT_NOTIF_ID, n)
}
private fun hasNotificationPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
companion object {
const val CHANNEL_ID = "cellguard"
const val ALERT_CHANNEL_ID = "cellguard_alert"
private const val NOTIF_ID = 0x0C6D
private const val ALERT_NOTIF_ID = 0x0C6E
private const val POLL_MS = 4_000L
private val ALERT_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val EMPTY = CellGuardState(
serving = null,
neighbors = emptyList(),
plmn = "",
operator = "",
rat = "",
frames = 0L,
blockEnabled = false,
detectEnabled = false,
ratLock = false,
suspect = emptyList(),
alerts = emptyList(),
)
/** Latest published [CellGuardState]; UI collects this. */
val state: MutableStateFlow<CellGuardState> = MutableStateFlow(EMPTY)
/** Read-only view for collectors that should not mutate. */
fun states(): StateFlow<CellGuardState> = state.asStateFlow()
/** Start the monitor as a foreground service. */
fun start(context: Context) {
ContextCompat.startForegroundService(
context,
Intent(context, DetectorService::class.java),
)
}
/** Stop the monitor. */
fun stop(context: Context) {
context.stopService(Intent(context, DetectorService::class.java))
}
}
}

View File

@@ -0,0 +1,582 @@
package com.seteclabs.cellguard.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.seteclabs.cellguard.CellGuardViewModel
import com.seteclabs.cellguard.model.Alert
import com.seteclabs.cellguard.model.CellGuardState
import com.seteclabs.cellguard.model.Tower
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// ---- header status derivation ----------------------------------------------
private enum class HeaderState(val label: String, val color: Color) {
ALERT("ALERT", CgRed),
ARMED("ARMED", CgGreen),
OFF("OFF", CgGray),
}
private fun headerStateOf(s: CellGuardState): HeaderState = when {
s.alerts.isNotEmpty() || s.suspect.isNotEmpty() -> HeaderState.ALERT
s.blockEnabled || s.detectEnabled -> HeaderState.ARMED
else -> HeaderState.OFF
}
private fun ratColor(rat: String): Color = when (rat.uppercase()) {
"LTE" -> CgGreen
"NR", "5G" -> CgCyan
"WCDMA", "TDSCDMA", "UMTS" -> CgAmber
"GSM", "CDMA", "2G" -> CgRed
else -> CgGray
}
// Unknown/unavailable numeric fields come through as -1; show a dash instead.
private fun dash(v: Long?): String = if (v != null && v >= 0) v.toString() else ""
private fun dash(v: Int?): String = if (v != null && v >= 0) v.toString() else ""
/** Best label for a tower row: carrier name, else PLMN, else CI, else dash. */
private fun towerLabel(t: Tower): String = t.op.ifBlank {
(t.mcc + t.mnc).ifBlank { if (t.ci >= 0) t.ci.toString() else "" }
}
// ---- top-level screen -------------------------------------------------------
@Composable
fun MainScreen(vm: CellGuardViewModel) {
val state by vm.state.collectAsState()
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,
)
}
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
private fun HeaderBar(state: CellGuardState) {
val hs = headerStateOf(state)
// Pulse the dot only while alerting.
val pulse = rememberInfiniteTransition(label = "pulse")
val pulseAlpha by pulse.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(650),
repeatMode = RepeatMode.Reverse,
),
label = "pulseAlpha",
)
val dotAlpha = if (hs == HeaderState.ALERT) pulseAlpha else 1f
val color by animateColorAsState(hs.color, label = "hdrColor")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.weight(1f)) {
Text(
"CELLGUARD",
color = MaterialTheme.colorScheme.onBackground,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Text(
"baseband monitor",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(999.dp))
.padding(horizontal = 12.dp, vertical = 7.dp),
) {
Box(
Modifier
.size(9.dp)
.alpha(dotAlpha)
.clip(CircleShape)
.background(color),
)
Spacer(Modifier.width(8.dp))
Text(
hs.label,
color = color,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
)
}
}
}
// ---- toggles ----------------------------------------------------------------
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Column(Modifier.weight(1f)) {
Text(
title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
)
Text(
subtitle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = CgOnSurfaceMuted,
uncheckedTrackColor = CgSurfaceHi,
uncheckedBorderColor = CgOutline,
),
)
}
}
}
// ---- serving cell -----------------------------------------------------------
@Composable
private fun ServingCard(state: CellGuardState) {
val serving = state.serving
val rat = serving?.rat?.takeIf { it.isNotBlank() } ?: state.rat.ifBlank { "" }
val operator = serving?.op?.takeIf { it.isNotBlank() }
?: state.operator.ifBlank { "Unknown operator" }
val plmn = state.plmn.ifBlank {
serving?.let { "${it.mcc}${it.mnc}".takeIf { s -> s.isNotBlank() } } ?: ""
}
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RatBadge(rat)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
operator,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"PLMN $plmn",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
Text(
"SERVING",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
Spacer(Modifier.height(14.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(14.dp))
Row(Modifier.fillMaxWidth()) {
Metric("CI", dash(serving?.ci), Modifier.weight(1f))
Metric("PCI", dash(serving?.pci), Modifier.weight(1f))
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth()) {
Metric("TAC", dash(serving?.tac), Modifier.weight(1f))
Metric("EARFCN", dash(serving?.earfcn), Modifier.weight(1f))
}
if (serving?.rsrp != null || serving?.rsrq != null) {
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth()) {
Metric(
"RSRP",
serving?.rsrp?.let { "$it dBm" } ?: "",
Modifier.weight(1f),
)
Metric(
"RSRQ",
serving?.rsrq?.let { "$it dB" } ?: "",
Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun Metric(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier) {
Text(
label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
Spacer(Modifier.height(2.dp))
Text(
value,
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
)
}
}
@Composable
private fun RatBadge(rat: String) {
val c = ratColor(rat)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(c.copy(alpha = 0.14f))
.border(1.dp, c.copy(alpha = 0.55f), RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
rat.uppercase(),
color = c,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
)
}
}
// ---- neighbor table ---------------------------------------------------------
@Composable
private fun NeighborTable(neighbors: List<Tower>) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(vertical = 4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(
"NEIGHBOR TOWERS",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
Text(
neighbors.size.toString(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
NeighborHeaderRow()
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
if (neighbors.isEmpty()) {
EmptyHint("No neighbors reported.")
} else {
neighbors.forEachIndexed { i, t ->
NeighborDataRow(t)
if (i < neighbors.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
}
}
}
// Column weights shared between header + data rows so they line up.
private const val W_ID = 2.2f
private const val W_RAT = 1.5f
private const val W_PCI = 1.1f
private const val W_TAC = 1.3f
private const val W_EARFCN = 1.5f
private const val W_RSRP = 1.3f
@Composable
private fun NeighborHeaderRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Th("ID", W_ID)
Th("RAT", W_RAT)
Th("PCI", W_PCI)
Th("TAC", W_TAC)
Th("EARFCN", W_EARFCN)
Th("RSRP", W_RSRP)
}
}
@Composable
private fun NeighborDataRow(t: Tower) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Td(towerLabel(t), W_ID, MaterialTheme.colorScheme.onSurface)
Box(Modifier.weight(W_RAT)) {
Text(
t.rat.uppercase().ifBlank { "" },
color = ratColor(t.rat),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Td(dash(t.pci), W_PCI, MaterialTheme.colorScheme.onSurfaceVariant)
Td(dash(t.tac), W_TAC, MaterialTheme.colorScheme.onSurfaceVariant)
Td(dash(t.earfcn), W_EARFCN, MaterialTheme.colorScheme.onSurfaceVariant)
Td(t.rsrp?.toString() ?: "", W_RSRP, MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun androidx.compose.foundation.layout.RowScope.Th(text: String, weight: Float) {
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(weight),
)
}
@Composable
private fun androidx.compose.foundation.layout.RowScope.Td(text: String, weight: Float, color: Color) {
Text(
text,
color = color,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(weight),
)
}
// ---- alerts -----------------------------------------------------------------
private val ALERT_TIME_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
@Composable
private fun AlertRow(a: Alert) {
Card(
colors = CardDefaults.cardColors(
containerColor = CgRed.copy(alpha = 0.10f),
),
shape = RoundedCornerShape(14.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, CgRed.copy(alpha = 0.35f), RoundedCornerShape(14.dp))
.padding(14.dp),
) {
Box(
Modifier
.padding(top = 5.dp)
.size(8.dp)
.clip(CircleShape)
.background(CgRed),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
a.kind.uppercase().ifBlank { "ALERT" },
color = CgRed,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Spacer(Modifier.weight(1f))
Text(
if (a.ts > 0) ALERT_TIME_FMT.format(Date(a.ts)) else "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
if (a.detail.isNotBlank()) {
Spacer(Modifier.height(3.dp))
Text(
a.detail,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
// ---- shared bits ------------------------------------------------------------
@Composable
private fun SectionLabel(text: String, count: Int) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp, start = 4.dp, end = 4.dp),
) {
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(8.dp))
if (count > 0) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(CgRed.copy(alpha = 0.18f))
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
count.toString(),
color = CgRed,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
}
}
}
}
@Composable
private fun EmptyHint(text: String) {
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}

View File

@@ -0,0 +1,85 @@
package com.seteclabs.cellguard.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// ---- CellGuard palette (dark-only, tuned for a monitoring/console feel) ----
val CgBackground = Color(0xFF0B0F14)
val CgSurface = Color(0xFF121A22)
val CgSurfaceHi = Color(0xFF18222C)
val CgOutline = Color(0xFF243140)
val CgOnBackground = Color(0xFFE6EDF3)
val CgOnSurfaceMuted = Color(0xFF8B99A6)
val CgGreen = Color(0xFF3FE08A) // armed / safe RAT
val CgCyan = Color(0xFF3FC6E0) // NR
val CgAmber = Color(0xFFE0B23F) // weak RAT (WCDMA/TDSCDMA)
val CgRed = Color(0xFFF0656A) // alert / 2G / suspect
val CgGray = Color(0xFF5A6773) // off
private val CgColorScheme = darkColorScheme(
primary = CgGreen,
onPrimary = Color(0xFF06130B),
secondary = CgCyan,
onSecondary = Color(0xFF04141A),
background = CgBackground,
onBackground = CgOnBackground,
surface = CgSurface,
onSurface = CgOnBackground,
surfaceVariant = CgSurfaceHi,
onSurfaceVariant = CgOnSurfaceMuted,
outline = CgOutline,
error = CgRed,
onError = Color(0xFF1A0405),
)
private val CgTypography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
letterSpacing = 0.2.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
letterSpacing = 0.6.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
),
)
@Composable
fun CellGuardTheme(content: @Composable () -> Unit) {
// The console is dark-only by design; ignore the system light/dark setting.
@Suppress("UNUSED_EXPRESSION")
isSystemInDarkTheme()
MaterialTheme(
colorScheme = CgColorScheme,
typography = CgTypography,
content = content,
)
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CellGuard</string>
<string name="notif_channel_id">cellguard_detector</string>
<string name="notif_channel_name">Detector Service</string>
<string name="notif_channel_desc">Ongoing baseband capture and IMSI-catcher detection.</string>
<string name="notif_title">CellGuard active</string>
<string name="notif_text">Monitoring baseband frames.</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose supplies its own Material3 styling; the platform theme only
needs to enable edge-to-edge with no action bar. -->
<style name="Theme.CellGuard" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>