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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CitizenClient.Incident>) {
|
||||
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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user