v0.1.6 — audit fixes (critical, moderate, minor) + UX polish

Critical
--------
- DetectionService: subscribe to threatLevel + top event flows; rebuild the
  foreground notification on every change so a locked-screen user sees
  escalations. Vibrate on upward tier transitions (escalating waveforms for
  YELLOW/ORANGE/RED), gated by Settings.vibrateOnAlert (default on).
- DetectionService: only mark _running=true if at least one scanner started;
  stopSelf() if everything was disabled or denied. Switch START_STICKY →
  START_NOT_STICKY so a system-killed service doesn't re-create into a
  stuck "running but not scanning" state.
- DeflockClient: detect Overpass timeout-in-body (`{"remark": "...timed
  out..."}`) and treat as failure — previously these 200-with-empty-elements
  responses got cached for 24 h, hiding ALPRs in that 5×5 km cell for the
  next day.
- DeflockScanner: record lastFetch coords + timestamp on BOTH success and
  failure, with a 60 s backoff window after a failed attempt. Previously
  `lastFetchLat` was only set on Success, so every subsequent location
  update would re-trigger a 30 s POST that collectLatest then cancelled —
  we'd never finish a fetch under sustained Overpass slowness.
- LocationProvider: stale-lastLocation race fix. The async `lastLocation`
  callback now only seeds `_location` if it's still null and we're still
  running — previously it could overwrite a fresher fix from
  requestLocationUpdates, or fire after stop() and resurrect _location with
  stale data.

Moderate
--------
- CitizenScanner: wait for the first non-null location with .first { } before
  starting the poll/delay loop. First Citizen poll now fires within seconds
  of the location fix, not up to 60 s after.
- MainScreen: when not running, show a muted gray circle with "IDLE" text
  instead of the same solid green look as "scanning, all clear" — the
  pulse animation was the only differentiator before.
- Compose state: rememberSaveable for the screen enum + bottom-sheet open
  state, so SETTINGS survives rotation.
- MainActivity: detect permanently-denied permissions (the user picked
  "don't ask again") via shouldShowRequestPermissionRationale. UI swaps the
  call-to-action to "Open app settings" which fires
  Settings.ACTION_APPLICATION_DETAILS_SETTINGS. onResume re-checks so a
  user returning from app settings is reflected immediately.

Improvements
------------
- BLE/WiFi scanners record SourceHealth.OK on a successful start (and
  FAILED with a specific reason on every short-circuit — disabled adapter,
  missing permission, etc.) so the drill-down sheet is honest about radio
  state, not just network state.
- DetectionEvent gains optional lat/lon (populated by DEFLOCK and CITIZEN);
  SourceRow shows a tap-to-open-Maps icon next to events with coordinates,
  firing a `geo:lat,lon?q=lat,lon(label)` Intent.
- SettingsScreen sliders use onValueChangeFinished — only commit to
  SharedPreferences on drag-release, not on every pixel of movement.
- New Settings.vibrateOnAlert toggle (default on) wired to a SettingsScreen
  row under a new "Alerts" section.

Minor
-----
- BleScanner iterates ALL manufacturer-data entries to find XUNTONG; only
  falls back to the first entry if no XUNTONG match is present. Previously
  we only inspected the first entry.
- Drop dead `?.` on JSONArray.optString in CitizenClient (returns String,
  never null).
- Remove unused rememberCoroutineScope in MainScreen.
- Update stale Phase/Waze references in DetectionService comments.
- Add VIBRATE permission to manifest.

