Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9314814ea4 | |||
| 7671e8fef8 |
@@ -13,7 +13,7 @@ on upward escalations — you don't have to be looking at the screen.
|
|||||||
> advertise/fuzz code from one of the reference projects is intentionally
|
> advertise/fuzz code from one of the reference projects is intentionally
|
||||||
> excluded.
|
> excluded.
|
||||||
|
|
||||||
Latest release: [v0.5.0](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
|
Latest release: [v0.5.2](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ Latest release: [v0.5.0](https://github.com/KaraZajac/OVERWATCH/releases) (debug
|
|||||||
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. |
|
| **DEFLOCK** | Crowdsourced ALPR locations within 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. |
|
| **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. |
|
| **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-assistant gear (Nest, Ring, Echo, hidden cams) as a secondary situational signal | Rides the BLE + WiFi scans — OUI / device-name / service-UUID / SSID matches from `MicTargets`. Score-capped at ORANGE so a cluster of doorbells never reads as ALPR-grade certainty. |
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
|
|
||||||
@@ -45,13 +46,21 @@ match in the same area produce a higher tier than either alone). When idle,
|
|||||||
the circle shows muted gray with `IDLE` text so it's distinguishable at a
|
the circle shows muted gray with `IDLE` text so it's distinguishable at a
|
||||||
glance from "scanning, all clear."
|
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
|
## How alerts work
|
||||||
|
|
||||||
- **In-app**: the threat circle pulses while scanning; tap it to open the
|
- **In-app**: the threat circle shows a live map with a threat-color ring and
|
||||||
bottom-sheet drill-down with per-source rows. DEFLOCK and CITIZEN events
|
source-color dots while scanning; tap it to open the bottom-sheet drill-down
|
||||||
carry coordinates — each row has a tap-to-open Maps icon (`geo:` intent).
|
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
|
- **Foreground notification**: rebuilt on every threat-tier change. Title
|
||||||
becomes `OVERWATCH • RED` (or whatever tier); text shows the top
|
becomes `OVERWATCH • RED` (or whatever tier); text shows the top
|
||||||
detection's score + label. Notification priority bumps to HIGH on RED so
|
detection's score + label. Notification priority bumps to HIGH on RED so
|
||||||
@@ -67,24 +76,30 @@ glance from "scanning, all clear."
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
|
ui/MainScreen.kt map circle + threat ring + START/STOP + drill-down sheet
|
||||||
ui/SettingsScreen.kt source toggles, distance sliders, vibrate, theme
|
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
|
ui/theme/Theme.kt Material 3 dark/light + threat colors
|
||||||
service/DetectionService.kt foreground service — owns scanners, notification, vibration
|
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/BleScanner.kt BLE callback scanner
|
||||||
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
|
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
|
||||||
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
|
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
|
||||||
scan/DeflockScanner.kt location-driven proximity check + failure backoff
|
scan/DeflockScanner.kt location-driven proximity check + failure backoff
|
||||||
scan/CitizenClient.kt GET /api/incident/trending + /api/incident/{id}
|
scan/CitizenClient.kt GET /api/incident/trending + /api/incident/{id}
|
||||||
scan/CitizenScanner.kt 60 s poller, fire/medical filter, per-id cache
|
scan/CitizenScanner.kt 60 s poller, fire/medical filter, per-id cache
|
||||||
fusion/ConfidenceEngine.kt scoring (one place — BLE / WiFi / DeFlock / Citizen)
|
scan/WazeClient.kt GET api.blackflagintel.com proxy (X-App-Token) → OpenWeb Ninja
|
||||||
|
scan/WazeScanner.kt ~4 min poller, 200-alert page, client-side POLICE filter
|
||||||
|
fusion/ConfidenceEngine.kt scoring (BLE / WiFi / DeFlock / Citizen / Waze / Commercial)
|
||||||
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
|
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
|
||||||
fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
|
fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
|
||||||
fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down
|
fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down
|
||||||
fusion/ThreatLevel.kt 4-tier enum + DetectionSource enum
|
fusion/ThreatLevel.kt 4-tier enum + DetectionSource enum
|
||||||
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
|
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
|
||||||
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
|
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
|
||||||
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
|
data/settings/SecureStore.kt Keystore AES/GCM store for the Waze proxy token
|
||||||
|
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers, MicTargets
|
||||||
```
|
```
|
||||||
|
|
||||||
No detection-history database. All state is in-memory and clears on stop, by
|
No detection-history database. All state is in-memory and clears on stop, by
|
||||||
@@ -96,8 +111,8 @@ into a stuck state.
|
|||||||
## Build & install
|
## Build & install
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
- **JDK 21** (Android Gradle Plugin 8.7.x rejects JDK 26)
|
- **JDK 17** (17 or 21; Android Gradle Plugin 8.7.x rejects JDK 26)
|
||||||
- **Android Studio** with SDK Platform 34 + Build-Tools 34.x + Platform-Tools
|
- **Android Studio** with SDK Platform 35 + Build-Tools 35.x + Platform-Tools
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1) Copy the example local.properties and point sdk.dir at your install
|
# 1) Copy the example local.properties and point sdk.dir at your install
|
||||||
@@ -115,6 +130,13 @@ export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
|||||||
Or download the latest debug-signed APK from
|
Or download the latest debug-signed APK from
|
||||||
[Releases](https://github.com/KaraZajac/OVERWATCH/releases).
|
[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
|
## Permissions
|
||||||
@@ -126,10 +148,11 @@ Or download the latest debug-signed APK from
|
|||||||
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Citizen proximity |
|
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Citizen proximity |
|
||||||
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
|
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
|
||||||
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
|
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
|
||||||
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass + Citizen API |
|
| `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 |
|
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off |
|
||||||
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
|
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
|
||||||
| `VIBRATE` | Haptic alert on threat-tier escalation |
|
| `VIBRATE` | Haptic alert on threat-tier escalation |
|
||||||
|
| `SYSTEM_ALERT_WINDOW` | Optional floating threat-circle overlay (special-access; granted via system settings) |
|
||||||
|
|
||||||
Requested at runtime when you press START for the first time. If you
|
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
|
permanently deny a required permission ("don't ask again"), the START button
|
||||||
@@ -142,7 +165,7 @@ so you can grant manually.
|
|||||||
|
|
||||||
Tap the gear icon in the top-right.
|
Tap the gear icon in the top-right.
|
||||||
|
|
||||||
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen / Waze independently.
|
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen / Waze / Commercial independently.
|
||||||
Changes take effect on the next Start. While scanning, a **Restart scan to
|
Changes take effect on the next Start. While scanning, a **Restart scan to
|
||||||
apply** button appears that does `stop()` + `start()` in one tap.
|
apply** button appears that does `stop()` + `start()` in one tap.
|
||||||
- **Proximity thresholds** (sliders commit on release, not per-pixel):
|
- **Proximity thresholds** (sliders commit on release, not per-pixel):
|
||||||
@@ -154,6 +177,8 @@ Tap the gear icon in the top-right.
|
|||||||
paid OpenWeb Ninja key stays server-side on the proxy. Empty = Waze source off.
|
paid OpenWeb Ninja key stays server-side on the proxy. Empty = Waze source off.
|
||||||
- **Alerts**:
|
- **Alerts**:
|
||||||
- Vibrate on threat escalation (default on)
|
- 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)
|
- **Appearance**: System / Dark / Light (default Dark)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -166,22 +191,27 @@ These live under `REFERENCES/` (gitignored):
|
|||||||
- **flock-detection** — confidence-scoring algorithm (highest reusability), RSSI rise-peak-fall, OUIs + UUIDs + patterns
|
- **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)
|
- **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android)
|
||||||
- **deflock** + **deflock-app** — Overpass query format + proximity-alert pattern (the Flutter app uses Overpass directly, not the CDN tiles, which the OVERWATCH client mirrors)
|
- **deflock** + **deflock-app** — Overpass query format + proximity-alert pattern (the Flutter app uses Overpass directly, not the CDN tiles, which the OVERWATCH client mirrors)
|
||||||
- **wazepolice** — live-map/api/georss recipe; informed v0.1.0–v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated)
|
- **wazepolice** — original live-map/api/georss recipe; that endpoint is now reCAPTCHA-gated, so v0.5.0 re-added Waze via the OpenWeb Ninja proxy (`api.blackflagintel.com`) instead of hitting Waze directly
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and
|
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and
|
||||||
field-tested. Current release **v0.1.7** addresses two full audit passes
|
field-tested. Current release **v0.5.1**. Notable changes:
|
||||||
(see release notes for v0.1.2, v0.1.3, v0.1.6). Notable changes since v0.1.0:
|
|
||||||
|
|
||||||
- v0.1.2 — Android 14+ foreground service type fix (location was being silently revoked); NaN-coordinate filter on map data.
|
- v0.1.2 — Android 14+ foreground service type fix; NaN-coordinate filter on map data.
|
||||||
- v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked).
|
- v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked).
|
||||||
- v0.1.4 — Citizen.com added as 5th source, per-source health registry.
|
- v0.1.4 — Citizen.com added as 5th source, per-source health registry.
|
||||||
- v0.1.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround).
|
- v0.1.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround at the time).
|
||||||
- v0.1.6 — Dynamic notification with tier + label, haptic alerts on escalation, Open-in-Maps for geo events, idle visual differentiated from "scanning, all clear", permanent-deny recovery via Open Settings.
|
- v0.1.6 — Dynamic notification with tier + label, haptic alerts, Open-in-Maps for geo events.
|
||||||
- v0.1.7 — System back from Settings returns to MAIN instead of exiting.
|
- v0.1.7 — System back from Settings returns to MAIN instead of exiting.
|
||||||
|
- 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).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -12,8 +12,21 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 16
|
versionCode = 18
|
||||||
versionName = "0.5.0"
|
versionName = "0.5.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
buildTypes {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.animation.core.infiniteRepeatable
|
|||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -135,6 +136,7 @@ fun MainScreen(
|
|||||||
animating = running,
|
animating = running,
|
||||||
userLocation = userLocation,
|
userLocation = userLocation,
|
||||||
mapPoints = mapPoints,
|
mapPoints = mapPoints,
|
||||||
|
events = events,
|
||||||
mapRadiusMeters = mapRadiusMeters,
|
mapRadiusMeters = mapRadiusMeters,
|
||||||
onTap = { showSheet = true }
|
onTap = { showSheet = true }
|
||||||
)
|
)
|
||||||
@@ -150,9 +152,15 @@ fun MainScreen(
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
StatusText(running = running, threat = threat, score = score, events = events)
|
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(
|
Button(
|
||||||
onClick = onStartStop,
|
onClick = onStartStop,
|
||||||
enabled = canStart,
|
enabled = canStart,
|
||||||
@@ -200,6 +208,8 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSheet) {
|
if (showSheet) {
|
||||||
@@ -230,6 +240,7 @@ private fun ThreatMapCircle(
|
|||||||
animating: Boolean,
|
animating: Boolean,
|
||||||
userLocation: Location?,
|
userLocation: Location?,
|
||||||
mapPoints: List<DeflockClient.AlprPoint>,
|
mapPoints: List<DeflockClient.AlprPoint>,
|
||||||
|
events: List<DetectionEvent>,
|
||||||
mapRadiusMeters: Float,
|
mapRadiusMeters: Float,
|
||||||
onTap: () -> Unit
|
onTap: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -240,6 +251,9 @@ private fun ThreatMapCircle(
|
|||||||
ThreatLevel.ORANGE -> ThreatColors.Orange
|
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||||
ThreatLevel.RED -> ThreatColors.Red
|
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 transition = rememberInfiniteTransition(label = "pulse")
|
||||||
val pulse by transition.animateFloat(
|
val pulse by transition.animateFloat(
|
||||||
@@ -254,7 +268,9 @@ private fun ThreatMapCircle(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(220.dp)
|
.size(300.dp)
|
||||||
|
.border(width = 6.dp, color = ringColor, shape = CircleShape)
|
||||||
|
.padding(6.dp)
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -300,8 +316,10 @@ private fun ThreatMapCircle(
|
|||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
// Build the marker drawables once per Composition rather than
|
// Build the marker drawables once per Composition rather than
|
||||||
// every recomposition — bitmap allocation isn't free.
|
// every recomposition — bitmap allocation isn't free.
|
||||||
val userDot = remember(ctx) { dotDrawable(ctx.resources, 36, DOT_USER_BLUE) }
|
val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 46, MARK_USER_WHITE) }
|
||||||
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
|
val 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(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { c ->
|
factory = { c ->
|
||||||
@@ -317,7 +335,8 @@ private fun ThreatMapCircle(
|
|||||||
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
map.overlays.clear()
|
map.overlays.clear()
|
||||||
|
|
||||||
// ALPR dots first, user dot last so the user draws on top.
|
// Source dots first (Flock red, Waze blue, Citizen purple),
|
||||||
|
// user position last so the crosshair always draws on top.
|
||||||
for (p in mapPoints) {
|
for (p in mapPoints) {
|
||||||
map.overlays.add(
|
map.overlays.add(
|
||||||
Marker(map).apply {
|
Marker(map).apply {
|
||||||
@@ -329,11 +348,29 @@ private fun ThreatMapCircle(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
for (e in events) {
|
||||||
|
val lat = e.lat ?: continue
|
||||||
|
val lon = e.lon ?: continue
|
||||||
|
val dot = when (e.source) {
|
||||||
|
DetectionSource.WAZE -> wazeDot
|
||||||
|
DetectionSource.CITIZEN -> citizenDot
|
||||||
|
else -> null
|
||||||
|
} ?: continue
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(lat, lon)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = dot
|
||||||
|
title = e.label
|
||||||
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
map.overlays.add(
|
map.overlays.add(
|
||||||
Marker(map).apply {
|
Marker(map).apply {
|
||||||
position = GeoPoint(fix.latitude, fix.longitude)
|
position = GeoPoint(fix.latitude, fix.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = userDot
|
icon = userMark
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,5 +25,44 @@ internal fun dotDrawable(
|
|||||||
return BitmapDrawable(resources, bitmap)
|
return BitmapDrawable(resources, bitmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal const val DOT_USER_BLUE = 0xFF2196F3.toInt()
|
/** Builds a ⌖-style position mark (register / gun-sight): a stroked circle with
|
||||||
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt()
|
* a cross through it, painted over a dark halo so it reads on any map tile. Used
|
||||||
|
* for the user's own location so it stays distinct from the colored source dots. */
|
||||||
|
internal fun crosshairDrawable(
|
||||||
|
resources: Resources,
|
||||||
|
sizePx: Int,
|
||||||
|
markColor: Int
|
||||||
|
): BitmapDrawable {
|
||||||
|
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
val c = sizePx / 2f
|
||||||
|
val r = sizePx * 0.30f
|
||||||
|
val pad = sizePx * 0.07f
|
||||||
|
val stroke = sizePx * 0.10f
|
||||||
|
|
||||||
|
val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = 0xB3000000.toInt()
|
||||||
|
strokeWidth = stroke + 3f
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
val mark = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = markColor
|
||||||
|
strokeWidth = stroke
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
// Halo pass then color pass so the mark stays legible on light or dark tiles.
|
||||||
|
for (p in listOf(halo, mark)) {
|
||||||
|
canvas.drawCircle(c, c, r, p)
|
||||||
|
canvas.drawLine(pad, c, sizePx - pad, c, p) // horizontal
|
||||||
|
canvas.drawLine(c, pad, c, sizePx - pad, p) // vertical
|
||||||
|
}
|
||||||
|
return BitmapDrawable(resources, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-source marker colors so geodata reads at a glance.
|
||||||
|
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt() // Flock / DeFlock ALPR cameras
|
||||||
|
internal const val DOT_WAZE_BLUE = 0xFF2196F3.toInt() // Waze police reports
|
||||||
|
internal const val DOT_CITIZEN_PURPLE = 0xFF9C27B0.toInt() // Citizen incidents
|
||||||
|
internal const val MARK_USER_WHITE = 0xFFFFFFFF.toInt() // the user's own position (⌖)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import org.osmdroid.util.GeoPoint
|
|||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
import org.soulstone.overwatch.data.settings.Settings
|
import org.soulstone.overwatch.data.settings.Settings
|
||||||
|
import org.soulstone.overwatch.fusion.DetectionSource
|
||||||
import org.soulstone.overwatch.fusion.ThreatLevel
|
import org.soulstone.overwatch.fusion.ThreatLevel
|
||||||
import org.soulstone.overwatch.service.DetectionService
|
import org.soulstone.overwatch.service.DetectionService
|
||||||
import org.soulstone.overwatch.ui.theme.ThreatColors
|
import org.soulstone.overwatch.ui.theme.ThreatColors
|
||||||
@@ -54,9 +55,11 @@ fun OverlayBubble() {
|
|||||||
val threat by DetectionService.store.threatLevel.collectAsState()
|
val threat by DetectionService.store.threatLevel.collectAsState()
|
||||||
val userLocation by DetectionService.location.collectAsState()
|
val userLocation by DetectionService.location.collectAsState()
|
||||||
val mapPoints by DetectionService.mapPoints.collectAsState()
|
val mapPoints by DetectionService.mapPoints.collectAsState()
|
||||||
|
val events by DetectionService.store.events.collectAsState()
|
||||||
val deflockProx by settings.deflockProximityM.collectAsState()
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
val citizenProx by settings.citizenProximityM.collectAsState()
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
val radius = max(deflockProx, citizenProx).toFloat()
|
val wazeProx by settings.wazeProximityM.collectAsState()
|
||||||
|
val radius = max(max(deflockProx, citizenProx), wazeProx).toFloat()
|
||||||
|
|
||||||
val activeColor = when (threat) {
|
val activeColor = when (threat) {
|
||||||
ThreatLevel.GREEN -> ThreatColors.Green
|
ThreatLevel.GREEN -> ThreatColors.Green
|
||||||
@@ -76,8 +79,10 @@ fun OverlayBubble() {
|
|||||||
label = "overlay-pulse"
|
label = "overlay-pulse"
|
||||||
)
|
)
|
||||||
|
|
||||||
val userDot = remember(ctx) { dotDrawable(ctx.resources, 30, DOT_USER_BLUE) }
|
val userMark = remember(ctx) { crosshairDrawable(ctx.resources, 34, MARK_USER_WHITE) }
|
||||||
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) }
|
val 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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -128,11 +133,28 @@ fun OverlayBubble() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
for (e in events) {
|
||||||
|
val lat = e.lat ?: continue
|
||||||
|
val lon = e.lon ?: continue
|
||||||
|
val dot = when (e.source) {
|
||||||
|
DetectionSource.WAZE -> wazeDot
|
||||||
|
DetectionSource.CITIZEN -> citizenDot
|
||||||
|
else -> null
|
||||||
|
} ?: continue
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(lat, lon)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = dot
|
||||||
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
map.overlays.add(
|
map.overlays.add(
|
||||||
Marker(map).apply {
|
Marker(map).apply {
|
||||||
position = GeoPoint(fix.latitude, fix.longitude)
|
position = GeoPoint(fix.latitude, fix.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = userDot
|
icon = userMark
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user