Files
CellGuard-app/app/src/main/java/com/seteclabs/cellguard/ui/MainScreen.kt

583 lines
20 KiB
Kotlin
Raw Normal View History

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),
)
}