v0.2.2 — live proximity refresh + map dot markers + COMMERCIAL row fit

- Map markers: replace osmdroid's default teardrop pin (which reads as
  a 'click me' affordance the map doesn't actually offer) with simple
  red dots for ALPRs, matching the blue user-position dot. Drawables
  share a single dotDrawable() helper.
- Live re-eval on proximity slider change. Bug: moving the DeFlock or
  Citizen distance slider while scanning updated the map's visible
  radius but didn't trigger a re-evaluation of which detections fire,
  so events outside a tightened radius lingered until restart and
  events inside a widened radius wouldn't appear until the next
  fix/poll cycle. Fix: add DetectionStore.clearSource(), add
  refresh() to DeflockScanner + CitizenScanner that clear the source
  and re-emit against cached state, observe the proximity StateFlows
  in DetectionService (drop initial replay so we don't redundantly
  clear+re-emit on first scan start).
- COMMERCIAL row label was clipping under the Switch. Shorten to
  'COMMERCIAL  •  Nest, Ring, Echo' (drop 'devices') and give every
  SourceToggle's Text Modifier.weight(1f) so labels wrap gracefully
  on narrow displays instead of getting truncated.
This commit is contained in:
2026-05-07 23:12:17 -04:00
parent 0e4387df45
commit 42f657bc0a
7 changed files with 105 additions and 20 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 10 versionCode = 11
versionName = "0.2.1" versionName = "0.2.2"
} }
buildTypes { buildTypes {
@@ -44,6 +44,17 @@ class DetectionStore(
_maxScore.value = 0 _maxScore.value = 0
} }
/** Drop every event from a single source — used when a proximity threshold
* changes and the owning scanner needs to re-emit a fresh slate (events
* outside the new radius would otherwise linger until their 5-min TTL). */
@Synchronized
fun clearSource(source: DetectionSource) {
val remaining = _events.value.filter { it.source != source }
if (remaining.size == _events.value.size) return
_events.value = remaining
recompute(remaining)
}
@Synchronized @Synchronized
fun pruneExpired() { fun pruneExpired() {
val cutoff = nowMs() - retentionMs val cutoff = nowMs() - retentionMs
@@ -99,15 +99,36 @@ class CitizenScanner(
// Drop cache entries that no longer appear in the trending list (resolved). // Drop cache entries that no longer appear in the trending list (resolved).
incidentCache.keys.retainAll(ids.toSet()) incidentCache.keys.retainAll(ids.toSet())
// Fetch any ids we haven't seen yet — Citizen incidents don't mutate,
// so a single fetch per id per session is enough.
for (id in ids) {
if (incidentCache[id] == null) {
client.fetchIncident(id)?.also { incidentCache[id] = it }
}
}
emitProximityEvents(fix, ids.mapNotNull { incidentCache[it] })
}
/**
* Re-evaluate the cached incident set against the current proximity + age
* thresholds and the latest fix, *without* a network refetch. Used when
* the user moves the proximity slider — events outside a tightened radius
* would otherwise linger and detections inside a widened radius wouldn't
* appear until the next poll cycle.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.CITIZEN)
emitProximityEvents(fix, incidentCache.values.toList())
}
private fun emitProximityEvents(fix: Location, incidents: Collection<CitizenClient.Incident>) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val limit = proximityMeters() val limit = proximityMeters()
val out = FloatArray(1) val out = FloatArray(1)
for (id in ids) { for (incident in incidents) {
val incident = incidentCache[id] ?: client.fetchIncident(id)?.also {
incidentCache[id] = it
} ?: continue
// Title-based pre-filter: drop pure fire/medical events. // Title-based pre-filter: drop pure fire/medical events.
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) && if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
!POLICE_TITLE_RX.containsMatchIn(incident.title)) { !POLICE_TITLE_RX.containsMatchIn(incident.title)) {
@@ -101,6 +101,27 @@ class DeflockScanner(
} }
} }
} }
emitProximityEvents(fix)
}
/**
* Re-evaluate the cached ALPRs against the current proximity threshold and
* the latest fix, *without* a network refetch. Used when the user moves the
* proximity slider — the slider changes [proximityMeters], but the scanner
* is otherwise idle (no new location ticks while stationary), so events
* outside the new radius would otherwise linger and detections inside the
* widened radius wouldn't appear until the next handleFix cycle.
*
* Clears the DEFLOCK source from the store first so events that fall
* outside a tightened radius disappear immediately.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.DEFLOCK)
emitProximityEvents(fix)
}
private fun emitProximityEvents(fix: Location) {
val points = _cachedPoints.value val points = _cachedPoints.value
if (points.isEmpty()) return if (points.isEmpty()) return
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.MainActivity import org.soulstone.overwatch.MainActivity
import org.soulstone.overwatch.R import org.soulstone.overwatch.R
@@ -108,6 +109,8 @@ class DetectionService : LifecycleService() {
private var observerJob: Job? = null private var observerJob: Job? = null
private var mapPointsJob: Job? = null private var mapPointsJob: Job? = null
private var locationJob: Job? = null private var locationJob: Job? = null
private var deflockProxJob: Job? = null
private var citizenProxJob: Job? = null
private var bleStarted = false private var bleStarted = false
private var wifiStarted = false private var wifiStarted = false
private var deflockStarted = false private var deflockStarted = false
@@ -229,6 +232,22 @@ class DetectionService : LifecycleService() {
locationJob = lifecycleScope.launch { locationJob = lifecycleScope.launch {
locationProvider.location.collect { _location.value = it } locationProvider.location.collect { _location.value = it }
} }
// Live re-eval when the user moves a proximity slider. drop(1) skips
// the StateFlow's initial replay so we don't redundantly clear+re-emit
// the events the scanner just produced from its first handleFix call.
deflockProxJob?.cancel()
if (deflockStarted) {
deflockProxJob = lifecycleScope.launch {
settings.deflockProximityM.drop(1).collect { deflockScanner.refresh() }
}
}
citizenProxJob?.cancel()
if (citizenStarted) {
citizenProxJob = lifecycleScope.launch {
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
}
}
} }
private fun endScanning() { private fun endScanning() {
@@ -247,6 +266,8 @@ class DetectionService : LifecycleService() {
observerJob?.cancel(); observerJob = null observerJob?.cancel(); observerJob = null
mapPointsJob?.cancel(); mapPointsJob = null mapPointsJob?.cancel(); mapPointsJob = null
locationJob?.cancel(); locationJob = null locationJob?.cancel(); locationJob = null
deflockProxJob?.cancel(); deflockProxJob = null
citizenProxJob?.cancel(); citizenProxJob = null
_mapPoints.value = emptyList() _mapPoints.value = emptyList()
_location.value = null _location.value = null
lastNotifiedTier = ThreatLevel.GREEN lastNotifiedTier = ThreatLevel.GREEN
@@ -224,11 +224,15 @@ fun MainScreen(
} }
} }
/** Builds a small white-bordered blue dot used as the user-position Marker. */ /** Builds a small filled-circle Marker icon. Used for both the user-position
private fun userDotDrawable( * dot (blue) and the ALPR pins (red) — osmdroid's default teardrop marker
resources: android.content.res.Resources * reads as a "click me" affordance which is wrong for a non-interactive
* visualization, so we use simple dots instead. */
private fun dotDrawable(
resources: android.content.res.Resources,
sizePx: Int,
coreColor: Int
): android.graphics.drawable.BitmapDrawable { ): android.graphics.drawable.BitmapDrawable {
val sizePx = 36
val bitmap = android.graphics.Bitmap.createBitmap( val bitmap = android.graphics.Bitmap.createBitmap(
sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888 sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888
) )
@@ -236,11 +240,11 @@ private fun userDotDrawable(
val outline = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { val outline = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFFFFFFF.toInt() color = 0xFFFFFFFF.toInt()
} }
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 2f, outline) canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, outline)
val core = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { val core = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF2196F3.toInt() color = coreColor
} }
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 6f, core) canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 4f, core)
return android.graphics.drawable.BitmapDrawable(resources, bitmap) return android.graphics.drawable.BitmapDrawable(resources, bitmap)
} }
@@ -318,9 +322,10 @@ private fun ThreatMapCircle(
// lambda doesn't run afoul of smart-cast-into-closure rules. // lambda doesn't run afoul of smart-cast-into-closure rules.
val fix: Location = userLocation val fix: Location = userLocation
val ctx = LocalContext.current val ctx = LocalContext.current
// Build the user-dot drawable once per Composition rather than // Build the marker drawables once per Composition rather than
// every recomposition — bitmap allocation isn't free. // every recomposition — bitmap allocation isn't free.
val userDot = remember(ctx) { userDotDrawable(ctx.resources) } val userDot = remember(ctx) { dotDrawable(ctx.resources, 36, 0xFF2196F3.toInt()) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, 0xFFD7263D.toInt()) }
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { c -> factory = { c ->
@@ -336,12 +341,13 @@ private fun ThreatMapCircle(
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude)) map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear() map.overlays.clear()
// ALPR pins first, user dot last so the dot draws on top. // ALPR dots first, user dot last so the user draws on top.
for (p in mapPoints) { for (p in mapPoints) {
map.overlays.add( map.overlays.add(
Marker(map).apply { Marker(map).apply {
position = GeoPoint(p.lat, p.lon) position = GeoPoint(p.lat, p.lon)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = flockDot
title = p.operator ?: p.manufacturer ?: "ALPR" title = p.operator ?: p.manufacturer ?: "ALPR"
setInfoWindow(null) setInfoWindow(null)
} }
@@ -83,7 +83,7 @@ fun SettingsScreen(
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) } SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) } SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo devices", mic) { settings.setMicEnabled(it) } SourceToggle("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (isRunning) { if (isRunning) {
Button( Button(
@@ -167,11 +167,16 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
// weight(1f) reserves the remaining row width for the label so it
// wraps on narrow screens instead of clipping under the Switch.
Text( Text(
text = label, text = label,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp, fontSize = 14.sp,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace,
modifier = Modifier
.weight(1f, fill = true)
.padding(end = 12.dp)
) )
Switch(checked = value, onCheckedChange = onChange) Switch(checked = value, onCheckedChange = onChange)
} }