- 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
583 lines
20 KiB
Kotlin
583 lines
20 KiB
Kotlin
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),
|
|
)
|
|
}
|