2 Commits

Author SHA1 Message Date
KaraZajac 9ffe5a9d9f v0.2.0 — live map circle + MIC detection (Echo/Ring/Nest/hidden cams)
- Replace the static threat circle with an osmdroid-backed map
  centered on the user, with red ALPR pins and a tier-color scrim.
  Falls back to the muted gradient when idle or before the first
  location fix arrives.
- Add DetectionSource.MIC: BLE/WiFi candidate path for Amazon
  Echo/Ring (Lab126 OUIs + AVS service UUID 0xFE03), Google Nest/
  Home/Chromecast (Google OUIs + mfg id 0x00E0), and generic
  Chinese hidden-cam vendors. Score capped at 84 (ORANGE) so RED
  stays reserved for ALPR/Axon-grade evidence. Toggleable in
  Settings; piggybacks on the BLE+WiFi scanners — no new radio.
- Drop the "[DЯΣΛMMΛKΣЯ]" stylized branding for a clean OVERWATCH
  header (notification channel + app label updated to match).
- Fix DeFlock geo-pin tap doing nothing: resolveActivity returns
  null on Android 11+ without a <queries> entry even when Maps is
  installed. Drop the pre-check, try/catch ActivityNotFoundException,
  fall back to a maps.google.com URL if no geo: handler exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:45:33 -04:00
KaraZajac cf62feae60 README: bring up to date with v0.1.7
Stale items corrected:
- Architecture file list referenced WazeClient.kt and WazeScanner.kt
  (deleted) and CDN-tile DeflockClient (now Overpass POST). Added the
  missing CitizenClient/CitizenScanner/SourceHealth/ThreatLevel files.
- Permissions table said "DeFlock CDN + Waze API" — now Overpass +
  Citizen. Added VIBRATE row.
- Settings section listed Waze instead of Citizen; missing the new
  Vibrate-on-escalation toggle and Restart-to-apply button.
- Status said "Phases 1-5 complete as of v0.1.0" — bumped to v0.1.7
  with a per-version changelog of what landed.

Added:
- Hero paragraph mentions notification + vibration alerting.
- New "How alerts work" section explaining notification updates,
  vibration cadence, drill-down sheet, and Open-in-Maps.
