feat: wigle wardriving UI — account linking and stats

This commit is contained in:
Sulkta 2026-03-14 15:49:28 -07:00
parent cec594cd96
commit 8c1e284db4
4 changed files with 269 additions and 3 deletions

View file

@ -672,4 +672,37 @@ class BeeApiClient(
return@withContext ApiResult.Error("Exception: ${e.message}") return@withContext ApiResult.Error("Exception: ${e.message}")
} }
} }
// ── WiGLE Wardriving API ──────────────────────────────────────────────────
suspend fun getWigleStatus(): ApiResult<com.adamaps.varroa.data.WigleStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wigle/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WigleStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun getWigleStats(): ApiResult<com.adamaps.varroa.data.WigleStats> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wigle/stats")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WigleStats::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun setWigleConfig(enabled: Boolean, apiName: String, apiToken: String): ApiResult<String> {
val json = gson.toJson(mapOf(
"enabled" to enabled,
"api_name" to apiName,
"api_token" to apiToken
))
return postRaw("/api/1/wigle/config", json)
}
} }

View file

@ -217,6 +217,24 @@ data class FrameKmTotal(
@SerializedName("count") val count: Int? = null @SerializedName("count") val count: Int? = null
) )
// ── WiGLE Wardriving ──────────────────────────────────────────────────────────
data class WigleStatus(
val enabled: Boolean = false,
@SerializedName("api_name") val apiName: String = "",
@SerializedName("total_networks") val totalNetworks: Int = 0,
@SerializedName("pending_upload") val pendingUpload: Int = 0,
@SerializedName("last_scan") val lastScan: Long? = null,
@SerializedName("last_upload") val lastUpload: Long? = null
)
data class WigleStats(
@SerializedName("total_networks") val totalNetworks: Int = 0,
@SerializedName("uploaded_networks") val uploadedNetworks: Int = 0,
@SerializedName("pending_upload") val pendingUpload: Int = 0,
@SerializedName("scans_today") val scansToday: Int = 0
)
// ── App state ───────────────────────────────────────────────────────────────── // ── App state ─────────────────────────────────────────────────────────────────
data class SessionStats( data class SessionStats(

View file

@ -30,6 +30,9 @@ import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.ui.theme.* import com.adamaps.varroa.ui.theme.*
@ -65,6 +68,14 @@ fun SettingsScreen(
val sshStatus by vm.sshStatus.collectAsState() val sshStatus by vm.sshStatus.collectAsState()
val wifiStatus by vm.wifiStatus.collectAsState() val wifiStatus by vm.wifiStatus.collectAsState()
val wifiConnectResult by vm.wifiConnectResult.collectAsState() val wifiConnectResult by vm.wifiConnectResult.collectAsState()
val wigleStatus by vm.wigleStatus.collectAsState()
val wigleStats by vm.wigleStats.collectAsState()
val wigleConfigResult by vm.wigleConfigResult.collectAsState()
// WiGLE config input state
var wigleEnabled by remember(wigleStatus) { mutableStateOf(wigleStatus?.enabled ?: false) }
var wigleApiName by remember { mutableStateOf("") }
var wigleApiToken by remember { mutableStateOf("") }
// WiFi config input state // WiFi config input state
var homeWifiSsid by remember { mutableStateOf("") } var homeWifiSsid by remember { mutableStateOf("") }
@ -296,6 +307,132 @@ fun SettingsScreen(
} }
} }
// ── WiGLE Wardriving ──────────────────────────────────────────────
SettingsSection("WIGLE WARDRIVING") {
if (!isPaired) {
Text("Pair device first to configure WiGLE",
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
} else {
// Enable toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.NetworkCheck, contentDescription = null,
tint = if (wigleStatus?.enabled == true) Amber else Color.Gray,
modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Column {
Text("WiFi Scanning",
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("Scan networks & upload to WiGLE",
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
}
}
Switch(
checked = wigleEnabled,
onCheckedChange = { wigleEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Background, checkedTrackColor = Amber)
)
}
Spacer(Modifier.height(12.dp))
// API credentials
Text("WiGLE Account", color = Amber, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontSize = 10.sp, letterSpacing = 1.sp)
Spacer(Modifier.height(8.dp))
if (wigleStatus?.apiName?.isNotBlank() == true) {
Text("Configured: ${wigleStatus?.apiName}",
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
Spacer(Modifier.height(4.dp))
}
OutlinedTextField(
value = wigleApiName,
onValueChange = { wigleApiName = it },
label = { Text("API Name", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
placeholder = { Text("From wigle.net → Account → API", color = Color.Gray, fontSize = 10.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber, unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber, unfocusedLabelColor = Color.Gray,
cursorColor = Amber, focusedTextColor = OnSurface, unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = wigleApiToken,
onValueChange = { wigleApiToken = it },
label = { Text("API Token", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
placeholder = { Text("●●●●●●●●●●●●", color = Color.Gray, fontSize = 10.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber, unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber, unfocusedLabelColor = Color.Gray,
cursorColor = Amber, focusedTextColor = OnSurface, unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
wigleConfigResult?.let {
Text(it, color = if (it.startsWith("Failed")) Color.Red else Amber,
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Spacer(Modifier.height(4.dp))
}
Button(
onClick = { vm.setWigleConfig(wigleEnabled, wigleApiName, wigleApiToken) },
enabled = wigleApiName.isNotBlank() && wigleApiToken.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background),
modifier = Modifier.fillMaxWidth()
) {
Text("Save WiGLE Config", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
// Stats (when enabled)
if (wigleStatus?.enabled == true) {
Spacer(Modifier.height(12.dp))
HorizontalDivider(color = SurfaceVariant)
Spacer(Modifier.height(12.dp))
Text("Statistics", color = Amber, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontSize = 10.sp, letterSpacing = 1.sp)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text("Networks Found", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
Text("${wigleStats?.totalNetworks ?: wigleStatus?.totalNetworks ?: 0}",
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
Column(modifier = Modifier.weight(1f)) {
Text("Pending Upload", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
Text("${wigleStats?.pendingUpload ?: wigleStatus?.pendingUpload ?: 0}",
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.height(8.dp))
wigleStatus?.lastScan?.let { ts ->
val ago = formatTimeAgo(ts)
Text("Last scan: $ago", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
}
wigleStatus?.lastUpload?.let { ts ->
val ago = formatTimeAgo(ts)
Text("Last upload: $ago", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
}
}
}
}
SettingsSection("ADAMAPS") { SettingsSection("ADAMAPS") {
SettingsField( SettingsField(
label = "ADAMaps API URL", label = "ADAMaps API URL",
@ -384,7 +521,8 @@ private fun SettingsField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
hint: String = "", hint: String = "",
numeric: Boolean = false numeric: Boolean = false,
keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text
) { ) {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
@ -392,8 +530,7 @@ private fun SettingsField(
label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
singleLine = true, singleLine = true,
keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
else KeyboardOptions.Default,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber, focusedBorderColor = Amber,
@ -576,3 +713,14 @@ private fun WalletLinkingSection(
) )
} }
} }
private fun formatTimeAgo(timestampSeconds: Long): String {
val now = System.currentTimeMillis() / 1000
val diff = now - timestampSeconds
return when {
diff < 60 -> "just now"
diff < 3600 -> "${diff / 60} minutes ago"
diff < 86400 -> "${diff / 3600} hours ago"
else -> "${diff / 86400} days ago"
}
}

View file

@ -9,6 +9,8 @@ import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.SettingsDataStore import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.data.WigleStatus
import com.adamaps.varroa.data.WigleStats
import com.adamaps.varroa.data.WifiStatus import com.adamaps.varroa.data.WifiStatus
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -64,6 +66,16 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val _wifiConnectResult = MutableStateFlow<String?>(null) private val _wifiConnectResult = MutableStateFlow<String?>(null)
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow() val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
// WiGLE state
private val _wigleStatus = MutableStateFlow<WigleStatus?>(null)
val wigleStatus: StateFlow<WigleStatus?> = _wigleStatus.asStateFlow()
private val _wigleStats = MutableStateFlow<WigleStats?>(null)
val wigleStats: StateFlow<WigleStats?> = _wigleStats.asStateFlow()
private val _wigleConfigResult = MutableStateFlow<String?>(null)
val wigleConfigResult: StateFlow<String?> = _wigleConfigResult.asStateFlow()
init { init {
// Initialize BeeApiClient with stored settings and token // Initialize BeeApiClient with stored settings and token
viewModelScope.launch { viewModelScope.launch {
@ -78,6 +90,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
if (s.isPaired) { if (s.isPaired) {
refreshSshStatus() refreshSshStatus()
refreshWifiStatus() refreshWifiStatus()
refreshWigleStatus()
} }
} }
} }
@ -260,4 +273,58 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun clearSshResult() { fun clearSshResult() {
_sshToggleResult.value = null _sshToggleResult.value = null
} }
// ── WiGLE ─────────────────────────────────────────────────────────────────
/**
* Refresh WiGLE status from device.
*/
fun refreshWigleStatus() {
viewModelScope.launch {
val client = beeClient ?: return@launch
when (val result = client.getWigleStatus()) {
is ApiResult.Success -> {
_wigleStatus.value = result.data
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get WiGLE status: ${result.message}")
}
}
when (val result = client.getWigleStats()) {
is ApiResult.Success -> {
_wigleStats.value = result.data
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get WiGLE stats: ${result.message}")
}
}
}
}
/**
* Set WiGLE configuration.
*/
fun setWigleConfig(enabled: Boolean, apiName: String, apiToken: String) {
viewModelScope.launch {
val client = beeClient ?: run {
_wigleConfigResult.value = "Not connected to device"
return@launch
}
_wigleConfigResult.value = null
when (val result = client.setWigleConfig(enabled, apiName, apiToken)) {
is ApiResult.Success -> {
_wigleConfigResult.value = "WiGLE configuration saved"
refreshWigleStatus()
}
is ApiResult.Error -> {
_wigleConfigResult.value = "Failed: ${result.message}"
}
}
}
}
fun clearWigleResult() {
_wigleConfigResult.value = null
}
} }