Compare commits

..

10 Commits

Author SHA1 Message Date
leviathan fc67d3d203 v0.3.2 — fix overlay crash on first show
v0.3.1 introduced TouchInterceptor (a FrameLayout wrapping the
ComposeView) so we could intercept touches before the inner MapView
saw ACTION_DOWN. That made the wrapper the window-root view (the
View actually attached to WindowManager) but I left the
ViewTreeLifecycleOwner / ViewTreeSavedStateRegistryOwner tags on
the inner ComposeView.

Compose's WindowRecomposer.create looks up findViewTreeLifecycleOwner
on the window-root, not on the inner ComposeView. With the tag
missing on the wrapper, Compose throws IllegalStateException at
composition startup — service crash on overlay enable.

Fix: move setViewTreeLifecycleOwner + setViewTreeSavedStateRegistryOwner
to the wrapper. v0.3.0 worked because the ComposeView itself was the
window-root then; the same pattern (owners on whatever's attached
directly to WindowManager) holds here.
2026-05-07 23:52:57 -04:00
leviathan eb26def14b v0.3.1 — overlay drag fix + drag-to-dismiss zone
- Bug: dragging the bubble panned the OSM map instead of moving the
  bubble. The OnTouchListener was attached to the ComposeView, but
  the inner MapView consumed ACTION_DOWN for its own pan handling
  before the listener fired. Fix: wrap the ComposeView in a custom
  TouchInterceptor FrameLayout whose onInterceptTouchEvent always
  returns true. Touches go to the wrapper's OnTouchListener; child
  views (including MapView) never see them. Bubble is now purely a
  visualization — pan/zoom is impossible.
- Drag-to-dismiss: when the user starts dragging (after passing
  TAP_SLOP), a translucent dark circle with a white X appears at
  bottom-center via a separate WindowManager view. Highlights red
  when the bubble's screen-space center is within hit slop of the X.
  Releasing on the X tears down the bubble AND fires onDismissed —
  DetectionService flips setOverlayEnabled(false) so the toggle and
  the bubble state stay in sync. Releasing elsewhere is a normal
  drag (just repositions). The dismiss zone uses FLAG_NOT_TOUCHABLE
  so it never steals the gesture; it's purely visual feedback.
2026-05-07 23:45:01 -04:00
leviathan 0841a0a33f v0.3.0 — floating threat-circle overlay (chat-bubble style)
A 140dp draggable bubble shows the same map / tier scrim / user dot /
ALPR dots that the in-app circle does, on top of any other app, while
scanning is on. Tap = brings the host app forward; drag = repositions.

- Manifest: add SYSTEM_ALERT_WINDOW (special-access — granted via
  system Settings page, not the runtime prompt).
- Settings: add overlayEnabled flag (default off) + a "Display over
  other apps" section in SettingsScreen. Flipping the toggle to on
  fires Settings.ACTION_MANAGE_OVERLAY_PERMISSION so the user can
  grant via the system page; if they deny or revoke, the OverlayMgr
  re-checks canDrawOverlays() at every show() call and silently
  no-ops, no crash.
