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