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

227 lines
8.6 KiB
Kotlin
Raw Normal View History

package com.seteclabs.cellguard.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.seteclabs.cellguard.data.AreaRow
import com.seteclabs.cellguard.data.SurveyDb
import com.seteclabs.cellguard.data.TowerRow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val stamp = SimpleDateFormat("MMM d, HH:mm", Locale.US)
/**
* The location-tagged survey log: the 2.5-mile areas the device has passed
* through, each expandable to the towers seen there. Reads [SurveyDb] directly
* (the detector service writes it) and refreshes on a short tick.
*/
@Composable
fun SurveyScreen() {
val ctx = LocalContext.current
val db = remember { SurveyDb(ctx) }
var areas by remember { mutableStateOf<List<AreaRow>>(emptyList()) }
var expanded by remember { mutableStateOf(-1L) }
var towers by remember { mutableStateOf<List<TowerRow>>(emptyList()) }
LaunchedEffect(Unit) {
while (true) {
areas = withContext(Dispatchers.IO) { runCatching { db.areas() }.getOrDefault(emptyList()) }
if (expanded >= 0) {
towers = withContext(Dispatchers.IO) { runCatching { db.towers(expanded) }.getOrDefault(emptyList()) }
}
delay(4_000)
}
}
DisposableEffect(Unit) { onDispose { runCatching { db.close() } } }
if (areas.isEmpty()) {
Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"No survey data yet.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
)
Spacer(Modifier.height(6.dp))
Text(
"Towers are logged with your GPS position as you move. Grant location and drive around to build the map.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
}
return
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text(
"SURVEY LOG · ${areas.size} area(s)",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
}
items(areas, key = { it.id }) { a ->
AreaCard(
area = a,
isOpen = a.id == expanded,
towers = if (a.id == expanded) towers else emptyList(),
onClick = { expanded = if (expanded == a.id) -1L else a.id },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
@Composable
private fun AreaCard(area: AreaRow, isOpen: Boolean, towers: List<TowerRow>, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(Modifier.padding(14.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
"AREA #${area.id}",
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
Text(
"${area.towerCount} tower(s)",
color = CgCyan,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(4.dp))
Text(
"%.5f, %.5f".format(area.lat, area.lon),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
Text(
"${stamp.format(Date(area.firstMs))}${stamp.format(Date(area.lastMs))}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
if (isOpen) {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(Modifier.height(8.dp))
if (towers.isEmpty()) {
Text(
"loading…",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
} else {
towers.forEach { TowerLogRow(it) }
}
}
}
}
}
@Composable
private fun TowerLogRow(t: TowerRow) {
val label = t.op.ifBlank { (t.mcc + t.mnc).ifBlank { if (t.ci >= 0) t.ci.toString() else "" } }
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RatChip(t.rat, t.registered)
Spacer(Modifier.width(8.dp))
Text(
label,
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Spacer(Modifier.weight(1f))
Text(
t.rsrp?.let { "$it dBm" } ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
}
Text(
"CI ${dashL(t.ci)} · PCI ${dashI(t.pci)} · TAC ${dashI(t.tac)} · EARFCN ${dashI(t.earfcn)} · seen ${t.count}×",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
)
}
}
@Composable
private fun RatChip(rat: String, registered: Boolean) {
val c = when (rat.uppercase()) {
"LTE" -> CgGreen
"NR", "5G" -> CgCyan
"WCDMA", "UMTS", "TDSCDMA" -> CgAmber
"GSM", "CDMA", "2G" -> CgRed
else -> CgGray
}
Text(
if (registered) "$rat" else rat,
color = Color.Black,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c).padding(horizontal = 6.dp, vertical = 1.dp),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
)
}
private fun dashL(v: Long): String = if (v >= 0) v.toString() else ""
private fun dashI(v: Int): String = if (v >= 0) v.toString() else ""