mirror of
https://git.churchofmalware.org/leviathan/OVERWATCH
synced 2026-09-24 08:35:03 +00:00
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:
co-authored by
Claude Opus 5
parent
fa75c624b1
commit
da97924ad2
@@ -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
@@ -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.
|
||||||
@@ -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,35 +349,32 @@ 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.
|
||||||
val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0)
|
if (camera.needsMove(fix.latitude, fix.longitude, mapRadiusMeters)) {
|
||||||
val latDegPerMeter = 1.0 / 111_000.0
|
val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0)
|
||||||
val lonDegPerMeter = 1.0 /
|
val latDegPerMeter = 1.0 / 111_000.0
|
||||||
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
val lonDegPerMeter = 1.0 /
|
||||||
val bbox = BoundingBox(
|
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
||||||
fix.latitude + r * latDegPerMeter,
|
val bbox = BoundingBox(
|
||||||
fix.longitude + r * lonDegPerMeter,
|
fix.latitude + r * latDegPerMeter,
|
||||||
fix.latitude - r * latDegPerMeter,
|
fix.longitude + r * lonDegPerMeter,
|
||||||
fix.longitude - r * lonDegPerMeter
|
fix.latitude - r * latDegPerMeter,
|
||||||
)
|
fix.longitude - r * lonDegPerMeter
|
||||||
map.post { map.zoomToBoundingBox(bbox, false, 0) }
|
)
|
||||||
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
|
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,28 +143,27 @@ fun OverlayBubble() {
|
|||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val r = radius.toDouble().coerceAtLeast(50.0)
|
// Same camera guard as the in-app circle — see MapCamera.
|
||||||
val latDegPerMeter = 1.0 / 111_000.0
|
if (camera.needsMove(fix.latitude, fix.longitude, radius)) {
|
||||||
val lonDegPerMeter = 1.0 /
|
val r = radius.toDouble().coerceAtLeast(50.0)
|
||||||
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
val latDegPerMeter = 1.0 / 111_000.0
|
||||||
val bbox = BoundingBox(
|
val lonDegPerMeter = 1.0 /
|
||||||
fix.latitude + r * latDegPerMeter,
|
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
||||||
fix.longitude + r * lonDegPerMeter,
|
val bbox = BoundingBox(
|
||||||
fix.latitude - r * latDegPerMeter,
|
fix.latitude + r * latDegPerMeter,
|
||||||
fix.longitude - r * lonDegPerMeter
|
fix.longitude + r * lonDegPerMeter,
|
||||||
)
|
fix.latitude - r * latDegPerMeter,
|
||||||
map.post { map.zoomToBoundingBox(bbox, false, 0) }
|
fix.longitude - r * lonDegPerMeter
|
||||||
|
)
|
||||||
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
|
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
@@ -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>
|
||||||
|
|||||||
Executable
+49
@@ -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'"
|
||||||
Executable
+89
@@ -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())
|
||||||
Reference in New Issue
Block a user