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