v0.5.8 — AIRCRAFT source, wider Overpass query, distance-graded scoring

Adds a sixth source: police and surveillance aircraft overhead, via the free
community ADS-B networks (opendata.adsb.fi, api.adsb.lol fallback, no API
key). Contacts are matched by ICAO 24-bit address against a bundled registry
of 1,971 US law-enforcement airframes generated by scripts/gen-le-aircraft.py
from ADSBexchange's basic-ac-db and committed as res/raw/le_aircraft.csv.

Community feeds are used deliberately: FlightAware and Flightradar24 filter
law-enforcement flights at government request, which is the traffic this
source exists to see. Two parse traps handled — the envelope is {"ac":[..]}
on radius queries but {"aircraft":[..]} on wider ones, and alt_baro is the
string "ground" when parked.

Registry coverage is imperfect, so the scanner also detects loiter/orbit
behaviour (3+ samples over 4+ min within 5 km of their centroid, below
12,000 ft and under 200 kt). Surveillance aircraft circle; airliners and
medevac flights going somewhere do not. Behaviour-only hits are capped at 69
and must be within 8 km, so ordinary traffic never alerts.

Rejected with measurements: plane-alert-db matched 0 of 394 live aircraft
over a 250 nm sweep of DC while 15 helicopters were aloft (only 255 of its
entries are US-registered); registry.faa.gov is Akamai-403 and N-number
keyed; OpenSky's CSV is 94 MB for the same data; EFF's Atlas of Surveillance
has no aircraft identities at all, only a 2022 FAA drone lookup.

Overpass query widened from ALPR-only to man_made=surveillance plus
highway=speed_camera, classified into ALPR / speed camera / generic camera
with separate falloff curves so a shop's CCTV cannot alarm like a Flock
install. Cache key bumped to deflock2_ so v1 ALPR-only entries are not served
for another 24 h.

DeFlock, Waze and aircraft are now scored by continuous distance falloff
rather than flat values. The anchors are absolute metres and deliberately
independent of the detection-radius setting: moving a slider must not move
the threat level.

New SOURCES.md documents every endpoint, identifier and scoring table,
including the sources that were tried and rejected and the measurements that
killed them.