- New OverlayManager: owns the WindowManager view at
  TYPE_APPLICATION_OVERLAY with FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL
  so touches outside the bubble pass through and the bubble never
  steals IME focus. Custom OverlayOwner implementing LifecycleOwner +
  SavedStateRegistryOwner since LifecycleService doesn't satisfy SSR
  (Compose's ComposeView requires both via the view tree).
- Drag/tap handler at the View layer: rawX/rawY math for the drag,
  TAP_SLOP_PX guard to discriminate tap from drag, tap launches
  MainActivity (FLAG_ACTIVITY_NEW_TASK | SINGLE_TOP).
- New OverlayBubble composable: smaller (140dp) self-contained version
  of the in-app threat circle that pulls running/threat/location/
  mapPoints/proximity from the same companion StateFlows. Shared
  dot-drawable helper extracted into ui/MarkerIcons.kt.
- DetectionService observes settings.overlayEnabled in beginScanning
  and toggles the overlay; endScanning hides it.
2026-05-07 23:24:56 -04:00
leviathan 42f657bc0a v0.2.2 — live proximity refresh + map dot markers + COMMERCIAL row fit
- Map markers: replace osmdroid's default teardrop pin (which reads as
  a 'click me' affordance the map doesn't actually offer) with simple
  red dots for ALPRs, matching the blue user-position dot. Drawables
  share a single dotDrawable() helper.
- Live re-eval on proximity slider change. Bug: moving the DeFlock or
  Citizen distance slider while scanning updated the map's visible
  radius but didn't trigger a re-evaluation of which detections fire,
  so events outside a tightened radius lingered until restart and
  events inside a widened radius wouldn't appear until the next
  fix/poll cycle. Fix: add DetectionStore.clearSource(), add
  refresh() to DeflockScanner + CitizenScanner that clear the source
  and re-emit against cached state, observe the proximity StateFlows
  in DetectionService (drop initial replay so we don't redundantly
  clear+re-emit on first scan start).
- COMMERCIAL row label was clipping under the Switch. Shorten to
  'COMMERCIAL  •  Nest, Ring, Echo' (drop 'devices') and give every
  SourceToggle's Text Modifier.weight(1f) so labels wrap gracefully
  on narrow displays instead of getting truncated.
2026-05-07 23:12:17 -04:00
leviathan 0e4387df45 v0.2.1 — UI polish on the map widget + Settings
- Center the OVERWATCH header (was left-aligned with the gear pushed
  by SpaceBetween — now Box-aligned so the title sits dead-center
  regardless of icon width).
- Settings + drill-down: rename the MIC source label to "COMMERCIAL"
  ("Nest, Ring, Echo devices") for clarity. Internal enum stays MIC.
- Drop the GREEN/YELLOW/ORANGE/RED text inside the threat circle and
  bump the tier scrim alpha (0.40-0.65 vs. 0.18-0.50) so the color
  reads at a glance over OSM tiles.
- Force the per-event "Open in Maps" pin to use Google Maps instead
  of whichever geo: handler the user has set as default (Waze, etc.
  could intercept). setPackage("com.google.android.apps.maps") + a
  matching <queries> entry in the manifest so it works on Android
  11+; web fallback if Maps isn't installed.
- Add a blue user-position dot at the center of the map circle, drawn
  on top of any ALPR pins.
- Auto-fit the visible map radius to max(deflockProximityM,
  citizenProximityM) via zoomToBoundingBox so the circle's edge
  literally represents the alert distance the user has chosen.
2026-05-07 23:02:18 -04:00
leviathan 245055d9d2 v0.2.0 — live map circle + MIC detection (Echo/Ring/Nest/hidden cams)
- Replace the static threat circle with an osmdroid-backed map
  centered on the user, with red ALPR pins and a tier-color scrim.
  Falls back to the muted gradient when idle or before the first
  location fix arrives.
- Add DetectionSource.MIC: BLE/WiFi candidate path for Amazon
  Echo/Ring (Lab126 OUIs + AVS service UUID 0xFE03), Google Nest/
  Home/Chromecast (Google OUIs + mfg id 0x00E0), and generic
  Chinese hidden-cam vendors. Score capped at 84 (ORANGE) so RED
  stays reserved for ALPR/Axon-grade evidence. Toggleable in
  Settings; piggybacks on the BLE+WiFi scanners — no new radio.
- Drop the "[DЯΣΛMMΛKΣЯ]" stylized branding for a clean OVERWATCH
  header (notification channel + app label updated to match).
- Fix DeFlock geo-pin tap doing nothing: resolveActivity returns
  null on Android 11+ without a <queries> entry even when Maps is
  installed. Drop the pre-check, try/catch ActivityNotFoundException,
  fall back to a maps.google.com URL if no geo: handler exists.
2026-05-07 22:45:33 -04:00
leviathan e277c48e89 README: bring up to date with v0.1.7
Stale items corrected:
- Architecture file list referenced WazeClient.kt and WazeScanner.kt
  (deleted) and CDN-tile DeflockClient (now Overpass POST). Added the
  missing CitizenClient/CitizenScanner/SourceHealth/ThreatLevel files.
- Permissions table said "DeFlock CDN + Waze API" — now Overpass +
  Citizen. Added VIBRATE row.
- Settings section listed Waze instead of Citizen; missing the new
  Vibrate-on-escalation toggle and Restart-to-apply button.
- Status said "Phases 1-5 complete as of v0.1.0" — bumped to v0.1.7
  with a per-version changelog of what landed.

Added:
- Hero paragraph mentions notification + vibration alerting.
- New "How alerts work" section explaining notification updates,
  vibration cadence, drill-down sheet, and Open-in-Maps.
- Idle-visual note in scoring section.
- START_NOT_STICKY note in architecture.
- Open-app-settings recovery note in permissions section.
2026-04-28 22:32:16 -04:00
leviathan dc2eb9881e v0.1.7 — Settings back button returns to main, doesn't exit app
The screen enum lives entirely inside Compose, so the system back press
went straight to Activity.finish(). Added a BackHandler in the SETTINGS
branch that intercepts and routes back to MAIN.

versionCode 7 → 8, versionName 0.1.6 → 0.1.7.
2026-04-28 22:16:09 -04:00
leviathan d8670f4c32 v0.1.6 — audit fixes (critical, moderate, minor) + UX polish
Critical
--------
- DetectionService: subscribe to threatLevel + top event flows; rebuild the
  foreground notification on every change so a locked-screen user sees
  escalations. Vibrate on upward tier transitions (escalating waveforms for
  YELLOW/ORANGE/RED), gated by Settings.vibrateOnAlert (default on).
- DetectionService: only mark _running=true if at least one scanner started;
  stopSelf() if everything was disabled or denied. Switch START_STICKY →
  START_NOT_STICKY so a system-killed service doesn't re-create into a
  stuck "running but not scanning" state.
- DeflockClient: detect Overpass timeout-in-body (`{"remark": "...timed
  out..."}`) and treat as failure — previously these 200-with-empty-elements
  responses got cached for 24 h, hiding ALPRs in that 5×5 km cell for the
  next day.
- DeflockScanner: record lastFetch coords + timestamp on BOTH success and
  failure, with a 60 s backoff window after a failed attempt. Previously
  `lastFetchLat` was only set on Success, so every subsequent location
  update would re-trigger a 30 s POST that collectLatest then cancelled —
  we'd never finish a fetch under sustained Overpass slowness.
- LocationProvider: stale-lastLocation race fix. The async `lastLocation`
  callback now only seeds `_location` if it's still null and we're still
  running — previously it could overwrite a fresher fix from
  requestLocationUpdates, or fire after stop() and resurrect _location with
  stale data.

Moderate
--------
- CitizenScanner: wait for the first non-null location with .first { } before
  starting the poll/delay loop. First Citizen poll now fires within seconds
  of the location fix, not up to 60 s after.
- MainScreen: when not running, show a muted gray circle with "IDLE" text
  instead of the same solid green look as "scanning, all clear" — the
  pulse animation was the only differentiator before.
- Compose state: rememberSaveable for the screen enum + bottom-sheet open
  state, so SETTINGS survives rotation.
- MainActivity: detect permanently-denied permissions (the user picked
  "don't ask again") via shouldShowRequestPermissionRationale. UI swaps the
  call-to-action to "Open app settings" which fires
  Settings.ACTION_APPLICATION_DETAILS_SETTINGS. onResume re-checks so a
  user returning from app settings is reflected immediately.

Improvements
------------
- BLE/WiFi scanners record SourceHealth.OK on a successful start (and
  FAILED with a specific reason on every short-circuit — disabled adapter,
  missing permission, etc.) so the drill-down sheet is honest about radio
  state, not just network state.
- DetectionEvent gains optional lat/lon (populated by DEFLOCK and CITIZEN);
  SourceRow shows a tap-to-open-Maps icon next to events with coordinates,
  firing a `geo:lat,lon?q=lat,lon(label)` Intent.
- SettingsScreen sliders use onValueChangeFinished — only commit to
  SharedPreferences on drag-release, not on every pixel of movement.
- New Settings.vibrateOnAlert toggle (default on) wired to a SettingsScreen
  row under a new "Alerts" section.

Minor
-----
- BleScanner iterates ALL manufacturer-data entries to find XUNTONG; only
  falls back to the first entry if no XUNTONG match is present. Previously
  we only inspected the first entry.
- Drop dead `?.` on JSONArray.optString in CitizenClient (returns String,
  never null).
- Remove unused rememberCoroutineScope in MainScreen.
- Update stale Phase/Waze references in DetectionService comments.
- Add VIBRATE permission to manifest.

versionCode 6 → 7, versionName 0.1.5 → 0.1.6.
2026-04-28 22:11:56 -04:00
leviathan 74f26439fc v0.1.5 — remove Waze entirely
Waze's reCAPTCHA gating on live-map/api/georss has no clean mobile
workaround, and the Citizen source added in v0.1.4 covers the same
threat model with better data. Keeping a permanently-failed source
visible was UI clutter — drop it.

Removed:
  - scan/WazeClient.kt and scan/WazeScanner.kt (deleted)
  - WAZE from DetectionSource enum
  - waze flow from SourceHealth (+ flowFor/record/reset cases)
  - WazeObservation + scoreWaze + W_WAZE_POLICE from ConfidenceEngine
  - wazeEnabled from Settings (+ KEY_WAZE)
  - WAZE row from SettingsScreen
  - wazeScanner from DetectionService

Renamed (Citizen now owns the proximity slider that Waze used to share):
  - Settings.wazeProximityM → citizenProximityM
  - Settings.setWazeProximityM → setCitizenProximityM
  - KEY_WAZE_PROX → KEY_CITIZEN_PROX
  - DEFAULT_WAZE_PROX → DEFAULT_CITIZEN_PROX (still 500)
  - SettingsScreen "Waze alert distance" → "Citizen alert distance"

Existing users will see the slider reset to 500 m default since the
SharedPreferences key changed.

versionCode 5 → 6, versionName 0.1.4 → 0.1.5.
2026-04-28 21:56:27 -04:00
28 changed files with 1809 additions and 514 deletions
+71 -28
View File
@@ -4,26 +4,31 @@
A native Android (Kotlin) **passive surveillance-detection** app. Open it, hit
**START**, and a circle turns **green / yellow / orange / red** depending on
how confident the engine is that there's a Flock Safety ALPR, an Axon body
camera, or police presence near you.
camera, or active police presence near you. With the screen locked, the
foreground notification updates with the current tier and the phone vibrates
on upward escalations — you don't have to be looking at the screen.
> **Passive defense only.** OVERWATCH only listens — it does not transmit,
> probe, jam, or interfere with any device or network. The Axon
> advertise/fuzz code from one of the reference projects is intentionally
> excluded.
Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
---
## What it detects
| Source | What it looks at | Where it comes from |
|---|---|---|
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API) |
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. |
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. |
| **WAZE** | Live `POLICE` reports within configurable proximity (default 500 m) and < 10 min old | `live-map/api/georss` polled every 60 s with a small bbox around the user. **Note:** Waze added reCAPTCHA gating to this endpoint in 2025/2026; mobile clients now receive HTTP 403. Source stays wired and surfaces the failure in the drill-down sheet so it's never silently empty. |
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. Pulled in to replace Waze's coverage gap. |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. |
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. First poll fires immediately on the first location fix. |
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
> **Why no Waze?** Waze added reCAPTCHA gating to its `live-map/api/georss` endpoint in 2025/2026. Mobile clients receive HTTP 403, and the only known workarounds (Selenium proxy on a home server, Waze for Cities partner program) aren't viable for a phone-deployed app. Citizen replaces it as the police-presence source.
Every observation is scored 0100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources:
```
@@ -35,7 +40,26 @@ RED 85 + certain
The user-facing circle uses the full 4-tier mapping. Cross-source corroboration
naturally pushes the global max upward (a BLE OUI hit *and* a DeFlock map
match in the same area produce a higher tier than either alone).
match in the same area produce a higher tier than either alone). When idle,
the circle shows muted gray with `IDLE` text so it's distinguishable at a
glance from "scanning, all clear."
---
## How alerts work
- **In-app**: the threat circle pulses while scanning; tap it to open the
bottom-sheet drill-down with per-source rows. DEFLOCK and CITIZEN events
carry coordinates — each row has a tap-to-open Maps icon (`geo:` intent).
- **Foreground notification**: rebuilt on every threat-tier change. Title
becomes `OVERWATCH • RED` (or whatever tier); text shows the top
detection's score + label. Notification priority bumps to HIGH on RED so
the system can surface it as a heads-up.
- **Vibration**: on upward tier transitions only. Short pulse for YELLOW,
double for ORANGE, escalating triple for RED. Toggle in Settings → Alerts.
- **Per-source health**: the drill-down sheet shows orange `Source unreachable`
text on a row when its scanner couldn't reach its data source — silent
empty results vs. real failures are distinguishable.
---
@@ -43,24 +67,28 @@ match in the same area produce a higher tier than either alone).
```
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
ui/SettingsScreen.kt per-source toggles, distance sliders, theme
service/DetectionService.kt foreground service — owns scanners + store
ui/SettingsScreen.kt source toggles, distance sliders, vibrate, theme
ui/theme/Theme.kt Material 3 dark/light + threat colors
service/DetectionService.kt foreground service — owns scanners, notification, vibration
scan/BleScanner.kt BLE callback scanner
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
scan/DeflockClient.kt CDN tile fetch + 24h cache
scan/DeflockScanner.kt location-driven proximity check
scan/WazeClient.kt live-map/api/georss bbox fetch
scan/WazeScanner.kt 60s poller + age/distance gate
fusion/ConfidenceEngine.kt scoring (one place)
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
scan/DeflockScanner.kt location-driven proximity check + failure backoff
scan/CitizenClient.kt GET /api/incident/trending + /api/incident/{id}
scan/CitizenScanner.kt 60 s poller, fire/medical filter, per-id cache
fusion/ConfidenceEngine.kt scoring (one place — BLE / WiFi / DeFlock / Citizen)
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
fusion/DetectionStore.kt in-memory dedup, 5-min retention
fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down
fusion/ThreatLevel.kt 4-tier enum + DetectionSource enum
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
```
No detection-history database. All state is in-memory and clears on stop, by
design.
design. Service uses `START_NOT_STICKY` — system kill doesn't auto-restart
into a stuck state.
---
@@ -82,7 +110,7 @@ export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
./gradlew :app:installDebug
```
Or download the latest signed APK from
Or download the latest debug-signed APK from
[Releases](https://github.com/KaraZajac/OVERWATCH/releases).
---
@@ -93,14 +121,18 @@ Or download the latest signed APK from
|---|---|
| `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+) | BLE scanning |
| `BLUETOOTH`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy |
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock proximity |
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Citizen proximity |
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock CDN + Waze API |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass + Citizen API |
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off |
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
| `VIBRATE` | Haptic alert on threat-tier escalation |
Requested at runtime when you press START for the first time.
Requested at runtime when you press START for the first time. If you
permanently deny a required permission ("don't ask again"), the START button
swaps to **Open app settings** which fires the per-app system-settings page
so you can grant manually.
---
@@ -108,11 +140,14 @@ Requested at runtime when you press START for the first time.
Tap the gear icon in the top-right.
- **Detection sources**: toggle BLE / WiFi / DeFlock / Waze independently. Takes
effect on next Start.
- **Proximity thresholds**:
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen independently.
Changes take effect on the next Start. While scanning, a **Restart scan to
apply** button appears that does `stop()` + `start()` in one tap.
- **Proximity thresholds** (sliders commit on release, not per-pixel):
- DeFlock: 50 m 1600 m (default 200 m)
- Waze: 100 m 5000 m (default 500 m)
- Citizen: 100 m 5000 m (default 500 m)
- **Alerts**:
- Vibrate on threat escalation (default on)
- **Appearance**: System / Dark / Light (default Dark)
---
@@ -124,15 +159,23 @@ These live under `REFERENCES/` (gitignored):
- **AxonCadabra** — BLE scanner skeleton (scan side only; advertise/fuzz code excluded)
- **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**CDN tile scheme, proximity-alert pattern
- **wazepolice** — live-map/api/georss recipe, Chrome header spoofing
- **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** — live-map/api/georss recipe; informed v0.1.0v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated)
---
## Status
Phases 15 (skeleton, BLE, WiFi, DeFlock, Waze, polish) complete as of v0.1.0.
Field-test-ready, not yet field-validated.
Phases 15 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and
field-tested. Current release **v0.1.7** addresses two full audit passes
(see release notes for v0.1.2, v0.1.3, v0.1.6). Notable changes since v0.1.0:
- v0.1.2 — Android 14+ foreground service type fix (location was being silently revoked); 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.
- v0.1.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround).
- v0.1.6 — Dynamic notification with tier + label, haptic alerts on escalation, Open-in-Maps for geo events, idle visual differentiated from "scanning, all clear", permanent-deny recovery via Open Settings.
- v0.1.7 — System back from Settings returns to MAIN instead of exiting.
## License
+3 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "0.1.4"
versionCode = 14
versionName = "0.3.2"
}
buildTypes {
@@ -61,6 +61,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.location)
implementation(libs.osmdroid.android)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+16
View File
@@ -35,8 +35,24 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Vibration on threat-tier escalation -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Floating threat-circle overlay (chat-bubble style). Special-access
permission — user grants via system Settings page, not the runtime
prompt. Only consumed when the user opts in to the bubble in app
Settings; otherwise dormant. -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<!-- Allow Intent.setPackage("com.google.android.apps.maps") on Android 11+
(package visibility) so we can force "Open in Maps" pins to land in
Google Maps regardless of the user's default geo: handler. -->
<queries>
<package android:name="com.google.android.apps.maps" />
</queries>
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -1,18 +1,25 @@
package org.soulstone.overwatch
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.osmdroid.config.Configuration
import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.service.DetectionService
import org.soulstone.overwatch.ui.MainScreen
@@ -44,6 +51,7 @@ class MainActivity : ComponentActivity() {
) { result ->
val allGranted = result.all { it.value }
permissionsGranted.value = allGranted
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
if (allGranted) {
// First-run path: user just granted everything, kick off scanning
// immediately so they don't have to tap START a second time.
@@ -51,17 +59,30 @@ class MainActivity : ComponentActivity() {
}
}
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false)
private val permissionsGranted = mutableStateOf(false)
/** True when at least one required permission is denied AND the system says
* we can no longer prompt for it (user picked "don't ask again"). The UI
* swaps the START button's call-to-action for an "Open app settings" link. */
private val permanentlyDenied = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// osmdroid requires a User-Agent and a writable cache before any
// MapView is constructed, otherwise OSM may rate-limit/IP-ban us.
// Set it here once per process — Configuration is a singleton.
Configuration.getInstance().apply {
userAgentValue = packageName
osmdroidBasePath = cacheDir
osmdroidTileCache = java.io.File(cacheDir, "osmdroid-tiles").apply { mkdirs() }
}
permissionsGranted.value = checkAllPermissions()
permanentlyDenied.value = false // reset on activity create
val settings = Settings.get(this)
setContent {
val themeMode by settings.themeMode.collectAsState()
OverwatchTheme(mode = themeMode) {
var screen by remember { mutableStateOf(Screen.MAIN) }
var screen by rememberSaveable { mutableStateOf(Screen.MAIN) }
when (screen) {
Screen.MAIN -> {
@@ -69,21 +90,45 @@ class MainActivity : ComponentActivity() {
val events by DetectionService.store.events.collectAsState()
val threat by DetectionService.store.threatLevel.collectAsState()
val maxScore by DetectionService.store.maxScore.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState()
val userLocation by DetectionService.location.collectAsState()
// Visible map radius = max of the two proximity sliders
// so the user sees the full area where a detection
// can fire. Using the raw setting values regardless of
// enabled-state keeps the visualization stable when a
// source is briefly toggled.
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val mapRadiusM = maxOf(deflockProx, citizenProx).toFloat()
val granted by permissionsGranted
val denied by permanentlyDenied
val message = when {
granted -> null
denied -> "Permissions permanently denied — open app settings to grant"
else -> "Tap START to grant Bluetooth, WiFi + location permissions"
}
MainScreen(
running = running,
threat = threat,
score = maxScore,
events = events,
mapPoints = mapPoints,
userLocation = userLocation,
mapRadiusMeters = mapRadiusM,
canStart = true,
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
permissionMessage = message,
showOpenAppSettings = denied && !granted,
onOpenAppSettings = { openAppSettings() },
onStartStop = {
if (running) {
DetectionService.stop(this)
} else {
if (granted) {
DetectionService.start(this)
} else if (denied) {
openAppSettings()
} else {
permissionLauncher.launch(requiredPermissions)
}
@@ -93,6 +138,10 @@ class MainActivity : ComponentActivity() {
)
}
Screen.SETTINGS -> {
// Route system back into MAIN instead of letting the
// activity finish — the screen enum is internal to
// Compose and the OS doesn't know about it.
BackHandler { screen = Screen.MAIN }
val running by DetectionService.running.collectAsState()
SettingsScreen(
settings = settings,
@@ -111,7 +160,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
permissionsGranted.value = checkAllPermissions()
// User may have granted permissions in app settings while we were paused.
val nowGranted = checkAllPermissions()
permissionsGranted.value = nowGranted
if (nowGranted) permanentlyDenied.value = false
}
private fun checkAllPermissions(): Boolean =
@@ -119,5 +171,23 @@ class MainActivity : ComponentActivity() {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
/** True if at least one missing permission is still askable via the system
* prompt. False means everything missing was denied with "don't ask again". */
private fun anyMissingCanStillAsk(): Boolean {
val missing = requiredPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) return true
return missing.any { ActivityCompat.shouldShowRequestPermissionRationale(this, it) }
}
private fun openAppSettings() {
val intent = Intent(
AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private enum class Screen { MAIN, SETTINGS }
}
@@ -49,12 +49,13 @@ class LocationProvider(private val context: Context) {
private val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
if (!running) return
val fix = result.lastLocation ?: return
_location.value = fix
}
}
private var running = false
@Volatile private var running = false
fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
@@ -68,12 +69,22 @@ class LocationProvider(private val context: Context) {
return false
}
try {
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
running = true
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
// Seed with the cached lastLocation only if (a) we haven't already
// received a fresh fix from requestLocationUpdates and (b) we're
// still running by the time the listener fires. Otherwise the
// listener can race and either overwrite a fresh fix with a stale
// one or fire after stop().
client.lastLocation.addOnSuccessListener { last ->
if (running && last != null && _location.value == null) {
_location.value = last
}
}
Log.i(TAG, "Location updates started")
return true
} catch (e: SecurityException) {
running = false
Log.e(TAG, "SecurityException starting location updates", e)
return false
}
@@ -29,32 +29,38 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
private val _deflockProximityM = MutableStateFlow(
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX)
)
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
private val _citizenProximityM = MutableStateFlow(
prefs.getInt(KEY_CITIZEN_PROX, DEFAULT_CITIZEN_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
private val _themeMode = MutableStateFlow(
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
)
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
private val _overlayEnabled = MutableStateFlow(prefs.getBoolean(KEY_OVERLAY, false))
val overlayEnabled: StateFlow<Boolean> = _overlayEnabled.asStateFlow()
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v }
fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
fun setDeflockProximityM(v: Int) {
val clamped = v.coerceIn(50, 1600)
@@ -62,10 +68,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_deflockProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
fun setCitizenProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
prefs.edit { putInt(KEY_CITIZEN_PROX, clamped) }
_citizenProximityM.value = clamped
}
fun setThemeMode(mode: ThemeMode) {
@@ -73,19 +79,31 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_themeMode.value = mode
}
fun setVibrateOnAlert(v: Boolean) {
prefs.edit { putBoolean(KEY_VIBRATE, v) }
_vibrateOnAlert.value = v
}
fun setOverlayEnabled(v: Boolean) {
prefs.edit { putBoolean(KEY_OVERLAY, v) }
_overlayEnabled.value = v
}
companion object {
private const val PREFS = "overwatch_settings"
private const val KEY_BLE = "src_ble"
private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_WAZE = "src_waze"
private const val KEY_CITIZEN = "src_citizen"
private const val KEY_MIC = "src_mic"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_THEME = "theme_mode"
private const val KEY_VIBRATE = "vibrate_on_alert"
private const val KEY_OVERLAY = "overlay_enabled"
const val DEFAULT_DEFLOCK_PROX = 200
const val DEFAULT_WAZE_PROX = 500
const val DEFAULT_CITIZEN_PROX = 500
@Volatile private var INSTANCE: Settings? = null
@@ -0,0 +1,153 @@
package org.soulstone.overwatch.data.targets
import java.util.UUID
/**
* Curated targets for "device with a microphone in your space" detection.
*
* Scope is intentionally narrow — only well-known smart-home OEMs whose devices
* stay in fixed locations and continuously listen. Apple manufacturer id 0x004C
* is deliberately excluded because every iPhone, AirPod, and Apple Watch
* advertises it; a coffee shop full of phones must not light up the alarm.
*
* Detection vectors collected from public OUI registries (Wireshark/IEEE)
* and device-setup advertisement docs.
*/
object MicTargets {
enum class Family { ECHO, RING, GOOGLE, HIDDEN_CAM }
/** Bluetooth SIG company identifiers for "voice/smart-home" device families. */
private val MFG_GOOGLE = 0x00E0
private val MFG_AMAZON = 0x0171
/** Yingxin / cheap-spy-cam mfg id seen in field reports. */
private val MFG_YINGXIN = 0x05A7
/** Echo/Alexa Voice Service GATT (FE03 — assigned to Amazon Lab126). */
private val UUID_AVS = UUID.fromString("0000fe03-0000-1000-8000-00805f9b34fb")
/** Lab126 (Amazon — Echo, Ring, Fire TV) WiFi/BLE OUIs. */
private val OUIS_AMAZON: Set<String> = setOf(
"0c:47:c9", "38:f7:3d", "44:65:0d", "50:dc:e7", "78:e1:03",
"a8:51:5b", "b0:09:da", "f0:27:2d", "f0:81:73", "f0:d2:f1",
"fc:65:de", "fc:a1:83", "ac:63:be", "00:bb:3a"
)
/** Google (Nest, Home, Chromecast) WiFi/BLE OUIs. */
private val OUIS_GOOGLE: Set<String> = setOf(
"f8:8f:ca", "f4:f5:e8", "94:eb:cd", "64:16:66", "fc:9f:e9",
"1c:f2:9a", "08:9e:08", "20:df:b9", "30:fd:38", "48:d6:d5",
"54:60:09", "6c:ad:f8", "70:3a:cb", "94:c9:60", "f4:f1:9e"
)
/** Generic Chinese hidden-cam / smart-mic vendor OUIs (high-noise; opt-in). */
private val OUIS_HIDDEN_CAM: Set<String> = setOf(
"fc:b4:67", // Yingxin / SmartLife mini cams
"00:e0:4c", // Realtek (used in many cheap cams)
"dc:4f:22", // Tuya-affiliated module vendors
"a4:c1:38", // Telink (often inside cheap BLE mics)
"8c:ce:4e" // Shenzhen iComm — frequent in spy-cam BOMs
)
private val ALL_OUIS: Set<String> = OUIS_AMAZON + OUIS_GOOGLE + OUIS_HIDDEN_CAM
/** Case-sensitive substrings — distinct enough to avoid false positives. */
private val BLE_NAME_HINTS: List<Pair<String, Family>> = listOf(
"Echo" to Family.ECHO,
"echo-" to Family.ECHO,
"FireTV" to Family.ECHO,
"Amazon" to Family.ECHO,
"Ring-" to Family.RING,
"Ring " to Family.RING,
"Doorbell" to Family.RING,
"Nest" to Family.GOOGLE,
"GoogleHome" to Family.GOOGLE,
"Chromecast" to Family.GOOGLE,
"Google-Home" to Family.GOOGLE
)
private val SSID_HINTS: List<Pair<String, Family>> = listOf(
"Amazon-" to Family.ECHO,
"Echo-" to Family.ECHO,
"Ring-" to Family.RING,
"Ring_" to Family.RING,
"Nest_" to Family.GOOGLE,
"GoogleHome" to Family.GOOGLE,
"Chromecast" to Family.GOOGLE
)
data class Match(val family: Family, val reason: String)
fun matchOui(mac: String?): Family? {
if (mac.isNullOrBlank() || mac.length < 8) return null
val prefix = mac.lowercase().substring(0, 8)
return when (prefix) {
in OUIS_AMAZON -> Family.ECHO // Amazon OUIs cover both Echo and Ring
in OUIS_GOOGLE -> Family.GOOGLE
in OUIS_HIDDEN_CAM -> Family.HIDDEN_CAM
else -> null
}
}
fun isMicOui(mac: String?): Boolean = matchOui(mac) != null
fun matchBleName(name: String?): Match? {
if (name.isNullOrBlank()) return null
for ((needle, family) in BLE_NAME_HINTS) {
if (name.contains(needle, ignoreCase = false)) {
return Match(family, "name:$needle")
}
}
return null
}
fun matchSsid(ssid: String?): Match? {
if (ssid.isNullOrBlank()) return null
for ((needle, family) in SSID_HINTS) {
if (ssid.contains(needle, ignoreCase = true)) {
return Match(family, "ssid:$needle")
}
}
return null
}
fun matchManufacturer(companyId: Int?): Family? = when (companyId) {
MFG_AMAZON -> Family.ECHO
MFG_GOOGLE -> Family.GOOGLE
MFG_YINGXIN -> Family.HIDDEN_CAM
else -> null
}
fun matchAvsService(advertisedUuids: List<UUID>?): Boolean {
if (advertisedUuids.isNullOrEmpty()) return false
return advertisedUuids.contains(UUID_AVS)
}
/** Cheap pre-filter for the BLE scanner — true if any mic signal could match. */
fun couldBeMicBle(
mac: String?,
name: String?,
advertisedUuids: List<UUID>?,
companyId: Int?
): Boolean {
if (isMicOui(mac)) return true
if (matchBleName(name) != null) return true
if (matchManufacturer(companyId) != null) return true
if (matchAvsService(advertisedUuids)) return true
return false
}
/** Cheap pre-filter for the WiFi scanner. */
fun couldBeMicWifi(bssid: String?, ssid: String?): Boolean {
if (isMicOui(bssid)) return true
if (matchSsid(ssid) != null) return true
return false
}
fun familyLabel(f: Family): String = when (f) {
Family.ECHO -> "Amazon Echo / Ring"
Family.RING -> "Ring"
Family.GOOGLE -> "Google Nest / Home"
Family.HIDDEN_CAM -> "Possible hidden mic / cam"
}
}
@@ -24,12 +24,11 @@ object ConfidenceEngine {
const val W_WIFI_SSID_GENERIC = 50
const val W_WIFI_SSID_FLOCK_FMT = 65
// Map / Waze (Phase 3 + 4)
// Map (Phase 3)
const val W_DEFLOCK_NEAR = 60 // <= 200m
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
const val W_WAZE_POLICE = 55
// Citizen (added when Waze went dark)
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
const val W_CITIZEN_INCIDENT = 55
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
@@ -39,6 +38,19 @@ object ConfidenceEngine {
const val B_STRONG_RSSI = 10 // > -50 dBm
const val B_STATIONARY = 15 // RSSI rise-peak-fall
// MIC channel — smart-home/voice-assistant detection. Capped so a Ring or
// Echo cluster can't push the global tier above ORANGE; RED stays reserved
// for ALPR/Axon-grade evidence.
const val MIC_SCORE_CAP = 84
const val W_MIC_OUI = 30
const val W_MIC_NAME = 45
const val W_MIC_MFG = 30
const val W_MIC_AVS_UUID = 50
const val W_MIC_SSID = 45
const val B_MIC_MULTI = 10
const val B_MIC_STATIONARY = 8
const val B_MIC_STRONG_RSSI = 5
/** What we observed about one BLE device on a single scan callback. */
data class BleObservation(
val mac: String,
@@ -66,16 +78,6 @@ object ConfidenceEngine {
val manufacturer: String?
)
/** A Waze POLICE alert observed within proximity + freshness thresholds. */
data class WazeObservation(
val uuid: String,
val distanceMeters: Float,
val ageMs: Long,
val confidence: Int, // raw 0-5
val reliability: Int, // raw 0-10
val subtype: String?
)
/** A Citizen incident observed within proximity + freshness, after the
* fire/medical filter is applied. */
data class CitizenObservation(
@@ -186,22 +188,6 @@ object ConfidenceEngine {
return Scored(score, methods.toString().trim(), label, isAxon)
}
fun scoreWaze(obs: WazeObservation): Scored {
// Plan baseline: 55 for any POLICE alert ≤500m & <10min old.
// Caller is responsible for applying the proximity + age gate before scoring.
var score = W_WAZE_POLICE
// Lightweight crowd-trust nudge: high reliability & high confidence each add a few points,
// capped well under the multi-method bonus so a corroborating BLE/WiFi hit still dominates.
if (obs.reliability >= 7) score += 5
if (obs.confidence >= 4) score += 5
score = score.coerceAtMost(100)
val methods = "waze_police rel=${obs.reliability} conf=${obs.confidence}"
val ageMin = (obs.ageMs / 60_000L).toInt()
val sub = obs.subtype?.let { " ($it)" } ?: ""
val label = "Police report$sub @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago"
return Scored(score, methods, label, isAxon = false)
}
fun scoreCitizen(obs: CitizenObservation): Scored {
var score = W_CITIZEN_INCIDENT
val tags = StringBuilder("citizen ")
@@ -229,6 +215,107 @@ object ConfidenceEngine {
return Scored(score, rangeTag, label, isAxon = false)
}
/** A BLE mic-bearing-device observation, score-capped at ORANGE. */
data class MicBleObservation(
val mac: String,
val rssi: Int,
val deviceName: String?,
val advertisedUuids: List<java.util.UUID>?,
val manufacturerCompanyId: Int?,
val isStationary: Boolean
)
/** A WiFi mic-bearing-device observation, score-capped at ORANGE. */
data class MicWifiObservation(
val bssid: String,
val ssid: String?,
val rssi: Int,
val isStationary: Boolean
)
fun scoreMicBle(obs: MicBleObservation): Scored {
var score = 0
var methodCount = 0
val methods = StringBuilder()
val ouiFamily = org.soulstone.overwatch.data.targets.MicTargets.matchOui(obs.mac)
if (ouiFamily != null) {
score += W_MIC_OUI
methods.append("mic_oui ")
methodCount++
}
val nameMatch = org.soulstone.overwatch.data.targets.MicTargets.matchBleName(obs.deviceName)
if (nameMatch != null) {
score += W_MIC_NAME
methods.append("mic_name ")
methodCount++
}
val mfgFamily = org.soulstone.overwatch.data.targets.MicTargets.matchManufacturer(obs.manufacturerCompanyId)
if (mfgFamily != null) {
score += W_MIC_MFG
methods.append("mic_mfg ")
methodCount++
}
if (org.soulstone.overwatch.data.targets.MicTargets.matchAvsService(obs.advertisedUuids)) {
score += W_MIC_AVS_UUID
methods.append("mic_avs ")
methodCount++
}
if (methodCount >= 2) {
score += B_MIC_MULTI
methods.append("multi ")
}
if (obs.rssi > -50) {
score += B_MIC_STRONG_RSSI
methods.append("strong_rssi ")
}
if (obs.isStationary) {
score += B_MIC_STATIONARY
methods.append("stationary ")
}
score = score.coerceAtMost(MIC_SCORE_CAP)
val family = nameMatch?.family ?: ouiFamily ?: mfgFamily
?: org.soulstone.overwatch.data.targets.MicTargets.Family.HIDDEN_CAM
val familyLabel = org.soulstone.overwatch.data.targets.MicTargets.familyLabel(family)
val nameSuffix = if (!obs.deviceName.isNullOrBlank()) "${obs.deviceName}" else ""
return Scored(score, methods.toString().trim(), "$familyLabel$nameSuffix (${obs.mac})", isAxon = false)
}
fun scoreMicWifi(obs: MicWifiObservation): Scored {
var score = 0
var methodCount = 0
val methods = StringBuilder()
val ouiFamily = org.soulstone.overwatch.data.targets.MicTargets.matchOui(obs.bssid)
if (ouiFamily != null) {
score += W_MIC_OUI
methods.append("mic_oui ")
methodCount++
}
val ssidMatch = org.soulstone.overwatch.data.targets.MicTargets.matchSsid(obs.ssid)
if (ssidMatch != null) {
score += W_MIC_SSID
methods.append("mic_ssid ")
methodCount++
}
if (methodCount >= 2) {
score += B_MIC_MULTI
methods.append("multi ")
}
if (obs.rssi > -50) {
score += B_MIC_STRONG_RSSI
methods.append("strong_rssi ")
}
if (obs.isStationary) {
score += B_MIC_STATIONARY
methods.append("stationary ")
}
score = score.coerceAtMost(MIC_SCORE_CAP)
val family = ssidMatch?.family ?: ouiFamily
?: org.soulstone.overwatch.data.targets.MicTargets.Family.HIDDEN_CAM
val familyLabel = org.soulstone.overwatch.data.targets.MicTargets.familyLabel(family)
val ssidSuffix = if (!obs.ssid.isNullOrBlank()) "${obs.ssid}" else ""
return Scored(score, methods.toString().trim(), "$familyLabel$ssidSuffix (${obs.bssid})", isAxon = false)
}
fun scoreWifi(obs: WifiObservation): Scored {
var score = 0
val methods = StringBuilder()
@@ -4,11 +4,12 @@ package org.soulstone.overwatch.fusion
* One observation from one source at one moment.
*
* @param source which scanner produced this
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Waze)
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Citizen)
* @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B")
* @param score 0-100 confidence assigned by the engine
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Waze sources
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Citizen sources
* @param lat / lon real-world coordinates for events that have them (DEFLOCK, CITIZEN); null for radio-only sources
* @param timestampMs wall-clock millis when this event was produced
*/
data class DetectionEvent(
@@ -18,7 +19,10 @@ data class DetectionEvent(
val score: Int,
val matchedMethods: String,
val rssi: Int? = null,
val lat: Double? = null,
val lon: Double? = null,
val timestampMs: Long = System.currentTimeMillis()
) {
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
val hasGeo: Boolean get() = lat != null && lon != null
}
@@ -44,6 +44,17 @@ class DetectionStore(
_maxScore.value = 0
}
/** Drop every event from a single source — used when a proximity threshold
* changes and the owning scanner needs to re-emit a fresh slate (events
* outside the new radius would otherwise linger until their 5-min TTL). */
@Synchronized
fun clearSource(source: DetectionSource) {
val remaining = _events.value.filter { it.source != source }
if (remaining.size == _events.value.size) return
_events.value = remaining
recompute(remaining)
}
@Synchronized
fun pruneExpired() {
val cutoff = nowMs() - retentionMs
@@ -26,21 +26,21 @@ object SourceHealth {
private val _ble = MutableStateFlow(Health())
private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health())
private val _waze = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health())
private val _mic = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow()
val waze: StateFlow<Health> = _waze.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow()
val mic: StateFlow<Health> = _mic.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock
DetectionSource.WAZE -> waze
DetectionSource.CITIZEN -> citizen
DetectionSource.MIC -> mic
}
fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
@@ -48,8 +48,8 @@ object SourceHealth {
DetectionSource.BLE -> _ble
DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock
DetectionSource.WAZE -> _waze
DetectionSource.CITIZEN -> _citizen
DetectionSource.MIC -> _mic
}
target.value = Health(
status = if (ok) Status.OK else Status.FAILED,
@@ -62,7 +62,7 @@ object SourceHealth {
_ble.value = Health()
_wifi.value = Health()
_deflock.value = Health()
_waze.value = Health()
_citizen.value = Health()
_mic.value = Health()
}
}
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
}
/** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE, CITIZEN }
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, MIC }
@@ -14,6 +14,7 @@ import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.targets.BleOuis
import org.soulstone.overwatch.data.targets.MicTargets
import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.RavenUuids
import org.soulstone.overwatch.fusion.ConfidenceEngine
@@ -21,6 +22,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker
import org.soulstone.overwatch.fusion.SourceHealth
/**
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
@@ -37,7 +39,9 @@ import org.soulstone.overwatch.fusion.RssiTracker
class BleScanner(
private val context: Context,
private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker()
private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
) {
companion object {
@@ -79,18 +83,30 @@ class BleScanner(
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "BLE scan permission missing")
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission missing")
return false
}
val adapter = bluetoothAdapter ?: run {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE not supported")
return false
}
if (!adapter.isEnabled) {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Bluetooth disabled")
return false
}
leScanner = adapter.bluetoothLeScanner ?: run {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE scanner unavailable")
return false
}
val adapter = bluetoothAdapter ?: return false
if (!adapter.isEnabled) return false
leScanner = adapter.bluetoothLeScanner ?: return false
try {
leScanner?.startScan(null, scanSettings, scanCallback)
running = true
SourceHealth.record(DetectionSource.BLE, ok = true)
Log.i(TAG, "BLE scan started")
return true
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting scan", e)
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission revoked")
return false
}
}
@@ -120,6 +136,11 @@ class BleScanner(
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode")
running = false
SourceHealth.record(
DetectionSource.BLE,
ok = false,
message = "BLE scan failed (code $errorCode)"
)
}
}
@@ -132,22 +153,39 @@ class BleScanner(
val advertisedUuids = record?.serviceUuids?.map { it.uuid }
val mfgSpecific = record?.manufacturerSpecificData
// Iterate ALL manufacturer-data entries; some devices advertise multiple
// and XUNTONG might not be the first one. Prefer the XUNTONG match if
// present, otherwise fall back to the first entry so we still surface
// *some* mfg signal in the observation.
var companyId: Int? = null
var payload: ByteArray? = null
if (mfgSpecific != null && mfgSpecific.size() > 0) {
companyId = mfgSpecific.keyAt(0)
payload = mfgSpecific.valueAt(0)
for (i in 0 until mfgSpecific.size()) {
val cid = mfgSpecific.keyAt(i)
val data = mfgSpecific.valueAt(i)
if (cid == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID) {
companyId = cid
payload = data
break
}
if (companyId == null) { companyId = cid; payload = data }
}
}
// Cheap pre-filter — drop devices that have zero target signals.
val candidate = BleOuis.matches(mac) ||
val isSurveillance = BleOuis.matches(mac) ||
Patterns.bleNameMatch(name) ||
Patterns.isPenguinNumeric(name) ||
RavenUuids.countMatches(advertisedUuids) > 0 ||
companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID
if (!candidate) return
val isMic = micEnabled() &&
MicTargets.couldBeMicBle(mac, name, advertisedUuids, companyId)
if (!isSurveillance && !isMic) return
rssi.update(mac, result.rssi)
val stationary = rssi.isStationary(mac)
if (isSurveillance) {
val obs = ConfidenceEngine.BleObservation(
mac = mac,
rssi = result.rssi,
@@ -155,11 +193,10 @@ class BleScanner(
advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId,
manufacturerPayload = payload,
isStationary = rssi.isStationary(mac)
isStationary = stationary
)
val scored = ConfidenceEngine.scoreBle(obs)
if (scored.score < ALARM_THRESHOLD) return
if (scored.score >= ALARM_THRESHOLD) {
store.submit(
DetectionEvent(
source = DetectionSource.BLE,
@@ -171,4 +208,31 @@ class BleScanner(
)
)
}
}
if (isMic) {
val obs = ConfidenceEngine.MicBleObservation(
mac = mac,
rssi = result.rssi,
deviceName = name,
advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId,
isStationary = stationary
)
val scored = ConfidenceEngine.scoreMicBle(obs)
if (scored.score >= ALARM_THRESHOLD) {
store.submit(
DetectionEvent(
source = DetectionSource.MIC,
// Disambiguate from any BLE event on the same MAC so the
// store's (source, key) dedup doesn't collide.
key = "mic:$mac",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = result.rssi
)
)
}
}
}
}
@@ -64,7 +64,10 @@ class CitizenClient {
val arr = JSONObject(raw.body).optJSONArray("results")
?: return@withContext TrendingResult.Success(emptyList())
val out = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) arr.optString(i)?.takeIf { it.isNotBlank() }?.let(out::add)
for (i in 0 until arr.length()) {
val id = arr.optString(i)
if (id.isNotBlank()) out.add(id)
}
TrendingResult.Success(out)
} catch (e: Exception) {
TrendingResult.Failed("parse: ${e.message}")
@@ -5,6 +5,7 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
@@ -57,6 +58,9 @@ class CitizenScanner(
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
// Wait for the first non-null location fix so the first poll fires
// immediately when location arrives, instead of after a 60 s delay.
locationProvider.location.first { it != null }
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) pollOnce(fix)
@@ -95,15 +99,36 @@ class CitizenScanner(
// Drop cache entries that no longer appear in the trending list (resolved).
incidentCache.keys.retainAll(ids.toSet())
// Fetch any ids we haven't seen yet — Citizen incidents don't mutate,
// so a single fetch per id per session is enough.
for (id in ids) {
if (incidentCache[id] == null) {
client.fetchIncident(id)?.also { incidentCache[id] = it }
}
}
emitProximityEvents(fix, ids.mapNotNull { incidentCache[it] })
}
/**
* Re-evaluate the cached incident set against the current proximity + age
* thresholds and the latest fix, *without* a network refetch. Used when
* the user moves the proximity slider — events outside a tightened radius
* would otherwise linger and detections inside a widened radius wouldn't
* appear until the next poll cycle.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.CITIZEN)
emitProximityEvents(fix, incidentCache.values.toList())
}
private fun emitProximityEvents(fix: Location, incidents: Collection<CitizenClient.Incident>) {
val now = System.currentTimeMillis()
val limit = proximityMeters()
val out = FloatArray(1)
for (id in ids) {
val incident = incidentCache[id] ?: client.fetchIncident(id)?.also {
incidentCache[id] = it
} ?: continue
for (incident in incidents) {
// Title-based pre-filter: drop pure fire/medical events.
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
!POLICE_TITLE_RX.containsMatchIn(incident.title)) {
@@ -137,7 +162,9 @@ class CitizenScanner(
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null
rssi = null,
lat = incident.lat,
lon = incident.lon
)
)
}
@@ -128,7 +128,15 @@ class DeflockClient(context: Context) {
val code = conn.responseCode
if (code in 200..299) {
val body = conn.inputStream.bufferedReader().use { it.readText() }
// Overpass returns HTTP 200 with `{"remark": "runtime error: Query timed out..."}`
// when the query exceeded server-side limits. Body has elements:[]; treat as
// failure so we don't poison the 24h cache with empty results.
if (looksLikeOverpassTimeout(body)) {
Log.w(TAG, "$endpoint returned 200 with timeout/runtime-limit remark")
null to "Overpass timeout"
} else {
body to null
}
} else {
Log.w(TAG, "$endpoint returned $code")
null to "HTTP $code"
@@ -141,6 +149,16 @@ class DeflockClient(context: Context) {
}
}
private fun looksLikeOverpassTimeout(body: String): Boolean {
if (!body.contains("remark", ignoreCase = true)) return false
val lower = body.lowercase()
return lower.contains("timed out") ||
lower.contains("timeout") ||
lower.contains("runtime error") ||
lower.contains("runtime limit exceeded") ||
lower.contains("rate_limited")
}
private fun parseSafely(json: String): List<AlprPoint> {
if (json.isBlank()) return emptyList()
return try {
@@ -4,6 +4,9 @@ import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
@@ -32,12 +35,19 @@ class DeflockScanner(
companion object {
private const val TAG = "DeflockScanner"
private const val REFETCH_THRESHOLD_M = 1500f
/** Don't retry an Overpass POST within this window after a failure. */
private const val FAILURE_BACKOFF_MS = 60_000L
}
private var job: Job? = null
private var lastFetchLat: Double? = null
private var lastFetchLon: Double? = null
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
private var lastAttemptMs: Long = 0L
private var lastAttemptOk: Boolean = false
private val _cachedPoints = MutableStateFlow<List<DeflockClient.AlprPoint>>(emptyList())
/** All ALPR points in the current cell — exposed so the UI map can render them.
* Distinct from the proximity-filtered DetectionEvents on [DetectionStore]. */
val cachedPoints: StateFlow<List<DeflockClient.AlprPoint>> = _cachedPoints.asStateFlow()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
@@ -55,25 +65,32 @@ class DeflockScanner(
job = null
lastFetchLat = null
lastFetchLon = null
cachedPoints = emptyList()
lastAttemptMs = 0L
lastAttemptOk = false
_cachedPoints.value = emptyList()
Log.i(TAG, "DeflockScanner stopped")
}
private suspend fun handleFix(fix: Location) {
if (shouldRefetch(fix)) {
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is DeflockClient.FetchResult.Success -> {
cachedPoints = result.points
// Mark the attempt before the network call so a concurrent location
// tick doesn't trigger a parallel re-fetch of the same area.
lastFetchLat = fix.latitude
lastFetchLon = fix.longitude
lastAttemptMs = System.currentTimeMillis()
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is DeflockClient.FetchResult.Success -> {
_cachedPoints.value = result.points
lastAttemptOk = true
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i(
TAG,
"Loaded ${cachedPoints.size} ALPRs around " +
"Loaded ${result.points.size} ALPRs around " +
"(${fix.latitude}, ${fix.longitude})"
)
}
is DeflockClient.FetchResult.Failed -> {
lastAttemptOk = false
SourceHealth.record(
DetectionSource.DEFLOCK,
ok = false,
@@ -84,11 +101,33 @@ class DeflockScanner(
}
}
}
if (cachedPoints.isEmpty()) return
emitProximityEvents(fix)
}
/**
* Re-evaluate the cached ALPRs against the current proximity threshold and
* the latest fix, *without* a network refetch. Used when the user moves the
* proximity slider — the slider changes [proximityMeters], but the scanner
* is otherwise idle (no new location ticks while stationary), so events
* outside the new radius would otherwise linger and detections inside the
* widened radius wouldn't appear until the next handleFix cycle.
*
* Clears the DEFLOCK source from the store first so events that fall
* outside a tightened radius disappear immediately.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.DEFLOCK)
emitProximityEvents(fix)
}
private fun emitProximityEvents(fix: Location) {
val points = _cachedPoints.value
if (points.isEmpty()) return
val limit = proximityMeters()
val out = FloatArray(1)
for (p in cachedPoints) {
for (p in points) {
Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out)
val dist = out[0]
if (dist > limit) continue
@@ -106,7 +145,9 @@ class DeflockScanner(
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null
rssi = null,
lat = p.lat,
lon = p.lon
)
)
}
@@ -115,6 +156,12 @@ class DeflockScanner(
private fun shouldRefetch(fix: Location): Boolean {
val lat = lastFetchLat ?: return true
val lon = lastFetchLon ?: return true
// After a failed attempt, hold off for FAILURE_BACKOFF_MS even if the
// user hasn't moved — avoids hammering Overpass when it's struggling.
if (!lastAttemptOk &&
System.currentTimeMillis() - lastAttemptMs < FAILURE_BACKOFF_MS) {
return false
}
val out = FloatArray(1)
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
return out[0] > REFETCH_THRESHOLD_M
@@ -1,131 +0,0 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Fetches Waze live-map alerts in a small bounding box around the user.
*
* Endpoint (recipe from REFERENCES/wazepolice):
* https://www.waze.com/live-map/api/georss?top=&bottom=&left=&right=&env=na&types=alerts
*
* Spoofs Chrome desktop headers — the public live-map endpoint requires Referer +
* a real-looking User-Agent, otherwise returns 403.
*
* Response shape:
* { "alerts": [
* { "uuid", "type": "POLICE", "subtype",
* "location": {"x": lon, "y": lat},
* "pubMillis", "reportedBy", "confidence" 0-5, "reliability" 0-10 } ] }
*/
class WazeClient {
companion object {
private const val TAG = "WazeClient"
private const val BASE = "https://www.waze.com/live-map/api/georss"
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private const val REFERER = "https://www.waze.com/live-map/"
private const val ORIGIN = "https://www.waze.com"
private const val TIMEOUT_MS = 10_000
/** Bounding box half-width in degrees — ~5.5 km N-S, varies E-W with latitude. */
private const val BBOX_HALF_DEG = 0.05
}
data class Alert(
val uuid: String,
val subtype: String?,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val confidence: Int,
val reliability: Int,
val reportedBy: String?
)
/** Outcome — distinguishes "no police alerts in area" from "couldn't reach Waze." */
sealed class FetchResult {
data class Success(val alerts: List<Alert>) : FetchResult()
data class Failed(val reason: String) : FetchResult()
}
suspend fun fetchPoliceNear(lat: Double, lon: Double): FetchResult = withContext(Dispatchers.IO) {
val top = lat + BBOX_HALF_DEG
val bottom = lat - BBOX_HALF_DEG
val left = lon - BBOX_HALF_DEG
val right = lon + BBOX_HALF_DEG
val url = URL("$BASE?top=$top&bottom=$bottom&left=$left&right=$right&env=na&types=alerts")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Referer", REFERER)
setRequestProperty("Origin", ORIGIN)
setRequestProperty("Accept", "application/json,text/javascript,*/*;q=0.8")
setRequestProperty("Accept-Language", "en-US,en;q=0.9")
}
try {
val code = conn.responseCode
if (code == 403) {
// Waze added reCAPTCHA gating to live-map in 2025/2026; mobile
// clients can no longer hit this endpoint without browser-level
// automation. Surface this distinctly so the UI can say so.
Log.w(TAG, "Waze returned 403 (upstream reCAPTCHA gating)")
return@withContext FetchResult.Failed("Upstream blocked (HTTP 403)")
}
if (code !in 200..299) {
Log.w(TAG, "Waze returned $code")
return@withContext FetchResult.Failed("HTTP $code")
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
FetchResult.Success(parsePolice(body))
} catch (e: Exception) {
Log.w(TAG, "Waze fetch failed: ${e.message}")
FetchResult.Failed(e.message ?: e.javaClass.simpleName)
} finally {
conn.disconnect()
}
}
private fun parsePolice(body: String): List<Alert> {
if (body.isBlank()) return emptyList()
return try {
val root = JSONObject(body)
val alerts = root.optJSONArray("alerts") ?: return emptyList()
val out = ArrayList<Alert>()
for (i in 0 until alerts.length()) {
val a = alerts.optJSONObject(i) ?: continue
if (a.optString("type") != "POLICE") continue
val loc = a.optJSONObject("location") ?: continue
val uuid = a.optString("uuid")
if (uuid.isBlank()) continue
val lat = loc.optDouble("y")
val lon = loc.optDouble("x")
if (lat.isNaN() || lon.isNaN()) continue
out.add(
Alert(
uuid = uuid,
subtype = a.optString("subtype").ifBlank { null },
lat = lat,
lon = lon,
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
confidence = a.optInt("confidence", 0),
reliability = a.optInt("reliability", 0),
reportedBy = a.optString("reportedBy").ifBlank { null }
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Waze response: ${e.message}")
emptyList()
}
}
}
@@ -1,111 +0,0 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* Polls Waze every 60s for live POLICE alerts in a small bounding box around the
* current location, then submits any inside [PROXIMITY_M] and younger than [MAX_AGE_MS].
*
* Skips the poll cycle if location is not yet known. Network-only — no on-disk cache
* (data is real-time by definition).
*/
class WazeScanner(
private val store: DetectionStore,
private val locationProvider: LocationProvider,
private val client: WazeClient = WazeClient(),
private val proximityMeters: () -> Float = { 500f }
) {
companion object {
private const val TAG = "WazeScanner"
private const val POLL_INTERVAL_MS = 60_000L
private const val MAX_AGE_MS = 10L * 60L * 1000L
}
private var job: Job? = null
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) {
pollOnce(fix)
} else {
Log.d(TAG, "Skip poll — no location yet")
}
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms)")
return true
}
fun stop() {
job?.cancel()
job = null
Log.i(TAG, "WazeScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
val result = client.fetchPoliceNear(fix.latitude, fix.longitude)
val alerts = when (result) {
is WazeClient.FetchResult.Success -> {
SourceHealth.record(DetectionSource.WAZE, ok = true)
result.alerts
}
is WazeClient.FetchResult.Failed -> {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Waze unreachable: ${result.reason}"
)
return
}
}
if (alerts.isEmpty()) return
val now = System.currentTimeMillis()
val limit = proximityMeters()
val out = FloatArray(1)
for (a in alerts) {
val age = now - a.pubMillis
if (age > MAX_AGE_MS) continue
Location.distanceBetween(fix.latitude, fix.longitude, a.lat, a.lon, out)
val dist = out[0]
if (dist > limit) continue
val obs = ConfidenceEngine.WazeObservation(
uuid = a.uuid,
distanceMeters = dist,
ageMs = age,
confidence = a.confidence,
reliability = a.reliability,
subtype = a.subtype
)
val scored = ConfidenceEngine.scoreWaze(obs)
store.submit(
DetectionEvent(
source = DetectionSource.WAZE,
key = a.uuid,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null
)
)
}
}
}
@@ -17,6 +17,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.targets.MicTargets
import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.WifiOuis
import org.soulstone.overwatch.fusion.ConfidenceEngine
@@ -24,6 +25,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker
import org.soulstone.overwatch.fusion.SourceHealth
/**
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
@@ -39,7 +41,9 @@ import org.soulstone.overwatch.fusion.RssiTracker
class WifiScanner(
private val context: Context,
private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker()
private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
) {
companion object {
@@ -86,15 +90,23 @@ class WifiScanner(
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "WiFi scan permission missing")
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "Permission missing")
return false
}
val mgr = wifiManager ?: run {
Log.w(TAG, "WifiManager unavailable")
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "WifiManager unavailable")
return false
}
if (!mgr.isWifiEnabled) {
Log.w(TAG, "WiFi disabled — scanner won't return results")
SourceHealth.record(
DetectionSource.WIFI, ok = false,
message = "WiFi disabled — enable in system settings"
)
// We still register the receiver so results arrive when the user enables WiFi.
} else {
SourceHealth.record(DetectionSource.WIFI, ok = true)
}
registerReceiver()
running = true
@@ -158,21 +170,21 @@ class WifiScanner(
val bssid = r.BSSID ?: continue
val ssid = readSsid(r)
val candidate = WifiOuis.matches(bssid) ||
val isSurveillance = WifiOuis.matches(bssid) ||
Patterns.ssidGenericMatch(ssid) ||
Patterns.ssidFlockFormat(ssid)
if (!candidate) continue
val isMic = micEnabled() && MicTargets.couldBeMicWifi(bssid, ssid)
if (!isSurveillance && !isMic) continue
rssi.update(bssid, r.level)
val stationary = rssi.isStationary(bssid)
if (isSurveillance) {
val obs = ConfidenceEngine.WifiObservation(
bssid = bssid,
ssid = ssid,
rssi = r.level,
isStationary = rssi.isStationary(bssid)
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
)
val scored = ConfidenceEngine.scoreWifi(obs)
if (scored.score < ALARM_THRESHOLD) continue
if (scored.score >= ALARM_THRESHOLD) {
store.submit(
DetectionEvent(
source = DetectionSource.WIFI,
@@ -185,6 +197,26 @@ class WifiScanner(
)
}
}
if (isMic) {
val obs = ConfidenceEngine.MicWifiObservation(
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
)
val scored = ConfidenceEngine.scoreMicWifi(obs)
if (scored.score >= ALARM_THRESHOLD) {
store.submit(
DetectionEvent(
source = DetectionSource.MIC,
key = "mic:$bssid",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = r.level
)
)
}
}
}
}
private fun readSsid(r: ScanResult): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -7,8 +7,12 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.location.Location
import android.os.Build
import android.os.IBinder
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
@@ -18,27 +22,38 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import org.soulstone.overwatch.MainActivity
import org.soulstone.overwatch.R
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.WazeScanner
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
import org.soulstone.overwatch.scan.WifiScanner
/**
* Foreground service that owns all scanners and the [DetectionStore].
* Foreground service that owns all four scanners (BLE, WiFi, DeFlock, Citizen)
* and the [DetectionStore]. UI observes companion-object state flows directly.
*
* Phase 1 wires only [BleScanner]; phases 2-4 will register WiFi, DeFlock, Waze.
* Responsibilities beyond scanner orchestration:
* - Updates the foreground notification on every threat-tier change so a
* locked-screen user sees escalations.
* - Vibrates on upward tier transitions (gated by Settings.vibrateOnAlert).
* - Resets [SourceHealth] on start/stop.
*
* The service is a singleton at runtime — UI binds to it (or observes the
* companion-object state flows directly, which is what we do here for simplicity).
* Returns START_NOT_STICKY so a system-killed service does not auto-restart
* into a zombie state where the notification disappears but `_running` stays
* stale. The user explicitly starts and stops; auto-restart isn't needed.
*/
class DetectionService : LifecycleService() {
@@ -56,6 +71,15 @@ class DetectionService : LifecycleService() {
private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow()
/** Latest ALPR cell cache — UI map renders these as pins. Mirrored from
* the active DeflockScanner while the service is running; cleared on stop. */
private val _mapPoints = MutableStateFlow<List<AlprPoint>>(emptyList())
val mapPoints: StateFlow<List<AlprPoint>> = _mapPoints.asStateFlow()
/** Latest fused location fix — UI map centers on this. */
private val _location = MutableStateFlow<Location?>(null)
val location: StateFlow<Location?> = _location.asStateFlow()
fun start(context: Context) {
val intent = Intent(context, DetectionService::class.java).apply {
action = ACTION_START
@@ -80,32 +104,42 @@ class DetectionService : LifecycleService() {
private lateinit var wifiScanner: WifiScanner
private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner
private lateinit var wazeScanner: WazeScanner
private lateinit var citizenScanner: CitizenScanner
private lateinit var overlayManager: OverlayManager
private var pruneJob: Job? = null
private var observerJob: Job? = null
private var mapPointsJob: Job? = null
private var locationJob: Job? = null
private var deflockProxJob: Job? = null
private var citizenProxJob: Job? = null
private var overlayJob: Job? = null
private var bleStarted = false
private var wifiStarted = false
private var deflockStarted = false
private var wazeStarted = false
private var citizenStarted = false
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
override fun onCreate() {
super.onCreate()
settings = Settings.get(this)
bleScanner = BleScanner(this, store)
wifiScanner = WifiScanner(this, store)
bleScanner = BleScanner(this, store, micEnabled = { settings.micEnabled.value })
wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value })
locationProvider = LocationProvider(this)
deflockScanner = DeflockScanner(
store, locationProvider, DeflockClient(this),
proximityMeters = { settings.deflockProximityM.value.toFloat() }
)
wazeScanner = WazeScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
)
citizenScanner = CitizenScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
proximityMeters = { settings.citizenProximityM.value.toFloat() }
)
overlayManager = OverlayManager(
context = this,
// User dragged the bubble onto the X — flip the persisted toggle
// so the setting and the bubble state stay aligned. The settings
// collector below will call hide() again, but hide() is idempotent.
onDismissed = { settings.setOverlayEnabled(false) }
)
createNotificationChannel()
}
@@ -119,13 +153,17 @@ class DetectionService : LifecycleService() {
stopSelf()
}
}
return START_STICKY
return START_NOT_STICKY
}
private fun beginScanning() {
if (_running.value) return
SourceHealth.reset()
startInForeground()
lastNotifiedTier = ThreatLevel.GREEN
// Bring up the foreground notification BEFORE any scanner so we don't
// accidentally call startForeground after work has already begun.
startInForeground(ThreatLevel.GREEN, topEvent = null)
if (settings.bleEnabled.value) {
bleStarted = bleScanner.start()
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
@@ -134,9 +172,7 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value ||
settings.wazeEnabled.value ||
settings.citizenEnabled.value
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
if (!locOk) {
@@ -145,14 +181,34 @@ class DetectionService : LifecycleService() {
if (settings.deflockEnabled.value) {
deflockScanner.start(lifecycleScope); deflockStarted = true
}
if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true
}
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
}
}
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted
if (!anyStarted) {
Log.w(TAG, "No scanner started — endScanning + stopSelf")
endScanning()
stopSelf()
return
}
// MIC piggybacks on the BLE/WiFi scanners. Surface its health so the
// user sees an explicit status row rather than a silent UNKNOWN.
if (settings.micEnabled.value) {
if (bleStarted || wifiStarted) {
SourceHealth.record(DetectionSource.MIC, ok = true)
} else {
SourceHealth.record(
DetectionSource.MIC,
ok = false,
message = "Needs BLE or WiFi scanner enabled"
)
}
}
_running.value = true
pruneJob?.cancel()
pruneJob = lifecycleScope.launch {
@@ -161,21 +217,81 @@ class DetectionService : LifecycleService() {
store.pruneExpired()
}
}
observerJob?.cancel()
observerJob = lifecycleScope.launch {
// Watch threat tier + the top event together; rebuild the notification
// on either change. Vibrate only when the tier ratchets upward.
store.threatLevel.combine(store.events) { tier, events ->
tier to events.firstOrNull()
}.collect { (tier, top) ->
onTierChanged(tier, top)
}
}
// Mirror scanner state to the companion StateFlows the UI observes.
// These exist so the map widget doesn't need a direct handle on the
// scanner instances (which are private to this service).
mapPointsJob?.cancel()
if (deflockStarted) {
mapPointsJob = lifecycleScope.launch {
deflockScanner.cachedPoints.collect { _mapPoints.value = it }
}
}
locationJob?.cancel()
locationJob = lifecycleScope.launch {
locationProvider.location.collect { _location.value = it }
}
// Live re-eval when the user moves a proximity slider. drop(1) skips
// the StateFlow's initial replay so we don't redundantly clear+re-emit
// the events the scanner just produced from its first handleFix call.
deflockProxJob?.cancel()
if (deflockStarted) {
deflockProxJob = lifecycleScope.launch {
settings.deflockProximityM.drop(1).collect { deflockScanner.refresh() }
}
}
citizenProxJob?.cancel()
if (citizenStarted) {
citizenProxJob = lifecycleScope.launch {
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
}
}
// Floating threat-circle overlay — observe the toggle and show/hide
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
// show() so a denied/revoked permission silently no-ops.
overlayJob?.cancel()
overlayJob = lifecycleScope.launch {
settings.overlayEnabled.collect { enabled ->
if (enabled) overlayManager.show() else overlayManager.hide()
}
}
}
private fun endScanning() {
if (!_running.value) return
if (!_running.value && !bleStarted && !wifiStarted && !deflockStarted && !citizenStarted) {
return
}
_running.value = false
if (bleStarted) { bleScanner.stop(); bleStarted = false }
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
locationProvider.stop()
store.clear()
SourceHealth.reset()
pruneJob?.cancel()
pruneJob = null
_running.value = false
pruneJob?.cancel(); pruneJob = null
observerJob?.cancel(); observerJob = null
mapPointsJob?.cancel(); mapPointsJob = null
locationJob?.cancel(); locationJob = null
deflockProxJob?.cancel(); deflockProxJob = null
citizenProxJob?.cancel(); citizenProxJob = null
overlayJob?.cancel(); overlayJob = null
overlayManager.hide()
_mapPoints.value = emptyList()
_location.value = null
lastNotifiedTier = ThreatLevel.GREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@@ -194,12 +310,46 @@ class DetectionService : LifecycleService() {
return null
}
private fun startInForeground() {
val notification = buildNotification()
private fun onTierChanged(tier: ThreatLevel, top: DetectionEvent?) {
// Re-issue the foreground notification with the current tier + top event
// so a locked-screen user sees the escalation even without opening the app.
val notification = buildNotification(tier, top)
val mgr = getSystemService(NotificationManager::class.java) ?: return
mgr.notify(NOTIFICATION_ID, notification)
if (tier.ordinal > lastNotifiedTier.ordinal && settings.vibrateOnAlert.value) {
vibrateForTier(tier)
}
lastNotifiedTier = tier
}
private fun vibrateForTier(tier: ThreatLevel) {
val v = currentVibrator() ?: return
val effect = when (tier) {
ThreatLevel.YELLOW -> VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)
ThreatLevel.ORANGE -> VibrationEffect.createWaveform(longArrayOf(0, 180, 100, 180), -1)
ThreatLevel.RED -> VibrationEffect.createWaveform(
longArrayOf(0, 250, 120, 250, 120, 400), -1
)
ThreatLevel.GREEN -> return
}
try { v.vibrate(effect) } catch (e: Exception) { Log.w(TAG, "vibrate failed: ${e.message}") }
}
private fun currentVibrator(): Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun startInForeground(tier: ThreatLevel, topEvent: DetectionEvent?) {
val notification = buildNotification(tier, topEvent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ requires the runtime type to cover every capability
// the service uses. We declare both in the manifest; pass both here
// so location-using sources (DeFlock, Waze) keep working with the
// Android 14+ requires the runtime type to cover every capability the
// service uses. We declare both in the manifest; pass both here so
// location-using sources (DeFlock, Citizen) keep working with the
// screen off.
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -209,7 +359,7 @@ class DetectionService : LifecycleService() {
}
}
private fun buildNotification(): Notification {
private fun buildNotification(tier: ThreatLevel, topEvent: DetectionEvent?): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@@ -217,14 +367,26 @@ class DetectionService : LifecycleService() {
this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val title = "OVERWATCH • ${tier.name}"
val text = topEvent?.let { "${it.score}${it.label}" }
?: getString(R.string.notification_text)
// Higher importance for ORANGE/RED so the system surfaces it more
// aggressively (heads-up notification, etc.). The channel was created
// with LOW; on supported versions this priority is best-effort.
val priority = when (tier) {
ThreatLevel.RED -> NotificationCompat.PRIORITY_HIGH
ThreatLevel.ORANGE -> NotificationCompat.PRIORITY_DEFAULT
else -> NotificationCompat.PRIORITY_LOW
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setPriority(priority)
.setOnlyAlertOnce(false)
.build()
}
@@ -0,0 +1,330 @@
package org.soulstone.overwatch.service
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PixelFormat
import android.provider.Settings
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlin.math.abs
import kotlin.math.sqrt
import org.soulstone.overwatch.MainActivity
import org.soulstone.overwatch.ui.OverlayBubble
/**
* Owns the floating threat-circle bubble — a [ComposeView] hosted in a
* [WindowManager] window at TYPE_APPLICATION_OVERLAY.
*
* Touch model:
* - The bubble window has FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL, so
* touches outside the bubble pass through to whatever app is underneath
* and the bubble never steals IME focus.
* - Touches *inside* the bubble are intercepted by [TouchInterceptor], a
* FrameLayout wrapper whose onInterceptTouchEvent always returns true.
* Without that wrapper the inner osmdroid MapView consumes ACTION_DOWN
* for its own pan handling and the OnTouchListener never sees the gesture.
* - Drag updates the window LayoutParams via WindowManager.updateViewLayout.
* - Tap (movement under TAP_SLOP_PX) launches MainActivity.
*
* Dismiss zone:
* - When the user begins dragging, a separate WindowManager view ([DismissView])
* appears at bottom-center showing an X. The bubble's screen-space center
* is checked against the X's bounds on each MOVE; on UP, if the bubble was
* released over the X, [onDismissed] fires (caller flips the setting off
* so the toggle and the bubble state stay in sync).
* - The dismiss zone uses FLAG_NOT_TOUCHABLE so it never steals the gesture
* — it's purely visual feedback.
*/
class OverlayManager(
private val context: Context,
private val onDismissed: () -> Unit
) {
private companion object {
private const val TAG = "OverlayManager"
private const val INITIAL_X_DP = 24
private const val INITIAL_Y_DP = 120
private const val TAP_SLOP_PX = 12
private const val BUBBLE_SIZE_DP = 140
private const val DISMISS_SIZE_DP = 88
private const val DISMISS_BOTTOM_MARGIN_DP = 100
/** Extra slop around the dismiss zone — released within this radius
* counts as "dropped on X". */
private const val DISMISS_HIT_SLOP_DP = 16
}
private val wm: WindowManager =
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val density = context.resources.displayMetrics.density
private val bubbleSizePx = (BUBBLE_SIZE_DP * density).toInt()
private var container: TouchInterceptor? = null
private var owner: OverlayOwner? = null
private var params: WindowManager.LayoutParams? = null
private var dismissView: DismissView? = null
private var dismissCenterX = 0f
private var dismissCenterY = 0f
private var dismissHitRadius = 0f
fun show() {
if (container != null) return
if (!Settings.canDrawOverlays(context)) {
Log.i(TAG, "Overlay permission not granted; skipping show()")
return
}
val newOwner = OverlayOwner()
val composeView = ComposeView(context).apply {
setContent { OverlayBubble() }
}
// Wrap so we can intercept *before* MapView's own touch handling.
// Compose's WindowRecomposer reads findViewTreeLifecycleOwner from
// the *window-root* view (= the wrapper here, since it's what's
// attached to WindowManager). Setting the owner only on the inner
// ComposeView throws IllegalStateException at composition startup —
// that was the v0.3.1 crash.
val wrapper = TouchInterceptor(context).apply {
setViewTreeLifecycleOwner(newOwner)
setViewTreeSavedStateRegistryOwner(newOwner)
addView(
composeView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
)
}
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = (INITIAL_X_DP * density).toInt()
y = (INITIAL_Y_DP * density).toInt()
}
wrapper.setOnTouchListener(DragHandler(lp))
try {
wm.addView(wrapper, lp)
container = wrapper
owner = newOwner
params = lp
Log.i(TAG, "Overlay bubble attached")
} catch (e: Exception) {
Log.w(TAG, "Failed to attach overlay: ${e.message}")
newOwner.destroy()
}
}
fun hide() {
val v = container ?: return
try { wm.removeView(v) } catch (e: Exception) {
Log.w(TAG, "removeView failed: ${e.message}")
}
owner?.destroy()
container = null
owner = null
params = null
hideDismissZone()
Log.i(TAG, "Overlay bubble detached")
}
private fun showDismissZone() {
if (dismissView != null) return
val sizePx = (DISMISS_SIZE_DP * density).toInt()
val marginPx = (DISMISS_BOTTOM_MARGIN_DP * density).toInt()
// Precompute screen-space center so MOVE checks don't traverse the
// view tree on every frame. Use displayMetrics (sufficient — a real
// multi-display split would need WindowManager#getCurrentWindowMetrics
// on API 30+, but the bubble lives on the user's primary display).
val dm = context.resources.displayMetrics
dismissCenterX = dm.widthPixels / 2f
dismissCenterY = dm.heightPixels - marginPx - sizePx / 2f
dismissHitRadius = sizePx / 2f + DISMISS_HIT_SLOP_DP * density
val v = DismissView(context)
val lp = WindowManager.LayoutParams(
sizePx, sizePx,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = marginPx
}
try {
wm.addView(v, lp)
dismissView = v
} catch (e: Exception) {
Log.w(TAG, "Failed to attach dismiss zone: ${e.message}")
}
}
private fun hideDismissZone() {
val v = dismissView ?: return
try { wm.removeView(v) } catch (_: Exception) {}
dismissView = null
}
private fun isOverDismiss(bubbleX: Int, bubbleY: Int): Boolean {
if (dismissView == null) return false
val cx = bubbleX + bubbleSizePx / 2f
val cy = bubbleY + bubbleSizePx / 2f
val dx = cx - dismissCenterX
val dy = cy - dismissCenterY
return sqrt((dx * dx + dy * dy).toDouble()) < dismissHitRadius
}
/** Drag with raw coords; tap if movement stayed under [TAP_SLOP_PX]. */
private inner class DragHandler(private val lp: WindowManager.LayoutParams) :
View.OnTouchListener {
private var startX = 0
private var startY = 0
private var touchDownX = 0f
private var touchDownY = 0f
private var moved = false
@SuppressLint("ClickableViewAccessibility")
override fun onTouch(v: View, ev: MotionEvent): Boolean {
return when (ev.action) {
MotionEvent.ACTION_DOWN -> {
startX = lp.x
startY = lp.y
touchDownX = ev.rawX
touchDownY = ev.rawY
moved = false
true
}
MotionEvent.ACTION_MOVE -> {
val dx = ev.rawX - touchDownX
val dy = ev.rawY - touchDownY
if (!moved && (abs(dx) > TAP_SLOP_PX || abs(dy) > TAP_SLOP_PX)) {
moved = true
showDismissZone()
}
if (moved) {
lp.x = startX + dx.toInt()
lp.y = startY + dy.toInt()
try { wm.updateViewLayout(v, lp) } catch (_: Exception) {}
dismissView?.setHighlighted(isOverDismiss(lp.x, lp.y))
}
true
}
MotionEvent.ACTION_UP -> {
if (moved && isOverDismiss(lp.x, lp.y)) {
// Released on the X — tear down and signal the caller
// so the persisted setting flips off too.
hide()
onDismissed()
} else if (!moved) {
// Tap → bring the host app forward.
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
try { context.startActivity(intent) } catch (_: Exception) {}
}
hideDismissZone()
true
}
MotionEvent.ACTION_CANCEL -> {
hideDismissZone()
true
}
else -> false
}
}
}
/** Always-claiming wrapper. Without this, the osmdroid MapView descendant
* consumes ACTION_DOWN for pan handling and the OnTouchListener never
* fires — drags pan the map instead of moving the bubble. */
private class TouchInterceptor(context: Context) : FrameLayout(context) {
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = true
}
/** Bottom-center dismiss target — translucent dark circle with a white X
* that flips red when the bubble is hovering over it. Drawn manually so
* we don't need to ship a vector resource for one-off use. */
private class DismissView(context: Context) : View(context) {
private val bg = Paint(Paint.ANTI_ALIAS_FLAG)
private val xStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFFFFFFF.toInt()
strokeWidth = 8f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private var highlighted = false
fun setHighlighted(value: Boolean) {
if (highlighted != value) {
highlighted = value
invalidate()
}
}
override fun onDraw(canvas: Canvas) {
val cx = width / 2f
val cy = height / 2f
val r = minOf(cx, cy)
bg.color = if (highlighted) 0xDDD7263D.toInt() else 0xCC1A1A1A.toInt()
canvas.drawCircle(cx, cy, r - 4f, bg)
val inset = r * 0.35f
canvas.drawLine(cx - inset, cy - inset, cx + inset, cy + inset, xStroke)
canvas.drawLine(cx + inset, cy - inset, cx - inset, cy + inset, xStroke)
}
}
/**
* Compose's [ComposeView] requires both a LifecycleOwner and a
* [SavedStateRegistryOwner] in its view tree. A bare Service isn't an SSR
* owner, so we synthesize one bound to the bubble's lifetime. The
* lifecycle is forced to RESUMED on construction (Compose only renders at
* STARTED+) and DESTROYED on [destroy].
*/
private class OverlayOwner : SavedStateRegistryOwner {
private val lifecycleReg = LifecycleRegistry(this)
private val ssrController = SavedStateRegistryController.create(this)
init {
ssrController.performAttach()
ssrController.performRestore(null)
lifecycleReg.currentState = Lifecycle.State.RESUMED
}
override val lifecycle: Lifecycle get() = lifecycleReg
override val savedStateRegistry: SavedStateRegistry
get() = ssrController.savedStateRegistry
fun destroy() {
lifecycleReg.currentState = Lifecycle.State.DESTROYED
}
}
}
@@ -1,5 +1,8 @@
package org.soulstone.overwatch.ui
import android.content.Intent
import android.location.Location
import android.net.Uri
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -20,7 +23,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -37,7 +42,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -48,11 +53,18 @@ 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 kotlinx.coroutines.launch
import androidx.compose.ui.viewinterop.AndroidView
import kotlin.math.cos
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.ui.theme.ThreatColors
@OptIn(ExperimentalMaterial3Api::class)
@@ -62,14 +74,21 @@ fun MainScreen(
threat: ThreatLevel,
score: Int,
events: List<DetectionEvent>,
mapPoints: List<DeflockClient.AlprPoint>,
userLocation: Location?,
/** Visible radius of the map circle, in meters. Driven by the larger of
* the DeFlock and Citizen proximity sliders so the user sees the full
* area where a detection could fire. */
mapRadiusMeters: Float,
onStartStop: () -> Unit,
onOpenSettings: () -> Unit,
canStart: Boolean,
permissionMessage: String?
permissionMessage: String?,
showOpenAppSettings: Boolean = false,
onOpenAppSettings: () -> Unit = {}
) {
var showSheet by remember { mutableStateOf(false) }
var showSheet by rememberSaveable { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetScope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -77,30 +96,26 @@ fun MainScreen(
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 24.dp)
) {
Row(
// Box (rather than Row + SpaceBetween) so the title is truly centered
// regardless of the gear icon's width.
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween
.padding(top = 8.dp)
) {
Column {
Text(
text = "[DЯΣΛMMΛKΣЯ]",
text = "OVERWATCH",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
fontFamily = FontFamily.Monospace,
letterSpacing = 4.sp,
modifier = Modifier.align(Alignment.Center)
)
Text(
text = " . //0VΣЯW4TCH",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
}
IconButton(onClick = onOpenSettings) {
IconButton(
onClick = onOpenSettings,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
Icons.Filled.Settings,
contentDescription = "Settings",
@@ -115,7 +130,14 @@ fun MainScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true })
ThreatMapCircle(
level = threat,
animating = running,
userLocation = userLocation,
mapPoints = mapPoints,
mapRadiusMeters = mapRadiusMeters,
onTap = { showSheet = true }
)
Spacer(Modifier.height(12.dp))
Text(
@@ -159,6 +181,24 @@ fun MainScreen(
fontSize = 13.sp
)
}
if (showOpenAppSettings) {
Spacer(Modifier.height(8.dp))
Button(
onClick = onOpenAppSettings,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Open app settings",
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
@@ -185,16 +225,25 @@ fun MainScreen(
}
@Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = when (level) {
private fun ThreatMapCircle(
level: ThreatLevel,
animating: Boolean,
userLocation: Location?,
mapPoints: List<DeflockClient.AlprPoint>,
mapRadiusMeters: Float,
onTap: () -> Unit
) {
val idleColor = MaterialTheme.colorScheme.surfaceVariant
val activeColor = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f,
initialValue = if (animating) 0.5f else 1.0f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
@@ -202,12 +251,22 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
),
label = "pulse"
)
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.size(220.dp)
.clip(CircleShape)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
// While idle OR before the first location fix arrives, fall back to the
// solid pulsing circle — a blank/loading map mid-tile-fetch reads as
// broken. The map only renders once we actually have something to show.
if (!animating || userLocation == null) {
val color = if (animating) activeColor else idleColor
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(
@@ -215,18 +274,108 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
color.copy(alpha = alpha * 0.6f)
)
)
)
.clickable(onClick = onTap),
),
contentAlignment = Alignment.Center
) {
val labelText = when {
!animating -> "IDLE"
else -> "WAITING FIX"
}
Text(
text = level.name,
color = Color.White,
fontSize = 28.sp,
text = labelText,
color = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 22.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
)
}
} else {
// OSM map snapshot, centered on the user, with red ALPR pins and
// a blue user-position dot. Non-interactive — touches are captured
// by the click overlay above, so a tap opens the source-details
// bottom sheet. Pan/zoom controls stay off.
// Capture into a local non-null val so the AndroidView update
// lambda doesn't run afoul of smart-cast-into-closure rules.
val fix: Location = userLocation
val ctx = LocalContext.current
// Build the marker drawables once per Composition rather than
// every recomposition — bitmap allocation isn't free.
val userDot = remember(ctx) { dotDrawable(ctx.resources, 36, DOT_USER_BLUE) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { c ->
MapView(c).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(false)
setBuiltInZoomControls(false)
isClickable = false
isFocusable = false
}
},
update = { map ->
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear()
// ALPR dots first, user dot last so the user draws on top.
for (p in mapPoints) {
map.overlays.add(
Marker(map).apply {
position = GeoPoint(p.lat, p.lon)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = flockDot
title = p.operator ?: p.manufacturer ?: "ALPR"
setInfoWindow(null)
}
)
}
map.overlays.add(
Marker(map).apply {
position = GeoPoint(fix.latitude, fix.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = userDot
setInfoWindow(null)
}
)
// Fit the visible radius to the larger of the two proximity
// settings. Defer to map.post so the call lands after layout
// — zoomToBoundingBox needs measured dimensions to compute
// the right zoom level. Latitude-aware longitude scaling so
// the bbox stays roughly square in real meters at any lat.
val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0)
val latDegPerMeter = 1.0 / 111_000.0
val lonDegPerMeter = 1.0 /
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
val bbox = BoundingBox(
fix.latitude + r * latDegPerMeter,
fix.longitude + r * lonDegPerMeter,
fix.latitude - r * latDegPerMeter,
fix.longitude - r * lonDegPerMeter
)
map.post { map.zoomToBoundingBox(bbox, false, 0) }
map.invalidate()
},
onRelease = { map -> map.onDetach() }
)
// Threat-tier scrim — pulses while scanning. Heavier alpha than
// the first cut so the tier color reads at a glance over OSM
// tiles, which are themselves cream/light by default.
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
Box(
modifier = Modifier
.fillMaxSize()
.background(activeColor.copy(alpha = scrimAlpha))
)
}
// Click capture sits on top so taps reach onTap regardless of which
// visual layer was painted underneath.
Box(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onTap)
)
}
}
@Composable
@@ -272,6 +421,14 @@ private fun SourcesPanel(events: List<DetectionEvent>) {
}
}
/** User-facing label for a detection source. The internal enum stays MIC
* (mic-bearing devices is the technical concept) while the UI shows the
* friendlier "COMMERCIAL" — Nest/Ring/Echo are commercial smart-home gear. */
private fun DetectionSource.displayLabel(): String = when (this) {
DetectionSource.MIC -> "COMMERCIAL"
else -> name
}
@Composable
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
val health by SourceHealth.flowFor(source).collectAsState()
@@ -291,7 +448,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = source.name,
text = source.displayLabel(),
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@@ -329,14 +486,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
)
} else {
Spacer(Modifier.height(4.dp))
events.take(3).forEach { e ->
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
events.take(3).forEach { e -> EventRow(e) }
if (events.size > 3) {
Text(
text = "+${events.size - 3} more",
@@ -348,3 +498,52 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
}
}
}
@Composable
private fun EventRow(e: DetectionEvent) {
val ctx = LocalContext.current
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f, fill = true)
)
if (e.hasGeo) {
IconButton(
onClick = {
// Force the pin to open in Google Maps rather than whichever
// app holds the user's default geo: handler — Waze, etc. can
// intercept geo: intents and we don't want that here. Falls
// back to a generic browser intent if Maps isn't installed.
val mapsUri = Uri.parse(
"https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}"
)
val mapsIntent = Intent(Intent.ACTION_VIEW, mapsUri)
.setPackage("com.google.android.apps.maps")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
ctx.startActivity(mapsIntent)
} catch (_: android.content.ActivityNotFoundException) {
val fallback = Intent(Intent.ACTION_VIEW, mapsUri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { ctx.startActivity(fallback) } catch (_: android.content.ActivityNotFoundException) {}
}
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Place,
contentDescription = "Open in Maps",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
}
}
@@ -0,0 +1,29 @@
package org.soulstone.overwatch.ui
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
/** Builds a small filled-circle Marker icon. Used for both the user-position
* dot (blue) and the ALPR pins (red) — osmdroid's default teardrop marker
* reads as a "click me" affordance which is wrong for a non-interactive
* visualization, so we use simple dots instead. Shared by the in-app and
* overlay versions of the threat circle. */
internal fun dotDrawable(
resources: Resources,
sizePx: Int,
coreColor: Int
): BitmapDrawable {
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val outline = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFFFFFFF.toInt() }
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, outline)
val core = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = coreColor }
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 4f, core)
return BitmapDrawable(resources, bitmap)
}
internal const val DOT_USER_BLUE = 0xFF2196F3.toInt()
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt()
@@ -0,0 +1,163 @@
package org.soulstone.overwatch.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import kotlin.math.cos
import kotlin.math.max
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.service.DetectionService
import org.soulstone.overwatch.ui.theme.ThreatColors
/**
* Smaller "chat-bubble" version of the threat-map circle, hosted in a
* WindowManager overlay by [org.soulstone.overwatch.service.OverlayManager].
*
* Self-contained: pulls all of its data from the same companion-level
* StateFlows the in-app [MainScreen] uses (DetectionService.running / store /
* mapPoints / location) plus the proximity sliders from [Settings]. The
* caller doesn't pass any state — keeps the OverlayManager dumb.
*
* Tap and drag are handled at the View layer (OverlayManager's OnTouchListener);
* this composable is render-only.
*/
@Composable
fun OverlayBubble() {
val ctx = LocalContext.current
val settings = remember(ctx) { Settings.get(ctx) }
val running by DetectionService.running.collectAsState()
val threat by DetectionService.store.threatLevel.collectAsState()
val userLocation by DetectionService.location.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val radius = max(deflockProx, citizenProx).toFloat()
val activeColor = when (threat) {
ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
val transition = rememberInfiniteTransition(label = "overlay-pulse")
val pulse by transition.animateFloat(
initialValue = 0.55f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Reverse
),
label = "overlay-pulse"
)
val userDot = remember(ctx) { dotDrawable(ctx.resources, 30, DOT_USER_BLUE) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) }
Box(
modifier = Modifier
.size(140.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
// The OverlayManager only attaches the bubble while running == true,
// but check anyway — paranoia keeps the bubble from rendering a stale
// map if a future code path lets the composition outlive the service.
val fix = userLocation
if (!running || fix == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(
activeColor.copy(alpha = pulse),
activeColor.copy(alpha = pulse * 0.6f)
)
)
)
)
} else {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { c ->
MapView(c).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(false)
setBuiltInZoomControls(false)
isClickable = false
isFocusable = false
}
},
update = { map ->
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear()
for (p in mapPoints) {
map.overlays.add(
Marker(map).apply {
position = GeoPoint(p.lat, p.lon)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = flockDot
title = p.operator ?: p.manufacturer ?: "ALPR"
setInfoWindow(null)
}
)
}
map.overlays.add(
Marker(map).apply {
position = GeoPoint(fix.latitude, fix.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = userDot
setInfoWindow(null)
}
)
val r = radius.toDouble().coerceAtLeast(50.0)
val latDegPerMeter = 1.0 / 111_000.0
val lonDegPerMeter = 1.0 /
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
val bbox = BoundingBox(
fix.latitude + r * latDegPerMeter,
fix.longitude + r * lonDegPerMeter,
fix.latitude - r * latDegPerMeter,
fix.longitude - r * lonDegPerMeter
)
map.post { map.zoomToBoundingBox(bbox, false, 0) }
map.invalidate()
},
onRelease = { map -> map.onDetach() }
)
// Tier scrim — same pulse alpha range as the in-app circle.
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
Box(
modifier = Modifier
.fillMaxSize()
.background(activeColor.copy(alpha = scrimAlpha))
)
}
}
}
@@ -1,5 +1,8 @@
package org.soulstone.overwatch.ui
import android.content.Intent
import android.net.Uri
import android.provider.Settings as AndroidSettings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -11,6 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,6 +31,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
@@ -45,11 +52,14 @@ fun SettingsScreen(
val ble by settings.bleEnabled.collectAsState()
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState()
val mic by settings.micEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val theme by settings.themeMode.collectAsState()
val vibrate by settings.vibrateOnAlert.collectAsState()
val overlay by settings.overlayEnabled.collectAsState()
val context = LocalContext.current
Column(
modifier = Modifier
@@ -78,8 +88,8 @@ fun SettingsScreen(
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports (gated)", waze) { settings.setWazeEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp))
if (isRunning) {
Button(
@@ -109,21 +119,49 @@ fun SettingsScreen(
SectionLabel("Proximity thresholds")
SliderRow(
label = "DeFlock alert distance",
valueLabel = "${deflockProx} m",
value = deflockProx.toFloat(),
persistedValue = deflockProx,
range = 50f..1600f,
steps = 30,
onChange = { settings.setDeflockProximityM(it.toInt()) }
onCommit = { settings.setDeflockProximityM(it) }
)
SliderRow(
label = "Waze alert distance",
valueLabel = "${wazeProx} m",
value = wazeProx.toFloat(),
label = "Citizen alert distance",
persistedValue = citizenProx,
range = 100f..5000f,
steps = 48,
onChange = { settings.setWazeProximityM(it.toInt()) }
onCommit = { settings.setCitizenProximityM(it) }
)
Spacer(Modifier.height(16.dp))
SectionLabel("Alerts")
SourceToggle("Vibrate on threat escalation", vibrate) { settings.setVibrateOnAlert(it) }
Spacer(Modifier.height(16.dp))
SectionLabel("Display over other apps")
SourceToggle("Floating threat circle", overlay) { enabled ->
settings.setOverlayEnabled(enabled)
// Special-access perm: can't be granted via runtime prompt. Bounce
// the user to the system settings page for this app so they can
// approve. The DetectionService re-checks canDrawOverlays at show()
// time so a denied/revoked perm just means the bubble silently
// doesn't appear — no crash.
if (enabled && !AndroidSettings.canDrawOverlays(context)) {
val intent = Intent(
AndroidSettings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { context.startActivity(intent) } catch (_: Exception) {}
}
}
if (overlay && !AndroidSettings.canDrawOverlays(context)) {
Text(
"Permission needed — system page should have opened. If not, grant manually under Apps → OVERWATCH → Display over other apps.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp)
)
}
Spacer(Modifier.height(16.dp))
SectionLabel("Appearance")
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
@@ -161,25 +199,35 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// weight(1f) reserves the remaining row width for the label so it
// wraps on narrow screens instead of clipping under the Switch.
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
fontFamily = FontFamily.Monospace,
modifier = Modifier
.weight(1f, fill = true)
.padding(end = 12.dp)
)
Switch(checked = value, onCheckedChange = onChange)
}
}
/**
* Slider that commits the value to Settings only on drag-release. The label
* tracks the live drag position locally to avoid spamming SharedPreferences
* writes (and downstream StateFlow re-emissions) on every pixel of movement.
*/
@Composable
private fun SliderRow(
label: String,
valueLabel: String,
value: Float,
persistedValue: Int,
range: ClosedFloatingPointRange<Float>,
steps: Int,
onChange: (Float) -> Unit
onCommit: (Int) -> Unit
) {
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -192,15 +240,16 @@ private fun SliderRow(
fontFamily = FontFamily.Monospace
)
Text(
text = valueLabel,
text = "${live.toInt()} m",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = value,
onValueChange = onChange,
value = live,
onValueChange = { live = it },
onValueChangeFinished = { onCommit(live.toInt()) },
valueRange = range,
steps = steps
)
+2 -4
View File
@@ -1,14 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">[DЯΣΛMMΛKΣЯ] OVERWATCH</string>
<string name="title_line1">[DЯΣΛMMΛKΣЯ]</string>
<string name="title_line2"> . //0VΣЯW4TCH</string>
<string name="app_name">OVERWATCH</string>
<string name="status_idle">Idle — press START to begin scanning</string>
<string name="status_scanning_clear">All clear</string>
<string name="status_scanning">Scanning…</string>
<string name="action_start">START</string>
<string name="action_stop">STOP</string>
<string name="notification_channel_name">DREAMMAKER / OVERWATCH detection</string>
<string name="notification_channel_name">OVERWATCH detection</string>
<string name="notification_channel_desc">Foreground notification while scanning</string>
<string name="notification_title">OVERWATCH active</string>
<string name="notification_text">Scanning for nearby surveillance</string>
+2
View File
@@ -7,6 +7,7 @@ activityCompose = "1.9.3"
composeBom = "2024.12.01"
material3 = "1.3.1"
playServicesLocation = "21.3.0"
osmdroid = "6.1.20"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -21,6 +22,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }