v0.5.1 — UI: map ring, ⌖ crosshair, source-color dots, bottom START
Build APK / build (push) Waiting to run

- Larger main-screen map circle (220 -> 300dp) wrapped in a threat-color ring
  (green/yellow/orange/red while scanning, gray idle).
- User position renders as a ⌖ crosshair; map geodata color-coded by source
  (Flock/DeFlock red, Waze blue, Citizen purple) in both the main circle and
  the floating overlay bubble.
- START button moved to the bottom of the screen.
- README brought current: COMMERCIAL source + overlay documented, Waze-via-proxy
  and the CI release pipeline described, architecture/permissions/changelog
  updated, build reqs corrected to JDK 17 / SDK 35.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
This commit is contained in:
KaraZajac
2026-07-13 17:03:06 -04:00
parent ef68394080
commit 7671e8fef8
5 changed files with 154 additions and 30 deletions
+44 -18
View File
@@ -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.1](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 (05) + reliability (010); 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 (05) + reliability (010); high values nudge the score up. No token → source shows "not configured" in the drill-down. |
| **COMMERCIAL** | Nearby consumer smart-home / voice-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, ≈ $13/mo at the 4-min poll). To keep the paid key off every device, the app doesn't hold it: a Caddy reverse proxy at `api.blackflagintel.com` injects the key server-side, and the app authenticates with a scoped, revocable `X-App-Token` entered in Settings (stored encrypted via the Android Keystore). The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only. Waze complements Citizen: denser for roadway stops / speed traps. > **Waze is back (v0.4.0+), via a key-protected proxy.** Waze reCAPTCHA-gated its `live-map/api/georss` endpoint in 2025/2026 — automated calls get HTTP 403 regardless of IP or headless-vs-headful browser (it scores browser *reputation*, verified by direct testing), which is why v0.1.5 removed the original integration and why no free scraper survives. OVERWATCH reads Waze POLICE alerts through [OpenWeb Ninja](https://www.openwebninja.com)'s hosted feed (pay-as-you-go ~$0.005/req, ≈ $13/mo at the 4-min poll). To keep the paid key off every device, the app doesn't hold it: a Caddy reverse proxy at `api.blackflagintel.com` injects the key server-side, and the app authenticates with a scoped, revocable `X-App-Token` entered in Settings (stored encrypted via the Android Keystore). The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only. Waze complements Citizen: denser for roadway stops / speed traps.
@@ -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,10 @@ 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.
--- ---
## Permissions ## Permissions
@@ -126,10 +145,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 +162,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 +174,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 +188,26 @@ 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.0v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated) - **wazepolice** — original live-map/api/georss recipe; that endpoint is now reCAPTCHA-gated, so v0.5.0 re-added Waze via the OpenWeb Ninja proxy (`api.blackflagintel.com`) instead of hitting Waze directly
--- ---
## Status ## Status
Phases 15 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and Phases 15 (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.1v0.2.2 — Live proximity refresh, map dot markers, map + Settings UI polish.
- v0.3.0v0.3.2 — Floating threat-circle overlay (chat-bubble style) + drag/crash fixes.
- v0.5.0 — Waze re-added via a key-protected Caddy proxy (`api.blackflagintel.com`): the OpenWeb Ninja key stays server-side, the app uses an encrypted per-device token. GitHub Actions release pipeline added.
- v0.5.1 — UI: larger map circle with a threat-color ring, ⌖ user crosshair, source-color dots (Flock red / Waze blue / Citizen purple), START moved to the bottom.
## License ## License
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 16 versionCode = 17
versionName = "0.5.0" versionName = "0.5.1"
} }
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)
} }
) )