From 42f657bc0a8e6e5581e2c51c49234b6831ecdab2 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Thu, 7 May 2026 23:12:17 -0400 Subject: [PATCH] =?UTF-8?q?v0.2.2=20=E2=80=94=20live=20proximity=20refresh?= =?UTF-8?q?=20+=20map=20dot=20markers=20+=20COMMERCIAL=20row=20fit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/build.gradle.kts | 4 +-- .../overwatch/fusion/DetectionStore.kt | 11 +++++++ .../overwatch/scan/CitizenScanner.kt | 31 ++++++++++++++++--- .../overwatch/scan/DeflockScanner.kt | 21 +++++++++++++ .../overwatch/service/DetectionService.kt | 21 +++++++++++++ .../org/soulstone/overwatch/ui/MainScreen.kt | 28 ++++++++++------- .../soulstone/overwatch/ui/SettingsScreen.kt | 9 ++++-- 7 files changed, 105 insertions(+), 20 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 536f9dd..21ee91d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "org.soulstone.overwatch" minSdk = 26 targetSdk = 35 - versionCode = 10 - versionName = "0.2.1" + versionCode = 11 + versionName = "0.2.2" } buildTypes { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt index d4e8800..4446387 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt @@ -44,6 +44,17 @@ class DetectionStore( _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 fun pruneExpired() { val cutoff = nowMs() - retentionMs diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt index 6dd3440..82787f9 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt @@ -99,15 +99,36 @@ class CitizenScanner( // Drop cache entries that no longer appear in the trending list (resolved). 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) { val now = System.currentTimeMillis() val limit = proximityMeters() val out = FloatArray(1) - for (id in ids) { - val incident = incidentCache[id] ?: client.fetchIncident(id)?.also { - incidentCache[id] = it - } ?: continue - + for (incident in incidents) { // Title-based pre-filter: drop pure fire/medical events. if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) && !POLICE_TITLE_RX.containsMatchIn(incident.title)) { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt index 8537922..30dd4b8 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt @@ -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 if (points.isEmpty()) return diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt index c1ea753..6a813e0 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.soulstone.overwatch.MainActivity import org.soulstone.overwatch.R @@ -108,6 +109,8 @@ class DetectionService : LifecycleService() { private var observerJob: Job? = null private var mapPointsJob: Job? = null private var locationJob: Job? = null + private var deflockProxJob: Job? = null + private var citizenProxJob: Job? = null private var bleStarted = false private var wifiStarted = false private var deflockStarted = false @@ -229,6 +232,22 @@ class DetectionService : LifecycleService() { locationJob = lifecycleScope.launch { 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() { @@ -247,6 +266,8 @@ class DetectionService : LifecycleService() { observerJob?.cancel(); observerJob = null mapPointsJob?.cancel(); mapPointsJob = null locationJob?.cancel(); locationJob = null + deflockProxJob?.cancel(); deflockProxJob = null + citizenProxJob?.cancel(); citizenProxJob = null _mapPoints.value = emptyList() _location.value = null lastNotifiedTier = ThreatLevel.GREEN diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt index 829b272..c4118eb 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt @@ -224,11 +224,15 @@ fun MainScreen( } } -/** Builds a small white-bordered blue dot used as the user-position Marker. */ -private fun userDotDrawable( - resources: android.content.res.Resources +/** Builds a small filled-circle Marker icon. Used for both the user-position + * dot (blue) and the ALPR pins (red) — osmdroid's default teardrop marker + * 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 { - val sizePx = 36 val bitmap = android.graphics.Bitmap.createBitmap( 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 { 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 { - 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) } @@ -318,9 +322,10 @@ private fun ThreatMapCircle( // lambda doesn't run afoul of smart-cast-into-closure rules. val fix: Location = userLocation 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. - 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( modifier = Modifier.fillMaxSize(), factory = { c -> @@ -336,12 +341,13 @@ private fun ThreatMapCircle( map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude)) 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) { map.overlays.add( Marker(map).apply { 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" setInfoWindow(null) } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt index f006c0c..fc1e599 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -83,7 +83,7 @@ fun SettingsScreen( SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) } SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(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)) if (isRunning) { Button( @@ -167,11 +167,16 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U verticalAlignment = Alignment.CenterVertically, 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 = label, color = MaterialTheme.colorScheme.onBackground, fontSize = 14.sp, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, + modifier = Modifier + .weight(1f, fill = true) + .padding(end = 12.dp) ) Switch(checked = value, onCheckedChange = onChange) }