mirror of
https://git.churchofmalware.org/leviathan/OVERWATCH
synced 2026-09-24 08:35:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbf2526fd | ||
|
|
46aa2e5591 |
@@ -12,7 +12,7 @@ on upward escalations — you don't have to be looking at the screen.
|
||||
> advertise/fuzz code from one of the reference projects is intentionally
|
||||
> excluded.
|
||||
|
||||
Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.14](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
|
||||
Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.15](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +37,7 @@ 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. **Screen off:** Android suspends unfiltered scans, so the scanner switches to a filtered scan (Raven UUIDs, XUNTONG, mic company ids); OUI-prefix and name matching resume when the screen is on — see [SOURCES.md §5.1](SOURCES.md). 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) |
|
||||
| **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. |
|
||||
| **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 the detection radius (default 500 m), up to ~45 min old, scored by distance and age | **Two selectable backends.** *OpenWeb Ninja* (default): `api.openwebninja.com/waze/alerts-and-jams` called with **your own API key** (`X-API-Key`, entered in Settings, stored encrypted on-device), polled every ~4 min with `alert_types=POLICE&max_jams=0` (~1.5 KB/poll). Metered, lags live Waze by ~20 min, and Waze never sees you — the request is made by OpenWeb Ninja's servers. *Direct* (opt-in, off by default): speaks the Waze app's own protocol over an anonymous account OVERWATCH registers, polled every 60 s. Free, keyless and live, but it sends a position (blurred ±500 m) to a Google service. Both re-filter to POLICE client-side so an upstream change can't let other types through. Alerts carry crowd-trust signals that nudge the score up. |
|
||||
| **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, Sonos, hidden cams) and camera-bearing smart glasses (Meta Ray-Ban / Oakley, Snap Spectacles, Vuzix, and HeyCyan-SDK frames such as the Nilox Smart AI Glasses) 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. |
|
||||
|
||||
@@ -53,7 +53,7 @@ Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest re
|
||||
> as a permanently-failing row. 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: pick a backend (v0.5.15+).** The public `live-map/api/georss` endpoint every scraper used is dead: it sits behind Google's edge, which returns **HTTP 403** to automated clients regardless of IP, headers, TLS fingerprint, or headless-vs-headful browser — Waze's own page requests included. That is why v0.1.5 removed the original integration. OVERWATCH now offers two ways in, and the choice is a genuine trade rather than one being better. **OpenWeb Ninja** (the default) reads a hosted Waze feed with a key you supply yourself; it costs a few dollars a month and lags ~20 min, but the request comes from their servers, so Waze learns nothing about you. **Direct** speaks the Waze app's own protocol — free, keyless, live — but OVERWATCH then holds an anonymous Waze account and sends a position on every poll, which is a real disclosure to a Google service. It is off unless you turn it on. See [Waze setup](#waze-setup-choosing-a-backend). Nothing is baked into the APK either way. 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
|
||||
@@ -148,7 +148,7 @@ is self-explanatory. The same map renders in a smaller floating overlay bubble
|
||||
ui/MainScreen.kt map circle + threat ring + START/STOP + drill-down sheet
|
||||
ui/OverlayBubble.kt floating "chat-bubble" version of the map circle
|
||||
ui/MarkerIcons.kt map marker drawables — source dots + ⌖ user crosshair
|
||||
ui/SettingsScreen.kt source toggles, distance sliders, Waze API key, vibrate, theme
|
||||
ui/SettingsScreen.kt source toggles, sliders, Waze backend + API key, vibrate, theme
|
||||
ui/theme/Theme.kt Material 3 dark/light + threat colors
|
||||
service/DetectionService.kt foreground service — owns scanners, notification, vibration
|
||||
service/OverlayManager.kt WindowManager host for the floating overlay bubble
|
||||
@@ -156,11 +156,15 @@ scan/BleScanner.kt BLE callback scanner
|
||||
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
|
||||
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
|
||||
scan/DeflockScanner.kt location-driven proximity check + failure backoff
|
||||
scan/WazeClient.kt GET api.openwebninja.com (X-API-Key, user's own key)
|
||||
scan/WazeSource.kt the two-backend interface (alert + result types)
|
||||
scan/WazeClient.kt backend 1 — GET api.openwebninja.com (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 polls whichever backend is selected, client-side POLICE filter
|
||||
scan/wazert/WazeRtClient.kt backend 2 — Waze's own app protocol, anonymous account
|
||||
scan/wazert/WazeRtFetcher.kt session + account + rate limits for backend 2
|
||||
scan/wazert/*.java, proto/ vendored protocol layer + waze.proto (MIT, see credits)
|
||||
fusion/ConfidenceEngine.kt scoring (BLE / WiFi / DeFlock / Waze / Commercial)
|
||||
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
|
||||
fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
|
||||
@@ -168,7 +172,7 @@ fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-d
|
||||
fusion/ThreatLevel.kt 4-tier enum + DetectionSource enum
|
||||
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
|
||||
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
|
||||
data/settings/SecureStore.kt Keystore AES/GCM store for the Waze API key
|
||||
data/settings/SecureStore.kt Keystore AES/GCM store for the Waze key + anonymous account
|
||||
data/targets/ BleOuis, WifiOuis, VendorOuis, RavenUuids, Patterns, Manufacturers, MicTargets
|
||||
```
|
||||
|
||||
@@ -178,19 +182,31 @@ into a stuck state.
|
||||
|
||||
---
|
||||
|
||||
## Waze setup (bring your own API key)
|
||||
## Waze setup (choosing a backend)
|
||||
|
||||
The Waze source is optional and off until you give it a key. It reads
|
||||
[OpenWeb Ninja](https://www.openwebninja.com)'s hosted Waze feed, and each
|
||||
install uses its **owner's own key** — no shared credential ships in the APK
|
||||
The Waze source reads user-reported `POLICE` alerts. There are two ways to get
|
||||
them and they trade off against each other, so OVERWATCH ships both and lets you
|
||||
pick under **gear icon → Waze police feed**.
|
||||
|
||||
| | OpenWeb Ninja *(default)* | Direct from Waze *(opt-in)* |
|
||||
|---|---|---|
|
||||
| Cost | ~$1–3/month, your own key | free |
|
||||
| Freshness | lags live Waze ~20 min | live |
|
||||
| Poll | every ~4 min | every 60 s |
|
||||
| What Waze learns about you | nothing — their servers make the request | an anonymous account and a position, every poll |
|
||||
| Setup | sign up, paste a key | none |
|
||||
|
||||
### Backend 1 — OpenWeb Ninja (default)
|
||||
|
||||
Each install uses its **owner's own key**; no shared credential ships in the APK
|
||||
and nobody has to be handed someone else's.
|
||||
|
||||
1. Sign up at **[openwebninja.com](https://www.openwebninja.com)**.
|
||||
2. Subscribe to the **Waze / Real-Time Traffic** API (the
|
||||
`waze/alerts-and-jams` endpoint).
|
||||
3. Copy your API key — it looks like `ak_…`.
|
||||
4. In OVERWATCH: **gear icon → Waze police feed → paste the key → Save**. The
|
||||
status line flips to `API key set — Waze feed enabled`.
|
||||
4. In OVERWATCH: **gear icon → Waze police feed → OpenWeb Ninja → paste the key
|
||||
→ Save**. The status line flips to `API key set — Waze feed enabled`.
|
||||
|
||||
The key is stored encrypted on-device (Android Keystore AES/GCM, see
|
||||
`data/settings/SecureStore.kt`) and is sent only to `api.openwebninja.com` as
|
||||
@@ -210,6 +226,36 @@ If a key is wrong or its quota is exhausted, the drill-down says so explicitly
|
||||
(`Invalid or missing API key (HTTP 401)` / `Rate limit or quota exceeded (HTTP
|
||||
429)`) rather than failing silently.
|
||||
|
||||
### Backend 2 — direct from Waze (opt-in, off by default)
|
||||
|
||||
This speaks the protocol the Waze app itself speaks, so there is no key, no
|
||||
bill and no lag. Select it and it works; the first poll registers an anonymous
|
||||
Waze account (Waze mints the username and password) and stores it encrypted
|
||||
on-device, then reuses it.
|
||||
|
||||
**Read this before turning it on.** OVERWATCH becomes a Waze client. It holds a
|
||||
Waze account and sends a position with every poll — the protocol layer blurs it
|
||||
by up to 500 m, but it is still roughly where you are, going to a Google
|
||||
service, about once a minute while scanning. The whole point of this app is
|
||||
knowing who is watching you, so it would be dishonest to bury that. If the trade
|
||||
isn't worth it to you, stay on OpenWeb Ninja, which tells Waze nothing.
|
||||
|
||||
Practical notes:
|
||||
|
||||
- **gear icon → Waze police feed → Direct from Waze**, then Start. Nothing else
|
||||
to configure.
|
||||
- Waze caps how many anonymous accounts a device may register per day, so the
|
||||
account is persisted and reused. **Forget** wipes it; the next poll mints a
|
||||
new one, and that counts against the cap.
|
||||
- A rejected account (they get purged upstream) backs off 30 s → 10 min rather
|
||||
than spinning a re-register loop.
|
||||
- A brand-new account's first request often draws a transient `504 Retry` from
|
||||
Waze. That is normal and absorbed with a retry — it costs a couple of seconds
|
||||
on first run, not a failed poll.
|
||||
- Data use is ~200 KB on the first poll of a session and ~8 KB per poll after,
|
||||
because the session is kept alive. Polling *slower* here would use *more*
|
||||
data, since a re-login re-sends the whole viewport.
|
||||
|
||||
---
|
||||
|
||||
## Build & install
|
||||
@@ -224,7 +270,10 @@ Toolchain as of v0.5.6: AGP 9.3.2 / Gradle 9.7.1 / Kotlin 2.4.10, `compileSdk`
|
||||
37. `targetSdk` stays at **35** deliberately — API 36+ tightens foreground-service
|
||||
behavior, and screen-off scanning is the core feature, so the runtime opt-in is
|
||||
kept separate from the compile-time bump. Note AGP 9 folds in Kotlin support, so
|
||||
there is no longer a standalone `kotlin.android` plugin in the build file.
|
||||
there is no longer a standalone `kotlin.android` plugin in the build file. Since
|
||||
v0.5.15 the build also applies `com.google.protobuf` 0.10.0 (the first release
|
||||
compatible with AGP 9) and generates the Waze RT message classes from
|
||||
`app/src/main/proto/waze.proto`, so a clean build downloads a `protoc` binary.
|
||||
|
||||
```sh
|
||||
# 1) Copy the example local.properties and point sdk.dir at your install
|
||||
@@ -289,9 +338,12 @@ Tap the gear icon in the top-right.
|
||||
so moving it in either direction cannot change the threat tier — and anything
|
||||
at YELLOW or above is shown whatever the range says, because a view setting
|
||||
that could hide a live alert would be a trap.
|
||||
- **Waze police feed**: paste your own OpenWeb Ninja API key — see
|
||||
[Waze setup](#waze-setup-bring-your-own-api-key). Stored encrypted on-device
|
||||
(Android Keystore), never baked into the APK. Empty = Waze source off.
|
||||
- **Waze police feed**: choose a backend — see
|
||||
[Waze setup](#waze-setup-choosing-a-backend). *OpenWeb Ninja* (default) needs
|
||||
your own API key, stored encrypted on-device (Android Keystore) and never baked
|
||||
into the APK; empty key = Waze source off. *Direct from Waze* needs no key but
|
||||
registers an anonymous Waze account and sends a blurred position each poll, so
|
||||
it is off until you pick it; **Forget** wipes the stored account.
|
||||
- **Alerts**:
|
||||
- Vibrate on threat escalation (default on)
|
||||
- **Display over other apps**: floating threat-circle overlay (needs the
|
||||
@@ -308,20 +360,22 @@ These live under `REFERENCES/` (gitignored):
|
||||
- **flock-detection** — confidence-scoring algorithm (highest reusability), RSSI rise-peak-fall, OUIs + UUIDs + patterns
|
||||
- **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android)
|
||||
- **deflock** + **deflock-app** — Overpass query format + proximity-alert pattern (the Flutter app uses Overpass directly, not the CDN tiles, which the OVERWATCH client mirrors)
|
||||
- **wazepolice** — original live-map/api/georss recipe; that endpoint is now reCAPTCHA-gated, so OVERWATCH reads OpenWeb Ninja's hosted feed instead of hitting Waze directly
|
||||
- **wazepolice** — original live-map/api/georss recipe; that endpoint now 403s at Google's edge, so OVERWATCH reads either OpenWeb Ninja's hosted feed or Waze's own app protocol instead
|
||||
- **[Nearby Glasses](https://github.com/yjeanrenaud/yj_nearbyglasses)** by [Yves Jeanrenaud](https://yves.app) (AGPL-3.0) — the curated smart-glasses identifier set behind the GLASSES family in the COMMERCIAL source: the Bluetooth SIG company ids for Meta / Luxottica / Snap, the HeyCyan-SDK primary service UUID that identifies the Nilox Smart AI Glasses, and the `rayban` / `heycyan` name tokens. OVERWATCH uses the project's published *identifiers* (facts about the radio protocol), not its code, and re-verified every company id against the SIG registry. One deliberate divergence: Nearby Glasses also matches `0x05D6` (Zhuhai Jieli); OVERWATCH does not, because that is the id of a Bluetooth chipset found in a huge share of cheap earbuds and speakers, and this app's COMMERCIAL source is tuned against false positives rather than for recall.
|
||||
|
||||
---
|
||||
|
||||
- **highway-radar-sabre-plus** (MIT) — the Waze RT protocol layer behind the **direct** Waze backend. Unlike every other reference here, this one is *vendored*, not just studied: `app/src/main/java/org/soulstone/overwatch/scan/wazert/` and `app/src/main/proto/waze.proto` are that project's code under its MIT licence (kept with the licence text beside them, and unmodified apart from the package name, the removal of the report-submission path, and an OkHttp-to-`HttpURLConnection` swap so OVERWATCH takes no extra dependency). OVERWATCH reads alerts and never reports.
|
||||
|
||||
## Status
|
||||
|
||||
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, polish) complete and
|
||||
field-tested. Current release **v0.5.14**. Notable changes:
|
||||
field-tested. Current release **v0.5.15**. Notable changes:
|
||||
|
||||
- v0.1.2 — Android 14+ foreground service type fix; NaN-coordinate filter on map data.
|
||||
- v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked).
|
||||
- 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 (the `live-map` endpoint started 403ing; 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.7 — System back from Settings returns to MAIN instead of exiting.
|
||||
- v0.2.0 — Live map circle + COMMERCIAL source (Nest / Ring / Echo / hidden cams).
|
||||
@@ -333,7 +387,7 @@ field-tested. Current release **v0.5.14**. Notable 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.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.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-choosing-a-backend).
|
||||
- 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.
|
||||
- v0.5.9 — Detection-radius slider moved onto the main screen (under the map, where you reach for it while moving) and a source-color legend added beneath the circle: ALPR red, speed camera amber, other cameras gray, Waze police blue, aircraft violet.
|
||||
@@ -342,6 +396,7 @@ field-tested. Current release **v0.5.14**. Notable changes:
|
||||
- v0.5.12 — The range slider can no longer change the threat tier in *either* direction. v0.5.10 stopped widening it from pulling in distant noise, but narrowing it still hid real alerts: a police report 312 m away scoring 52 disappeared and the circle went green because the view was set to 300 m. Scanners now evaluate at their own fixed radii (DeFlock 1200 m, Waze 2000 m) instead of the user's setting, events carry their distance, and anything at YELLOW or above is displayed regardless of range.
|
||||
- v0.5.13 — Fixed the map coming back fully zoomed out (whole world) after the first stop/start. The camera-position guard added in v0.5.9 was remembered outside the branch that owns the `MapView`, so a rebuilt map compared against the *previous* map's position, saw no change, and never zoomed in. Scoped it to the map's own lifetime. Reproduced deterministically from the second start onward and verified over five cycles.
|
||||
- v0.5.14 — Smart-glasses coverage extended from the [Nearby Glasses](https://github.com/yjeanrenaud/yj_nearbyglasses) identifier set (credit: Yves Jeanrenaud): the HeyCyan-SDK service UUID that identifies Nilox Smart AI Glasses and other HeyCyan frames, case-insensitive `rayban` / `ray-ban` / `heycyan` name tokens, and service UUIDs are now also read from advertised *service-data* keys, where those glasses have been seen to carry them. The HeyCyan and Echo service UUIDs join the screen-off ScanFilter set, so glasses can be caught with the phone in a pocket. Also fixes a pre-existing mislabel found while re-verifying every company id against the Bluetooth SIG registry: `0x05A7` is Sonos, not a hidden-cam vendor — it now reads "Sonos speaker" instead of "Possible hidden mic / cam".
|
||||
- v0.5.15 — **A second Waze backend: straight from Waze, no API key.** The public `live-map/api/georss` endpoint 403s automated clients at Google's edge, so until now the only way in was OpenWeb Ninja's metered feed. OVERWATCH can now instead speak the Waze app's own protocol over an anonymous account it registers itself: free, keyless, live, and polled every 60 s instead of every 4 min. It is **off by default and opt-in**, because it makes the app a Waze client — it holds a Waze account and sends a position (blurred by up to 500 m) to a Google service on every poll, and in a surveillance-detection app that has to be your call, not a default. OpenWeb Ninja remains the default and is unchanged; pick either under **gear → Waze police feed**. See [Waze setup](#waze-setup-choosing-a-backend) and [SOURCES.md §2.2](SOURCES.md). The protocol layer is vendored from **highway-radar-sabre-plus** (MIT, credited below), with the report-submission path removed — OVERWATCH reads alerts and never reports one. Also: radio rows in Settings are now tappable across their whole width instead of only on the 20 dp circle.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+83
-9
@@ -17,7 +17,7 @@ network-facing rots; re-verify before quoting it.
|
||||
| **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 |
|
||||
| **WAZE** | Live crowd-sourced police reports | OpenWeb Ninja *or* Waze's own protocol | ~$1–3/mo *or* free | 2026-09-21 |
|
||||
| **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 |
|
||||
|
||||
@@ -68,7 +68,22 @@ which is why generic cameras are worth carrying.
|
||||
- **Dead:** `cdn.deflock.me/regions/*.json` — behind Cloudflare bot mitigation,
|
||||
unusable from a mobile HTTP client.
|
||||
|
||||
### 2.2 OpenWeb Ninja — the WAZE source
|
||||
### 2.2 The WAZE source — two backends
|
||||
|
||||
The WAZE source reads user-reported `POLICE` alerts. Two backends exist and the
|
||||
user picks one in Settings; `scan/WazeSource.kt` is the interface both implement,
|
||||
so neither leaks into scoring or UI. Default is OpenWeb Ninja.
|
||||
|
||||
| | OpenWeb Ninja | Direct (Waze RT) |
|
||||
|---|---|---|
|
||||
| Key | user's own, required | none |
|
||||
| Cost | ~$0.005/request | free |
|
||||
| Poll | 240 s | 60 s |
|
||||
| Latency | ~20 min behind live | live |
|
||||
| Position sent to Waze | none | every poll, jittered ±500 m |
|
||||
| Default | yes | no, opt-in |
|
||||
|
||||
#### 2.2a OpenWeb Ninja (default)
|
||||
|
||||
```
|
||||
GET https://api.openwebninja.com/waze/alerts-and-jams
|
||||
@@ -80,7 +95,7 @@ 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
|
||||
≈ 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):
|
||||
@@ -96,12 +111,68 @@ Verified behaviour (2026-09-16):
|
||||
- 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.2b Direct — the Waze app's own protocol (opt-in)
|
||||
|
||||
```
|
||||
POST https://rt-xlb-am.waze.com/rtserver/distrib/static # register
|
||||
POST https://rt-xlb-am.waze.com/rtserver/distrib/login # authenticate
|
||||
POST https://rt-xlb-am.waze.com/rtserver/distrib/command # query / stream
|
||||
Content-Type: binary/octet-stream Response: application/x-protobuf
|
||||
```
|
||||
|
||||
Regional hosts: `rt-xlb-am` (North America), `rt-xlb-il` (Israel),
|
||||
`rt-xlb-row` (rest of world).
|
||||
|
||||
No key and no account of the user's: `/static` mints an anonymous
|
||||
username/password pair on request. The request body is line-oriented — protobuf
|
||||
messages framed as `"ProtoBase64," + base64(Batch{Element})`, plain-text command
|
||||
lines (`SeeMe`, `SetMood`, `Location`, `MapDisplayed`) — joined by newlines. The
|
||||
message set lives in `app/src/main/proto/waze.proto` (proto2, `LITE_RUNTIME`).
|
||||
|
||||
Behaviour verified 2026-09-21, from a Linux host and from an Android 16
|
||||
emulator:
|
||||
|
||||
- **The session is stateful.** `/command` returns each alert *once* per session
|
||||
as an `AddAlertAction`, then a removal as an `old_command` line of the form
|
||||
`RmAlert,<uuid>` — not a message type. A client that treats each response as a
|
||||
snapshot goes empty after the first query, so responses are merged into a cache
|
||||
with a 5-minute soft-delete.
|
||||
- **Login sets a `Waze-Session-Affinity` cookie** that every later `/command`
|
||||
must carry, and the session idles out after ~100 s.
|
||||
- **A fresh account's first `/command` usually returns an in-band
|
||||
`ServerError{code: 504, description: "Retry"}` inside an HTTP 200.** It
|
||||
succeeds on the next attempt, so it is retried rather than treated as failure.
|
||||
- **Queries are a series of shrinking boxes** (5 steps, each half the last, each
|
||||
shrunk to 0.75 before sending). The server thins results by viewport size, so a
|
||||
single wide box drops near-driver detail.
|
||||
- **Longitude arrives as unsigned 32-bit micro-degrees** and must be mapped back
|
||||
to signed, or the western hemisphere lands on the wrong side of the planet.
|
||||
- **Data volume:** ~195 KB for the first (handshake) response of a session, ~8 KB
|
||||
per query after. Keeping the session alive is therefore *cheaper* than polling
|
||||
slowly, which is why the poll is 60 s.
|
||||
- Anonymous-account registration is capped per device per day (10), so the
|
||||
account is persisted encrypted and reused, and a rejected account backs off
|
||||
30 s → 10 min instead of spinning the register loop.
|
||||
|
||||
**What it costs the user, and why it is off by default:** the app becomes a Waze
|
||||
client. It holds a Waze account and sends a position on every poll — the codec
|
||||
jitters it by up to 500 m, but Waze is a Google service and this is a real
|
||||
disclosure. The Settings screen says so in those terms before the toggle.
|
||||
|
||||
The protocol layer under `scan/wazert/` is **vendored** from
|
||||
highway-radar-sabre-plus (MIT, licence kept beside the code), unmodified apart
|
||||
from the package name, removal of the report-submission path, and swapping OkHttp
|
||||
for `HttpURLConnection`. OVERWATCH reads alerts and never reports one.
|
||||
|
||||
**Why not `live-map/api/georss`:** the endpoint every scraper used is fronted by
|
||||
Google's edge, which returns **HTTP 403** to automated clients. Tested 2026-07
|
||||
and again 2026-09 from a residential IP — plain curl, headless Chromium,
|
||||
**headful** Chromium on a real display, and undetected-chromedriver all 403, as
|
||||
did Waze's own page requests. The response carries `via: 1.1 google`, i.e. the
|
||||
block is at the front end, before any application logic; no user-agent, cookie,
|
||||
TLS fingerprint or IP changes it. The RT hosts above are a different front end
|
||||
and answer normally. The Waze for Cities partner feed excludes POLICE and is
|
||||
agency-only.
|
||||
|
||||
### 2.3 ADS-B community networks — the AIRCRAFT source
|
||||
|
||||
@@ -560,3 +631,6 @@ Reference projects studied while building (kept under a gitignored `REFERENCES/`
|
||||
- **deflock / deflock-app** — the Overpass query shape and proximity-alert
|
||||
pattern.
|
||||
- **wazepolice** — the original `live-map/api/georss` recipe, now dead.
|
||||
- **highway-radar-sabre-plus** (MIT) — the Waze RT protocol layer. The only
|
||||
reference that is *vendored* rather than studied: `scan/wazert/*.java` and
|
||||
`proto/waze.proto` are that project's code, carried with its licence.
|
||||
|
||||
+33
-2
@@ -4,6 +4,7 @@ plugins {
|
||||
// The Compose compiler plugin is still applied separately.
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.protobuf)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -14,8 +15,8 @@ android {
|
||||
applicationId = "org.soulstone.overwatch"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 30
|
||||
versionName = "0.5.14"
|
||||
versionCode = 31
|
||||
versionName = "0.5.15"
|
||||
}
|
||||
|
||||
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
||||
@@ -62,7 +63,36 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Generates WazeProto from src/main/proto/waze.proto. `lite` keeps the runtime and
|
||||
// the generated code small enough for an app that only speaks one protocol.
|
||||
protobuf {
|
||||
protoc { artifact = libs.protobuf.protoc.get().toString() }
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
// Android projects get no default `java` builtin from the plugin, so
|
||||
// create it (maybeCreate keeps this correct either way) and ask for the
|
||||
// lite generator to match the protobuf-javalite runtime.
|
||||
task.builtins {
|
||||
maybeCreate("java").option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Play services drags in androidx.fragment 1.1.0, whose FragmentActivity
|
||||
// predates the ActivityResult APIs: it failed to call
|
||||
// super.onRequestPermissionsResult() and used invalid request codes. Lint
|
||||
// fails the release build over it (InvalidFragmentVersionForActivityResult)
|
||||
// because MainActivity registers the permission prompt that way. Nothing in
|
||||
// this app uses Fragments, so this is a constraint rather than a dependency —
|
||||
// it raises a version already on the classpath instead of adding an edge.
|
||||
constraints {
|
||||
implementation(libs.androidx.fragment) {
|
||||
because("play-services pulls fragment 1.1.0, which is unsafe with registerForActivityResult")
|
||||
}
|
||||
}
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.service)
|
||||
@@ -77,6 +107,7 @@ dependencies {
|
||||
|
||||
implementation(libs.play.services.location)
|
||||
implementation(libs.osmdroid.android)
|
||||
implementation(libs.protobuf.javalite)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The result of one Waze RT area query: the alerts the server added (as
|
||||
* AddAlertAction elements) and the uuids it removed (as "RmAlert," old_command
|
||||
* lines).
|
||||
*
|
||||
* The RT /command endpoint is session-stateful, it only sends an alert once per
|
||||
* session, then a removal when it clears, so callers must MERGE these deltas into
|
||||
* a persistent cache rather than treat each result as a full snapshot. See
|
||||
* {@link WazeAlertCache}.
|
||||
*/
|
||||
final class AlertQueryResult {
|
||||
final List<WazeAlert> newAlerts;
|
||||
final List<String> removedIds;
|
||||
|
||||
AlertQueryResult(List<WazeAlert> newAlerts, List<String> removedIds) {
|
||||
this.newAlerts = newAlerts;
|
||||
this.removedIds = removedIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A synthetic Android device fingerprint sent in ClientInfo when registering /
|
||||
* logging in to Waze: one of 8 fixed device profiles, matching what the Waze
|
||||
* client itself reports, with a fresh random installationId.
|
||||
*/
|
||||
final class DeviceIdentity {
|
||||
final String manufacturer;
|
||||
final String model;
|
||||
final String osVersion; // synthetic "<release>-SDK<api>" token, sent as ClientInfo.osVersion
|
||||
final int screenW;
|
||||
final int screenH;
|
||||
final String installationId;
|
||||
|
||||
DeviceIdentity(String manufacturer, String model, String osVersion,
|
||||
int screenW, int screenH, String installationId) {
|
||||
this.manufacturer = manufacturer;
|
||||
this.model = model;
|
||||
this.osVersion = osVersion;
|
||||
this.screenW = screenW;
|
||||
this.screenH = screenH;
|
||||
this.installationId = installationId;
|
||||
}
|
||||
|
||||
private static final DeviceIdentity[] POOL = {
|
||||
new DeviceIdentity("samsung", "SM-S928B", "16-SDK36", 1440, 3088, ""),
|
||||
new DeviceIdentity("samsung", "SM-A546B", "15-SDK35", 1080, 2340, ""),
|
||||
new DeviceIdentity("Google", "Pixel 9 Pro", "16-SDK36", 1280, 2856, ""),
|
||||
new DeviceIdentity("Google", "Pixel 8", "15-SDK35", 1080, 2400, ""),
|
||||
new DeviceIdentity("OnePlus", "CPH2451", "15-SDK35", 1240, 2772, ""),
|
||||
new DeviceIdentity("Xiaomi", "2201117TG", "14-SDK34", 1220, 2712, ""),
|
||||
new DeviceIdentity("motorola", "moto g84", "15-SDK35", 1080, 2400, ""),
|
||||
new DeviceIdentity("Nothing", "A065", "15-SDK35", 1080, 2412, ""),
|
||||
};
|
||||
|
||||
private static final Random RNG = new Random();
|
||||
|
||||
/** Pick a random profile and assign it a fresh installation UUID. */
|
||||
static DeviceIdentity random() {
|
||||
DeviceIdentity d = POOL[RNG.nextInt(POOL.length)];
|
||||
return new DeviceIdentity(d.manufacturer, d.model, d.osVersion,
|
||||
d.screenW, d.screenH, UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
/**
|
||||
* Bounding-box geometry for Waze RT area queries (circle to box, shrink, and the
|
||||
* shrinking-box series).
|
||||
*
|
||||
* <p>A single MapDisplayed query over the full HR radius is thinned server-side by
|
||||
* viewport size, so minor/near-driver alerts (e.g. a car stopped on the shoulder)
|
||||
* get dropped. Querying a series of progressively smaller boxes around the driver
|
||||
* defeats that: the smaller the viewport the less the server thins it, so the inner
|
||||
* detail comes through. Each box is shrunk to 0.75 before querying, as the Waze
|
||||
* client does for its primary viewport.
|
||||
*
|
||||
* <p>Boxes are {@code [lonMin, latMin, lonMax, latMax]}.
|
||||
*/
|
||||
final class GeoBoxes {
|
||||
private GeoBoxes() {}
|
||||
|
||||
/** A lon/lat box of half-width radiusM around the point. */
|
||||
static double[] circleToBox(double lon, double lat, double radiusM) {
|
||||
double dLat = radiusM / WazeConstants.M_PER_DEG_LAT;
|
||||
double dLon = radiusM / WazeConstants.mPerDegLon(lat);
|
||||
return new double[]{lon - dLon, lat - dLat, lon + dLon, lat + dLat};
|
||||
}
|
||||
|
||||
/** Same center, half-extent scaled by {@code factor}. */
|
||||
static double[] shrink(double[] box, double factor) {
|
||||
double cx = (box[0] + box[2]) / 2.0;
|
||||
double cy = (box[1] + box[3]) / 2.0;
|
||||
double hx = ((box[2] - box[0]) / 2.0) * factor;
|
||||
double hy = ((box[3] - box[1]) / 2.0) * factor;
|
||||
return new double[]{cx - hx, cy - hy, cx + hx, cy + hy};
|
||||
}
|
||||
|
||||
/**
|
||||
* The shrinking-box series: the full-radius box, then each
|
||||
* successive box halved, for {@code steps} zoom levels in all.
|
||||
*/
|
||||
static double[][] shrinkingBoxes(double lon, double lat, double radiusM, int steps) {
|
||||
double[][] out = new double[steps][];
|
||||
out[0] = circleToBox(lon, lat, radiusM);
|
||||
for (int i = 1; i < steps; i++) {
|
||||
out[i] = shrink(out[i - 1], 0.5);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 highway-radar-sabre-plus contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
/**
|
||||
* A Waze alert decoded from a RealtimeAlert protobuf message (fetch-relevant
|
||||
* fields only).
|
||||
*/
|
||||
public final class WazeAlert {
|
||||
public final String uuid;
|
||||
public final long id;
|
||||
public final String type;
|
||||
public final String subtype;
|
||||
public final double lon;
|
||||
public final double lat;
|
||||
public final int magvar;
|
||||
public final long pubMillis;
|
||||
public final Integer nThumbsUp;
|
||||
public final String street;
|
||||
public final String city;
|
||||
|
||||
public WazeAlert(String uuid, long id, String type, String subtype,
|
||||
double lon, double lat, int magvar, long pubMillis,
|
||||
Integer nThumbsUp, String street, String city) {
|
||||
this.uuid = uuid;
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.subtype = subtype;
|
||||
this.lon = lon;
|
||||
this.lat = lat;
|
||||
this.magvar = magvar;
|
||||
this.pubMillis = pubMillis;
|
||||
this.nThumbsUp = nThumbsUp;
|
||||
this.street = street;
|
||||
this.city = city;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Persistent uuid-keyed alert cache with soft-delete, matching the caching
|
||||
* behavior of the original wzsabre plugin (merge, soft-delete, expiry, filter).
|
||||
*
|
||||
* <p>WHY this exists: the Waze RT {@code /command} endpoint is session-stateful.
|
||||
* It returns each alert as an {@code AddAlertAction} only ONCE per session, then a
|
||||
* {@code "RmAlert,<uuid>"} removal line when the alert clears. The previous source
|
||||
* replaced its cache with every response, so after the first query the cache went
|
||||
* near-empty and alerts vanished from Highway Radar mid-drive. This class instead
|
||||
* accumulates adds and soft-deletes removals (kept {@link #SOFT_DELETE_MS} before
|
||||
* purge), so the merged view stays complete.
|
||||
*/
|
||||
final class WazeAlertCache {
|
||||
/** wzsabre uses PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS (5 minutes). */
|
||||
static final long SOFT_DELETE_MS = 5 * 60_000L;
|
||||
|
||||
private final Map<String, WazeAlert> alertCache = new LinkedHashMap<>();
|
||||
private final Map<String, Long> softDeleted = new LinkedHashMap<>();
|
||||
|
||||
/** Apply one query's deltas: upsert adds, soft-delete removals still cached. */
|
||||
synchronized void submit(AlertQueryResult result) {
|
||||
for (WazeAlert a : result.newAlerts) {
|
||||
if (a.uuid != null && a.uuid.length() != 0) {
|
||||
alertCache.put(a.uuid, a);
|
||||
softDeleted.remove(a.uuid);
|
||||
}
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
for (String id : result.removedIds) {
|
||||
if (alertCache.containsKey(id) && !softDeleted.containsKey(id)) {
|
||||
softDeleted.put(id, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void purgeExpiredSoftDeletes() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expired = new ArrayList<>();
|
||||
for (Map.Entry<String, Long> e : softDeleted.entrySet()) {
|
||||
if (now - e.getValue() >= SOFT_DELETE_MS) expired.add(e.getKey());
|
||||
}
|
||||
for (String id : expired) {
|
||||
alertCache.remove(id);
|
||||
softDeleted.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSoftDeleteExpired(String uuid) {
|
||||
Long t = softDeleted.get(uuid);
|
||||
return t != null && System.currentTimeMillis() - t >= SOFT_DELETE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live view of the cache: purge expired soft-deletes, then return every cached
|
||||
* alert that is not currently soft-deleted.
|
||||
*/
|
||||
synchronized List<WazeAlert> snapshot() {
|
||||
purgeExpiredSoftDeletes();
|
||||
List<WazeAlert> out = new ArrayList<>();
|
||||
for (WazeAlert a : alertCache.values()) {
|
||||
if (!(softDeleted.containsKey(a.uuid) && !isSoftDeleteExpired(a.uuid))) {
|
||||
out.add(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
synchronized void clear() {
|
||||
alertCache.clear();
|
||||
softDeleted.clear();
|
||||
}
|
||||
|
||||
synchronized int size() {
|
||||
return alertCache.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
/**
|
||||
* Constants and host resolution for the Waze mobile-app "RT" protocol.
|
||||
*/
|
||||
final class WazeConstants {
|
||||
static final int PROTOCOL_VERSION = 234;
|
||||
static final String APP_VERSION = "5.17.1.0";
|
||||
|
||||
static final String PATH_LOGIN = "/rtserver/distrib/login";
|
||||
static final String PATH_STATIC = "/rtserver/distrib/static";
|
||||
static final String PATH_COMMAND = "/rtserver/distrib/command";
|
||||
|
||||
static final String WAIT_TIMEOUT_LOGIN = "8500";
|
||||
static final String WAIT_TIMEOUT_COMMAND = "10500";
|
||||
|
||||
static final long SESSION_IDLE_TIMEOUT_MS = 100_000L;
|
||||
static final long KEEPALIVE_INTERVAL_MS = 60_000L;
|
||||
static final int MAX_CONSECUTIVE_REJECTIONS = 10;
|
||||
static final int MAX_ACCOUNTS_PER_DAY = 10;
|
||||
static final int NUM_QUERY_SLOTS = 5;
|
||||
static final int TILE_NUM_ROWS = 18000;
|
||||
static final double M_PER_DEG_LAT = 110574.0;
|
||||
|
||||
private WazeConstants() {}
|
||||
|
||||
static double mPerDegLon(double lat) {
|
||||
return Math.cos(Math.toRadians(lat)) * 111320.0;
|
||||
}
|
||||
|
||||
/** RT server host for a region ("na" covers all of California). */
|
||||
static String rtHost(String region) {
|
||||
if ("na".equals(region)) return "rt-xlb-am.waze.com";
|
||||
if ("il".equals(region)) return "rt-xlb-il.waze.com";
|
||||
return "rt-xlb-row.waze.com";
|
||||
}
|
||||
|
||||
static String tileHost(String region) {
|
||||
if ("na".equals(region)) return "ctilesgcs-am.waze.com";
|
||||
if ("il".equals(region)) return "ctilesgcs-il.waze.com";
|
||||
return "ctilesgcs-row.waze.com";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
/**
|
||||
* Anonymous Waze account credentials, minted by the /rtserver/distrib/static
|
||||
* register endpoint. {@code community} is the username, {@code secret} the password.
|
||||
*/
|
||||
final class WazeCredentials {
|
||||
final String community;
|
||||
final String secret;
|
||||
|
||||
WazeCredentials(String community, String secret) {
|
||||
this.community = community;
|
||||
this.secret = secret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
/** Waze RT-protocol error conditions. */
|
||||
final class WazeExceptions {
|
||||
private WazeExceptions() {}
|
||||
|
||||
/** The server rejected the account/session (HTTP 4xx, e.g. 403). The slot should be replaced. */
|
||||
static final class AccountRejectedException extends Exception {
|
||||
AccountRejectedException(String message) { super(message); }
|
||||
}
|
||||
|
||||
/** The session is no longer valid ("relogin", "unknown userid", "secretkey missing"). Re-login. */
|
||||
static final class SessionExpiredException extends Exception {
|
||||
SessionExpiredException(String message) { super(message); }
|
||||
}
|
||||
|
||||
/** A generic protocol/operation failure (HTTP 5xx, register failure, etc.). */
|
||||
static final class WazeOperationException extends Exception {
|
||||
WazeOperationException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Replaces the upstream OkHttp-based client of the same name from
|
||||
// highway-radar-sabre-plus (MIT). Same contract on HttpURLConnection, so the
|
||||
// app takes no OkHttp dependency. See LICENSE in this directory.
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OVERWATCH replacement for the SABRE Plus OkHttp-based {@code WazeHttpClient}.
|
||||
* Same contract — binary/octet-stream POSTs, per-session cookies (login sets
|
||||
* {@code Waze-Session-Affinity}, every later command must carry it), three
|
||||
* attempts on transport failure — on {@link HttpURLConnection}, so the app takes
|
||||
* no OkHttp dependency. Only POST is kept; the tile-server GET is not part of the
|
||||
* alert fetch path.
|
||||
*/
|
||||
class WazeHttpClient {
|
||||
private static final int CONNECT_TIMEOUT_MS = 15_000;
|
||||
/** Must exceed the /command long-poll (x-waze-wait-timeout: 10500 ms). */
|
||||
private static final int READ_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Session cookies by name. */
|
||||
private final Map<String, String> cookies = new LinkedHashMap<>();
|
||||
|
||||
/** Drop all cookies (login clears them before re-authenticating). */
|
||||
void clearCookies() { synchronized (cookies) { cookies.clear(); } }
|
||||
|
||||
static final class HttpResult {
|
||||
final int code;
|
||||
final byte[] body;
|
||||
HttpResult(int code, byte[] body) { this.code = code; this.body = body; }
|
||||
}
|
||||
|
||||
HttpResult post(String url, byte[] body, Map<String, String> headers) throws IOException {
|
||||
IOException last = null;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
return postOnce(url, body, headers);
|
||||
} catch (IOException e) {
|
||||
last = e;
|
||||
if (attempt == 3) break; // no point sleeping after the final attempt
|
||||
try { Thread.sleep(400L * attempt); } catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
private HttpResult postOnce(String url, byte[] body, Map<String, String> headers) throws IOException {
|
||||
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
c.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
c.setReadTimeout(READ_TIMEOUT_MS);
|
||||
c.setRequestMethod("POST");
|
||||
c.setDoOutput(true);
|
||||
c.setUseCaches(false);
|
||||
c.setFixedLengthStreamingMode(body.length);
|
||||
c.setRequestProperty("Content-Type", "binary/octet-stream");
|
||||
if (headers != null) {
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (e.getValue() != null) c.setRequestProperty(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
String cookieHeader = cookieHeader();
|
||||
if (cookieHeader != null) c.setRequestProperty("Cookie", cookieHeader);
|
||||
try (OutputStream out = c.getOutputStream()) { out.write(body); }
|
||||
int code = c.getResponseCode();
|
||||
rememberCookies(c.getHeaderFields());
|
||||
InputStream in = code >= 400 ? c.getErrorStream() : c.getInputStream();
|
||||
byte[] bytes = in == null ? new byte[0] : readAll(in);
|
||||
return new HttpResult(code, bytes);
|
||||
} finally {
|
||||
c.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private String cookieHeader() {
|
||||
synchronized (cookies) {
|
||||
if (cookies.isEmpty()) return null;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : cookies.entrySet()) {
|
||||
if (sb.length() > 0) sb.append("; ");
|
||||
sb.append(e.getKey()).append('=').append(e.getValue());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the name=value pair of every Set-Cookie (attributes are irrelevant: one host, one path). */
|
||||
private void rememberCookies(Map<String, List<String>> responseHeaders) {
|
||||
if (responseHeaders == null) return;
|
||||
for (Map.Entry<String, List<String>> h : responseHeaders.entrySet()) {
|
||||
if (h.getKey() == null || !h.getKey().equalsIgnoreCase("Set-Cookie") || h.getValue() == null) continue;
|
||||
for (String raw : h.getValue()) {
|
||||
if (raw == null) continue;
|
||||
int semi = raw.indexOf(';');
|
||||
String pair = (semi >= 0 ? raw.substring(0, semi) : raw).trim();
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
synchronized (cookies) { cookies.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream in) throws IOException {
|
||||
try (InputStream s = in) {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(8192);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = s.read(buf)) != -1) bos.write(buf, 0, n);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
import android.util.Base64;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Encodes/decodes the Waze "RT" protocol wire format on top of the generated
|
||||
* {@link WazeProto} protobuf classes. (Named *Codec to avoid clashing with the
|
||||
* generated WazeProto outer class.)
|
||||
*
|
||||
* Body framing: each protobuf "line" is {@code "ProtoBase64," + base64(Batch{element})}
|
||||
* with NO_WRAP base64; multiple lines are joined with '\n'. Raw command lines
|
||||
* (SeeMe / Location / MapDisplayed) are sent as plain text, not protobuf-wrapped.
|
||||
*/
|
||||
final class WazeRtCodec {
|
||||
private WazeRtCodec() {}
|
||||
|
||||
private static String protoBase64Line(WazeProto.Element element) {
|
||||
WazeProto.Batch batch = WazeProto.Batch.newBuilder().addElement(element).build();
|
||||
return "ProtoBase64," + Base64.encodeToString(batch.toByteArray(), Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
// Upstream's reportPayload() is deliberately absent: OVERWATCH is read-only.
|
||||
|
||||
// ── Request line builders ────────────────────────────────────────────────
|
||||
|
||||
static String buildClientInfoLine(DeviceIdentity device, double lon, double lat) {
|
||||
// Position is jittered by up to +/-500m before encoding (matches official).
|
||||
double lonOffset = ((Math.random() - 0.5) * 1000.0) / (Math.cos(Math.toRadians(lat)) * 111320.0);
|
||||
double latOffset = ((Math.random() - 0.5) * 1000.0) / 110574.0;
|
||||
|
||||
WazeProto.Coordinate pos = WazeProto.Coordinate.newBuilder()
|
||||
.setLonTimes1000000((int) Math.round((lon + lonOffset) * 1_000_000.0))
|
||||
.setLatTimes1000000((int) Math.round((lat + latOffset) * 1_000_000.0))
|
||||
.build();
|
||||
|
||||
WazeProto.Display display = WazeProto.Display.newBuilder()
|
||||
.setType(WazeProto.Display.Type.BUILT_IN)
|
||||
.setWidth(device.screenW)
|
||||
.setHeight(device.screenH)
|
||||
.build();
|
||||
|
||||
WazeProto.ClientInfo ci = WazeProto.ClientInfo.newBuilder()
|
||||
.setProtocol(WazeConstants.PROTOCOL_VERSION)
|
||||
.setClientVersion(WazeConstants.APP_VERSION)
|
||||
.setLastPosition(pos)
|
||||
.setManufacturer(device.manufacturer)
|
||||
.setModel(device.model)
|
||||
.setOsVersion(device.osVersion)
|
||||
.setLocale("en")
|
||||
.setInstallationId(device.installationId)
|
||||
.setDeviceType(WazeProto.DeviceType.ANDROID_DEVICE)
|
||||
.setAppType(WazeProto.AppType.WAZE)
|
||||
.addDisplay(display)
|
||||
.setOsLanguageId("en")
|
||||
.setSessionUuid(UUID.randomUUID().toString())
|
||||
.setCurrentTimeMillis(System.currentTimeMillis())
|
||||
.setAppFlavor(WazeProto.AppFlavor.ALPHA)
|
||||
.build();
|
||||
|
||||
return protoBase64Line(WazeProto.Element.newBuilder().setClientInfo(ci).build());
|
||||
}
|
||||
|
||||
static String buildRegisterLine() {
|
||||
return protoBase64Line(WazeProto.Element.newBuilder()
|
||||
.setRegister(WazeProto.Register.getDefaultInstance()).build());
|
||||
}
|
||||
|
||||
static String buildLoginLine(String community, String secret) {
|
||||
WazeProto.PasswordCredential cred = WazeProto.PasswordCredential.newBuilder()
|
||||
.setUsername(community)
|
||||
.setPassword(secret)
|
||||
.build();
|
||||
WazeProto.LoginRequest lr = WazeProto.LoginRequest.newBuilder()
|
||||
.setPasswordCredential(cred)
|
||||
.setReason(WazeProto.LoginRequest.LoginReason.NORMAL)
|
||||
.build();
|
||||
return protoBase64Line(WazeProto.Element.newBuilder().setLoginRequest(lr).build());
|
||||
}
|
||||
|
||||
static String buildAdsLine() {
|
||||
return protoBase64Line(WazeProto.Element.newBuilder()
|
||||
.setReportAdsSetting(WazeProto.ReportAdsSettings.getDefaultInstance()).build());
|
||||
}
|
||||
|
||||
/** uid request header = base64(UID{id=serverSessionId, secretKey=secretKey}). */
|
||||
static String buildUidHeader(WazeSessionInfo session) {
|
||||
WazeProto.UID uid = WazeProto.UID.newBuilder()
|
||||
.setId(session.serverSessionId)
|
||||
.setSecretKey(session.secretKey)
|
||||
.build();
|
||||
return Base64.encodeToString(uid.toByteArray(), Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
// ── Raw command-line builders (sent as-is as the /command body) ──────────
|
||||
|
||||
private static String f6(double v) { return String.format(Locale.US, "%.6f", v); }
|
||||
|
||||
/** bbox = [lonMin, latMin, lonMax, latMax]. */
|
||||
static String mapDisplayedCommand(double lonMin, double latMin, double lonMax, double latMax) {
|
||||
double midLon = (lonMin + lonMax) / 2.0;
|
||||
double midLat = (latMin + latMax) / 2.0;
|
||||
return "MapDisplayed,"
|
||||
+ f6(lonMin) + "," + f6(latMax) + "," + f6(lonMax) + "," + f6(latMax) + ","
|
||||
+ f6(lonMax) + "," + f6(latMin) + "," + f6(lonMin) + "," + f6(latMin) + ","
|
||||
+ f6(midLon) + "," + f6(midLat) + ",67186,"
|
||||
+ f6(lonMin) + "," + f6(latMax) + "," + f6(lonMax) + "," + f6(latMax) + ","
|
||||
+ f6(lonMax) + "," + f6(latMin) + "," + f6(lonMin) + "," + f6(latMin);
|
||||
}
|
||||
|
||||
/** Default SeeMe level (matches the handshake's original no-arg behavior). */
|
||||
static String seeMeCommand() { return seeMeCommand(1); }
|
||||
|
||||
/** mode 1 = handshake SeeMe; mode 2 = the post-report SeeMe used to close
|
||||
* out the simulateDriving sequence. Wire form: "SeeMe," + level + ",2,T,T,T,1,-1,1,7". */
|
||||
static String seeMeCommand(int mode) {
|
||||
return "SeeMe," + mode + ",2,T,T,T,1,-1,1,7";
|
||||
}
|
||||
|
||||
static String setMoodCommand(){ return "SetMood,1"; }
|
||||
static String locationCommand(double lon, double lat) {
|
||||
return "Location," + lon + "," + lat;
|
||||
}
|
||||
|
||||
/**
|
||||
* "At" position update carrying the road-snap result: fromNode/toNode are the
|
||||
* tile-local directional node indices from a {@code SegmentMatch}, or -1/-1 when
|
||||
* no segment matched. lon/lat formatted identically to {@link #locationCommand}
|
||||
* (plain concatenation, not fixed-decimal), as the Waze client formats it.
|
||||
*/
|
||||
static String atCommand(double lon, double lat, int heading, long fromNode, long toNode) {
|
||||
return "At," + lon + "," + lat + ",0," + heading + ",1," + fromNode + "," + toNode + ",T,0,-1,-1,0";
|
||||
}
|
||||
|
||||
/** Handshake = SeeMe + SetMood + Location + MapDisplayed, newline-joined, one POST. */
|
||||
static String handshakePayload(double lon, double lat) {
|
||||
double[] b = circleToBox(lon, lat);
|
||||
return seeMeCommand() + "\n" + setMoodCommand() + "\n" + locationCommand(lon, lat)
|
||||
+ "\n" + mapDisplayedCommand(b[0], b[1], b[2], b[3]);
|
||||
}
|
||||
|
||||
/** Default ~0.018deg lon x 0.015deg lat box around a point (matches official). */
|
||||
static double[] circleToBox(double lon, double lat) {
|
||||
return new double[]{ lon - 0.018, lat - 0.015, lon + 0.018, lat + 0.015 };
|
||||
}
|
||||
|
||||
// ── Response parsing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts removed-alert uuids from a batch. The RT server signals a cleared
|
||||
* alert with an {@code old_command} line of the form {@code "RmAlert,<uuid>"}
|
||||
* (NOT a RemoveAlertAction message; verified on the live feed). Strip the prefix
|
||||
* and trim to get the uuid.
|
||||
*/
|
||||
static List<String> parseRemovedAlertIds(WazeProto.Batch batch) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (WazeProto.Element el : batch.getElementList()) {
|
||||
String oc = el.getOldCommand();
|
||||
if (oc == null) continue;
|
||||
oc = oc.trim();
|
||||
if (oc.startsWith("RmAlert,")) {
|
||||
out.add(oc.substring("RmAlert,".length()).trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<WazeAlert> parseAlerts(WazeProto.Batch batch) {
|
||||
List<WazeAlert> out = new ArrayList<>();
|
||||
for (WazeProto.Element el : batch.getElementList()) {
|
||||
if (!el.hasAddAlertAction()) continue;
|
||||
WazeProto.AddAlertAction aaa = el.getAddAlertAction();
|
||||
if (!aaa.hasRealtimeAlert()) continue;
|
||||
WazeProto.RealtimeAlert ra = aaa.getRealtimeAlert();
|
||||
if (!ra.hasAlertInfo()) continue;
|
||||
WazeProto.AlertInfo info = ra.getAlertInfo();
|
||||
if (!info.hasPosition()) continue;
|
||||
WazeProto.Coordinate c = info.getPosition();
|
||||
|
||||
// Longitude is an unsigned-32-bit micro-degree value; map to signed range.
|
||||
long lonRaw = ((long) c.getLonTimes1000000()) & 0xFFFFFFFFL;
|
||||
if (lonRaw >= 0x80000000L) lonRaw -= 0x100000000L;
|
||||
double lon = lonRaw / 1_000_000.0;
|
||||
double lat = c.getLatTimes1000000() / 1_000_000.0;
|
||||
|
||||
String type = typeName(info.getType());
|
||||
String subtype = subTypeName(info.getSubType());
|
||||
int magvar = info.getAzymuth();
|
||||
|
||||
long reportTime = 0L;
|
||||
Integer thumbs = null;
|
||||
String street = null, city = null;
|
||||
if (ra.hasAlertReportingInfo()) {
|
||||
WazeProto.AlertReportingInfo ri = ra.getAlertReportingInfo();
|
||||
reportTime = ri.getReportTime();
|
||||
if (ri.getThumbsUpCount() > 0) thumbs = ri.getThumbsUpCount();
|
||||
String s = ri.getAlertAddress().getStreet();
|
||||
String ci2 = ri.getAlertAddress().getCity();
|
||||
if (s != null && !s.isEmpty()) street = s;
|
||||
if (ci2 != null && !ci2.isEmpty()) city = ci2;
|
||||
}
|
||||
long pubMillis = reportTime > 0 ? reportTime * 1000L : System.currentTimeMillis();
|
||||
|
||||
out.add(new WazeAlert(ra.getAlertUuid(), ra.getId(), type, subtype,
|
||||
lon, lat, magvar, pubMillis, thumbs, street, city));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-enum → wire name, as the SABRE consumer expects it: the generated enum
|
||||
* constant name, except the unnamed types (UNKNOWN_TYPE and
|
||||
* the reserved __NOT_IN_USE__ values) collapse to "UNKNOWN".
|
||||
*/
|
||||
private static String typeName(WazeProto.AlertType t) {
|
||||
String n = t.name();
|
||||
if (n.equals("UNKNOWN_TYPE") || n.startsWith("__NOT_IN_USE")) return "UNKNOWN";
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtype-enum → wire name, as the SABRE consumer expects it:
|
||||
* the enum constant name, except NO_SUBTYPE and the reserved __NOT_IN_USE__
|
||||
* values map to "" so the caller falls back to the type name.
|
||||
*/
|
||||
private static String subTypeName(WazeProto.AlertSubType s) {
|
||||
String n = s.name();
|
||||
if (n.equals("NO_SUBTYPE") || n.startsWith("__NOT_IN_USE")) return "";
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.soulstone.overwatch.data.settings.SecureStore;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OVERWATCH's driver for the vendored Waze RT protocol layer: owns the anonymous
|
||||
* account, the session lifecycle, the alert cache and the rate limits, and hands
|
||||
* back just the POLICE alerts near a point.
|
||||
*
|
||||
* <p>This class is OVERWATCH's own (the classes it drives are vendored from
|
||||
* highway-radar-sabre-plus — see LICENSE in this directory). It is a slimmed
|
||||
* equivalent of that project's WazeProtocolSource: no Highway Radar plumbing, no
|
||||
* reporting, no tile decoding, no keepalive thread.
|
||||
*
|
||||
* <p><b>What talking to Waze costs the user.</b> The RT protocol is the one the
|
||||
* Waze app itself speaks. Using it means registering an anonymous account with
|
||||
* Waze (username/password minted by Waze, stored encrypted on-device) and sending
|
||||
* a position with each query — jittered by up to 500 m by the vendored codec, but
|
||||
* still a rough location. That is a real disclosure, so the backend is opt-in and
|
||||
* the Settings screen says so plainly before the user turns it on.
|
||||
*
|
||||
* <p><b>Rate limits.</b> Waze caps anonymous registrations per device per day, so
|
||||
* a minted account is persisted and reused, registrations are counted against
|
||||
* {@link WazeConstants#MAX_ACCOUNTS_PER_DAY} in a rolling 24 h window, and a
|
||||
* rejected account backs off exponentially (30 s → 10 min) instead of spinning the
|
||||
* register→login loop. A generic failure (5xx, network flap) backs off 15 s.
|
||||
*
|
||||
* <p>Every public method is synchronized: a poll can overlap a Settings action
|
||||
* ("forget account") and both touch the session and the cache.
|
||||
*/
|
||||
public final class WazeRtFetcher {
|
||||
|
||||
private static final String TAG = "WazeRtFetcher";
|
||||
private static final String PREFS = "overwatch_wazert";
|
||||
private static final String KEY_ACCOUNT = "wazert_account"; // SecureStore (encrypted)
|
||||
private static final String KEY_REG_COUNT = "reg_count"; // plain prefs (not secret)
|
||||
private static final String KEY_REG_WINDOW = "reg_window_start";
|
||||
|
||||
/** Never sweep a box tighter than this: the user is moving, and a 2 km box
|
||||
* would leave nothing ahead of them by the next poll. Matches upstream. */
|
||||
private static final double MIN_QUERY_RADIUS_M = 8000.0;
|
||||
/** Progressively smaller boxes defeat the server's viewport-size thinning. */
|
||||
private static final int SHRINK_STEPS = 5;
|
||||
private static final long QUERY_BUDGET_MS = 10_000L;
|
||||
/** A brand-new account's first command often draws an in-band 504 "Retry";
|
||||
* it succeeds on the next attempt, so absorb it rather than lose a poll. */
|
||||
private static final int HANDSHAKE_ATTEMPTS = 3;
|
||||
|
||||
private static final long BACKOFF_BASE_MS = 30_000L;
|
||||
private static final long BACKOFF_MAX_MS = 10 * 60_000L;
|
||||
private static final long GENERIC_FAIL_BACKOFF_MS = 15_000L;
|
||||
private static final long DAY_MS = 24 * 3600_000L;
|
||||
|
||||
/** One Waze police report, reduced to what OVERWATCH scores. */
|
||||
public static final class PoliceAlert {
|
||||
public final String uuid;
|
||||
public final String subtype; // e.g. POLICE_WITH_MOBILE_CAMERA; "" when unspecified
|
||||
public final double lat;
|
||||
public final double lon;
|
||||
public final long pubMillis;
|
||||
public final int thumbsUp; // crowd corroboration; 0 when absent
|
||||
|
||||
PoliceAlert(String uuid, String subtype, double lat, double lon, long pubMillis, int thumbsUp) {
|
||||
this.uuid = uuid;
|
||||
this.subtype = subtype;
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
this.pubMillis = pubMillis;
|
||||
this.thumbsUp = thumbsUp;
|
||||
}
|
||||
}
|
||||
|
||||
private final Context ctx;
|
||||
private final String region;
|
||||
private final WazeAlertCache cache = new WazeAlertCache();
|
||||
|
||||
private WazeSession session;
|
||||
private int consecutiveRejections = 0;
|
||||
private long backoffUntilMs = 0L;
|
||||
|
||||
public WazeRtFetcher(Context context, String region) {
|
||||
this.ctx = context.getApplicationContext();
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking: ensure a session, sweep the area, and return the cached POLICE
|
||||
* alerts. Throws on failure (the caller turns that into a status message);
|
||||
* the thrown message is safe to show, it never contains credentials.
|
||||
*/
|
||||
public synchronized List<PoliceAlert> fetchPoliceNear(double lat, double lon, double radiusMeters)
|
||||
throws Exception {
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
if (now < backoffUntilMs) {
|
||||
throw new WazeExceptions.WazeOperationException(
|
||||
"backing off " + ((backoffUntilMs - now) / 1000) + "s after a rejected session");
|
||||
}
|
||||
try {
|
||||
queryArea(lat, lon, Math.max(radiusMeters, MIN_QUERY_RADIUS_M));
|
||||
consecutiveRejections = 0;
|
||||
backoffUntilMs = 0L;
|
||||
return police(cache.snapshot());
|
||||
} catch (WazeExceptions.AccountRejectedException e) {
|
||||
// Waze disowned this account (they get purged). Drop it so the next
|
||||
// attempt mints a fresh one, and back off so a persistent rejection
|
||||
// cannot burn the daily registration cap in seconds.
|
||||
forgetAccount();
|
||||
session = null;
|
||||
consecutiveRejections++;
|
||||
setBackoff();
|
||||
throw e;
|
||||
} catch (WazeExceptions.SessionExpiredException e) {
|
||||
session = null; // credentials are still good, just re-login next poll
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
if (backoffUntilMs <= SystemClock.elapsedRealtime()) {
|
||||
backoffUntilMs = SystemClock.elapsedRealtime() + GENERIC_FAIL_BACKOFF_MS;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the session (not the account) so the next fetch logs in again. */
|
||||
public synchronized void reset() {
|
||||
session = null;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// ── session + query ──────────────────────────────────────────────────────
|
||||
|
||||
private void queryArea(double lat, double lon, double radiusMeters) throws Exception {
|
||||
if (session == null) session = restoreOrCreateSession();
|
||||
boolean wasUnregistered = session.getCredentials() == null;
|
||||
if (wasUnregistered && !canRegisterToday()) {
|
||||
throw new WazeExceptions.WazeOperationException(
|
||||
"Waze's daily anonymous-account limit is reached — try again tomorrow");
|
||||
}
|
||||
|
||||
long sessionBefore = session.currentServerSessionId();
|
||||
WazeProto.Batch handshake = prepareWithRetry(lat, lon);
|
||||
|
||||
// Credentials exist now: persist immediately so a failure in the box loop
|
||||
// below cannot lose a freshly minted account and force a wasteful
|
||||
// re-register on the next poll.
|
||||
if (wasUnregistered) {
|
||||
noteRegistration();
|
||||
saveAccount(session.getCredentials(), session.getDevice());
|
||||
Log.i(TAG, "Registered a new anonymous Waze account");
|
||||
}
|
||||
|
||||
// A new server session re-sends every active alert but sends no removal for
|
||||
// alerts that cleared while we were logged out, so a stale cache would show
|
||||
// ghosts. Defer the clear until the first box succeeds, so a failure right
|
||||
// after a re-login keeps serving the previous cache instead of blanking.
|
||||
boolean sessionChanged = session.currentServerSessionId() != sessionBefore;
|
||||
boolean cleared = false;
|
||||
|
||||
double[][] boxes = GeoBoxes.shrinkingBoxes(lon, lat, radiusMeters, SHRINK_STEPS);
|
||||
long deadline = SystemClock.elapsedRealtime() + QUERY_BUDGET_MS;
|
||||
for (double[] box : boxes) {
|
||||
if (SystemClock.elapsedRealtime() >= deadline) break; // best-effort, like the official client
|
||||
WazeProto.Batch batch = session.queryBox(GeoBoxes.shrink(box, 0.75));
|
||||
if (sessionChanged && !cleared) {
|
||||
cache.clear();
|
||||
cleared = true;
|
||||
}
|
||||
if (handshake != null) {
|
||||
// The handshake's own MapDisplayed box is a real viewport query and the
|
||||
// server will not send those alerts again this session, so merge it.
|
||||
cache.submit(new AlertQueryResult(
|
||||
WazeRtCodec.parseAlerts(handshake), WazeRtCodec.parseRemovedAlertIds(handshake)));
|
||||
handshake = null;
|
||||
}
|
||||
cache.submit(new AlertQueryResult(
|
||||
WazeRtCodec.parseAlerts(batch), WazeRtCodec.parseRemovedAlertIds(batch)));
|
||||
}
|
||||
}
|
||||
|
||||
private WazeProto.Batch prepareWithRetry(double lat, double lon) throws Exception {
|
||||
WazeExceptions.WazeOperationException last = null;
|
||||
for (int attempt = 1; attempt <= HANDSHAKE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return session.prepareForArea(lat, lon);
|
||||
} catch (WazeExceptions.WazeOperationException e) {
|
||||
last = e;
|
||||
Log.w(TAG, "Waze handshake attempt " + attempt + " failed: " + e.getMessage());
|
||||
if (attempt == HANDSHAKE_ATTEMPTS) break;
|
||||
try {
|
||||
Thread.sleep(2000L * attempt);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
/** POLICE only: OVERWATCH scores police presence, and nothing else the feed
|
||||
* carries (jams, closures, potholes) belongs in a surveillance readout. */
|
||||
private static List<PoliceAlert> police(List<WazeAlert> all) {
|
||||
List<PoliceAlert> out = new ArrayList<>();
|
||||
for (WazeAlert a : all) {
|
||||
if (!"POLICE".equals(a.type)) continue;
|
||||
if (a.uuid == null || a.uuid.isEmpty()) continue;
|
||||
out.add(new PoliceAlert(a.uuid, a.subtype == null ? "" : a.subtype,
|
||||
a.lat, a.lon, a.pubMillis, a.nThumbsUp == null ? 0 : a.nThumbsUp));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── account persistence ──────────────────────────────────────────────────
|
||||
|
||||
private WazeSession restoreOrCreateSession() {
|
||||
String blob = SecureStore.INSTANCE.get(ctx, KEY_ACCOUNT);
|
||||
if (blob == null || blob.isEmpty()) return new WazeSession(region);
|
||||
try {
|
||||
JSONObject o = new JSONObject(blob);
|
||||
WazeCredentials creds = new WazeCredentials(o.getString("community"), o.getString("secret"));
|
||||
DeviceIdentity device = new DeviceIdentity(
|
||||
o.getString("manufacturer"), o.getString("model"), o.getString("os"),
|
||||
o.getInt("w"), o.getInt("h"), o.getString("installation_id"));
|
||||
return new WazeSession(region, device, creds);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Stored Waze account unreadable, registering a new one: " + e.getMessage());
|
||||
return new WazeSession(region);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAccount(WazeCredentials c, DeviceIdentity d) {
|
||||
if (c == null || d == null) return;
|
||||
try {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("community", c.community);
|
||||
o.put("secret", c.secret);
|
||||
o.put("manufacturer", d.manufacturer);
|
||||
o.put("model", d.model);
|
||||
o.put("os", d.osVersion);
|
||||
o.put("w", d.screenW);
|
||||
o.put("h", d.screenH);
|
||||
o.put("installation_id", d.installationId);
|
||||
SecureStore.INSTANCE.put(ctx, KEY_ACCOUNT, o.toString());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Could not persist the Waze account: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Wipe the stored anonymous account. Also the Settings "forget" action. */
|
||||
public synchronized void forgetAccount() {
|
||||
SecureStore.INSTANCE.put(ctx, KEY_ACCOUNT, ""); // empty clears the entry
|
||||
cache.clear();
|
||||
session = null;
|
||||
}
|
||||
|
||||
public synchronized boolean hasAccount() {
|
||||
String blob = SecureStore.INSTANCE.get(ctx, KEY_ACCOUNT);
|
||||
return blob != null && !blob.isEmpty();
|
||||
}
|
||||
|
||||
// ── registration cap + backoff ───────────────────────────────────────────
|
||||
|
||||
private SharedPreferences prefs() {
|
||||
return ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private boolean windowRolledOver() {
|
||||
long start = prefs().getLong(KEY_REG_WINDOW, 0L);
|
||||
return start == 0L || System.currentTimeMillis() - start >= DAY_MS;
|
||||
}
|
||||
|
||||
private boolean canRegisterToday() {
|
||||
if (windowRolledOver()) return true;
|
||||
return prefs().getInt(KEY_REG_COUNT, 0) < WazeConstants.MAX_ACCOUNTS_PER_DAY;
|
||||
}
|
||||
|
||||
private void noteRegistration() {
|
||||
SharedPreferences p = prefs();
|
||||
if (windowRolledOver()) {
|
||||
p.edit().putLong(KEY_REG_WINDOW, System.currentTimeMillis()).putInt(KEY_REG_COUNT, 1).apply();
|
||||
} else {
|
||||
p.edit().putInt(KEY_REG_COUNT, p.getInt(KEY_REG_COUNT, 0) + 1).apply();
|
||||
}
|
||||
}
|
||||
|
||||
private void setBackoff() {
|
||||
int step = Math.min(Math.max(consecutiveRejections, 1), 5);
|
||||
long delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * (1L << (step - 1)));
|
||||
backoffUntilMs = SystemClock.elapsedRealtime() + delay;
|
||||
Log.w(TAG, "Waze RT backing off " + (delay / 1000) + "s");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored from highway-radar-sabre-plus (MIT). See LICENSE in this directory.
|
||||
// Changed for OVERWATCH: package declaration, and the user-report submission
|
||||
// methods are removed — OVERWATCH only reads alerts, it never reports.
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* One Waze "RT" protocol session: mints an anonymous account (register), logs in,
|
||||
* and runs alert queries. Single-slot, synchronous (runs on the caller's worker
|
||||
* thread). Follows the Waze client's own session sequence for the fetch path
|
||||
* (no keepalive or session pooling).
|
||||
*
|
||||
* No backend or pre-shared credentials are needed: register() asks Waze itself for
|
||||
* a fresh username/password. Credentials + device can be injected (persisted across
|
||||
* runs) so we don't re-register on every fetch, Waze caps anonymous accounts per day.
|
||||
*/
|
||||
final class WazeSession {
|
||||
private static final String TAG = "WazeRT";
|
||||
|
||||
private final String region;
|
||||
private final DeviceIdentity device;
|
||||
private final WazeHttpClient http;
|
||||
|
||||
private WazeCredentials credentials; // from register() or injected
|
||||
private WazeSessionInfo session; // from login()
|
||||
private int seqCount = 1;
|
||||
private long lastRequestMs = 0L;
|
||||
/** Server session the handshake last ran on (0 = none), so it runs once per login. */
|
||||
private long handshakedSessionId = 0L;
|
||||
|
||||
WazeSession(String region) {
|
||||
this(region, DeviceIdentity.random(), null);
|
||||
}
|
||||
|
||||
WazeSession(String region, DeviceIdentity device, WazeCredentials credentials) {
|
||||
this(region, device, credentials, new WazeHttpClient());
|
||||
}
|
||||
|
||||
/** Test seam: same as above with the transport supplied by the caller. */
|
||||
WazeSession(String region, DeviceIdentity device, WazeCredentials credentials, WazeHttpClient http) {
|
||||
this.region = region;
|
||||
this.device = device;
|
||||
this.credentials = credentials;
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
WazeCredentials getCredentials() { return credentials; }
|
||||
DeviceIdentity getDevice() { return device; }
|
||||
|
||||
/** Server session id (0 if not logged in), lets the caller detect a re-login. */
|
||||
long currentServerSessionId() { return session != null ? session.serverSessionId : 0L; }
|
||||
|
||||
/** Drop the logged-in session but KEEP credentials, so the next call re-logs in
|
||||
* (with the in-memory account) instead of registering a brand-new one. */
|
||||
void invalidateSession() { session = null; }
|
||||
|
||||
/**
|
||||
* Inspect a parsed response batch for in-band errors the server returns with an
|
||||
* HTTP 200: a {@code ServerError} element, or a {@code LoginError}. Without this,
|
||||
* a server that invalidates the session but replies 200 looks like success and
|
||||
* the session zombies: {@code lastRequestMs} keeps updating so it never idles
|
||||
* out, and no alerts are ever merged again.
|
||||
*/
|
||||
static void checkErrors(WazeProto.Batch batch)
|
||||
throws WazeExceptions.AccountRejectedException,
|
||||
WazeExceptions.SessionExpiredException,
|
||||
WazeExceptions.WazeOperationException {
|
||||
for (WazeProto.Element el : batch.getElementList()) {
|
||||
if (el.hasError()) {
|
||||
WazeProto.ServerError err = el.getError();
|
||||
int code = err.getCode();
|
||||
String desc = err.getDescription().toLowerCase(Locale.US);
|
||||
if (desc.contains("relogin") || desc.contains("unknown userid")
|
||||
|| desc.contains("secretkey missing") || desc.contains("secret key missing"))
|
||||
throw new WazeExceptions.SessionExpiredException(
|
||||
"server error: " + err.getDescription());
|
||||
if (code >= 400 && code < 500)
|
||||
throw new WazeExceptions.AccountRejectedException(
|
||||
"server error " + code + ": " + err.getDescription());
|
||||
if (code >= 500)
|
||||
throw new WazeExceptions.WazeOperationException(
|
||||
"server error " + code + ": " + err.getDescription());
|
||||
// Informational / unknown (incl. the proto default code 0), the
|
||||
// official ignores these; a normal 200 batch can legitimately carry
|
||||
// one, so do NOT fail the whole refresh over it.
|
||||
Log.w(TAG, "Ignoring non-fatal server error " + code + ": " + err.getDescription());
|
||||
}
|
||||
if (el.hasLoginError()) throwForLoginError(el.getLoginError().getErrorType());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Waze auth-error type to an exception. Transient server-side problems
|
||||
* (INTERNAL_ISSUES / UNKNOWN) are operational, they must NOT nuke a good account
|
||||
* by triggering a re-register; only genuine credential/authorization failures do.
|
||||
*/
|
||||
private static void throwForLoginError(WazeProto.LoginError.AuthErrorType t)
|
||||
throws WazeExceptions.AccountRejectedException, WazeExceptions.WazeOperationException {
|
||||
if (t == WazeProto.LoginError.AuthErrorType.INTERNAL_ISSUES
|
||||
|| t == WazeProto.LoginError.AuthErrorType.UNKNOWN_ERROR)
|
||||
throw new WazeExceptions.WazeOperationException("login error: " + t);
|
||||
throw new WazeExceptions.AccountRejectedException("login error: " + t);
|
||||
}
|
||||
|
||||
private String url(String path) { return "https://" + WazeConstants.rtHost(region) + path; }
|
||||
private String nextSeq() { return String.valueOf(seqCount++); }
|
||||
|
||||
private boolean sessionValid() {
|
||||
return session != null
|
||||
&& (SystemClock.elapsedRealtime() - lastRequestMs) < WazeConstants.SESSION_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ── register ─────────────────────────────────────────────────────────────
|
||||
|
||||
void register(double lon, double lat) throws Exception {
|
||||
String body = WazeRtCodec.buildClientInfoLine(device, lon, lat)
|
||||
+ "\n" + WazeRtCodec.buildRegisterLine();
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("User-Agent", WazeConstants.APP_VERSION); // bare "5.17.1.0"
|
||||
headers.put("x-waze-network-version", "3");
|
||||
headers.put("sequence-number", nextSeq());
|
||||
|
||||
WazeHttpClient.HttpResult r = http.post(url(WazeConstants.PATH_STATIC),
|
||||
body.getBytes(StandardCharsets.UTF_8), headers);
|
||||
if (r.code >= 400) throw new WazeExceptions.WazeOperationException("register HTTP " + r.code);
|
||||
if (r.body.length == 0) throw new WazeExceptions.WazeOperationException("empty register response");
|
||||
|
||||
WazeProto.Batch batch = WazeProto.Batch.parseFrom(r.body);
|
||||
checkErrors(batch);
|
||||
for (WazeProto.Element el : batch.getElementList()) {
|
||||
if (el.hasRegisterSuccessful()) {
|
||||
WazeProto.RegisterSuccessful rs = el.getRegisterSuccessful();
|
||||
String user = rs.getUsername(), pass = rs.getPassword();
|
||||
if (user == null || user.isEmpty()) throw new WazeExceptions.WazeOperationException("empty community from register");
|
||||
if (pass == null || pass.isEmpty()) throw new WazeExceptions.WazeOperationException("empty secret from register");
|
||||
credentials = new WazeCredentials(user, pass);
|
||||
Log.d(TAG, "Registered anonymous account: " + user);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new WazeExceptions.WazeOperationException("register: no RegisterSuccessful element (" + r.body.length + "B)");
|
||||
}
|
||||
|
||||
// ── login ────────────────────────────────────────────────────────────────
|
||||
|
||||
void login(double lon, double lat) throws Exception {
|
||||
if (credentials == null) throw new WazeExceptions.WazeOperationException("login before register");
|
||||
http.clearCookies();
|
||||
|
||||
String body = WazeRtCodec.buildClientInfoLine(device, lon, lat)
|
||||
+ "\n" + WazeRtCodec.buildLoginLine(credentials.community, credentials.secret)
|
||||
+ "\n" + WazeRtCodec.buildAdsLine();
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("User-Agent", "waze/" + WazeConstants.APP_VERSION); // login uses the "waze/" prefix
|
||||
headers.put("cache-control", "no-cache");
|
||||
headers.put("sequence-number", nextSeq());
|
||||
headers.put("x-waze-network-version", "3");
|
||||
headers.put("x-waze-wait-timeout", WazeConstants.WAIT_TIMEOUT_LOGIN);
|
||||
|
||||
WazeHttpClient.HttpResult r = http.post(url(WazeConstants.PATH_LOGIN),
|
||||
body.getBytes(StandardCharsets.UTF_8), headers);
|
||||
// A 4xx here means Waze no longer accepts these credentials (anonymous
|
||||
// accounts get purged): classify as AccountRejected so the caller clears
|
||||
// the persisted account and re-registers instead of failing forever.
|
||||
if (r.code >= 400 && r.code < 500)
|
||||
throw new WazeExceptions.AccountRejectedException("login HTTP " + r.code);
|
||||
if (r.code >= 500) throw new WazeExceptions.WazeOperationException("login HTTP " + r.code);
|
||||
if (r.body.length == 0) throw new WazeExceptions.WazeOperationException("empty login response");
|
||||
|
||||
WazeProto.Batch batch = WazeProto.Batch.parseFrom(r.body);
|
||||
checkErrors(batch); // in-band LoginError → specific exception, not blanket reject
|
||||
for (WazeProto.Element el : batch.getElementList()) {
|
||||
// A login failure can arrive nested in the LoginResponse oneof (not just
|
||||
// as a top-level LoginError element), classify it too, so a transient
|
||||
// INTERNAL_ISSUES doesn't fall through to the blanket AccountRejected
|
||||
// below and needlessly re-register.
|
||||
if (el.hasLoginResponse() && el.getLoginResponse().hasLoginError())
|
||||
throwForLoginError(el.getLoginResponse().getLoginError().getErrorType());
|
||||
if (el.hasLoginResponse() && el.getLoginResponse().hasLoginSuccess()) {
|
||||
WazeProto.LoginSuccess s = el.getLoginResponse().getLoginSuccess();
|
||||
if (s.getServerSessionId() == 0) throw new WazeExceptions.WazeOperationException("zero serverSessionId");
|
||||
if (s.getSecretKey() == null || s.getSecretKey().isEmpty()) throw new WazeExceptions.WazeOperationException("empty secretKey");
|
||||
session = new WazeSessionInfo(s.getServerSessionId(), s.getSecretKey(),
|
||||
String.valueOf(s.getGlobalUserId()));
|
||||
seqCount = 2;
|
||||
lastRequestMs = SystemClock.elapsedRealtime();
|
||||
Log.d(TAG, "Login OK: sessionId=" + s.getServerSessionId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new WazeExceptions.AccountRejectedException("login: no LoginSuccess element (" + r.body.length + "B)");
|
||||
}
|
||||
|
||||
// ── command + query ──────────────────────────────────────────────────────
|
||||
|
||||
private WazeProto.Batch command(String payload) throws Exception {
|
||||
if (session == null) throw new WazeExceptions.SessionExpiredException("command before login");
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("User-Agent", WazeConstants.APP_VERSION); // bare "5.17.1.0"
|
||||
headers.put("cache-control", "no-cache");
|
||||
headers.put("sequence-number", nextSeq());
|
||||
headers.put("x-waze-network-version", "3");
|
||||
headers.put("x-waze-wait-timeout", WazeConstants.WAIT_TIMEOUT_COMMAND);
|
||||
headers.put("uid", WazeRtCodec.buildUidHeader(session));
|
||||
|
||||
WazeHttpClient.HttpResult r = http.post(url(WazeConstants.PATH_COMMAND),
|
||||
payload.getBytes(StandardCharsets.UTF_8), headers);
|
||||
// 4xx on a command = the server no longer honors this session → re-login.
|
||||
if (r.code >= 400 && r.code < 500) {
|
||||
session = null;
|
||||
throw new WazeExceptions.SessionExpiredException("command HTTP " + r.code);
|
||||
}
|
||||
if (r.code >= 500) throw new WazeExceptions.WazeOperationException("command HTTP " + r.code);
|
||||
if (r.body.length == 0) throw new WazeExceptions.WazeOperationException("empty command response");
|
||||
WazeProto.Batch batch = WazeProto.Batch.parseFrom(r.body);
|
||||
// Check for an in-band error BEFORE marking the session healthy, so a zombie
|
||||
// session (HTTP 200 + "please relogin") doesn't keep refreshing lastRequestMs.
|
||||
try {
|
||||
checkErrors(batch);
|
||||
} catch (WazeExceptions.SessionExpiredException e) {
|
||||
session = null; // force ensureReady to re-login next time
|
||||
throw e;
|
||||
}
|
||||
lastRequestMs = SystemClock.elapsedRealtime();
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** Register (if no creds) and log in (if no valid session). */
|
||||
private void ensureReady(double lon, double lat) throws Exception {
|
||||
if (credentials == null) register(lon, lat);
|
||||
if (!sessionValid()) login(lon, lat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (if no credentials) + log in (if no valid session) + run the
|
||||
* SeeMe/SetMood/Location/MapDisplayed handshake once per login, mirroring the
|
||||
* official client's ensureAlive (login + handshake only when the session is
|
||||
* new or idle; nothing at all otherwise).
|
||||
*
|
||||
* The handshake's MapDisplayed box is a real viewport query, and the RT server
|
||||
* sends each alert once per session, so its response is returned for the
|
||||
* caller to merge into the alert cache instead of being discarded. Returns
|
||||
* null when the session was already handshaken (no request was sent).
|
||||
*/
|
||||
WazeProto.Batch prepareForArea(double lat, double lon) throws Exception {
|
||||
ensureReady(lon, lat);
|
||||
if (session != null && session.serverSessionId == handshakedSessionId) return null;
|
||||
WazeProto.Batch batch = command(WazeRtCodec.handshakePayload(lon, lat));
|
||||
handshakedSessionId = session != null ? session.serverSessionId : 0L;
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* One MapDisplayed query for a bbox {@code [lonMin, latMin, lonMax, latMax]};
|
||||
* returns the raw batch so the caller can parse both added alerts and removed
|
||||
* ids from it (the RT response carries both).
|
||||
*/
|
||||
WazeProto.Batch queryBox(double[] bbox) throws Exception {
|
||||
return command(WazeRtCodec.mapDisplayedCommand(bbox[0], bbox[1], bbox[2], bbox[3]));
|
||||
}
|
||||
|
||||
/** Full flow for a single box: prepare + one query. Used by the debug selfTest. */
|
||||
List<WazeAlert> fetchArea(double lat, double lon, double radiusMeters) throws Exception {
|
||||
prepareForArea(lat, lon);
|
||||
double latDelta = radiusMeters / WazeConstants.M_PER_DEG_LAT;
|
||||
double lonDelta = radiusMeters / WazeConstants.mPerDegLon(lat);
|
||||
WazeProto.Batch batch = queryBox(new double[]{
|
||||
lon - lonDelta, lat - latDelta, lon + lonDelta, lat + latDelta});
|
||||
return WazeRtCodec.parseAlerts(batch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.soulstone.overwatch.scan.wazert;
|
||||
|
||||
// Vendored unmodified from highway-radar-sabre-plus (MIT) except for the
|
||||
// package declaration. See LICENSE in this directory.
|
||||
|
||||
/** Authenticated session state returned by a successful login. */
|
||||
final class WazeSessionInfo {
|
||||
final long serverSessionId;
|
||||
final String secretKey;
|
||||
final String globalUserId;
|
||||
|
||||
WazeSessionInfo(long serverSessionId, String secretKey, String globalUserId) {
|
||||
this.serverSessionId = serverSessionId;
|
||||
this.secretKey = secretKey;
|
||||
this.globalUserId = globalUserId;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,19 @@ class Settings private constructor(
|
||||
|
||||
enum class ThemeMode { SYSTEM, DARK, LIGHT }
|
||||
|
||||
/**
|
||||
* Which backend the WAZE source reads from.
|
||||
*
|
||||
* [OPENWEB_NINJA] is the default and stays the default: it costs money but
|
||||
* tells Waze nothing about the user, because the request is made by OpenWeb
|
||||
* Ninja's servers rather than the phone. [DIRECT] is free and live but speaks
|
||||
* the Waze app's own protocol, which means registering an anonymous Waze
|
||||
* account and sending a (jittered) position on every poll. That is a real
|
||||
* disclosure to a Google service, so it is never selected on the user's
|
||||
* behalf — they choose it in Settings after reading what it does.
|
||||
*/
|
||||
enum class WazeBackend { OPENWEB_NINJA, DIRECT }
|
||||
|
||||
private val _bleEnabled = MutableStateFlow(prefs.getBoolean(KEY_BLE, true))
|
||||
val bleEnabled: StateFlow<Boolean> = _bleEnabled.asStateFlow()
|
||||
|
||||
@@ -55,6 +68,12 @@ class Settings private constructor(
|
||||
private val _wazeApiKey = MutableStateFlow(SecureStore.get(appContext, KEY_WAZE_API_KEY) ?: "")
|
||||
val wazeApiKey: StateFlow<String> = _wazeApiKey.asStateFlow()
|
||||
|
||||
private val _wazeBackend = MutableStateFlow(
|
||||
runCatching { WazeBackend.valueOf(prefs.getString(KEY_WAZE_BACKEND, null) ?: "") }
|
||||
.getOrDefault(WazeBackend.OPENWEB_NINJA)
|
||||
)
|
||||
val wazeBackend: StateFlow<WazeBackend> = _wazeBackend.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow(
|
||||
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
||||
)
|
||||
@@ -93,6 +112,11 @@ class Settings private constructor(
|
||||
SecureStore.put(appContext, KEY_LEGACY_WAZE_PROXY_TOKEN, "")
|
||||
}
|
||||
|
||||
fun setWazeBackend(v: WazeBackend) {
|
||||
prefs.edit { putString(KEY_WAZE_BACKEND, v.name) }
|
||||
_wazeBackend.value = v
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
prefs.edit { putString(KEY_THEME, mode.name) }
|
||||
_themeMode.value = mode
|
||||
@@ -118,6 +142,7 @@ class Settings private constructor(
|
||||
private const val KEY_MIC = "src_mic"
|
||||
private const val KEY_DETECTION_RADIUS = "detection_radius_m"
|
||||
private const val KEY_WAZE_API_KEY = "waze_api_key"
|
||||
private const val KEY_WAZE_BACKEND = "waze_backend"
|
||||
private const val KEY_LEGACY_WAZE_PROXY_TOKEN = "waze_proxy_token"
|
||||
private const val KEY_THEME = "theme_mode"
|
||||
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||
|
||||
@@ -12,6 +12,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
// The alert and result types are shared with the other WAZE backend. Importing the
|
||||
// nested names keeps this client's body reading the way it always has. (A typealias
|
||||
// would not: Kotlin will not resolve a nested class through one.)
|
||||
import org.soulstone.overwatch.scan.WazeSource.Alert
|
||||
import org.soulstone.overwatch.scan.WazeSource.FetchResult
|
||||
|
||||
/**
|
||||
* Fetches live Waze POLICE alerts from OpenWeb Ninja's hosted Waze feed.
|
||||
@@ -34,10 +39,12 @@ import org.json.JSONObject
|
||||
* the scanner reports that instead of calling out, so Waze stays dormant rather
|
||||
* than erroring on every poll.
|
||||
*
|
||||
* Direct scraping is not an option: `waze.com/live-map/api/georss` is gated by
|
||||
* reCAPTCHA Enterprise *reputation* scoring and 403s automated clients
|
||||
* regardless of IP or headless-vs-headful browser, which is why this reads a
|
||||
* hosted feed at all.
|
||||
* Why a hosted feed at all: `waze.com/live-map/api/georss`, the endpoint every
|
||||
* scraper used, is fronted by Google's edge and 403s automated clients outright
|
||||
* — no IP, header or browser makes it answer. A direct path does exist (the Waze
|
||||
* app's own protocol, see [org.soulstone.overwatch.scan.wazert.WazeRtClient]) but
|
||||
* it means holding a Waze account and sending a position, so it is opt-in and
|
||||
* this metered-but-anonymous backend stays the default.
|
||||
*
|
||||
* Response shape (verified live 2026-09-16): `{ "data": { "alerts": [...],
|
||||
* "jams": [...] } }`, each alert carrying `alert_id`, `type`, `subtype`
|
||||
@@ -50,7 +57,7 @@ import org.json.JSONObject
|
||||
*/
|
||||
class WazeClient(
|
||||
private val apiKey: () -> String = { "" }
|
||||
) {
|
||||
) : WazeSource {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WazeClient"
|
||||
@@ -63,26 +70,21 @@ class WazeClient(
|
||||
private const val BBOX_MIN_RADIUS_M = 800.0
|
||||
}
|
||||
|
||||
override val backendName: String = "OpenWeb Ninja"
|
||||
|
||||
/** True when the user has entered an API key. False → source is unconfigured. */
|
||||
val isConfigured: Boolean get() = apiKey().isNotBlank()
|
||||
override val isConfigured: Boolean get() = apiKey().isNotBlank()
|
||||
|
||||
data class Alert(
|
||||
val uuid: String,
|
||||
val subtype: String?,
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val pubMillis: Long,
|
||||
val confidence: Int, // 0-5
|
||||
val reliability: Int // 0-10
|
||||
)
|
||||
override val unconfiguredReason: String =
|
||||
"OpenWeb Ninja API key not set — add it in Settings"
|
||||
|
||||
/** Outcome — distinguishes "no police alerts in area" from "couldn't reach the feed." */
|
||||
sealed class FetchResult {
|
||||
data class Success(val alerts: List<Alert>) : FetchResult()
|
||||
data class Failed(val reason: String) : FetchResult()
|
||||
}
|
||||
/** Slow on purpose: the feed is metered (~$0.005/request) and lags live Waze
|
||||
* by ~20 min anyway, so a faster poll would buy nothing but cost money. Kept
|
||||
* just under the DetectionStore's 5-min retention so a standing checkpoint is
|
||||
* re-submitted before it can expire and flicker out. */
|
||||
override val pollIntervalMs: Long = 240_000L
|
||||
|
||||
suspend fun fetchPoliceNear(
|
||||
override suspend fun fetchPoliceNear(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
radiusMeters: Float
|
||||
|
||||
@@ -16,25 +16,22 @@ import org.soulstone.overwatch.fusion.DetectionStore
|
||||
import org.soulstone.overwatch.fusion.SourceHealth
|
||||
|
||||
/**
|
||||
* Polls the OpenWeb Ninja Waze feed for live POLICE alerts around the current
|
||||
* Polls a [WazeSource] backend for live POLICE alerts around the current
|
||||
* location, then submits any inside [proximityMeters] and younger than
|
||||
* [MAX_AGE_MS].
|
||||
*
|
||||
* Poll cadence is deliberately slow — the feed is a metered paid API and lags
|
||||
* live Waze by ~20 min anyway, so a 4-min poll loses nothing and keeps request
|
||||
* volume (and pay-as-you-go cost, ~$0.005/req) low at ~15 req/active-hour. Kept
|
||||
* just under the DetectionStore's 5-min retention so a persistent alert (a
|
||||
* standing checkpoint) is re-submitted before it can expire and flicker out. If
|
||||
* no API key is configured the loop records the source as unreachable (with a
|
||||
* clear reason) and skips the network call rather than hammering a 401.
|
||||
* The backend supplies its own poll cadence, because the two differ by an order
|
||||
* of magnitude for good reasons: the metered hosted feed polls every 4 min to
|
||||
* keep cost down, the direct protocol every 60 s to keep its session alive. If
|
||||
* the backend is not configured (no API key, say) the loop records the source as
|
||||
* unreachable with that backend's own 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
|
||||
* moved proximity slider without a network refetch.
|
||||
*/
|
||||
class WazeScanner(
|
||||
private val store: DetectionStore,
|
||||
private val locationProvider: LocationProvider,
|
||||
private val client: WazeClient = WazeClient(),
|
||||
private val source: WazeSource,
|
||||
private val proximityMeters: () -> Float = { EVAL_RADIUS_M }
|
||||
) {
|
||||
|
||||
@@ -44,17 +41,14 @@ class WazeScanner(
|
||||
* than the camera one because police move and a report stays above
|
||||
* GREEN further out. */
|
||||
const val EVAL_RADIUS_M = 2000f
|
||||
private const val POLL_INTERVAL_MS = 240_000L
|
||||
// The hosted feed only lists still-active alerts but lags live Waze, so
|
||||
// real police sightings routinely arrive already 20-30 min old. A 10-min
|
||||
// cutoff (fine for the old direct-live feed) would drop nearly all of
|
||||
// them; 45 min matches what the feed actually serves as "current."
|
||||
// Hosted-feed alerts routinely arrive already 20-30 min old (it lags live
|
||||
// Waze), so a 10-min cutoff would drop nearly all of them. 45 min matches
|
||||
// what that feed serves as "current" and is harmless for the live backend.
|
||||
/** 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 lastAlerts: List<WazeClient.Alert> = emptyList()
|
||||
|
||||
fun start(scope: CoroutineScope): Boolean {
|
||||
if (job != null) return true
|
||||
@@ -65,41 +59,39 @@ class WazeScanner(
|
||||
while (isActive) {
|
||||
val fix = locationProvider.location.value
|
||||
if (fix != null) pollOnce(fix)
|
||||
delay(POLL_INTERVAL_MS)
|
||||
delay(source.pollIntervalMs)
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms, configured=${client.isConfigured})")
|
||||
Log.i(
|
||||
TAG,
|
||||
"WazeScanner started (backend=${source.backendName}, " +
|
||||
"interval=${source.pollIntervalMs}ms, configured=${source.isConfigured})"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
lastAlerts = emptyList()
|
||||
Log.i(TAG, "WazeScanner stopped")
|
||||
}
|
||||
|
||||
private suspend fun pollOnce(fix: Location) {
|
||||
if (!client.isConfigured) {
|
||||
SourceHealth.record(
|
||||
DetectionSource.WAZE,
|
||||
ok = false,
|
||||
message = "OpenWeb Ninja API key not set — add it in Settings"
|
||||
)
|
||||
if (!source.isConfigured) {
|
||||
SourceHealth.record(DetectionSource.WAZE, ok = false, message = source.unconfiguredReason)
|
||||
return
|
||||
}
|
||||
|
||||
when (val result = client.fetchPoliceNear(fix.latitude, fix.longitude, proximityMeters())) {
|
||||
is WazeClient.FetchResult.Failed -> {
|
||||
when (val result = source.fetchPoliceNear(fix.latitude, fix.longitude, proximityMeters())) {
|
||||
is WazeSource.FetchResult.Failed -> {
|
||||
SourceHealth.record(
|
||||
DetectionSource.WAZE,
|
||||
ok = false,
|
||||
message = "Waze feed unreachable: ${result.reason}"
|
||||
message = "${source.backendName} unreachable: ${result.reason}"
|
||||
)
|
||||
}
|
||||
is WazeClient.FetchResult.Success -> {
|
||||
is WazeSource.FetchResult.Success -> {
|
||||
SourceHealth.record(DetectionSource.WAZE, ok = true)
|
||||
lastAlerts = result.alerts
|
||||
// Deliberately no clearSource() here: re-submitting refreshes
|
||||
// still-present alerts by key (dedup) and lets vanished ones age
|
||||
// out via the store's 5-min TTL. Clearing every poll would briefly
|
||||
@@ -111,7 +103,7 @@ class WazeScanner(
|
||||
}
|
||||
|
||||
|
||||
private fun emitProximityEvents(fix: Location, alerts: List<WazeClient.Alert>) {
|
||||
private fun emitProximityEvents(fix: Location, alerts: List<WazeSource.Alert>) {
|
||||
val now = System.currentTimeMillis()
|
||||
val limit = proximityMeters()
|
||||
val out = FloatArray(1)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.soulstone.overwatch.scan
|
||||
|
||||
/**
|
||||
* A backend the WAZE detection source can read live police reports from.
|
||||
*
|
||||
* Two exist, and they are a genuine trade-off rather than one superseding the
|
||||
* other:
|
||||
*
|
||||
* - [WazeClient] reads OpenWeb Ninja's hosted Waze feed with the user's own API
|
||||
* key. Costs money per request, lags live Waze by ~20 min, and tells Waze
|
||||
* nothing about the user — the request comes from OpenWeb Ninja's servers.
|
||||
* - [org.soulstone.overwatch.scan.wazert.WazeRtClient] speaks the Waze app's own
|
||||
* protocol directly. Free, live, no key — but it registers an anonymous Waze
|
||||
* account and sends a (jittered) position with every poll, which is a real
|
||||
* disclosure to a Google service. Off by default; the user opts in.
|
||||
*
|
||||
* [WazeScanner] is written against this interface so neither backend leaks into
|
||||
* the scoring or UI layers, and so a backend can set its own poll cadence: the
|
||||
* metered one polls slowly because requests cost money, the direct one polls
|
||||
* fast enough to keep its session alive.
|
||||
*/
|
||||
interface WazeSource {
|
||||
|
||||
/** Short backend name, for status rows and logs. */
|
||||
val backendName: String
|
||||
|
||||
/** False when the backend needs setup the user has not done yet. */
|
||||
val isConfigured: Boolean
|
||||
|
||||
/** Shown to the user when [isConfigured] is false. */
|
||||
val unconfiguredReason: String
|
||||
|
||||
/** How often [WazeScanner] should poll this backend. */
|
||||
val pollIntervalMs: Long
|
||||
|
||||
/** Police reports within [radiusMeters] of the point, or a failure reason. */
|
||||
suspend fun fetchPoliceNear(lat: Double, lon: Double, radiusMeters: Float): FetchResult
|
||||
|
||||
/**
|
||||
* One police report, normalised across backends.
|
||||
*
|
||||
* [confidence] (0-5) and [reliability] (0-10) are Waze's own crowd-trust
|
||||
* numbers. A backend that cannot supply one reports 0 rather than inventing
|
||||
* a value — ConfidenceEngine treats them as bonuses over a distance floor,
|
||||
* so a missing signal costs a few points and never fabricates a detection.
|
||||
*/
|
||||
data class Alert(
|
||||
val uuid: String,
|
||||
val subtype: String?,
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val pubMillis: Long,
|
||||
val confidence: Int,
|
||||
val reliability: Int
|
||||
)
|
||||
|
||||
/** Distinguishes "no police reports in this area" from "couldn't reach the feed." */
|
||||
sealed class FetchResult {
|
||||
data class Success(val alerts: List<Alert>) : FetchResult()
|
||||
data class Failed(val reason: String) : FetchResult()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.soulstone.overwatch.scan.wazert
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.soulstone.overwatch.scan.WazeSource
|
||||
|
||||
/**
|
||||
* The WAZE source read straight from Waze, with no hosted middleman and no API
|
||||
* key: [WazeRtFetcher] speaks the protocol the Waze app itself speaks, over an
|
||||
* anonymous account it registers on first use.
|
||||
*
|
||||
* Why this exists: the public `waze.com/live-map/api/georss` endpoint every
|
||||
* scraper used is dead — it is fronted by Google's edge, which 403s automated
|
||||
* clients outright. The app protocol is a different host and a different path,
|
||||
* and it answers. The cost is that OVERWATCH is now a Waze client: it holds a
|
||||
* Waze account and sends a position (jittered up to 500 m by the protocol layer)
|
||||
* on every poll. That is why this backend is opt-in and the Settings copy says
|
||||
* so before the user turns it on.
|
||||
*
|
||||
* Poll cadence is 60 s, deliberately under the protocol's ~100 s session idle
|
||||
* timeout: a live session receives only deltas (~8 KB) while a re-login re-sends
|
||||
* the whole viewport (~200 KB), so polling faster here uses *less* data than
|
||||
* polling slowly. There is no per-request cost to weigh against it.
|
||||
*/
|
||||
class WazeRtClient(
|
||||
context: Context,
|
||||
region: String = DEFAULT_REGION
|
||||
) : WazeSource {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WazeRtClient"
|
||||
/** "na" covers North America; the fetcher maps it to the regional RT host. */
|
||||
const val DEFAULT_REGION = "na"
|
||||
private const val POLL_INTERVAL_MS = 60_000L
|
||||
}
|
||||
|
||||
private val fetcher = WazeRtFetcher(context, region)
|
||||
|
||||
override val backendName: String = "Waze direct"
|
||||
/** Nothing to configure — the account mints itself on first poll. */
|
||||
override val isConfigured: Boolean = true
|
||||
override val unconfiguredReason: String = ""
|
||||
override val pollIntervalMs: Long = POLL_INTERVAL_MS
|
||||
|
||||
override suspend fun fetchPoliceNear(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
radiusMeters: Float
|
||||
): WazeSource.FetchResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val alerts = fetcher.fetchPoliceNear(lat, lon, radiusMeters.toDouble())
|
||||
WazeSource.FetchResult.Success(
|
||||
alerts.map { a ->
|
||||
WazeSource.Alert(
|
||||
uuid = a.uuid,
|
||||
subtype = a.subtype.ifBlank { null },
|
||||
lat = a.lat,
|
||||
lon = a.lon,
|
||||
pubMillis = a.pubMillis,
|
||||
// The RT fetch path carries no confidence/reliability — those
|
||||
// live in the alert-details sub-tree the vendored proto omits —
|
||||
// but it does carry the thumbs-up count, which is the same crowd
|
||||
// corroboration expressed differently. Map it onto the 0-10
|
||||
// reliability scale the engine already reads, and leave
|
||||
// confidence at 0 rather than invent a number.
|
||||
confidence = 0,
|
||||
reliability = a.thumbsUp.coerceIn(0, 10)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Waze RT fetch failed: ${e.message}")
|
||||
WazeSource.FetchResult.Failed(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the stored anonymous account; the next poll registers a fresh one. */
|
||||
fun forgetAccount() = fetcher.forgetAccount()
|
||||
|
||||
/** True once an anonymous account has been minted and persisted. */
|
||||
fun hasAccount(): Boolean = fetcher.hasAccount()
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import org.soulstone.overwatch.scan.DeflockScanner
|
||||
import org.soulstone.overwatch.scan.DeflockClient.SurveillancePoint
|
||||
import org.soulstone.overwatch.scan.WazeClient
|
||||
import org.soulstone.overwatch.scan.WazeScanner
|
||||
import org.soulstone.overwatch.scan.wazert.WazeRtClient
|
||||
import org.soulstone.overwatch.scan.WifiScanner
|
||||
|
||||
/**
|
||||
@@ -132,9 +133,15 @@ class DetectionService : LifecycleService() {
|
||||
// "show within" setting is a view control and must never decide what
|
||||
// gets scored, or narrowing it would hide a real alert.
|
||||
deflockScanner = DeflockScanner(store, locationProvider, DeflockClient(this))
|
||||
// Backend is read once per service lifetime, like the source toggles:
|
||||
// Start/Stop applies a change, a live switch does not.
|
||||
wazeScanner = WazeScanner(
|
||||
store, locationProvider,
|
||||
client = WazeClient(apiKey = { settings.wazeApiKey.value })
|
||||
source = when (settings.wazeBackend.value) {
|
||||
Settings.WazeBackend.DIRECT -> WazeRtClient(this)
|
||||
Settings.WazeBackend.OPENWEB_NINJA ->
|
||||
WazeClient(apiKey = { settings.wazeApiKey.value })
|
||||
}
|
||||
)
|
||||
aircraftScanner = AircraftScanner(this, store, locationProvider)
|
||||
overlayManager = OverlayManager(
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -46,11 +47,13 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.soulstone.overwatch.data.settings.Settings
|
||||
import org.soulstone.overwatch.scan.wazert.WazeRtClient
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -66,6 +69,7 @@ fun SettingsScreen(
|
||||
val aircraft by settings.aircraftEnabled.collectAsState()
|
||||
val mic by settings.micEnabled.collectAsState()
|
||||
val wazeApiKey by settings.wazeApiKey.collectAsState()
|
||||
val wazeBackend by settings.wazeBackend.collectAsState()
|
||||
val theme by settings.themeMode.collectAsState()
|
||||
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||
val overlay by settings.overlayEnabled.collectAsState()
|
||||
@@ -132,16 +136,39 @@ fun SettingsScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Waze police feed")
|
||||
Text(
|
||||
"Bring your own key: sign up at openwebninja.com, subscribe to the " +
|
||||
"Waze API, and paste the key here. Stored encrypted on-device — " +
|
||||
"never in the app package. Pay-as-you-go runs about \$1-3/month.",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
ApiKeyField(currentKey = wazeApiKey, onSave = { settings.setWazeApiKey(it) })
|
||||
RadioRow(
|
||||
"OpenWeb Ninja • your API key",
|
||||
wazeBackend == Settings.WazeBackend.OPENWEB_NINJA
|
||||
) { settings.setWazeBackend(Settings.WazeBackend.OPENWEB_NINJA) }
|
||||
RadioRow(
|
||||
"Direct from Waze • no key",
|
||||
wazeBackend == Settings.WazeBackend.DIRECT
|
||||
) { settings.setWazeBackend(Settings.WazeBackend.DIRECT) }
|
||||
|
||||
when (wazeBackend) {
|
||||
Settings.WazeBackend.OPENWEB_NINJA -> {
|
||||
HelpText(
|
||||
"Bring your own key: sign up at openwebninja.com, subscribe to " +
|
||||
"the Waze API, and paste the key here. Stored encrypted " +
|
||||
"on-device — never in the app package. Pay-as-you-go runs " +
|
||||
"about \$1-3/month. Waze never sees you: the request is made " +
|
||||
"by OpenWeb Ninja, not your phone."
|
||||
)
|
||||
ApiKeyField(currentKey = wazeApiKey, onSave = { settings.setWazeApiKey(it) })
|
||||
}
|
||||
Settings.WazeBackend.DIRECT -> {
|
||||
HelpText(
|
||||
"Free, live, no key: OVERWATCH speaks the Waze app's own " +
|
||||
"protocol. It registers an anonymous Waze account (stored " +
|
||||
"encrypted on-device) and sends your position — blurred by up " +
|
||||
"to 500 m — with each poll, about once a minute.\n\n" +
|
||||
"Waze is a Google service. Choosing this tells Google roughly " +
|
||||
"where you are, the way running the Waze app would. Pick it " +
|
||||
"only if that trade is worth a live feed to you."
|
||||
)
|
||||
WazeAccountControls()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Alerts")
|
||||
@@ -175,13 +202,13 @@ fun SettingsScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Appearance")
|
||||
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
||||
RadioRow("System default", theme == Settings.ThemeMode.SYSTEM) {
|
||||
settings.setThemeMode(Settings.ThemeMode.SYSTEM)
|
||||
}
|
||||
ThemeRadio("Dark", theme == Settings.ThemeMode.DARK) {
|
||||
RadioRow("Dark", theme == Settings.ThemeMode.DARK) {
|
||||
settings.setThemeMode(Settings.ThemeMode.DARK)
|
||||
}
|
||||
ThemeRadio("Light", theme == Settings.ThemeMode.LIGHT) {
|
||||
RadioRow("Light", theme == Settings.ThemeMode.LIGHT) {
|
||||
settings.setThemeMode(Settings.ThemeMode.LIGHT)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -325,15 +352,76 @@ private fun ApiKeyField(currentKey: String, onSave: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Small explanatory paragraph under a setting. */
|
||||
@Composable
|
||||
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
private fun HelpText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the anonymous Waze account the direct backend mints, plus a way to
|
||||
* throw it away. Waze caps how many anonymous accounts a device may register per
|
||||
* day, so forgetting one is not free — the button says so rather than inviting
|
||||
* the user to tap it repeatedly.
|
||||
*/
|
||||
@Composable
|
||||
private fun WazeAccountControls() {
|
||||
val context = LocalContext.current
|
||||
// Bumped after a forget, to re-read the store.
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
val hasAccount = remember(revision) { WazeRtClient(context).hasAccount() }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (hasAccount) "Anonymous Waze account stored"
|
||||
else "No account yet — one is created on the first poll",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f, fill = true)
|
||||
)
|
||||
if (hasAccount) {
|
||||
Button(
|
||||
onClick = {
|
||||
WazeRtClient(context).forgetAccount()
|
||||
revision++
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("Forget", fontSize = 13.sp, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// The whole row is the target, not just the radio circle: these rows
|
||||
// choose a data backend and a theme, and hunting a 20 dp dot on a phone
|
||||
// in a car is friction for no reason. `selectable` also gives the row
|
||||
// the right accessibility semantics for a radio group.
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
// Null: the row above owns the click, so the button must not double-handle it.
|
||||
RadioButton(selected = selected, onClick = null)
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
// Vendored from highway-radar-sabre-plus (MIT, (c) 2026 highway-radar-sabre-plus
|
||||
// contributors) — see app/src/main/java/org/soulstone/overwatch/scan/wazert/LICENSE.
|
||||
// Unmodified except for java_package, which points at OVERWATCH's own package.
|
||||
//
|
||||
// Minimal, wire-compatible description of the Waze mobile RT protocol, limited
|
||||
// to the messages this plugin exchanges. Field numbers and wire types match
|
||||
// what the Waze client sends and accepts on the wire.
|
||||
//
|
||||
// Scope: ONLY the messages needed for the area-alert FETCH path
|
||||
// (ClientInfo / register / login + incoming RealtimeAlert parsing). Reporting
|
||||
// messages, alert "details" sub-trees, and other unrelated Element members are
|
||||
// intentionally omitted. Because every field is proto2 optional/repeated, the
|
||||
// omitted fields are simply skipped on the wire and do not affect compatibility.
|
||||
//
|
||||
// All integer scalars use plain int32/int64 (the writers emit writeInt32 /
|
||||
// writeInt64 -- NOT writeSInt32/writeUInt32 -- so there is no zig-zag encoding,
|
||||
// e.g. Coordinate.lat/lon and AlertInfo.azymuth are int32).
|
||||
|
||||
syntax = "proto2";
|
||||
|
||||
option java_package = "org.soulstone.overwatch.scan.wazert";
|
||||
option java_outer_classname = "WazeProto";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message Batch {
|
||||
repeated Element element = 1001;
|
||||
optional int64 expiration_gmt = 1002;
|
||||
}
|
||||
|
||||
// The big polymorphic container. Each Element carries exactly one logical
|
||||
// payload (selected by the caller); proto2 makes them all independent optional
|
||||
// fields rather than a oneof. Only the members this plugin uses are declared
|
||||
// here; many other high-numbered members exist in the full schema.
|
||||
message Element {
|
||||
optional int32 request_id = 1;
|
||||
optional string old_command = 2001;
|
||||
optional ServerError error = 2003;
|
||||
optional ReportAdsSettings report_ads_setting = 2108;
|
||||
optional ResponseTimestamp response_timestamp = 2150;
|
||||
optional ClientInfo client_info = 2184;
|
||||
optional Register register = 2219;
|
||||
optional RegisterSuccessful register_successful = 2220;
|
||||
optional UID uid = 2221;
|
||||
optional LoginError login_error = 2224;
|
||||
optional LoginSuccessful login_successful = 2225;
|
||||
optional AT at = 2384;
|
||||
optional AddAlertAction add_alert_action = 2708;
|
||||
optional AddUserReportedAlertRequest add_user_reported_alert_request = 2737;
|
||||
optional AddUserReportedAlertResponse add_user_reported_alert_response = 2738;
|
||||
optional LoginRequest login_request = 2744;
|
||||
optional LoginResponse login_response = 2745;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared scalar/helper messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message StringEntry {
|
||||
optional string key = 1;
|
||||
optional string value = 2;
|
||||
}
|
||||
|
||||
message StringMap {
|
||||
repeated StringEntry entry = 1;
|
||||
}
|
||||
|
||||
message Coordinate {
|
||||
optional int32 lon_times1000000 = 101;
|
||||
optional int32 lat_times1000000 = 102;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum DeviceType {
|
||||
UNKNOWN_DEVICE_TYPE = 0;
|
||||
SYMBIAN_NO_TOUCH_SCREEN_DEVICE = 10;
|
||||
SYMBIAN_TOUCH_SCREEN_DEVICE = 11;
|
||||
IPHONE_DEVICE = 21;
|
||||
CE_NO_TOUCH_SCREEN_DEVICE = 30;
|
||||
CE_TOUCH_SCREEN_DEVICE = 31;
|
||||
ANDROID_DEVICE = 50;
|
||||
WP8_DEVICE = 80;
|
||||
LINUX_DEVICE = 90;
|
||||
WEB = 100;
|
||||
}
|
||||
|
||||
enum AppType {
|
||||
UNKNOWN_APP_TYPE = 0;
|
||||
WAZE = 1;
|
||||
RIDER = 2;
|
||||
BROADCAST = 3;
|
||||
WAZE_BUILT_IN_DISPLAY = 4;
|
||||
WAZE_BUILT_IN_DISPLAY_PHONE = 5;
|
||||
WAZE_BUILT_IN_DISPLAY_CARPLAY = 6;
|
||||
WEB_CLIENT = 7;
|
||||
CARPOOL_ALERTS_ORCHESTRATOR = 8;
|
||||
WAZE_ANDROID_AUTOMOTIVE_OS = 9;
|
||||
}
|
||||
|
||||
enum AppFlavor {
|
||||
UNKNOWN_APP_FLAVOR = 0;
|
||||
RELEASE = 1;
|
||||
FISHFOOD = 2;
|
||||
DOGFOOD = 3;
|
||||
PRE_BETA = 4;
|
||||
ALPHA = 5;
|
||||
DEV = 6;
|
||||
ONEOFF = 7;
|
||||
BETA = 8;
|
||||
}
|
||||
|
||||
enum ClientApiType {
|
||||
UNKNOWN_CLIENT_API = 0;
|
||||
TRANSPORT = 1;
|
||||
AUDIO = 2;
|
||||
}
|
||||
|
||||
enum SegmentDirection {
|
||||
SEGMENT_DIRECTION_UNSPECIFIED = 0;
|
||||
SEGMENT_DIRECTION_FORWARD = 1;
|
||||
SEGMENT_DIRECTION_BACKWARD = 2;
|
||||
SEGMENT_DIRECTION_BOTH = 3;
|
||||
}
|
||||
|
||||
enum AlertType {
|
||||
UNKNOWN_TYPE = 0;
|
||||
CHIT_CHAT = 1;
|
||||
POLICE = 2;
|
||||
ACCIDENT = 3;
|
||||
JAM = 4;
|
||||
TRAFFIC_INFO = 5;
|
||||
HAZARD = 6;
|
||||
MISC = 7;
|
||||
CONSTRUCTION = 8;
|
||||
PARKING = 9;
|
||||
DYNAMIC = 10;
|
||||
CAMERA = 11;
|
||||
__NOT_IN_USE__PARKED = 12;
|
||||
ROAD_CLOSED = 13;
|
||||
SYSTEM_ROAD_CLOSED = 14;
|
||||
UNKNOWN_ALERT = 15;
|
||||
SOS = 16;
|
||||
CRASH_PRONE = 17;
|
||||
TURN_CLOSED = 19;
|
||||
NEW_BAD_WEATHER = 100;
|
||||
NEW_LANE_CLOSED = 101;
|
||||
PERMANENT_HAZARD = 103;
|
||||
PERSONAL_SAFETY = 104;
|
||||
}
|
||||
|
||||
enum AlertSubType {
|
||||
NO_SUBTYPE = 0;
|
||||
POLICE_VISIBLE = 201;
|
||||
POLICE_HIDING = 202;
|
||||
POLICE_WITH_MOBILE_CAMERA = 203;
|
||||
ACCIDENT_MINOR = 301;
|
||||
ACCIDENT_MAJOR = 302;
|
||||
JAM_MODERATE_TRAFFIC = 401;
|
||||
JAM_HEAVY_TRAFFIC = 402;
|
||||
JAM_STAND_STILL_TRAFFIC = 403;
|
||||
JAM_LIGHT_TRAFFIC = 404;
|
||||
HAZARD_ON_ROAD = 601;
|
||||
HAZARD_ON_SHOULDER = 602;
|
||||
HAZARD_WEATHER = 603;
|
||||
HAZARD_ON_ROAD_OBJECT = 604;
|
||||
HAZARD_ON_ROAD_POT_HOLE = 605;
|
||||
HAZARD_ON_ROAD_ROAD_KILL = 606;
|
||||
HAZARD_ON_SHOULDER_CAR_STOPPED = 607;
|
||||
HAZARD_ON_SHOULDER_ANIMALS = 608;
|
||||
HAZARD_ON_SHOULDER_MISSING_SIGN = 609;
|
||||
HAZARD_WEATHER_FOG = 610;
|
||||
HAZARD_WEATHER_HAIL = 611;
|
||||
HAZARD_WEATHER_HEAVY_RAIN = 612;
|
||||
HAZARD_WEATHER_HEAVY_SNOW = 613;
|
||||
HAZARD_WEATHER_FLOOD = 614;
|
||||
HAZARD_WEATHER_MONSOON = 615;
|
||||
HAZARD_WEATHER_TORNADO = 616;
|
||||
HAZARD_WEATHER_HEAT_WAVE = 617;
|
||||
HAZARD_WEATHER_HURRICANE = 618;
|
||||
HAZARD_WEATHER_FREEZING_RAIN = 619;
|
||||
HAZARD_ON_ROAD_LANE_CLOSED = 620;
|
||||
HAZARD_ON_ROAD_OIL = 621;
|
||||
HAZARD_ON_ROAD_ICE = 622;
|
||||
HAZARD_ON_ROAD_CONSTRUCTION = 623;
|
||||
HAZARD_ON_ROAD_CAR_STOPPED = 624;
|
||||
HAZARD_ON_ROAD_TRAFFIC_LIGHT_FAULT = 625;
|
||||
HAZARD_ON_ROAD_EMERGENCY_VEHICLE = 626;
|
||||
__NOT_IN_USE__PARKED_ON = 1201;
|
||||
__NOT_IN_USE__PARKED_OFF = 1202;
|
||||
ROAD_CLOSED_HAZARD = 1301;
|
||||
ROAD_CLOSED_CONSTRUCTION = 1302;
|
||||
ROAD_CLOSED_EVENT = 1303;
|
||||
SOS_FLAT_TIRE = 1601;
|
||||
SOS_NO_FUEL = 1602;
|
||||
SOS_MEDICAL_HELP = 1603;
|
||||
SOS_MECHANICAL_PROBLEM = 1604;
|
||||
SOS_OTHER = 1605;
|
||||
SOS_BATTERY_ISSUE = 1606;
|
||||
CRASH_PRONE_SHORT_ALERT_LENGTH = 1701;
|
||||
CRASH_PRONE_LONG_ALERT_LENGTH = 1702;
|
||||
TURN_CLOSED_EVENT = 1901;
|
||||
BAD_WEATHER_DEFAULT = 2000;
|
||||
BAD_WEATHER_SLIPPERY_ROAD = 2001;
|
||||
LANE_CLOSURE_BLOCKED_LANES = 2002;
|
||||
LANE_CLOSURE_LEFT_LANE = 2003;
|
||||
LANE_CLOSURE_RIGHT_LANE = 2004;
|
||||
LANE_CLOSURE_CENTER_LANE = 2005;
|
||||
PERMANENT_HAZARD_SPEED_BUMP = 3001;
|
||||
PERMANENT_HAZARD_TOPES = 3002;
|
||||
PERMANENT_HAZARD_TOLL_BOOTH = 3003;
|
||||
PERMANENT_HAZARD_DANGEROUS_CURVE = 3004;
|
||||
PERMANENT_HAZARD_DANGEROUS_INTERSECTION = 3005;
|
||||
PERMANENT_HAZARD_DANGEROUS_SPLIT = 3006;
|
||||
PERMANENT_HAZARD_DANGEROUS_MERGE = 3007;
|
||||
PERMANENT_HAZARD_SCHOOL_ZONE = 3008;
|
||||
DEFAULT_PERSONAL_SAFETY = 4001;
|
||||
DEFAULT_CAMERA = 5001;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClientInfo (most failure-prone for register/login handshakes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message ClientInfo {
|
||||
// Nested per generated source: WazeProtocol.ClientInfo.ClientApi
|
||||
message ClientApi {
|
||||
optional ClientApiType type = 1;
|
||||
optional string package_name = 2;
|
||||
}
|
||||
|
||||
enum Environment {
|
||||
STAGING = 1;
|
||||
AUTOPUSH = 2;
|
||||
PRODUCTION = 3;
|
||||
}
|
||||
|
||||
optional int32 protocol = 1;
|
||||
optional string device = 2;
|
||||
optional string client_version = 3;
|
||||
optional Coordinate last_position = 4;
|
||||
optional string manufacturer = 5;
|
||||
optional string model = 6;
|
||||
optional string name = 7;
|
||||
optional int32 width = 8;
|
||||
optional int32 height = 9;
|
||||
optional string application_type = 10;
|
||||
optional string os_version = 11;
|
||||
optional int32 applicatin_mode_bitmap = 12;
|
||||
optional StringMap client_ab_tests = 13;
|
||||
optional bool return_text = 14;
|
||||
optional bool simulated = 15;
|
||||
optional string locale = 16;
|
||||
optional string installation_id = 17;
|
||||
optional DeviceType device_type = 18;
|
||||
optional AppType app_type = 19;
|
||||
repeated ClientApi client_api = 20;
|
||||
optional Environment environment = 21;
|
||||
optional StringMap debug_options = 22;
|
||||
optional string user_agent = 23;
|
||||
repeated Display display = 24;
|
||||
optional string os_language_id = 25;
|
||||
optional string session_uuid = 26;
|
||||
optional bool is_jailbroken = 27;
|
||||
optional int64 current_time_millis = 28;
|
||||
optional string device_brand = 29;
|
||||
optional bool is_24_hour_format = 30;
|
||||
optional AppFlavor app_flavor = 31;
|
||||
}
|
||||
|
||||
message Display {
|
||||
enum Type {
|
||||
UNKNOWN = 1;
|
||||
BUILT_IN = 2;
|
||||
CARPLAY = 3;
|
||||
ANDROID_AUTO = 4;
|
||||
SMART_DEVICE_LINK = 5;
|
||||
WEB_LINK = 6;
|
||||
PIONEER = 7;
|
||||
WEB = 8;
|
||||
}
|
||||
optional Type type = 1;
|
||||
optional int32 width = 2;
|
||||
optional int32 height = 3;
|
||||
}
|
||||
|
||||
message ReportAdsSettings {
|
||||
optional string advertising_id = 1;
|
||||
optional bool opted_out_of_tracking = 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NOTE: Register carries only `code`; the username/password live in
|
||||
// RegisterSuccessful (the server response).
|
||||
message Register {
|
||||
optional string code = 1;
|
||||
}
|
||||
|
||||
message RegisterSuccessful {
|
||||
optional string username = 1;
|
||||
optional string password = 2;
|
||||
optional string token = 3;
|
||||
optional int64 user_id = 4;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message PasswordCredential {
|
||||
optional string username = 1;
|
||||
optional string password = 2;
|
||||
}
|
||||
|
||||
message TokenCredential {
|
||||
optional string token = 1;
|
||||
}
|
||||
|
||||
message LoginRequest {
|
||||
enum LoginReason {
|
||||
NORMAL = 0;
|
||||
SIGN_IN = 1;
|
||||
}
|
||||
enum ExcludedLoginResponse {
|
||||
FORMAT_UNSPECIFIED = 0;
|
||||
USER_PROFILE = 1;
|
||||
LOGIN_ENCOURAGEMENT = 2;
|
||||
GET_MEETINGS = 3;
|
||||
CONSENT = 4;
|
||||
POINTS = 6;
|
||||
}
|
||||
oneof credential {
|
||||
PasswordCredential password_credential = 1;
|
||||
TokenCredential token_credential = 2;
|
||||
}
|
||||
optional LoginReason reason = 3;
|
||||
optional bool logout_other_devices = 4 [default = true];
|
||||
repeated ExcludedLoginResponse excluded_login_responses = 5;
|
||||
}
|
||||
|
||||
message LoginSuccess {
|
||||
optional int64 server_session_id = 1;
|
||||
optional string global_user_id = 2;
|
||||
optional string secret_key = 3;
|
||||
optional string session_id = 4;
|
||||
}
|
||||
|
||||
message LoginError {
|
||||
enum AuthErrorType {
|
||||
UNKNOWN_ERROR = 0;
|
||||
WRONG_USER_PASSWORD = 1;
|
||||
INTERNAL_ISSUES = 2;
|
||||
NOT_AUTHORIZED = 3;
|
||||
REFRESH_TOKEN = 4;
|
||||
ANOTHER_DEVICE_LOGGED_IN = 5;
|
||||
INVALID_TOKEN = 6;
|
||||
UNAUTHENTICATED_TOKEN = 7;
|
||||
TOKEN_QUOTA_EXCEEDED = 8;
|
||||
APP_VERSION_NOT_SUPPORTED = 9;
|
||||
}
|
||||
optional int64 reason = 1;
|
||||
optional AuthErrorType error_type = 2;
|
||||
// Omitted: another_device_logged_in_details=3, app_version_not_supported_details=4,
|
||||
// internal_issues_details=5 (detail sub-messages, not needed for fetch path).
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
oneof response {
|
||||
LoginSuccess login_success = 1;
|
||||
LoginError login_error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth header helpers referenced by Element
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message UID {
|
||||
optional int64 id = 1;
|
||||
optional string secret_key = 2;
|
||||
optional int32 protocol = 3;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert FETCH payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message AddAlertAction {
|
||||
optional RealtimeAlert realtime_alert = 1;
|
||||
}
|
||||
|
||||
message RealtimeAlert {
|
||||
optional int64 id = 1;
|
||||
optional AlertInfo alert_info = 2;
|
||||
optional AlertReportingInfo alert_reporting_info = 3;
|
||||
// Field 4 is not present in this protocol version.
|
||||
optional bool notify_alerter_shown = 5;
|
||||
optional string alert_uuid = 6;
|
||||
optional string alerter_shown_token = 7;
|
||||
}
|
||||
|
||||
message AlertInfo {
|
||||
optional AlertType type = 1;
|
||||
optional AlertSubType sub_type = 2;
|
||||
optional Coordinate position = 3;
|
||||
// Omitted: segment_direction_id = 4 (SegmentNodes sub-message).
|
||||
optional SegmentDirection seg_direction = 5;
|
||||
optional int32 azymuth = 6;
|
||||
optional bool on_route = 7;
|
||||
optional int32 priority = 8;
|
||||
// Omitted: alert_details = 9 (large AlertDetails sub-tree).
|
||||
}
|
||||
|
||||
message AlertReportingInfo {
|
||||
optional int64 primary_alert_id = 1;
|
||||
optional int32 kind = 2;
|
||||
optional string description = 3;
|
||||
optional int64 report_time = 4;
|
||||
optional string reporter_username = 5;
|
||||
optional bool current_user_is_reporter = 6;
|
||||
optional string report_image_id = 7;
|
||||
optional AlertAddress alert_address = 8;
|
||||
optional int32 thumbs_up_count = 9;
|
||||
optional bool current_user_thumbed_up = 10;
|
||||
optional int32 seconds_to_report = 11;
|
||||
optional bool should_ping_user = 12;
|
||||
optional bool archive = 13;
|
||||
// Omitted: beep_info=14, comment_info=15, chat_info=16, alert_source_info=17
|
||||
// (sub-messages, not needed for the fetch path).
|
||||
}
|
||||
|
||||
message AlertAddress {
|
||||
optional string location_near_by_report = 1;
|
||||
optional string street = 2;
|
||||
optional string city = 3;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server bookkeeping members referenced by Element (minimal stubs).
|
||||
// Field numbers preserved; bodies kept empty so they parse as opaque and are
|
||||
// skipped, since the fetch path never reads them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message ServerError {
|
||||
optional int32 code = 10101;
|
||||
optional string description = 10102;
|
||||
optional string request_id = 10103;
|
||||
optional string command_name = 10105;
|
||||
optional string service_name = 10106;
|
||||
optional string exception_type = 10107;
|
||||
optional string exception_detail = 10108;
|
||||
}
|
||||
|
||||
// Referenced by Element (fields 2150, 2225, 2384). Bodies intentionally left
|
||||
// empty: the fetch path does not read their contents, and an empty proto2
|
||||
// message parses any wire bytes as unknown fields, preserving compatibility.
|
||||
message ResponseTimestamp {}
|
||||
message LoginSuccessful {}
|
||||
message AT {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert REPORTING (write) path (plain int32/int64/enum/double/message, no
|
||||
// zig-zag). Built by WazeReportCodec.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
message CoordinateWithAlt {
|
||||
optional int32 lon_times1000000 = 101;
|
||||
optional int32 lat_times1000000 = 102;
|
||||
optional int32 alt_times1000000 = 103;
|
||||
}
|
||||
|
||||
message SegmentNodes {
|
||||
optional int64 from_node = 1;
|
||||
optional int64 to_node = 2;
|
||||
}
|
||||
|
||||
message GpsPosition {
|
||||
optional CoordinateWithAlt coordinate = 1;
|
||||
optional double horizontal_accuracy_meters = 2;
|
||||
optional int64 time_epoch_ms = 3;
|
||||
}
|
||||
|
||||
message UserPosition {
|
||||
optional GpsPosition gps_position = 1;
|
||||
optional SegmentNodes segment_nodes = 2;
|
||||
}
|
||||
|
||||
message Timestamp {
|
||||
optional int64 seconds = 1;
|
||||
optional int32 nanos = 2;
|
||||
}
|
||||
|
||||
enum ReportingManner {
|
||||
REPORTING_MANNER_UNSPECIFIED = 0;
|
||||
REPORTING_MANNER_DEFAULT = 1;
|
||||
REPORTING_MANNER_VOICE = 2;
|
||||
REPORTING_MANNER_VOICE_CONVERSATION = 3;
|
||||
}
|
||||
|
||||
enum PoliceAlertSubType {
|
||||
POLICE_UNSPECIFIED = 0;
|
||||
POLICE_DEFAULT = 1;
|
||||
POLICE_HIDDEN_REPORT = 2;
|
||||
POLICE_MOBILE_CAMERA = 3;
|
||||
}
|
||||
message PoliceDetails { optional PoliceAlertSubType type = 1; }
|
||||
|
||||
enum CrashReportSubType {
|
||||
CRASH_REPORT_UNSPECIFIED = 0;
|
||||
CRASH_REPORT_DEFAULT = 1;
|
||||
CRASH_REPORT_PILE_UP = 2;
|
||||
CRASH_REPORT_MAJOR = 3;
|
||||
CRASH_REPORT_MINOR = 4;
|
||||
}
|
||||
message CrashDetails { optional CrashReportSubType type = 1; }
|
||||
|
||||
enum TrafficReportSubType {
|
||||
TRAFFIC_REPORT_UNSPECIFIED = 0;
|
||||
TRAFFIC_REPORT_DEFAULT = 1;
|
||||
TRAFFIC_REPORT_STANDSTILL = 2;
|
||||
TRAFFIC_REPORT_LIGHT = 3;
|
||||
TRAFFIC_REPORT_MODERATE = 4;
|
||||
TRAFFIC_REPORT_HEAVY = 5;
|
||||
}
|
||||
message TrafficDetails { optional TrafficReportSubType type = 1; }
|
||||
|
||||
enum HazardReportSubType {
|
||||
HAZARD_REPORT_UNSPECIFIED = 0;
|
||||
HAZARD_REPORT_DEFAULT = 1;
|
||||
HAZARD_REPORT_CONSTRUCTION = 2;
|
||||
HAZARD_REPORT_VEHICLE_STOPPED = 3;
|
||||
HAZARD_REPORT_OBJECT_ON_ROAD = 4;
|
||||
HAZARD_REPORT_POTHOLE = 5;
|
||||
HAZARD_REPORT_BROKEN_TRAFFIC_LIGHT = 6;
|
||||
HAZARD_REPORT_OIL = 7;
|
||||
HAZARD_REPORT_ANIMALS = 8;
|
||||
HAZARD_REPORT_MISSING_SIGN = 9;
|
||||
HAZARD_REPORT_ROAD_KILL = 10;
|
||||
HAZARD_REPORT_SHOULDER = 11;
|
||||
HAZARD_REPORT_SHOULDER_VEHICLE_STOPPED = 12;
|
||||
HAZARD_REPORT_EMERGENCY_VEHICLE = 13;
|
||||
}
|
||||
message HazardOnRoadDetails { optional HazardReportSubType type = 1; }
|
||||
|
||||
message AlertDetails {
|
||||
oneof details {
|
||||
TrafficDetails traffic = 1;
|
||||
PoliceDetails police = 2;
|
||||
CrashDetails crash = 3;
|
||||
HazardOnRoadDetails hazard_on_road = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message AddUserReportedAlertRequest {
|
||||
optional UserPosition user_position = 1;
|
||||
optional int32 azymuth = 2;
|
||||
optional AlertDetails alert_details = 3;
|
||||
optional SegmentDirection segment_direction = 4;
|
||||
optional Timestamp report_time = 5;
|
||||
optional bool is_offline_delayed_report = 6;
|
||||
optional ReportingManner reporting_manner = 7;
|
||||
}
|
||||
|
||||
message AddUserReportedAlertResponse {
|
||||
enum AddAlertStatus {
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
SUCCESS = 1;
|
||||
FAILURE = 2;
|
||||
}
|
||||
optional AddAlertStatus status = 1;
|
||||
optional int32 received_points_count = 2;
|
||||
optional int64 client_alert_id = 3;
|
||||
optional AlertDetails alert_details = 4;
|
||||
optional string alert_uuid = 5;
|
||||
}
|
||||
@@ -2,4 +2,5 @@ plugins {
|
||||
// No kotlin.android alias — AGP 9 provides Kotlin support itself.
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.protobuf) apply false
|
||||
}
|
||||
|
||||
+15
-10
@@ -53,7 +53,7 @@
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-eyebrow">
|
||||
<span class="dot dot-pulse"></span>
|
||||
v0.5.14 · Android · passive
|
||||
v0.5.15 · Android · passive
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">OVERWATCH</span>
|
||||
@@ -244,13 +244,18 @@
|
||||
<h3>Waze — police alerts</h3>
|
||||
<p>
|
||||
Live user-reported <code>POLICE</code> alerts (default 500 m,
|
||||
~45 min freshness). Waze reCAPTCHA-gated its map endpoint, so
|
||||
OVERWATCH reads <a href="https://www.openwebninja.com">OpenWeb
|
||||
Ninja</a>'s hosted feed at <code>api.openwebninja.com</code> —
|
||||
using <strong>your own API key</strong>, pasted into Settings and
|
||||
stored encrypted in the Android Keystore. Nothing ships in the APK,
|
||||
and each install bills to its own account (~$1–3/month
|
||||
pay-as-you-go).
|
||||
~45 min freshness). Waze's public map endpoint now 403s automated
|
||||
clients at Google's edge, so OVERWATCH offers <strong>two ways
|
||||
in</strong> and lets you choose. <strong>OpenWeb Ninja</strong>
|
||||
(default) reads their hosted feed at
|
||||
<code>api.openwebninja.com</code> using <strong>your own API
|
||||
key</strong>, pasted into Settings and stored encrypted in the
|
||||
Android Keystore — Waze never sees you, and each install bills to
|
||||
its own account (~$1–3/month). <strong>Direct</strong> speaks Waze's
|
||||
own app protocol over an anonymous account: free, keyless and live,
|
||||
but it sends a blurred position to a Google service on every poll,
|
||||
so it stays off until you turn it on. Nothing ships in the APK
|
||||
either way.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -436,7 +441,7 @@
|
||||
<ul>
|
||||
<li>Grab the latest debug-signed APK from
|
||||
<a href="https://github.com/KaraZajac/OVERWATCH/releases/latest">Releases</a>
|
||||
(currently <strong>v0.5.14</strong>).</li>
|
||||
(currently <strong>v0.5.15</strong>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-active">
|
||||
@@ -505,7 +510,7 @@
|
||||
transmit, probe, jam, or interfere with any device or network.
|
||||
</p>
|
||||
<p class="footer-meta">
|
||||
v0.5.14 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
|
||||
v0.5.15 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
|
||||
</p>
|
||||
<div class="onion-line">
|
||||
<span class="onion-label">Also on Tor</span>
|
||||
|
||||
@@ -8,6 +8,11 @@ composeBom = "2026.08.00"
|
||||
material3 = "1.3.1"
|
||||
playServicesLocation = "21.4.0"
|
||||
osmdroid = "6.1.20"
|
||||
androidxFragment = "1.9.0"
|
||||
# Protobuf 0.10.0 is the first protobuf-gradle-plugin release compatible with AGP 9
|
||||
# (AGP 9 removed the applicationVariants API the older ones bound to).
|
||||
protobufPlugin = "0.10.0"
|
||||
protobuf = "4.35.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -23,7 +28,11 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
||||
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||
androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "androidxFragment" }
|
||||
protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf" }
|
||||
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
|
||||
|
||||
Reference in New Issue
Block a user