- Idle-visual note in scoring section.
- START_NOT_STICKY note in architecture.
- Open-app-settings recovery note in permissions section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:32:16 -04:00
16 changed files with 688 additions and 137 deletions
+70 -28
View File
@@ -4,27 +4,31 @@
A native Android (Kotlin) **passive surveillance-detection** app. Open it, hit A native Android (Kotlin) **passive surveillance-detection** app. Open it, hit
**START**, and a circle turns **green / yellow / orange / red** depending on **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 how confident the engine is that there's a Flock Safety ALPR, an Axon body
camera, or police presence near you. camera, or active police presence near you. With the screen locked, the
foreground notification updates with the current tier and the phone vibrates
on upward escalations — you don't have to be looking at the screen.
> **Passive defense only.** OVERWATCH only listens — it does not transmit, > **Passive defense only.** OVERWATCH only listens — it does not transmit,
> probe, jam, or interfere with any device or network. The Axon > probe, jam, or interfere with any device or network. The Axon
> advertise/fuzz code from one of the reference projects is intentionally > advertise/fuzz code from one of the reference projects is intentionally
> excluded. > excluded.
Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
--- ---
## What it detects ## What it detects
| Source | What it looks at | Where it comes from | | Source | What it looks at | Where it comes from |
|---|---|---| |---|---|---|
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API) | | **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. |
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) | | **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. | | **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. | | **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. |
> **Why no Waze?** Waze added reCAPTCHA gating to its `live-map/api/georss` endpoint in 2025/2026. Mobile clients receive HTTP 403, and the only known workarounds (Selenium proxy on a home server, Waze for Cities partner program) aren't viable for a phone-deployed app. Citizen replaces it. > **Why no Waze?** Waze added reCAPTCHA gating to its `live-map/api/georss` endpoint in 2025/2026. Mobile clients receive HTTP 403, and the only known workarounds (Selenium proxy on a home server, Waze for Cities partner program) aren't viable for a phone-deployed app. Citizen replaces it as the police-presence source.
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is Every observation is scored 0100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources: the maximum live score across all sources:
``` ```
@@ -36,7 +40,26 @@ RED 85 + certain
The user-facing circle uses the full 4-tier mapping. Cross-source corroboration 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 naturally pushes the global max upward (a BLE OUI hit *and* a DeFlock map
match in the same area produce a higher tier than either alone). match in the same area produce a higher tier than either alone). When idle,
the circle shows muted gray with `IDLE` text so it's distinguishable at a
glance from "scanning, all clear."
---
## How alerts work
- **In-app**: the threat circle pulses while scanning; tap it to open the
bottom-sheet drill-down with per-source rows. DEFLOCK and CITIZEN events
carry coordinates — each row has a tap-to-open Maps icon (`geo:` intent).
- **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.
--- ---
@@ -44,24 +67,28 @@ match in the same area produce a higher tier than either alone).
``` ```
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
ui/SettingsScreen.kt per-source toggles, distance sliders, theme ui/SettingsScreen.kt source toggles, distance sliders, vibrate, theme
service/DetectionService.kt foreground service — owns scanners + store ui/theme/Theme.kt Material 3 dark/light + threat colors
service/DetectionService.kt foreground service — owns scanners, notification, vibration
scan/BleScanner.kt BLE callback scanner scan/BleScanner.kt BLE callback scanner
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
scan/DeflockClient.kt CDN tile fetch + 24h cache scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
scan/DeflockScanner.kt location-driven proximity check scan/DeflockScanner.kt location-driven proximity check + failure backoff
scan/WazeClient.kt live-map/api/georss bbox fetch scan/CitizenClient.kt GET /api/incident/trending + /api/incident/{id}
scan/WazeScanner.kt 60s poller + age/distance gate scan/CitizenScanner.kt 60 s poller, fire/medical filter, per-id cache
fusion/ConfidenceEngine.kt scoring (one place) fusion/ConfidenceEngine.kt scoring (one place — BLE / WiFi / DeFlock / Citizen)
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
fusion/DetectionStore.kt in-memory dedup, 5-min retention fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down
fusion/ThreatLevel.kt 4-tier enum + DetectionSource enum
data/location/LocationProvider.kt FusedLocationProviderClient wrapper data/location/LocationProvider.kt FusedLocationProviderClient wrapper
data/settings/Settings.kt SharedPreferences-backed StateFlow settings data/settings/Settings.kt SharedPreferences-backed StateFlow settings
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
``` ```
No detection-history database. All state is in-memory and clears on stop, by No detection-history database. All state is in-memory and clears on stop, by
design. design. Service uses `START_NOT_STICKY` — system kill doesn't auto-restart
into a stuck state.
--- ---
@@ -83,7 +110,7 @@ export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
./gradlew :app:installDebug ./gradlew :app:installDebug
``` ```
Or download the latest signed APK from Or download the latest debug-signed APK from
[Releases](https://github.com/KaraZajac/OVERWATCH/releases). [Releases](https://github.com/KaraZajac/OVERWATCH/releases).
--- ---
@@ -94,14 +121,18 @@ Or download the latest signed APK from
|---|---| |---|---|
| `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+) | BLE scanning | | `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+) | BLE scanning |
| `BLUETOOTH`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy | | `BLUETOOTH`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy |
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock proximity | | `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Citizen proximity |
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location | | `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results | | `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock CDN + Waze API | | `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass + Citizen API |
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off | | `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off |
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification | | `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
| `VIBRATE` | Haptic alert on threat-tier escalation |
Requested at runtime when you press START for the first time. Requested at runtime when you press START for the first time. If you
permanently deny a required permission ("don't ask again"), the START button
swaps to **Open app settings** which fires the per-app system-settings page
so you can grant manually.
--- ---
@@ -109,11 +140,14 @@ Requested at runtime when you press START for the first time.
Tap the gear icon in the top-right. Tap the gear icon in the top-right.
- **Detection sources**: toggle BLE / WiFi / DeFlock / Waze independently. Takes - **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen independently.
effect on next Start. Changes take effect on the next Start. While scanning, a **Restart scan to
- **Proximity thresholds**: apply** button appears that does `stop()` + `start()` in one tap.
- **Proximity thresholds** (sliders commit on release, not per-pixel):
- DeFlock: 50 m 1600 m (default 200 m) - DeFlock: 50 m 1600 m (default 200 m)
- Waze: 100 m 5000 m (default 500 m) - Citizen: 100 m 5000 m (default 500 m)
- **Alerts**:
- Vibrate on threat escalation (default on)
- **Appearance**: System / Dark / Light (default Dark) - **Appearance**: System / Dark / Light (default Dark)
--- ---
@@ -125,15 +159,23 @@ These live under `REFERENCES/` (gitignored):
- **AxonCadabra** — BLE scanner skeleton (scan side only; advertise/fuzz code excluded) - **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-detection** — confidence-scoring algorithm (highest reusability), RSSI rise-peak-fall, OUIs + UUIDs + patterns
- **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android) - **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android)
- **deflock** + **deflock-app**CDN tile scheme, proximity-alert pattern - **deflock** + **deflock-app**Overpass query format + proximity-alert pattern (the Flutter app uses Overpass directly, not the CDN tiles, which the OVERWATCH client mirrors)
- **wazepolice** — live-map/api/georss recipe, Chrome header spoofing - **wazepolice** — live-map/api/georss recipe; informed v0.1.0v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated)
--- ---
## Status ## Status
Phases 15 (skeleton, BLE, WiFi, DeFlock, Waze, polish) complete as of v0.1.0. Phases 15 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and
Field-test-ready, not yet field-validated. field-tested. Current release **v0.1.7** addresses two full audit passes
(see release notes for v0.1.2, v0.1.3, v0.1.6). Notable changes since v0.1.0:
- v0.1.2 — Android 14+ foreground service type fix (location was being silently revoked); NaN-coordinate filter on map data.
- v0.1.3 — DeFlock CDN replaced by direct Overpass calls (Cloudflare-blocked).
- v0.1.4 — Citizen.com added as 5th source, per-source health registry.
- v0.1.5 — Waze removed (reCAPTCHA-gated; no clean mobile workaround).
- v0.1.6 — Dynamic notification with tier + label, haptic alerts on escalation, Open-in-Maps for geo events, idle visual differentiated from "scanning, all clear", permanent-deny recovery via Open Settings.
- v0.1.7 — System back from Settings returns to MAIN instead of exiting.
## License ## License
+3 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 8 versionCode = 9
versionName = "0.1.7" versionName = "0.2.0"
} }
buildTypes { buildTypes {
@@ -61,6 +61,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(libs.osmdroid.android)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
} }
@@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.osmdroid.config.Configuration
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.service.DetectionService import org.soulstone.overwatch.service.DetectionService
import org.soulstone.overwatch.ui.MainScreen import org.soulstone.overwatch.ui.MainScreen
@@ -66,6 +67,14 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() permissionsGranted.value = checkAllPermissions()
permanentlyDenied.value = false // reset on activity create permanentlyDenied.value = false // reset on activity create
val settings = Settings.get(this) val settings = Settings.get(this)
@@ -81,6 +90,8 @@ class MainActivity : ComponentActivity() {
val events by DetectionService.store.events.collectAsState() val events by DetectionService.store.events.collectAsState()
val threat by DetectionService.store.threatLevel.collectAsState() val threat by DetectionService.store.threatLevel.collectAsState()
val maxScore by DetectionService.store.maxScore.collectAsState() val maxScore by DetectionService.store.maxScore.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState()
val userLocation by DetectionService.location.collectAsState()
val granted by permissionsGranted val granted by permissionsGranted
val denied by permanentlyDenied val denied by permanentlyDenied
@@ -95,6 +106,8 @@ class MainActivity : ComponentActivity() {
threat = threat, threat = threat,
score = maxScore, score = maxScore,
events = events, events = events,
mapPoints = mapPoints,
userLocation = userLocation,
canStart = true, canStart = true,
permissionMessage = message, permissionMessage = message,
showOpenAppSettings = denied && !granted, showOpenAppSettings = denied && !granted,
@@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true)) private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow() val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
private val _deflockProximityM = MutableStateFlow( private val _deflockProximityM = MutableStateFlow(
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX) prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX)
) )
@@ -54,6 +57,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.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 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 setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
fun setDeflockProximityM(v: Int) { fun setDeflockProximityM(v: Int) {
val clamped = v.coerceIn(50, 1600) val clamped = v.coerceIn(50, 1600)
@@ -83,6 +87,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private const val KEY_WIFI = "src_wifi" private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock" private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_CITIZEN = "src_citizen" private const val KEY_CITIZEN = "src_citizen"
private const val KEY_MIC = "src_mic"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m" private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m" private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_THEME = "theme_mode" private const val KEY_THEME = "theme_mode"
@@ -0,0 +1,153 @@
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 — only well-known smart-home OEMs whose devices
* stay in fixed locations and continuously listen. Apple manufacturer id 0x004C
* is deliberately excluded because every iPhone, AirPod, and Apple Watch
* advertises it; a coffee shop full of phones must not light up the alarm.
*
* Detection vectors collected from public OUI registries (Wireshark/IEEE)
* and device-setup advertisement docs.
*/
object MicTargets {
enum class Family { ECHO, RING, GOOGLE, HIDDEN_CAM }
/** 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
/** 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
)
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
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"
}
}
@@ -38,6 +38,19 @@ object ConfidenceEngine {
const val B_STRONG_RSSI = 10 // > -50 dBm const val B_STRONG_RSSI = 10 // > -50 dBm
const val B_STATIONARY = 15 // RSSI rise-peak-fall 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. */ /** What we observed about one BLE device on a single scan callback. */
data class BleObservation( data class BleObservation(
val mac: String, val mac: String,
@@ -202,6 +215,107 @@ object ConfidenceEngine {
return Scored(score, rangeTag, label, isAxon = false) 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 { fun scoreWifi(obs: WifiObservation): Scored {
var score = 0 var score = 0
val methods = StringBuilder() val methods = StringBuilder()
@@ -27,17 +27,20 @@ object SourceHealth {
private val _wifi = MutableStateFlow(Health()) private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health()) private val _deflock = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health()) private val _citizen = MutableStateFlow(Health())
private val _mic = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow() val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow() val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow() val deflock: StateFlow<Health> = _deflock.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow() val citizen: StateFlow<Health> = _citizen.asStateFlow()
val mic: StateFlow<Health> = _mic.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) { fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock DetectionSource.DEFLOCK -> deflock
DetectionSource.CITIZEN -> citizen DetectionSource.CITIZEN -> citizen
DetectionSource.MIC -> mic
} }
fun record(source: DetectionSource, ok: Boolean, message: String? = null) { fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
@@ -46,6 +49,7 @@ object SourceHealth {
DetectionSource.WIFI -> _wifi DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock DetectionSource.DEFLOCK -> _deflock
DetectionSource.CITIZEN -> _citizen DetectionSource.CITIZEN -> _citizen
DetectionSource.MIC -> _mic
} }
target.value = Health( target.value = Health(
status = if (ok) Status.OK else Status.FAILED, status = if (ok) Status.OK else Status.FAILED,
@@ -59,5 +63,6 @@ object SourceHealth {
_wifi.value = Health() _wifi.value = Health()
_deflock.value = Health() _deflock.value = Health()
_citizen.value = Health() _citizen.value = Health()
_mic.value = Health()
} }
} }
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
} }
/** Logical signal channel — used in the drill-down UI. */ /** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN } enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, MIC }
@@ -14,6 +14,7 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.targets.BleOuis 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.Patterns
import org.soulstone.overwatch.data.targets.RavenUuids import org.soulstone.overwatch.data.targets.RavenUuids
import org.soulstone.overwatch.fusion.ConfidenceEngine import org.soulstone.overwatch.fusion.ConfidenceEngine
@@ -38,7 +39,9 @@ import org.soulstone.overwatch.fusion.SourceHealth
class BleScanner( class BleScanner(
private val context: Context, private val context: Context,
private val store: DetectionStore, private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker() private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
) { ) {
companion object { companion object {
@@ -170,14 +173,19 @@ class BleScanner(
} }
// Cheap pre-filter — drop devices that have zero target signals. // Cheap pre-filter — drop devices that have zero target signals.
val candidate = BleOuis.matches(mac) || val isSurveillance = BleOuis.matches(mac) ||
Patterns.bleNameMatch(name) || Patterns.bleNameMatch(name) ||
Patterns.isPenguinNumeric(name) || Patterns.isPenguinNumeric(name) ||
RavenUuids.countMatches(advertisedUuids) > 0 || RavenUuids.countMatches(advertisedUuids) > 0 ||
companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID
if (!candidate) return val isMic = micEnabled() &&
MicTargets.couldBeMicBle(mac, name, advertisedUuids, companyId)
if (!isSurveillance && !isMic) return
rssi.update(mac, result.rssi) rssi.update(mac, result.rssi)
val stationary = rssi.isStationary(mac)
if (isSurveillance) {
val obs = ConfidenceEngine.BleObservation( val obs = ConfidenceEngine.BleObservation(
mac = mac, mac = mac,
rssi = result.rssi, rssi = result.rssi,
@@ -185,11 +193,10 @@ class BleScanner(
advertisedUuids = advertisedUuids, advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId, manufacturerCompanyId = companyId,
manufacturerPayload = payload, manufacturerPayload = payload,
isStationary = rssi.isStationary(mac) isStationary = stationary
) )
val scored = ConfidenceEngine.scoreBle(obs) val scored = ConfidenceEngine.scoreBle(obs)
if (scored.score < ALARM_THRESHOLD) return if (scored.score >= ALARM_THRESHOLD) {
store.submit( store.submit(
DetectionEvent( DetectionEvent(
source = DetectionSource.BLE, source = DetectionSource.BLE,
@@ -202,3 +209,30 @@ class BleScanner(
) )
} }
} }
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
)
)
}
}
}
}
@@ -4,6 +4,9 @@ import android.location.Location
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job 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.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
@@ -41,7 +44,10 @@ class DeflockScanner(
private var lastFetchLon: Double? = null private var lastFetchLon: Double? = null
private var lastAttemptMs: Long = 0L private var lastAttemptMs: Long = 0L
private var lastAttemptOk: Boolean = false private var lastAttemptOk: Boolean = false
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList() private val _cachedPoints = MutableStateFlow<List<DeflockClient.AlprPoint>>(emptyList())
/** All ALPR points in the current cell — exposed so the UI map can render them.
* Distinct from the proximity-filtered DetectionEvents on [DetectionStore]. */
val cachedPoints: StateFlow<List<DeflockClient.AlprPoint>> = _cachedPoints.asStateFlow()
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
if (job != null) return true if (job != null) return true
@@ -61,7 +67,7 @@ class DeflockScanner(
lastFetchLon = null lastFetchLon = null
lastAttemptMs = 0L lastAttemptMs = 0L
lastAttemptOk = false lastAttemptOk = false
cachedPoints = emptyList() _cachedPoints.value = emptyList()
Log.i(TAG, "DeflockScanner stopped") Log.i(TAG, "DeflockScanner stopped")
} }
@@ -74,12 +80,12 @@ class DeflockScanner(
lastAttemptMs = System.currentTimeMillis() lastAttemptMs = System.currentTimeMillis()
when (val result = client.fetchAround(fix.latitude, fix.longitude)) { when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is DeflockClient.FetchResult.Success -> { is DeflockClient.FetchResult.Success -> {
cachedPoints = result.points _cachedPoints.value = result.points
lastAttemptOk = true lastAttemptOk = true
SourceHealth.record(DetectionSource.DEFLOCK, ok = true) SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i( Log.i(
TAG, TAG,
"Loaded ${cachedPoints.size} ALPRs around " + "Loaded ${result.points.size} ALPRs around " +
"(${fix.latitude}, ${fix.longitude})" "(${fix.latitude}, ${fix.longitude})"
) )
} }
@@ -95,11 +101,12 @@ class DeflockScanner(
} }
} }
} }
if (cachedPoints.isEmpty()) return val points = _cachedPoints.value
if (points.isEmpty()) return
val limit = proximityMeters() val limit = proximityMeters()
val out = FloatArray(1) val out = FloatArray(1)
for (p in cachedPoints) { for (p in points) {
Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out) Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out)
val dist = out[0] val dist = out[0]
if (dist > limit) continue if (dist > limit) continue
@@ -17,6 +17,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.targets.MicTargets
import org.soulstone.overwatch.data.targets.Patterns import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.WifiOuis import org.soulstone.overwatch.data.targets.WifiOuis
import org.soulstone.overwatch.fusion.ConfidenceEngine import org.soulstone.overwatch.fusion.ConfidenceEngine
@@ -40,7 +41,9 @@ import org.soulstone.overwatch.fusion.SourceHealth
class WifiScanner( class WifiScanner(
private val context: Context, private val context: Context,
private val store: DetectionStore, private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker() private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
) { ) {
companion object { companion object {
@@ -167,21 +170,21 @@ class WifiScanner(
val bssid = r.BSSID ?: continue val bssid = r.BSSID ?: continue
val ssid = readSsid(r) val ssid = readSsid(r)
val candidate = WifiOuis.matches(bssid) || val isSurveillance = WifiOuis.matches(bssid) ||
Patterns.ssidGenericMatch(ssid) || Patterns.ssidGenericMatch(ssid) ||
Patterns.ssidFlockFormat(ssid) Patterns.ssidFlockFormat(ssid)
if (!candidate) continue val isMic = micEnabled() && MicTargets.couldBeMicWifi(bssid, ssid)
if (!isSurveillance && !isMic) continue
rssi.update(bssid, r.level) rssi.update(bssid, r.level)
val stationary = rssi.isStationary(bssid)
if (isSurveillance) {
val obs = ConfidenceEngine.WifiObservation( val obs = ConfidenceEngine.WifiObservation(
bssid = bssid, bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
ssid = ssid,
rssi = r.level,
isStationary = rssi.isStationary(bssid)
) )
val scored = ConfidenceEngine.scoreWifi(obs) val scored = ConfidenceEngine.scoreWifi(obs)
if (scored.score < ALARM_THRESHOLD) continue if (scored.score >= ALARM_THRESHOLD) {
store.submit( store.submit(
DetectionEvent( DetectionEvent(
source = DetectionSource.WIFI, source = DetectionSource.WIFI,
@@ -194,6 +197,26 @@ class WifiScanner(
) )
} }
} }
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
)
)
}
}
}
}
private fun readSsid(r: ScanResult): String? { private fun readSsid(r: ScanResult): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.location.Location
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.VibrationEffect import android.os.VibrationEffect
@@ -28,6 +29,7 @@ import org.soulstone.overwatch.R
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
@@ -35,6 +37,7 @@ import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
import org.soulstone.overwatch.scan.WifiScanner import org.soulstone.overwatch.scan.WifiScanner
/** /**
@@ -67,6 +70,15 @@ class DetectionService : LifecycleService() {
private val _running = MutableStateFlow(false) private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow() 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) { fun start(context: Context) {
val intent = Intent(context, DetectionService::class.java).apply { val intent = Intent(context, DetectionService::class.java).apply {
action = ACTION_START action = ACTION_START
@@ -94,6 +106,8 @@ class DetectionService : LifecycleService() {
private lateinit var citizenScanner: CitizenScanner private lateinit var citizenScanner: CitizenScanner
private var pruneJob: Job? = null private var pruneJob: Job? = null
private var observerJob: Job? = null private var observerJob: Job? = null
private var mapPointsJob: Job? = null
private var locationJob: Job? = null
private var bleStarted = false private var bleStarted = false
private var wifiStarted = false private var wifiStarted = false
private var deflockStarted = false private var deflockStarted = false
@@ -104,8 +118,8 @@ class DetectionService : LifecycleService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
settings = Settings.get(this) settings = Settings.get(this)
bleScanner = BleScanner(this, store) bleScanner = BleScanner(this, store, micEnabled = { settings.micEnabled.value })
wifiScanner = WifiScanner(this, store) wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value })
locationProvider = LocationProvider(this) locationProvider = LocationProvider(this)
deflockScanner = DeflockScanner( deflockScanner = DeflockScanner(
store, locationProvider, DeflockClient(this), store, locationProvider, DeflockClient(this),
@@ -169,6 +183,20 @@ class DetectionService : LifecycleService() {
return 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 _running.value = true
pruneJob?.cancel() pruneJob?.cancel()
pruneJob = lifecycleScope.launch { pruneJob = lifecycleScope.launch {
@@ -187,6 +215,20 @@ class DetectionService : LifecycleService() {
onTierChanged(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 }
}
} }
private fun endScanning() { private fun endScanning() {
@@ -203,6 +245,10 @@ class DetectionService : LifecycleService() {
SourceHealth.reset() SourceHealth.reset()
pruneJob?.cancel(); pruneJob = null pruneJob?.cancel(); pruneJob = null
observerJob?.cancel(); observerJob = null observerJob?.cancel(); observerJob = null
mapPointsJob?.cancel(); mapPointsJob = null
locationJob?.cancel(); locationJob = null
_mapPoints.value = emptyList()
_location.value = null
lastNotifiedTier = ThreatLevel.GREEN lastNotifiedTier = ThreatLevel.GREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -1,5 +1,8 @@
package org.soulstone.overwatch.ui 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.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
@@ -19,8 +22,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -51,11 +52,17 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.ui.theme.ThreatColors import org.soulstone.overwatch.ui.theme.ThreatColors
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -65,6 +72,8 @@ fun MainScreen(
threat: ThreatLevel, threat: ThreatLevel,
score: Int, score: Int,
events: List<DetectionEvent>, events: List<DetectionEvent>,
mapPoints: List<DeflockClient.AlprPoint>,
userLocation: Location?,
onStartStop: () -> Unit, onStartStop: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
canStart: Boolean, canStart: Boolean,
@@ -88,22 +97,14 @@ fun MainScreen(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column {
Text( Text(
text = "[DЯΣΛMMΛKΣЯ]", text = "OVERWATCH",
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp, fontSize = 26.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace,
letterSpacing = 4.sp
) )
Text(
text = " . //0VΣЯW4TCH",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
}
IconButton(onClick = onOpenSettings) { IconButton(onClick = onOpenSettings) {
Icon( Icon(
Icons.Filled.Settings, Icons.Filled.Settings,
@@ -119,7 +120,13 @@ fun MainScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true }) ThreatMapCircle(
level = threat,
animating = running,
userLocation = userLocation,
mapPoints = mapPoints,
onTap = { showSheet = true }
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
@@ -207,10 +214,13 @@ fun MainScreen(
} }
@Composable @Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) { private fun ThreatMapCircle(
// When the scanner isn't running, deliberately use a muted color and IDLE level: ThreatLevel,
// text so the user can tell at a glance whether they're scanning. Without animating: Boolean,
// this, idle and "scanning, all clear" both render as solid green. userLocation: Location?,
mapPoints: List<DeflockClient.AlprPoint>,
onTap: () -> Unit
) {
val idleColor = MaterialTheme.colorScheme.surfaceVariant val idleColor = MaterialTheme.colorScheme.surfaceVariant
val activeColor = when (level) { val activeColor = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green ThreatLevel.GREEN -> ThreatColors.Green
@@ -218,13 +228,10 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
ThreatLevel.ORANGE -> ThreatColors.Orange ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red ThreatLevel.RED -> ThreatColors.Red
} }
val color = if (animating) activeColor else idleColor
val labelText = if (animating) level.name else "IDLE"
val labelColor = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "pulse") val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat( val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f, initialValue = if (animating) 0.5f else 1.0f,
targetValue = 1.0f, targetValue = 1.0f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200), animation = tween(durationMillis = 1200),
@@ -232,12 +239,22 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
), ),
label = "pulse" label = "pulse"
) )
val alpha = if (animating) pulse else 1.0f
Box( Box(
modifier = Modifier modifier = Modifier
.size(220.dp) .size(220.dp)
.clip(CircleShape) .clip(CircleShape),
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( .background(
Brush.radialGradient( Brush.radialGradient(
colors = listOf( colors = listOf(
@@ -245,18 +262,94 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
color.copy(alpha = alpha * 0.6f) color.copy(alpha = alpha * 0.6f)
) )
) )
) ),
.clickable(onClick = onTap),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val labelText = when {
!animating -> "IDLE"
else -> "WAITING FIX"
}
Text( Text(
text = labelText, text = labelText,
color = labelColor, color = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 28.sp, fontSize = 22.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
) )
} }
} else {
// OSM map snapshot, centered on the user, with red ALPR pins.
// Non-interactive — touches are captured by the click overlay above
// so a tap opens the source-details bottom sheet (matching the old
// circle's UX). 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
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(false)
setBuiltInZoomControls(false)
isClickable = false
isFocusable = false
controller.setZoom(17.0)
}
},
update = { map ->
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
map.overlays.clear()
for (p in mapPoints) {
val m = Marker(map).apply {
position = GeoPoint(p.lat, p.lon)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = p.operator ?: p.manufacturer ?: "ALPR"
// Disable osmdroid's per-marker info popup since
// the map isn't interactive — the bottom sheet is
// the canonical "details" surface.
setInfoWindow(null)
}
map.overlays.add(m)
}
map.invalidate()
},
onRelease = { map -> map.onDetach() }
)
// Threat-tier scrim — pulses while scanning, dims tiles to keep
// the dark theme aesthetic and signals tier without text.
val scrimAlpha = (0.35f * pulse).coerceIn(0.18f, 0.5f)
Box(
modifier = Modifier
.fillMaxSize()
.background(activeColor.copy(alpha = scrimAlpha))
)
// Tier label, top-center. Smaller than the old text so the map
// remains readable underneath.
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 14.dp),
contentAlignment = Alignment.TopCenter
) {
Text(
text = level.name,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace,
letterSpacing = 2.sp
)
}
}
// Click capture sits on top so taps reach onTap regardless of which
// visual layer was painted underneath.
Box(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onTap)
)
}
} }
@Composable @Composable
@@ -390,10 +483,23 @@ private fun EventRow(e: DetectionEvent) {
if (e.hasGeo) { if (e.hasGeo) {
IconButton( IconButton(
onClick = { onClick = {
// resolveActivity returns null on Android 11+ without a matching
// <queries> entry even when Google Maps is installed. Skip the
// pre-check and let startActivity handle it; catch the rare
// "no app at all" case instead of silently no-op'ing.
val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})") val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(ctx.packageManager) != null) { try {
ctx.startActivity(intent) ctx.startActivity(intent)
} catch (_: android.content.ActivityNotFoundException) {
// Fall back to a Google Maps URL — works even on devices
// without a registered geo: handler.
val webUri = Uri.parse(
"https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}"
)
val webIntent = Intent(Intent.ACTION_VIEW, webUri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { ctx.startActivity(webIntent) } catch (_: android.content.ActivityNotFoundException) {}
} }
}, },
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
@@ -49,6 +49,7 @@ fun SettingsScreen(
val wifi by settings.wifiEnabled.collectAsState() val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState() val deflock by settings.deflockEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState() val citizen by settings.citizenEnabled.collectAsState()
val mic by settings.micEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState() val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState() val citizenProx by settings.citizenProximityM.collectAsState()
val theme by settings.themeMode.collectAsState() val theme by settings.themeMode.collectAsState()
@@ -82,6 +83,7 @@ fun SettingsScreen(
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) } SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) } SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("MIC • Smart speakers / cams (Echo, Ring, Nest)", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (isRunning) { if (isRunning) {
Button( Button(
+2 -4
View File
@@ -1,14 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">[DЯΣΛMMΛKΣЯ] OVERWATCH</string> <string name="app_name">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_idle">Idle — press START to begin scanning</string>
<string name="status_scanning_clear">All clear</string> <string name="status_scanning_clear">All clear</string>
<string name="status_scanning">Scanning…</string> <string name="status_scanning">Scanning…</string>
<string name="action_start">START</string> <string name="action_start">START</string>
<string name="action_stop">STOP</string> <string name="action_stop">STOP</string>
<string name="notification_channel_name">DREAMMAKER / OVERWATCH detection</string> <string name="notification_channel_name">OVERWATCH detection</string>
<string name="notification_channel_desc">Foreground notification while scanning</string> <string name="notification_channel_desc">Foreground notification while scanning</string>
<string name="notification_title">OVERWATCH active</string> <string name="notification_title">OVERWATCH active</string>
<string name="notification_text">Scanning for nearby surveillance</string> <string name="notification_text">Scanning for nearby surveillance</string>
+2
View File
@@ -7,6 +7,7 @@ activityCompose = "1.9.3"
composeBom = "2024.12.01" composeBom = "2024.12.01"
material3 = "1.3.1" material3 = "1.3.1"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
osmdroid = "6.1.20"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -21,6 +22,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }