30 Commits

Author SHA1 Message Date
Kara Zajac f958e30653 Drop the DREAMMAKER byline from the footer
OVERWATCH stands on its own — remove the "DREAMMAKER project" attribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173RhsXkdMViDR8DMNAG3ms
2026-07-15 20:39:33 -04:00
KaraZajac fb4502f79a docs: clean up the README header (drop DREAMMAKER branding)
Replaces the stylized "[DЯΣΛMMΛKΣЯ] // 0VΣЯW4TCH" header block with a plain
"# OVERWATCH" title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-15 20:36:33 -04:00
Kara Zajac 869726ce19 Cache-bust style.css so the new .shots gallery rules load
Cloudflare was serving a stale cached style.css without the screenshots
gallery CSS; version the link (?v=2) to fetch the fresh stylesheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173RhsXkdMViDR8DMNAG3ms
2026-07-15 19:18:13 -04:00
KaraZajac 66b58891a3 docs: add a Screenshots section to the README landing page
Shows the three app screens (main / sources / settings) on the repo's front
page, reusing the existing docs/img assets. Complements the showcase site's
gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-15 19:13:20 -04:00
Kara Zajac 7d85dec970 Add screenshots gallery to the OVERWATCH showcase page
New "Screens" section (3-up phone gallery: threat ring, source
drill-down, settings) with captions, plus a nav anchor and .shots CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173RhsXkdMViDR8DMNAG3ms
2026-07-15 19:09:10 -04:00
Kara Zajac 98b6769256 docs: adopt the radar icon + v0.5.3 (smart-glasses detection)
Swaps the ◉ glyph for the new radar logo (nav, footer, favicon, OG card),
bumps to v0.5.3, and notes smart-glasses (Meta/Snap/Vuzix) in the Commercial source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:55:07 -04:00
KaraZajac ae6ea536fc v0.5.3 — smart-glasses detection + radar icon
Build APK / build (push) Has been cancelled
versionCode 18 -> 19, versionName 0.5.2 -> 0.5.3. README: COMMERCIAL source now
lists smart glasses; changelog updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-15 18:29:39 -04:00
KaraZajac 7eb3f2e1b3 feat: radar app icon (launcher + themed + notification)
Radar logo — range rings, crosshair, sweep beam, red contact blip on the dark
screen. Adaptive foreground + a monochrome themed-icon layer + a matching
notification small icon (was the generic system ic_menu_view).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-15 18:28:54 -04:00
KaraZajac c730657605 feat: detect Meta/Snap/Vuzix smart glasses in the COMMERCIAL source
Camera-bearing smart glasses via Bluetooth SIG company IDs (Meta 0x01AB, Meta
Platforms Technologies 0x058E, Luxottica 0x0D53, Snap 0x03C2, Vuzix 0x060C) —
the same manufacturer-id vector used for Echo/Nest and the one Nearby Glasses
relies on. RayNeo/XREAL/Rokid (no dedicated SIG id) matched by BLE-name hint.
New Family.GLASSES; COMMERCIAL toggle label updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-15 18:28:54 -04:00
KaraZajac 610d6161e8 docs: link the homepage (overwatch.netslum.io) in the README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-13 22:51:51 -04:00
Kara Zajac 84b1de016b docs: add OVERWATCH showcase page (overwatch.netslum.io)
Static landing page (index.html + style.css + app.js + og) reusing the
SKELETONKEY project-page design system, tailored to the Android app:
hero + threat-circle screenshot, six detection-source cards, the
green/yellow/orange/red tier model, passive-only ethics, and a
sideload guide. Canonical = overwatch.netslum.io.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:03:11 -04:00
KaraZajac 9314814ea4 v0.5.2 — commit fixed debug keystore for stable signing
Build APK / build (push) Has been cancelled
Every CI build previously minted a fresh debug keystore, so each release failed
to install over the last (signature mismatch) — forcing an uninstall and loss of
the on-device proxy token. Commit a fixed debug.keystore (a debug key is
non-secret; password is the well-known "android") and point the debug
signingConfig at it, so CI and local builds sign identically and updates install
in place. No functional app changes.

versionCode 17 -> 18, versionName 0.5.1 -> 0.5.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-13 17:11:07 -04:00
KaraZajac 7671e8fef8 v0.5.1 — UI: map ring, ⌖ crosshair, source-color dots, bottom START
Build APK / build (push) Has been cancelled
- Larger main-screen map circle (220 -> 300dp) wrapped in a threat-color ring
  (green/yellow/orange/red while scanning, gray idle).
- User position renders as a ⌖ crosshair; map geodata color-coded by source
  (Flock/DeFlock red, Waze blue, Citizen purple) in both the main circle and
  the floating overlay bubble.
- START button moved to the bottom of the screen.
- README brought current: COMMERCIAL source + overlay documented, Waze-via-proxy
  and the CI release pipeline described, architecture/permissions/changelog
  updated, build reqs corrected to JDK 17 / SDK 35.

versionCode 16 -> 17, versionName 0.5.0 -> 0.5.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-13 17:03:06 -04:00
KaraZajac ef68394080 ci: build debug APK and publish release on tag via GitHub Actions
Build APK / build (push) Has been cancelled
release.yml builds :app:assembleDebug (JDK 17 + Android SDK) and uploads the
APK as an artifact on every run; on a v* tag push it also creates a GitHub
Release with the APK attached. No build-time secrets — the app carries no
key/token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-13 00:26:07 -04:00
KaraZajac 26c4248282 v0.5.0 — Waze police source via key-protected proxy
Re-adds the WAZE detection source (removed in v0.1.5 when Waze reCAPTCHA-gated
its live-map endpoint). Data comes from OpenWeb Ninja's hosted Waze feed, but
the paid API key never ships in the app: a Caddy reverse proxy at
api.blackflagintel.com injects the key server-side, and the app authenticates
with a scoped X-App-Token entered in Settings and stored encrypted via the
Android Keystore (SecureStore). No credential is baked into the APK.

- scan/WazeClient.kt, scan/WazeScanner.kt: proxy client + 4-min poller,
  200-alert page + client-side POLICE filter, 45-min freshness window.
- data/settings/SecureStore.kt: Keystore AES/GCM at-rest store (no dependency).
- Settings: encrypted wazeProxyToken + "Waze police feed" token field.
- ConfidenceEngine.scoreWaze, SourceHealth/DetectionSource WAZE, service wiring.
- Removes the BuildConfig/local.properties key baking entirely.

versionCode 15 -> 16, versionName 0.4.0 -> 0.5.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
2026-07-13 00:26:07 -04:00
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 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
KaraZajac 5a7a9e90e4 v0.1.4 — Citizen.com as 5th detection source
Waze remains gated behind 2025/2026 reCAPTCHA on live-map; added Citizen
as a working alternative for police-presence signal. Citizen pulls from
911 + scanner traffic, returns rich incident data (lat/lon, timestamp,
severity level, responding precinct, title), and has no auth or
rate-limit gating.

New scan/CitizenClient.kt:
  - GET /api/incident/trending (bbox query → list of incident ids)
  - GET /api/incident/{id}      (full detail per id)
  - Sealed TrendingResult so the scanner can surface 4xx via SourceHealth.

New scan/CitizenScanner.kt:
  - 60s poll interval, 30-min freshness window
  - Per-id detail cache for the lifetime of a start/stop cycle —
    incidents are immutable, so each is fetched at most once per session
  - Title regex filter: drops pure fire/medical events that don't imply
    police presence; retains them when the title also names police action
  - Submits to the shared DetectionStore as DetectionSource.CITIZEN

ConfidenceEngine.scoreCitizen:
  - Base 55 (matches the old W_WAZE_POLICE weight)
  - +5 if level >= 2 (Citizen's own severity)
  - +5 if title contains police-action keyword (police/officer/arrest/
    swat/tactical/raid/pursuit/stop/search warrant)

Settings: new citizenEnabled toggle (default on); UI row in
SettingsScreen. SourceHealth has a new flow for CITIZEN. DetectionService
starts the scanner alongside the others when location is available.

Continued investigation of Waze / Google Maps police APIs:
  - Waze SDK (hewliyang/waze-traffic-api): wraps the same blocked endpoint
  - ddd/google_maps reverse-engineering: locations only, no incidents
  - Google Maps Platform: no public incidents API (just displays Waze data internally)
  - TomTom Traffic Incidents: traffic-only, no police presence
  - Waze for Cities partner feed: real but requires being a city/police agency

versionCode 4 → 5, versionName 0.1.3 → 0.1.4.
2026-04-28 21:48:54 -04:00
KaraZajac 00584f58c9 v0.1.3 — DeFlock via Overpass + per-source health UI
The cdn.deflock.me CDN is gated behind Cloudflare bot mitigation that
mobile HTTP clients can't pass. The live deflock-app Flutter client
abandoned that path; it POSTs Overpass-QL queries directly to
overpass.deflock.org (with overpass-api.de as a fallback). Verified by
hitting the same endpoint from curl — 22 ALPRs returned for the
Springfield VA bbox, matching the user's screenshot of the working app.

DeflockClient rewrite:
  - POST [out:json][timeout:25];(node[surveillance][type=ALPR](bbox););out body;
  - 5 km half-width bbox around the user
  - 24h on-disk cache keyed by 0.05° grid cell (revisits don't refetch)
  - Returns sealed FetchResult: Success(points) | Failed(reason)

DeflockScanner update:
  - Replaces 20° tile concept with distance-based refetch (1.5 km threshold)
  - Records SourceHealth on each fetch outcome

Waze: reCAPTCHA gating confirmed. WazeClient.fetchPoliceNear now returns
sealed FetchResult; WazeScanner records SourceHealth.FAILED with
"Upstream blocked (HTTP 403)" so the user sees why no Waze data is
flowing instead of silent zeros.

New fusion/SourceHealth.kt — per-source MutableStateFlow registry,
record(source, ok, message) + reset() called on service start/stop.

UI: SourceRow in the bottom-sheet drill-down now shows the health
message in orange when status = FAILED instead of "no detections".

versionCode 3 → 4, versionName 0.1.2 → 0.1.3.
2026-04-28 21:36:47 -04:00
KaraZajac 6c57297f58 v0.1.2 — audit fixes
Critical:
  - DetectionService.startInForeground now passes
    FOREGROUND_SERVICE_TYPE_LOCATION OR'd with TYPE_CONNECTED_DEVICE on
    Android 14+. Without this, the system silently revoked location access
    once the screen locked, breaking DeFlock + Waze for foreground-service
    use (the whole point of the foreground service).
  - DeflockClient and WazeClient now skip JSON entries whose lat/lon parse
    to NaN. Previously NaN flowed into Location.distanceBetween, the
    NaN > limit check returned false (IEEE 754), and we submitted a
    full-confidence detection labeled "@0m" — instant false-positive RED
    from a single malformed map entry.

UX:
  - First-run permission flow auto-starts scanning after the user grants
    everything; no second tap on START required.
  - Settings shows a "Restart scan to apply" button when toggling sources
    while scanning. Source toggle changes used to silently no-op until
    the next manual stop+start.

versionCode 1 → 3, versionName 0.1.0 → 0.1.2.
2026-04-28 21:22:20 -04:00
KaraZajac 88e6f52ce7 Fix START button being disabled before permissions are granted
The button was gated on `granted || running`, but the only thing that
triggers the permission request is tapping the button — catch-22 that
left first-time users with no way to grant permissions.

Always enable the button when not running; the onStartStop handler already
routes correctly (start scanning if granted, else launch the permission
request flow). Updated the helper text to point at this directly.
2026-04-28 21:15:42 -04:00
KaraZajac 3574970a5f Add OVERWATCH v0.1.0 — full detection engine + polish
Phase 1 (BLE), Phase 2 (WiFi BSSID/SSID), Phase 3 (DeFlock map proximity),
Phase 4 (Waze live POLICE alerts), and Phase 5 polish all wired through one
DetectionStore. Confidence engine scores 0-100; UI maps to 4-tier circle.

Polish:
  - Stylized two-line app title: [DЯΣΛMMΛKΣЯ] // 0VΣЯW4TCH
  - Modal bottom sheet for source drill-down (tap circle)
  - Settings screen: per-source toggles, proximity sliders, theme select
  - SharedPreferences-backed Settings with StateFlow exposure
  - DetectionService respects per-source toggles at start time
  - Scanners read proximity overrides via supplier lambdas

README documents all sources, architecture, build steps, permissions, and
the legal disclaimer.
2026-04-28 21:10:57 -04:00
34 changed files with 2805 additions and 51 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Build APK
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Build debug APK
run: |
chmod +x ./gradlew
./gradlew :app:assembleDebug --stacktrace --no-daemon
- name: Stage APK
run: |
mkdir -p out
cp app/build/outputs/apk/debug/app-debug.apk "out/OVERWATCH-${GITHUB_REF_NAME}.apk"
# Uploaded on every run (incl. workflow_dispatch) so a build can be
# validated without cutting a release.
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: overwatch-apk
path: out/*.apk
# Only tag pushes (v*) create a GitHub Release with the APK attached.
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: out/*.apk
generate_release_notes: true
+71 -21
View File
@@ -1,5 +1,4 @@
# [DЯΣΛMMΛKΣЯ]
## &nbsp;&nbsp;&nbsp;. //0VΣЯW4TCH
# OVERWATCH
A native Android (Kotlin) **passive surveillance-detection** app. Open it, hit
**START**, and a circle turns **green / yellow / orange / red** depending on
@@ -13,7 +12,21 @@ on upward escalations — you don't have to be looking at the screen.
> advertise/fuzz code from one of the reference projects is intentionally
> excluded.
Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest release: [v0.5.3](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
---
## Screenshots
<p align="center">
<img src="docs/img/overwatch-main.png" width="240" alt="Main screen — live threat-map circle" />
&nbsp;&nbsp;
<img src="docs/img/overwatch-sources.png" width="240" alt="Detection-source drill-down" />
&nbsp;&nbsp;
<img src="docs/img/overwatch-settings.png" width="240" alt="Settings" />
</p>
<p align="center"><sub>Live threat-map circle · source drill-down · settings</sub></p>
---
@@ -25,8 +38,10 @@ Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug
| **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. 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. |
| **WAZE** | User-reported `POLICE` alerts still active in the feed within configurable proximity (default 500 m), up to ~45 min old | `api.blackflagintel.com/waze/alerts-and-jams` — the OVERWATCH proxy (Caddy) that injects the OpenWeb Ninja key server-side and forwards to their hosted Waze scrape, sidestepping the reCAPTCHA gating that 403s direct `live-map/api/georss` calls. The app authenticates with an `X-App-Token` entered in Settings (encrypted on-device); the paid key never ships in the APK. Polled every ~4 min. Upstream ignores type filtering and caps at 200 alerts, so the client pulls the full page and filters to `POLICE` itself. Alerts carry confidence (05) + reliability (010); high values nudge the score up. No token → source shows "not configured" in the drill-down. |
| **COMMERCIAL** | Nearby consumer smart-home / voice gear (Nest, Ring, Echo, hidden cams) and camera-bearing smart glasses (Meta, Snap, Vuzix) as a secondary situational signal | Rides the BLE + WiFi scans — OUI / device-name / service-UUID / SSID matches plus Bluetooth SIG company IDs from `MicTargets`. Score-capped at ORANGE so a cluster of doorbells (or a passing pair of Ray-Bans) never reads as ALPR-grade certainty. |
> **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.
> **Waze is back (v0.4.0+), via a key-protected proxy.** Waze reCAPTCHA-gated its `live-map/api/georss` endpoint in 2025/2026 — automated calls get HTTP 403 regardless of IP or headless-vs-headful browser (it scores browser *reputation*, verified by direct testing), which is why v0.1.5 removed the original integration and why no free scraper survives. OVERWATCH reads Waze POLICE alerts through [OpenWeb Ninja](https://www.openwebninja.com)'s hosted feed (pay-as-you-go ~$0.005/req, ≈ $13/mo at the 4-min poll). To keep the paid key off every device, the app doesn't hold it: a Caddy reverse proxy at `api.blackflagintel.com` injects the key server-side, and the app authenticates with a scoped, revocable `X-App-Token` entered in Settings (stored encrypted via the Android Keystore). The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only. Waze complements Citizen: denser for roadway stops / speed traps.
Every observation is scored 0100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources:
@@ -44,13 +59,21 @@ 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."
While scanning, the circle becomes a live OpenStreetMap centered on you, wrapped
in a **threat-color ring** (the current tier at a glance) and marked with a ⌖
crosshair for your position. Map geodata is color-coded by source — **Flock /
DeFlock cameras red, Waze police blue, Citizen incidents purple** — so each dot
is self-explanatory. The same map renders in a smaller floating overlay bubble
(Settings → Display over other apps) so it works over other apps.
---
## 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).
- **In-app**: the threat circle shows a live map with a threat-color ring and
source-color dots while scanning; tap it to open the bottom-sheet drill-down
with per-source rows. DeFlock, Citizen, and Waze events carry coordinates —
each row has a tap-to-open Maps icon.
- **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
@@ -66,24 +89,30 @@ glance from "scanning, all clear."
## Architecture
```
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
ui/SettingsScreen.kt source toggles, distance sliders, vibrate, theme
ui/MainScreen.kt map circle + threat ring + START/STOP + drill-down sheet
ui/OverlayBubble.kt floating "chat-bubble" version of the map circle
ui/MarkerIcons.kt map marker drawables — source dots + ⌖ user crosshair
ui/SettingsScreen.kt source toggles, distance sliders, Waze token, vibrate, theme
ui/theme/Theme.kt Material 3 dark/light + threat colors
service/DetectionService.kt foreground service — owns scanners, notification, vibration
service/OverlayManager.kt WindowManager host for the floating overlay bubble
scan/BleScanner.kt BLE callback scanner
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
scan/DeflockScanner.kt location-driven proximity check + failure backoff
scan/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)
scan/WazeClient.kt GET api.blackflagintel.com proxy (X-App-Token) → OpenWeb Ninja
scan/WazeScanner.kt ~4 min poller, 200-alert page, client-side POLICE filter
fusion/ConfidenceEngine.kt scoring (BLE / WiFi / DeFlock / Citizen / Waze / Commercial)
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
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
data/settings/SecureStore.kt Keystore AES/GCM store for the Waze proxy token
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers, MicTargets
```
No detection-history database. All state is in-memory and clears on stop, by
@@ -95,13 +124,14 @@ into a stuck state.
## Build & install
Requires:
- **JDK 21** (Android Gradle Plugin 8.7.x rejects JDK 26)
- **Android Studio** with SDK Platform 34 + Build-Tools 34.x + Platform-Tools
- **JDK 17** (17 or 21; Android Gradle Plugin 8.7.x rejects JDK 26)
- **Android Studio** with SDK Platform 35 + Build-Tools 35.x + Platform-Tools
```sh
# 1) Copy the example local.properties and point sdk.dir at your install
cp local.properties.example local.properties
# edit local.properties → sdk.dir=/Users/<you>/Library/Android/sdk
# (Waze needs no build config — paste the proxy token into Settings in-app)
# 2) Make sure JAVA_HOME is JDK 21
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
@@ -113,6 +143,13 @@ export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
Or download the latest debug-signed APK from
[Releases](https://github.com/KaraZajac/OVERWATCH/releases).
Releases are cut by CI: pushing a `v*` tag runs `.github/workflows/release.yml`,
which builds `:app:assembleDebug` and attaches the APK to a GitHub Release. No
build-time secrets — the app ships with no key or token. A fixed `debug.keystore`
is committed (a debug key is non-secret; password is the well-known `android`) so
every build — CI or local — signs identically and updates install in place
without an uninstall.
---
## Permissions
@@ -124,10 +161,11 @@ Or download the latest debug-signed APK from
| `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 Overpass + Citizen API |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass, Citizen API, Waze proxy |
| `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 |
| `SYSTEM_ALERT_WINDOW` | Optional floating threat-circle overlay (special-access; granted via system settings) |
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
@@ -140,14 +178,20 @@ so you can grant manually.
Tap the gear icon in the top-right.
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen independently.
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen / Waze / Commercial 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)
- Citizen: 100 m 5000 m (default 500 m)
- Waze: 100 m 5000 m (default 500 m)
- **Waze police feed**: paste the `api.blackflagintel.com` proxy token. It's
stored encrypted on-device (Android Keystore), never baked into the APK; the
paid OpenWeb Ninja key stays server-side on the proxy. Empty = Waze source off.
- **Alerts**:
- Vibrate on threat escalation (default on)
- **Display over other apps**: floating threat-circle overlay (needs the
special-access permission; the app bounces you to the system page to grant it).
- **Appearance**: System / Dark / Light (default Dark)
---
@@ -160,22 +204,28 @@ These live under `REFERENCES/` (gitignored):
- **flock-detection** — confidence-scoring algorithm (highest reusability), RSSI rise-peak-fall, OUIs + UUIDs + patterns
- **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android)
- **deflock** + **deflock-app** — Overpass query format + proximity-alert pattern (the Flutter app uses Overpass directly, not the CDN tiles, which the OVERWATCH client mirrors)
- **wazepolice** — live-map/api/georss recipe; informed v0.1.0v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated)
- **wazepolice** — original live-map/api/georss recipe; that endpoint is now reCAPTCHA-gated, so v0.5.0 re-added Waze via the OpenWeb Ninja proxy (`api.blackflagintel.com`) instead of hitting Waze directly
---
## Status
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:
field-tested. Current release **v0.5.1**. Notable changes:
- v0.1.2 — Android 14+ foreground service type fix (location was being silently revoked); NaN-coordinate filter on map data.
- v0.1.2 — Android 14+ foreground service type fix; NaN-coordinate filter on map data.
- v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked).
- v0.1.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.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround at the time).
- v0.1.6 — Dynamic notification with tier + label, haptic alerts, Open-in-Maps for geo events.
- v0.1.7 — System back from Settings returns to MAIN instead of exiting.
- v0.2.0 — Live map circle + COMMERCIAL source (Nest / Ring / Echo / hidden cams).
- v0.2.1v0.2.2 — Live proximity refresh, map dot markers, map + Settings UI polish.
- v0.3.0v0.3.2 — Floating threat-circle overlay (chat-bubble style) + drag/crash fixes.
- v0.5.0 — Waze re-added via a key-protected Caddy proxy (`api.blackflagintel.com`): the OpenWeb Ninja key stays server-side, the app uses an encrypted per-device token. GitHub Actions release pipeline added.
- v0.5.1 — UI: larger map circle with a threat-color ring, ⌖ user crosshair, source-color dots (Flock red / Waze blue / Citizen purple), START moved to the bottom.
- v0.5.2 — Committed a fixed debug keystore so CI + local builds sign identically; updates now install in place (no functional changes).
- v0.5.3 — Detect Meta / Snap / Vuzix smart glasses in the COMMERCIAL source (BLE company-id + name vectors); new radar app icon (launcher, themed, and notification).
## License
+15 -2
View File
@@ -12,8 +12,21 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
versionCode = 14
versionName = "0.3.2"
versionCode = 19
versionName = "0.5.3"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
// password is the well-known "android") so CI and local builds sign
// identically. Without it each CI build minted a fresh debug key and updates
// wouldn't install over the previous one.
signingConfigs {
getByName("debug") {
storeFile = rootProject.file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
@@ -99,7 +99,8 @@ class MainActivity : ComponentActivity() {
// source is briefly toggled.
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val mapRadiusM = maxOf(deflockProx, citizenProx).toFloat()
val wazeProx by settings.wazeProximityM.collectAsState()
val mapRadiusM = maxOf(deflockProx, citizenProx, wazeProx).toFloat()
val granted by permissionsGranted
val denied by permanentlyDenied
@@ -0,0 +1,89 @@
package org.soulstone.overwatch.data.settings
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Tiny at-rest secret store for on-device credentials (the Waze proxy token).
*
* Values are AES/GCM-encrypted with a key held in the Android Keystore (hardware
* -backed where available and never exportable), and the ciphertext is kept in
* app-private SharedPreferences. This protects the token beyond plain prefs —
* against device backups, rooted-device extraction, and forensic access — so a
* shared/published APK carries no credential and a compromised backup yields
* nothing usable.
*
* No third-party dependency (Jetpack Security's EncryptedSharedPreferences is in
* maintenance limbo); this is ~40 lines of standard Keystore AES/GCM instead.
*/
object SecureStore {
private const val TAG = "SecureStore"
private const val PREFS = "overwatch_secure"
private const val KEY_ALIAS = "overwatch_secret_key"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_LEN = 12
private const val GCM_TAG_BITS = 128
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}
/** Encrypt + persist [value] under [name]. Empty string clears the entry. */
fun put(context: Context, name: String, value: String) {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (value.isEmpty()) {
prefs.edit { remove(name) }
return
}
try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
// Prepend the (12-byte) IV so decryption is self-contained.
val blob = Base64.encodeToString(cipher.iv + ct, Base64.NO_WRAP)
prefs.edit { putString(name, blob) }
} catch (e: Exception) {
Log.w(TAG, "Failed to store secret '$name': ${e.message}")
}
}
/** Decrypt the value stored under [name], or null if absent/undecryptable. */
fun get(context: Context, name: String): String? {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val blob = prefs.getString(name, null) ?: return null
return try {
val bytes = Base64.decode(blob, Base64.NO_WRAP)
val iv = bytes.copyOfRange(0, IV_LEN)
val ct = bytes.copyOfRange(IV_LEN, bytes.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(ct), Charsets.UTF_8)
} catch (e: Exception) {
Log.w(TAG, "Failed to read secret '$name': ${e.message}")
null
}
}
}
@@ -16,7 +16,10 @@ import kotlinx.coroutines.flow.asStateFlow
* Per-source toggles only take effect at the next Start cycle — flipping a
* source while scanning will NOT live-restart that scanner.
*/
class Settings private constructor(private val prefs: SharedPreferences) {
class Settings private constructor(
private val prefs: SharedPreferences,
private val appContext: Context
) {
enum class ThemeMode { SYSTEM, DARK, LIGHT }
@@ -32,6 +35,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
@@ -45,6 +51,17 @@ class Settings private constructor(private val prefs: SharedPreferences) {
)
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
// Shared secret the app presents to the api.blackflagintel.com proxy, which
// holds the real OpenWeb Ninja key server-side. Stored encrypted (Keystore),
// never baked into the APK, so a published build carries no usable credential.
private val _wazeProxyToken = MutableStateFlow(SecureStore.get(appContext, KEY_WAZE_TOKEN) ?: "")
val wazeProxyToken: StateFlow<String> = _wazeProxyToken.asStateFlow()
private val _themeMode = MutableStateFlow(
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
)
@@ -60,6 +77,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
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 setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v }
fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
fun setDeflockProximityM(v: Int) {
@@ -74,6 +92,18 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_citizenProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
}
fun setWazeProxyToken(v: String) {
val t = v.trim()
SecureStore.put(appContext, KEY_WAZE_TOKEN, t)
_wazeProxyToken.value = t
}
fun setThemeMode(mode: ThemeMode) {
prefs.edit { putString(KEY_THEME, mode.name) }
_themeMode.value = mode
@@ -95,21 +125,26 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_CITIZEN = "src_citizen"
private const val KEY_WAZE = "src_waze"
private const val KEY_MIC = "src_mic"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_WAZE_TOKEN = "waze_proxy_token"
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_CITIZEN_PROX = 500
const val DEFAULT_WAZE_PROX = 500
@Volatile private var INSTANCE: Settings? = null
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
INSTANCE ?: Settings(
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE),
context.applicationContext
).also { INSTANCE = it }
}
}
@@ -5,17 +5,19 @@ 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.
* Scope is intentionally narrow — well-known smart-home OEMs whose devices stay
* in fixed locations and continuously listen, plus body-worn smart glasses
* (Meta Ray-Ban, Snap Spectacles) that record the space around them. 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 }
enum class Family { ECHO, RING, GOOGLE, HIDDEN_CAM, GLASSES }
/** Bluetooth SIG company identifiers for "voice/smart-home" device families. */
private val MFG_GOOGLE = 0x00E0
@@ -23,6 +25,25 @@ object MicTargets {
/** Yingxin / cheap-spy-cam mfg id seen in field reports. */
private val MFG_YINGXIN = 0x05A7
/**
* Bluetooth SIG company identifiers for camera-bearing smart glasses. The
* manufacturer-id field is the reliable vector — device names and service
* UUIDs are inconsistent across advertising frames (per the open-source
* Nearby Glasses project, which this mirrors). Caveat: these ids are
* manufacturer-wide, so Meta's also match Quest VR headsets (still a
* camera/mic device, so an acceptable secondary signal). Luxottica in BLE
* mfg data is essentially only their *smart* eyewear — plain sunglasses
* have no radio. Brands without a dedicated SIG company id (RayNeo — which
* rides under TCL's 0x0BC6, too broad to use — plus XREAL, Rokid) advertise
* under a chipset vendor's id, so those are caught by distinctive BLE-name
* hints below instead.
*/
private val MFG_META = 0x01AB // Meta Platforms, Inc. (ex-Facebook)
private val MFG_META_TECH = 0x058E // Meta Platforms Technologies (Reality Labs; also Quest)
private val MFG_LUXOTTICA = 0x0D53 // Luxottica — Ray-Ban / Oakley Meta frames
private val MFG_SNAP = 0x03C2 // Snap Inc. — Spectacles
private val MFG_VUZIX = 0x060C // Vuzix — enterprise / AR smart glasses
/** Echo/Alexa Voice Service GATT (FE03 — assigned to Amazon Lab126). */
private val UUID_AVS = UUID.fromString("0000fe03-0000-1000-8000-00805f9b34fb")
@@ -63,7 +84,13 @@ object MicTargets {
"Nest" to Family.GOOGLE,
"GoogleHome" to Family.GOOGLE,
"Chromecast" to Family.GOOGLE,
"Google-Home" to Family.GOOGLE
"Google-Home" to Family.GOOGLE,
"Spectacles" to Family.GLASSES,
"Ray-Ban" to Family.GLASSES,
"RayNeo" to Family.GLASSES,
"Vuzix" to Family.GLASSES,
"XREAL" to Family.GLASSES,
"Rokid" to Family.GLASSES
)
private val SSID_HINTS: List<Pair<String, Family>> = listOf(
@@ -115,6 +142,7 @@ object MicTargets {
MFG_AMAZON -> Family.ECHO
MFG_GOOGLE -> Family.GOOGLE
MFG_YINGXIN -> Family.HIDDEN_CAM
MFG_META, MFG_META_TECH, MFG_LUXOTTICA, MFG_SNAP, MFG_VUZIX -> Family.GLASSES
else -> null
}
@@ -149,5 +177,6 @@ object MicTargets {
Family.RING -> "Ring"
Family.GOOGLE -> "Google Nest / Home"
Family.HIDDEN_CAM -> "Possible hidden mic / cam"
Family.GLASSES -> "Smart glasses"
}
}
@@ -28,11 +28,14 @@ object ConfidenceEngine {
const val W_DEFLOCK_NEAR = 60 // <= 200m
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
// Citizen (real-time incident feed)
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
// Waze (live POLICE alerts via the OpenWeb Ninja hosted feed)
const val W_WAZE_POLICE = 55
// Bonuses
const val B_MULTI_METHOD = 20
const val B_STRONG_RSSI = 10 // > -50 dBm
@@ -90,6 +93,16 @@ object ConfidenceEngine {
val precinct: 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?
)
data class Scored(
val score: Int,
val methods: String,
@@ -206,6 +219,22 @@ object ConfidenceEngine {
return Scored(score, tags.toString().trim(), label, isAxon = false)
}
fun scoreWaze(obs: WazeObservation): Scored {
// Baseline 55 for any POLICE alert within the proximity + age gate the
// caller already applied. Small crowd-trust nudges for high reliability
// and high confidence, capped well under the multi-method bonus so a
// corroborating BLE/WiFi/DeFlock hit still dominates the global tier.
var score = W_WAZE_POLICE
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 scoreDeflock(obs: DeflockObservation): Scored {
val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR
val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m"
@@ -27,12 +27,14 @@ object SourceHealth {
private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health())
private val _waze = 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 citizen: StateFlow<Health> = _citizen.asStateFlow()
val waze: StateFlow<Health> = _waze.asStateFlow()
val mic: StateFlow<Health> = _mic.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
@@ -40,6 +42,7 @@ object SourceHealth {
DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock
DetectionSource.CITIZEN -> citizen
DetectionSource.WAZE -> waze
DetectionSource.MIC -> mic
}
@@ -49,6 +52,7 @@ object SourceHealth {
DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock
DetectionSource.CITIZEN -> _citizen
DetectionSource.WAZE -> _waze
DetectionSource.MIC -> _mic
}
target.value = Health(
@@ -63,6 +67,7 @@ object SourceHealth {
_wifi.value = Health()
_deflock.value = Health()
_citizen.value = Health()
_waze.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, CITIZEN, MIC }
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, WAZE, MIC }
@@ -0,0 +1,225 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.cos
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches live Waze POLICE alerts through the OVERWATCH proxy at
* `api.blackflagintel.com` (a Caddy vhost on the ASTROPHAGE box). The proxy
* holds the real OpenWeb Ninja API key server-side and injects it; the app only
* presents a scoped, revocable `X-App-Token`. So the valuable key never ships in
* the APK — a leaked/decompiled build carries only the proxy token, which works
* against this one endpoint and can be rotated on the server in seconds.
*
* GET https://api.blackflagintel.com/waze/alerts-and-jams
* ?bottom_left=<minLat>,<minLon>&top_right=<maxLat>,<maxLon>&max_alerts=200
* Header: X-App-Token: <token from encrypted Settings, never baked into the APK>
*
* The token is entered once in Settings and stored encrypted (see [SecureStore]);
* an empty token means the source is unconfigured — [isConfigured] is false and
* the scanner surfaces that instead of calling out.
*
* Two verified quirks of the upstream feed drive the request/parse shape:
* - It echoes but does NOT honor an `alert_types` filter, and defaults to
* `max_alerts=20`, so POLICE reports get crowded out by HAZARD/ROAD_CLOSED.
* We pull the full page (200 = server ceiling) and filter to POLICE here.
* - Response envelope is `{ "data": { "alerts": [...], "jams": [...] } }`;
* each alert carries `alert_id`, `type`, `subtype` (nullable), `latitude`,
* `longitude`, `alert_confidence` (0-5), `alert_reliability` (0-10), and an
* ISO-8601 `publish_datetime_utc`. The parser also accepts Waze-native names
* (`location.y`/`confidence`/`pubMillis`) so a minor upstream change to the
* shape doesn't silently zero out detections.
*/
class WazeClient(
private val appToken: () -> String = { "" }
) {
companion object {
private const val TAG = "WazeClient"
private const val BASE = "https://api.blackflagintel.com/waze/alerts-and-jams"
private const val TIMEOUT_MS = 10_000
/** Never sweep a box tighter than this, so alerts the user is driving
* toward are already fetched by the time the precise proximity filter
* (applied in the scanner) starts including them. */
private const val BBOX_MIN_RADIUS_M = 800.0
}
/** True when a proxy token is set. False → source is unconfigured. */
val isConfigured: Boolean get() = appToken().isNotBlank()
data class Alert(
val uuid: String,
val subtype: String?,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val confidence: Int, // 0-5
val reliability: Int // 0-10
)
/** Outcome — distinguishes "no police alerts in area" from "couldn't reach the feed." */
sealed class FetchResult {
data class Success(val alerts: List<Alert>) : FetchResult()
data class Failed(val reason: String) : FetchResult()
}
suspend fun fetchPoliceNear(
lat: Double,
lon: Double,
radiusMeters: Float
): FetchResult = withContext(Dispatchers.IO) {
if (!isConfigured) return@withContext FetchResult.Failed("Proxy token not set")
val r = radiusMeters.toDouble().coerceAtLeast(BBOX_MIN_RADIUS_M)
val latDelta = r / 111_000.0
val lonDelta = r / (111_000.0 * cos(Math.toRadians(lat)).coerceAtLeast(0.01))
// Double.toString() is locale-independent (always '.'), so string-building
// the coordinates avoids any comma-decimal-separator surprise.
val bottomLeft = "${lat - latDelta},${lon - lonDelta}"
val topRight = "${lat + latDelta},${lon + lonDelta}"
// max_alerts=200 (the server ceiling), not an alert_types filter — see
// the class KDoc. The proximity-sized bbox keeps the real alert count
// well under 200, so POLICE entries are never truncated away.
val url = URL(
"$BASE?bottom_left=${enc(bottomLeft)}&top_right=${enc(topRight)}&max_alerts=200"
)
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("X-App-Token", appToken())
setRequestProperty("Accept", "application/json")
}
try {
when (val code = conn.responseCode) {
in 200..299 -> {
val body = conn.inputStream.bufferedReader().use { it.readText() }
FetchResult.Success(parsePolice(body))
}
401, 403 -> {
Log.w(TAG, "Waze proxy rejected token ($code)")
FetchResult.Failed("Proxy rejected token (HTTP $code)")
}
429 -> {
Log.w(TAG, "Waze feed rate-limited (429)")
FetchResult.Failed("Rate limited (HTTP 429)")
}
else -> {
Log.w(TAG, "Waze feed returned $code")
FetchResult.Failed("HTTP $code")
}
}
} catch (e: Exception) {
Log.w(TAG, "Waze feed 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 alerts = extractAlerts(JSONObject(body)) ?: return emptyList()
val out = ArrayList<Alert>(alerts.length())
for (i in 0 until alerts.length()) {
val a = alerts.optJSONObject(i) ?: continue
// We request alert_types=POLICE server-side; this is a belt-and-
// suspenders guard in case the envelope also carries other types.
val type = firstString(a, "type", "alertType")
if (type != null && !type.equals("POLICE", ignoreCase = true)) continue
val loc = a.optJSONObject("location")
val lat = doubleOrNull(a, "latitude") ?: loc?.let { doubleOrNull(it, "y") } ?: continue
val lon = doubleOrNull(a, "longitude") ?: loc?.let { doubleOrNull(it, "x") } ?: continue
if (lat.isNaN() || lon.isNaN()) continue
val uuid = firstString(a, "alert_id", "uuid", "id")
?: "$lat,$lon,${firstLong(a, "publish_datetime_utc") ?: i}"
out.add(
Alert(
uuid = uuid,
subtype = firstString(a, "subtype"),
lat = lat,
lon = lon,
pubMillis = parseTimeMillis(a),
confidence = firstInt(a, "alert_confidence", "confidence") ?: 0,
reliability = firstInt(a, "alert_reliability", "reliability") ?: 0
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Waze feed response: ${e.message}")
emptyList()
}
}
/** Locate the alerts array across the known envelope variants. */
private fun extractAlerts(root: JSONObject): JSONArray? {
root.optJSONArray("alerts")?.let { return it }
when (val data = root.opt("data")) {
is JSONArray -> return data
is JSONObject -> data.optJSONArray("alerts")?.let { return it }
}
root.optJSONObject("result")?.optJSONArray("alerts")?.let { return it }
return null
}
/** publish_datetime_utc is an ISO-8601 string; fall back to epoch-millis
* fields, then to "now" so a missing timestamp never drops an alert. */
private fun parseTimeMillis(a: JSONObject): Long {
val iso = firstString(a, "publish_datetime_utc", "pub_datetime_utc")
if (iso != null) {
try {
return Instant.parse(iso).toEpochMilli()
} catch (_: Exception) {
try {
return LocalDateTime.parse(iso.replace(' ', 'T'))
.toInstant(ZoneOffset.UTC).toEpochMilli()
} catch (_: Exception) { /* fall through */ }
}
}
return firstLong(a, "pubMillis", "pub_millis") ?: System.currentTimeMillis()
}
private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8")
private fun firstString(o: JSONObject, vararg keys: String): String? {
for (k in keys) {
val v = o.optString(k, "")
if (v.isNotBlank() && v != "null") return v
}
return null
}
private fun doubleOrNull(o: JSONObject, key: String): Double? {
if (!o.has(key) || o.isNull(key)) return null
val v = o.optDouble(key, Double.NaN)
return if (v.isNaN()) null else v
}
private fun firstInt(o: JSONObject, vararg keys: String): Int? {
for (k in keys) if (o.has(k) && !o.isNull(k)) return o.optInt(k, 0)
return null
}
private fun firstLong(o: JSONObject, vararg keys: String): Long? {
for (k in keys) if (o.has(k) && !o.isNull(k)) return o.optLong(k, 0L)
return null
}
}
@@ -0,0 +1,155 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* Polls the OpenWeb Ninja Waze feed for live POLICE alerts around the current
* location, then submits any inside [proximityMeters] and younger than
* [MAX_AGE_MS].
*
* Poll cadence is deliberately slow — the feed is a metered paid API and lags
* live Waze by ~20 min anyway, so a 4-min poll loses nothing and keeps request
* volume (and pay-as-you-go cost, ~$0.005/req) low at ~15 req/active-hour. Kept
* just under the DetectionStore's 5-min retention so a persistent alert (a
* standing checkpoint) is re-submitted before it can expire and flicker out. If
* no API key is configured the loop records the source as unreachable (with a
* clear reason) and skips the network call rather than hammering a 401.
*
* The last fetched alert set is cached so [refresh] can re-evaluate against a
* moved proximity slider without a network refetch (mirrors CitizenScanner).
*/
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 = 240_000L
// The hosted feed only lists still-active alerts but lags live Waze, so
// real police sightings routinely arrive already 20-30 min old. A 10-min
// cutoff (fine for the old direct-live feed) would drop nearly all of
// them; 45 min matches what the feed actually serves as "current."
private const val MAX_AGE_MS = 45L * 60L * 1000L
}
private var job: Job? = null
private var lastAlerts: List<WazeClient.Alert> = emptyList()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
// Wait for the first fix so the opening poll fires as soon as we have
// a location instead of after a full interval.
locationProvider.location.first { it != null }
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) pollOnce(fix)
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms, configured=${client.isConfigured})")
return true
}
fun stop() {
job?.cancel()
job = null
lastAlerts = emptyList()
Log.i(TAG, "WazeScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
if (!client.isConfigured) {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Proxy token not set — add it in Settings"
)
return
}
when (val result = client.fetchPoliceNear(fix.latitude, fix.longitude, proximityMeters())) {
is WazeClient.FetchResult.Failed -> {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Waze feed unreachable: ${result.reason}"
)
}
is WazeClient.FetchResult.Success -> {
SourceHealth.record(DetectionSource.WAZE, ok = true)
lastAlerts = result.alerts
// Deliberately no clearSource() here: re-submitting refreshes
// still-present alerts by key (dedup) and lets vanished ones age
// out via the store's 5-min TTL. Clearing every poll would briefly
// drop the tier and re-raise it, double-firing the escalation
// vibration each cycle. (Mirrors CitizenScanner.)
emitProximityEvents(fix, result.alerts)
}
}
}
/**
* Re-evaluate the last fetched alert set against the current proximity + age
* thresholds and latest fix, without a network refetch. Used when the user
* moves the Waze proximity slider.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.WAZE)
emitProximityEvents(fix, lastAlerts)
}
private fun emitProximityEvents(fix: Location, alerts: List<WazeClient.Alert>) {
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 scored = ConfidenceEngine.scoreWaze(
ConfidenceEngine.WazeObservation(
uuid = a.uuid,
distanceMeters = dist,
ageMs = age,
confidence = a.confidence,
reliability = a.reliability,
subtype = a.subtype
)
)
store.submit(
DetectionEvent(
source = DetectionSource.WAZE,
key = "waze:${a.uuid}",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = a.lat,
lon = a.lon
)
)
}
}
}
@@ -39,6 +39,8 @@ import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
import org.soulstone.overwatch.scan.WazeClient
import org.soulstone.overwatch.scan.WazeScanner
import org.soulstone.overwatch.scan.WifiScanner
/**
@@ -105,6 +107,7 @@ class DetectionService : LifecycleService() {
private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner
private lateinit var citizenScanner: CitizenScanner
private lateinit var wazeScanner: WazeScanner
private lateinit var overlayManager: OverlayManager
private var pruneJob: Job? = null
private var observerJob: Job? = null
@@ -112,11 +115,13 @@ class DetectionService : LifecycleService() {
private var locationJob: Job? = null
private var deflockProxJob: Job? = null
private var citizenProxJob: Job? = null
private var wazeProxJob: Job? = null
private var overlayJob: Job? = null
private var bleStarted = false
private var wifiStarted = false
private var deflockStarted = false
private var citizenStarted = false
private var wazeStarted = false
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
@@ -134,6 +139,11 @@ class DetectionService : LifecycleService() {
store, locationProvider,
proximityMeters = { settings.citizenProximityM.value.toFloat() }
)
wazeScanner = WazeScanner(
store, locationProvider,
client = WazeClient(appToken = { settings.wazeProxyToken.value }),
proximityMeters = { settings.wazeProximityM.value.toFloat() }
)
overlayManager = OverlayManager(
context = this,
// User dragged the bubble onto the X — flip the persisted toggle
@@ -172,7 +182,8 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value ||
settings.wazeEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
if (!locOk) {
@@ -184,10 +195,13 @@ class DetectionService : LifecycleService() {
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true
}
}
}
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted || wazeStarted
if (!anyStarted) {
Log.w(TAG, "No scanner started — endScanning + stopSelf")
endScanning()
@@ -257,6 +271,12 @@ class DetectionService : LifecycleService() {
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
}
}
wazeProxJob?.cancel()
if (wazeStarted) {
wazeProxJob = lifecycleScope.launch {
settings.wazeProximityM.drop(1).collect { wazeScanner.refresh() }
}
}
// Floating threat-circle overlay — observe the toggle and show/hide
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
@@ -278,6 +298,7 @@ class DetectionService : LifecycleService() {
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
locationProvider.stop()
store.clear()
SourceHealth.reset()
@@ -287,6 +308,7 @@ class DetectionService : LifecycleService() {
locationJob?.cancel(); locationJob = null
deflockProxJob?.cancel(); deflockProxJob = null
citizenProxJob?.cancel(); citizenProxJob = null
wazeProxJob?.cancel(); wazeProxJob = null
overlayJob?.cancel(); overlayJob = null
overlayManager.hide()
_mapPoints.value = emptyList()
@@ -381,7 +403,7 @@ class DetectionService : LifecycleService() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view)
.setSmallIcon(R.drawable.ic_stat_radar)
.setOngoing(true)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
@@ -9,6 +9,7 @@ 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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -135,6 +136,7 @@ fun MainScreen(
animating = running,
userLocation = userLocation,
mapPoints = mapPoints,
events = events,
mapRadiusMeters = mapRadiusMeters,
onTap = { showSheet = true }
)
@@ -150,9 +152,15 @@ fun MainScreen(
Spacer(Modifier.height(24.dp))
StatusText(running = running, threat = threat, score = score, events = events)
}
Spacer(Modifier.height(24.dp))
// Push the primary action button to the bottom of the screen.
Spacer(Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onStartStop,
enabled = canStart,
@@ -200,6 +208,8 @@ fun MainScreen(
}
}
}
Spacer(Modifier.height(24.dp))
}
if (showSheet) {
@@ -230,6 +240,7 @@ private fun ThreatMapCircle(
animating: Boolean,
userLocation: Location?,
mapPoints: List<DeflockClient.AlprPoint>,
events: List<DetectionEvent>,
mapRadiusMeters: Float,
onTap: () -> Unit
) {
@@ -240,6 +251,9 @@ private fun ThreatMapCircle(
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
// Steady threat-tier ring around the circle: tier color while scanning,
// muted gray when idle. Gives the current level at a glance even over the map.
val ringColor = if (animating) activeColor else idleColor
val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat(
@@ -254,7 +268,9 @@ private fun ThreatMapCircle(
Box(
modifier = Modifier
.size(220.dp)
.size(300.dp)
.border(width = 6.dp, color = ringColor, shape = CircleShape)
.padding(6.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
@@ -300,8 +316,10 @@ private fun ThreatMapCircle(
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 userMark = remember(ctx) { crosshairDrawable(ctx.resources, 46, MARK_USER_WHITE) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
val wazeDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_WAZE_BLUE) }
val citizenDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_CITIZEN_PURPLE) }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { c ->
@@ -317,7 +335,8 @@ private fun ThreatMapCircle(
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear()
// ALPR dots first, user dot last so the user draws on top.
// Source dots first (Flock red, Waze blue, Citizen purple),
// user position last so the crosshair always draws on top.
for (p in mapPoints) {
map.overlays.add(
Marker(map).apply {
@@ -329,11 +348,29 @@ private fun ThreatMapCircle(
}
)
}
for (e in events) {
val lat = e.lat ?: continue
val lon = e.lon ?: continue
val dot = when (e.source) {
DetectionSource.WAZE -> wazeDot
DetectionSource.CITIZEN -> citizenDot
else -> null
} ?: continue
map.overlays.add(
Marker(map).apply {
position = GeoPoint(lat, lon)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = dot
title = e.label
setInfoWindow(null)
}
)
}
map.overlays.add(
Marker(map).apply {
position = GeoPoint(fix.latitude, fix.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = userDot
icon = userMark
setInfoWindow(null)
}
)
@@ -25,5 +25,44 @@ internal fun dotDrawable(
return BitmapDrawable(resources, bitmap)
}
internal const val DOT_USER_BLUE = 0xFF2196F3.toInt()
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt()
/** Builds a ⌖-style position mark (register / gun-sight): a stroked circle with
* a cross through it, painted over a dark halo so it reads on any map tile. Used
* for the user's own location so it stays distinct from the colored source dots. */
internal fun crosshairDrawable(
resources: Resources,
sizePx: Int,
markColor: Int
): BitmapDrawable {
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val c = sizePx / 2f
val r = sizePx * 0.30f
val pad = sizePx * 0.07f
val stroke = sizePx * 0.10f
val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
color = 0xB3000000.toInt()
strokeWidth = stroke + 3f
strokeCap = Paint.Cap.ROUND
}
val mark = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
color = markColor
strokeWidth = stroke
strokeCap = Paint.Cap.ROUND
}
// Halo pass then color pass so the mark stays legible on light or dark tiles.
for (p in listOf(halo, mark)) {
canvas.drawCircle(c, c, r, p)
canvas.drawLine(pad, c, sizePx - pad, c, p) // horizontal
canvas.drawLine(c, pad, c, sizePx - pad, p) // vertical
}
return BitmapDrawable(resources, bitmap)
}
// Per-source marker colors so geodata reads at a glance.
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt() // Flock / DeFlock ALPR cameras
internal const val DOT_WAZE_BLUE = 0xFF2196F3.toInt() // Waze police reports
internal const val DOT_CITIZEN_PURPLE = 0xFF9C27B0.toInt() // Citizen incidents
internal const val MARK_USER_WHITE = 0xFFFFFFFF.toInt() // the user's own position (⌖)
@@ -29,6 +29,7 @@ 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.DetectionSource
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.service.DetectionService
import org.soulstone.overwatch.ui.theme.ThreatColors
@@ -54,9 +55,11 @@ fun OverlayBubble() {
val threat by DetectionService.store.threatLevel.collectAsState()
val userLocation by DetectionService.location.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState()
val events by DetectionService.store.events.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val radius = max(deflockProx, citizenProx).toFloat()
val wazeProx by settings.wazeProximityM.collectAsState()
val radius = max(max(deflockProx, citizenProx), wazeProx).toFloat()
val activeColor = when (threat) {
ThreatLevel.GREEN -> ThreatColors.Green
@@ -76,8 +79,10 @@ fun OverlayBubble() {
label = "overlay-pulse"
)
val userDot = remember(ctx) { dotDrawable(ctx.resources, 30, DOT_USER_BLUE) }
val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 34, MARK_USER_WHITE) }
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) }
val wazeDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_WAZE_BLUE) }
val citizenDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_CITIZEN_PURPLE) }
Box(
modifier = Modifier
@@ -128,11 +133,28 @@ fun OverlayBubble() {
}
)
}
for (e in events) {
val lat = e.lat ?: continue
val lon = e.lon ?: continue
val dot = when (e.source) {
DetectionSource.WAZE -> wazeDot
DetectionSource.CITIZEN -> citizenDot
else -> null
} ?: continue
map.overlays.add(
Marker(map).apply {
position = GeoPoint(lat, lon)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = dot
setInfoWindow(null)
}
)
}
map.overlays.add(
Marker(map).apply {
position = GeoPoint(fix.latitude, fix.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = userDot
icon = userMark
setInfoWindow(null)
}
)
@@ -17,6 +17,8 @@ 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.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -26,14 +28,19 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
@@ -53,9 +60,12 @@ fun SettingsScreen(
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val mic by settings.micEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val wazeToken by settings.wazeProxyToken.collectAsState()
val theme by settings.themeMode.collectAsState()
val vibrate by settings.vibrateOnAlert.collectAsState()
val overlay by settings.overlayEnabled.collectAsState()
@@ -89,7 +99,8 @@ fun SettingsScreen(
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo, glasses", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp))
if (isRunning) {
Button(
@@ -131,6 +142,24 @@ fun SettingsScreen(
steps = 48,
onCommit = { settings.setCitizenProximityM(it) }
)
SliderRow(
label = "Waze alert distance",
persistedValue = wazeProx,
range = 100f..5000f,
steps = 48,
onCommit = { settings.setWazeProximityM(it) }
)
Spacer(Modifier.height(16.dp))
SectionLabel("Waze police feed")
Text(
"Needs a proxy token (api.blackflagintel.com). Stored encrypted on-device — never in the app package.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 4.dp)
)
TokenField(currentToken = wazeToken, onSave = { settings.setWazeProxyToken(it) })
Spacer(Modifier.height(16.dp))
SectionLabel("Alerts")
@@ -256,6 +285,63 @@ private fun SliderRow(
}
}
/**
* Masked entry for the Waze proxy token. Commits on Save (persisted encrypted
* via Settings/SecureStore), with a show/hide toggle and a set/unset status line.
*/
@Composable
private fun TokenField(currentToken: String, onSave: (String) -> Unit) {
var text by remember(currentToken) { mutableStateOf(currentToken) }
var visible by remember { mutableStateOf(false) }
val isSet = currentToken.isNotBlank()
Column(modifier = Modifier.padding(vertical = 4.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
label = { Text("Proxy token", fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
visualTransformation =
if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(
if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (visible) "Hide token" else "Show token"
)
}
},
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace, fontSize = 13.sp
),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isSet) "Token set — Waze feed enabled" else "No token — Waze feed off",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f, fill = true)
)
Button(
onClick = { onSave(text) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp)
) {
Text("Save", fontSize = 13.sp, fontFamily = FontFamily.Monospace)
}
}
}
}
@Composable
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
@@ -1,13 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Radar: range rings + crosshair + sweep beam + a red contact blip. Drawn on
top of the @color/bg_dark adaptive background. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- lit screen glow -->
<path android:pathData="M54,54 m-36,0 a36,36 0 1,0 72,0 a36,36 0 1,0 -72,0">
<aapt:attr name="android:fillColor">
<gradient android:type="radial"
android:centerX="54" android:centerY="54" android:gradientRadius="36">
<item android:offset="0.0" android:color="#123A24" />
<item android:offset="1.0" android:color="#000B0E12" />
</gradient>
</aapt:attr>
</path>
<!-- sweep afterglow wedge -->
<path
android:pathData="M54,54 L54,19 A35,35 0 0,1 83.3,35 Z"
android:fillColor="#1FAA59"
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
android:fillAlpha="0.32" />
<!-- range rings -->
<path
android:fillColor="#F4F6FA"
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
android:pathData="M54,54 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0"
android:strokeColor="#1FAA59"
android:strokeWidth="2" />
<path
android:pathData="M54,54 m-23,0 a23,23 0 1,0 46,0 a23,23 0 1,0 -46,0"
android:strokeColor="#1FAA59"
android:strokeWidth="2"
android:strokeAlpha="0.85" />
<path
android:pathData="M54,54 m-12,0 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0"
android:strokeColor="#1FAA59"
android:strokeWidth="2"
android:strokeAlpha="0.85" />
<!-- crosshair -->
<path
android:pathData="M20,54 L88,54"
android:strokeColor="#1FAA59"
android:strokeWidth="1.4"
android:strokeAlpha="0.55" />
<path
android:pathData="M54,20 L54,88"
android:strokeColor="#1FAA59"
android:strokeWidth="1.4"
android:strokeAlpha="0.55" />
<!-- leading sweep beam -->
<path
android:pathData="M54,54 L83.3,35"
android:strokeColor="#33E38C"
android:strokeWidth="2.4"
android:strokeLineCap="round" />
<!-- contact blip -->
<path
android:pathData="M72,40 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0"
android:fillColor="#D7263D"
android:fillAlpha="0.22" />
<path
android:pathData="M72,40 m-3.3,0 a3.3,3.3 0 1,0 6.6,0 a3.3,3.3 0 1,0 -6.6,0"
android:fillColor="#D7263D" />
<!-- hub -->
<path
android:pathData="M54,54 m-2.4,0 a2.4,2.4 0 1,0 4.8,0 a2.4,2.4 0 1,0 -4.8,0"
android:fillColor="#33E38C" />
</vector>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Single-colour radar silhouette for Android 13+ themed icons (system tints it). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M54,54 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.2" />
<path
android:pathData="M54,54 m-23,0 a23,23 0 1,0 46,0 a23,23 0 1,0 -46,0"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.2" />
<path
android:pathData="M54,54 m-12,0 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.2" />
<path
android:pathData="M20,54 L88,54"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.8" />
<path
android:pathData="M54,20 L54,88"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.8" />
<path
android:pathData="M54,54 L83.3,35"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.6"
android:strokeLineCap="round" />
<path
android:pathData="M72,40 m-3.6,0 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0"
android:fillColor="#FFFFFF" />
<path
android:pathData="M54,54 m-2.6,0 a2.6,2.6 0 1,0 5.2,0 a2.6,2.6 0 1,0 -5.2,0"
android:fillColor="#FFFFFF" />
</vector>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar / notification small icon. White silhouette; the system tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,12 m-10,0 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.4" />
<path
android:pathData="M12,12 m-5.2,0 a5.2,5.2 0 1,0 10.4,0 a5.2,5.2 0 1,0 -10.4,0"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.4" />
<path
android:pathData="M2.5,12 L21.5,12"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.1" />
<path
android:pathData="M12,2.5 L12,21.5"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.1" />
<path
android:pathData="M12,12 L20,7.5"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
<path
android:pathData="M16.4,8.9 m-1.8,0 a1.8,1.8 0 1,0 3.6,0 a1.8,1.8 0 1,0 -3.6,0"
android:fillColor="#FFFFFF" />
</vector>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
BIN
View File
Binary file not shown.
View File
+46
View File
@@ -0,0 +1,46 @@
/* OVERWATCH landing — count-up stats + scroll reveal. No network, no deps. */
(function () {
'use strict';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* count-up numbers when the stats row scrolls into view */
function countUp(el) {
const target = parseInt(el.dataset.target, 10);
if (isNaN(target)) return;
if (!target || reduceMotion) { el.textContent = String(target); return; }
const dur = 1100, start = performance.now();
(function tick(now) {
const p = Math.min(1, (now - start) / dur);
el.textContent = String(Math.round(target * (1 - Math.pow(1 - p, 3))));
if (p < 1) requestAnimationFrame(tick);
})(performance.now());
}
const statsRow = document.getElementById('stats-row');
if (statsRow) {
const nums = statsRow.querySelectorAll('.num[data-target]');
if ('IntersectionObserver' in window && !reduceMotion) {
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (e.isIntersecting) { nums.forEach(countUp); obs.disconnect(); }
});
}, { threshold: 0.4 });
io.observe(statsRow);
} else {
nums.forEach((el) => { el.textContent = el.dataset.target; });
}
}
/* scroll-triggered reveal */
const reveals = document.querySelectorAll('.reveal');
if (!('IntersectionObserver' in window) || reduceMotion) {
reveals.forEach((el) => el.classList.add('in'));
} else {
const ro = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add('in'); obs.unobserve(e.target); }
});
}, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });
reveals.forEach((el) => ro.observe(el));
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+506
View File
@@ -0,0 +1,506 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OVERWATCH — passive surveillance detection for Android</title>
<meta name="description" content="A native Android app that passively flags nearby Flock Safety ALPRs, Axon body cameras, and police presence. Fuses BLE/WiFi radio scans, the DeFlock ALPR map, and Citizen/Waze feeds into a green→red threat tier. Listens only — never transmits.">
<meta property="og:title" content="OVERWATCH — passive surveillance detection">
<meta property="og:description" content="Android app that flags nearby Flock ALPRs, Axon body cams, and police by fusing BLE/WiFi + the DeFlock map + Citizen/Waze. Passive only — it never transmits.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://overwatch.netslum.io/">
<meta property="og:image" content="https://overwatch.netslum.io/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://overwatch.netslum.io/og.png">
<meta name="theme-color" content="#07070d">
<link rel="icon" type="image/svg+xml" href="logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=2">
</head>
<body>
<div class="bg-mesh" aria-hidden="true">
<div class="mesh-blob mesh-blob-1"></div>
<div class="mesh-blob mesh-blob-2"></div>
<div class="mesh-blob mesh-blob-3"></div>
</div>
<nav class="nav">
<div class="container nav-inner">
<a class="nav-brand" href="#">
<img class="nav-logo" src="logo.svg" width="30" height="30" alt="" aria-hidden="true">
OVERWATCH
</a>
<div class="nav-links">
<a href="#detect">How it works</a>
<a href="#sources">Sources</a>
<a href="#tiers">Tiers</a>
<a href="#screens">Screens</a>
<a href="#download">Download</a>
<a class="nav-github" href="https://github.com/KaraZajac/OVERWATCH" aria-label="GitHub">
<svg height="18" viewBox="0 0 16 16" width="18" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
</div>
</div>
</nav>
<!-- ──────────────── HERO ──────────────── -->
<header class="hero">
<div class="container hero-inner">
<div class="hero-eyebrow">
<span class="dot dot-pulse"></span>
v0.5.3 · Android · passive
</div>
<h1 class="hero-title">
<span class="display-wordmark">OVERWATCH</span>
</h1>
<p class="hero-tag">
A native Android app that <strong>passively detects surveillance around you</strong>.
Open it, hit <strong>START</strong>, and a ring turns green → yellow →
orange → red as the engine gets more sure there's a Flock Safety ALPR,
an Axon body camera, or active police nearby.
<span class="hero-tag-pop">It only listens. It never transmits, probes, or jams.</span>
</p>
<div class="phone-hero">
<img class="phone" src="img/overwatch-main.png" width="1096" height="2560"
alt="OVERWATCH main screen: a live map inside a green &quot;all clear&quot; threat ring, centered on the user with a crosshair, and a STOP button." loading="eager">
</div>
<div class="stats-row" id="stats-row">
<div class="stat-chip"><span class="num" data-target="6">0</span><span>fused sources</span></div>
<div class="stat-chip stat-vfy"><span class="num" data-target="4">0</span><span>threat tiers</span></div>
<div class="stat-chip"><span class="num" data-target="0">0</span><span>packets sent</span></div>
<div class="stat-chip"><span class="num" data-target="17">0</span><span>releases</span></div>
</div>
<div class="cta-row">
<a class="btn btn-primary" href="https://github.com/KaraZajac/OVERWATCH/releases/latest">
↓ Download APK
</a>
<a class="btn" href="#detect">How it works</a>
<a class="btn btn-ghost" href="https://github.com/KaraZajac/OVERWATCH">
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
Source on GitHub
</a>
</div>
<p class="hero-warn">Passive defense only. Debug-signed APK — sideload; no Play Store. <a href="https://github.com/KaraZajac/OVERWATCH">Read the README.</a></p>
</div>
</header>
<!-- ──────────────── TRUST STRIP ──────────────── -->
<section class="trust-strip">
<div class="container">
<div class="trust-row">
<span class="trust-label">Fuses signal from</span>
<ul class="trust-items">
<li>Bluetooth-LE + WiFi radio</li>
<li>DeFlock ALPR map</li>
<li>Citizen incidents</li>
<li>Waze police alerts</li>
<li>OpenStreetMap · Overpass</li>
</ul>
</div>
</div>
</section>
<!-- ──────────────── HOW IT WORKS ──────────────── -->
<section id="detect" class="section section-feature reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">how it works</span>
<h2>One tap. A live threat map.</h2>
<p class="lead">
While scanning, the circle becomes an <strong>OpenStreetMap centered on
you</strong>, wrapped in a threat-color ring for the current tier and
marked with a ⌖ crosshair. Every detection is a dot, color-coded by
source — so each mark is self-explanatory. Lock the screen and the
foreground notification keeps the tier live; the phone vibrates only
when the threat <em>escalates</em>.
</p>
</div>
<div class="feature-split">
<div class="phone-frame">
<img class="phone" src="img/overwatch-sources.png" width="856" height="2000"
alt="Detection-sources drill-down: BLE, WiFi, DeFlock, Citizen, Waze and Commercial rows, each with a color tier dot and the observations that fired it." loading="lazy">
</div>
<div class="explain-annotations">
<div class="annotation">
<span class="anno-num">1</span>
<div>
<strong>Threat-color ring</strong>
<p>The map is wrapped in the current tier's color — green, yellow,
orange, or red — so you read your situation at a glance without
opening anything.</p>
</div>
</div>
<div class="annotation">
<span class="anno-num">2</span>
<div>
<strong>Source-color dots</strong>
<p><span class="legend-dot red"></span> Flock / DeFlock cameras red ·
<span class="legend-dot blue"></span> Waze police blue ·
<span class="legend-dot purple"></span> Citizen incidents purple.
Tap any row for coordinates + a Maps deep-link.</p>
</div>
</div>
<div class="annotation">
<span class="anno-num">3</span>
<div>
<strong>Foreground notification</strong>
<p>Rebuilt on every tier change — <code>OVERWATCH • RED</code> with
the top detection's score. Priority bumps to HIGH on RED so the
system surfaces it as a heads-up, screen locked or not.</p>
</div>
</div>
<div class="annotation">
<span class="anno-num">4</span>
<div>
<strong>Escalation-only haptics</strong>
<p>A short pulse for YELLOW, double for ORANGE, escalating triple
for RED — but only on <em>upward</em> transitions, so it never
nags you while a threat lingers.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ──────────────── DETECTION SOURCES ──────────────── -->
<section id="sources" class="section section-bento reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">detection sources</span>
<h2>Six sensors, one score.</h2>
<p class="lead">Every observation is scored 0100 by the confidence
engine; the on-screen tier is the <strong>max live score across all
sources</strong>. Cross-source corroboration — a BLE hit <em>and</em> a
DeFlock map match in the same spot — naturally pushes the tier higher
than either alone.</p>
</div>
<div class="bento">
<article class="bento-card bento-lg">
<div class="bento-icon">📶</div>
<h3>BLE — Bluetooth-LE</h3>
<p>
Vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id
<code>0x09C8</code>, "TN" serial pattern), Raven service UUIDs, and
device-name patterns. Iterates <em>every</em> manufacturer-data entry
per advert — not just the first — so it doesn't miss a buried match.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">📡</div>
<h3>WiFi</h3>
<p>
BSSID OUI prefixes for Flock infrastructure (31-prefix superset) and
<code>Flock-XXXX</code> SSID patterns, polled every 35 s — just under
the Android 11+ scan throttle.
</p>
</article>
<article class="bento-card bento-cam">
<div class="bento-icon">🎥</div>
<h3>DeFlock — ALPR map</h3>
<p>
Crowdsourced ALPR camera locations within range (200 m default) via
the Overpass API (<code>overpass.deflock.org</code> → fallback
<code>overpass-api.de</code>), with a 24 h on-disk cache and failure
backoff. Refetches when you move &gt; 1.5 km.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🚨</div>
<h3>Citizen</h3>
<p>
Real-time public-safety incidents — police-relevant only,
fire/medical filtered out — within range and under 30 min old,
polled every 60 s from the Citizen trending feed.
</p>
</article>
<article class="bento-card bento-lg">
<div class="bento-icon">🚓</div>
<h3>Waze — police alerts</h3>
<p>
Live user-reported <code>POLICE</code> alerts (default 500 m,
~45 min freshness). Waze reCAPTCHA-gated its map endpoint, so
OVERWATCH reads a key-protected feed through a Caddy proxy at
<code>api.blackflagintel.com</code> that injects the key
server-side — the paid key <strong>never ships in the APK</strong>.
The app authenticates with a scoped, revocable <code>X-App-Token</code>
stored encrypted in the Android Keystore.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🏠</div>
<h3>Commercial</h3>
<p>
Nearby consumer smart-home gear (Nest, Ring, Echo, hidden cams) and
<strong>smart glasses (Meta, Snap, Vuzix)</strong> as a secondary
situational signal, riding the BLE + WiFi scans. Score-capped at
ORANGE so a cluster of doorbells never reads as ALPR-grade certainty.
</p>
</article>
</div>
</div>
</section>
<!-- ──────────────── THREAT TIERS ──────────────── -->
<section id="tiers" class="section reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">confidence</span>
<h2>Four tiers. Highest live score wins.</h2>
</div>
<div class="tiers">
<div class="tier tier-green">
<div class="tier-range">&lt; 40</div>
<div class="tier-name">GREEN</div>
<p>Nothing credible. Idle shows a muted gray ring so "all clear" reads differently from "not scanning."</p>
</div>
<div class="tier tier-yellow">
<div class="tier-range">40 69</div>
<div class="tier-name">YELLOW</div>
<p>A single weak indicator. Short vibration pulse on the way up.</p>
</div>
<div class="tier tier-orange">
<div class="tier-range">70 84</div>
<div class="tier-name">ORANGE</div>
<p>High confidence — usually a hard radio hit or a close map match. Double pulse.</p>
</div>
<div class="tier tier-red">
<div class="tier-range">85 +</div>
<div class="tier-name">RED</div>
<p>Certain. Notification jumps to HIGH priority; escalating triple pulse.</p>
</div>
</div>
</div>
</section>
<!-- ──────────────── SCREENSHOTS ──────────────── -->
<section id="screens" class="section reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">screenshots</span>
<h2>The whole app, top to bottom.</h2>
<p class="lead">No account, no onboarding, no cloud dashboard to
configure — open it, hit START, and drill into any source. Three taps
deep is as complex as it gets.</p>
</div>
<div class="shots">
<figure class="shot">
<img class="phone" src="img/overwatch-main.png" width="1096" height="2560"
alt="OVERWATCH main screen: a live map inside a green all-clear threat ring, centered on you with a crosshair, and a STOP button." loading="lazy">
<figcaption><strong>The threat ring</strong>A live map centered on you, wrapped in the current tier's color. Green reads &ldquo;all clear&rdquo; at a glance.</figcaption>
</figure>
<figure class="shot">
<img class="phone" src="img/overwatch-sources.png" width="856" height="2000"
alt="Detection-sources drill-down: BLE, WiFi, DeFlock, Citizen, Waze and Commercial rows, each with a color tier dot and the observations that fired it." loading="lazy">
<figcaption><strong>Source drill-down</strong>Tap the ring for every source, its live tier, and the exact observation that fired it.</figcaption>
</figure>
<figure class="shot">
<img class="phone" src="img/overwatch-settings.png" width="856" height="2000"
alt="Settings screen: per-source toggles, proximity distance sliders, and the encrypted Waze proxy token field." loading="lazy">
<figcaption><strong>Settings</strong>Toggle each source, tune proximity distances, and paste an encrypted Waze proxy token.</figcaption>
</figure>
</div>
</div>
</section>
<!-- ──────────────── CAPABILITIES ──────────────── -->
<section class="section section-bento reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">built for the street</span>
<h2>Runs in your pocket, not the cloud.</h2>
</div>
<div class="bento">
<article class="bento-card bento-lg">
<div class="bento-icon">🔇</div>
<h3>Passive by design</h3>
<p>
OVERWATCH only listens — it never transmits, probes, jams, or
interferes with any device or network. The Axon advertise/fuzz code
from one of the reference projects is <strong>intentionally
excluded</strong>. It reads the airwaves and public feeds; that's it.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🫧</div>
<h3>Floating overlay</h3>
<p>
The same threat-ring map renders in a small draggable bubble over
other apps (Settings → Display over other apps), so it stays with
you while you use your phone.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🔐</div>
<h3>Keys stay off-device</h3>
<p>
The paid Waze feed key lives server-side behind the proxy; the app
holds only a scoped, revocable token, encrypted via the Android
Keystore. No secrets in the APK.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🩺</div>
<h3>Honest per-source health</h3>
<p>
The drill-down marks a source <code>Source unreachable</code> when
its scanner couldn't reach its feed — a silent empty result and a
real failure look different.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🗺</div>
<h3>Your position, not your data</h3>
<p>
Location drives the proximity checks and the map on-device. No
account, no telemetry, no analytics — the scans and feeds run and
the results stay on your phone.
</p>
</article>
</div>
</div>
</section>
<!-- ──────────────── CALLOUT ──────────────── -->
<section class="section section-callout reveal">
<div class="container">
<div class="callout">
<div class="callout-mark">🛡</div>
<div>
<h3>Situational awareness, not interference</h3>
<p>
OVERWATCH is a defensive, receive-only tool for knowing what's
watching <em>you</em> — Flock ALPRs, Axon body cameras, and police
presence — in the space you're already in. It does not touch, spoof,
or disrupt anything. Know your local laws; use it responsibly.
</p>
</div>
</div>
</div>
</section>
<!-- ──────────────── DOWNLOAD ──────────────── -->
<section id="download" class="section section-timeline reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">get it</span>
<h2>Sideload in three steps.</h2>
</div>
<div class="feature-split">
<div class="phone-frame">
<img class="phone" src="img/overwatch-settings.png" width="856" height="2000"
alt="Settings screen: per-source toggles (BLE, WiFi, DeFlock, Citizen, Waze, Commercial), proximity distance sliders, and the encrypted Waze proxy token field." loading="lazy">
</div>
<div class="timeline dl-steps">
<div class="tl-col tl-shipped">
<div class="tl-tag">1 · download</div>
<ul>
<li>Grab the latest debug-signed APK from
<a href="https://github.com/KaraZajac/OVERWATCH/releases/latest">Releases</a>
(currently <strong>v0.5.3</strong>).</li>
</ul>
</div>
<div class="tl-col tl-active">
<div class="tl-tag">2 · install</div>
<ul>
<li>Sideload it — allow "install unknown apps" for your browser or files app, then open the APK.</li>
</ul>
</div>
<div class="tl-col tl-next">
<div class="tl-tag">3 · grant + start</div>
<ul>
<li>Grant location + nearby-devices + notifications, toggle the sources you want, hit <strong>START</strong>.</li>
<li>Optional: paste a Waze proxy token in Settings for police-report coverage.</li>
</ul>
</div>
</div>
</div>
<p class="tl-foot">
Build from source, file issues, or read the internals:
<a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
</p>
</div>
</section>
<!-- ──────────────── FOOTER ──────────────── -->
<footer class="footer">
<div class="container footer-inner">
<div class="footer-col">
<div class="footer-brand">
<img class="nav-logo" src="logo.svg" width="26" height="26" alt="" aria-hidden="true">
OVERWATCH
</div>
<p class="footer-tag">
Passive Android surveillance-detection. Listens only — never
transmits.
</p>
</div>
<div class="footer-col">
<h4>Project</h4>
<ul>
<li><a href="https://github.com/KaraZajac/OVERWATCH">Source</a></li>
<li><a href="https://github.com/KaraZajac/OVERWATCH/releases">Releases</a></li>
<li><a href="https://github.com/KaraZajac/OVERWATCH/releases/latest">Latest APK</a></li>
</ul>
</div>
<div class="footer-col">
<h4>Signal from</h4>
<ul>
<li><a href="https://deflock.me">DeFlock ALPR map</a></li>
<li><a href="https://citizen.com">Citizen</a></li>
<li><a href="https://www.waze.com">Waze</a></li>
<li><a href="https://www.openstreetmap.org">OpenStreetMap</a></li>
</ul>
</div>
<div class="footer-col">
<h4>More</h4>
<ul>
<li><a href="https://netslum.io/notes/overwatch-passive-counter-surveillance/">The writeup</a></li>
<li><a href="https://netslum.io">netslum.io</a></li>
</ul>
</div>
</div>
<div class="container footer-bottom">
<p>
OVERWATCH is a receive-only situational-awareness tool. It does not
transmit, probe, jam, or interfere with any device or network.
</p>
<p class="footer-meta">
v0.5.3 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
</p>
</div>
</footer>
<script src="app.js" defer></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="OVERWATCH">
<defs>
<radialGradient id="ow-bg" cx="50%" cy="40%" r="74%">
<stop offset="0" stop-color="#123a24"/>
<stop offset="1" stop-color="#0a0f12"/>
</radialGradient>
</defs>
<rect x="4" y="4" width="100" height="100" rx="26" fill="url(#ow-bg)" stroke="#1f3a2c"/>
<path d="M54,54 L54,20 A34,34 0 0,1 82.5,36 Z" fill="#1fbf6a" fill-opacity="0.30"/>
<circle cx="54" cy="54" r="33" fill="none" stroke="#10b981" stroke-width="2.4"/>
<circle cx="54" cy="54" r="22" fill="none" stroke="#10b981" stroke-width="2.2" stroke-opacity="0.82"/>
<circle cx="54" cy="54" r="11" fill="none" stroke="#10b981" stroke-width="2" stroke-opacity="0.68"/>
<path d="M54,21 V87 M21,54 H87" stroke="#10b981" stroke-width="1.3" stroke-opacity="0.5"/>
<circle cx="70" cy="40" r="4.6" fill="#ef4444"/>
</svg>

