Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ad4b89e66 | |||
| 2a2eb9f4af | |||
| 9ffe5a9d9f | |||
| cf62feae60 | |||
| e8ebcfbb41 |
@@ -4,27 +4,31 @@
|
||||
A native Android (Kotlin) **passive surveillance-detection** app. Open it, hit
|
||||
**START**, and a circle turns **green / yellow / orange / red** depending on
|
||||
how confident the engine is that there's a Flock Safety ALPR, an Axon body
|
||||
camera, or police presence near you.
|
||||
camera, or active police presence near you. With the screen locked, the
|
||||
foreground notification updates with the current tier and the phone vibrates
|
||||
on upward escalations — you don't have to be looking at the screen.
|
||||
|
||||
> **Passive defense only.** OVERWATCH only listens — it does not transmit,
|
||||
> probe, jam, or interfere with any device or network. The Axon
|
||||
> advertise/fuzz code from one of the reference projects is intentionally
|
||||
> excluded.
|
||||
|
||||
Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload).
|
||||
|
||||
---
|
||||
|
||||
## What it detects
|
||||
|
||||
| Source | What it looks at | Where it comes from |
|
||||
|---|---|---|
|
||||
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API) |
|
||||
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. |
|
||||
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
|
||||
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | 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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
> **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 0–100 by `ConfidenceEngine`. The on-screen tier is
|
||||
the maximum live score across all sources:
|
||||
|
||||
```
|
||||
@@ -36,32 +40,55 @@ RED 85 + certain
|
||||
|
||||
The user-facing circle uses the full 4-tier mapping. Cross-source corroboration
|
||||
naturally pushes the global max upward (a BLE OUI hit *and* a DeFlock map
|
||||
match in the same area produce a higher tier than either alone).
|
||||
match in the same area produce a higher tier than either alone). When idle,
|
||||
the circle shows muted gray with `IDLE` text so it's distinguishable at a
|
||||
glance from "scanning, all clear."
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
|
||||
ui/SettingsScreen.kt per-source toggles, distance sliders, theme
|
||||
service/DetectionService.kt foreground service — owns scanners + store
|
||||
scan/BleScanner.kt BLE callback scanner
|
||||
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
|
||||
scan/DeflockClient.kt CDN tile fetch + 24h cache
|
||||
scan/DeflockScanner.kt location-driven proximity check
|
||||
scan/WazeClient.kt live-map/api/georss bbox fetch
|
||||
scan/WazeScanner.kt 60s poller + age/distance gate
|
||||
fusion/ConfidenceEngine.kt scoring (one place)
|
||||
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
|
||||
fusion/DetectionStore.kt in-memory dedup, 5-min retention
|
||||
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
|
||||
ui/SettingsScreen.kt source toggles, distance sliders, vibrate, theme
|
||||
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/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
|
||||
fusion/ConfidenceEngine.kt scoring (one place — BLE / WiFi / DeFlock / Citizen)
|
||||
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
|
||||
fusion/DetectionStore.kt in-memory dedup, 5-min retention, max-tier flow
|
||||
fusion/SourceHealth.kt per-source OK/FAILED registry for the drill-down
|
||||
fusion/ThreatLevel.kt 4-tier enum + DetectionSource enum
|
||||
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
|
||||
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
|
||||
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
|
||||
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
|
||||
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Or download the latest signed APK from
|
||||
Or download the latest debug-signed APK from
|
||||
[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`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy |
|
||||
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock proximity |
|
||||
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock/Citizen proximity |
|
||||
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
|
||||
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
|
||||
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock CDN + Waze API |
|
||||
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock Overpass + Citizen API |
|
||||
| `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 |
|
||||
|
||||
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.
|
||||
|
||||
- **Detection sources**: toggle BLE / WiFi / DeFlock / Waze independently. Takes
|
||||
effect on next Start.
|
||||
- **Proximity thresholds**:
|
||||
- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen independently.
|
||||
Changes take effect on the next Start. While scanning, a **Restart scan to
|
||||
apply** button appears that does `stop()` + `start()` in one tap.
|
||||
- **Proximity thresholds** (sliders commit on release, not per-pixel):
|
||||
- DeFlock: 50 m – 1600 m (default 200 m)
|
||||
- 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)
|
||||
|
||||
---
|
||||
@@ -125,15 +159,23 @@ These live under `REFERENCES/` (gitignored):
|
||||
- **AxonCadabra** — BLE scanner skeleton (scan side only; advertise/fuzz code excluded)
|
||||
- **flock-detection** — confidence-scoring algorithm (highest reusability), RSSI rise-peak-fall, OUIs + UUIDs + patterns
|
||||
- **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android)
|
||||
- **deflock** + **deflock-app** — CDN tile scheme, proximity-alert pattern
|
||||
- **wazepolice** — live-map/api/georss recipe, Chrome header spoofing
|
||||
- **deflock** + **deflock-app** — Overpass query format + proximity-alert pattern (the Flutter app uses Overpass directly, not the CDN tiles, which the OVERWATCH client mirrors)
|
||||
- **wazepolice** — live-map/api/georss recipe; informed v0.1.0–v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated)
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, Waze, polish) complete as of v0.1.0.
|
||||
Field-test-ready, not yet field-validated.
|
||||
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, Citizen, polish) complete and
|
||||
field-tested. Current release **v0.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
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "org.soulstone.overwatch"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 7
|
||||
versionName = "0.1.6"
|
||||
versionCode = 11
|
||||
versionName = "0.2.2"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -61,6 +61,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
implementation(libs.play.services.location)
|
||||
implementation(libs.osmdroid.android)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
|
||||
<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"
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.soulstone.overwatch.data.settings.Settings
|
||||
import org.soulstone.overwatch.service.DetectionService
|
||||
import org.soulstone.overwatch.ui.MainScreen
|
||||
@@ -65,6 +67,14 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
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)
|
||||
@@ -80,6 +90,16 @@ 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 mapRadiusM = maxOf(deflockProx, citizenProx).toFloat()
|
||||
val granted by permissionsGranted
|
||||
val denied by permanentlyDenied
|
||||
|
||||
@@ -94,6 +114,9 @@ class MainActivity : ComponentActivity() {
|
||||
threat = threat,
|
||||
score = maxScore,
|
||||
events = events,
|
||||
mapPoints = mapPoints,
|
||||
userLocation = userLocation,
|
||||
mapRadiusMeters = mapRadiusM,
|
||||
canStart = true,
|
||||
permissionMessage = message,
|
||||
showOpenAppSettings = denied && !granted,
|
||||
@@ -115,6 +138,10 @@ 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,
|
||||
|
||||
@@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
|
||||
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(
|
||||
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 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 setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
|
||||
|
||||
fun setDeflockProximityM(v: Int) {
|
||||
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_DEFLOCK = "src_deflock"
|
||||
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_CITIZEN_PROX = "citizen_proximity_m"
|
||||
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_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,
|
||||
@@ -202,6 +215,107 @@ object ConfidenceEngine {
|
||||
return Scored(score, rangeTag, label, isAxon = false)
|
||||
}
|
||||
|
||||
/** A BLE mic-bearing-device observation, score-capped at ORANGE. */
|
||||
data class MicBleObservation(
|
||||
val mac: String,
|
||||
val rssi: Int,
|
||||
val deviceName: String?,
|
||||
val advertisedUuids: List<java.util.UUID>?,
|
||||
val manufacturerCompanyId: Int?,
|
||||
val isStationary: Boolean
|
||||
)
|
||||
|
||||
/** A WiFi mic-bearing-device observation, score-capped at ORANGE. */
|
||||
data class MicWifiObservation(
|
||||
val bssid: String,
|
||||
val ssid: String?,
|
||||
val rssi: Int,
|
||||
val isStationary: Boolean
|
||||
)
|
||||
|
||||
fun scoreMicBle(obs: MicBleObservation): Scored {
|
||||
var score = 0
|
||||
var methodCount = 0
|
||||
val methods = StringBuilder()
|
||||
val ouiFamily = org.soulstone.overwatch.data.targets.MicTargets.matchOui(obs.mac)
|
||||
if (ouiFamily != null) {
|
||||
score += W_MIC_OUI
|
||||
methods.append("mic_oui ")
|
||||
methodCount++
|
||||
}
|
||||
val nameMatch = org.soulstone.overwatch.data.targets.MicTargets.matchBleName(obs.deviceName)
|
||||
if (nameMatch != null) {
|
||||
score += W_MIC_NAME
|
||||
methods.append("mic_name ")
|
||||
methodCount++
|
||||
}
|
||||
val mfgFamily = org.soulstone.overwatch.data.targets.MicTargets.matchManufacturer(obs.manufacturerCompanyId)
|
||||
if (mfgFamily != null) {
|
||||
score += W_MIC_MFG
|
||||
methods.append("mic_mfg ")
|
||||
methodCount++
|
||||
}
|
||||
if (org.soulstone.overwatch.data.targets.MicTargets.matchAvsService(obs.advertisedUuids)) {
|
||||
score += W_MIC_AVS_UUID
|
||||
methods.append("mic_avs ")
|
||||
methodCount++
|
||||
}
|
||||
if (methodCount >= 2) {
|
||||
score += B_MIC_MULTI
|
||||
methods.append("multi ")
|
||||
}
|
||||
if (obs.rssi > -50) {
|
||||
score += B_MIC_STRONG_RSSI
|
||||
methods.append("strong_rssi ")
|
||||
}
|
||||
if (obs.isStationary) {
|
||||
score += B_MIC_STATIONARY
|
||||
methods.append("stationary ")
|
||||
}
|
||||
score = score.coerceAtMost(MIC_SCORE_CAP)
|
||||
val family = nameMatch?.family ?: ouiFamily ?: mfgFamily
|
||||
?: org.soulstone.overwatch.data.targets.MicTargets.Family.HIDDEN_CAM
|
||||
val familyLabel = org.soulstone.overwatch.data.targets.MicTargets.familyLabel(family)
|
||||
val nameSuffix = if (!obs.deviceName.isNullOrBlank()) " — ${obs.deviceName}" else ""
|
||||
return Scored(score, methods.toString().trim(), "$familyLabel$nameSuffix (${obs.mac})", isAxon = false)
|
||||
}
|
||||
|
||||
fun scoreMicWifi(obs: MicWifiObservation): Scored {
|
||||
var score = 0
|
||||
var methodCount = 0
|
||||
val methods = StringBuilder()
|
||||
val ouiFamily = org.soulstone.overwatch.data.targets.MicTargets.matchOui(obs.bssid)
|
||||
if (ouiFamily != null) {
|
||||
score += W_MIC_OUI
|
||||
methods.append("mic_oui ")
|
||||
methodCount++
|
||||
}
|
||||
val ssidMatch = org.soulstone.overwatch.data.targets.MicTargets.matchSsid(obs.ssid)
|
||||
if (ssidMatch != null) {
|
||||
score += W_MIC_SSID
|
||||
methods.append("mic_ssid ")
|
||||
methodCount++
|
||||
}
|
||||
if (methodCount >= 2) {
|
||||
score += B_MIC_MULTI
|
||||
methods.append("multi ")
|
||||
}
|
||||
if (obs.rssi > -50) {
|
||||
score += B_MIC_STRONG_RSSI
|
||||
methods.append("strong_rssi ")
|
||||
}
|
||||
if (obs.isStationary) {
|
||||
score += B_MIC_STATIONARY
|
||||
methods.append("stationary ")
|
||||
}
|
||||
score = score.coerceAtMost(MIC_SCORE_CAP)
|
||||
val family = ssidMatch?.family ?: ouiFamily
|
||||
?: org.soulstone.overwatch.data.targets.MicTargets.Family.HIDDEN_CAM
|
||||
val familyLabel = org.soulstone.overwatch.data.targets.MicTargets.familyLabel(family)
|
||||
val ssidSuffix = if (!obs.ssid.isNullOrBlank()) " — ${obs.ssid}" else ""
|
||||
return Scored(score, methods.toString().trim(), "$familyLabel$ssidSuffix (${obs.bssid})", isAxon = false)
|
||||
}
|
||||
|
||||
fun scoreWifi(obs: WifiObservation): Scored {
|
||||
var score = 0
|
||||
val methods = StringBuilder()
|
||||
|
||||
@@ -44,6 +44,17 @@ class DetectionStore(
|
||||
_maxScore.value = 0
|
||||
}
|
||||
|
||||
/** Drop every event from a single source — used when a proximity threshold
|
||||
* changes and the owning scanner needs to re-emit a fresh slate (events
|
||||
* outside the new radius would otherwise linger until their 5-min TTL). */
|
||||
@Synchronized
|
||||
fun clearSource(source: DetectionSource) {
|
||||
val remaining = _events.value.filter { it.source != source }
|
||||
if (remaining.size == _events.value.size) return
|
||||
_events.value = remaining
|
||||
recompute(remaining)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pruneExpired() {
|
||||
val cutoff = nowMs() - retentionMs
|
||||
|
||||
@@ -27,17 +27,20 @@ object SourceHealth {
|
||||
private val _wifi = MutableStateFlow(Health())
|
||||
private val _deflock = MutableStateFlow(Health())
|
||||
private val _citizen = 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 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.MIC -> mic
|
||||
}
|
||||
|
||||
fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
|
||||
@@ -46,6 +49,7 @@ object SourceHealth {
|
||||
DetectionSource.WIFI -> _wifi
|
||||
DetectionSource.DEFLOCK -> _deflock
|
||||
DetectionSource.CITIZEN -> _citizen
|
||||
DetectionSource.MIC -> _mic
|
||||
}
|
||||
target.value = Health(
|
||||
status = if (ok) Status.OK else Status.FAILED,
|
||||
@@ -59,5 +63,6 @@ object SourceHealth {
|
||||
_wifi.value = Health()
|
||||
_deflock.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. */
|
||||
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 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
|
||||
@@ -38,7 +39,9 @@ import org.soulstone.overwatch.fusion.SourceHealth
|
||||
class BleScanner(
|
||||
private val context: Context,
|
||||
private val store: DetectionStore,
|
||||
private val rssi: RssiTracker = RssiTracker()
|
||||
private val rssi: RssiTracker = RssiTracker(),
|
||||
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
|
||||
private val micEnabled: () -> Boolean = { false }
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -170,35 +173,66 @@ class BleScanner(
|
||||
}
|
||||
|
||||
// Cheap pre-filter — drop devices that have zero target signals.
|
||||
val candidate = BleOuis.matches(mac) ||
|
||||
val isSurveillance = BleOuis.matches(mac) ||
|
||||
Patterns.bleNameMatch(name) ||
|
||||
Patterns.isPenguinNumeric(name) ||
|
||||
RavenUuids.countMatches(advertisedUuids) > 0 ||
|
||||
companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID
|
||||
if (!candidate) return
|
||||
val isMic = micEnabled() &&
|
||||
MicTargets.couldBeMicBle(mac, name, advertisedUuids, companyId)
|
||||
if (!isSurveillance && !isMic) return
|
||||
|
||||
rssi.update(mac, result.rssi)
|
||||
val obs = ConfidenceEngine.BleObservation(
|
||||
mac = mac,
|
||||
rssi = result.rssi,
|
||||
deviceName = name,
|
||||
advertisedUuids = advertisedUuids,
|
||||
manufacturerCompanyId = companyId,
|
||||
manufacturerPayload = payload,
|
||||
isStationary = rssi.isStationary(mac)
|
||||
)
|
||||
val scored = ConfidenceEngine.scoreBle(obs)
|
||||
if (scored.score < ALARM_THRESHOLD) return
|
||||
val stationary = rssi.isStationary(mac)
|
||||
|
||||
store.submit(
|
||||
DetectionEvent(
|
||||
source = DetectionSource.BLE,
|
||||
key = mac,
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
matchedMethods = scored.methods,
|
||||
rssi = result.rssi
|
||||
if (isSurveillance) {
|
||||
val obs = ConfidenceEngine.BleObservation(
|
||||
mac = mac,
|
||||
rssi = result.rssi,
|
||||
deviceName = name,
|
||||
advertisedUuids = advertisedUuids,
|
||||
manufacturerCompanyId = companyId,
|
||||
manufacturerPayload = payload,
|
||||
isStationary = stationary
|
||||
)
|
||||
)
|
||||
val scored = ConfidenceEngine.scoreBle(obs)
|
||||
if (scored.score >= ALARM_THRESHOLD) {
|
||||
store.submit(
|
||||
DetectionEvent(
|
||||
source = DetectionSource.BLE,
|
||||
key = mac,
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
matchedMethods = scored.methods,
|
||||
rssi = result.rssi
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isMic) {
|
||||
val obs = ConfidenceEngine.MicBleObservation(
|
||||
mac = mac,
|
||||
rssi = result.rssi,
|
||||
deviceName = name,
|
||||
advertisedUuids = advertisedUuids,
|
||||
manufacturerCompanyId = companyId,
|
||||
isStationary = stationary
|
||||
)
|
||||
val scored = ConfidenceEngine.scoreMicBle(obs)
|
||||
if (scored.score >= ALARM_THRESHOLD) {
|
||||
store.submit(
|
||||
DetectionEvent(
|
||||
source = DetectionSource.MIC,
|
||||
// Disambiguate from any BLE event on the same MAC so the
|
||||
// store's (source, key) dedup doesn't collide.
|
||||
key = "mic:$mac",
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
matchedMethods = scored.methods,
|
||||
rssi = result.rssi
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,15 +99,36 @@ class CitizenScanner(
|
||||
// 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 (id in ids) {
|
||||
val incident = incidentCache[id] ?: client.fetchIncident(id)?.also {
|
||||
incidentCache[id] = it
|
||||
} ?: continue
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -4,6 +4,9 @@ import android.location.Location
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.soulstone.overwatch.data.location.LocationProvider
|
||||
@@ -41,7 +44,10 @@ class DeflockScanner(
|
||||
private var lastFetchLon: Double? = null
|
||||
private var lastAttemptMs: Long = 0L
|
||||
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 {
|
||||
if (job != null) return true
|
||||
@@ -61,7 +67,7 @@ class DeflockScanner(
|
||||
lastFetchLon = null
|
||||
lastAttemptMs = 0L
|
||||
lastAttemptOk = false
|
||||
cachedPoints = emptyList()
|
||||
_cachedPoints.value = emptyList()
|
||||
Log.i(TAG, "DeflockScanner stopped")
|
||||
}
|
||||
|
||||
@@ -74,12 +80,12 @@ class DeflockScanner(
|
||||
lastAttemptMs = System.currentTimeMillis()
|
||||
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
|
||||
is DeflockClient.FetchResult.Success -> {
|
||||
cachedPoints = result.points
|
||||
_cachedPoints.value = result.points
|
||||
lastAttemptOk = true
|
||||
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Loaded ${cachedPoints.size} ALPRs around " +
|
||||
"Loaded ${result.points.size} ALPRs around " +
|
||||
"(${fix.latitude}, ${fix.longitude})"
|
||||
)
|
||||
}
|
||||
@@ -95,11 +101,33 @@ class DeflockScanner(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cachedPoints.isEmpty()) return
|
||||
emitProximityEvents(fix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluate the cached ALPRs against the current proximity threshold and
|
||||
* the latest fix, *without* a network refetch. Used when the user moves the
|
||||
* proximity slider — the slider changes [proximityMeters], but the scanner
|
||||
* is otherwise idle (no new location ticks while stationary), so events
|
||||
* outside the new radius would otherwise linger and detections inside the
|
||||
* widened radius wouldn't appear until the next handleFix cycle.
|
||||
*
|
||||
* Clears the DEFLOCK source from the store first so events that fall
|
||||
* outside a tightened radius disappear immediately.
|
||||
*/
|
||||
fun refresh() {
|
||||
val fix = locationProvider.location.value ?: return
|
||||
store.clearSource(DetectionSource.DEFLOCK)
|
||||
emitProximityEvents(fix)
|
||||
}
|
||||
|
||||
private fun emitProximityEvents(fix: Location) {
|
||||
val points = _cachedPoints.value
|
||||
if (points.isEmpty()) return
|
||||
|
||||
val limit = proximityMeters()
|
||||
val out = FloatArray(1)
|
||||
for (p in cachedPoints) {
|
||||
for (p in points) {
|
||||
Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out)
|
||||
val dist = out[0]
|
||||
if (dist > limit) continue
|
||||
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.soulstone.overwatch.data.targets.MicTargets
|
||||
import org.soulstone.overwatch.data.targets.Patterns
|
||||
import org.soulstone.overwatch.data.targets.WifiOuis
|
||||
import org.soulstone.overwatch.fusion.ConfidenceEngine
|
||||
@@ -40,7 +41,9 @@ import org.soulstone.overwatch.fusion.SourceHealth
|
||||
class WifiScanner(
|
||||
private val context: Context,
|
||||
private val store: DetectionStore,
|
||||
private val rssi: RssiTracker = RssiTracker()
|
||||
private val rssi: RssiTracker = RssiTracker(),
|
||||
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
|
||||
private val micEnabled: () -> Boolean = { false }
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -167,31 +170,51 @@ class WifiScanner(
|
||||
val bssid = r.BSSID ?: continue
|
||||
val ssid = readSsid(r)
|
||||
|
||||
val candidate = WifiOuis.matches(bssid) ||
|
||||
val isSurveillance = WifiOuis.matches(bssid) ||
|
||||
Patterns.ssidGenericMatch(ssid) ||
|
||||
Patterns.ssidFlockFormat(ssid)
|
||||
if (!candidate) continue
|
||||
val isMic = micEnabled() && MicTargets.couldBeMicWifi(bssid, ssid)
|
||||
if (!isSurveillance && !isMic) continue
|
||||
|
||||
rssi.update(bssid, r.level)
|
||||
val obs = ConfidenceEngine.WifiObservation(
|
||||
bssid = bssid,
|
||||
ssid = ssid,
|
||||
rssi = r.level,
|
||||
isStationary = rssi.isStationary(bssid)
|
||||
)
|
||||
val scored = ConfidenceEngine.scoreWifi(obs)
|
||||
if (scored.score < ALARM_THRESHOLD) continue
|
||||
val stationary = rssi.isStationary(bssid)
|
||||
|
||||
store.submit(
|
||||
DetectionEvent(
|
||||
source = DetectionSource.WIFI,
|
||||
key = bssid,
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
matchedMethods = scored.methods,
|
||||
rssi = r.level
|
||||
if (isSurveillance) {
|
||||
val obs = ConfidenceEngine.WifiObservation(
|
||||
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
|
||||
)
|
||||
)
|
||||
val scored = ConfidenceEngine.scoreWifi(obs)
|
||||
if (scored.score >= ALARM_THRESHOLD) {
|
||||
store.submit(
|
||||
DetectionEvent(
|
||||
source = DetectionSource.WIFI,
|
||||
key = bssid,
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
matchedMethods = scored.methods,
|
||||
rssi = r.level
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isMic) {
|
||||
val obs = ConfidenceEngine.MicWifiObservation(
|
||||
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
|
||||
)
|
||||
val scored = ConfidenceEngine.scoreMicWifi(obs)
|
||||
if (scored.score >= ALARM_THRESHOLD) {
|
||||
store.submit(
|
||||
DetectionEvent(
|
||||
source = DetectionSource.MIC,
|
||||
key = "mic:$bssid",
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
matchedMethods = scored.methods,
|
||||
rssi = r.level
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -22,12 +23,14 @@ 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
|
||||
@@ -35,6 +38,7 @@ 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.WifiScanner
|
||||
|
||||
/**
|
||||
@@ -67,6 +71,15 @@ class DetectionService : LifecycleService() {
|
||||
private val _running = MutableStateFlow(false)
|
||||
val running: StateFlow<Boolean> = _running.asStateFlow()
|
||||
|
||||
/** Latest ALPR cell cache — UI map renders these as pins. Mirrored from
|
||||
* the active DeflockScanner while the service is running; cleared on stop. */
|
||||
private val _mapPoints = MutableStateFlow<List<AlprPoint>>(emptyList())
|
||||
val mapPoints: StateFlow<List<AlprPoint>> = _mapPoints.asStateFlow()
|
||||
|
||||
/** Latest fused location fix — UI map centers on this. */
|
||||
private val _location = MutableStateFlow<Location?>(null)
|
||||
val location: StateFlow<Location?> = _location.asStateFlow()
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, DetectionService::class.java).apply {
|
||||
action = ACTION_START
|
||||
@@ -94,6 +107,10 @@ class DetectionService : LifecycleService() {
|
||||
private lateinit var citizenScanner: CitizenScanner
|
||||
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 bleStarted = false
|
||||
private var wifiStarted = false
|
||||
private var deflockStarted = false
|
||||
@@ -104,8 +121,8 @@ class DetectionService : LifecycleService() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settings = Settings.get(this)
|
||||
bleScanner = BleScanner(this, store)
|
||||
wifiScanner = WifiScanner(this, store)
|
||||
bleScanner = BleScanner(this, store, micEnabled = { settings.micEnabled.value })
|
||||
wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value })
|
||||
locationProvider = LocationProvider(this)
|
||||
deflockScanner = DeflockScanner(
|
||||
store, locationProvider, DeflockClient(this),
|
||||
@@ -169,6 +186,20 @@ class DetectionService : LifecycleService() {
|
||||
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 {
|
||||
@@ -187,6 +218,36 @@ class DetectionService : LifecycleService() {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endScanning() {
|
||||
@@ -203,6 +264,12 @@ class DetectionService : LifecycleService() {
|
||||
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
|
||||
_mapPoints.value = emptyList()
|
||||
_location.value = null
|
||||
lastNotifiedTier = ThreatLevel.GREEN
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -19,8 +22,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.filled.Place
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -40,6 +41,7 @@ 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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -51,11 +53,18 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlin.math.cos
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.soulstone.overwatch.fusion.DetectionEvent
|
||||
import org.soulstone.overwatch.fusion.DetectionSource
|
||||
import org.soulstone.overwatch.fusion.SourceHealth
|
||||
import org.soulstone.overwatch.fusion.ThreatLevel
|
||||
import org.soulstone.overwatch.scan.DeflockClient
|
||||
import org.soulstone.overwatch.ui.theme.ThreatColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -65,6 +74,12 @@ 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,
|
||||
@@ -81,30 +96,26 @@ fun MainScreen(
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Row(
|
||||
// Box (rather than Row + SpaceBetween) so the title is truly centered
|
||||
// regardless of the gear icon's width.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "[DЯΣΛMMΛKΣЯ]",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = " . //0VΣЯW4TCH",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Text(
|
||||
text = "OVERWATCH",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = 4.sp,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Settings,
|
||||
contentDescription = "Settings",
|
||||
@@ -119,7 +130,14 @@ fun MainScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true })
|
||||
ThreatMapCircle(
|
||||
level = threat,
|
||||
animating = running,
|
||||
userLocation = userLocation,
|
||||
mapPoints = mapPoints,
|
||||
mapRadiusMeters = mapRadiusMeters,
|
||||
onTap = { showSheet = true }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
@@ -206,11 +224,39 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
private fun dotDrawable(
|
||||
resources: android.content.res.Resources,
|
||||
sizePx: Int,
|
||||
coreColor: Int
|
||||
): android.graphics.drawable.BitmapDrawable {
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(
|
||||
sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = android.graphics.Canvas(bitmap)
|
||||
val outline = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
}
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, outline)
|
||||
val core = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = coreColor
|
||||
}
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 4f, core)
|
||||
return android.graphics.drawable.BitmapDrawable(resources, bitmap)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
|
||||
// When the scanner isn't running, deliberately use a muted color and IDLE
|
||||
// text so the user can tell at a glance whether they're scanning. Without
|
||||
// this, idle and "scanning, all clear" both render as solid green.
|
||||
private fun ThreatMapCircle(
|
||||
level: ThreatLevel,
|
||||
animating: Boolean,
|
||||
userLocation: Location?,
|
||||
mapPoints: List<DeflockClient.AlprPoint>,
|
||||
mapRadiusMeters: Float,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
val idleColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
val activeColor = when (level) {
|
||||
ThreatLevel.GREEN -> ThreatColors.Green
|
||||
@@ -218,13 +264,10 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
|
||||
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||
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 pulse by transition.animateFloat(
|
||||
initialValue = if (animating) 0.6f else 1.0f,
|
||||
initialValue = if (animating) 0.5f else 1.0f,
|
||||
targetValue = 1.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200),
|
||||
@@ -232,29 +275,129 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
|
||||
),
|
||||
label = "pulse"
|
||||
)
|
||||
val alpha = if (animating) pulse else 1.0f
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
color.copy(alpha = alpha),
|
||||
color.copy(alpha = alpha * 0.6f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(onClick = onTap),
|
||||
.clip(CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = labelText,
|
||||
color = labelColor,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontFamily = FontFamily.Monospace
|
||||
// While idle OR before the first location fix arrives, fall back to the
|
||||
// solid pulsing circle — a blank/loading map mid-tile-fetch reads as
|
||||
// broken. The map only renders once we actually have something to show.
|
||||
if (!animating || userLocation == null) {
|
||||
val color = if (animating) activeColor else idleColor
|
||||
val alpha = if (animating) pulse else 1.0f
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
color.copy(alpha = alpha),
|
||||
color.copy(alpha = alpha * 0.6f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val labelText = when {
|
||||
!animating -> "IDLE"
|
||||
else -> "WAITING FIX"
|
||||
}
|
||||
Text(
|
||||
text = labelText,
|
||||
color = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// OSM map snapshot, centered on the user, with red ALPR pins and
|
||||
// a blue user-position dot. Non-interactive — touches are captured
|
||||
// by the click overlay above, so a tap opens the source-details
|
||||
// bottom sheet. Pan/zoom controls stay off.
|
||||
// Capture into a local non-null val so the AndroidView update
|
||||
// lambda doesn't run afoul of smart-cast-into-closure rules.
|
||||
val fix: Location = userLocation
|
||||
val ctx = LocalContext.current
|
||||
// Build the marker drawables once per Composition rather than
|
||||
// every recomposition — bitmap allocation isn't free.
|
||||
val userDot = remember(ctx) { dotDrawable(ctx.resources, 36, 0xFF2196F3.toInt()) }
|
||||
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, 0xFFD7263D.toInt()) }
|
||||
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()
|
||||
|
||||
// ALPR dots first, user dot last so the user 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
map.overlays.add(
|
||||
Marker(map).apply {
|
||||
position = GeoPoint(fix.latitude, fix.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = userDot
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -302,6 +445,14 @@ 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()
|
||||
@@ -321,7 +472,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = source.name,
|
||||
text = source.displayLabel(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -390,10 +541,22 @@ private fun EventRow(e: DetectionEvent) {
|
||||
if (e.hasGeo) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
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)
|
||||
if (intent.resolveActivity(ctx.packageManager) != null) {
|
||||
ctx.startActivity(intent)
|
||||
// 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)
|
||||
|
||||
@@ -49,6 +49,7 @@ fun SettingsScreen(
|
||||
val wifi by settings.wifiEnabled.collectAsState()
|
||||
val deflock by settings.deflockEnabled.collectAsState()
|
||||
val citizen by settings.citizenEnabled.collectAsState()
|
||||
val mic by settings.micEnabled.collectAsState()
|
||||
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||
val theme by settings.themeMode.collectAsState()
|
||||
@@ -82,6 +83,7 @@ fun SettingsScreen(
|
||||
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("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (isRunning) {
|
||||
Button(
|
||||
@@ -165,11 +167,16 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// weight(1f) reserves the remaining row width for the label so it
|
||||
// wraps on narrow screens instead of clipping under the Switch.
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = true)
|
||||
.padding(end = 12.dp)
|
||||
)
|
||||
Switch(checked = value, onCheckedChange = onChange)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">[DЯΣΛMMΛKΣЯ] OVERWATCH</string>
|
||||
<string name="title_line1">[DЯΣΛMMΛKΣЯ]</string>
|
||||
<string name="title_line2"> . //0VΣЯW4TCH</string>
|
||||
<string name="app_name">OVERWATCH</string>
|
||||
<string name="status_idle">Idle — press START to begin scanning</string>
|
||||
<string name="status_scanning_clear">All clear</string>
|
||||
<string name="status_scanning">Scanning…</string>
|
||||
<string name="action_start">START</string>
|
||||
<string name="action_stop">STOP</string>
|
||||
<string name="notification_channel_name">DREAMMAKER / OVERWATCH detection</string>
|
||||
<string name="notification_channel_name">OVERWATCH detection</string>
|
||||
<string name="notification_channel_desc">Foreground notification while scanning</string>
|
||||
<string name="notification_title">OVERWATCH active</string>
|
||||
<string name="notification_text">Scanning for nearby surveillance</string>
|
||||
|
||||
@@ -7,6 +7,7 @@ activityCompose = "1.9.3"
|
||||
composeBom = "2024.12.01"
|
||||
material3 = "1.3.1"
|
||||
playServicesLocation = "21.3.0"
|
||||
osmdroid = "6.1.20"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -21,6 +22,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
||||
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user