versionCode 6 → 7, versionName 0.1.5 → 0.1.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KaraZajac
2026-04-28 22:11:56 -04:00
parent c9cafb3e67
commit 2ff63da9b5
15 changed files with 385 additions and 72 deletions
@@ -19,8 +19,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -36,8 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -65,11 +68,12 @@ fun MainScreen(
onStartStop: () -> Unit,
onOpenSettings: () -> Unit,
canStart: Boolean,
permissionMessage: String?
permissionMessage: String?,
showOpenAppSettings: Boolean = false,
onOpenAppSettings: () -> Unit = {}
) {
var showSheet by remember { mutableStateOf(false) }
var showSheet by rememberSaveable { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetScope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -159,6 +163,24 @@ fun MainScreen(
fontSize = 13.sp
)
}
if (showOpenAppSettings) {
Spacer(Modifier.height(8.dp))
Button(
onClick = onOpenAppSettings,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Open app settings",
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
@@ -186,12 +208,20 @@ fun MainScreen(
@Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = when (level) {
// When the scanner isn't running, deliberately use a muted color and IDLE
// text so the user can tell at a glance whether they're scanning. Without
// this, idle and "scanning, all clear" both render as solid green.
val idleColor = MaterialTheme.colorScheme.surfaceVariant
val activeColor = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
val color = if (animating) activeColor else idleColor
val labelText = if (animating) level.name else "IDLE"
val labelColor = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f,
@@ -220,8 +250,8 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
contentAlignment = Alignment.Center
) {
Text(
text = level.name,
color = Color.White,
text = labelText,
color = labelColor,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
@@ -329,14 +359,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
)
} else {
Spacer(Modifier.height(4.dp))
events.take(3).forEach { e ->
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
events.take(3).forEach { e -> EventRow(e) }
if (events.size > 3) {
Text(
text = "+${events.size - 3} more",
@@ -348,3 +371,40 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
}
}
}
@Composable
private fun EventRow(e: DetectionEvent) {
val ctx = LocalContext.current
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f, fill = true)
)
if (e.hasGeo) {
IconButton(
onClick = {
val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(ctx.packageManager) != null) {
ctx.startActivity(intent)
}
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Place,
contentDescription = "Open in Maps",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
}
}
@@ -27,6 +27,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
@@ -49,6 +52,7 @@ fun SettingsScreen(
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val theme by settings.themeMode.collectAsState()
val vibrate by settings.vibrateOnAlert.collectAsState()
Column(
modifier = Modifier
@@ -107,21 +111,23 @@ fun SettingsScreen(
SectionLabel("Proximity thresholds")
SliderRow(
label = "DeFlock alert distance",
valueLabel = "${deflockProx} m",
value = deflockProx.toFloat(),
persistedValue = deflockProx,
range = 50f..1600f,
steps = 30,
onChange = { settings.setDeflockProximityM(it.toInt()) }
onCommit = { settings.setDeflockProximityM(it) }
)
SliderRow(
label = "Citizen alert distance",
valueLabel = "${citizenProx} m",
value = citizenProx.toFloat(),
persistedValue = citizenProx,
range = 100f..5000f,
steps = 48,
onChange = { settings.setCitizenProximityM(it.toInt()) }
onCommit = { settings.setCitizenProximityM(it) }
)
Spacer(Modifier.height(16.dp))
SectionLabel("Alerts")
SourceToggle("Vibrate on threat escalation", vibrate) { settings.setVibrateOnAlert(it) }
Spacer(Modifier.height(16.dp))
SectionLabel("Appearance")
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
@@ -169,15 +175,20 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
}
}
/**
* Slider that commits the value to Settings only on drag-release. The label
* tracks the live drag position locally to avoid spamming SharedPreferences
* writes (and downstream StateFlow re-emissions) on every pixel of movement.
*/
@Composable
private fun SliderRow(
label: String,
valueLabel: String,
value: Float,
persistedValue: Int,
range: ClosedFloatingPointRange<Float>,
steps: Int,
onChange: (Float) -> Unit
onCommit: (Int) -> Unit
) {
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -190,15 +201,16 @@ private fun SliderRow(
fontFamily = FontFamily.Monospace
)
Text(
text = valueLabel,
text = "${live.toInt()} m",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = value,
onValueChange = onChange,
value = live,
onValueChange = { live = it },
onValueChangeFinished = { onCommit(live.toInt()) },
valueRange = range,
steps = steps
)