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