Verified against live traffic: detected San Diego PD's AS50 at 1580 m /
1000 ft scoring 83 (ORANGE) and a San Diego County Sheriff B407 at 11742 m
scoring 44, with the score tracking the helicopter from 81 to 83 as it
closed. Both match the falloff formula exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsfSpnwuxCkcXKkD9XH8SR
This commit is contained in:
KaraZajac
2026-09-16 23:20:53 -04:00
co-authored by Claude Opus 5
parent fa75c624b1
commit da97924ad2
28 changed files with 3552 additions and 745 deletions
+61 -33
View File
@@ -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 > advertise/fuzz code from one of the reference projects is intentionally
> excluded. > excluded.
Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.6](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload). Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
--- ---
@@ -36,27 +36,29 @@ Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest re
|---|---|---| |---|---|---|
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns — plus 18 IEEE-verified surveillance-vendor OUIs (ShotSpotter, WatchGuard/Motorola, Verkada, Avigilon Alta, Axis body cams, FLIR, Hanwha, March Networks, GeoVision, Mobotix, Sunell) with vendor-named labels | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. Police-exclusive OUIs (WatchGuard, ShotSpotter) score ORANGE on sight, same rationale as Axon. | | **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns — plus 18 IEEE-verified surveillance-vendor OUIs (ShotSpotter, WatchGuard/Motorola, Verkada, Avigilon Alta, Axis body cams, FLIR, Hanwha, March Networks, GeoVision, Mobotix, Sunell) with vendor-named labels | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. Police-exclusive OUIs (WatchGuard, ShotSpotter) score ORANGE on sight, same rationale as Axon. |
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset) + the same 18 vendor OUIs (WatchGuard 4RE in-car APs, Openpath/Alta readers, WiFi-capable cameras), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) | | **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset) + the same 18 vendor OUIs (WatchGuard 4RE in-car APs, Openpath/Alta readers, WiFi-capable cameras), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. | | **DEFLOCK** | Crowdsourced ALPR locations within the detection radius (default 500 m), scored by how close each one actually is | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. |
| **CITIZEN** | ⚠️ **Feed retired upstream (June 2026)** — see the note below. Previously: real-time public-safety incidents (police-relevant only) within proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}`. The endpoints now return empty stubs, so the source reports "feed retired upstream" and backs off to a 30-min heartbeat instead of polling a dead endpoint. | | **WAZE** | User-reported `POLICE` alerts still active in the feed within the detection radius (default 500 m), up to ~45 min old, scored by distance and age | `api.openwebninja.com/waze/alerts-and-jams` — [OpenWeb Ninja](https://www.openwebninja.com)'s hosted Waze feed, called directly with **your own API key** (`X-API-Key`) entered in Settings and stored encrypted on-device. Sidesteps the reCAPTCHA gating that 403s direct `live-map/api/georss` calls. Polled every ~4 min with `alert_types=POLICE&max_jams=0` (server-side filtering, ~1.5 KB/poll), and the client re-filters by type so a silent upstream change can't let other alert types through. Alerts carry confidence (0–5) + reliability (0–10); high values nudge the score up. No key → source shows "not configured" in the drill-down. |
| **WAZE** | User-reported `POLICE` alerts still active in the feed within configurable proximity (default 500 m), up to ~45 min old | `api.openwebninja.com/waze/alerts-and-jams` — [OpenWeb Ninja](https://www.openwebninja.com)'s hosted Waze feed, called directly with **your own API key** (`X-API-Key`) entered in Settings and stored encrypted on-device. Sidesteps the reCAPTCHA gating that 403s direct `live-map/api/georss` calls. Polled every ~4 min with `alert_types=POLICE&max_jams=0` (server-side filtering, ~1.5 KB/poll), and the client re-filters by type so a silent upstream change can't let other alert types through. Alerts carry confidence (0–5) + reliability (0–10); high values nudge the score up. No key → source shows "not configured" in the drill-down. | | **AIRCRAFT** | Police / surveillance aircraft overhead — identified from a bundled registry of 1,971 US law-enforcement airframes, plus loiter detection for unlisted ones | `opendata.adsb.fi` → fallback `api.adsb.lol`, polled every 60 s. **No API key.** Community ADS-B networks are used specifically because the commercial trackers filter law-enforcement flights at government request. Matched on the ICAO 24-bit address; scored by ground distance, altitude and orbit behaviour. |
| **COMMERCIAL** | Nearby consumer smart-home / voice gear (Nest, Ring, Echo, hidden cams) and camera-bearing smart glasses (Meta, Snap, Vuzix) as a secondary situational signal | Rides the BLE + WiFi scans — OUI / device-name / service-UUID / SSID matches plus Bluetooth SIG company IDs from `MicTargets`. Score-capped at ORANGE so a cluster of doorbells (or a passing pair of Ray-Bans) never reads as ALPR-grade certainty. | | **COMMERCIAL** | Nearby consumer smart-home / voice gear (Nest, Ring, Echo, hidden cams) and camera-bearing smart glasses (Meta, Snap, Vuzix) as a secondary situational signal | Rides the BLE + WiFi scans — OUI / device-name / service-UUID / SSID matches plus Bluetooth SIG company IDs from `MicTargets`. Score-capped at ORANGE so a cluster of doorbells (or a passing pair of Ray-Bans) never reads as ALPR-grade certainty. |
> **Citizen went dark (v0.5.5).** Citizen ended the police-dispatch data > **Citizen was removed (v0.5.7).** Citizen ended the police-dispatch data
> partnership behind its public feed in June 2026 and stubbed the endpoints. > partnership behind its public feed in June 2026. It first degraded to silent
> Verified 2026-08-28: `/api/incident/trending` answers HTTP 200 with a bare > empty stubs, and as of **2026-09-16** `citizen.com/api/incident/trending`
> JSON empty string, `/api/incident/{id}` returns `{}` for every id (real or > returns **HTTP 410 Gone — `{"error":"This endpoint has been removed."}`**,
> invented), and `data.sp0n.io/v1/incidents/trending` — the host the current web > which is upstream formally tombstoning it. `data.sp0n.io` (the host the
> app uses — returns a zero-byte body even with no query params at all. Every > current web app uses) answers 200 with a zero-byte body even with no query
> sibling path (`nearby`, `recent`, `latest`, `map`, `list`, `active`) returns > params. Structured incident data is now Citizen's paid Enterprise API only,
> `{}`. That is a decommissioned surface, not a changed contract, so there is no > which needs a business use-case application — not something this app can
> parameter or host fix; structured incident data is now Citizen's paid > ship. The source and all its code were therefore deleted rather than left
> Enterprise API only. The app no longer leaks the resulting JSON parse error > as a permanently-failing row. Police presence is still covered by Waze
> into the drill-down — it reports the shutdown plainly and stops hammering the > (denser for roadway stops anyway) and DeFlock.
> endpoint. Police presence is still covered by Waze (denser for roadway stops
> anyway) and DeFlock.
> **Waze needs your own API key (v0.5.6+).** Waze reCAPTCHA-gated its `live-map/api/georss` endpoint in 2025/2026 — automated calls get HTTP 403 regardless of IP or headless-vs-headful browser (it scores browser *reputation*, verified by direct testing), which is why v0.1.5 removed the original integration and why no free scraper survives. OVERWATCH reads Waze POLICE alerts through [OpenWeb Ninja](https://www.openwebninja.com)'s hosted feed. Earlier builds routed this through a private proxy that injected a shared key, which meant handing out a credential that wasn't the user's; **v0.5.6 calls OpenWeb Ninja directly with a key you supply yourself** — see [Waze setup](#waze-setup-bring-your-own-api-key) below. Nothing is baked into the APK, so a published build carries no credential and each install bills to its own account. The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only. > **Waze needs your own API key (v0.5.6+).** Waze reCAPTCHA-gated its `live-map/api/georss` endpoint in 2025/2026 — automated calls get HTTP 403 regardless of IP or headless-vs-headful browser (it scores browser *reputation*, verified by direct testing), which is why v0.1.5 removed the original integration and why no free scraper survives. OVERWATCH reads Waze POLICE alerts through [OpenWeb Ninja](https://www.openwebninja.com)'s hosted feed. Earlier builds routed this through a private proxy that injected a shared key, which meant handing out a credential that wasn't the user's; **v0.5.6 calls OpenWeb Ninja directly with a key you supply yourself** — see [Waze setup](#waze-setup-bring-your-own-api-key) below. Nothing is baked into the APK, so a published build carries no credential and each install bills to its own account. The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only.
> **Full reference:** [SOURCES.md](SOURCES.md) documents every endpoint, every
> BLE/WiFi identifier, the scoring tables, and the sources that were tried and
> rejected (and why).
Every observation is scored 0–100 by `ConfidenceEngine`. The on-screen tier is Every observation is scored 0–100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources: the maximum live score across all sources:
@@ -67,6 +69,29 @@ ORANGE 70 – 84 high confidence
RED 85 + certain RED 85 + certain
``` ```
**DeFlock and Waze are scored on a sliding scale, not a flat value.** Both
carry real coordinates, so the score is a continuous falloff over the actual
distance — drive toward a camera and the number climbs, drive past and it
drops. The anchors are absolute metres and deliberately independent of the
detection-radius setting: a camera 200 m away is equally close whether the
slider reads 300 m or 5 km, so moving a setting must never move the threat
level. A Waze report additionally decays by up to 12 points across its 45-min
freshness window, because police move and a surveyed camera does not.
| Distance | DeFlock ALPR | Waze police (fresh) |
|---|---|---|
| 0 m | 92 RED | 80 ORANGE |
| 50 m | 85 RED | 75 ORANGE |
| 100 m | 77 ORANGE | 70 ORANGE |
| 200 m | 60 YELLOW | 62 YELLOW |
| 600 m | 45 YELLOW | 49 YELLOW |
| 1200 m | 35 GREEN | 41 YELLOW |
| 3000 m | 22 GREEN | 28 GREEN |
A fixed ALPR peaks higher and decays faster than a crowd-sourced police pin:
its position is surveyed and exact, while a Waze report is a coarse pin on a
car that may be moving toward you.
The user-facing circle uses the full 4-tier mapping. Cross-source corroboration 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 naturally pushes the global max upward (a BLE OUI hit *and* a DeFlock map
match in the same area produce a higher tier than either alone). When idle, match in the same area produce a higher tier than either alone). When idle,
@@ -76,7 +101,7 @@ glance from "scanning, all clear."
While scanning, the circle becomes a live OpenStreetMap centered on you, wrapped While scanning, the circle becomes a live OpenStreetMap centered on you, wrapped
in a **threat-color ring** (the current tier at a glance) and marked with a ⌖ in a **threat-color ring** (the current tier at a glance) and marked with a ⌖
crosshair for your position. Map geodata is color-coded by source — **Flock / crosshair for your position. Map geodata is color-coded by source — **Flock /
DeFlock cameras red, Waze police blue, Citizen incidents purple** — so each dot DeFlock cameras red, Waze police blue** — so each dot
is self-explanatory. The same map renders in a smaller floating overlay bubble is self-explanatory. The same map renders in a smaller floating overlay bubble
(Settings → Display over other apps) so it works over other apps. (Settings → Display over other apps) so it works over other apps.
@@ -86,7 +111,7 @@ is self-explanatory. The same map renders in a smaller floating overlay bubble
- **In-app**: the threat circle shows a live map with a threat-color ring and - **In-app**: the threat circle shows a live map with a threat-color ring and
source-color dots while scanning; tap it to open the bottom-sheet drill-down source-color dots while scanning; tap it to open the bottom-sheet drill-down
with per-source rows. DeFlock, Citizen, and Waze events carry coordinates — with per-source rows. DeFlock and Waze events carry coordinates —
each row has a tap-to-open Maps icon. each row has a tap-to-open Maps icon.
- **Foreground notification**: rebuilt on every threat-tier change. Title - **Foreground notification**: rebuilt on every threat-tier change. Title
becomes `OVERWATCH • RED` (or whatever tier); text shows the top becomes `OVERWATCH • RED` (or whatever tier); text shows the top
@@ -114,11 +139,12 @@ scan/BleScanner.kt BLE callback scanner
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
scan/DeflockScanner.kt location-driven proximity check + failure backoff scan/DeflockScanner.kt location-driven proximity check + failure backoff
scan/CitizenClient.kt GET /api/incident/trending + /api/incident/{id}
scan/CitizenScanner.kt 60 s poller, fire/medical filter, per-id cache
scan/WazeClient.kt GET api.openwebninja.com (X-API-Key, user's own key) scan/WazeClient.kt GET api.openwebninja.com (X-API-Key, user's own key)
scan/AircraftClient.kt GET adsb.fi → adsb.lol (no key), live ADS-B contacts
scan/AircraftScanner.kt 60 s poller, registry match + loiter/orbit detection
data/targets/LeAircraft.kt bundled ICAO-hex table of law-enforcement aircraft
scan/WazeScanner.kt ~4 min poller, 200-alert page, client-side POLICE filter scan/WazeScanner.kt ~4 min poller, 200-alert page, client-side POLICE filter
fusion/ConfidenceEngine.kt scoring (BLE / WiFi / DeFlock / Citizen / Waze / Commercial) fusion/ConfidenceEngine.kt scoring (BLE / WiFi / DeFlock / Waze / Commercial)
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down
@@ -212,10 +238,10 @@ without an uninstall.
|---|---| |---|---|
| `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+) | BLE scanning | | `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+) | BLE scanning |
| `BLUETOOTH`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy | | `BLUETOOTH`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy |
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Citizen proximity | | `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Waze proximity |
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location | | `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results | | `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass, Citizen API, OpenWeb Ninja Waze feed | | `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass, OpenWeb Ninja Waze feed, ADS-B aircraft feeds |
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off | | `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off |
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification | | `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
| `VIBRATE` | Haptic alert on threat-tier escalation | | `VIBRATE` | Haptic alert on threat-tier escalation |
@@ -232,13 +258,13 @@ so you can grant manually.
Tap the gear icon in the top-right. Tap the gear icon in the top-right.
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen / Waze / Commercial independently. - **Detection sources**: toggle BLE / WiFi / DeFlock / Waze / Aircraft / Commercial independently.
Changes take effect on the next Start. While scanning, a **Restart scan to Changes take effect on the next Start. While scanning, a **Restart scan to
apply** button appears that does `stop()` + `start()` in one tap. apply** button appears that does `stop()` + `start()` in one tap.
- **Proximity thresholds** (sliders commit on release, not per-pixel): - **Detection radius** (one slider for both location sources; commits on
- DeFlock: 50 m – 1600 m (default 200 m) release, not per-pixel): 100 m – 5000 m, default 500 m. It sets what gets
- Citizen: 100 m – 5000 m (default 500 m) reported and what the map circle draws — *not* how alarming a hit is, which
- Waze: 100 m – 5000 m (default 500 m) is purely a function of real distance (see below).
- **Waze police feed**: paste your own OpenWeb Ninja API key — see - **Waze police feed**: paste your own OpenWeb Ninja API key — see
[Waze setup](#waze-setup-bring-your-own-api-key). Stored encrypted on-device [Waze setup](#waze-setup-bring-your-own-api-key). Stored encrypted on-device
(Android Keystore), never baked into the APK. Empty = Waze source off. (Android Keystore), never baked into the APK. Empty = Waze source off.
@@ -264,12 +290,12 @@ These live under `REFERENCES/` (gitignored):
## Status ## Status
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and Phases 1–5 (skeleton, BLE, WiFi, DeFlock, polish) complete and
field-tested. Current release **v0.5.6**. Notable changes: field-tested. Current release **v0.5.7**. Notable changes:
- v0.1.2 — Android 14+ foreground service type fix; NaN-coordinate filter on map data. - 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). - v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked).
- v0.1.4 — Citizen.com added as 5th source, per-source health registry. - v0.1.4 — Citizen.com added as 5th source, per-source health registry. *(source removed in v0.5.7 — feed retired upstream)*
- v0.1.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround at the time). - v0.1.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround at the time).
- v0.1.6 — Dynamic notification with tier + label, haptic alerts, Open-in-Maps for geo events. - v0.1.6 — Dynamic notification with tier + label, haptic alerts, Open-in-Maps for geo events.
- v0.1.7 — System back from Settings returns to MAIN instead of exiting. - v0.1.7 — System back from Settings returns to MAIN instead of exiting.
@@ -277,12 +303,14 @@ field-tested. Current release **v0.5.6**. Notable changes:
- v0.2.1–v0.2.2 — Live proximity refresh, map dot markers, map + Settings UI polish. - v0.2.1–v0.2.2 — Live proximity refresh, map dot markers, map + Settings UI polish.
- v0.3.0–v0.3.2 — Floating threat-circle overlay (chat-bubble style) + drag/crash fixes. - v0.3.0–v0.3.2 — Floating threat-circle overlay (chat-bubble style) + drag/crash fixes.
- v0.5.0 — Waze re-added via a key-protected Caddy proxy (`api.blackflagintel.com`): the OpenWeb Ninja key stays server-side, the app uses an encrypted per-device token. GitHub Actions release pipeline added. - v0.5.0 — Waze re-added via a key-protected Caddy proxy (`api.blackflagintel.com`): the OpenWeb Ninja key stays server-side, the app uses an encrypted per-device token. GitHub Actions release pipeline added.
- v0.5.1 — UI: larger map circle with a threat-color ring, ⌖ user crosshair, source-color dots (Flock red / Waze blue / Citizen purple), START moved to the bottom. - v0.5.1 — UI: larger map circle with a threat-color ring, ⌖ user crosshair, source-color dots (Flock red / Waze blue), START moved to the bottom.
- v0.5.2 — Committed a fixed debug keystore so CI + local builds sign identically; updates now install in place (no functional changes). - v0.5.2 — Committed a fixed debug keystore so CI + local builds sign identically; updates now install in place (no functional changes).
- v0.5.3 — Detect Meta / Snap / Vuzix smart glasses in the COMMERCIAL source (BLE company-id + name vectors); new radar app icon (launcher, themed, and notification). - v0.5.3 — Detect Meta / Snap / Vuzix smart glasses in the COMMERCIAL source (BLE company-id + name vectors); new radar app icon (launcher, themed, and notification).
- v0.5.4 — 18 IEEE-verified surveillance-vendor OUIs across BLE + WiFi (ShotSpotter, WatchGuard/Motorola, Verkada, Avigilon Alta, Axis, FLIR, Hanwha, March Networks, GeoVision, Mobotix, Sunell); police-exclusive vendors (WatchGuard, ShotSpotter) score ORANGE on sight; vendor-named drill-down labels. - v0.5.4 — 18 IEEE-verified surveillance-vendor OUIs across BLE + WiFi (ShotSpotter, WatchGuard/Motorola, Verkada, Avigilon Alta, Axis, FLIR, Hanwha, March Networks, GeoVision, Mobotix, Sunell); police-exclusive vendors (WatchGuard, ShotSpotter) score ORANGE on sight; vendor-named drill-down labels.
- v0.5.5 — Citizen feed confirmed retired upstream; the source now reports the shutdown instead of leaking a JSON parse error, and backs off to a 30-min heartbeat. Toolchain modernized: AGP 9.3.2, Gradle 9.7.1, Kotlin 2.4.10, Compose BOM 2026.08.00, `compileSdk` 37 (`targetSdk` held at 35); CI actions bumped off deprecated Node-20 versions. - v0.5.5 — Citizen feed confirmed retired upstream; the source now reports the shutdown instead of leaking a JSON parse error, and backs off to a 30-min heartbeat. Toolchain modernized: AGP 9.3.2, Gradle 9.7.1, Kotlin 2.4.10, Compose BOM 2026.08.00, `compileSdk` 37 (`targetSdk` held at 35); CI actions bumped off deprecated Node-20 versions.
- v0.5.6 — Waze now calls OpenWeb Ninja directly with **your own API key** instead of a shared proxy token; the `api.blackflagintel.com` proxy is no longer used and the stale token is purged from the secret store on upgrade. Requests add `alert_types=POLICE&max_jams=0` (~18 KB → ~1.5 KB per poll). See [Waze setup](#waze-setup-bring-your-own-api-key). - v0.5.6 — Waze now calls OpenWeb Ninja directly with **your own API key** instead of a shared proxy token; the `api.blackflagintel.com` proxy is no longer used and the stale token is purged from the secret store on upgrade. Requests add `alert_types=POLICE&max_jams=0` (~18 KB → ~1.5 KB per poll). See [Waze setup](#waze-setup-bring-your-own-api-key).
- v0.5.7 — Citizen source **removed entirely** (client, scanner, scoring, settings, map dots and the drill-down row). Its endpoint now returns HTTP 410 Gone, so there was nothing left to degrade gracefully into. OVERWATCH is now a five-source app: BLE, WiFi, DeFlock, Waze, Commercial.
- v0.5.8 — **AIRCRAFT source**: police / surveillance aircraft overhead via free community ADS-B feeds, matched against a bundled 1,971-entry registry of US law-enforcement airframes (regenerate with `scripts/gen-le-aircraft.py`), plus loiter/orbit detection so unlisted aircraft circling overhead still register. Overpass query widened to speed cameras and generic surveillance nodes, each scored on its own curve. DeFlock/Waze/aircraft all scored by continuous distance falloff. New [SOURCES.md](SOURCES.md) reference.
## License ## License
+437
View File
@@ -0,0 +1,437 @@
# OVERWATCH — sources, identifiers and endpoints
Every external data source, every signature the app matches on, and every
scoring table, in one place. Written to be the reference behind a talk, so it
records **what was tried and failed** alongside what works — the dead ends are
usually the more interesting half.
Dates are when a claim was last verified against the live service. Anything
network-facing rots; re-verify before quoting it.
---
## 1. Detection sources at a glance
| Source | What it sees | Transport | Cost | Verified |
|---|---|---|---|---|
| **BLE** | Bluetooth-LE advertisements from surveillance hardware | Local radio | free | 2026-09 |
| **WIFI** | BSSIDs + SSIDs of surveillance infrastructure | Local radio | free | 2026-09 |
| **DEFLOCK** | Mapped fixed surveillance: ALPR, speed cameras, CCTV | Overpass / OSM | free | 2026-09-17 |
| **WAZE** | Live crowd-sourced police reports | OpenWeb Ninja | ~$1–3/mo | 2026-09-16 |
| **AIRCRAFT** | Police / surveillance aircraft overhead | ADS-B community feeds | free | 2026-09-17 |
| **COMMERCIAL** | Consumer cameras, voice assistants, smart glasses | Rides BLE + WiFi | free | 2026-09 |
Two radio sources (passive, local) and three network sources (position-driven).
The app **never transmits** to any detected device — it only listens, and only
queries third-party APIs about coordinates.
---
## 2. Network endpoints
### 2.1 Overpass / OpenStreetMap — the DEFLOCK source
```
POST https://overpass.deflock.org/api/interpreter (preferred)
POST https://overpass-api.de/api/interpreter (fallback)
[out:json][timeout:25];
(node["man_made"="surveillance"](S,W,N,E);
node["highway"="speed_camera"](S,W,N,E););out body;
```
**DeFlock and OSM are the same dataset.** DeFlock is a crowdsourcing project
whose contributors map ALPR cameras *into OpenStreetMap* as
`man_made=surveillance` + `surveillance:type=ALPR` nodes. There is no separate
DeFlock database to query — the only DeFlock-specific piece is their Overpass
mirror, which the app prefers because it is less rate-limited for this use.
The OSM ALPR registry passed **336,000 nodes** worldwide in early 2026, covering
Flock plus Motorola Vigilant, Axon Fleet and Genetec.
`man_made=surveillance` is the superset that already contains the ALPR nodes,
so the query does not ask for ALPR separately; the parser classifies by tag.
Measured node counts in a 5 km bbox (2026-09-17):
| Area | ALPR | speed_camera | all surveillance |
|---|---|---|---|
| Northern Virginia | 166 | 31 | 189 |
| Manhattan | 292 | 12 | 515 |
Widening past ALPR roughly doubles the worst case rather than exploding it,
which is why generic cameras are worth carrying.
- Response cached on disk 24 h, keyed by 0.05° grid cell (`deflock2_<lat>_<lon>`).
- Refetch when the user moves > 1.5 km from the last fetch centre.
- A `200 OK` body containing `{"remark": "...timed out..."}` is treated as a
failure, so an Overpass timeout cannot poison the cache.
- **Dead:** `cdn.deflock.me/regions/*.json` — behind Cloudflare bot mitigation,
unusable from a mobile HTTP client.
### 2.2 OpenWeb Ninja — the WAZE source
```
GET https://api.openwebninja.com/waze/alerts-and-jams
?bottom_left=<lat>,<lon>&top_right=<lat>,<lon>
&max_alerts=200&max_jams=0&alert_types=POLICE
Header: X-API-Key: <the user's own key>
```
**Bring your own key** — sign up at openwebninja.com, subscribe to the Waze API,
paste the key into Settings. Stored encrypted (Android Keystore AES/GCM via
`SecureStore`); nothing ships in the APK. Pay-as-you-go ≈ **$0.005/request**,
≈ 15 requests/active hour at the ~4-minute poll, so **$1–3/month**. The free
tier's 100 requests/month verifies the integration but will not run it.
Verified behaviour (2026-09-16):
- `alert_types=POLICE` **is** honoured server-side now. It was echoed-but-ignored
when the integration was first written, so the client still re-filters by type
— a silent revert must not let other alert types through.
- `max_jams=0` suppresses the jam array. With both parameters a typical response
drops from **~17.9 KB to ~1.5 KB**.
- Auth failure is `HTTP 401 {"message":"Unauthorized"}` for both a missing and an
invalid key.
- `URLEncoder` renders the bbox comma as `%2C`; the API parses that correctly.
- Police alerts very often carry `alert_confidence: 0` and `alert_reliability: 0`,
so the distance floor matters more than the crowd-trust bonuses.
**Why not scrape Waze directly:** `waze.com/live-map/api/georss` is gated by
reCAPTCHA Enterprise *reputation* scoring. Tested 2026-07 from a residential IP —
plain curl, headless Chromium, **headful** Chromium on a real display, and
undetected-chromedriver all returned **HTTP 403**, including Waze's own page
requests. It scores browser reputation, not automation flags, so no scraper
survives. The Waze for Cities partner feed excludes POLICE and is agency-only.
### 2.3 ADS-B community networks — the AIRCRAFT source
```
GET https://opendata.adsb.fi/api/v2/lat/<lat>/lon/<lon>/dist/30 (preferred)
GET https://api.adsb.lol/v2/lat/<lat>/lon/<lon>/dist/30 (fallback)
```
**No API key, no account.** Both are volunteer data-in/data-out networks running
the same readsb-derived schema, so one parser covers both.
Community networks matter specifically here: the commercial trackers
(FlightAware, Flightradar24) filter military and sensitive law-enforcement
flights at government request — exactly the traffic this source exists to see.
ADSBexchange, adsb.fi, adsb.lol and airplanes.live do not filter.
- Envelope is `{"ac":[...]}` on radius queries and `{"aircraft":[...]}` on wider
ones — **both shapes occur**, and reading only one silently returns zero
aircraft. The parser accepts either.
- Fields used: `hex` (ICAO 24-bit address), `flight`, `lat`, `lon`, `alt_baro`,
`gs`, `track`, `r` (registration), `t` (type).
- `alt_baro` is the **string** `"ground"` for parked aircraft, so a naive
integer read reports that as 0 ft.
- Rapid sequential queries across many metros get refused; the app polls once
per minute from one point, well inside courtesy limits.
### 2.4 Aircraft identity — ADSBexchange `basic-ac-db`
```
https://downloads.adsbexchange.com/downloads/basic-ac-db.json.gz
```
14.5 MB gzipped, **618,270 aircraft**, newline-delimited JSON, derived from the
FAA registry and **already keyed by ICAO hex** — which is what ADS-B broadcasts,
so no N-number→hex conversion is needed. Fields: `icao`, `reg`, `ownop`
(owner/operator), `icaotype`, `model`, `faa_pia`, `faa_ladd`, `mil`.
Filtering `ownop` for law-enforcement patterns yields **1,971 US aircraft**
(213 LADD-flagged), bundled as `app/src/main/res/raw/le_aircraft.csv` (~83 KB).
Regenerate with `scripts/gen-le-aircraft.py`. It runs at development time and
commits its output; the app never downloads it.
Owner-match patterns: `SHERIFF`, `POLICE`, `STATE PATROL`, `HIGHWAY PATROL`,
`STATE TROOPER`, `PUBLIC SAFETY`, `US MARSHAL`, `MARSHALS SERVICE`, `CONSTABLE`,
`DEPT/DEPARTMENT OF CORRECTION`, `BORDER PATROL`, `CUSTOMS AND BORDER`,
`HOMELAND SECURITY`, `DRUG ENFORCEMENT`, `FEDERAL BUREAU OF INVESTIGATION`.
Excluded to avoid false positives: `MARSHALL SPACE` (NASA), plus `FIRE`,
`AMBULANCE`, `MEDICAL`, `EMS`, `AIR METHODS`, `LIFE FLIGHT`, `MEDEVAC`,
`HOSPITAL` — air-ambulance operators share a lot of vocabulary with police
aviation, and a medevac helicopter is not what this app exists to warn about.
Top operators by airframe count: DHS 98, US CBP 54, Arizona DPS 35, CHP 24,
Texas DPS 23, US Border Patrol 20.
**Rejected alternatives:**
- **FAA releasable registry** (`registry.faa.gov/database/...`) — HTTP **403**,
Akamai bot mitigation. Also keyed by N-number, not hex.
- **OpenSky `aircraftDatabase.csv`** — works, has an `owner` column, but 94 MB
for the same information and *not* hex-keyed as cleanly. 6× the download.
- **plane-alert-db** (`sdr-enthusiasts`) — the obvious community choice, and the
wrong one for US coverage. `plane-alert-pol.csv` holds 996 police aircraft but
only **255 US-registered**; matching all 2,760 of its police+government hexes
against **394 live aircraft** over a 250 nm sweep of Washington DC produced
**zero hits**, while 15 helicopters were aloft. Good for international and
head-of-state airframes, thin for US local police.
- **EFF Atlas of Surveillance** — no aircraft identities. Its only aircraft
dataset is an FAA **drone** registration lookup, last updated **April 2022**,
and drones do not broadcast ADS-B. Still useful as per-agency context
("what does this department deploy"), not as a live source.
### 2.5 Retired: Citizen
```
GET https://citizen.com/api/incident/trending -> HTTP 410 Gone
{"error":"This endpoint has been removed."}
```
Citizen ended the police-dispatch data partnership behind its public feed in
**June 2026**. Timeline: the endpoint first degraded to a silent stub returning
a bare JSON empty string `""` (verified 2026-08-28), then to a formal **410 Gone**
(verified 2026-09-16). `/api/incident/{id}` returns `{}` for every id, real or
invented, and `data.sp0n.io/v1/incidents/trending` — the host the current web app
uses — answers 200 with a **zero-byte body even with no query parameters at all**,
which rules out a parameter-shape mismatch. Every sibling path (`nearby`,
`recent`, `latest`, `map`, `list`, `active`) returns `{}`.
That is a decommissioned surface, not a changed contract. Structured incident
data is now Citizen's paid Enterprise API only. The source was removed entirely
in v0.5.7 rather than left as a permanently-failing row.
*(Historical footnote for the talk: passing that empty string to `JSONObject`
throws `Value of type java.lang.String cannot be converted to JSONObject` —
the doubled space in the message is where the empty value interpolates. That
exact string, leaked into the app's UI, is how the shutdown was first noticed.)*
---
## 3. Radio identifiers
### 3.1 BLE OUIs — surveillance
`data/targets/BleOuis.kt`. Matched on the lowercased `xx:xx:xx` MAC prefix.
**Axon** (flagged separately, scores 80): `00:25:df`
**Flock + supply chain** (24, from flock-detection):
```
58:8e:81 cc:cc:cc ec:1b:bd 90:35:ea f0:82:c0 1c:34:f1 38:5b:44 94:34:69
b4:e3:f9 3c:91:80 d8:f3:bc 80:30:49 14:5a:fc 9c:2f:9d 94:08:53 e4:aa:ea
48:e7:29 c8:c9:a3 74:4c:a1 70:c9:4e 04:0d:84 08:3a:88 a4:cf:12 d8:a0:d8
```
### 3.2 WiFi OUIs — Flock infrastructure
`data/targets/WifiOuis.kt`. 31-prefix superset (flock-you research by
NitekryDPaul + DeFlockJoplin), overlapping flock-detection's 24:
```
70:c9:4e 3c:91:80 d8:f3:bc 80:30:49 b8:35:32 14:5a:fc 74:4c:a1 08:3a:88
9c:2f:9d c0:35:32 94:08:53 e4:aa:ea f4:6a:dd f8:a2:d6 24:b2:b9 00:f4:8d
d0:39:57 e8:d0:fc e0:4f:43 b8:1e:a4 70:08:94 58:8e:81 ec:1b:bd 3c:71:bf
58:00:e3 90:35:ea 5c:93:a2 64:6e:69 48:27:ea a4:cf:12 82:6b:f2
```
Android exposes only the BSSID — flock-you's promiscuous-mode `addr1` and
wildcard-probe tricks are not portable to a userspace Android app.
### 3.3 Vendor OUIs — enterprise / municipal surveillance
`data/targets/VendorOuis.kt`. All 18 verified against the IEEE registry
(Wireshark `manuf` snapshot, 2026-08-28), so a hit names the manufacturer
directly. Merged into **both** the BLE and WiFi match sets.
| OUI | Vendor | Radio reality |
|---|---|---|
| `d4:11:d6` | ShotSpotter / SoundThinking | Acoustic gunshot sensors; patents cite WiFi/BT/Zigbee/LPWAN backhaul. **Police-exclusive.** |
| `00:1d:96` | WatchGuard Video (Motorola) | V300 body cams (BT 5.0 + WiFi), 4RE in-car systems run cruiser-local APs. **Police-exclusive.** |
| `e0:a7:00` | Verkada | AD33/AD34 readers advertise BLE for app unlock; SV sensors carry BLE. Cameras are PoE. |
| `70:1a:d5` | Avigilon Alta (Openpath) | Readers use BLE + WiFi (+ UWB) for mobile credentials — constant advertisers. |
| `00:40:8c` `ac:cc:8e` `b8:a4:4f` `e8:27:25` | Axis Communications | W110/W120 **body cams** have BLE 5.1 + dual-band WiFi; fixed cameras are wired. An Axis hit over the air is disproportionately likely to be a body cam. |
| `00:40:7f` `00:1b:d8` | FLIR Systems | Handhelds carry Bluetooth (METERLiNK) + WiFi; fixed thermal is wired. |
| `44:b4:23` `8c:1d:55` `e4:30:22` | Hanwha Vision | Predominantly wired IP video. |
| `00:10:be` `00:12:81` | March Networks | Wired; transit recorders may carry WiFi. |
| `00:13:e2` | GeoVision | Predominantly wired. |
| `00:03:c5` | Mobotix | Predominantly wired. |
| `00:1c:27` | Sunell Electronics | Predominantly wired. |
**Police-exclusive subset** (`ShotSpotter`, `WatchGuard`) scores 75 on sight —
same rationale as the Axon OUI: these prefixes appear on nothing consumer.
### 3.4 BLE service UUIDs — Flock Raven (gunshot detection)
`data/targets/RavenUuids.kt`. 16-bit UUIDs expanded to the Bluetooth base
`0000xxxx-0000-1000-8000-00805f9b34fb`:
| UUID | Meaning | Firmware |
|---|---|---|
| `180a` | Device Information | all |
| `3100` | GPS Location | 1.2.x+ |
| `3200` | Power Management | 1.2.x+ |
| `3300` | Network Status | 1.2.x+ |
| `3400` | Upload Statistics | 1.3.x |
| `3500` | Error Diagnostics | 1.3.x |
| `1809` | Health Thermometer | 1.1.x |
| `1819` | Location & Navigation | 1.1.x |
Three or more matching UUIDs scores 90; one or two scores 70.
### 3.5 Manufacturer-specific data
`data/targets/Manufacturers.kt`. Company ID **`0x09C8` (XUNTONG)** scores 60; a
`"TN"`-prefixed serial in the payload adds 20.
The BLE scanner iterates **every** manufacturer-data entry in an advertisement,
not just the first — devices advertise multiple and the target is often not
first in the list.
### 3.6 Name and SSID patterns
`data/targets/Patterns.kt`.
- **BLE local names** (case-sensitive substring): `FS Ext Battery`, `Penguin`,
`Flock`, `Pigvision`, `FlockCam`, `FS-`
- **Penguin numeric**: post-March-2025 firmware advertises a bare 8–12 digit
decimal ID and nothing else — matched as its own weak signal (15).
- **Generic SSID** (case-insensitive): `flock`, `FS_`, `Penguin`, `Pigvision`,
`FlockOS`, `flocksafety`, `FS Ext Battery`
- **Flock SSID format**: `^Flock-[0-9A-Fa-f]{4}$` — the specific form, scored
higher (65) than a generic substring hit (50).
### 3.7 COMMERCIAL / consumer gear
`data/targets/MicTargets.kt`. Families: `ECHO`, `RING`, `GOOGLE`, `HIDDEN_CAM`,
`GLASSES`.
Bluetooth SIG company IDs:
| ID | Vendor |
|---|---|
| `0x00E0` | Google |
| `0x0171` | Amazon |
| `0x05A7` | Yingxin |
| `0x01AB` | Meta Platforms |
| `0x058E` | Meta Platforms Technologies (Reality Labs / Quest) |
| `0x0D53` | Luxottica — Ray-Ban / Oakley Meta frames |
| `0x03C2` | Snap Inc. — Spectacles |
| `0x060C` | Vuzix |
`0x004C` (Apple) is **deliberately excluded** — every iPhone and AirPod in range
would match, drowning the signal.
Hidden-camera OUIs: `fc:b4:67` (Yingxin/SmartLife), `00:e0:4c` (Realtek, in many
cheap cams), `dc:4f:22` (Tuya-affiliated modules), `a4:c1:38` (Telink, common in
cheap BLE mics), `8c:ce:4e` (Shenzhen iComm, frequent in spy-cam BOMs).
Scores are **capped at 84** so a cluster of doorbells — or a passing pair of
Ray-Bans — can never read as ALPR-grade certainty. RED is reserved for
ALPR/Axon-grade evidence.
---
## 4. Scoring
Score 0–100 per observation; the on-screen tier is the **maximum live score**
across all sources. Events expire after 5 minutes.
```
GREEN < 40 nothing credible
YELLOW 40 – 69 single weak indicator
ORANGE 70 – 84 high confidence
RED 85 + certain
```
### 4.1 Distance falloff
Sources that carry real coordinates are scored on a **continuous falloff over
actual distance**, linearly interpolated between anchors. The anchors are
**absolute metres and deliberately independent of the user's detection-radius
setting**: a camera 200 m away is equally close whether the slider reads 300 m
or 5 km, so moving a setting must never move the threat level. The radius
decides what gets *reported*, never how alarming it is.
| Distance | ALPR | Speed camera | Generic camera | Waze police | Aircraft |
|---|---|---|---|---|---|
| 0 m | 92 | 75 | 55 | 80 | 88 |
| 50 m | 85 | 70 | 48 | — | — |
| 200 m | 60 | 52 | 38 | 62 | — |
| 600 m | 45 | 40 | 30 | 49 | — |
| 1000 m | — | — | — | — | 80 |
| 1500 m | 30 | 28 | 22 | — | — |
| 3000 m | 22 | 20 | 18 | 28 | 68 |
| 6000 m | — | — | — | — | 55 |
| 15000 m | — | — | — | — | 30 |
A surveyed ALPR peaks highest and decays fastest — its position is exact and it
cannot see you from a kilometre away. A Waze report is a coarse pin on a car
that may be moving toward you. An aircraft decays slowest of all, because one
orbiting 5 km away is still watching you.
### 4.2 Modifiers
- **Waze age decay** — up to −12 points across the 45-minute freshness window.
Police move; a surveyed camera does not.
- **Waze crowd trust** — `reliability ≥ 7` +5, `confidence ≥ 4` +5.
- **Aircraft altitude** — ≤3,000 ft +6; ≤8,000 ft +0; ≤15,000 ft −12;
above −30. A known police airframe at 30,000 ft is an airliner's neighbour,
not an observer.
- **Aircraft loitering** — +10. Surveillance aircraft orbit; airliners and
medevac flights heading somewhere do not.
- **Aircraft LADD** — +4. The operator asked public trackers to hide it.
- **Unidentified aircraft cap** — 69. Behaviour-only evidence never reaches the
"certain" band; circling could still be news or survey work.
- **Multi-method bonus** — +20 when two independent BLE/WiFi methods agree.
- **Strong RSSI** (> −50 dBm) +10; **stationary** (rise-peak-fall) +15.
### 4.3 Loiter detection
An aircraft is "loitering" when, over the last 15 minutes of tracked history:
at least **3 samples** spanning at least **4 minutes**, every one within
**5 km** of their own centroid, while below **12,000 ft** and under **200 kt**.
A registry hit is reported wherever it is, within 15 km. An *unregistered*
aircraft is reported only when it is both loitering **and** within 8 km, so
ordinary traffic passing overhead never raises an alert.
---
## 5. Testing notes
Emulator GPS is the main obstacle to testing the position-driven sources, and it
has a trap worth recording:
`adb emu geo fix` appears to do nothing — it returns `OK` and the position never
changes. The real cause is that **nothing has the GPS provider started**. With a
`PRIORITY_BALANCED_POWER_ACCURACY` request, Play services prefers the network
provider and parks the fused provider at `ProviderRequest[OFF]`, so the injected
NMEA has nowhere to land. Once the app requests `PRIORITY_HIGH_ACCURACY` and a
scan is running, `geo fix` lands immediately.
Two working injection methods, both needing an active GPS consumer:
```sh
# 1. emulator console — note LON comes first
adb -s emulator-5558 emu geo fix <lon> <lat> <alt> <sats>
# 2. Android test provider — note LAT comes first
adb -s <serial> shell appops set --uid 0 android:mock_location allow
adb -s <serial> shell cmd location providers add-test-provider gps
adb -s <serial> shell cmd location providers set-test-provider-enabled gps true
adb -s <serial> shell cmd location providers set-test-provider-location gps --location <lat>,<lon>
```
Wrapped as `scripts/emu-gps.sh <lat> <lon> [serial] [seconds]`. Always confirm
with `adb shell dumpsys location | grep -m1 "last location"`, which prints the
coordinates **and** an `et=` age — a large age means the fix did not land. A
reboot resets the `appops` grant, and the argument order differs between the two
methods, which is easy to transpose.
---
## 6. Provenance
Reference projects studied while building (kept under a gitignored `REFERENCES/`):
- **AxonCadabra** — BLE scanner skeleton. Scan side only; its advertise/fuzz
code is deliberately excluded, since OVERWATCH never transmits.
- **flock-detection** — the confidence-scoring algorithm, RSSI rise-peak-fall
stationary detection, OUIs, UUIDs and name patterns.
- **flock-you** — the 31-OUI WiFi superset.
- **deflock / deflock-app** — the Overpass query shape and proximity-alert
pattern.
- **wazepolice** — the original `live-map/api/georss` recipe, now dead.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 22 versionCode = 24
versionName = "0.5.6" versionName = "0.5.8"
} }
// Fixed debug keystore committed to the repo (a debug key is non-secret — its // Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -92,15 +92,11 @@ class MainActivity : ComponentActivity() {
val maxScore by DetectionService.store.maxScore.collectAsState() val maxScore by DetectionService.store.maxScore.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState() val mapPoints by DetectionService.mapPoints.collectAsState()
val userLocation by DetectionService.location.collectAsState() val userLocation by DetectionService.location.collectAsState()
// Visible map radius = max of the two proximity sliders // The map shows exactly the radius the sources use —
// so the user sees the full area where a detection // one setting, so the circle and the detection area can
// can fire. Using the raw setting values regardless of // no longer disagree.
// enabled-state keeps the visualization stable when a val detectionRadius by settings.detectionRadiusM.collectAsState()
// source is briefly toggled. val mapRadiusM = detectionRadius.toFloat()
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val mapRadiusM = maxOf(deflockProx, citizenProx, wazeProx).toFloat()
val granted by permissionsGranted val granted by permissionsGranted
val denied by permanentlyDenied val denied by permanentlyDenied
@@ -39,8 +39,19 @@ class LocationProvider(private val context: Context) {
private val _location = MutableStateFlow<Location?>(null) private val _location = MutableStateFlow<Location?>(null)
val location: StateFlow<Location?> = _location.asStateFlow() val location: StateFlow<Location?> = _location.asStateFlow()
// HIGH_ACCURACY, not BALANCED. Two reasons:
// - Proximity thresholds go down to 50 m (DeFlock's "very near" band).
// BALANCED resolves via wifi/cell to roughly a city block, which is
// coarser than the distances this app makes decisions at.
// - BALANCED asks Play services for the network provider and never
// escalates to GPS, so on a device with no usable wifi-scan geolocation
// (notably an emulator) the fused provider parks at ProviderRequest[OFF]
// and no fix is ever delivered — every location-driven source then sits
// at "no detections" forever.
// GPS cost is minor next to the continuous SCAN_MODE_LOW_LATENCY BLE scan
// this service already runs, and scanning is explicitly user-started.
private val request: LocationRequest = LocationRequest.Builder( private val request: LocationRequest = LocationRequest.Builder(
Priority.PRIORITY_BALANCED_POWER_ACCURACY, Priority.PRIORITY_HIGH_ACCURACY,
INTERVAL_MS INTERVAL_MS
) )
.setMinUpdateIntervalMillis(MIN_INTERVAL_MS) .setMinUpdateIntervalMillis(MIN_INTERVAL_MS)
@@ -32,29 +32,22 @@ class Settings private constructor(
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true)) private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow() val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true)) private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow() val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
private val _aircraftEnabled = MutableStateFlow(prefs.getBoolean(KEY_AIRCRAFT, true))
val aircraftEnabled: StateFlow<Boolean> = _aircraftEnabled.asStateFlow()
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true)) private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow() val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
private val _deflockProximityM = MutableStateFlow( // One radius for every location-driven source (DeFlock + Waze). Two
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX) // separate sliders meant the map had to visualise the larger of them, so
// the circle rarely matched what either source was actually using.
private val _detectionRadiusM = MutableStateFlow(
prefs.getInt(KEY_DETECTION_RADIUS, DEFAULT_DETECTION_RADIUS)
) )
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow() val detectionRadiusM: StateFlow<Int> = _detectionRadiusM.asStateFlow()
private val _citizenProximityM = MutableStateFlow(
prefs.getInt(KEY_CITIZEN_PROX, DEFAULT_CITIZEN_PROX)
)
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
// The user's own OpenWeb Ninja API key for the Waze feed. Stored encrypted // The user's own OpenWeb Ninja API key for the Waze feed. Stored encrypted
// (Keystore), never baked into the APK, so a published build carries no // (Keystore), never baked into the APK, so a published build carries no
@@ -76,26 +69,14 @@ class Settings private constructor(
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v } fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v } fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v } fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v } fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v }
fun setAircraftEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_AIRCRAFT, v) }; _aircraftEnabled.value = v }
fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v } fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
fun setDeflockProximityM(v: Int) { fun setDetectionRadiusM(v: Int) {
val clamped = v.coerceIn(50, 1600) val clamped = v.coerceIn(RADIUS_MIN, RADIUS_MAX)
prefs.edit { putInt(KEY_DEFLOCK_PROX, clamped) } prefs.edit { putInt(KEY_DETECTION_RADIUS, clamped) }
_deflockProximityM.value = clamped _detectionRadiusM.value = clamped
}
fun setCitizenProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_CITIZEN_PROX, clamped) }
_citizenProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
} }
fun setWazeApiKey(v: String) { fun setWazeApiKey(v: String) {
@@ -132,21 +113,19 @@ class Settings private constructor(
private const val KEY_BLE = "src_ble" private const val KEY_BLE = "src_ble"
private const val KEY_WIFI = "src_wifi" private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock" private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_CITIZEN = "src_citizen"
private const val KEY_WAZE = "src_waze" private const val KEY_WAZE = "src_waze"
private const val KEY_AIRCRAFT = "src_aircraft"
private const val KEY_MIC = "src_mic" private const val KEY_MIC = "src_mic"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m" private const val KEY_DETECTION_RADIUS = "detection_radius_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_WAZE_API_KEY = "waze_api_key" private const val KEY_WAZE_API_KEY = "waze_api_key"
private const val KEY_LEGACY_WAZE_PROXY_TOKEN = "waze_proxy_token" private const val KEY_LEGACY_WAZE_PROXY_TOKEN = "waze_proxy_token"
private const val KEY_THEME = "theme_mode" private const val KEY_THEME = "theme_mode"
private const val KEY_VIBRATE = "vibrate_on_alert" private const val KEY_VIBRATE = "vibrate_on_alert"
private const val KEY_OVERLAY = "overlay_enabled" private const val KEY_OVERLAY = "overlay_enabled"
const val DEFAULT_DEFLOCK_PROX = 200 const val DEFAULT_DETECTION_RADIUS = 500
const val DEFAULT_CITIZEN_PROX = 500 const val RADIUS_MIN = 100
const val DEFAULT_WAZE_PROX = 500 const val RADIUS_MAX = 5000
@Volatile private var INSTANCE: Settings? = null @Volatile private var INSTANCE: Settings? = null
@@ -0,0 +1,66 @@
package org.soulstone.overwatch.data.targets
import android.content.Context
import android.util.Log
import org.soulstone.overwatch.R
/**
* Known law-enforcement aircraft, keyed by the ICAO 24-bit address that every
* ADS-B transmission carries.
*
* Backed by `res/raw/le_aircraft.csv`, generated at development time by
* `scripts/gen-le-aircraft.py` from ADSBexchange's registry-derived database.
* ~1,970 entries, ~83 KB; it ships with the APK and is never downloaded, so
* this source works with no key, no account and no extra network call beyond
* the live position feed itself.
*/
object LeAircraft {
private const val TAG = "LeAircraft"
data class Entry(
val icaoHex: String,
val owner: String,
/** FAA "Limiting Aircraft Data Displayed" — the operator asked to be
* hidden from public trackers, which is itself worth knowing. */
val ladd: Boolean
)
@Volatile private var table: Map<String, Entry>? = null
/** Parsed on first use and cached; the file is small enough to hold whole. */
fun load(context: Context): Map<String, Entry> {
table?.let { return it }
return synchronized(this) {
table ?: parse(context).also { table = it }
}
}
fun lookup(context: Context, icaoHex: String): Entry? =
load(context)[icaoHex.trim().lowercase()]
private fun parse(context: Context): Map<String, Entry> = try {
val out = HashMap<String, Entry>(2048)
context.resources.openRawResource(R.raw.le_aircraft).bufferedReader().useLines { lines ->
for (line in lines) {
if (line.isBlank() || line.startsWith("#")) continue
val parts = line.split(',')
if (parts.size < 2) continue
val hex = parts[0].trim().lowercase()
if (hex.length != 6) continue
out[hex] = Entry(
icaoHex = hex,
owner = parts[1].trim(),
ladd = parts.getOrNull(2)?.trim() == "1"
)
}
}
Log.i(TAG, "Loaded ${out.size} law-enforcement aircraft")
out
} catch (e: Exception) {
// A missing or corrupt asset must not take the scanner down; without
// the table every contact simply falls back to behaviour-only scoring.
Log.w(TAG, "Failed to load aircraft table: ${e.message}")
emptyMap()
}
}
@@ -1,5 +1,8 @@
package org.soulstone.overwatch.fusion package org.soulstone.overwatch.fusion
import kotlin.math.roundToInt
import org.soulstone.overwatch.scan.DeflockClient
/** /**
* Confidence scoring — port of flock-detection's algorithm with weights from the OVERWATCH plan. * Confidence scoring — port of flock-detection's algorithm with weights from the OVERWATCH plan.
* *
@@ -29,17 +32,15 @@ object ConfidenceEngine {
const val W_WIFI_SSID_GENERIC = 50 const val W_WIFI_SSID_GENERIC = 50
const val W_WIFI_SSID_FLOCK_FMT = 65 const val W_WIFI_SSID_FLOCK_FMT = 65
// Map (Phase 3) // DeFlock + Waze are scored by a continuous distance falloff — see the
const val W_DEFLOCK_NEAR = 60 // <= 200m // FALLOFF tables below. A flat "inside the radius" score made the tier
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m // meaningless once the radius became user-set up to 5 km: in a city there
// is always an ALPR within a mile, so the app sat on YELLOW permanently.
// Citizen (real-time incident feed) /** Waze freshness window — shared with WazeScanner so the two can't drift. */
const val W_CITIZEN_INCIDENT = 55 const val WAZE_MAX_AGE_MS = 45L * 60L * 1000L
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2 /** Points shed across the full freshness window; police move, old reports rot. */
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword const val B_WAZE_AGE_DECAY = 12f
// Waze (live POLICE alerts via the OpenWeb Ninja hosted feed)
const val W_WAZE_POLICE = 55
// Bonuses // Bonuses
const val B_MULTI_METHOD = 20 const val B_MULTI_METHOD = 20
@@ -78,24 +79,27 @@ object ConfidenceEngine {
val isStationary: Boolean = false val isStationary: Boolean = false
) )
/** A DeFlock map ALPR observed within proximity threshold. */ /** A mapped surveillance node observed within the detection radius. */
data class DeflockObservation( data class DeflockObservation(
val osmId: Long, val osmId: Long,
val distanceMeters: Float, val distanceMeters: Float,
val kind: DeflockClient.Kind,
val operator: String?, val operator: String?,
val manufacturer: String? val manufacturer: String?
) )
/** A Citizen incident observed within proximity + freshness, after the /** An aircraft seen overhead by [org.soulstone.overwatch.scan.AircraftScanner]. */
* fire/medical filter is applied. */ data class AircraftObservation(
data class CitizenObservation( val icaoHex: String,
val incidentId: String,
val distanceMeters: Float, val distanceMeters: Float,
val ageMs: Long, val altitudeFt: Int?,
val level: Int, // 0-5 severity (Citizen's own scale) val isKnownLawEnforcement: Boolean,
val title: String, val isLoitering: Boolean,
val isPoliceTitled: Boolean, val isLadd: Boolean,
val precinct: String? val owner: String?,
val registration: String?,
val aircraftType: String?,
val callsign: String?
) )
/** A Waze POLICE alert observed within proximity + freshness thresholds. */ /** A Waze POLICE alert observed within proximity + freshness thresholds. */
@@ -214,47 +218,155 @@ object ConfidenceEngine {
return Scored(score, methods.toString().trim(), label, isAxon) return Scored(score, methods.toString().trim(), label, isAxon)
} }
fun scoreCitizen(obs: CitizenObservation): Scored { fun scoreAircraft(obs: AircraftObservation): Scored {
var score = W_CITIZEN_INCIDENT var score = falloff(obs.distanceMeters, AIRCRAFT_FALLOFF)
val tags = StringBuilder("citizen ") val tags = StringBuilder("aircraft ")
if (obs.level >= 2) {
score += B_CITIZEN_LEVEL_BUMP // Altitude is the strongest discriminator after identity: a known
tags.append("L${obs.level} ") // police airframe at 30,000 ft is an airliner's neighbour, not an
// observer. Penalise height rather than filtering, so a high orbit
// still shows up quietly.
val alt = obs.altitudeFt
when {
alt == null -> Unit
alt <= 3_000 -> { score += 6f; tags.append("very_low ") }
alt <= 8_000 -> tags.append("low ")
alt <= 15_000 -> { score -= 12f; tags.append("mid_alt ") }
else -> { score -= 30f; tags.append("high_alt ") }
} }
if (obs.isPoliceTitled) {
score += B_CITIZEN_POLICE_TITLE if (obs.isLoitering) { score += 10f; tags.append("loitering ") }
tags.append("police_title ") if (obs.isKnownLawEnforcement) {
tags.append("known_le ")
} else {
// Behaviour-only: cap it. Circling is suggestive, not proof.
score = minOf(score, AIRCRAFT_UNKNOWN_CAP.toFloat())
tags.append("unidentified ")
} }
if (!obs.precinct.isNullOrBlank()) tags.append("precinct=${obs.precinct} ") // The operator asked public trackers to hide this airframe.
score = score.coerceAtMost(100) if (obs.isLadd) { score += 4f; tags.append("ladd ") }
val ageMin = (obs.ageMs / 60_000L).toInt()
val label = "${obs.title} @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago" val final = score.roundToInt().coerceIn(0, 100)
return Scored(score, tags.toString().trim(), label, isAxon = false) val who = obs.owner
?: obs.registration
?: obs.callsign
?: "Unidentified aircraft"
val what = obs.aircraftType?.let { " ($it)" } ?: ""
val altText = obs.altitudeFt?.let { ", ${it} ft" } ?: ""
val verb = if (obs.isLoitering) "circling" else "overhead"
val label = "$who$what $verb @ ${obs.distanceMeters.toInt()}m$altText"
tags.append("d=${obs.distanceMeters.toInt()}m hex=${obs.icaoHex}")
return Scored(final, tags.toString().trim(), label, isAxon = false)
} }
fun scoreWaze(obs: WazeObservation): Scored { fun scoreWaze(obs: WazeObservation): Scored {
// Baseline 55 for any POLICE alert within the proximity + age gate the // Distance first, then the two crowd-trust nudges, then age. Kept well
// caller already applied. Small crowd-trust nudges for high reliability // under the multi-method bonus so a corroborating BLE/WiFi/DeFlock hit
// and high confidence, capped well under the multi-method bonus so a // still dominates the global tier.
// corroborating BLE/WiFi/DeFlock hit still dominates the global tier. var score = falloff(obs.distanceMeters, WAZE_FALLOFF)
var score = W_WAZE_POLICE if (obs.reliability >= 7) score += 5f
if (obs.reliability >= 7) score += 5 if (obs.confidence >= 4) score += 5f
if (obs.confidence >= 4) score += 5 // Police move; a report at the far end of the freshness window is much
score = score.coerceAtMost(100) // weaker evidence than one a minute old.
val methods = "waze_police rel=${obs.reliability} conf=${obs.confidence}" val ageFraction = (obs.ageMs.toFloat() / WAZE_MAX_AGE_MS).coerceIn(0f, 1f)
score -= B_WAZE_AGE_DECAY * ageFraction
val final = score.roundToInt().coerceIn(0, 100)
val ageMin = (obs.ageMs / 60_000L).toInt() val ageMin = (obs.ageMs / 60_000L).toInt()
val methods = "waze_police d=${obs.distanceMeters.toInt()}m age=${ageMin}min " +
"rel=${obs.reliability} conf=${obs.confidence}"
val sub = obs.subtype?.let { " ($it)" } ?: "" val sub = obs.subtype?.let { " ($it)" } ?: ""
val label = "Police report$sub @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago" val label = "Police report$sub @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago"
return Scored(score, methods, label, isAxon = false) return Scored(final, methods, label, isAxon = false)
}
/**
* Distance falloff anchors, as (metres, score) pairs interpolated linearly
* by [falloff].
*
* These are **absolute distances, deliberately not a fraction of the user's
* detection radius.** A camera 200 m away is exactly as close whether the
* radius slider reads 300 m or 5 km, so it has to score the same either
* way; keying off the radius would make the threat level move when the
* user touched a setting, which is the opposite of what the number means.
* The radius decides what gets *reported*, never how alarming it is.
*
* A fixed ALPR's position is surveyed and exact, so it stays alarming
* closer in and decays slowly. The 50 m and 200 m values are carried over
* from the old step scoring so calibration at typical distances is
* unchanged.
*/
private val DEFLOCK_FALLOFF = arrayOf(
0f to 92f, 50f to 85f, 200f to 60f, 600f to 45f, 1500f to 30f, 3000f to 22f
)
/**
* A fixed speed camera is enforcement infrastructure, but it only acts on
* you if you're speeding past it — relevant, not alarming.
*/
private val SPEED_CAMERA_FALLOFF = arrayOf(
0f to 75f, 50f to 70f, 200f to 52f, 600f to 40f, 1500f to 28f, 3000f to 20f
)
/**
* A generic `man_made=surveillance` node is as likely to be a shop's CCTV
* as a street camera, so it peaks below the YELLOW line at any real
* distance and never drives the tier on its own.
*/
private val CAMERA_FALLOFF = arrayOf(
0f to 55f, 50f to 48f, 200f to 38f, 600f to 30f, 1500f to 22f, 3000f to 18f
)
/**
* A Waze police report is a crowd-sourced pin on a moving car: coarser
* than a surveyed camera, so it peaks lower and decays faster. 300 m holds
* the old flat baseline of 55.
*/
private val WAZE_FALLOFF = arrayOf(
0f to 80f, 100f to 70f, 300f to 55f, 800f to 45f, 2000f to 32f, 4000f to 25f
)
/**
* Aircraft falloff, over ground distance. Far wider than the ground
* sources because an aircraft orbiting 5 km away is still watching you —
* unlike a camera 5 km away, which cannot see you at all.
*/
private val AIRCRAFT_FALLOFF = arrayOf(
0f to 88f, 1000f to 80f, 3000f to 68f, 6000f to 55f, 10000f to 42f, 15000f to 30f
)
/** An unregistered aircraft is judged on behaviour alone, so it is capped
* below the "certain" band — circling could still be news or survey work. */
const val AIRCRAFT_UNKNOWN_CAP = 69
/** Linear interpolation across [anchors]; clamps outside the first/last. */
private fun falloff(distanceMeters: Float, anchors: Array<Pair<Float, Float>>): Float {
val d = distanceMeters.coerceAtLeast(0f)
if (d <= anchors.first().first) return anchors.first().second
for (i in 0 until anchors.size - 1) {
val (d0, s0) = anchors[i]
val (d1, s1) = anchors[i + 1]
if (d <= d1) return s0 + (d - d0) / (d1 - d0) * (s1 - s0)
}
return anchors.last().second
} }
fun scoreDeflock(obs: DeflockObservation): Scored { fun scoreDeflock(obs: DeflockObservation): Scored {
val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR val d = obs.distanceMeters
val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m" val (curve, fallbackName, tag) = when (obs.kind) {
DeflockClient.Kind.ALPR -> Triple(DEFLOCK_FALLOFF, "ALPR", "alpr")
DeflockClient.Kind.SPEED_CAMERA ->
Triple(SPEED_CAMERA_FALLOFF, "Speed camera", "speed_cam")
DeflockClient.Kind.CAMERA ->
Triple(CAMERA_FALLOFF, "Surveillance camera", "camera")
}
val score = falloff(d, curve).roundToInt().coerceIn(0, 100)
val descriptor = listOfNotNull(obs.manufacturer, obs.operator) val descriptor = listOfNotNull(obs.manufacturer, obs.operator)
.joinToString(" / ").ifBlank { "ALPR" } .joinToString(" / ").ifBlank { fallbackName }
val label = "%s @ %dm (osm:%d)".format(descriptor, obs.distanceMeters.toInt(), obs.osmId) // The OSM id rides in the methods line rather than the label: it is
return Scored(score, rangeTag, label, isAxon = false) // what you need to look a camera up, and nothing you want shouting
// from the one-line status on the main screen.
val label = "%s @ %dm".format(descriptor, d.toInt())
return Scored(score, "$tag d=${d.toInt()}m osm:${obs.osmId}", label, isAxon = false)
} }
/** A BLE mic-bearing-device observation, score-capped at ORANGE. */ /** A BLE mic-bearing-device observation, score-capped at ORANGE. */
@@ -4,12 +4,12 @@ package org.soulstone.overwatch.fusion
* One observation from one source at one moment. * One observation from one source at one moment.
* *
* @param source which scanner produced this * @param source which scanner produced this
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Citizen) * @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, alert id for Waze)
* @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B") * @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B")
* @param score 0-100 confidence assigned by the engine * @param score 0-100 confidence assigned by the engine
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial") * @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/Citizen sources * @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, CITIZEN); null for radio-only sources * @param lat / lon real-world coordinates for events that have them (DEFLOCK, WAZE); null for radio-only sources
* @param timestampMs wall-clock millis when this event was produced * @param timestampMs wall-clock millis when this event was produced
*/ */
data class DetectionEvent( data class DetectionEvent(
@@ -26,23 +26,23 @@ object SourceHealth {
private val _ble = MutableStateFlow(Health()) private val _ble = MutableStateFlow(Health())
private val _wifi = MutableStateFlow(Health()) private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health()) private val _deflock = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health())
private val _waze = MutableStateFlow(Health()) private val _waze = MutableStateFlow(Health())
private val _aircraft = MutableStateFlow(Health())
private val _mic = MutableStateFlow(Health()) private val _mic = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow() val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow() val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow() val deflock: StateFlow<Health> = _deflock.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow()
val waze: StateFlow<Health> = _waze.asStateFlow() val waze: StateFlow<Health> = _waze.asStateFlow()
val aircraft: StateFlow<Health> = _aircraft.asStateFlow()
val mic: StateFlow<Health> = _mic.asStateFlow() val mic: StateFlow<Health> = _mic.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) { fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock DetectionSource.DEFLOCK -> deflock
DetectionSource.CITIZEN -> citizen
DetectionSource.WAZE -> waze DetectionSource.WAZE -> waze
DetectionSource.AIRCRAFT -> aircraft
DetectionSource.MIC -> mic DetectionSource.MIC -> mic
} }
@@ -51,8 +51,8 @@ object SourceHealth {
DetectionSource.BLE -> _ble DetectionSource.BLE -> _ble
DetectionSource.WIFI -> _wifi DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock DetectionSource.DEFLOCK -> _deflock
DetectionSource.CITIZEN -> _citizen
DetectionSource.WAZE -> _waze DetectionSource.WAZE -> _waze
DetectionSource.AIRCRAFT -> _aircraft
DetectionSource.MIC -> _mic DetectionSource.MIC -> _mic
} }
target.value = Health( target.value = Health(
@@ -66,8 +66,8 @@ object SourceHealth {
_ble.value = Health() _ble.value = Health()
_wifi.value = Health() _wifi.value = Health()
_deflock.value = Health() _deflock.value = Health()
_citizen.value = Health()
_waze.value = Health() _waze.value = Health()
_aircraft.value = Health()
_mic.value = Health() _mic.value = Health()
} }
} }
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
} }
/** Logical signal channel — used in the drill-down UI. */ /** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, WAZE, MIC } enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE, AIRCRAFT, MIC }
@@ -0,0 +1,146 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Live aircraft positions from the community ADS-B networks.
*
* GET https://opendata.adsb.fi/api/v2/lat/<lat>/lon/<lon>/dist/<nm>
* GET https://api.adsb.lol/v2/lat/<lat>/lon/<lon>/dist/<nm> (fallback)
*
* **No API key, no account.** Both are volunteer, data-in/data-out networks
* running the same readsb-derived schema, so one parser covers both and either
* can carry the source alone — the same belt-and-braces shape as the two
* Overpass endpoints in [DeflockClient].
*
* Community networks matter here specifically: the commercial trackers
* (FlightAware, Flightradar24) filter military and sensitive law-enforcement
* flights at government request, which is exactly the traffic this source
* exists to see. These don't filter.
*
* Response is `{ "ac": [...] }` (adsb.fi) or `{ "aircraft": [...] }` (wider
* queries and adsb.lol) — verified both shapes live 2026-09-17, and the parser
* accepts either because reading only one silently returns zero aircraft.
* Fields used: `hex` (the ICAO address matched against [LeAircraft]), `flight`,
* `lat`, `lon`, `alt_baro`, `gs`, `track`, `r` (registration), `t` (type).
*/
class AircraftClient {
companion object {
private const val TAG = "AircraftClient"
private const val TIMEOUT_MS = 15_000
private const val USER_AGENT = "OVERWATCH/0.5 (+github.com/KaraZajac/OVERWATCH)"
/** Query radius. Aircraft are fast and visible from far off, so this is
* deliberately much wider than any ground-source radius; the scanner
* decides what is actually close enough to matter. */
const val QUERY_RADIUS_NM = 30
}
data class Contact(
val icaoHex: String,
val callsign: String?,
val registration: String?,
val type: String?,
val lat: Double,
val lon: Double,
/** Barometric altitude in feet; null when the aircraft reports "ground". */
val altitudeFt: Int?,
val groundSpeedKt: Double?,
val trackDeg: Double?
)
sealed class FetchResult {
data class Success(val contacts: List<Contact>) : FetchResult()
data class Failed(val reason: String) : FetchResult()
}
private val endpoints = listOf(
"https://opendata.adsb.fi/api/v2",
"https://api.adsb.lol/v2"
)
suspend fun fetchAround(lat: Double, lon: Double): FetchResult = withContext(Dispatchers.IO) {
var lastError: String? = null
for (base in endpoints) {
val url = "$base/lat/${"%.4f".format(lat)}/lon/${"%.4f".format(lon)}/dist/$QUERY_RADIUS_NM"
when (val r = get(url)) {
is Raw.Ok -> return@withContext FetchResult.Success(parse(r.body))
is Raw.Err -> lastError = r.reason
}
}
FetchResult.Failed(lastError ?: "No ADS-B endpoint reachable")
}
private sealed class Raw {
data class Ok(val body: String) : Raw()
data class Err(val reason: String) : Raw()
}
private fun get(url: String): Raw {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json")
}
return try {
val code = conn.responseCode
if (code in 200..299) {
Raw.Ok(conn.inputStream.bufferedReader().use { it.readText() })
} else {
Log.w(TAG, "$url returned $code")
Raw.Err("HTTP $code")
}
} catch (e: Exception) {
Log.w(TAG, "$url failed: ${e.message}")
Raw.Err(e.message ?: e.javaClass.simpleName)
} finally {
conn.disconnect()
}
}
private fun parse(body: String): List<Contact> {
if (body.isBlank()) return emptyList()
return try {
val root = JSONObject(body)
val arr = root.optJSONArray("ac") ?: root.optJSONArray("aircraft") ?: return emptyList()
val out = ArrayList<Contact>(arr.length())
for (i in 0 until arr.length()) {
val a = arr.optJSONObject(i) ?: continue
val hex = a.optString("hex").trim().lowercase()
if (hex.length != 6) continue
val lat = a.optDouble("lat", Double.NaN)
val lon = a.optDouble("lon", Double.NaN)
if (lat.isNaN() || lon.isNaN()) continue
out.add(
Contact(
icaoHex = hex,
callsign = a.optString("flight").trim().ifBlank { null },
registration = a.optString("r").trim().ifBlank { null },
type = a.optString("t").trim().ifBlank { null },
lat = lat,
lon = lon,
// alt_baro is "ground" (a string) for parked aircraft,
// so optInt would quietly turn that into 0 ft.
// alt_baro is the string "ground" for parked aircraft,
// so optInt would quietly report that as 0 ft.
altitudeFt = if (a.opt("alt_baro") is Number) a.optInt("alt_baro") else null,
groundSpeedKt = a.optDouble("gs", Double.NaN)
.takeIf { !it.isNaN() },
trackDeg = a.optDouble("track", Double.NaN).takeIf { !it.isNaN() }
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse ADS-B response: ${e.message}")
emptyList()
}
}
}
@@ -0,0 +1,195 @@
package org.soulstone.overwatch.scan
import android.content.Context
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.targets.LeAircraft
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* Watches overhead air traffic for surveillance aircraft.
*
* Two independent signals, because either alone misses real cases:
*
* 1. **Registry match** — the contact's ICAO address is in [LeAircraft].
* Authoritative when it fires, but the bundled list only covers aircraft
* whose registered owner names them: measured 2026-09-17, a 250 nm sweep of
* Washington DC returned 394 aircraft and matched none of 2,760 community
* police/government hexes, while 15 helicopters were aloft.
*
* 2. **Loiter/orbit behaviour** — surveillance aircraft circle; airliners,
* and medevac flights heading somewhere, do not. An aircraft that stays
* inside [LOITER_RADIUS_M] of its own track centroid across several
* minutes, low and slow, is behaving like a surveillance asset whatever
* the registry says. This is what catches the unlisted ones.
*
* A registry hit is reported wherever it is; an unregistered aircraft is only
* reported when it is both loitering *and* close, so ordinary traffic passing
* overhead never raises an alert.
*/
class AircraftScanner(
private val context: Context,
private val store: DetectionStore,
private val locationProvider: LocationProvider,
private val client: AircraftClient = AircraftClient()
) {
companion object {
private const val TAG = "AircraftScanner"
/** Aircraft move fast, but the feed is a courtesy — don't hammer it. */
private const val POLL_INTERVAL_MS = 60_000L
/** Ignore anything further out than this; the query itself is wider. */
private const val MAX_RANGE_M = 15_000f
/** An unregistered aircraft has to be at least this close to report. */
private const val UNKNOWN_MAX_RANGE_M = 8_000f
/** Above this, it's transiting airspace, not watching anything. */
private const val LOW_ALTITUDE_FT = 12_000
/** Surveillance orbits are slow; airliners are not. */
private const val SLOW_GROUND_SPEED_KT = 200.0
/** Track history older than this is dropped. */
private const val HISTORY_TTL_MS = 15L * 60L * 1000L
/** Loiter test: samples must span at least this long… */
private const val LOITER_MIN_SPAN_MS = 4L * 60L * 1000L
/** …and stay within this radius of their own centroid. */
private const val LOITER_RADIUS_M = 5_000f
private const val LOITER_MIN_SAMPLES = 3
}
private data class Sample(val lat: Double, val lon: Double, val atMs: Long)
private var job: Job? = null
/** Per-aircraft recent track, for the loiter test. Cleared on stop. */
private val history = mutableMapOf<String, MutableList<Sample>>()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
locationProvider.location.first { it != null }
while (isActive) {
locationProvider.location.value?.let { pollOnce(it) }
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "AircraftScanner started (interval=${POLL_INTERVAL_MS}ms)")
return true
}
fun stop() {
job?.cancel()
job = null
history.clear()
Log.i(TAG, "AircraftScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is AircraftClient.FetchResult.Failed -> SourceHealth.record(
DetectionSource.AIRCRAFT, ok = false,
message = "ADS-B feed unreachable: ${result.reason}"
)
is AircraftClient.FetchResult.Success -> {
SourceHealth.record(DetectionSource.AIRCRAFT, ok = true)
handle(fix, result.contacts)
}
}
}
private fun handle(fix: Location, contacts: List<AircraftClient.Contact>) {
val now = System.currentTimeMillis()
recordHistory(contacts, now)
val out = FloatArray(1)
for (c in contacts) {
Location.distanceBetween(fix.latitude, fix.longitude, c.lat, c.lon, out)
val ground = out[0]
if (ground > MAX_RANGE_M) continue
val known = LeAircraft.lookup(context, c.icaoHex)
val low = (c.altitudeFt ?: Int.MAX_VALUE) <= LOW_ALTITUDE_FT
val slow = (c.groundSpeedKt ?: Double.MAX_VALUE) <= SLOW_GROUND_SPEED_KT
val loitering = low && slow && isLoitering(c.icaoHex, now)
// An unknown aircraft is only interesting if it is behaving like a
// surveillance asset AND is overhead; otherwise it's just traffic.
if (known == null && !(loitering && ground <= UNKNOWN_MAX_RANGE_M)) continue
val obs = ConfidenceEngine.AircraftObservation(
icaoHex = c.icaoHex,
distanceMeters = ground,
altitudeFt = c.altitudeFt,
isKnownLawEnforcement = known != null,
isLoitering = loitering,
isLadd = known?.ladd == true,
owner = known?.owner,
registration = c.registration,
aircraftType = c.type,
callsign = c.callsign
)
val scored = ConfidenceEngine.scoreAircraft(obs)
store.submit(
DetectionEvent(
source = DetectionSource.AIRCRAFT,
key = "air:${c.icaoHex}",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = c.lat,
lon = c.lon
)
)
}
}
private fun recordHistory(contacts: List<AircraftClient.Contact>, now: Long) {
for (c in contacts) {
history.getOrPut(c.icaoHex) { mutableListOf() }
.add(Sample(c.lat, c.lon, now))
}
val cutoff = now - HISTORY_TTL_MS
val it = history.entries.iterator()
while (it.hasNext()) {
val e = it.next()
e.value.removeAll { s -> s.atMs < cutoff }
if (e.value.isEmpty()) it.remove()
}
}
/**
* True when the aircraft's recent track stays bunched around its own
* centroid — i.e. it is going round rather than going somewhere.
*/
private fun isLoitering(hex: String, now: Long): Boolean {
val samples = history[hex] ?: return false
if (samples.size < LOITER_MIN_SAMPLES) return false
val span = now - samples.first().atMs
if (span < LOITER_MIN_SPAN_MS) return false
val cLat = samples.sumOf { it.lat } / samples.size
val cLon = samples.sumOf { it.lon } / samples.size
val out = FloatArray(1)
for (s in samples) {
Location.distanceBetween(cLat, cLon, s.lat, s.lon, out)
if (out[0] > LOITER_RADIUS_M) return false
}
return true
}
}
@@ -1,168 +0,0 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Public Citizen.com endpoints (originally verified 2026-04-29):
*
* GET /api/incident/trending?lowerLatitude=&upperLatitude=&lowerLongitude=&upperLongitude=&limit=20
* → { "results": ["<incidentId>", ...] }
*
* GET /api/incident/{id}
* → { "title", "level", "ll": [lat, lon], "ts" (ms), "police", "raw", ... }
*
* No auth, no rate-limit headers observed. Be a good citizen (heh) — only fetch
* detail for IDs we haven't already seen.
*
* **Feed retired upstream — re-verified 2026-08-28.** Citizen ended the
* police-dispatch data partnership behind this feed in June 2026 and stubbed the
* public endpoints. They now answer HTTP 200 with no usable payload:
* - `/api/incident/trending` → a bare JSON empty string (`""`)
* - `/api/incident/{id}` → `{}` for every id, real or invented
* - `data.sp0n.io/v1/incidents/trending` (the host the current web app uses)
* → 200 with a zero-byte body, even with no query params at all
* Every sibling path (`nearby`, `recent`, `latest`, `map`, `list`, `active`)
* returns `{}`, so this is a decommissioned surface, not a changed contract.
* Structured incident data is now Citizen's paid Enterprise API only.
*
* That `""` is why this source used to surface a raw Java parse error: passing
* an empty JSON string to JSONObject throws "Value of type java.lang.String
* cannot be converted to JSONObject". [TrendingResult.Retired] now models the
* stub explicitly so the UI can say what actually happened, and so the scanner
* can back off instead of polling a dead endpoint every 60 s.
*/
class CitizenClient {
companion object {
private const val TAG = "CitizenClient"
private const val BASE = "https://citizen.com/api/incident"
private const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/121.0.0.0 Mobile Safari/537.36"
private const val TIMEOUT_MS = 10_000
/** Bounding-box half-width in degrees — ~5.5 km N-S, varies E-W. */
private const val BBOX_HALF_DEG = 0.05
private const val LIMIT = 30
}
data class Incident(
val id: String,
val title: String,
val level: Int,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val precinct: String?
)
sealed class TrendingResult {
data class Success(val ids: List<String>) : TrendingResult()
/** HTTP 200 carrying no usable payload — the retired-stub signature
* described in the class KDoc. Distinct from [Failed] (a network or
* HTTP error, which may be transient) and from a [Success] holding an
* empty list (feed alive, genuinely nothing nearby). */
object Retired : TrendingResult()
data class Failed(val reason: String) : TrendingResult()
}
suspend fun trendingNear(lat: Double, lon: Double): TrendingResult = withContext(Dispatchers.IO) {
val top = lat + BBOX_HALF_DEG
val bottom = lat - BBOX_HALF_DEG
val left = lon - BBOX_HALF_DEG
val right = lon + BBOX_HALF_DEG
val url = URL(
"$BASE/trending?lowerLatitude=$bottom&upperLatitude=$top" +
"&lowerLongitude=$left&upperLongitude=$right&limit=$LIMIT"
)
when (val raw = httpGetJson(url)) {
is RawResult.Success -> parseTrending(raw.body)
is RawResult.Failed -> TrendingResult.Failed(raw.reason)
}
}
/**
* A well-formed object carrying a `results` array is the only shape that
* counts as a live feed. Anything else the stubbed endpoint can hand back —
* an empty body, a bare JSON string, a bare `{}`, or unparseable text — is
* reported as [TrendingResult.Retired] rather than thrown, so a dead
* upstream never surfaces as a JSON exception in the drill-down.
*/
private fun parseTrending(body: String): TrendingResult {
val trimmed = body.trim()
if (trimmed.isEmpty()) return TrendingResult.Retired
val root = try {
JSONObject(trimmed)
} catch (e: Exception) {
Log.w(TAG, "trending returned non-object payload (${trimmed.take(40)})")
return TrendingResult.Retired
}
// Key absent → stub. Key present but empty → feed alive, nothing nearby.
val arr = root.optJSONArray("results") ?: return TrendingResult.Retired
val out = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) {
val id = arr.optString(i)
if (id.isNotBlank()) out.add(id)
}
return TrendingResult.Success(out)
}
/** Returns null on any failure (parse, network, missing fields). */
suspend fun fetchIncident(id: String): Incident? = withContext(Dispatchers.IO) {
val url = URL("$BASE/$id")
val body = (httpGetJson(url) as? RawResult.Success)?.body ?: return@withContext null
try {
val o = JSONObject(body)
val ll = o.optJSONArray("ll")
val lat = ll?.optDouble(0) ?: o.optDouble("latitude")
val lon = ll?.optDouble(1) ?: o.optDouble("longitude")
if (lat.isNaN() || lon.isNaN()) return@withContext null
Incident(
id = id,
title = o.optString("title").ifBlank { "Citizen incident" },
level = o.optInt("level", 0),
lat = lat,
lon = lon,
pubMillis = o.optLong("ts", System.currentTimeMillis()),
precinct = o.optString("police").ifBlank { null }
)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Citizen incident $id: ${e.message}")
null
}
}
private sealed class RawResult {
data class Success(val body: String) : RawResult()
data class Failed(val reason: String) : RawResult()
}
private fun httpGetJson(url: URL): RawResult {
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json,*/*")
}
return try {
val code = conn.responseCode
if (code in 200..299) {
RawResult.Success(conn.inputStream.bufferedReader().use { it.readText() })
} else {
Log.w(TAG, "$url returned $code")
RawResult.Failed("HTTP $code")
}
} catch (e: Exception) {
Log.w(TAG, "$url failed: ${e.message}")
RawResult.Failed(e.message ?: e.javaClass.simpleName)
} finally {
conn.disconnect()
}
}
}
@@ -1,195 +0,0 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* Polls Citizen.com for nearby active incidents, filters out pure fire/medical
* (no police presence implied), and submits a detection event for each
* remaining incident inside [proximityMeters] and younger than [MAX_AGE_MS].
*
* Detail responses are cached in-memory by incident id for the life of the
* scanner — Citizen incidents don't mutate after creation, so we only need to
* fetch each id once per session.
*/
class CitizenScanner(
private val store: DetectionStore,
private val locationProvider: LocationProvider,
private val client: CitizenClient = CitizenClient(),
private val proximityMeters: () -> Float = { 500f }
) {
companion object {
private const val TAG = "CitizenScanner"
private const val POLL_INTERVAL_MS = 60_000L
/** Poll interval once the feed has answered with the retired-stub shape.
* Citizen shut the public feed down in June 2026 (see [CitizenClient]),
* so this is almost certainly permanent — but it's cheap to keep a slow
* heartbeat so the source recovers on its own if it ever returns. */
private const val RETIRED_RETRY_MS = 30L * 60L * 1000L
private const val MAX_AGE_MS = 30L * 60L * 1000L
/** Skip incidents whose title is purely fire/medical with no police implication. */
private val FIRE_MEDICAL_RX = Regex(
"\\b(fire|smoke|gas\\s+(odor|leak)|medical|cardiac|ambulance|" +
"ems|injury|alarm|odor)\\b",
RegexOption.IGNORE_CASE
)
/** Title contains an explicit police-action keyword → score bump. */
private val POLICE_TITLE_RX = Regex(
"\\b(police|officer|patrol|arrest|swat|tactical|raid|pursuit|" +
"stop|search\\s+warrant)\\b",
RegexOption.IGNORE_CASE
)
}
private var job: Job? = null
/** Detail cache for the lifetime of one start/stop cycle. */
private val incidentCache = mutableMapOf<String, CitizenClient.Incident>()
/** Set once the feed answers with the retired-stub shape; slows the poll. */
private var feedRetired = false
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
// Wait for the first non-null location fix so the first poll fires
// immediately when location arrives, instead of after a 60 s delay.
locationProvider.location.first { it != null }
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) pollOnce(fix)
delay(if (feedRetired) RETIRED_RETRY_MS else POLL_INTERVAL_MS)
}
}
Log.i(TAG, "CitizenScanner started (interval=${POLL_INTERVAL_MS}ms)")
return true
}
fun stop() {
job?.cancel()
job = null
incidentCache.clear()
feedRetired = false
Log.i(TAG, "CitizenScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
when (val trending = client.trendingNear(fix.latitude, fix.longitude)) {
is CitizenClient.TrendingResult.Retired -> {
// Upstream shutdown, not a fault on this device — say so plainly
// so the row doesn't read like a fixable connectivity problem.
if (!feedRetired) {
feedRetired = true
Log.w(TAG, "Citizen public feed retired — backing off to ${RETIRED_RETRY_MS}ms")
}
SourceHealth.record(
DetectionSource.CITIZEN,
ok = false,
message = "Citizen public feed retired upstream (June 2026) — no data available"
)
return
}
is CitizenClient.TrendingResult.Failed -> {
SourceHealth.record(
DetectionSource.CITIZEN,
ok = false,
message = "Citizen unreachable: ${trending.reason}"
)
return
}
is CitizenClient.TrendingResult.Success -> {
feedRetired = false
SourceHealth.record(DetectionSource.CITIZEN, ok = true)
handleIds(fix, trending.ids)
}
}
}
private suspend fun handleIds(fix: Location, ids: List<String>) {
// 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 (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)) {
continue
}
val age = now - incident.pubMillis
if (age > MAX_AGE_MS) continue
Location.distanceBetween(
fix.latitude, fix.longitude,
incident.lat, incident.lon,
out
)
val dist = out[0]
if (dist > limit) continue
val obs = ConfidenceEngine.CitizenObservation(
incidentId = incident.id,
distanceMeters = dist,
ageMs = age,
level = incident.level,
title = incident.title,
isPoliceTitled = POLICE_TITLE_RX.containsMatchIn(incident.title),
precinct = incident.precinct
)
val scored = ConfidenceEngine.scoreCitizen(obs)
store.submit(
DetectionEvent(
source = DetectionSource.CITIZEN,
key = "citizen:${incident.id}",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = incident.lat,
lon = incident.lon
)
)
}
}
}
@@ -17,9 +17,16 @@ import org.json.JSONObject
* gated behind Cloudflare bot mitigation that we cannot pass from a mobile HTTP * gated behind Cloudflare bot mitigation that we cannot pass from a mobile HTTP
* client. * client.
* *
* Covers three kinds of mapped fixed surveillance, not just ALPR — see [Kind].
* Measured node counts in a 5 km bbox (2026-09-17): northern Virginia
* ALPR 166 / speed 31 / all-surveillance 189; Manhattan ALPR 292 / speed 12 /
* all-surveillance 515. So pulling every `man_made=surveillance` node roughly
* doubles the worst-case payload rather than exploding it, which is why the
* generic camera class is worth carrying.
*
* Strategy: * Strategy:
* - POST an Overpass-QL query for `man_made=surveillance + surveillance:type=ALPR` * - POST one Overpass-QL union for `man_made=surveillance` (ALPR is a subtype)
* inside a small bbox around the user. * and `highway=speed_camera` inside a small bbox around the user.
* - Try `overpass.deflock.org` first (less rate-limited for this use case), * - Try `overpass.deflock.org` first (less rate-limited for this use case),
* fall back to public `overpass-api.de`. * fall back to public `overpass-api.de`.
* - Cache the JSON response on disk by 0.05° grid cell (24h TTL). Revisits to * - Cache the JSON response on disk by 0.05° grid cell (24h TTL). Revisits to
@@ -40,17 +47,28 @@ class DeflockClient(context: Context) {
) )
} }
data class AlprPoint( /**
* What a mapped node actually is. These are scored very differently: an
* ALPR reads and records your plate into a searchable network, a speed
* camera is fixed enforcement that only matters if you're speeding, and a
* generic `man_made=surveillance` node is as likely to be a shop's CCTV as
* anything aimed at the street. Lumping them together would let a corner
* store's camera raise the same alarm as a Flock installation.
*/
enum class Kind { ALPR, SPEED_CAMERA, CAMERA }
data class SurveillancePoint(
val id: Long, val id: Long,
val lat: Double, val lat: Double,
val lon: Double, val lon: Double,
val kind: Kind = Kind.ALPR,
val operator: String? = null, val operator: String? = null,
val manufacturer: String? = null val manufacturer: String? = null
) )
/** Outcome of a fetch — distinguishes "no ALPRs in area" from "couldn't reach the API." */ /** Outcome of a fetch — distinguishes "no ALPRs in area" from "couldn't reach the API." */
sealed class FetchResult { sealed class FetchResult {
data class Success(val points: List<AlprPoint>) : FetchResult() data class Success(val points: List<SurveillancePoint>) : FetchResult()
data class Failed(val reason: String) : FetchResult() data class Failed(val reason: String) : FetchResult()
} }
@@ -85,7 +103,9 @@ class DeflockClient(context: Context) {
// same cache key, so micro-movements don't refetch. // same cache key, so micro-movements don't refetch.
val latStep = floor(lat / FETCH_RADIUS_DEG).toInt() val latStep = floor(lat / FETCH_RADIUS_DEG).toInt()
val lonStep = floor(lon / FETCH_RADIUS_DEG).toInt() val lonStep = floor(lon / FETCH_RADIUS_DEG).toInt()
return "deflock_${latStep}_${lonStep}" // v2 prefix: v1 cached ALPR-only responses, which would otherwise be
// served for up to 24 h after this build widened the query.
return "deflock2_${latStep}_${lonStep}"
} }
private fun cachedJson(key: String): String? { private fun cachedJson(key: String): String? {
@@ -95,10 +115,15 @@ class DeflockClient(context: Context) {
return try { f.readText() } catch (e: Exception) { null } return try { f.readText() } catch (e: Exception) { null }
} }
private fun buildQuery(south: Double, west: Double, north: Double, east: Double): String = private fun buildQuery(south: Double, west: Double, north: Double, east: Double): String {
"[out:json][timeout:$OVERPASS_QUERY_TIMEOUT_S];" + val bbox = "($south,$west,$north,$east)"
"(node[\"man_made\"=\"surveillance\"][\"surveillance:type\"=\"ALPR\"]" + // man_made=surveillance is the superset that already contains the
"($south,$west,$north,$east););out body;" // surveillance:type=ALPR nodes, so asking for ALPR separately would
// just duplicate them; the parser sorts the classes out by tag.
return "[out:json][timeout:$OVERPASS_QUERY_TIMEOUT_S];" +
"(node[\"man_made\"=\"surveillance\"]$bbox;" +
"node[\"highway\"=\"speed_camera\"]$bbox;);out body;"
}
/** Try each endpoint in order until one returns 2xx. Returns body + last error message. */ /** Try each endpoint in order until one returns 2xx. Returns body + last error message. */
private fun downloadFromAny(query: String): Pair<String?, String?> { private fun downloadFromAny(query: String): Pair<String?, String?> {
@@ -159,12 +184,12 @@ class DeflockClient(context: Context) {
lower.contains("rate_limited") lower.contains("rate_limited")
} }
private fun parseSafely(json: String): List<AlprPoint> { private fun parseSafely(json: String): List<SurveillancePoint> {
if (json.isBlank()) return emptyList() if (json.isBlank()) return emptyList()
return try { return try {
val root = JSONObject(json) val root = JSONObject(json)
val elements = root.optJSONArray("elements") ?: return emptyList() val elements = root.optJSONArray("elements") ?: return emptyList()
val out = ArrayList<AlprPoint>(elements.length()) val out = ArrayList<SurveillancePoint>(elements.length())
for (i in 0 until elements.length()) { for (i in 0 until elements.length()) {
val el = elements.optJSONObject(i) ?: continue val el = elements.optJSONObject(i) ?: continue
if (el.optString("type") != "node") continue if (el.optString("type") != "node") continue
@@ -172,11 +197,19 @@ class DeflockClient(context: Context) {
val lon = el.optDouble("lon") val lon = el.optDouble("lon")
if (lat.isNaN() || lon.isNaN()) continue if (lat.isNaN() || lon.isNaN()) continue
val tags = el.optJSONObject("tags") val tags = el.optJSONObject("tags")
val surveillanceType = tags?.optString("surveillance:type").orEmpty()
val kind = when {
surveillanceType.equals("ALPR", ignoreCase = true) -> Kind.ALPR
tags?.optString("highway").orEmpty()
.equals("speed_camera", ignoreCase = true) -> Kind.SPEED_CAMERA
else -> Kind.CAMERA
}
out.add( out.add(
AlprPoint( SurveillancePoint(
id = el.optLong("id", 0L), id = el.optLong("id", 0L),
lat = lat, lat = lat,
lon = lon, lon = lon,
kind = kind,
operator = tags?.optString("operator")?.ifBlank { null } operator = tags?.optString("operator")?.ifBlank { null }
?: tags?.optString("surveillance:operator")?.ifBlank { null }, ?: tags?.optString("surveillance:operator")?.ifBlank { null },
manufacturer = tags?.optString("manufacturer")?.ifBlank { null } manufacturer = tags?.optString("manufacturer")?.ifBlank { null }
@@ -44,10 +44,10 @@ class DeflockScanner(
private var lastFetchLon: Double? = null private var lastFetchLon: Double? = null
private var lastAttemptMs: Long = 0L private var lastAttemptMs: Long = 0L
private var lastAttemptOk: Boolean = false private var lastAttemptOk: Boolean = false
private val _cachedPoints = MutableStateFlow<List<DeflockClient.AlprPoint>>(emptyList()) private val _cachedPoints = MutableStateFlow<List<DeflockClient.SurveillancePoint>>(emptyList())
/** All ALPR points in the current cell — exposed so the UI map can render them. /** All ALPR points in the current cell — exposed so the UI map can render them.
* Distinct from the proximity-filtered DetectionEvents on [DetectionStore]. */ * Distinct from the proximity-filtered DetectionEvents on [DetectionStore]. */
val cachedPoints: StateFlow<List<DeflockClient.AlprPoint>> = _cachedPoints.asStateFlow() val cachedPoints: StateFlow<List<DeflockClient.SurveillancePoint>> = _cachedPoints.asStateFlow()
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
if (job != null) return true if (job != null) return true
@@ -85,7 +85,7 @@ class DeflockScanner(
SourceHealth.record(DetectionSource.DEFLOCK, ok = true) SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i( Log.i(
TAG, TAG,
"Loaded ${result.points.size} ALPRs around " + "Loaded ${result.points.size} surveillance nodes around " +
"(${fix.latitude}, ${fix.longitude})" "(${fix.latitude}, ${fix.longitude})"
) )
} }
@@ -134,6 +134,7 @@ class DeflockScanner(
val obs = ConfidenceEngine.DeflockObservation( val obs = ConfidenceEngine.DeflockObservation(
osmId = p.id, osmId = p.id,
distanceMeters = dist, distanceMeters = dist,
kind = p.kind,
operator = p.operator, operator = p.operator,
manufacturer = p.manufacturer manufacturer = p.manufacturer
) )
@@ -29,7 +29,7 @@ import org.soulstone.overwatch.fusion.SourceHealth
* clear reason) and skips the network call rather than hammering a 401. * clear reason) and skips the network call rather than hammering a 401.
* *
* The last fetched alert set is cached so [refresh] can re-evaluate against a * The last fetched alert set is cached so [refresh] can re-evaluate against a
* moved proximity slider without a network refetch (mirrors CitizenScanner). * moved proximity slider without a network refetch.
*/ */
class WazeScanner( class WazeScanner(
private val store: DetectionStore, private val store: DetectionStore,
@@ -45,7 +45,8 @@ class WazeScanner(
// real police sightings routinely arrive already 20-30 min old. A 10-min // real police sightings routinely arrive already 20-30 min old. A 10-min
// cutoff (fine for the old direct-live feed) would drop nearly all of // cutoff (fine for the old direct-live feed) would drop nearly all of
// them; 45 min matches what the feed actually serves as "current." // them; 45 min matches what the feed actually serves as "current."
private const val MAX_AGE_MS = 45L * 60L * 1000L /** Shared with ConfidenceEngine, which decays the score across this window. */
private const val MAX_AGE_MS = ConfidenceEngine.WAZE_MAX_AGE_MS
} }
private var job: Job? = null private var job: Job? = null
@@ -99,7 +100,7 @@ class WazeScanner(
// still-present alerts by key (dedup) and lets vanished ones age // still-present alerts by key (dedup) and lets vanished ones age
// out via the store's 5-min TTL. Clearing every poll would briefly // out via the store's 5-min TTL. Clearing every poll would briefly
// drop the tier and re-raise it, double-firing the escalation // drop the tier and re-raise it, double-firing the escalation
// vibration each cycle. (Mirrors CitizenScanner.) // vibration each cycle.
emitProximityEvents(fix, result.alerts) emitProximityEvents(fix, result.alerts)
} }
} }
@@ -34,17 +34,17 @@ import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.AircraftScanner
import org.soulstone.overwatch.scan.BleScanner import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint import org.soulstone.overwatch.scan.DeflockClient.SurveillancePoint
import org.soulstone.overwatch.scan.WazeClient import org.soulstone.overwatch.scan.WazeClient
import org.soulstone.overwatch.scan.WazeScanner import org.soulstone.overwatch.scan.WazeScanner
import org.soulstone.overwatch.scan.WifiScanner import org.soulstone.overwatch.scan.WifiScanner
/** /**
* Foreground service that owns all four scanners (BLE, WiFi, DeFlock, Citizen) * Foreground service that owns all four scanners (BLE, WiFi, DeFlock, Waze)
* and the [DetectionStore]. UI observes companion-object state flows directly. * and the [DetectionStore]. UI observes companion-object state flows directly.
* *
* Responsibilities beyond scanner orchestration: * Responsibilities beyond scanner orchestration:
@@ -75,8 +75,8 @@ class DetectionService : LifecycleService() {
/** Latest ALPR cell cache — UI map renders these as pins. Mirrored from /** Latest ALPR cell cache — UI map renders these as pins. Mirrored from
* the active DeflockScanner while the service is running; cleared on stop. */ * the active DeflockScanner while the service is running; cleared on stop. */
private val _mapPoints = MutableStateFlow<List<AlprPoint>>(emptyList()) private val _mapPoints = MutableStateFlow<List<SurveillancePoint>>(emptyList())
val mapPoints: StateFlow<List<AlprPoint>> = _mapPoints.asStateFlow() val mapPoints: StateFlow<List<SurveillancePoint>> = _mapPoints.asStateFlow()
/** Latest fused location fix — UI map centers on this. */ /** Latest fused location fix — UI map centers on this. */
private val _location = MutableStateFlow<Location?>(null) private val _location = MutableStateFlow<Location?>(null)
@@ -106,22 +106,21 @@ class DetectionService : LifecycleService() {
private lateinit var wifiScanner: WifiScanner private lateinit var wifiScanner: WifiScanner
private lateinit var locationProvider: LocationProvider private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner private lateinit var deflockScanner: DeflockScanner
private lateinit var citizenScanner: CitizenScanner
private lateinit var wazeScanner: WazeScanner private lateinit var wazeScanner: WazeScanner
private lateinit var aircraftScanner: AircraftScanner
private lateinit var overlayManager: OverlayManager private lateinit var overlayManager: OverlayManager
private var pruneJob: Job? = null private var pruneJob: Job? = null
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 deflockProxJob: Job? = null
private var citizenProxJob: Job? = null
private var wazeProxJob: Job? = null private var wazeProxJob: Job? = null
private var overlayJob: Job? = null private var overlayJob: 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
private var citizenStarted = false
private var wazeStarted = false private var wazeStarted = false
private var aircraftStarted = false
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */ /** Last threat tier the notification displayed; tracks upward transitions for vibration. */
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
@@ -133,17 +132,14 @@ class DetectionService : LifecycleService() {
locationProvider = LocationProvider(this) locationProvider = LocationProvider(this)
deflockScanner = DeflockScanner( deflockScanner = DeflockScanner(
store, locationProvider, DeflockClient(this), store, locationProvider, DeflockClient(this),
proximityMeters = { settings.deflockProximityM.value.toFloat() } proximityMeters = { settings.detectionRadiusM.value.toFloat() }
)
citizenScanner = CitizenScanner(
store, locationProvider,
proximityMeters = { settings.citizenProximityM.value.toFloat() }
) )
wazeScanner = WazeScanner( wazeScanner = WazeScanner(
store, locationProvider, store, locationProvider,
client = WazeClient(apiKey = { settings.wazeApiKey.value }), client = WazeClient(apiKey = { settings.wazeApiKey.value }),
proximityMeters = { settings.wazeProximityM.value.toFloat() } proximityMeters = { settings.detectionRadiusM.value.toFloat() }
) )
aircraftScanner = AircraftScanner(this, store, locationProvider)
overlayManager = OverlayManager( overlayManager = OverlayManager(
context = this, context = this,
// User dragged the bubble onto the X — flip the persisted toggle // User dragged the bubble onto the X — flip the persisted toggle
@@ -182,8 +178,8 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope) wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)") if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
} }
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value || val needsLocation = settings.deflockEnabled.value || settings.wazeEnabled.value ||
settings.wazeEnabled.value settings.aircraftEnabled.value
if (needsLocation) { if (needsLocation) {
val locOk = locationProvider.start() val locOk = locationProvider.start()
if (!locOk) { if (!locOk) {
@@ -192,16 +188,17 @@ class DetectionService : LifecycleService() {
if (settings.deflockEnabled.value) { if (settings.deflockEnabled.value) {
deflockScanner.start(lifecycleScope); deflockStarted = true deflockScanner.start(lifecycleScope); deflockStarted = true
} }
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
if (settings.wazeEnabled.value) { if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true wazeScanner.start(lifecycleScope); wazeStarted = true
} }
if (settings.aircraftEnabled.value) {
aircraftScanner.start(lifecycleScope); aircraftStarted = true
}
} }
} }
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted || wazeStarted val anyStarted = bleStarted || wifiStarted || deflockStarted || wazeStarted ||
aircraftStarted
if (!anyStarted) { if (!anyStarted) {
Log.w(TAG, "No scanner started — endScanning + stopSelf") Log.w(TAG, "No scanner started — endScanning + stopSelf")
endScanning() endScanning()
@@ -259,22 +256,14 @@ class DetectionService : LifecycleService() {
// Live re-eval when the user moves a proximity slider. drop(1) skips // 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 StateFlow's initial replay so we don't redundantly clear+re-emit
// the events the scanner just produced from its first handleFix call. // 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() 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() }
}
}
wazeProxJob?.cancel() wazeProxJob?.cancel()
if (wazeStarted) { deflockProxJob = lifecycleScope.launch {
wazeProxJob = lifecycleScope.launch { settings.detectionRadiusM.drop(1).collect {
settings.wazeProximityM.drop(1).collect { wazeScanner.refresh() } if (deflockStarted) deflockScanner.refresh()
if (wazeStarted) wazeScanner.refresh()
} }
} }
@@ -290,15 +279,15 @@ class DetectionService : LifecycleService() {
} }
private fun endScanning() { private fun endScanning() {
if (!_running.value && !bleStarted && !wifiStarted && !deflockStarted && !citizenStarted) { if (!_running.value && !bleStarted && !wifiStarted && !deflockStarted && !wazeStarted) {
return return
} }
_running.value = false _running.value = false
if (bleStarted) { bleScanner.stop(); bleStarted = false } if (bleStarted) { bleScanner.stop(); bleStarted = false }
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false } if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false } if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false } if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
if (aircraftStarted) { aircraftScanner.stop(); aircraftStarted = false }
locationProvider.stop() locationProvider.stop()
store.clear() store.clear()
SourceHealth.reset() SourceHealth.reset()
@@ -307,7 +296,6 @@ class DetectionService : LifecycleService() {
mapPointsJob?.cancel(); mapPointsJob = null mapPointsJob?.cancel(); mapPointsJob = null
locationJob?.cancel(); locationJob = null locationJob?.cancel(); locationJob = null
deflockProxJob?.cancel(); deflockProxJob = null deflockProxJob?.cancel(); deflockProxJob = null
citizenProxJob?.cancel(); citizenProxJob = null
wazeProxJob?.cancel(); wazeProxJob = null wazeProxJob?.cancel(); wazeProxJob = null
overlayJob?.cancel(); overlayJob = null overlayJob?.cancel(); overlayJob = null
overlayManager.hide() overlayManager.hide()
@@ -371,7 +359,7 @@ class DetectionService : LifecycleService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ requires the runtime type to cover every capability the // Android 14+ requires the runtime type to cover every capability the
// service uses. We declare both in the manifest; pass both here so // service uses. We declare both in the manifest; pass both here so
// location-using sources (DeFlock, Citizen) keep working with the // location-using sources (DeFlock, Waze) keep working with the
// screen off. // screen off.
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -3,11 +3,6 @@ package org.soulstone.overwatch.ui
import android.content.Intent import android.content.Intent
import android.location.Location import android.location.Location
import android.net.Uri import android.net.Uri
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -48,7 +43,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -75,10 +69,10 @@ fun MainScreen(
threat: ThreatLevel, threat: ThreatLevel,
score: Int, score: Int,
events: List<DetectionEvent>, events: List<DetectionEvent>,
mapPoints: List<DeflockClient.AlprPoint>, mapPoints: List<DeflockClient.SurveillancePoint>,
userLocation: Location?, userLocation: Location?,
/** Visible radius of the map circle, in meters. Driven by the larger of /** Visible radius of the map circle, in meters. Driven by the larger of
* the DeFlock and Citizen proximity sliders so the user sees the full * the DeFlock and Waze proximity sliders so the user sees the full
* area where a detection could fire. */ * area where a detection could fire. */
mapRadiusMeters: Float, mapRadiusMeters: Float,
onStartStop: () -> Unit, onStartStop: () -> Unit,
@@ -239,7 +233,7 @@ private fun ThreatMapCircle(
level: ThreatLevel, level: ThreatLevel,
animating: Boolean, animating: Boolean,
userLocation: Location?, userLocation: Location?,
mapPoints: List<DeflockClient.AlprPoint>, mapPoints: List<DeflockClient.SurveillancePoint>,
events: List<DetectionEvent>, events: List<DetectionEvent>,
mapRadiusMeters: Float, mapRadiusMeters: Float,
onTap: () -> Unit onTap: () -> Unit
@@ -255,16 +249,11 @@ private fun ThreatMapCircle(
// muted gray when idle. Gives the current level at a glance even over the map. // muted gray when idle. Gives the current level at a glance even over the map.
val ringColor = if (animating) activeColor else idleColor val ringColor = if (animating) activeColor else idleColor
val transition = rememberInfiniteTransition(label = "pulse") // No pulse value is read in this scope on purpose — see PulseVisuals.kt.
val pulse by transition.animateFloat( // Reading the infinite animation here recomposed the map host every frame,
initialValue = if (animating) 0.5f else 1.0f, // which re-ran the AndroidView update block (clearing and reallocating
targetValue = 1.0f, // every marker, and queuing a zoom) ~60 times a second.
animationSpec = infiniteRepeatable( val camera = remember { MapCamera() }
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Reverse
),
label = "pulse"
)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -278,33 +267,13 @@ private fun ThreatMapCircle(
// solid pulsing circle — a blank/loading map mid-tile-fetch reads as // solid pulsing circle — a blank/loading map mid-tile-fetch reads as
// broken. The map only renders once we actually have something to show. // broken. The map only renders once we actually have something to show.
if (!animating || userLocation == null) { if (!animating || userLocation == null) {
val color = if (animating) activeColor else idleColor PulsingDisc(
val alpha = if (animating) pulse else 1.0f color = if (animating) activeColor else idleColor,
Box( animating = animating,
modifier = Modifier label = if (animating) "WAITING FIX" else "IDLE",
.fillMaxSize() labelColor = if (animating) Color.White
.background( else MaterialTheme.colorScheme.onSurfaceVariant
Brush.radialGradient(
colors = listOf(
color.copy(alpha = alpha),
color.copy(alpha = alpha * 0.6f)
) )
)
),
contentAlignment = Alignment.Center
) {
val labelText = when {
!animating -> "IDLE"
else -> "WAITING FIX"
}
Text(
text = labelText,
color = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 22.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
)
}
} else { } else {
// OSM map snapshot, centered on the user, with red ALPR pins and // OSM map snapshot, centered on the user, with red ALPR pins and
// a blue user-position dot. Non-interactive — touches are captured // a blue user-position dot. Non-interactive — touches are captured
@@ -318,8 +287,10 @@ private fun ThreatMapCircle(
// every recomposition — bitmap allocation isn't free. // every recomposition — bitmap allocation isn't free.
val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 46, MARK_USER_WHITE) } val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 46, MARK_USER_WHITE) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) } val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
val speedDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_SPEED_AMBER) }
val cameraDot = remember(ctx) { dotDrawable(ctx.resources, 18, DOT_CAMERA_GRAY) }
val wazeDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_WAZE_BLUE) } val wazeDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_WAZE_BLUE) }
val citizenDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_CITIZEN_PURPLE) } val aircraftDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_AIRCRAFT_VIOLET) }
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { c -> factory = { c ->
@@ -332,18 +303,21 @@ private fun ThreatMapCircle(
} }
}, },
update = { map -> update = { map ->
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear() map.overlays.clear()
// Source dots first (Flock red, Waze blue, Citizen purple), // Source dots first (Flock red, Waze blue),
// user position last so the crosshair always draws on top. // user position last so the crosshair always 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_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = flockDot icon = when (p.kind) {
title = p.operator ?: p.manufacturer ?: "ALPR" DeflockClient.Kind.ALPR -> flockDot
DeflockClient.Kind.SPEED_CAMERA -> speedDot
DeflockClient.Kind.CAMERA -> cameraDot
}
title = p.operator ?: p.manufacturer ?: p.kind.name
setInfoWindow(null) setInfoWindow(null)
} }
) )
@@ -353,7 +327,7 @@ private fun ThreatMapCircle(
val lon = e.lon ?: continue val lon = e.lon ?: continue
val dot = when (e.source) { val dot = when (e.source) {
DetectionSource.WAZE -> wazeDot DetectionSource.WAZE -> wazeDot
DetectionSource.CITIZEN -> citizenDot DetectionSource.AIRCRAFT -> aircraftDot
else -> null else -> null
} ?: continue } ?: continue
map.overlays.add( map.overlays.add(
@@ -375,11 +349,12 @@ private fun ThreatMapCircle(
} }
) )
// Fit the visible radius to the larger of the two proximity // Move the camera only when the position or the radius
// settings. Defer to map.post so the call lands after layout // actually changed. zoomToBoundingBox has to be deferred to
// — zoomToBoundingBox needs measured dimensions to compute // map.post because it needs measured dimensions, so issuing
// the right zoom level. Latitude-aware longitude scaling so // one per pass built a backlog of stale zooms that fought
// the bbox stays roughly square in real meters at any lat. // each other — that was the lurching when a slider moved.
if (camera.needsMove(fix.latitude, fix.longitude, mapRadiusMeters)) {
val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0) val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0)
val latDegPerMeter = 1.0 / 111_000.0 val latDegPerMeter = 1.0 / 111_000.0
val lonDegPerMeter = 1.0 / val lonDegPerMeter = 1.0 /
@@ -390,20 +365,16 @@ private fun ThreatMapCircle(
fix.latitude - r * latDegPerMeter, fix.latitude - r * latDegPerMeter,
fix.longitude - r * lonDegPerMeter fix.longitude - r * lonDegPerMeter
) )
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.post { map.zoomToBoundingBox(bbox, false, 0) } map.post { map.zoomToBoundingBox(bbox, false, 0) }
}
map.invalidate() map.invalidate()
}, },
onRelease = { map -> map.onDetach() } onRelease = { map -> map.onDetach() }
) )
// Threat-tier scrim — pulses while scanning. Heavier alpha than // Threat-tier scrim — pulses while scanning, in its own leaf so
// the first cut so the tier color reads at a glance over OSM // the animation never recomposes the map above it.
// tiles, which are themselves cream/light by default. TierScrim(color = activeColor, animating = animating)
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
Box(
modifier = Modifier
.fillMaxSize()
.background(activeColor.copy(alpha = scrimAlpha))
)
} }
// Click capture sits on top so taps reach onTap regardless of which // Click capture sits on top so taps reach onTap regardless of which
// visual layer was painted underneath. // visual layer was painted underneath.
@@ -62,7 +62,9 @@ internal fun crosshairDrawable(
} }
// Per-source marker colors so geodata reads at a glance. // Per-source marker colors so geodata reads at a glance.
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt() // Flock / DeFlock ALPR cameras internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt() // ALPR — reads and records plates
internal const val DOT_SPEED_AMBER = 0xFFFF9800.toInt() // fixed speed / traffic enforcement
internal const val DOT_CAMERA_GRAY = 0xFF9E9E9E.toInt() // generic mapped surveillance camera
internal const val DOT_WAZE_BLUE = 0xFF2196F3.toInt() // Waze police reports internal const val DOT_WAZE_BLUE = 0xFF2196F3.toInt() // Waze police reports
internal const val DOT_CITIZEN_PURPLE = 0xFF9C27B0.toInt() // Citizen incidents internal const val DOT_AIRCRAFT_VIOLET = 0xFF7C4DFF.toInt() // police / surveillance aircraft
internal const val MARK_USER_WHITE = 0xFFFFFFFF.toInt() // the user's own position (⌖) internal const val MARK_USER_WHITE = 0xFFFFFFFF.toInt() // the user's own position (⌖)
@@ -1,10 +1,5 @@
package org.soulstone.overwatch.ui package org.soulstone.overwatch.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -17,7 +12,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -28,6 +22,7 @@ import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
@@ -56,10 +51,8 @@ fun OverlayBubble() {
val userLocation by DetectionService.location.collectAsState() val userLocation by DetectionService.location.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState() val mapPoints by DetectionService.mapPoints.collectAsState()
val events by DetectionService.store.events.collectAsState() val events by DetectionService.store.events.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState() val detectionRadius by settings.detectionRadiusM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState() val radius = detectionRadius.toFloat()
val wazeProx by settings.wazeProximityM.collectAsState()
val radius = max(max(deflockProx, citizenProx), wazeProx).toFloat()
val activeColor = when (threat) { val activeColor = when (threat) {
ThreatLevel.GREEN -> ThreatColors.Green ThreatLevel.GREEN -> ThreatColors.Green
@@ -68,21 +61,16 @@ fun OverlayBubble() {
ThreatLevel.RED -> ThreatColors.Red ThreatLevel.RED -> ThreatColors.Red
} }
val transition = rememberInfiniteTransition(label = "overlay-pulse") // The pulse lives in PulseVisuals' leaves now; reading it here would
val pulse by transition.animateFloat( // recompose the map host every frame.
initialValue = 0.55f, val camera = remember { MapCamera() }
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Reverse
),
label = "overlay-pulse"
)
val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 34, MARK_USER_WHITE) } val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 34, MARK_USER_WHITE) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) } val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) }
val speedDot = remember(ctx) { dotDrawable(ctx.resources, 18, DOT_SPEED_AMBER) }
val cameraDot = remember(ctx) { dotDrawable(ctx.resources, 15, DOT_CAMERA_GRAY) }
val wazeDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_WAZE_BLUE) } val wazeDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_WAZE_BLUE) }
val citizenDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_CITIZEN_PURPLE) } val aircraftDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_AIRCRAFT_VIOLET) }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -95,17 +83,11 @@ fun OverlayBubble() {
// map if a future code path lets the composition outlive the service. // map if a future code path lets the composition outlive the service.
val fix = userLocation val fix = userLocation
if (!running || fix == null) { if (!running || fix == null) {
Box( PulsingDisc(
modifier = Modifier color = activeColor,
.fillMaxSize() animating = running,
.background( label = null,
Brush.radialGradient( labelColor = activeColor
colors = listOf(
activeColor.copy(alpha = pulse),
activeColor.copy(alpha = pulse * 0.6f)
)
)
)
) )
} else { } else {
AndroidView( AndroidView(
@@ -120,15 +102,18 @@ fun OverlayBubble() {
} }
}, },
update = { map -> update = { map ->
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear() map.overlays.clear()
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_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = flockDot icon = when (p.kind) {
title = p.operator ?: p.manufacturer ?: "ALPR" DeflockClient.Kind.ALPR -> flockDot
DeflockClient.Kind.SPEED_CAMERA -> speedDot
DeflockClient.Kind.CAMERA -> cameraDot
}
title = p.operator ?: p.manufacturer ?: p.kind.name
setInfoWindow(null) setInfoWindow(null)
} }
) )
@@ -138,7 +123,7 @@ fun OverlayBubble() {
val lon = e.lon ?: continue val lon = e.lon ?: continue
val dot = when (e.source) { val dot = when (e.source) {
DetectionSource.WAZE -> wazeDot DetectionSource.WAZE -> wazeDot
DetectionSource.CITIZEN -> citizenDot DetectionSource.AIRCRAFT -> aircraftDot
else -> null else -> null
} ?: continue } ?: continue
map.overlays.add( map.overlays.add(
@@ -158,6 +143,8 @@ fun OverlayBubble() {
setInfoWindow(null) setInfoWindow(null)
} }
) )
// Same camera guard as the in-app circle — see MapCamera.
if (camera.needsMove(fix.latitude, fix.longitude, radius)) {
val r = radius.toDouble().coerceAtLeast(50.0) val r = radius.toDouble().coerceAtLeast(50.0)
val latDegPerMeter = 1.0 / 111_000.0 val latDegPerMeter = 1.0 / 111_000.0
val lonDegPerMeter = 1.0 / val lonDegPerMeter = 1.0 /
@@ -168,18 +155,15 @@ fun OverlayBubble() {
fix.latitude - r * latDegPerMeter, fix.latitude - r * latDegPerMeter,
fix.longitude - r * lonDegPerMeter fix.longitude - r * lonDegPerMeter
) )
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.post { map.zoomToBoundingBox(bbox, false, 0) } map.post { map.zoomToBoundingBox(bbox, false, 0) }
}
map.invalidate() map.invalidate()
}, },
onRelease = { map -> map.onDetach() } onRelease = { map -> map.onDetach() }
) )
// Tier scrim — same pulse alpha range as the in-app circle. // Tier scrim — own leaf, so the pulse never recomposes the map.
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f) TierScrim(color = activeColor, animating = running)
Box(
modifier = Modifier
.fillMaxSize()
.background(activeColor.copy(alpha = scrimAlpha))
)
} }
} }
} }
@@ -0,0 +1,118 @@
package org.soulstone.overwatch.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material3.Text
import androidx.compose.ui.unit.sp
/**
* The pulsing threat visuals, deliberately split into their own leaf
* composables.
*
* **Why this file exists.** The pulse is an infinite animation, so anything
* that reads it recomposes on every frame. Both the in-app circle and the
* floating bubble used to read it in the same scope that hosted the map's
* `AndroidView`, which meant the map's `update` block re-ran ~60 times a
* second: it cleared every overlay and reallocated a `Marker` for all of them
* (152 ALPRs in a dense area) each frame, and queued a `zoomToBoundingBox`
* through `map.post` on every one of those passes while `setCenter` fought it.
* That is what made the map flicker and lurch — worst when a proximity slider
* moved, because the new bounding box landed on top of a backlog of stale
* zoom runnables.
*
* Keeping the animated read inside these leaves means the map host recomposes
* only when the fix, the points, the events or the radius actually change.
* Anything that hosts a map must therefore render the pulse through these and
* never read a pulse value in its own scope.
*/
@Composable
private fun rememberPulse(animating: Boolean): State<Float> {
val transition = rememberInfiniteTransition(label = "threat-pulse")
return transition.animateFloat(
initialValue = if (animating) 0.5f else 1.0f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Reverse
),
label = "threat-pulse"
)
}
/** Solid pulsing disc — the idle / waiting-for-fix face of the circle. */
@Composable
internal fun PulsingDisc(
color: Color,
animating: Boolean,
label: String?,
labelColor: Color,
modifier: Modifier = Modifier
) {
val pulse by rememberPulse(animating)
val alpha = if (animating) pulse else 1.0f
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(color.copy(alpha = alpha), color.copy(alpha = alpha * 0.6f))
)
),
contentAlignment = Alignment.Center
) {
if (label != null) {
Text(
text = label,
color = labelColor,
fontSize = 22.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
)
}
}
}
/** Threat-tier scrim laid over the map so the tier reads at a glance. */
@Composable
internal fun TierScrim(color: Color, animating: Boolean, modifier: Modifier = Modifier) {
val pulse by rememberPulse(animating)
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
Box(modifier.fillMaxSize().background(color.copy(alpha = scrimAlpha)))
}
/**
* Remembers the last camera position applied to a [org.osmdroid.views.MapView]
* so the bounding-box zoom is only issued when the center or radius actually
* moved, rather than on every pass through an `AndroidView` update block.
*/
internal class MapCamera {
private var lat = Double.NaN
private var lon = Double.NaN
private var radius = Float.NaN
/** True (and records the new values) when this camera differs from the last applied one. */
fun needsMove(newLat: Double, newLon: Double, newRadius: Float): Boolean {
// Sub-metre jitter on a stationary fix shouldn't re-zoom the map.
val moved = lat.isNaN() ||
kotlin.math.abs(newLat - lat) > 1e-5 ||
kotlin.math.abs(newLon - lon) > 1e-5 ||
radius != newRadius
if (moved) { lat = newLat; lon = newLon; radius = newRadius }
return moved
}
}
@@ -59,12 +59,10 @@ fun SettingsScreen(
val ble by settings.bleEnabled.collectAsState() val ble by settings.bleEnabled.collectAsState()
val wifi by settings.wifiEnabled.collectAsState() val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState() val deflock by settings.deflockEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState() val waze by settings.wazeEnabled.collectAsState()
val aircraft by settings.aircraftEnabled.collectAsState()
val mic by settings.micEnabled.collectAsState() val mic by settings.micEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState() val detectionRadius by settings.detectionRadiusM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val wazeApiKey by settings.wazeApiKey.collectAsState() val wazeApiKey by settings.wazeApiKey.collectAsState()
val theme by settings.themeMode.collectAsState() val theme by settings.themeMode.collectAsState()
val vibrate by settings.vibrateOnAlert.collectAsState() val vibrate by settings.vibrateOnAlert.collectAsState()
@@ -98,8 +96,8 @@ fun SettingsScreen(
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) } SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
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("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) } SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
SourceToggle("AIRCRAFT • Police / surveillance planes", aircraft) { settings.setAircraftEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo, glasses", mic) { settings.setMicEnabled(it) } SourceToggle("COMMERCIAL • Nest, Ring, Echo, glasses", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (isRunning) { if (isRunning) {
@@ -127,27 +125,21 @@ fun SettingsScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionLabel("Proximity thresholds") SectionLabel("Detection radius")
SliderRow( Text(
label = "DeFlock alert distance", "How close a Flock/DeFlock camera or a Waze police report has to be " +
persistedValue = deflockProx, "to count. Also the area drawn on the map.",
range = 50f..1600f, fontSize = 11.sp,
steps = 30, color = MaterialTheme.colorScheme.onSurfaceVariant,
onCommit = { settings.setDeflockProximityM(it) } fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 4.dp)
) )
SliderRow( SliderRow(
label = "Citizen alert distance", label = "Detection radius",
persistedValue = citizenProx, persistedValue = detectionRadius,
range = 100f..5000f, range = Settings.RADIUS_MIN.toFloat()..Settings.RADIUS_MAX.toFloat(),
steps = 48, steps = 48,
onCommit = { settings.setCitizenProximityM(it) } onCommit = { settings.setDetectionRadiusM(it) }
)
SliderRow(
label = "Waze alert distance",
persistedValue = wazeProx,
range = 100f..5000f,
steps = 48,
onCommit = { settings.setWazeProximityM(it) }
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
File diff suppressed because it is too large Load Diff
+27 -27
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OVERWATCH — passive surveillance detection for Android</title> <title>OVERWATCH — passive surveillance detection for Android</title>
<meta name="description" content="A native Android app that passively flags nearby Flock Safety ALPRs, Axon body cameras, and police presence. Fuses BLE/WiFi radio scans, the DeFlock ALPR map, and Citizen/Waze feeds into a green→red threat tier. Listens only — never transmits."> <meta name="description" content="A native Android app that passively flags nearby Flock Safety ALPRs, Axon body cameras, and police presence. Fuses BLE/WiFi radio scans, the DeFlock ALPR map, and live Waze police reports into a green→red threat tier. Listens only — never transmits.">
<meta property="og:title" content="OVERWATCH — passive surveillance detection"> <meta property="og:title" content="OVERWATCH — passive surveillance detection">
<meta property="og:description" content="Android app that flags nearby Flock ALPRs, Axon body cams, and police by fusing BLE/WiFi + the DeFlock map + Citizen/Waze. Passive only — it never transmits."> <meta property="og:description" content="Android app that flags nearby Flock ALPRs, Axon body cams, and police by fusing BLE/WiFi + the DeFlock map + Waze police reports. Passive only — it never transmits.">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://overwatch.netslum.io/"> <meta property="og:url" content="https://overwatch.netslum.io/">
<meta property="og:image" content="https://overwatch.netslum.io/og.png"> <meta property="og:image" content="https://overwatch.netslum.io/og.png">
@@ -17,7 +17,6 @@
<meta name="theme-color" content="#07070d"> <meta name="theme-color" content="#07070d">
<link rel="icon" type="image/svg+xml" href="logo.svg"> <link rel="icon" type="image/svg+xml" href="logo.svg">
<link rel="stylesheet" href="style.css?v=2"> <link rel="stylesheet" href="style.css?v=2">
</head> </head>
<body> <body>
@@ -54,7 +53,7 @@
<div class="container hero-inner"> <div class="container hero-inner">
<div class="hero-eyebrow"> <div class="hero-eyebrow">
<span class="dot dot-pulse"></span> <span class="dot dot-pulse"></span>
v0.5.6 · Android · passive v0.5.8 · Android · passive
</div> </div>
<h1 class="hero-title"> <h1 class="hero-title">
<span class="display-wordmark">OVERWATCH</span> <span class="display-wordmark">OVERWATCH</span>
@@ -76,7 +75,7 @@
<div class="stat-chip"><span class="num" data-target="6">0</span><span>fused sources</span></div> <div class="stat-chip"><span class="num" data-target="6">0</span><span>fused sources</span></div>
<div class="stat-chip stat-vfy"><span class="num" data-target="4">0</span><span>threat tiers</span></div> <div class="stat-chip stat-vfy"><span class="num" data-target="4">0</span><span>threat tiers</span></div>
<div class="stat-chip"><span class="num" data-target="0">0</span><span>packets sent</span></div> <div class="stat-chip"><span class="num" data-target="0">0</span><span>packets sent</span></div>
<div class="stat-chip"><span class="num" data-target="21">0</span><span>releases</span></div> <div class="stat-chip"><span class="num" data-target="23">0</span><span>releases</span></div>
</div> </div>
<div class="cta-row"> <div class="cta-row">
@@ -102,8 +101,8 @@
<ul class="trust-items"> <ul class="trust-items">
<li>Bluetooth-LE + WiFi radio</li> <li>Bluetooth-LE + WiFi radio</li>
<li>DeFlock ALPR map</li> <li>DeFlock ALPR map</li>
<li>Citizen incidents</li>
<li>Waze police alerts</li> <li>Waze police alerts</li>
<li>ADS-B aircraft</li>
<li>OpenStreetMap · Overpass</li> <li>OpenStreetMap · Overpass</li>
</ul> </ul>
</div> </div>
@@ -129,7 +128,7 @@
<div class="feature-split"> <div class="feature-split">
<div class="phone-frame"> <div class="phone-frame">
<img class="phone" src="img/overwatch-sources.png" width="856" height="2000" <img class="phone" src="img/overwatch-sources.png" width="856" height="2000"
alt="Detection-sources drill-down: BLE, WiFi, DeFlock, Citizen, Waze and Commercial rows, each with a color tier dot and the observations that fired it." loading="lazy"> alt="Detection-sources drill-down: BLE, WiFi, DeFlock, Waze and Commercial rows, each with a color tier dot and the observations that fired it." loading="lazy">
</div> </div>
<div class="explain-annotations"> <div class="explain-annotations">
<div class="annotation"> <div class="annotation">
@@ -147,7 +146,8 @@
<strong>Source-color dots</strong> <strong>Source-color dots</strong>
<p><span class="legend-dot red"></span> Flock / DeFlock cameras red · <p><span class="legend-dot red"></span> Flock / DeFlock cameras red ·
<span class="legend-dot blue"></span> Waze police blue · <span class="legend-dot blue"></span> Waze police blue ·
<span class="legend-dot purple"></span> Citizen incidents purple. <span class="legend-dot" style="background:#7C4DFF"></span> aircraft violet ·
user position as a ⌖ crosshair.
Tap any row for coordinates + a Maps deep-link.</p> Tap any row for coordinates + a Maps deep-link.</p>
</div> </div>
</div> </div>
@@ -202,6 +202,21 @@
</p> </p>
</article> </article>
<article class="bento-card bento-lg">
<div class="bento-icon">✈️</div>
<h3>Aircraft — eyes above</h3>
<p>
Police and surveillance aircraft overhead, from the free community
<a href="https://adsb.fi">ADS-B</a> networks — used specifically
because the commercial trackers filter law-enforcement flights at
government request. Contacts are matched by ICAO address against a
bundled registry of <strong>1,971 US law-enforcement airframes</strong>,
and scored by distance, altitude and <em>orbit behaviour</em> — because
surveillance aircraft circle, and airliners don't. An unlisted aircraft
loitering low and slow overhead still registers.
</p>
</article>
<article class="bento-card"> <article class="bento-card">
<div class="bento-icon">📡</div> <div class="bento-icon">📡</div>
<h3>WiFi</h3> <h3>WiFi</h3>
@@ -224,20 +239,6 @@
</p> </p>
</article> </article>
<article class="bento-card">
<div class="bento-icon">🚨</div>
<h3>Citizen <span style="font-size:.72em;opacity:.75">— retired upstream</span></h3>
<p>
Real-time public-safety incidents — police-relevant only,
fire/medical filtered out — within range and under 30 min old.
Citizen ended the police-dispatch partnership behind its public feed
in <strong>June 2026</strong> and stubbed the endpoints, so this
source now reports the shutdown plainly and backs off instead of
polling a dead API. Police presence is still covered by Waze and
DeFlock.
</p>
</article>
<article class="bento-card bento-lg"> <article class="bento-card bento-lg">
<div class="bento-icon">🚓</div> <div class="bento-icon">🚓</div>
<h3>Waze — police alerts</h3> <h3>Waze — police alerts</h3>
@@ -319,7 +320,7 @@
</figure> </figure>
<figure class="shot"> <figure class="shot">
<img class="phone" src="img/overwatch-sources.png" width="856" height="2000" <img class="phone" src="img/overwatch-sources.png" width="856" height="2000"
alt="Detection-sources drill-down: BLE, WiFi, DeFlock, Citizen, Waze and Commercial rows, each with a color tier dot and the observations that fired it." loading="lazy"> alt="Detection-sources drill-down: BLE, WiFi, DeFlock, Waze and Commercial rows, each with a color tier dot and the observations that fired it." loading="lazy">
<figcaption><strong>Source drill-down</strong>Tap the ring for every source, its live tier, and the exact observation that fired it.</figcaption> <figcaption><strong>Source drill-down</strong>Tap the ring for every source, its live tier, and the exact observation that fired it.</figcaption>
</figure> </figure>
<figure class="shot"> <figure class="shot">
@@ -423,7 +424,7 @@
<div class="feature-split"> <div class="feature-split">
<div class="phone-frame"> <div class="phone-frame">
<img class="phone" src="img/overwatch-settings.png" width="856" height="2000" <img class="phone" src="img/overwatch-settings.png" width="856" height="2000"
alt="Settings screen: per-source toggles (BLE, WiFi, DeFlock, Citizen, Waze, Commercial), proximity distance sliders, and the encrypted Waze API key field." loading="lazy"> alt="Settings screen: per-source toggles (BLE, WiFi, DeFlock, Waze, Commercial), proximity distance sliders, and the encrypted Waze API key field." loading="lazy">
</div> </div>
<div class="timeline dl-steps"> <div class="timeline dl-steps">
<div class="tl-col tl-shipped"> <div class="tl-col tl-shipped">
@@ -431,7 +432,7 @@
<ul> <ul>
<li>Grab the latest debug-signed APK from <li>Grab the latest debug-signed APK from
<a href="https://github.com/KaraZajac/OVERWATCH/releases/latest">Releases</a> <a href="https://github.com/KaraZajac/OVERWATCH/releases/latest">Releases</a>
(currently <strong>v0.5.6</strong>).</li> (currently <strong>v0.5.8</strong>).</li>
</ul> </ul>
</div> </div>
<div class="tl-col tl-active"> <div class="tl-col tl-active">
@@ -482,7 +483,6 @@
<h4>Signal from</h4> <h4>Signal from</h4>
<ul> <ul>
<li><a href="https://deflock.me">DeFlock ALPR map</a></li> <li><a href="https://deflock.me">DeFlock ALPR map</a></li>
<li><a href="https://citizen.com">Citizen</a></li>
<li><a href="https://www.waze.com">Waze</a></li> <li><a href="https://www.waze.com">Waze</a></li>
<li><a href="https://www.openstreetmap.org">OpenStreetMap</a></li> <li><a href="https://www.openstreetmap.org">OpenStreetMap</a></li>
</ul> </ul>
@@ -501,7 +501,7 @@
transmit, probe, jam, or interfere with any device or network. transmit, probe, jam, or interfere with any device or network.
</p> </p>
<p class="footer-meta"> <p class="footer-meta">
v0.5.6 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a> v0.5.8 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
</p> </p>
<div class="onion-line"> <div class="onion-line">
<span class="onion-label">Also on Tor</span> <span class="onion-label">Also on Tor</span>
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Feed a location to an Android emulator, for testing OVERWATCH's
# location-driven sources (DeFlock, Waze).
#
# scripts/emu-gps.sh <lat> <lon> [serial] [seconds]
# scripts/emu-gps.sh 38.8324 -77.1062 # Springfield VA, 120s
# scripts/emu-gps.sh 40.7128 -74.0060 emulator-5558 300
#
# Why not `adb emu geo fix`: it returns OK and frequently changes nothing.
# Verified 2026-09-16 on emulator 37.1.11.0 — a fresh AVD and a 2-day-old one
# both stayed pinned at a stale Northern California fix through dozens of
# `geo fix` calls and raw `geo nmea` sentences, every one answered OK. The
# same trap is recorded in the DUCAT project's notes. Android's own test
# provider bypasses the emulator console entirely and lands immediately.
#
# Two things have to be true or the app still sees nothing:
# 1. `appops set --uid 0` (NOT `--uid shell`, which fails with
# "uid 2000 not allowed to perform MOCK_LOCATION").
# 2. Something must actually engage the GPS provider. OVERWATCH requests
# PRIORITY_HIGH_ACCURACY, so starting a scan is enough; with a BALANCED
# request Play services parks the fused provider at ProviderRequest[OFF]
# and no fix is ever delivered.
#
# Note the argument orders differ between tools and it is easy to transpose:
# this script and `set-test-provider-location` take LAT,LON; `geo fix` takes
# LON first. Verify with:
# adb -s <serial> shell dumpsys location | grep -m1 "last location"
set -e
LAT=${1:?usage: emu-gps.sh <lat> <lon> [serial] [seconds]}
LON=${2:?usage: emu-gps.sh <lat> <lon> [serial] [seconds]}
SERIAL=${3:-emulator-5558}
SECONDS_TOTAL=${4:-120}
export PATH=$PATH:${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools
adb -s "$SERIAL" shell settings put secure location_mode 3 >/dev/null 2>&1 || true
adb -s "$SERIAL" shell appops set --uid 0 android:mock_location allow >/dev/null 2>&1 || true
adb -s "$SERIAL" shell cmd location providers add-test-provider gps >/dev/null 2>&1 || true
adb -s "$SERIAL" shell cmd location providers set-test-provider-enabled gps true >/dev/null 2>&1 || true
echo "feeding $LAT,$LON to $SERIAL for ${SECONDS_TOTAL}s (every 3s)…"
END=$((SECONDS + SECONDS_TOTAL))
while [ $SECONDS -lt $END ]; do
# A test-provider location is one-shot; re-send so it never ages out from
# under a scanner that only polls every few minutes.
adb -s "$SERIAL" shell cmd location providers set-test-provider-location gps \
--location "$LAT,$LON" >/dev/null 2>&1 || true
sleep 3
done
echo "done. verify: adb -s $SERIAL shell dumpsys location | grep -m1 'last location'"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Regenerate app/src/main/res/raw/le_aircraft.csv — the bundled list of
law-enforcement aircraft, keyed by the ICAO hex that ADS-B actually broadcasts.
python3 scripts/gen-le-aircraft.py
Source: ADSBexchange's `basic-ac-db.json.gz` (~14.5 MB, ~618k aircraft), which
is derived from the FAA registry and already keyed by ICAO hex. That matters:
registry.faa.gov itself is behind Akamai bot mitigation and answers 403, and
the raw FAA registry is keyed by N-number, which would need a separate
N-number -> hex conversion step.
Why not the community plane-alert-db lists: measured 2026-09-17, their
plane-alert-pol.csv holds 996 aircraft but only 255 US-registered, and matching
all 2,760 of their police+government hexes against 394 live aircraft over a
250 nm sweep of Washington DC produced zero hits. Filtering this database by
owner name yields ~1,950 US law-enforcement aircraft — roughly 7.7x the US
coverage.
This runs at development time and commits its output; the app never downloads
it. Re-run occasionally to pick up registry changes.
"""
import gzip, io, json, re, sys, urllib.request
from pathlib import Path
SRC = "https://downloads.adsbexchange.com/downloads/basic-ac-db.json.gz"
OUT = Path(__file__).resolve().parent.parent / "app/src/main/res/raw/le_aircraft.csv"
# Owner-name patterns. Word boundaries matter: a bare "MARSHAL" matches
# "MARSHALL SPACE FLIGHT CENTER", and a bare "POLICE" would be fine but
# "SHERIFF" needs to catch both "SHERIFF" and "SHERIFFS".
LE = re.compile(r"(\bSHERIFF|\bPOLICE\b|\bSTATE PATROL\b|\bHIGHWAY PATROL\b|"
r"\bSTATE TROOPER|\bPUBLIC SAFETY\b|\bUS MARSHAL\b|"
r"\bMARSHALS SERVICE\b|\bCONSTABLE\b|\bDEPT OF CORRECTION|"
r"\bDEPARTMENT OF CORRECTION|\bBORDER PATROL\b|"
r"\bCUSTOMS AND BORDER|\bCUSTOMS & BOR|\bHOMELAND SECURITY\b|"
r"\bDRUG ENFORCEMENT\b|\bFEDERAL BUREAU OF INVESTIGATION\b)", re.I)
# Air-ambulance and firefighting outfits share a lot of vocabulary with police
# aviation ("PUBLIC SAFETY"), and a medevac helicopter overhead is not the
# thing this app exists to warn about.
EXCLUDE = re.compile(r"MARSHALL SPACE|\bFIRE\b|AMBULANCE|MEDICAL|\bEMS\b|"
r"AIR METHODS|LIFE FLIGHT|MEDEVAC|HOSPITAL", re.I)
def main() -> int:
print(f"fetching {SRC} …")
req = urllib.request.Request(SRC, headers={"User-Agent": "OVERWATCH-build/1.0"})
with urllib.request.urlopen(req, timeout=180) as r:
raw = gzip.decompress(r.read())
rows, ladd, seen = [], 0, 0
for line in io.BytesIO(raw):
line = line.decode("utf-8", "replace").strip().rstrip(",")
if not line or line in "[]":
continue
try:
rec = json.loads(line)
except ValueError:
continue
seen += 1
own = (rec.get("ownop") or "").strip()
if not own or not LE.search(own) or EXCLUDE.search(own):
continue
hexid = (rec.get("icao") or "").strip().lower()
if len(hexid) != 6:
continue
# Commas would break the one-line-per-record format; the owner is
# display text only, so squashing punctuation is harmless.
owner = re.sub(r"[,\r\n]+", " ", own)[:48].strip()
if rec.get("faa_ladd"):
ladd += 1
rows.append((hexid, owner, "1" if rec.get("faa_ladd") else "0"))
rows.sort()
OUT.parent.mkdir(parents=True, exist_ok=True)
with OUT.open("w", encoding="utf-8") as f:
f.write("# hex,owner,ladd — generated by scripts/gen-le-aircraft.py, do not edit\n")
for hexid, owner, l in rows:
f.write(f"{hexid},{owner},{l}\n")
print(f"scanned {seen} aircraft -> {len(rows)} law-enforcement ({ladd} LADD-flagged)")
print(f"wrote {OUT} ({OUT.stat().st_size // 1024} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())