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

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.gradle/
build/
*/build/
/local.properties
.idea/
.claude/
*.apk
*.iml
.DS_Store
captures/
.externalNativeBuild/
.cxx/

45
BUILD.md Normal file
View File

@@ -0,0 +1,45 @@
# Building CellGuard
## Toolchain
| Component | Version |
|-------------------|-----------|
| Gradle (wrapper) | 8.9 |
| Android Gradle Plugin | 8.5.2 |
| Kotlin | 1.9.23 |
| Compose compiler | 1.5.11 |
| JDK | 21 |
| compileSdk / targetSdk | 35 |
| minSdk | 33 |
| Build tools | 35.0.0 |
These versions are mutually compatible: AGP 8.5.2 requires Gradle 8.7+ and JDK 17+
(JDK 21 is fine), and Kotlin 1.9.23 pins the Compose compiler extension to 1.5.11.
## Prerequisites
- JDK 21 installed at `/usr/lib/jvm/java-21-openjdk-arm64`
- Android SDK at `/home/snake/Android/Sdk` with:
- `platforms/android-35`
- `build-tools/35.0.0`
- Network access for the first build (downloads AndroidX, Compose BOM 2024.06.00,
and libsu 5.2.2 from Google, Maven Central, and JitPack).
## Build command (debug APK)
```bash
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-arm64
export ANDROID_HOME=/home/snake/Android/Sdk
cd /home/snake/CellGuard-app && ./gradlew :app:assembleDebug
```
## Output
The debug APK is written to:
```
/home/snake/CellGuard-app/app/build/outputs/apk/debug/app-debug.apk
```
For a release (unsigned) build, run `./gradlew :app:assembleRelease`; the artifact
lands in `app/build/outputs/apk/release/`.

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>

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.23" apply false
}

9
gradle.properties Normal file
View File

@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
# AGP 8.5.2 was validated up to compileSdk 34; silence the (non-fatal) warning
# for the API 35 compileSdk this project targets.
android.suppressUnsupportedCompileSdk=35

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

25
settings.gradle.kts Normal file
View File

@@ -0,0 +1,25 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}
rootProject.name = "CellGuard"
include(":app")