diff --git a/README.md b/README.md index c44c74f..58a232a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ on upward escalations — you don't have to be looking at the screen. > advertise/fuzz code from one of the reference projects is intentionally > excluded. -Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.11](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload). +Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.12](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload). --- @@ -95,12 +95,16 @@ is RED on top of it, ORANGE at 100 m, and GREEN by 500 m — still drawn on the map, just not an alarm. Waze keeps a wider band on purpose: a police car covers 700 m in under a minute; a bollarded camera never moves. -**This calibration is what makes a single range slider safe.** The tier is -`max(score)` over everything reported, and the slider decides what gets -reported — so with looser curves, standing still and dragging it from 300 m to -500 m flipped the app GREEN → YELLOW with nothing physical changing. Now -sweeping 200 m → 4900 m leaves the tier untouched, while standing 29 m from a -camera still reads **89 RED**. +**Scoring is fully decoupled from the range control.** The scanners evaluate +everything inside their own fixed radii (DeFlock 1200 m, Waze 2000 m, aircraft +15 km) and the tier is computed from that, so the slider cannot move it in +either direction. It took two passes to get right: calibrating the curves stopped +*widening* the range from dragging in distant noise, but narrowing it could still +hide a genuine alert — a police report 312 m away scoring 52 vanished, and the +circle went green, simply because the view was set to 300 m. Anything at YELLOW +or above is now shown regardless of range. Measured: a camera 285 m away scoring +48 holds the tier at YELLOW from a 200 m view range all the way to 4900 m, and +standing 29 m from one still reads **89 RED**. The user-facing circle uses the full 4-tier mapping. Cross-source corroboration naturally pushes the global max upward (a BLE OUI hit *and* a DeFlock map @@ -281,8 +285,10 @@ Tap the gear icon in the top-right. committing on release rather than per-pixel so dragging it doesn't restart the location scanners on every frame. It is a **view control, not a sensitivity control**: it sets what the circle draws and what the drill-down lists, and - nothing else. Scores come from real distance alone, so widening it adds dots - and rows but cannot change the threat tier. + nothing else. The scanners evaluate at their own fixed radii regardless of it, + so moving it in either direction cannot change the threat tier — and anything + at YELLOW or above is shown whatever the range says, because a view setting + that could hide a live alert would be a trap. - **Waze police feed**: paste your own OpenWeb Ninja API key — see [Waze setup](#waze-setup-bring-your-own-api-key). Stored encrypted on-device (Android Keystore), never baked into the APK. Empty = Waze source off. @@ -309,7 +315,7 @@ These live under `REFERENCES/` (gitignored): ## Status Phases 1–5 (skeleton, BLE, WiFi, DeFlock, polish) complete and -field-tested. Current release **v0.5.11**. Notable changes: +field-tested. Current release **v0.5.12**. Notable changes: - v0.1.2 — Android 14+ foreground service type fix; NaN-coordinate filter on map data. - v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked). @@ -332,6 +338,7 @@ field-tested. Current release **v0.5.11**. Notable changes: - v0.5.9 — Detection-radius slider moved onto the main screen (under the map, where you reach for it while moving) and a source-color legend added beneath the circle: ALPR red, speed camera amber, other cameras gray, Waze police blue, aircraft violet. - v0.5.10 — Recalibrated every distance curve so the range slider can no longer move the threat tier: each crosses below YELLOW at roughly the distance the thing stops being able to act on you (ALPR is RED on top of it, GREEN by 500 m). The main-screen slider is relabelled `show within` to say what it is — a view control, not a sensitivity control. - v0.5.11 — Android 15/16 and cutout-display compatibility. Opts into edge-to-edge explicitly and pads every screen with `WindowInsets.safeDrawing`; reproduced on Android 16 with a punch-hole, where v0.5.10 drew its title *inside* the status bar and buried the gear icon under the wifi/battery icons. The overlay bubble now states its cutout mode so it can't park under a camera hole. **BLE screen-off fix:** Android suspends unfiltered scans when the screen turns off and a foreground service does not exempt it, so the scanner switches to a filtered scan (Raven UUIDs, XUNTONG, mic company ids, capped at 16) while the screen is off — see [SOURCES.md §5](SOURCES.md). +- v0.5.12 — The range slider can no longer change the threat tier in *either* direction. v0.5.10 stopped widening it from pulling in distant noise, but narrowing it still hid real alerts: a police report 312 m away scoring 52 disappeared and the circle went green because the view was set to 300 m. Scanners now evaluate at their own fixed radii (DeFlock 1200 m, Waze 2000 m) instead of the user's setting, events carry their distance, and anything at YELLOW or above is displayed regardless of range. ## License diff --git a/SOURCES.md b/SOURCES.md index 0c1d413..2b91f4a 100644 --- a/SOURCES.md +++ b/SOURCES.md @@ -358,8 +358,14 @@ decides what gets *reported*, never how alarming it is. | 15000 m | — | — | — | — | 30 | **Every curve crosses below 40 (YELLOW) at roughly the distance the thing stops -being able to act on you.** That is not cosmetic — it is what makes a single -range slider safe. The on-screen tier is `max(score)` over everything reported, +being able to act on you**, and **scoring is fully decoupled from the range +control**: scanners evaluate everything inside their own fixed radii (DeFlock +1200 m, Waze 2000 m, aircraft 15 km) and the tier is computed from that, so the +`show within` slider cannot move it in either direction. Anything at YELLOW or +above is displayed whatever the range says, since a view setting that can hide a +live alert is a trap rather than a feature. + +Getting there took two passes, and the second one is the instructive half. The on-screen tier is `max(score)` over everything reported, and the slider decides what is reported, so loose curves let the setting leak into the alarm: measured against a real 195-node cache, standing still and dragging from 300 m to 500 m flipped the app GREEN → YELLOW with nothing diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7381a1c..25f8fdd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "org.soulstone.overwatch" minSdk = 26 targetSdk = 35 - versionCode = 27 - versionName = "0.5.11" + versionCode = 28 + versionName = "0.5.12" } // Fixed debug keystore committed to the repo (a debug key is non-secret — its diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt index 3ea97e3..788a4c1 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt @@ -10,6 +10,9 @@ package org.soulstone.overwatch.fusion * @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial") * @param rssi signal strength if applicable (BLE/WiFi); null for map/feed sources * @param lat / lon real-world coordinates for events that have them (DEFLOCK, WAZE); null for radio-only sources + * @param distanceMeters how far away it was when observed; null for radio-only sources, which + * have no position. The UI filters what it *shows* on this; it never filters what is + * *scored*, so the threat tier cannot move when the view range changes. * @param timestampMs wall-clock millis when this event was produced */ data class DetectionEvent( @@ -21,8 +24,22 @@ data class DetectionEvent( val rssi: Int? = null, val lat: Double? = null, val lon: Double? = null, + val distanceMeters: Float? = null, val timestampMs: Long = System.currentTimeMillis() ) { val level: ThreatLevel get() = ThreatLevel.fromScore(score) val hasGeo: Boolean get() = lat != null && lon != null + + /** + * Whether this belongs on screen at the given view range. + * + * Anything at or above YELLOW is shown **regardless of range**: the range + * is a view control, and a setting that could hide a live alert — or worse, + * quietly turn the circle green — would be a trap. Radio sources have no + * position and are always shown. + */ + fun visibleAt(viewRangeMeters: Float): Boolean = + score >= ThreatLevel.YELLOW.minScore || + distanceMeters == null || + distanceMeters <= viewRangeMeters } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/AircraftScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/AircraftScanner.kt index 9ba0419..b4fe07d 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/AircraftScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/AircraftScanner.kt @@ -154,7 +154,8 @@ class AircraftScanner( matchedMethods = scored.methods, rssi = null, lat = c.lat, - lon = c.lon + lon = c.lon, + distanceMeters = ground ) ) } 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 ef9a2fa..54efd15 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt @@ -29,11 +29,18 @@ class DeflockScanner( private val store: DetectionStore, private val locationProvider: LocationProvider, private val client: DeflockClient, - private val proximityMeters: () -> Float = { 200f } + private val proximityMeters: () -> Float = { EVAL_RADIUS_M } ) { companion object { private const val TAG = "DeflockScanner" + /** + * Fixed evaluation radius — deliberately NOT the user's view slider. + * Everything inside this is scored and can drive the threat tier; the + * slider only decides how much of it is drawn. An ALPR scores below + * YELLOW past ~500 m, so this carries margin while keeping the store small. + */ + const val EVAL_RADIUS_M = 1200f private const val REFETCH_THRESHOLD_M = 1500f /** Don't retry an Overpass POST within this window after a failure. */ private const val FAILURE_BACKOFF_MS = 60_000L @@ -104,22 +111,6 @@ 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 @@ -148,7 +139,8 @@ class DeflockScanner( matchedMethods = scored.methods, rssi = null, lat = p.lat, - lon = p.lon + lon = p.lon, + distanceMeters = dist ) ) } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt index 87a86af..7fb8108 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt @@ -35,11 +35,15 @@ class WazeScanner( private val store: DetectionStore, private val locationProvider: LocationProvider, private val client: WazeClient = WazeClient(), - private val proximityMeters: () -> Float = { 500f } + private val proximityMeters: () -> Float = { EVAL_RADIUS_M } ) { companion object { private const val TAG = "WazeScanner" + /** Fixed evaluation radius — see DeflockScanner.EVAL_RADIUS_M. Wider + * than the camera one because police move and a report stays above + * GREEN further out. */ + const val EVAL_RADIUS_M = 2000f private const val POLL_INTERVAL_MS = 240_000L // The hosted feed only lists still-active alerts but lags live Waze, so // real police sightings routinely arrive already 20-30 min old. A 10-min @@ -106,16 +110,6 @@ class WazeScanner( } } - /** - * Re-evaluate the last fetched alert set against the current proximity + age - * thresholds and latest fix, without a network refetch. Used when the user - * moves the Waze proximity slider. - */ - fun refresh() { - val fix = locationProvider.location.value ?: return - store.clearSource(DetectionSource.WAZE) - emitProximityEvents(fix, lastAlerts) - } private fun emitProximityEvents(fix: Location, alerts: List) { val now = System.currentTimeMillis() @@ -148,7 +142,8 @@ class WazeScanner( matchedMethods = scored.methods, rssi = null, lat = a.lat, - lon = a.lon + lon = a.lon, + distanceMeters = dist ) ) } 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 bbfaa4f..c9bd2c6 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -113,8 +113,6 @@ 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 wazeProxJob: Job? = null private var overlayJob: Job? = null private var bleStarted = false private var wifiStarted = false @@ -130,14 +128,13 @@ class DetectionService : LifecycleService() { bleScanner = BleScanner(this, store, micEnabled = { settings.micEnabled.value }) wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value }) locationProvider = LocationProvider(this) - deflockScanner = DeflockScanner( - store, locationProvider, DeflockClient(this), - proximityMeters = { settings.detectionRadiusM.value.toFloat() } - ) + // Scanners use their own fixed evaluation radii. The user's + // "show within" setting is a view control and must never decide what + // gets scored, or narrowing it would hide a real alert. + deflockScanner = DeflockScanner(store, locationProvider, DeflockClient(this)) wazeScanner = WazeScanner( store, locationProvider, - client = WazeClient(apiKey = { settings.wazeApiKey.value }), - proximityMeters = { settings.detectionRadiusM.value.toFloat() } + client = WazeClient(apiKey = { settings.wazeApiKey.value }) ) aircraftScanner = AircraftScanner(this, store, locationProvider) overlayManager = OverlayManager( @@ -256,16 +253,9 @@ class DetectionService : LifecycleService() { // 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. - // One radius now drives both location sources, so one collector - // re-evaluates whichever of them is running. - deflockProxJob?.cancel() - wazeProxJob?.cancel() - deflockProxJob = lifecycleScope.launch { - settings.detectionRadiusM.drop(1).collect { - if (deflockStarted) deflockScanner.refresh() - if (wazeStarted) wazeScanner.refresh() - } - } + // Nothing to do on a radius change any more: "show within" is a view + // control, the scanners evaluate at their own fixed radii, and the UI + // re-filters itself. Scoring is deliberately independent of it. // Floating threat-circle overlay — observe the toggle and show/hide // accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each @@ -295,8 +285,6 @@ class DetectionService : LifecycleService() { observerJob?.cancel(); observerJob = null mapPointsJob?.cancel(); mapPointsJob = null locationJob?.cancel(); locationJob = null - deflockProxJob?.cancel(); deflockProxJob = null - wazeProxJob?.cancel(); wazeProxJob = null overlayJob?.cancel(); overlayJob = null overlayManager.hide() _mapPoints.value = emptyList() 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 5ca7479..e085e74 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt @@ -241,7 +241,7 @@ fun MainScreen( fontFamily = FontFamily.Monospace ) Spacer(Modifier.height(8.dp)) - SourcesPanel(events = events) + SourcesPanel(events = events.filter { it.visibleAt(detectionRadiusM.toFloat()) }) Spacer(Modifier.height(16.dp)) } } @@ -343,6 +343,7 @@ private fun ThreatMapCircle( ) } for (e in events) { + if (!e.visibleAt(mapRadiusMeters)) continue val lat = e.lat ?: continue val lon = e.lon ?: continue val dot = when (e.source) { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/OverlayBubble.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/OverlayBubble.kt index d8463a8..7936668 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/OverlayBubble.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/OverlayBubble.kt @@ -119,6 +119,7 @@ fun OverlayBubble() { ) } for (e in events) { + if (!e.visibleAt(radius)) continue val lat = e.lat ?: continue val lon = e.lon ?: continue val dot = when (e.source) { diff --git a/docs/index.html b/docs/index.html index 00885ab..87d3a34 100644 --- a/docs/index.html +++ b/docs/index.html @@ -53,7 +53,7 @@
- v0.5.11 · Android · passive + v0.5.12 · Android · passive

OVERWATCH @@ -75,7 +75,7 @@
0fused sources
0threat tiers
0packets sent
-
0releases
+
0releases

@@ -432,7 +432,7 @@
@@ -501,7 +501,7 @@ transmit, probe, jam, or interfere with any device or network.

Also on Tor