After

Width:  |  Height:  |  Size: 932 B

+35
View File
@@ -0,0 +1,35 @@
<!doctype html><html lang="en"><head><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Space+Grotesk:wght@700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; overflow: hidden; position: relative;
background: radial-gradient(130% 130% at 12% 8%, #101b2e 0%, #07070d 52%, #0b0712 100%);
color: #ecedf7; font-family: "Inter", sans-serif;
}
.wrap { padding: 74px 82px; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.brand { display: flex; align-items: center; gap: 20px; margin-bottom: 26px; }
.mark { color: #10b981; font-size: 46px; filter: drop-shadow(0 0 18px rgba(16,185,129,.8)); }
.name { font-family: "Space Grotesk", sans-serif; font-weight: 700; font-size: 84px; letter-spacing: .01em; }
.tag { font-size: 31px; line-height: 1.38; color: #c5c5d3; font-weight: 500; max-width: 960px; }
.tag b { color: #ecedf7; font-weight: 700; }
.tiers { display: flex; gap: 22px; margin: 36px 0 26px; }
.tdot { display: flex; align-items: center; gap: 9px; font-family: "JetBrains Mono", monospace; font-size: 17px; color: #9a9aad; }
.tdot i { width: 15px; height: 15px; border-radius: 50%; }
.sources { font-family: "JetBrains Mono", monospace; font-size: 20px; color: #8a8a9d; }
.sources b { color: #34d399; }
.url { position: absolute; bottom: 48px; right: 82px; font-family: "JetBrains Mono", monospace; font-size: 21px; color: #5b5b75; }
</style></head><body>
<div class="wrap">
<div class="brand"><img src="logo.svg" style="width:80px;height:80px;border-radius:17px" alt=""><span class="name">OVERWATCH</span></div>
<div class="tag">Passive surveillance detection for Android. Flag nearby <b>Flock ALPRs</b>, <b>Axon body cams</b> &amp; <b>police</b><b>it only listens.</b></div>
<div class="tiers">
<span class="tdot"><i style="background:#22c55e;box-shadow:0 0 12px #22c55e"></i>GREEN</span>
<span class="tdot"><i style="background:#eab308;box-shadow:0 0 12px #eab308"></i>YELLOW</span>
<span class="tdot"><i style="background:#f97316;box-shadow:0 0 12px #f97316"></i>ORANGE</span>
<span class="tdot"><i style="background:#ef4444;box-shadow:0 0 12px #ef4444"></i>RED</span>
</div>
<div class="sources">BLE · WiFi · <b>DeFlock</b> · Citizen · Waze &nbsp;&nbsp; six sensors, fused</div>
</div>
<div class="url">overwatch.netslum.io</div>
</body></html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+1123
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -14,3 +14,8 @@ sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk
# in your shell profile
# (b) put the line below into ~/.gradle/gradle.properties (NOT project gradle.properties):
# org.gradle.java.home=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# The Waze police source no longer uses an app-side key. The OpenWeb Ninja key
# lives only on the api.blackflagintel.com proxy (server-side); the app calls
# that proxy with a token pasted into Settings (stored encrypted on-device).
# Nothing to configure here for Waze.