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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user