3 Commits

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

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

versionCode 1 → 3, versionName 0.1.0 → 0.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:22:20 -04:00
KaraZajac aa34a1c518 Fix START button being disabled before permissions are granted
The button was gated on `granted || running`, but the only thing that
triggers the permission request is tapping the button — catch-22 that
left first-time users with no way to grant permissions.

Always enable the button when not running; the onStartStop handler already
routes correctly (start scanning if granted, else launch the permission
request flow). Updated the helper text to point at this directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:15:42 -04:00
KaraZajac 87b1f0b887 Add OVERWATCH v0.1.0 — full detection engine + polish
Phase 1 (BLE), Phase 2 (WiFi BSSID/SSID), Phase 3 (DeFlock map proximity),
Phase 4 (Waze live POLICE alerts), and Phase 5 polish all wired through one
DetectionStore. Confidence engine scores 0-100; UI maps to 4-tier circle.

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

README documents all sources, architecture, build steps, permissions, and
the legal disclaimer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:10:57 -04:00
47 changed files with 359 additions and 4921 deletions
-51
View File
@@ -1,51 +0,0 @@
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
+36 -130
View File
@@ -1,49 +1,28 @@
# OVERWATCH
# [DЯΣΛMMΛKΣЯ]
## &nbsp;&nbsp;&nbsp;. //0VΣЯW4TCH
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 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.
camera, or police presence near you.
> **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" />
&nbsp;&nbsp;
<img src="docs/img/overwatch-sources.png" width="240" alt="Detection-source drill-down" />
&nbsp;&nbsp;
<img src="docs/img/overwatch-settings.png" width="240" alt="Settings" />
</p>
<p align="center"><sub>Live threat-map circle · source drill-down · settings</sub></p>
---
## 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). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. |
| **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) |
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. |
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. First poll fires immediately on the first location fix. |
| **WAZE** | User-reported `POLICE` alerts still active in the feed within configurable proximity (default 500 m), up to ~45 min old | `api.blackflagintel.com/waze/alerts-and-jams` — the OVERWATCH proxy (Caddy) that injects the OpenWeb Ninja key server-side and forwards to their hosted Waze scrape, sidestepping the reCAPTCHA gating that 403s direct `live-map/api/georss` calls. The app authenticates with an `X-App-Token` entered in Settings (encrypted on-device); the paid key never ships in the APK. Polled every ~4 min. Upstream ignores type filtering and caps at 200 alerts, so the client pulls the full page and filters to `POLICE` itself. Alerts carry confidence (05) + reliability (010); high values nudge the score up. No token → source shows "not configured" in the drill-down. |
| **COMMERCIAL** | Nearby consumer smart-home / voice gear (Nest, Ring, Echo, hidden cams) and camera-bearing smart glasses (Meta, Snap, Vuzix) as a secondary situational signal | Rides the BLE + WiFi scans — OUI / device-name / service-UUID / SSID matches plus Bluetooth SIG company IDs from `MicTargets`. Score-capped at ORANGE so a cluster of doorbells (or a passing pair of Ray-Bans) never reads as ALPR-grade certainty. |
| **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 |
> **Waze is back (v0.4.0+), via a key-protected proxy.** Waze reCAPTCHA-gated its `live-map/api/georss` endpoint in 2025/2026 — automated calls get HTTP 403 regardless of IP or headless-vs-headful browser (it scores browser *reputation*, verified by direct testing), which is why v0.1.5 removed the original integration and why no free scraper survives. OVERWATCH reads Waze POLICE alerts through [OpenWeb Ninja](https://www.openwebninja.com)'s hosted feed (pay-as-you-go ~$0.005/req, ≈ $13/mo at the 4-min poll). To keep the paid key off every device, the app doesn't hold it: a Caddy reverse proxy at `api.blackflagintel.com` injects the key server-side, and the app authenticates with a scoped, revocable `X-App-Token` entered in Settings (stored encrypted via the Android Keystore). The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only. Waze complements Citizen: denser for roadway stops / speed traps.
Every observation is scored 0100 by `ConfidenceEngine`. The on-screen tier is
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources:
```
@@ -55,83 +34,45 @@ 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). 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.
match in the same area produce a higher tier than either alone).
---
## Architecture
```
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
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
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
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
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
```
No detection-history database. All state is in-memory and clears on stop, by
design. Service uses `START_NOT_STICKY` — system kill doesn't auto-restart
into a stuck state.
design.
---
## Build & install
Requires:
- **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
- **JDK 21** (Android Gradle Plugin 8.7.x rejects JDK 26)
- **Android Studio** with SDK Platform 34 + Build-Tools 34.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
@@ -140,16 +81,9 @@ export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
./gradlew :app:installDebug
```
Or download the latest debug-signed APK from
Or download the latest 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
@@ -158,19 +92,14 @@ without an uninstall.
|---|---|
| `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/Citizen proximity |
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock proximity |
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass, Citizen API, Waze proxy |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock CDN + Waze API |
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off |
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
| `VIBRATE` | Haptic alert on threat-tier escalation |
| `SYSTEM_ALERT_WINDOW` | Optional floating threat-circle overlay (special-access; granted via system settings) |
Requested at runtime when you press START for the first time. If you
permanently deny a required permission ("don't ask again"), the START button
swaps to **Open app settings** which fires the per-app system-settings page
so you can grant manually.
Requested at runtime when you press START for the first time.
---
@@ -178,20 +107,11 @@ so you can grant manually.
Tap the gear icon in the top-right.
- **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):
- **Detection sources**: toggle BLE / WiFi / DeFlock / Waze independently. Takes
effect on next Start.
- **Proximity thresholds**:
- 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)
---
@@ -203,29 +123,15 @@ 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**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
- **deflock** + **deflock-app**CDN tile scheme, proximity-alert pattern
- **wazepolice** — live-map/api/georss recipe, Chrome header spoofing
---
## Status
Phases 15 (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.1v0.2.2 — Live proximity refresh, map dot markers, map + Settings UI polish.
- v0.3.0v0.3.2 — Floating threat-circle overlay (chat-bubble style) + drag/crash fixes.
- v0.5.0 — Waze re-added via a key-protected Caddy proxy (`api.blackflagintel.com`): the OpenWeb Ninja key stays server-side, the app uses an encrypted per-device token. GitHub Actions release pipeline added.
- v0.5.1 — UI: larger map circle with a threat-color ring, ⌖ user crosshair, source-color dots (Flock red / Waze blue / Citizen purple), START moved to the bottom.
- v0.5.2 — Committed a fixed debug keystore so CI + local builds sign identically; updates now install in place (no functional changes).
- v0.5.3 — Detect Meta / Snap / Vuzix smart glasses in the COMMERCIAL source (BLE company-id + name vectors); new radar app icon (launcher, themed, and notification).
Phases 15 (skeleton, BLE, WiFi, DeFlock, Waze, polish) complete as of v0.1.0.
Field-test-ready, not yet field-validated.
## License
+2 -16
View File
@@ -12,21 +12,8 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
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"
}
versionCode = 3
versionName = "0.1.2"
}
buildTypes {
@@ -74,7 +61,6 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.location)
implementation(libs.osmdroid.android)
debugImplementation(libs.androidx.compose.ui.tooling)
}
-16
View File
@@ -35,24 +35,8 @@
<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,25 +1,18 @@
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.saveable.rememberSaveable
import androidx.compose.runtime.remember
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
@@ -51,7 +44,6 @@ class MainActivity : ComponentActivity() {
) { result ->
val allGranted = result.all { it.value }
permissionsGranted.value = allGranted
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
if (allGranted) {
// First-run path: user just granted everything, kick off scanning
// immediately so they don't have to tap START a second time.
@@ -59,30 +51,17 @@ class MainActivity : ComponentActivity() {
}
}
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)
private val permissionsGranted = androidx.compose.runtime.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 rememberSaveable { mutableStateOf(Screen.MAIN) }
var screen by remember { mutableStateOf(Screen.MAIN) }
when (screen) {
Screen.MAIN -> {
@@ -90,46 +69,21 @@ 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,
mapPoints = mapPoints,
userLocation = userLocation,
mapRadiusMeters = mapRadiusM,
canStart = true,
permissionMessage = message,
showOpenAppSettings = denied && !granted,
onOpenAppSettings = { openAppSettings() },
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
onStartStop = {
if (running) {
DetectionService.stop(this)
} else {
if (granted) {
DetectionService.start(this)
} else if (denied) {
openAppSettings()
} else {
permissionLauncher.launch(requiredPermissions)
}
@@ -139,10 +93,6 @@ 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,
@@ -161,10 +111,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// User may have granted permissions in app settings while we were paused.
val nowGranted = checkAllPermissions()
permissionsGranted.value = nowGranted
if (nowGranted) permanentlyDenied.value = false
permissionsGranted.value = checkAllPermissions()
}
private fun checkAllPermissions(): Boolean =
@@ -172,23 +119,5 @@ 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,13 +49,12 @@ 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
}
}
@Volatile private var running = false
private var running = false
fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
@@ -69,22 +68,12 @@ class LocationProvider(private val context: Context) {
return false
}
try {
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
}
}
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
running = true
Log.i(TAG, "Location updates started")
return true
} catch (e: SecurityException) {
running = false
Log.e(TAG, "SecurityException starting location updates", e)
return false
}
@@ -1,89 +0,0 @@
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,10 +16,7 @@ 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,
private val appContext: Context
) {
class Settings private constructor(private val prefs: SharedPreferences) {
enum class ThemeMode { SYSTEM, DARK, LIGHT }
@@ -32,53 +29,28 @@ class Settings private constructor(
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)
@@ -86,65 +58,35 @@ class Settings private constructor(
_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
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
).also { INSTANCE = it }
}
}
@@ -1,182 +0,0 @@
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,16 +24,9 @@ object ConfidenceEngine {
const val W_WIFI_SSID_GENERIC = 50
const val W_WIFI_SSID_FLOCK_FMT = 65
// Map (Phase 3)
// Map / Waze (Phase 3 + 4)
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
@@ -41,19 +34,6 @@ 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,
@@ -81,18 +61,6 @@ 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,
@@ -201,30 +169,12 @@ 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 {
// 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.
// Plan baseline: 55 for any POLICE alert ≤500m & <10min old.
// Caller is responsible for applying the proximity + age gate before scoring.
var score = W_WAZE_POLICE
// Lightweight crowd-trust nudge: high reliability & high confidence each add a few points,
// capped well under the multi-method bonus so a corroborating BLE/WiFi hit still dominates.
if (obs.reliability >= 7) score += 5
if (obs.confidence >= 4) score += 5
score = score.coerceAtMost(100)
@@ -244,107 +194,6 @@ 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,12 +4,11 @@ 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 Citizen)
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Waze)
* @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/Citizen sources
* @param lat / lon real-world coordinates for events that have them (DEFLOCK, CITIZEN); null for radio-only sources
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Waze sources
* @param timestampMs wall-clock millis when this event was produced
*/
data class DetectionEvent(
@@ -19,10 +18,7 @@ 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,17 +44,6 @@ 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
@@ -1,73 +0,0 @@
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, CITIZEN, WAZE, MIC }
enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE }
@@ -14,7 +14,6 @@ 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
@@ -22,7 +21,6 @@ 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).
@@ -39,9 +37,7 @@ import org.soulstone.overwatch.fusion.SourceHealth
class BleScanner(
private val context: Context,
private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
private val rssi: RssiTracker = RssiTracker()
) {
companion object {
@@ -83,30 +79,18 @@ 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
}
}
@@ -136,11 +120,6 @@ 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)"
)
}
}
@@ -153,86 +132,43 @@ 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) {
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 }
}
companyId = mfgSpecific.keyAt(0)
payload = mfgSpecific.valueAt(0)
}
// Cheap pre-filter — drop devices that have zero target signals.
val isSurveillance = BleOuis.matches(mac) ||
val candidate = BleOuis.matches(mac) ||
Patterns.bleNameMatch(name) ||
Patterns.isPenguinNumeric(name) ||
RavenUuids.countMatches(advertisedUuids) > 0 ||
companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID
val isMic = micEnabled() &&
MicTargets.couldBeMicBle(mac, name, advertisedUuids, companyId)
if (!isSurveillance && !isMic) return
if (!candidate) return
rssi.update(mac, result.rssi)
val stationary = rssi.isStationary(mac)
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
if (isSurveillance) {
val obs = ConfidenceEngine.BleObservation(
mac = mac,
rssi = result.rssi,
deviceName = name,
advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId,
manufacturerPayload = payload,
isStationary = stationary
store.submit(
DetectionEvent(
source = DetectionSource.BLE,
key = mac,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = result.rssi
)
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
)
)
}
}
)
}
}
@@ -1,133 +0,0 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* 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()
}
}
}
@@ -1,172 +0,0 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.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,39 +5,31 @@ 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 org.json.JSONObject
import kotlin.math.floor
import org.json.JSONArray
/**
* 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.
* Fetches DeFlock ALPR tile data from the public CDN, with a 24h on-disk cache.
*
* 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.
* 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.
*/
class DeflockClient(context: Context) {
companion object {
private const val TAG = "DeflockClient"
private const val FETCH_RADIUS_DEG = 0.05 // ~5.5 km half-width bbox
private const val TILE_SIZE_DEG = 20
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 = 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"
)
private const val TIMEOUT_MS = 15_000
}
data class AlprPoint(
@@ -48,133 +40,82 @@ class DeflockClient(context: Context) {
val manufacturer: String? = null
)
/** 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()
data class TileKey(val tileLat: Int, val tileLon: Int) {
fun fileName() = "deflock_${tileLat}_${tileLon}.json"
}
private val cacheDir: File = File(context.cacheDir, "deflock").apply { mkdirs() }
suspend fun fetchAround(lat: Double, lon: Double): FetchResult = withContext(Dispatchers.IO) {
val key = cacheKeyFor(lat, lon)
val cached = cachedJson(key)
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)
if (cached != null) {
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")
return@withContext parseSafely(cached)
}
val downloaded = downloadTile(tile) ?: return@withContext emptyList()
try {
File(cacheDir, "$key.json").writeText(body)
File(cacheDir, tile.fileName()).writeText(downloaded)
} catch (e: Exception) {
Log.w(TAG, "Failed to write cache for $key: ${e.message}")
Log.w(TAG, "Failed to write tile cache for $tile: ${e.message}")
}
FetchResult.Success(parseSafely(body))
parseSafely(downloaded)
}
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")
private fun cachedJson(tile: TileKey): String? {
val f = File(cacheDir, tile.fileName())
if (!f.exists()) return null
if (System.currentTimeMillis() - f.lastModified() > CACHE_TTL_MS) return null
val age = System.currentTimeMillis() - f.lastModified()
if (age > CACHE_TTL_MS) return null
return try { f.readText() } catch (e: Exception) { null }
}
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)
private fun downloadTile(tile: TileKey): String? {
val url = URL("$CDN_BASE/${tile.tileLat}/${tile.tileLon}.json")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "POST"
doOutput = true
requestMethod = "GET"
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 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
}
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() }
} else {
Log.w(TAG, "$endpoint returned $code")
null to "HTTP $code"
Log.w(TAG, "CDN returned $code for $tile")
null
}
} catch (e: Exception) {
Log.w(TAG, "$endpoint failed: ${e.message}")
null to (e.message ?: e.javaClass.simpleName)
Log.w(TAG, "Download failed for $tile: ${e.message}")
null
} 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 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")
val arr = JSONArray(json)
val out = ArrayList<AlprPoint>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val lat = o.optDouble("lat")
val lon = o.optDouble("lon")
if (lat.isNaN() || lon.isNaN()) continue
val tags = el.optJSONObject("tags")
val tags = o.optJSONObject("tags")
out.add(
AlprPoint(
id = el.optLong("id", 0L),
id = o.optLong("id", 0L),
lat = lat,
lon = lon,
operator = tags?.optString("operator")?.ifBlank { null }
@@ -188,7 +129,7 @@ class DeflockClient(context: Context) {
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Overpass response: ${e.message}")
Log.w(TAG, "Failed to parse tile JSON: ${e.message}")
emptyList()
}
}
@@ -4,9 +4,6 @@ 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
@@ -14,16 +11,17 @@ 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]; 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.
* 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.
*/
class DeflockScanner(
private val store: DetectionStore,
@@ -34,20 +32,11 @@ class DeflockScanner(
companion object {
private const val TAG = "DeflockScanner"
private const val REFETCH_THRESHOLD_M = 1500f
/** Don't retry an Overpass POST within this window after a failure. */
private const val FAILURE_BACKOFF_MS = 60_000L
}
private var job: Job? = null
private var lastFetchLat: Double? = null
private var lastFetchLon: Double? = null
private var 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()
private var lastTile: DeflockClient.TileKey? = null
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
@@ -63,71 +52,23 @@ class DeflockScanner(
fun stop() {
job?.cancel()
job = null
lastFetchLat = null
lastFetchLon = null
lastAttemptMs = 0L
lastAttemptOk = false
_cachedPoints.value = emptyList()
lastTile = null
cachedPoints = emptyList()
Log.i(TAG, "DeflockScanner stopped")
}
private suspend fun handleFix(fix: Location) {
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).
}
}
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")
}
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
if (cachedPoints.isEmpty()) return
val limit = proximityMeters()
val out = FloatArray(1)
for (p in points) {
for (p in cachedPoints) {
Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out)
val dist = out[0]
if (dist > limit) continue
@@ -145,25 +86,9 @@ class DeflockScanner(
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = p.lat,
lon = p.lon
rssi = null
)
)
}
}
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,127 +3,79 @@ package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.cos
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches live Waze POLICE alerts through the OVERWATCH proxy at
* `api.blackflagintel.com` (a Caddy vhost on the ASTROPHAGE box). The proxy
* holds the real OpenWeb Ninja API key server-side and injects it; the app only
* presents a scoped, revocable `X-App-Token`. So the valuable key never ships in
* the APK — a leaked/decompiled build carries only the proxy token, which works
* against this one endpoint and can be rotated on the server in seconds.
* Fetches Waze live-map alerts in a small bounding box around the user.
*
* 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>
* Endpoint (recipe from REFERENCES/wazepolice):
* https://www.waze.com/live-map/api/georss?top=&bottom=&left=&right=&env=na&types=alerts
*
* 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.
* Spoofs Chrome desktop headers — the public live-map endpoint requires Referer +
* a real-looking User-Agent, otherwise returns 403.
*
* 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.
* Response shape:
* { "alerts": [
* { "uuid", "type": "POLICE", "subtype",
* "location": {"x": lon, "y": lat},
* "pubMillis", "reportedBy", "confidence" 0-5, "reliability" 0-10 } ] }
*/
class WazeClient(
private val appToken: () -> String = { "" }
) {
class WazeClient {
companion object {
private const val TAG = "WazeClient"
private const val BASE = "https://api.blackflagintel.com/waze/alerts-and-jams"
private const val BASE = "https://www.waze.com/live-map/api/georss"
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private const val REFERER = "https://www.waze.com/live-map/"
private const val ORIGIN = "https://www.waze.com"
private const val TIMEOUT_MS = 10_000
/** 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
/** Bounding box half-width in degrees — ~5.5 km N-S, varies E-W with latitude. */
private const val BBOX_HALF_DEG = 0.05
}
/** True when a proxy token is set. False → source is unconfigured. */
val isConfigured: Boolean get() = appToken().isNotBlank()
data class Alert(
val uuid: String,
val subtype: String?,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val confidence: Int, // 0-5
val reliability: Int // 0-10
val confidence: Int,
val reliability: Int,
val reportedBy: String?
)
/** 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"
)
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")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("X-App-Token", appToken())
setRequestProperty("Accept", "application/json")
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Referer", REFERER)
setRequestProperty("Origin", ORIGIN)
setRequestProperty("Accept", "application/json,text/javascript,*/*;q=0.8")
setRequestProperty("Accept-Language", "en-US,en;q=0.9")
}
try {
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 code = conn.responseCode
if (code !in 200..299) {
Log.w(TAG, "Waze returned $code")
return@withContext emptyList()
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
parsePolice(body)
} catch (e: Exception) {
Log.w(TAG, "Waze feed fetch failed: ${e.message}")
FetchResult.Failed(e.message ?: e.javaClass.simpleName)
Log.w(TAG, "Waze fetch failed: ${e.message}")
emptyList()
} finally {
conn.disconnect()
}
@@ -132,94 +84,35 @@ class WazeClient(
private fun parsePolice(body: String): List<Alert> {
if (body.isBlank()) return emptyList()
return try {
val alerts = extractAlerts(JSONObject(body)) ?: return emptyList()
val out = ArrayList<Alert>(alerts.length())
val root = JSONObject(body)
val alerts = root.optJSONArray("alerts") ?: return emptyList()
val out = ArrayList<Alert>()
for (i in 0 until alerts.length()) {
val a = alerts.optJSONObject(i) ?: continue
// 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 (a.optString("type") != "POLICE") continue
val loc = a.optJSONObject("location") ?: continue
val uuid = a.optString("uuid")
if (uuid.isBlank()) continue
val lat = loc.optDouble("y")
val lon = loc.optDouble("x")
if (lat.isNaN() || lon.isNaN()) continue
val uuid = firstString(a, "alert_id", "uuid", "id")
?: "$lat,$lon,${firstLong(a, "publish_datetime_utc") ?: i}"
out.add(
Alert(
uuid = uuid,
subtype = firstString(a, "subtype"),
subtype = a.optString("subtype").ifBlank { null },
lat = lat,
lon = lon,
pubMillis = parseTimeMillis(a),
confidence = firstInt(a, "alert_confidence", "confidence") ?: 0,
reliability = firstInt(a, "alert_reliability", "reliability") ?: 0
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
confidence = a.optInt("confidence", 0),
reliability = a.optInt("reliability", 0),
reportedBy = a.optString("reportedBy").ifBlank { null }
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Waze feed response: ${e.message}")
Log.w(TAG, "Failed to parse Waze 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,7 +5,6 @@ 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
@@ -13,23 +12,13 @@ import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* Polls the OpenWeb Ninja Waze feed for live POLICE alerts around the current
* location, then submits any inside [proximityMeters] and younger than
* [MAX_AGE_MS].
* 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].
*
* 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).
* Skips the poll cycle if location is not yet known. Network-only — no on-disk cache
* (data is real-time by definition).
*/
class WazeScanner(
private val store: DetectionStore,
@@ -40,83 +29,38 @@ class WazeScanner(
companion object {
private const val TAG = "WazeScanner"
private const val POLL_INTERVAL_MS = 240_000L
// The hosted feed only lists still-active alerts but lags live Waze, so
// real police sightings routinely arrive already 20-30 min old. A 10-min
// cutoff (fine for the old direct-live feed) would drop nearly all of
// them; 45 min matches what the feed actually serves as "current."
private const val MAX_AGE_MS = 45L * 60L * 1000L
private const val POLL_INTERVAL_MS = 60_000L
private const val MAX_AGE_MS = 10L * 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)
if (fix != null) {
pollOnce(fix)
} else {
Log.d(TAG, "Skip poll — no location yet")
}
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms, configured=${client.isConfigured})")
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms)")
return true
}
fun stop() {
job?.cancel()
job = null
lastAlerts = emptyList()
Log.i(TAG, "WazeScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
if (!client.isConfigured) {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Proxy token not set — add it in Settings"
)
return
}
when (val result = client.fetchPoliceNear(fix.latitude, fix.longitude, proximityMeters())) {
is WazeClient.FetchResult.Failed -> {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Waze feed unreachable: ${result.reason}"
)
}
is WazeClient.FetchResult.Success -> {
SourceHealth.record(DetectionSource.WAZE, ok = true)
lastAlerts = result.alerts
// Deliberately no clearSource() here: re-submitting refreshes
// still-present alerts by key (dedup) and lets vanished ones age
// out via the store's 5-min TTL. Clearing every poll would briefly
// drop the tier and re-raise it, double-firing the escalation
// vibration each cycle. (Mirrors CitizenScanner.)
emitProximityEvents(fix, result.alerts)
}
}
}
/**
* Re-evaluate the last fetched alert set against the current proximity + age
* thresholds and latest fix, without a network refetch. Used when the user
* moves the Waze proximity slider.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.WAZE)
emitProximityEvents(fix, lastAlerts)
}
private fun emitProximityEvents(fix: Location, alerts: List<WazeClient.Alert>) {
val alerts = client.fetchPoliceNear(fix.latitude, fix.longitude)
if (alerts.isEmpty()) return
val now = System.currentTimeMillis()
val limit = proximityMeters()
val out = FloatArray(1)
@@ -128,26 +72,23 @@ class WazeScanner(
val dist = out[0]
if (dist > limit) continue
val scored = ConfidenceEngine.scoreWaze(
ConfidenceEngine.WazeObservation(
uuid = a.uuid,
distanceMeters = dist,
ageMs = age,
confidence = a.confidence,
reliability = a.reliability,
subtype = a.subtype
)
val obs = ConfidenceEngine.WazeObservation(
uuid = a.uuid,
distanceMeters = dist,
ageMs = age,
confidence = a.confidence,
reliability = a.reliability,
subtype = a.subtype
)
val scored = ConfidenceEngine.scoreWaze(obs)
store.submit(
DetectionEvent(
source = DetectionSource.WAZE,
key = "waze:${a.uuid}",
key = a.uuid,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = a.lat,
lon = a.lon
rssi = null
)
)
}
@@ -17,7 +17,6 @@ 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
@@ -25,7 +24,6 @@ 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].
@@ -41,9 +39,7 @@ import org.soulstone.overwatch.fusion.SourceHealth
class WifiScanner(
private val context: Context,
private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
private val rssi: RssiTracker = RssiTracker()
) {
companion object {
@@ -90,23 +86,15 @@ 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
@@ -170,51 +158,31 @@ class WifiScanner(
val bssid = r.BSSID ?: continue
val ssid = readSsid(r)
val isSurveillance = WifiOuis.matches(bssid) ||
val candidate = WifiOuis.matches(bssid) ||
Patterns.ssidGenericMatch(ssid) ||
Patterns.ssidFlockFormat(ssid)
val isMic = micEnabled() && MicTargets.couldBeMicWifi(bssid, ssid)
if (!isSurveillance && !isMic) continue
if (!candidate) continue
rssi.update(bssid, r.level)
val stationary = rssi.isStationary(bssid)
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
if (isSurveillance) {
val obs = ConfidenceEngine.WifiObservation(
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
store.submit(
DetectionEvent(
source = DetectionSource.WIFI,
key = bssid,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = r.level
)
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,12 +7,8 @@ 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
@@ -22,40 +18,25 @@ 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 four scanners (BLE, WiFi, DeFlock, Citizen)
* and the [DetectionStore]. UI observes companion-object state flows directly.
* Foreground service that owns all scanners and the [DetectionStore].
*
* 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.
* Phase 1 wires only [BleScanner]; phases 2-4 will register WiFi, DeFlock, Waze.
*
* 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.
* 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).
*/
class DetectionService : LifecycleService() {
@@ -73,15 +54,6 @@ 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
@@ -106,51 +78,27 @@ 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, micEnabled = { settings.micEnabled.value })
wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value })
bleScanner = BleScanner(this, store)
wifiScanner = WifiScanner(this, store)
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()
}
@@ -163,17 +111,12 @@ class DetectionService : LifecycleService() {
stopSelf()
}
}
return START_NOT_STICKY
return START_STICKY
}
private fun beginScanning() {
if (_running.value) return
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)
startInForeground()
if (settings.bleEnabled.value) {
bleStarted = bleScanner.start()
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
@@ -182,8 +125,7 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value ||
settings.wazeEnabled.value
val needsLocation = settings.deflockEnabled.value || settings.wazeEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
if (!locOk) {
@@ -192,37 +134,11 @@ 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 {
@@ -231,89 +147,19 @@ 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 && !bleStarted && !wifiStarted && !deflockStarted && !citizenStarted) {
return
}
_running.value = false
if (!_running.value) return
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()
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
pruneJob?.cancel()
pruneJob = null
_running.value = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@@ -332,46 +178,12 @@ class DetectionService : LifecycleService() {
return null
}
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)
private fun startInForeground() {
val notification = buildNotification()
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
// Android 14+ requires the runtime type to cover every capability
// the service uses. We declare both in the manifest; pass both here
// so location-using sources (DeFlock, Waze) keep working with the
// screen off.
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -381,7 +193,7 @@ class DetectionService : LifecycleService() {
}
}
private fun buildNotification(tier: ThreatLevel, topEvent: DetectionEvent?): Notification {
private fun buildNotification(): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@@ -389,26 +201,14 @@ 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(title)
.setContentText(text)
.setSmallIcon(R.drawable.ic_stat_radar)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(priority)
.setOnlyAlertOnce(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
@@ -1,330 +0,0 @@
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,15 +1,11 @@
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
@@ -24,9 +20,7 @@ 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
@@ -39,11 +33,10 @@ 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.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -54,18 +47,10 @@ 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 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 kotlinx.coroutines.launch
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)
@@ -75,21 +60,14 @@ 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?,
showOpenAppSettings: Boolean = false,
onOpenAppSettings: () -> Unit = {}
permissionMessage: String?
) {
var showSheet by rememberSaveable { mutableStateOf(false) }
var showSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetScope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -97,26 +75,30 @@ fun MainScreen(
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 24.dp)
) {
// Box (rather than Row + SpaceBetween) so the title is truly centered
// regardless of the gear icon's width.
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.padding(top = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween
) {
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)
) {
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) {
Icon(
Icons.Filled.Settings,
contentDescription = "Settings",
@@ -131,15 +113,7 @@ fun MainScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
ThreatMapCircle(
level = threat,
animating = running,
userLocation = userLocation,
mapPoints = mapPoints,
events = events,
mapRadiusMeters = mapRadiusMeters,
onTap = { showSheet = true }
)
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true })
Spacer(Modifier.height(12.dp))
Text(
@@ -152,15 +126,9 @@ fun MainScreen(
Spacer(Modifier.height(24.dp))
StatusText(running = running, threat = threat, score = score, events = events)
}
// Push the primary action button to the bottom of the screen.
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onStartStop,
enabled = canStart,
@@ -189,27 +157,7 @@ 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) {
@@ -235,29 +183,16 @@ fun MainScreen(
}
@Composable
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) {
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = 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.5f else 1.0f,
initialValue = if (animating) 0.6f else 1.0f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
@@ -265,152 +200,29 @@ private fun ThreatMapCircle(
),
label = "pulse"
)
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.size(300.dp)
.border(width = 6.dp, color = ringColor, shape = CircleShape)
.padding(6.dp)
.clip(CircleShape),
.size(220.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
color.copy(alpha = alpha),
color.copy(alpha = alpha * 0.6f)
)
)
)
.clickable(onClick = onTap),
contentAlignment = Alignment.Center
) {
// While idle OR before the first location fix arrives, fall back to the
// solid pulsing circle — a blank/loading map mid-tile-fetch reads as
// broken. The map only renders once we actually have something to show.
if (!animating || userLocation == null) {
val color = if (animating) activeColor else idleColor
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(
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)
Text(
text = level.name,
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
)
}
}
@@ -458,19 +270,8 @@ 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(
@@ -485,7 +286,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = source.displayLabel(),
text = source.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@@ -493,7 +294,6 @@ 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
@@ -506,15 +306,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
.background(statusColor)
)
}
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()) {
if (events.isEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
text = "no detections",
@@ -523,7 +315,14 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
)
} else {
Spacer(Modifier.height(4.dp))
events.take(3).forEach { e -> EventRow(e) }
events.take(3).forEach { e ->
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
if (events.size > 3) {
Text(
text = "+${events.size - 3} more",
@@ -535,52 +334,3 @@ 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)
)
}
}
}
}
@@ -1,68 +0,0 @@
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 (⌖)
@@ -1,185 +0,0 @@
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,8 +1,5 @@
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
@@ -14,11 +11,8 @@ 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
@@ -28,19 +22,11 @@ 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
@@ -59,17 +45,10 @@ fun SettingsScreen(
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
@@ -97,10 +76,8 @@ 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 (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo, glasses", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp))
if (isRunning) {
Button(
@@ -130,67 +107,21 @@ fun SettingsScreen(
SectionLabel("Proximity thresholds")
SliderRow(
label = "DeFlock alert distance",
persistedValue = deflockProx,
valueLabel = "${deflockProx} m",
value = deflockProx.toFloat(),
range = 50f..1600f,
steps = 30,
onCommit = { settings.setDeflockProximityM(it) }
)
SliderRow(
label = "Citizen alert distance",
persistedValue = citizenProx,
range = 100f..5000f,
steps = 48,
onCommit = { settings.setCitizenProximityM(it) }
onChange = { settings.setDeflockProximityM(it.toInt()) }
)
SliderRow(
label = "Waze alert distance",
persistedValue = wazeProx,
valueLabel = "${wazeProx} m",
value = wazeProx.toFloat(),
range = 100f..5000f,
steps = 48,
onCommit = { settings.setWazeProximityM(it) }
onChange = { settings.setWazeProximityM(it.toInt()) }
)
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) {
@@ -228,35 +159,25 @@ 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,
modifier = Modifier
.weight(1f, fill = true)
.padding(end = 12.dp)
fontFamily = FontFamily.Monospace
)
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,
persistedValue: Int,
valueLabel: String,
value: Float,
range: ClosedFloatingPointRange<Float>,
steps: Int,
onCommit: (Int) -> Unit
onChange: (Float) -> Unit
) {
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -269,79 +190,21 @@ private fun SliderRow(
fontFamily = FontFamily.Monospace
)
Text(
text = "${live.toInt()} m",
text = valueLabel,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = live,
onValueChange = { live = it },
onValueChangeFinished = { onCommit(live.toInt()) },
value = value,
onValueChange = onChange,
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,76 +1,13 @@
<?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:fillAlpha="0.32" />
<!-- range rings -->
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
<path
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" />
android:fillColor="#F4F6FA"
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
</vector>
@@ -1,39 +0,0 @@
<?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>
@@ -1,32 +0,0 @@
<?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,5 +2,4 @@
<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,5 +2,4 @@
<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>
+4 -2
View File
@@ -1,12 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">OVERWATCH</string>
<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="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">OVERWATCH detection</string>
<string name="notification_channel_name">DREAMMAKER / 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>
BIN
View File
Binary file not shown.
View File
-46
View File
@@ -1,46 +0,0 @@
/* 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.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

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

Before

Width:  |  Height:  |  Size: 932 B

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

Before

Width:  |  Height:  |  Size: 70 KiB

-1123
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -7,7 +7,6 @@ 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" }
@@ -22,7 +21,6 @@ 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" }
-5
View File
@@ -14,8 +14,3 @@ 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.