Compare commits
10 Commits
5a7a9e90e4
...
fc67d3d203
| Author | SHA1 | Date | |
|---|---|---|---|
| fc67d3d203 | |||
| eb26def14b | |||
| 0841a0a33f | |||
| 42f657bc0a | |||
| 0e4387df45 | |||
| 245055d9d2 | |||
| e277c48e89 | |||
| dc2eb9881e | |||
| d8670f4c32 | |||
| 74f26439fc |
@@ -4,26 +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. |
|
||||||
| **WAZE** | Live `POLICE` reports within configurable proximity (default 500 m) and < 10 min old | `live-map/api/georss` polled every 60 s with a small bbox around the user. **Note:** Waze added reCAPTCHA gating to this endpoint in 2025/2026; mobile clients now receive HTTP 403. Source stays wired and surfaces the failure in the drill-down sheet so it's never silently empty. |
|
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. First poll fires immediately on the first location fix. |
|
||||||
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. Pulled in to replace Waze's coverage gap. |
|
|
||||||
|
|
||||||
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
|
> **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
|
||||||
the maximum live score across all sources:
|
the maximum live score across all sources:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -35,32 +40,55 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
||||||
scan/BleScanner.kt BLE callback scanner
|
service/DetectionService.kt foreground service — owns scanners, notification, vibration
|
||||||
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
|
scan/BleScanner.kt BLE callback scanner
|
||||||
scan/DeflockClient.kt CDN tile fetch + 24h cache
|
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
|
||||||
scan/DeflockScanner.kt location-driven proximity check
|
scan/DeflockClient.kt Overpass POST (deflock.org → overpass-api.de) + 24h cache
|
||||||
scan/WazeClient.kt live-map/api/georss bbox fetch
|
scan/DeflockScanner.kt location-driven proximity check + failure backoff
|
||||||
scan/WazeScanner.kt 60s poller + age/distance gate
|
scan/CitizenClient.kt GET /api/incident/trending + /api/incident/{id}
|
||||||
fusion/ConfidenceEngine.kt scoring (one place)
|
scan/CitizenScanner.kt 60 s poller, fire/medical filter, per-id cache
|
||||||
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
|
fusion/ConfidenceEngine.kt scoring (one place — BLE / WiFi / DeFlock / Citizen)
|
||||||
fusion/DetectionStore.kt in-memory dedup, 5-min retention
|
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/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,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).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -93,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,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)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -124,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.0–v0.1.5 Waze integration that has since been removed (endpoint is reCAPTCHA-gated)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phases 1–5 (skeleton, BLE, WiFi, DeFlock, Waze, polish) complete as of v0.1.0.
|
Phases 1–5 (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
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 14
|
||||||
versionName = "0.1.4"
|
versionName = "0.3.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,24 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Vibration on threat-tier escalation -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<!-- Floating threat-circle overlay (chat-bubble style). Special-access
|
||||||
|
permission — user grants via system Settings page, not the runtime
|
||||||
|
prompt. Only consumed when the user opts in to the bubble in app
|
||||||
|
Settings; otherwise dormant. -->
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
package org.soulstone.overwatch
|
package org.soulstone.overwatch
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
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
|
||||||
@@ -44,6 +51,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
) { result ->
|
) { result ->
|
||||||
val allGranted = result.all { it.value }
|
val allGranted = result.all { it.value }
|
||||||
permissionsGranted.value = allGranted
|
permissionsGranted.value = allGranted
|
||||||
|
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
|
||||||
if (allGranted) {
|
if (allGranted) {
|
||||||
// First-run path: user just granted everything, kick off scanning
|
// First-run path: user just granted everything, kick off scanning
|
||||||
// immediately so they don't have to tap START a second time.
|
// immediately so they don't have to tap START a second time.
|
||||||
@@ -51,17 +59,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false)
|
private val permissionsGranted = mutableStateOf(false)
|
||||||
|
/** True when at least one required permission is denied AND the system says
|
||||||
|
* we can no longer prompt for it (user picked "don't ask again"). The UI
|
||||||
|
* swaps the START button's call-to-action for an "Open app settings" link. */
|
||||||
|
private val permanentlyDenied = mutableStateOf(false)
|
||||||
|
|
||||||
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
|
||||||
val settings = Settings.get(this)
|
val settings = Settings.get(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val themeMode by settings.themeMode.collectAsState()
|
val themeMode by settings.themeMode.collectAsState()
|
||||||
OverwatchTheme(mode = themeMode) {
|
OverwatchTheme(mode = themeMode) {
|
||||||
var screen by remember { mutableStateOf(Screen.MAIN) }
|
var screen by rememberSaveable { mutableStateOf(Screen.MAIN) }
|
||||||
|
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.MAIN -> {
|
Screen.MAIN -> {
|
||||||
@@ -69,21 +90,45 @@ 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()
|
||||||
|
// 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 granted by permissionsGranted
|
||||||
|
val denied by permanentlyDenied
|
||||||
|
|
||||||
|
val message = when {
|
||||||
|
granted -> null
|
||||||
|
denied -> "Permissions permanently denied — open app settings to grant"
|
||||||
|
else -> "Tap START to grant Bluetooth, WiFi + location permissions"
|
||||||
|
}
|
||||||
|
|
||||||
MainScreen(
|
MainScreen(
|
||||||
running = running,
|
running = running,
|
||||||
threat = threat,
|
threat = threat,
|
||||||
score = maxScore,
|
score = maxScore,
|
||||||
events = events,
|
events = events,
|
||||||
|
mapPoints = mapPoints,
|
||||||
|
userLocation = userLocation,
|
||||||
|
mapRadiusMeters = mapRadiusM,
|
||||||
canStart = true,
|
canStart = true,
|
||||||
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
|
permissionMessage = message,
|
||||||
|
showOpenAppSettings = denied && !granted,
|
||||||
|
onOpenAppSettings = { openAppSettings() },
|
||||||
onStartStop = {
|
onStartStop = {
|
||||||
if (running) {
|
if (running) {
|
||||||
DetectionService.stop(this)
|
DetectionService.stop(this)
|
||||||
} else {
|
} else {
|
||||||
if (granted) {
|
if (granted) {
|
||||||
DetectionService.start(this)
|
DetectionService.start(this)
|
||||||
|
} else if (denied) {
|
||||||
|
openAppSettings()
|
||||||
} else {
|
} else {
|
||||||
permissionLauncher.launch(requiredPermissions)
|
permissionLauncher.launch(requiredPermissions)
|
||||||
}
|
}
|
||||||
@@ -93,6 +138,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Screen.SETTINGS -> {
|
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()
|
val running by DetectionService.running.collectAsState()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -111,7 +160,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
permissionsGranted.value = checkAllPermissions()
|
// User may have granted permissions in app settings while we were paused.
|
||||||
|
val nowGranted = checkAllPermissions()
|
||||||
|
permissionsGranted.value = nowGranted
|
||||||
|
if (nowGranted) permanentlyDenied.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAllPermissions(): Boolean =
|
private fun checkAllPermissions(): Boolean =
|
||||||
@@ -119,5 +171,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if at least one missing permission is still askable via the system
|
||||||
|
* prompt. False means everything missing was denied with "don't ask again". */
|
||||||
|
private fun anyMissingCanStillAsk(): Boolean {
|
||||||
|
val missing = requiredPermissions.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isEmpty()) return true
|
||||||
|
return missing.any { ActivityCompat.shouldShowRequestPermissionRationale(this, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppSettings() {
|
||||||
|
val intent = Intent(
|
||||||
|
AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", packageName, null)
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
private enum class Screen { MAIN, SETTINGS }
|
private enum class Screen { MAIN, SETTINGS }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ class LocationProvider(private val context: Context) {
|
|||||||
|
|
||||||
private val callback = object : LocationCallback() {
|
private val callback = object : LocationCallback() {
|
||||||
override fun onLocationResult(result: LocationResult) {
|
override fun onLocationResult(result: LocationResult) {
|
||||||
|
if (!running) return
|
||||||
val fix = result.lastLocation ?: return
|
val fix = result.lastLocation ?: return
|
||||||
_location.value = fix
|
_location.value = fix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var running = false
|
@Volatile private var running = false
|
||||||
|
|
||||||
fun hasPermission(): Boolean =
|
fun hasPermission(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
@@ -68,12 +69,22 @@ class LocationProvider(private val context: Context) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
|
|
||||||
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
|
|
||||||
running = true
|
running = true
|
||||||
|
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
|
||||||
|
// Seed with the cached lastLocation only if (a) we haven't already
|
||||||
|
// received a fresh fix from requestLocationUpdates and (b) we're
|
||||||
|
// still running by the time the listener fires. Otherwise the
|
||||||
|
// listener can race and either overwrite a fresh fix with a stale
|
||||||
|
// one or fire after stop().
|
||||||
|
client.lastLocation.addOnSuccessListener { last ->
|
||||||
|
if (running && last != null && _location.value == null) {
|
||||||
|
_location.value = last
|
||||||
|
}
|
||||||
|
}
|
||||||
Log.i(TAG, "Location updates started")
|
Log.i(TAG, "Location updates started")
|
||||||
return true
|
return true
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
running = false
|
||||||
Log.e(TAG, "SecurityException starting location updates", e)
|
Log.e(TAG, "SecurityException starting location updates", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,32 +29,38 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
|
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
|
||||||
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
|
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
|
|
||||||
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
|
|
||||||
|
|
||||||
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)
|
||||||
)
|
)
|
||||||
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
|
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
|
||||||
|
|
||||||
private val _wazeProximityM = MutableStateFlow(
|
private val _citizenProximityM = MutableStateFlow(
|
||||||
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
|
prefs.getInt(KEY_CITIZEN_PROX, DEFAULT_CITIZEN_PROX)
|
||||||
)
|
)
|
||||||
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
|
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
|
||||||
|
|
||||||
private val _themeMode = MutableStateFlow(
|
private val _themeMode = MutableStateFlow(
|
||||||
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
||||||
)
|
)
|
||||||
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
|
||||||
|
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
|
||||||
|
|
||||||
|
private val _overlayEnabled = MutableStateFlow(prefs.getBoolean(KEY_OVERLAY, false))
|
||||||
|
val overlayEnabled: StateFlow<Boolean> = _overlayEnabled.asStateFlow()
|
||||||
|
|
||||||
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
|
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
|
||||||
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
|
fun 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 setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.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)
|
||||||
@@ -62,10 +68,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
_deflockProximityM.value = clamped
|
_deflockProximityM.value = clamped
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWazeProximityM(v: Int) {
|
fun setCitizenProximityM(v: Int) {
|
||||||
val clamped = v.coerceIn(100, 5000)
|
val clamped = v.coerceIn(100, 5000)
|
||||||
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
|
prefs.edit { putInt(KEY_CITIZEN_PROX, clamped) }
|
||||||
_wazeProximityM.value = clamped
|
_citizenProximityM.value = clamped
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
@@ -73,19 +79,31 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
_themeMode.value = mode
|
_themeMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVibrateOnAlert(v: Boolean) {
|
||||||
|
prefs.edit { putBoolean(KEY_VIBRATE, v) }
|
||||||
|
_vibrateOnAlert.value = v
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOverlayEnabled(v: Boolean) {
|
||||||
|
prefs.edit { putBoolean(KEY_OVERLAY, v) }
|
||||||
|
_overlayEnabled.value = v
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS = "overwatch_settings"
|
private const val PREFS = "overwatch_settings"
|
||||||
private const val KEY_BLE = "src_ble"
|
private const val KEY_BLE = "src_ble"
|
||||||
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_WAZE = "src_waze"
|
|
||||||
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_WAZE_PROX = "waze_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"
|
||||||
|
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||||
|
private const val KEY_OVERLAY = "overlay_enabled"
|
||||||
|
|
||||||
const val DEFAULT_DEFLOCK_PROX = 200
|
const val DEFAULT_DEFLOCK_PROX = 200
|
||||||
const val DEFAULT_WAZE_PROX = 500
|
const val DEFAULT_CITIZEN_PROX = 500
|
||||||
|
|
||||||
@Volatile private var INSTANCE: Settings? = null
|
@Volatile private var INSTANCE: Settings? = null
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,11 @@ object ConfidenceEngine {
|
|||||||
const val W_WIFI_SSID_GENERIC = 50
|
const val W_WIFI_SSID_GENERIC = 50
|
||||||
const val W_WIFI_SSID_FLOCK_FMT = 65
|
const val W_WIFI_SSID_FLOCK_FMT = 65
|
||||||
|
|
||||||
// Map / Waze (Phase 3 + 4)
|
// Map (Phase 3)
|
||||||
const val W_DEFLOCK_NEAR = 60 // <= 200m
|
const val W_DEFLOCK_NEAR = 60 // <= 200m
|
||||||
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
||||||
const val W_WAZE_POLICE = 55
|
|
||||||
|
|
||||||
// Citizen (added when Waze went dark)
|
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
|
||||||
const val W_CITIZEN_INCIDENT = 55
|
const val W_CITIZEN_INCIDENT = 55
|
||||||
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
|
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
|
||||||
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
|
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
|
||||||
@@ -39,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,
|
||||||
@@ -66,16 +78,6 @@ object ConfidenceEngine {
|
|||||||
val manufacturer: String?
|
val manufacturer: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A Waze POLICE alert observed within proximity + freshness thresholds. */
|
|
||||||
data class WazeObservation(
|
|
||||||
val uuid: String,
|
|
||||||
val distanceMeters: Float,
|
|
||||||
val ageMs: Long,
|
|
||||||
val confidence: Int, // raw 0-5
|
|
||||||
val reliability: Int, // raw 0-10
|
|
||||||
val subtype: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A Citizen incident observed within proximity + freshness, after the
|
/** A Citizen incident observed within proximity + freshness, after the
|
||||||
* fire/medical filter is applied. */
|
* fire/medical filter is applied. */
|
||||||
data class CitizenObservation(
|
data class CitizenObservation(
|
||||||
@@ -186,22 +188,6 @@ object ConfidenceEngine {
|
|||||||
return Scored(score, methods.toString().trim(), label, isAxon)
|
return Scored(score, methods.toString().trim(), label, isAxon)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scoreWaze(obs: WazeObservation): Scored {
|
|
||||||
// Plan baseline: 55 for any POLICE alert ≤500m & <10min old.
|
|
||||||
// Caller is responsible for applying the proximity + age gate before scoring.
|
|
||||||
var score = W_WAZE_POLICE
|
|
||||||
// Lightweight crowd-trust nudge: high reliability & high confidence each add a few points,
|
|
||||||
// capped well under the multi-method bonus so a corroborating BLE/WiFi hit still dominates.
|
|
||||||
if (obs.reliability >= 7) score += 5
|
|
||||||
if (obs.confidence >= 4) score += 5
|
|
||||||
score = score.coerceAtMost(100)
|
|
||||||
val methods = "waze_police rel=${obs.reliability} conf=${obs.confidence}"
|
|
||||||
val ageMin = (obs.ageMs / 60_000L).toInt()
|
|
||||||
val sub = obs.subtype?.let { " ($it)" } ?: ""
|
|
||||||
val label = "Police report$sub @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago"
|
|
||||||
return Scored(score, methods, label, isAxon = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scoreCitizen(obs: CitizenObservation): Scored {
|
fun scoreCitizen(obs: CitizenObservation): Scored {
|
||||||
var score = W_CITIZEN_INCIDENT
|
var score = W_CITIZEN_INCIDENT
|
||||||
val tags = StringBuilder("citizen ")
|
val tags = StringBuilder("citizen ")
|
||||||
@@ -229,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()
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ package org.soulstone.overwatch.fusion
|
|||||||
* One observation from one source at one moment.
|
* One observation from one source at one moment.
|
||||||
*
|
*
|
||||||
* @param source which scanner produced this
|
* @param source which scanner produced this
|
||||||
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Waze)
|
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Citizen)
|
||||||
* @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B")
|
* @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B")
|
||||||
* @param score 0-100 confidence assigned by the engine
|
* @param score 0-100 confidence assigned by the engine
|
||||||
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
|
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
|
||||||
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Waze sources
|
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Citizen sources
|
||||||
|
* @param lat / lon real-world coordinates for events that have them (DEFLOCK, CITIZEN); null for radio-only sources
|
||||||
* @param timestampMs wall-clock millis when this event was produced
|
* @param timestampMs wall-clock millis when this event was produced
|
||||||
*/
|
*/
|
||||||
data class DetectionEvent(
|
data class DetectionEvent(
|
||||||
@@ -18,7 +19,10 @@ data class DetectionEvent(
|
|||||||
val score: Int,
|
val score: Int,
|
||||||
val matchedMethods: String,
|
val matchedMethods: String,
|
||||||
val rssi: Int? = null,
|
val rssi: Int? = null,
|
||||||
|
val lat: Double? = null,
|
||||||
|
val lon: Double? = null,
|
||||||
val timestampMs: Long = System.currentTimeMillis()
|
val timestampMs: Long = System.currentTimeMillis()
|
||||||
) {
|
) {
|
||||||
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
|
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
|
||||||
|
val hasGeo: Boolean get() = lat != null && lon != null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ class DetectionStore(
|
|||||||
_maxScore.value = 0
|
_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
|
@Synchronized
|
||||||
fun pruneExpired() {
|
fun pruneExpired() {
|
||||||
val cutoff = nowMs() - retentionMs
|
val cutoff = nowMs() - retentionMs
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ object SourceHealth {
|
|||||||
private val _ble = MutableStateFlow(Health())
|
private val _ble = MutableStateFlow(Health())
|
||||||
private val _wifi = MutableStateFlow(Health())
|
private val _wifi = MutableStateFlow(Health())
|
||||||
private val _deflock = MutableStateFlow(Health())
|
private val _deflock = MutableStateFlow(Health())
|
||||||
private val _waze = 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 waze: StateFlow<Health> = _waze.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.WAZE -> waze
|
|
||||||
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) {
|
||||||
@@ -48,8 +48,8 @@ object SourceHealth {
|
|||||||
DetectionSource.BLE -> _ble
|
DetectionSource.BLE -> _ble
|
||||||
DetectionSource.WIFI -> _wifi
|
DetectionSource.WIFI -> _wifi
|
||||||
DetectionSource.DEFLOCK -> _deflock
|
DetectionSource.DEFLOCK -> _deflock
|
||||||
DetectionSource.WAZE -> _waze
|
|
||||||
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,
|
||||||
@@ -62,7 +62,7 @@ object SourceHealth {
|
|||||||
_ble.value = Health()
|
_ble.value = Health()
|
||||||
_wifi.value = Health()
|
_wifi.value = Health()
|
||||||
_deflock.value = Health()
|
_deflock.value = Health()
|
||||||
_waze.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, WAZE, 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
|
||||||
@@ -21,6 +22,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
|
|||||||
import org.soulstone.overwatch.fusion.DetectionSource
|
import org.soulstone.overwatch.fusion.DetectionSource
|
||||||
import org.soulstone.overwatch.fusion.DetectionStore
|
import org.soulstone.overwatch.fusion.DetectionStore
|
||||||
import org.soulstone.overwatch.fusion.RssiTracker
|
import org.soulstone.overwatch.fusion.RssiTracker
|
||||||
|
import org.soulstone.overwatch.fusion.SourceHealth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
|
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
|
||||||
@@ -37,7 +39,9 @@ import org.soulstone.overwatch.fusion.RssiTracker
|
|||||||
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 {
|
||||||
@@ -79,18 +83,30 @@ class BleScanner(
|
|||||||
if (running) return true
|
if (running) return true
|
||||||
if (!hasScanPermission()) {
|
if (!hasScanPermission()) {
|
||||||
Log.w(TAG, "BLE scan permission missing")
|
Log.w(TAG, "BLE scan permission missing")
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission missing")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val adapter = bluetoothAdapter ?: run {
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE not supported")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Bluetooth disabled")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
leScanner = adapter.bluetoothLeScanner ?: run {
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE scanner unavailable")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val adapter = bluetoothAdapter ?: return false
|
|
||||||
if (!adapter.isEnabled) return false
|
|
||||||
leScanner = adapter.bluetoothLeScanner ?: return false
|
|
||||||
try {
|
try {
|
||||||
leScanner?.startScan(null, scanSettings, scanCallback)
|
leScanner?.startScan(null, scanSettings, scanCallback)
|
||||||
running = true
|
running = true
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = true)
|
||||||
Log.i(TAG, "BLE scan started")
|
Log.i(TAG, "BLE scan started")
|
||||||
return true
|
return true
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "SecurityException starting scan", e)
|
Log.e(TAG, "SecurityException starting scan", e)
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission revoked")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +136,11 @@ class BleScanner(
|
|||||||
override fun onScanFailed(errorCode: Int) {
|
override fun onScanFailed(errorCode: Int) {
|
||||||
Log.e(TAG, "BLE scan failed: $errorCode")
|
Log.e(TAG, "BLE scan failed: $errorCode")
|
||||||
running = false
|
running = false
|
||||||
|
SourceHealth.record(
|
||||||
|
DetectionSource.BLE,
|
||||||
|
ok = false,
|
||||||
|
message = "BLE scan failed (code $errorCode)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,43 +153,86 @@ class BleScanner(
|
|||||||
|
|
||||||
val advertisedUuids = record?.serviceUuids?.map { it.uuid }
|
val advertisedUuids = record?.serviceUuids?.map { it.uuid }
|
||||||
val mfgSpecific = record?.manufacturerSpecificData
|
val mfgSpecific = record?.manufacturerSpecificData
|
||||||
|
// Iterate ALL manufacturer-data entries; some devices advertise multiple
|
||||||
|
// and XUNTONG might not be the first one. Prefer the XUNTONG match if
|
||||||
|
// present, otherwise fall back to the first entry so we still surface
|
||||||
|
// *some* mfg signal in the observation.
|
||||||
var companyId: Int? = null
|
var companyId: Int? = null
|
||||||
var payload: ByteArray? = null
|
var payload: ByteArray? = null
|
||||||
if (mfgSpecific != null && mfgSpecific.size() > 0) {
|
if (mfgSpecific != null && mfgSpecific.size() > 0) {
|
||||||
companyId = mfgSpecific.keyAt(0)
|
for (i in 0 until mfgSpecific.size()) {
|
||||||
payload = mfgSpecific.valueAt(0)
|
val cid = mfgSpecific.keyAt(i)
|
||||||
|
val data = mfgSpecific.valueAt(i)
|
||||||
|
if (cid == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID) {
|
||||||
|
companyId = cid
|
||||||
|
payload = data
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (companyId == null) { companyId = cid; payload = data }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 obs = ConfidenceEngine.BleObservation(
|
val stationary = rssi.isStationary(mac)
|
||||||
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
|
|
||||||
|
|
||||||
store.submit(
|
if (isSurveillance) {
|
||||||
DetectionEvent(
|
val obs = ConfidenceEngine.BleObservation(
|
||||||
source = DetectionSource.BLE,
|
mac = mac,
|
||||||
key = mac,
|
rssi = result.rssi,
|
||||||
label = scored.label,
|
deviceName = name,
|
||||||
score = scored.score,
|
advertisedUuids = advertisedUuids,
|
||||||
matchedMethods = scored.methods,
|
manufacturerCompanyId = companyId,
|
||||||
rssi = result.rssi
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ class CitizenClient {
|
|||||||
val arr = JSONObject(raw.body).optJSONArray("results")
|
val arr = JSONObject(raw.body).optJSONArray("results")
|
||||||
?: return@withContext TrendingResult.Success(emptyList())
|
?: return@withContext TrendingResult.Success(emptyList())
|
||||||
val out = ArrayList<String>(arr.length())
|
val out = ArrayList<String>(arr.length())
|
||||||
for (i in 0 until arr.length()) arr.optString(i)?.takeIf { it.isNotBlank() }?.let(out::add)
|
for (i in 0 until arr.length()) {
|
||||||
|
val id = arr.optString(i)
|
||||||
|
if (id.isNotBlank()) out.add(id)
|
||||||
|
}
|
||||||
TrendingResult.Success(out)
|
TrendingResult.Success(out)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
TrendingResult.Failed("parse: ${e.message}")
|
TrendingResult.Failed("parse: ${e.message}")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.util.Log
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.soulstone.overwatch.data.location.LocationProvider
|
import org.soulstone.overwatch.data.location.LocationProvider
|
||||||
@@ -57,6 +58,9 @@ class CitizenScanner(
|
|||||||
fun start(scope: CoroutineScope): Boolean {
|
fun start(scope: CoroutineScope): Boolean {
|
||||||
if (job != null) return true
|
if (job != null) return true
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
|
// Wait for the first non-null location fix so the first poll fires
|
||||||
|
// immediately when location arrives, instead of after a 60 s delay.
|
||||||
|
locationProvider.location.first { it != null }
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val fix = locationProvider.location.value
|
val fix = locationProvider.location.value
|
||||||
if (fix != null) pollOnce(fix)
|
if (fix != null) pollOnce(fix)
|
||||||
@@ -95,15 +99,36 @@ class CitizenScanner(
|
|||||||
// Drop cache entries that no longer appear in the trending list (resolved).
|
// Drop cache entries that no longer appear in the trending list (resolved).
|
||||||
incidentCache.keys.retainAll(ids.toSet())
|
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 now = System.currentTimeMillis()
|
||||||
val limit = proximityMeters()
|
val limit = proximityMeters()
|
||||||
val out = FloatArray(1)
|
val out = FloatArray(1)
|
||||||
|
|
||||||
for (id in ids) {
|
for (incident in incidents) {
|
||||||
val incident = incidentCache[id] ?: client.fetchIncident(id)?.also {
|
|
||||||
incidentCache[id] = it
|
|
||||||
} ?: continue
|
|
||||||
|
|
||||||
// Title-based pre-filter: drop pure fire/medical events.
|
// Title-based pre-filter: drop pure fire/medical events.
|
||||||
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
|
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
|
||||||
!POLICE_TITLE_RX.containsMatchIn(incident.title)) {
|
!POLICE_TITLE_RX.containsMatchIn(incident.title)) {
|
||||||
@@ -137,7 +162,9 @@ class CitizenScanner(
|
|||||||
label = scored.label,
|
label = scored.label,
|
||||||
score = scored.score,
|
score = scored.score,
|
||||||
matchedMethods = scored.methods,
|
matchedMethods = scored.methods,
|
||||||
rssi = null
|
rssi = null,
|
||||||
|
lat = incident.lat,
|
||||||
|
lon = incident.lon
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,15 @@ class DeflockClient(context: Context) {
|
|||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
if (code in 200..299) {
|
if (code in 200..299) {
|
||||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||||
body to null
|
// Overpass returns HTTP 200 with `{"remark": "runtime error: Query timed out..."}`
|
||||||
|
// when the query exceeded server-side limits. Body has elements:[]; treat as
|
||||||
|
// failure so we don't poison the 24h cache with empty results.
|
||||||
|
if (looksLikeOverpassTimeout(body)) {
|
||||||
|
Log.w(TAG, "$endpoint returned 200 with timeout/runtime-limit remark")
|
||||||
|
null to "Overpass timeout"
|
||||||
|
} else {
|
||||||
|
body to null
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "$endpoint returned $code")
|
Log.w(TAG, "$endpoint returned $code")
|
||||||
null to "HTTP $code"
|
null to "HTTP $code"
|
||||||
@@ -141,6 +149,16 @@ class DeflockClient(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun looksLikeOverpassTimeout(body: String): Boolean {
|
||||||
|
if (!body.contains("remark", ignoreCase = true)) return false
|
||||||
|
val lower = body.lowercase()
|
||||||
|
return lower.contains("timed out") ||
|
||||||
|
lower.contains("timeout") ||
|
||||||
|
lower.contains("runtime error") ||
|
||||||
|
lower.contains("runtime limit exceeded") ||
|
||||||
|
lower.contains("rate_limited")
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseSafely(json: String): List<AlprPoint> {
|
private fun parseSafely(json: String): List<AlprPoint> {
|
||||||
if (json.isBlank()) return emptyList()
|
if (json.isBlank()) return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -32,12 +35,19 @@ class DeflockScanner(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DeflockScanner"
|
private const val TAG = "DeflockScanner"
|
||||||
private const val REFETCH_THRESHOLD_M = 1500f
|
private const val REFETCH_THRESHOLD_M = 1500f
|
||||||
|
/** Don't retry an Overpass POST within this window after a failure. */
|
||||||
|
private const val FAILURE_BACKOFF_MS = 60_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
private var lastFetchLat: Double? = null
|
private var lastFetchLat: Double? = null
|
||||||
private var lastFetchLon: Double? = null
|
private var lastFetchLon: Double? = null
|
||||||
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
|
private var lastAttemptMs: Long = 0L
|
||||||
|
private var lastAttemptOk: Boolean = false
|
||||||
|
private val _cachedPoints = MutableStateFlow<List<DeflockClient.AlprPoint>>(emptyList())
|
||||||
|
/** All ALPR points in the current cell — exposed so the UI map can render them.
|
||||||
|
* Distinct from the proximity-filtered DetectionEvents on [DetectionStore]. */
|
||||||
|
val cachedPoints: StateFlow<List<DeflockClient.AlprPoint>> = _cachedPoints.asStateFlow()
|
||||||
|
|
||||||
fun start(scope: CoroutineScope): Boolean {
|
fun start(scope: CoroutineScope): Boolean {
|
||||||
if (job != null) return true
|
if (job != null) return true
|
||||||
@@ -55,25 +65,32 @@ class DeflockScanner(
|
|||||||
job = null
|
job = null
|
||||||
lastFetchLat = null
|
lastFetchLat = null
|
||||||
lastFetchLon = null
|
lastFetchLon = null
|
||||||
cachedPoints = emptyList()
|
lastAttemptMs = 0L
|
||||||
|
lastAttemptOk = false
|
||||||
|
_cachedPoints.value = emptyList()
|
||||||
Log.i(TAG, "DeflockScanner stopped")
|
Log.i(TAG, "DeflockScanner stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleFix(fix: Location) {
|
private suspend fun handleFix(fix: Location) {
|
||||||
if (shouldRefetch(fix)) {
|
if (shouldRefetch(fix)) {
|
||||||
|
// Mark the attempt before the network call so a concurrent location
|
||||||
|
// tick doesn't trigger a parallel re-fetch of the same area.
|
||||||
|
lastFetchLat = fix.latitude
|
||||||
|
lastFetchLon = fix.longitude
|
||||||
|
lastAttemptMs = System.currentTimeMillis()
|
||||||
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
|
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
|
||||||
is DeflockClient.FetchResult.Success -> {
|
is DeflockClient.FetchResult.Success -> {
|
||||||
cachedPoints = result.points
|
_cachedPoints.value = result.points
|
||||||
lastFetchLat = fix.latitude
|
lastAttemptOk = true
|
||||||
lastFetchLon = fix.longitude
|
|
||||||
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})"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is DeflockClient.FetchResult.Failed -> {
|
is DeflockClient.FetchResult.Failed -> {
|
||||||
|
lastAttemptOk = false
|
||||||
SourceHealth.record(
|
SourceHealth.record(
|
||||||
DetectionSource.DEFLOCK,
|
DetectionSource.DEFLOCK,
|
||||||
ok = false,
|
ok = false,
|
||||||
@@ -84,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 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
|
||||||
@@ -106,7 +145,9 @@ class DeflockScanner(
|
|||||||
label = scored.label,
|
label = scored.label,
|
||||||
score = scored.score,
|
score = scored.score,
|
||||||
matchedMethods = scored.methods,
|
matchedMethods = scored.methods,
|
||||||
rssi = null
|
rssi = null,
|
||||||
|
lat = p.lat,
|
||||||
|
lon = p.lon
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,6 +156,12 @@ class DeflockScanner(
|
|||||||
private fun shouldRefetch(fix: Location): Boolean {
|
private fun shouldRefetch(fix: Location): Boolean {
|
||||||
val lat = lastFetchLat ?: return true
|
val lat = lastFetchLat ?: return true
|
||||||
val lon = lastFetchLon ?: return true
|
val lon = lastFetchLon ?: return true
|
||||||
|
// After a failed attempt, hold off for FAILURE_BACKOFF_MS even if the
|
||||||
|
// user hasn't moved — avoids hammering Overpass when it's struggling.
|
||||||
|
if (!lastAttemptOk &&
|
||||||
|
System.currentTimeMillis() - lastAttemptMs < FAILURE_BACKOFF_MS) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
val out = FloatArray(1)
|
val out = FloatArray(1)
|
||||||
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
|
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
|
||||||
return out[0] > REFETCH_THRESHOLD_M
|
return out[0] > REFETCH_THRESHOLD_M
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package org.soulstone.overwatch.scan
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches Waze live-map alerts in a small bounding box around the user.
|
|
||||||
*
|
|
||||||
* Endpoint (recipe from REFERENCES/wazepolice):
|
|
||||||
* https://www.waze.com/live-map/api/georss?top=&bottom=&left=&right=&env=na&types=alerts
|
|
||||||
*
|
|
||||||
* Spoofs Chrome desktop headers — the public live-map endpoint requires Referer +
|
|
||||||
* a real-looking User-Agent, otherwise returns 403.
|
|
||||||
*
|
|
||||||
* Response shape:
|
|
||||||
* { "alerts": [
|
|
||||||
* { "uuid", "type": "POLICE", "subtype",
|
|
||||||
* "location": {"x": lon, "y": lat},
|
|
||||||
* "pubMillis", "reportedBy", "confidence" 0-5, "reliability" 0-10 } ] }
|
|
||||||
*/
|
|
||||||
class WazeClient {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "WazeClient"
|
|
||||||
private const val BASE = "https://www.waze.com/live-map/api/georss"
|
|
||||||
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
private const val REFERER = "https://www.waze.com/live-map/"
|
|
||||||
private const val ORIGIN = "https://www.waze.com"
|
|
||||||
private const val TIMEOUT_MS = 10_000
|
|
||||||
|
|
||||||
/** Bounding box half-width in degrees — ~5.5 km N-S, varies E-W with latitude. */
|
|
||||||
private const val BBOX_HALF_DEG = 0.05
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Alert(
|
|
||||||
val uuid: String,
|
|
||||||
val subtype: String?,
|
|
||||||
val lat: Double,
|
|
||||||
val lon: Double,
|
|
||||||
val pubMillis: Long,
|
|
||||||
val confidence: Int,
|
|
||||||
val reliability: Int,
|
|
||||||
val reportedBy: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Outcome — distinguishes "no police alerts in area" from "couldn't reach Waze." */
|
|
||||||
sealed class FetchResult {
|
|
||||||
data class Success(val alerts: List<Alert>) : FetchResult()
|
|
||||||
data class Failed(val reason: String) : FetchResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchPoliceNear(lat: Double, lon: Double): FetchResult = withContext(Dispatchers.IO) {
|
|
||||||
val top = lat + BBOX_HALF_DEG
|
|
||||||
val bottom = lat - BBOX_HALF_DEG
|
|
||||||
val left = lon - BBOX_HALF_DEG
|
|
||||||
val right = lon + BBOX_HALF_DEG
|
|
||||||
val url = URL("$BASE?top=$top&bottom=$bottom&left=$left&right=$right&env=na&types=alerts")
|
|
||||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
|
||||||
connectTimeout = TIMEOUT_MS
|
|
||||||
readTimeout = TIMEOUT_MS
|
|
||||||
requestMethod = "GET"
|
|
||||||
instanceFollowRedirects = true
|
|
||||||
setRequestProperty("User-Agent", USER_AGENT)
|
|
||||||
setRequestProperty("Referer", REFERER)
|
|
||||||
setRequestProperty("Origin", ORIGIN)
|
|
||||||
setRequestProperty("Accept", "application/json,text/javascript,*/*;q=0.8")
|
|
||||||
setRequestProperty("Accept-Language", "en-US,en;q=0.9")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val code = conn.responseCode
|
|
||||||
if (code == 403) {
|
|
||||||
// Waze added reCAPTCHA gating to live-map in 2025/2026; mobile
|
|
||||||
// clients can no longer hit this endpoint without browser-level
|
|
||||||
// automation. Surface this distinctly so the UI can say so.
|
|
||||||
Log.w(TAG, "Waze returned 403 (upstream reCAPTCHA gating)")
|
|
||||||
return@withContext FetchResult.Failed("Upstream blocked (HTTP 403)")
|
|
||||||
}
|
|
||||||
if (code !in 200..299) {
|
|
||||||
Log.w(TAG, "Waze returned $code")
|
|
||||||
return@withContext FetchResult.Failed("HTTP $code")
|
|
||||||
}
|
|
||||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
FetchResult.Success(parsePolice(body))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Waze fetch failed: ${e.message}")
|
|
||||||
FetchResult.Failed(e.message ?: e.javaClass.simpleName)
|
|
||||||
} finally {
|
|
||||||
conn.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parsePolice(body: String): List<Alert> {
|
|
||||||
if (body.isBlank()) return emptyList()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(body)
|
|
||||||
val alerts = root.optJSONArray("alerts") ?: return emptyList()
|
|
||||||
val out = ArrayList<Alert>()
|
|
||||||
for (i in 0 until alerts.length()) {
|
|
||||||
val a = alerts.optJSONObject(i) ?: continue
|
|
||||||
if (a.optString("type") != "POLICE") continue
|
|
||||||
val loc = a.optJSONObject("location") ?: continue
|
|
||||||
val uuid = a.optString("uuid")
|
|
||||||
if (uuid.isBlank()) continue
|
|
||||||
val lat = loc.optDouble("y")
|
|
||||||
val lon = loc.optDouble("x")
|
|
||||||
if (lat.isNaN() || lon.isNaN()) continue
|
|
||||||
out.add(
|
|
||||||
Alert(
|
|
||||||
uuid = uuid,
|
|
||||||
subtype = a.optString("subtype").ifBlank { null },
|
|
||||||
lat = lat,
|
|
||||||
lon = lon,
|
|
||||||
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
|
|
||||||
confidence = a.optInt("confidence", 0),
|
|
||||||
reliability = a.optInt("reliability", 0),
|
|
||||||
reportedBy = a.optString("reportedBy").ifBlank { null }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
out
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to parse Waze response: ${e.message}")
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package org.soulstone.overwatch.scan
|
|
||||||
|
|
||||||
import android.location.Location
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.soulstone.overwatch.data.location.LocationProvider
|
|
||||||
import org.soulstone.overwatch.fusion.ConfidenceEngine
|
|
||||||
import org.soulstone.overwatch.fusion.DetectionEvent
|
|
||||||
import org.soulstone.overwatch.fusion.DetectionSource
|
|
||||||
import org.soulstone.overwatch.fusion.DetectionStore
|
|
||||||
import org.soulstone.overwatch.fusion.SourceHealth
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Polls Waze every 60s for live POLICE alerts in a small bounding box around the
|
|
||||||
* current location, then submits any inside [PROXIMITY_M] and younger than [MAX_AGE_MS].
|
|
||||||
*
|
|
||||||
* Skips the poll cycle if location is not yet known. Network-only — no on-disk cache
|
|
||||||
* (data is real-time by definition).
|
|
||||||
*/
|
|
||||||
class WazeScanner(
|
|
||||||
private val store: DetectionStore,
|
|
||||||
private val locationProvider: LocationProvider,
|
|
||||||
private val client: WazeClient = WazeClient(),
|
|
||||||
private val proximityMeters: () -> Float = { 500f }
|
|
||||||
) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "WazeScanner"
|
|
||||||
private const val POLL_INTERVAL_MS = 60_000L
|
|
||||||
private const val MAX_AGE_MS = 10L * 60L * 1000L
|
|
||||||
}
|
|
||||||
|
|
||||||
private var job: Job? = null
|
|
||||||
|
|
||||||
fun start(scope: CoroutineScope): Boolean {
|
|
||||||
if (job != null) return true
|
|
||||||
job = scope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
val fix = locationProvider.location.value
|
|
||||||
if (fix != null) {
|
|
||||||
pollOnce(fix)
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Skip poll — no location yet")
|
|
||||||
}
|
|
||||||
delay(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms)")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
job?.cancel()
|
|
||||||
job = null
|
|
||||||
Log.i(TAG, "WazeScanner stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun pollOnce(fix: Location) {
|
|
||||||
val result = client.fetchPoliceNear(fix.latitude, fix.longitude)
|
|
||||||
val alerts = when (result) {
|
|
||||||
is WazeClient.FetchResult.Success -> {
|
|
||||||
SourceHealth.record(DetectionSource.WAZE, ok = true)
|
|
||||||
result.alerts
|
|
||||||
}
|
|
||||||
is WazeClient.FetchResult.Failed -> {
|
|
||||||
SourceHealth.record(
|
|
||||||
DetectionSource.WAZE,
|
|
||||||
ok = false,
|
|
||||||
message = "Waze unreachable: ${result.reason}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (alerts.isEmpty()) return
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val limit = proximityMeters()
|
|
||||||
val out = FloatArray(1)
|
|
||||||
|
|
||||||
for (a in alerts) {
|
|
||||||
val age = now - a.pubMillis
|
|
||||||
if (age > MAX_AGE_MS) continue
|
|
||||||
Location.distanceBetween(fix.latitude, fix.longitude, a.lat, a.lon, out)
|
|
||||||
val dist = out[0]
|
|
||||||
if (dist > limit) continue
|
|
||||||
|
|
||||||
val obs = ConfidenceEngine.WazeObservation(
|
|
||||||
uuid = a.uuid,
|
|
||||||
distanceMeters = dist,
|
|
||||||
ageMs = age,
|
|
||||||
confidence = a.confidence,
|
|
||||||
reliability = a.reliability,
|
|
||||||
subtype = a.subtype
|
|
||||||
)
|
|
||||||
val scored = ConfidenceEngine.scoreWaze(obs)
|
|
||||||
store.submit(
|
|
||||||
DetectionEvent(
|
|
||||||
source = DetectionSource.WAZE,
|
|
||||||
key = a.uuid,
|
|
||||||
label = scored.label,
|
|
||||||
score = scored.score,
|
|
||||||
matchedMethods = scored.methods,
|
|
||||||
rssi = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -24,6 +25,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
|
|||||||
import org.soulstone.overwatch.fusion.DetectionSource
|
import org.soulstone.overwatch.fusion.DetectionSource
|
||||||
import org.soulstone.overwatch.fusion.DetectionStore
|
import org.soulstone.overwatch.fusion.DetectionStore
|
||||||
import org.soulstone.overwatch.fusion.RssiTracker
|
import org.soulstone.overwatch.fusion.RssiTracker
|
||||||
|
import org.soulstone.overwatch.fusion.SourceHealth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
|
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
|
||||||
@@ -39,7 +41,9 @@ import org.soulstone.overwatch.fusion.RssiTracker
|
|||||||
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 {
|
||||||
@@ -86,15 +90,23 @@ class WifiScanner(
|
|||||||
if (running) return true
|
if (running) return true
|
||||||
if (!hasScanPermission()) {
|
if (!hasScanPermission()) {
|
||||||
Log.w(TAG, "WiFi scan permission missing")
|
Log.w(TAG, "WiFi scan permission missing")
|
||||||
|
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "Permission missing")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val mgr = wifiManager ?: run {
|
val mgr = wifiManager ?: run {
|
||||||
Log.w(TAG, "WifiManager unavailable")
|
Log.w(TAG, "WifiManager unavailable")
|
||||||
|
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "WifiManager unavailable")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!mgr.isWifiEnabled) {
|
if (!mgr.isWifiEnabled) {
|
||||||
Log.w(TAG, "WiFi disabled — scanner won't return results")
|
Log.w(TAG, "WiFi disabled — scanner won't return results")
|
||||||
|
SourceHealth.record(
|
||||||
|
DetectionSource.WIFI, ok = false,
|
||||||
|
message = "WiFi disabled — enable in system settings"
|
||||||
|
)
|
||||||
// We still register the receiver so results arrive when the user enables WiFi.
|
// We still register the receiver so results arrive when the user enables WiFi.
|
||||||
|
} else {
|
||||||
|
SourceHealth.record(DetectionSource.WIFI, ok = true)
|
||||||
}
|
}
|
||||||
registerReceiver()
|
registerReceiver()
|
||||||
running = true
|
running = true
|
||||||
@@ -158,31 +170,51 @@ 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 obs = ConfidenceEngine.WifiObservation(
|
val stationary = rssi.isStationary(bssid)
|
||||||
bssid = bssid,
|
|
||||||
ssid = ssid,
|
|
||||||
rssi = r.level,
|
|
||||||
isStationary = rssi.isStationary(bssid)
|
|
||||||
)
|
|
||||||
val scored = ConfidenceEngine.scoreWifi(obs)
|
|
||||||
if (scored.score < ALARM_THRESHOLD) continue
|
|
||||||
|
|
||||||
store.submit(
|
if (isSurveillance) {
|
||||||
DetectionEvent(
|
val obs = ConfidenceEngine.WifiObservation(
|
||||||
source = DetectionSource.WIFI,
|
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
|
||||||
key = bssid,
|
|
||||||
label = scored.label,
|
|
||||||
score = scored.score,
|
|
||||||
matchedMethods = scored.methods,
|
|
||||||
rssi = r.level
|
|
||||||
)
|
)
|
||||||
)
|
val scored = ConfidenceEngine.scoreWifi(obs)
|
||||||
|
if (scored.score >= ALARM_THRESHOLD) {
|
||||||
|
store.submit(
|
||||||
|
DetectionEvent(
|
||||||
|
source = DetectionSource.WIFI,
|
||||||
|
key = bssid,
|
||||||
|
label = scored.label,
|
||||||
|
score = scored.score,
|
||||||
|
matchedMethods = scored.methods,
|
||||||
|
rssi = r.level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMic) {
|
||||||
|
val obs = ConfidenceEngine.MicWifiObservation(
|
||||||
|
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
|
||||||
|
)
|
||||||
|
val scored = ConfidenceEngine.scoreMicWifi(obs)
|
||||||
|
if (scored.score >= ALARM_THRESHOLD) {
|
||||||
|
store.submit(
|
||||||
|
DetectionEvent(
|
||||||
|
source = DetectionSource.MIC,
|
||||||
|
key = "mic:$bssid",
|
||||||
|
label = scored.label,
|
||||||
|
score = scored.score,
|
||||||
|
matchedMethods = scored.methods,
|
||||||
|
rssi = r.level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ 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.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
@@ -18,27 +22,38 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.soulstone.overwatch.MainActivity
|
import org.soulstone.overwatch.MainActivity
|
||||||
import org.soulstone.overwatch.R
|
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.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.scan.BleScanner
|
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.WazeScanner
|
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
|
||||||
import org.soulstone.overwatch.scan.WifiScanner
|
import org.soulstone.overwatch.scan.WifiScanner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground service that owns all scanners and the [DetectionStore].
|
* Foreground service that owns all four scanners (BLE, WiFi, DeFlock, Citizen)
|
||||||
|
* and the [DetectionStore]. UI observes companion-object state flows directly.
|
||||||
*
|
*
|
||||||
* Phase 1 wires only [BleScanner]; phases 2-4 will register WiFi, DeFlock, Waze.
|
* Responsibilities beyond scanner orchestration:
|
||||||
|
* - Updates the foreground notification on every threat-tier change so a
|
||||||
|
* locked-screen user sees escalations.
|
||||||
|
* - Vibrates on upward tier transitions (gated by Settings.vibrateOnAlert).
|
||||||
|
* - Resets [SourceHealth] on start/stop.
|
||||||
*
|
*
|
||||||
* The service is a singleton at runtime — UI binds to it (or observes the
|
* Returns START_NOT_STICKY so a system-killed service does not auto-restart
|
||||||
* companion-object state flows directly, which is what we do here for simplicity).
|
* into a zombie state where the notification disappears but `_running` stays
|
||||||
|
* stale. The user explicitly starts and stops; auto-restart isn't needed.
|
||||||
*/
|
*/
|
||||||
class DetectionService : LifecycleService() {
|
class DetectionService : LifecycleService() {
|
||||||
|
|
||||||
@@ -56,6 +71,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
|
||||||
@@ -80,32 +104,42 @@ class DetectionService : LifecycleService() {
|
|||||||
private lateinit var wifiScanner: WifiScanner
|
private lateinit var wifiScanner: WifiScanner
|
||||||
private lateinit var locationProvider: LocationProvider
|
private lateinit var locationProvider: LocationProvider
|
||||||
private lateinit var deflockScanner: DeflockScanner
|
private lateinit var deflockScanner: DeflockScanner
|
||||||
private lateinit var wazeScanner: WazeScanner
|
|
||||||
private lateinit var citizenScanner: CitizenScanner
|
private lateinit var citizenScanner: CitizenScanner
|
||||||
|
private lateinit var overlayManager: OverlayManager
|
||||||
private var pruneJob: Job? = null
|
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 overlayJob: 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
|
||||||
private var wazeStarted = false
|
|
||||||
private var citizenStarted = false
|
private var citizenStarted = false
|
||||||
|
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
|
||||||
|
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
|
||||||
|
|
||||||
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),
|
||||||
proximityMeters = { settings.deflockProximityM.value.toFloat() }
|
proximityMeters = { settings.deflockProximityM.value.toFloat() }
|
||||||
)
|
)
|
||||||
wazeScanner = WazeScanner(
|
|
||||||
store, locationProvider,
|
|
||||||
proximityMeters = { settings.wazeProximityM.value.toFloat() }
|
|
||||||
)
|
|
||||||
citizenScanner = CitizenScanner(
|
citizenScanner = CitizenScanner(
|
||||||
store, locationProvider,
|
store, locationProvider,
|
||||||
proximityMeters = { settings.wazeProximityM.value.toFloat() }
|
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
||||||
|
)
|
||||||
|
overlayManager = OverlayManager(
|
||||||
|
context = this,
|
||||||
|
// User dragged the bubble onto the X — flip the persisted toggle
|
||||||
|
// so the setting and the bubble state stay aligned. The settings
|
||||||
|
// collector below will call hide() again, but hide() is idempotent.
|
||||||
|
onDismissed = { settings.setOverlayEnabled(false) }
|
||||||
)
|
)
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
@@ -119,13 +153,17 @@ class DetectionService : LifecycleService() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun beginScanning() {
|
private fun beginScanning() {
|
||||||
if (_running.value) return
|
if (_running.value) return
|
||||||
SourceHealth.reset()
|
SourceHealth.reset()
|
||||||
startInForeground()
|
lastNotifiedTier = ThreatLevel.GREEN
|
||||||
|
// Bring up the foreground notification BEFORE any scanner so we don't
|
||||||
|
// accidentally call startForeground after work has already begun.
|
||||||
|
startInForeground(ThreatLevel.GREEN, topEvent = null)
|
||||||
|
|
||||||
if (settings.bleEnabled.value) {
|
if (settings.bleEnabled.value) {
|
||||||
bleStarted = bleScanner.start()
|
bleStarted = bleScanner.start()
|
||||||
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
|
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
|
||||||
@@ -134,9 +172,7 @@ class DetectionService : LifecycleService() {
|
|||||||
wifiStarted = wifiScanner.start(lifecycleScope)
|
wifiStarted = wifiScanner.start(lifecycleScope)
|
||||||
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
||||||
}
|
}
|
||||||
val needsLocation = settings.deflockEnabled.value ||
|
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
|
||||||
settings.wazeEnabled.value ||
|
|
||||||
settings.citizenEnabled.value
|
|
||||||
if (needsLocation) {
|
if (needsLocation) {
|
||||||
val locOk = locationProvider.start()
|
val locOk = locationProvider.start()
|
||||||
if (!locOk) {
|
if (!locOk) {
|
||||||
@@ -145,14 +181,34 @@ class DetectionService : LifecycleService() {
|
|||||||
if (settings.deflockEnabled.value) {
|
if (settings.deflockEnabled.value) {
|
||||||
deflockScanner.start(lifecycleScope); deflockStarted = true
|
deflockScanner.start(lifecycleScope); deflockStarted = true
|
||||||
}
|
}
|
||||||
if (settings.wazeEnabled.value) {
|
|
||||||
wazeScanner.start(lifecycleScope); wazeStarted = true
|
|
||||||
}
|
|
||||||
if (settings.citizenEnabled.value) {
|
if (settings.citizenEnabled.value) {
|
||||||
citizenScanner.start(lifecycleScope); citizenStarted = true
|
citizenScanner.start(lifecycleScope); citizenStarted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted
|
||||||
|
if (!anyStarted) {
|
||||||
|
Log.w(TAG, "No scanner started — endScanning + stopSelf")
|
||||||
|
endScanning()
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIC piggybacks on the BLE/WiFi scanners. Surface its health so the
|
||||||
|
// user sees an explicit status row rather than a silent UNKNOWN.
|
||||||
|
if (settings.micEnabled.value) {
|
||||||
|
if (bleStarted || wifiStarted) {
|
||||||
|
SourceHealth.record(DetectionSource.MIC, ok = true)
|
||||||
|
} else {
|
||||||
|
SourceHealth.record(
|
||||||
|
DetectionSource.MIC,
|
||||||
|
ok = false,
|
||||||
|
message = "Needs BLE or WiFi scanner enabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_running.value = true
|
_running.value = true
|
||||||
pruneJob?.cancel()
|
pruneJob?.cancel()
|
||||||
pruneJob = lifecycleScope.launch {
|
pruneJob = lifecycleScope.launch {
|
||||||
@@ -161,21 +217,81 @@ class DetectionService : LifecycleService() {
|
|||||||
store.pruneExpired()
|
store.pruneExpired()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
observerJob?.cancel()
|
||||||
|
observerJob = lifecycleScope.launch {
|
||||||
|
// Watch threat tier + the top event together; rebuild the notification
|
||||||
|
// on either change. Vibrate only when the tier ratchets upward.
|
||||||
|
store.threatLevel.combine(store.events) { tier, events ->
|
||||||
|
tier to events.firstOrNull()
|
||||||
|
}.collect { (tier, top) ->
|
||||||
|
onTierChanged(tier, top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror scanner state to the companion StateFlows the UI observes.
|
||||||
|
// These exist so the map widget doesn't need a direct handle on the
|
||||||
|
// scanner instances (which are private to this service).
|
||||||
|
mapPointsJob?.cancel()
|
||||||
|
if (deflockStarted) {
|
||||||
|
mapPointsJob = lifecycleScope.launch {
|
||||||
|
deflockScanner.cachedPoints.collect { _mapPoints.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locationJob?.cancel()
|
||||||
|
locationJob = lifecycleScope.launch {
|
||||||
|
locationProvider.location.collect { _location.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live re-eval when the user moves a proximity slider. drop(1) skips
|
||||||
|
// the StateFlow's initial replay so we don't redundantly clear+re-emit
|
||||||
|
// the events the scanner just produced from its first handleFix call.
|
||||||
|
deflockProxJob?.cancel()
|
||||||
|
if (deflockStarted) {
|
||||||
|
deflockProxJob = lifecycleScope.launch {
|
||||||
|
settings.deflockProximityM.drop(1).collect { deflockScanner.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
citizenProxJob?.cancel()
|
||||||
|
if (citizenStarted) {
|
||||||
|
citizenProxJob = lifecycleScope.launch {
|
||||||
|
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating threat-circle overlay — observe the toggle and show/hide
|
||||||
|
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
|
||||||
|
// show() so a denied/revoked permission silently no-ops.
|
||||||
|
overlayJob?.cancel()
|
||||||
|
overlayJob = lifecycleScope.launch {
|
||||||
|
settings.overlayEnabled.collect { enabled ->
|
||||||
|
if (enabled) overlayManager.show() else overlayManager.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun endScanning() {
|
private fun endScanning() {
|
||||||
if (!_running.value) return
|
if (!_running.value && !bleStarted && !wifiStarted && !deflockStarted && !citizenStarted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_running.value = false
|
||||||
if (bleStarted) { bleScanner.stop(); bleStarted = false }
|
if (bleStarted) { bleScanner.stop(); bleStarted = false }
|
||||||
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
||||||
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
||||||
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
|
|
||||||
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
|
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
|
||||||
locationProvider.stop()
|
locationProvider.stop()
|
||||||
store.clear()
|
store.clear()
|
||||||
SourceHealth.reset()
|
SourceHealth.reset()
|
||||||
pruneJob?.cancel()
|
pruneJob?.cancel(); pruneJob = null
|
||||||
pruneJob = null
|
observerJob?.cancel(); observerJob = null
|
||||||
_running.value = false
|
mapPointsJob?.cancel(); mapPointsJob = null
|
||||||
|
locationJob?.cancel(); locationJob = null
|
||||||
|
deflockProxJob?.cancel(); deflockProxJob = null
|
||||||
|
citizenProxJob?.cancel(); citizenProxJob = null
|
||||||
|
overlayJob?.cancel(); overlayJob = null
|
||||||
|
overlayManager.hide()
|
||||||
|
_mapPoints.value = emptyList()
|
||||||
|
_location.value = null
|
||||||
|
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)
|
||||||
} else {
|
} else {
|
||||||
@@ -194,12 +310,46 @@ class DetectionService : LifecycleService() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startInForeground() {
|
private fun onTierChanged(tier: ThreatLevel, top: DetectionEvent?) {
|
||||||
val notification = buildNotification()
|
// Re-issue the foreground notification with the current tier + top event
|
||||||
|
// so a locked-screen user sees the escalation even without opening the app.
|
||||||
|
val notification = buildNotification(tier, top)
|
||||||
|
val mgr = getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
mgr.notify(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
if (tier.ordinal > lastNotifiedTier.ordinal && settings.vibrateOnAlert.value) {
|
||||||
|
vibrateForTier(tier)
|
||||||
|
}
|
||||||
|
lastNotifiedTier = tier
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrateForTier(tier: ThreatLevel) {
|
||||||
|
val v = currentVibrator() ?: return
|
||||||
|
val effect = when (tier) {
|
||||||
|
ThreatLevel.YELLOW -> VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||||
|
ThreatLevel.ORANGE -> VibrationEffect.createWaveform(longArrayOf(0, 180, 100, 180), -1)
|
||||||
|
ThreatLevel.RED -> VibrationEffect.createWaveform(
|
||||||
|
longArrayOf(0, 250, 120, 250, 120, 400), -1
|
||||||
|
)
|
||||||
|
ThreatLevel.GREEN -> return
|
||||||
|
}
|
||||||
|
try { v.vibrate(effect) } catch (e: Exception) { Log.w(TAG, "vibrate failed: ${e.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentVibrator(): Vibrator? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startInForeground(tier: ThreatLevel, topEvent: DetectionEvent?) {
|
||||||
|
val notification = buildNotification(tier, topEvent)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
// Android 14+ requires the runtime type to cover every capability
|
// Android 14+ requires the runtime type to cover every capability the
|
||||||
// the service uses. We declare both in the manifest; pass both here
|
// service uses. We declare both in the manifest; pass both here so
|
||||||
// so location-using sources (DeFlock, Waze) keep working with the
|
// location-using sources (DeFlock, Citizen) keep working with the
|
||||||
// screen off.
|
// screen off.
|
||||||
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||||
@@ -209,7 +359,7 @@ class DetectionService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(): Notification {
|
private fun buildNotification(tier: ThreatLevel, topEvent: DetectionEvent?): Notification {
|
||||||
val openIntent = Intent(this, MainActivity::class.java).apply {
|
val openIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
@@ -217,14 +367,26 @@ class DetectionService : LifecycleService() {
|
|||||||
this, 0, openIntent,
|
this, 0, openIntent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
|
val title = "OVERWATCH • ${tier.name}"
|
||||||
|
val text = topEvent?.let { "${it.score} • ${it.label}" }
|
||||||
|
?: getString(R.string.notification_text)
|
||||||
|
// Higher importance for ORANGE/RED so the system surfaces it more
|
||||||
|
// aggressively (heads-up notification, etc.). The channel was created
|
||||||
|
// with LOW; on supported versions this priority is best-effort.
|
||||||
|
val priority = when (tier) {
|
||||||
|
ThreatLevel.RED -> NotificationCompat.PRIORITY_HIGH
|
||||||
|
ThreatLevel.ORANGE -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
else -> NotificationCompat.PRIORITY_LOW
|
||||||
|
}
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle(getString(R.string.notification_title))
|
.setContentTitle(title)
|
||||||
.setContentText(getString(R.string.notification_text))
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setContentIntent(pi)
|
.setContentIntent(pi)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(priority)
|
||||||
|
.setOnlyAlertOnce(false)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package org.soulstone.overwatch.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||||
|
import androidx.savedstate.SavedStateRegistry
|
||||||
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import org.soulstone.overwatch.MainActivity
|
||||||
|
import org.soulstone.overwatch.ui.OverlayBubble
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the floating threat-circle bubble — a [ComposeView] hosted in a
|
||||||
|
* [WindowManager] window at TYPE_APPLICATION_OVERLAY.
|
||||||
|
*
|
||||||
|
* Touch model:
|
||||||
|
* - The bubble window has FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL, so
|
||||||
|
* touches outside the bubble pass through to whatever app is underneath
|
||||||
|
* and the bubble never steals IME focus.
|
||||||
|
* - Touches *inside* the bubble are intercepted by [TouchInterceptor], a
|
||||||
|
* FrameLayout wrapper whose onInterceptTouchEvent always returns true.
|
||||||
|
* Without that wrapper the inner osmdroid MapView consumes ACTION_DOWN
|
||||||
|
* for its own pan handling and the OnTouchListener never sees the gesture.
|
||||||
|
* - Drag updates the window LayoutParams via WindowManager.updateViewLayout.
|
||||||
|
* - Tap (movement under TAP_SLOP_PX) launches MainActivity.
|
||||||
|
*
|
||||||
|
* Dismiss zone:
|
||||||
|
* - When the user begins dragging, a separate WindowManager view ([DismissView])
|
||||||
|
* appears at bottom-center showing an X. The bubble's screen-space center
|
||||||
|
* is checked against the X's bounds on each MOVE; on UP, if the bubble was
|
||||||
|
* released over the X, [onDismissed] fires (caller flips the setting off
|
||||||
|
* so the toggle and the bubble state stay in sync).
|
||||||
|
* - The dismiss zone uses FLAG_NOT_TOUCHABLE so it never steals the gesture
|
||||||
|
* — it's purely visual feedback.
|
||||||
|
*/
|
||||||
|
class OverlayManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val onDismissed: () -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val TAG = "OverlayManager"
|
||||||
|
private const val INITIAL_X_DP = 24
|
||||||
|
private const val INITIAL_Y_DP = 120
|
||||||
|
private const val TAP_SLOP_PX = 12
|
||||||
|
private const val BUBBLE_SIZE_DP = 140
|
||||||
|
private const val DISMISS_SIZE_DP = 88
|
||||||
|
private const val DISMISS_BOTTOM_MARGIN_DP = 100
|
||||||
|
/** Extra slop around the dismiss zone — released within this radius
|
||||||
|
* counts as "dropped on X". */
|
||||||
|
private const val DISMISS_HIT_SLOP_DP = 16
|
||||||
|
}
|
||||||
|
|
||||||
|
private val wm: WindowManager =
|
||||||
|
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
|
private val density = context.resources.displayMetrics.density
|
||||||
|
private val bubbleSizePx = (BUBBLE_SIZE_DP * density).toInt()
|
||||||
|
|
||||||
|
private var container: TouchInterceptor? = null
|
||||||
|
private var owner: OverlayOwner? = null
|
||||||
|
private var params: WindowManager.LayoutParams? = null
|
||||||
|
|
||||||
|
private var dismissView: DismissView? = null
|
||||||
|
private var dismissCenterX = 0f
|
||||||
|
private var dismissCenterY = 0f
|
||||||
|
private var dismissHitRadius = 0f
|
||||||
|
|
||||||
|
fun show() {
|
||||||
|
if (container != null) return
|
||||||
|
if (!Settings.canDrawOverlays(context)) {
|
||||||
|
Log.i(TAG, "Overlay permission not granted; skipping show()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newOwner = OverlayOwner()
|
||||||
|
val composeView = ComposeView(context).apply {
|
||||||
|
setContent { OverlayBubble() }
|
||||||
|
}
|
||||||
|
// Wrap so we can intercept *before* MapView's own touch handling.
|
||||||
|
// Compose's WindowRecomposer reads findViewTreeLifecycleOwner from
|
||||||
|
// the *window-root* view (= the wrapper here, since it's what's
|
||||||
|
// attached to WindowManager). Setting the owner only on the inner
|
||||||
|
// ComposeView throws IllegalStateException at composition startup —
|
||||||
|
// that was the v0.3.1 crash.
|
||||||
|
val wrapper = TouchInterceptor(context).apply {
|
||||||
|
setViewTreeLifecycleOwner(newOwner)
|
||||||
|
setViewTreeSavedStateRegistryOwner(newOwner)
|
||||||
|
addView(
|
||||||
|
composeView,
|
||||||
|
FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lp = WindowManager.LayoutParams(
|
||||||
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.TOP or Gravity.START
|
||||||
|
x = (INITIAL_X_DP * density).toInt()
|
||||||
|
y = (INITIAL_Y_DP * density).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.setOnTouchListener(DragHandler(lp))
|
||||||
|
|
||||||
|
try {
|
||||||
|
wm.addView(wrapper, lp)
|
||||||
|
container = wrapper
|
||||||
|
owner = newOwner
|
||||||
|
params = lp
|
||||||
|
Log.i(TAG, "Overlay bubble attached")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to attach overlay: ${e.message}")
|
||||||
|
newOwner.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hide() {
|
||||||
|
val v = container ?: return
|
||||||
|
try { wm.removeView(v) } catch (e: Exception) {
|
||||||
|
Log.w(TAG, "removeView failed: ${e.message}")
|
||||||
|
}
|
||||||
|
owner?.destroy()
|
||||||
|
container = null
|
||||||
|
owner = null
|
||||||
|
params = null
|
||||||
|
hideDismissZone()
|
||||||
|
Log.i(TAG, "Overlay bubble detached")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDismissZone() {
|
||||||
|
if (dismissView != null) return
|
||||||
|
val sizePx = (DISMISS_SIZE_DP * density).toInt()
|
||||||
|
val marginPx = (DISMISS_BOTTOM_MARGIN_DP * density).toInt()
|
||||||
|
|
||||||
|
// Precompute screen-space center so MOVE checks don't traverse the
|
||||||
|
// view tree on every frame. Use displayMetrics (sufficient — a real
|
||||||
|
// multi-display split would need WindowManager#getCurrentWindowMetrics
|
||||||
|
// on API 30+, but the bubble lives on the user's primary display).
|
||||||
|
val dm = context.resources.displayMetrics
|
||||||
|
dismissCenterX = dm.widthPixels / 2f
|
||||||
|
dismissCenterY = dm.heightPixels - marginPx - sizePx / 2f
|
||||||
|
dismissHitRadius = sizePx / 2f + DISMISS_HIT_SLOP_DP * density
|
||||||
|
|
||||||
|
val v = DismissView(context)
|
||||||
|
val lp = WindowManager.LayoutParams(
|
||||||
|
sizePx, sizePx,
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||||
|
y = marginPx
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
wm.addView(v, lp)
|
||||||
|
dismissView = v
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to attach dismiss zone: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideDismissZone() {
|
||||||
|
val v = dismissView ?: return
|
||||||
|
try { wm.removeView(v) } catch (_: Exception) {}
|
||||||
|
dismissView = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOverDismiss(bubbleX: Int, bubbleY: Int): Boolean {
|
||||||
|
if (dismissView == null) return false
|
||||||
|
val cx = bubbleX + bubbleSizePx / 2f
|
||||||
|
val cy = bubbleY + bubbleSizePx / 2f
|
||||||
|
val dx = cx - dismissCenterX
|
||||||
|
val dy = cy - dismissCenterY
|
||||||
|
return sqrt((dx * dx + dy * dy).toDouble()) < dismissHitRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drag with raw coords; tap if movement stayed under [TAP_SLOP_PX]. */
|
||||||
|
private inner class DragHandler(private val lp: WindowManager.LayoutParams) :
|
||||||
|
View.OnTouchListener {
|
||||||
|
|
||||||
|
private var startX = 0
|
||||||
|
private var startY = 0
|
||||||
|
private var touchDownX = 0f
|
||||||
|
private var touchDownY = 0f
|
||||||
|
private var moved = false
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
override fun onTouch(v: View, ev: MotionEvent): Boolean {
|
||||||
|
return when (ev.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
startX = lp.x
|
||||||
|
startY = lp.y
|
||||||
|
touchDownX = ev.rawX
|
||||||
|
touchDownY = ev.rawY
|
||||||
|
moved = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val dx = ev.rawX - touchDownX
|
||||||
|
val dy = ev.rawY - touchDownY
|
||||||
|
if (!moved && (abs(dx) > TAP_SLOP_PX || abs(dy) > TAP_SLOP_PX)) {
|
||||||
|
moved = true
|
||||||
|
showDismissZone()
|
||||||
|
}
|
||||||
|
if (moved) {
|
||||||
|
lp.x = startX + dx.toInt()
|
||||||
|
lp.y = startY + dy.toInt()
|
||||||
|
try { wm.updateViewLayout(v, lp) } catch (_: Exception) {}
|
||||||
|
dismissView?.setHighlighted(isOverDismiss(lp.x, lp.y))
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (moved && isOverDismiss(lp.x, lp.y)) {
|
||||||
|
// Released on the X — tear down and signal the caller
|
||||||
|
// so the persisted setting flips off too.
|
||||||
|
hide()
|
||||||
|
onDismissed()
|
||||||
|
} else if (!moved) {
|
||||||
|
// Tap → bring the host app forward.
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
try { context.startActivity(intent) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
hideDismissZone()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
hideDismissZone()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always-claiming wrapper. Without this, the osmdroid MapView descendant
|
||||||
|
* consumes ACTION_DOWN for pan handling and the OnTouchListener never
|
||||||
|
* fires — drags pan the map instead of moving the bubble. */
|
||||||
|
private class TouchInterceptor(context: Context) : FrameLayout(context) {
|
||||||
|
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom-center dismiss target — translucent dark circle with a white X
|
||||||
|
* that flips red when the bubble is hovering over it. Drawn manually so
|
||||||
|
* we don't need to ship a vector resource for one-off use. */
|
||||||
|
private class DismissView(context: Context) : View(context) {
|
||||||
|
private val bg = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val xStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0xFFFFFFFF.toInt()
|
||||||
|
strokeWidth = 8f
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
private var highlighted = false
|
||||||
|
|
||||||
|
fun setHighlighted(value: Boolean) {
|
||||||
|
if (highlighted != value) {
|
||||||
|
highlighted = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val cx = width / 2f
|
||||||
|
val cy = height / 2f
|
||||||
|
val r = minOf(cx, cy)
|
||||||
|
bg.color = if (highlighted) 0xDDD7263D.toInt() else 0xCC1A1A1A.toInt()
|
||||||
|
canvas.drawCircle(cx, cy, r - 4f, bg)
|
||||||
|
val inset = r * 0.35f
|
||||||
|
canvas.drawLine(cx - inset, cy - inset, cx + inset, cy + inset, xStroke)
|
||||||
|
canvas.drawLine(cx + inset, cy - inset, cx - inset, cy + inset, xStroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose's [ComposeView] requires both a LifecycleOwner and a
|
||||||
|
* [SavedStateRegistryOwner] in its view tree. A bare Service isn't an SSR
|
||||||
|
* owner, so we synthesize one bound to the bubble's lifetime. The
|
||||||
|
* lifecycle is forced to RESUMED on construction (Compose only renders at
|
||||||
|
* STARTED+) and DESTROYED on [destroy].
|
||||||
|
*/
|
||||||
|
private class OverlayOwner : SavedStateRegistryOwner {
|
||||||
|
private val lifecycleReg = LifecycleRegistry(this)
|
||||||
|
private val ssrController = SavedStateRegistryController.create(this)
|
||||||
|
|
||||||
|
init {
|
||||||
|
ssrController.performAttach()
|
||||||
|
ssrController.performRestore(null)
|
||||||
|
lifecycleReg.currentState = Lifecycle.State.RESUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
override val lifecycle: Lifecycle get() = lifecycleReg
|
||||||
|
override val savedStateRegistry: SavedStateRegistry
|
||||||
|
get() = ssrController.savedStateRegistry
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
lifecycleReg.currentState = Lifecycle.State.DESTROYED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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
|
||||||
@@ -20,7 +23,9 @@ 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 androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -37,7 +42,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -48,11 +53,18 @@ 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 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.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)
|
||||||
@@ -62,14 +74,21 @@ fun MainScreen(
|
|||||||
threat: ThreatLevel,
|
threat: ThreatLevel,
|
||||||
score: Int,
|
score: Int,
|
||||||
events: List<DetectionEvent>,
|
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,
|
onStartStop: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
canStart: Boolean,
|
canStart: Boolean,
|
||||||
permissionMessage: String?
|
permissionMessage: String?,
|
||||||
|
showOpenAppSettings: Boolean = false,
|
||||||
|
onOpenAppSettings: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var showSheet by remember { mutableStateOf(false) }
|
var showSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val sheetScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -77,30 +96,26 @@ fun MainScreen(
|
|||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(horizontal = 24.dp)
|
.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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp),
|
.padding(top = 8.dp)
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Text(
|
||||||
Text(
|
text = "OVERWATCH",
|
||||||
text = "[DЯΣΛMMΛKΣЯ]",
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
fontSize = 26.sp,
|
||||||
fontSize = 22.sp,
|
fontWeight = FontWeight.Bold,
|
||||||
fontWeight = FontWeight.Bold,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontFamily = FontFamily.Monospace
|
letterSpacing = 4.sp,
|
||||||
)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
Text(
|
)
|
||||||
text = " . //0VΣЯW4TCH",
|
IconButton(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
onClick = onOpenSettings,
|
||||||
fontSize = 18.sp,
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
fontWeight = FontWeight.Medium,
|
) {
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onOpenSettings) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Settings,
|
Icons.Filled.Settings,
|
||||||
contentDescription = "Settings",
|
contentDescription = "Settings",
|
||||||
@@ -115,7 +130,14 @@ 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,
|
||||||
|
mapRadiusMeters = mapRadiusMeters,
|
||||||
|
onTap = { showSheet = true }
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -159,6 +181,24 @@ fun MainScreen(
|
|||||||
fontSize = 13.sp
|
fontSize = 13.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showOpenAppSettings) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onOpenAppSettings,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Open app settings",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,16 +225,25 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
|
private fun ThreatMapCircle(
|
||||||
val color = when (level) {
|
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
|
ThreatLevel.GREEN -> ThreatColors.Green
|
||||||
ThreatLevel.YELLOW -> ThreatColors.Yellow
|
ThreatLevel.YELLOW -> ThreatColors.Yellow
|
||||||
ThreatLevel.ORANGE -> ThreatColors.Orange
|
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||||
ThreatLevel.RED -> ThreatColors.Red
|
ThreatLevel.RED -> ThreatColors.Red
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
@@ -202,29 +251,129 @@ 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),
|
||||||
.background(
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
color.copy(alpha = alpha),
|
|
||||||
color.copy(alpha = alpha * 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.clickable(onClick = onTap),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
// While idle OR before the first location fix arrives, fall back to the
|
||||||
text = level.name,
|
// solid pulsing circle — a blank/loading map mid-tile-fetch reads as
|
||||||
color = Color.White,
|
// broken. The map only renders once we actually have something to show.
|
||||||
fontSize = 28.sp,
|
if (!animating || userLocation == null) {
|
||||||
fontWeight = FontWeight.Black,
|
val color = if (animating) activeColor else idleColor
|
||||||
fontFamily = FontFamily.Monospace
|
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, DOT_USER_BLUE) }
|
||||||
|
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,6 +421,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
|
@Composable
|
||||||
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
||||||
val health by SourceHealth.flowFor(source).collectAsState()
|
val health by SourceHealth.flowFor(source).collectAsState()
|
||||||
@@ -291,7 +448,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = source.name,
|
text = source.displayLabel(),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -329,14 +486,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
events.take(3).forEach { e ->
|
events.take(3).forEach { e -> EventRow(e) }
|
||||||
Text(
|
|
||||||
text = "${e.score} • ${e.label} • ${e.matchedMethods}",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (events.size > 3) {
|
if (events.size > 3) {
|
||||||
Text(
|
Text(
|
||||||
text = "+${events.size - 3} more",
|
text = "+${events.size - 3} more",
|
||||||
@@ -348,3 +498,52 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventRow(e: DetectionEvent) {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${e.score} • ${e.label} • ${e.matchedMethods}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.weight(1f, fill = true)
|
||||||
|
)
|
||||||
|
if (e.hasGeo) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
// Force the pin to open in Google Maps rather than whichever
|
||||||
|
// app holds the user's default geo: handler — Waze, etc. can
|
||||||
|
// intercept geo: intents and we don't want that here. Falls
|
||||||
|
// back to a generic browser intent if Maps isn't installed.
|
||||||
|
val mapsUri = Uri.parse(
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}"
|
||||||
|
)
|
||||||
|
val mapsIntent = Intent(Intent.ACTION_VIEW, mapsUri)
|
||||||
|
.setPackage("com.google.android.apps.maps")
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try {
|
||||||
|
ctx.startActivity(mapsIntent)
|
||||||
|
} catch (_: android.content.ActivityNotFoundException) {
|
||||||
|
val fallback = Intent(Intent.ACTION_VIEW, mapsUri)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try { ctx.startActivity(fallback) } catch (_: android.content.ActivityNotFoundException) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Place,
|
||||||
|
contentDescription = "Open in Maps",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.soulstone.overwatch.ui
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
|
||||||
|
/** Builds a small filled-circle Marker icon. Used for both the user-position
|
||||||
|
* dot (blue) and the ALPR pins (red) — osmdroid's default teardrop marker
|
||||||
|
* reads as a "click me" affordance which is wrong for a non-interactive
|
||||||
|
* visualization, so we use simple dots instead. Shared by the in-app and
|
||||||
|
* overlay versions of the threat circle. */
|
||||||
|
internal fun dotDrawable(
|
||||||
|
resources: Resources,
|
||||||
|
sizePx: Int,
|
||||||
|
coreColor: Int
|
||||||
|
): BitmapDrawable {
|
||||||
|
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
val outline = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFFFFFFF.toInt() }
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, outline)
|
||||||
|
val core = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = coreColor }
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 4f, core)
|
||||||
|
return BitmapDrawable(resources, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val DOT_USER_BLUE = 0xFF2196F3.toInt()
|
||||||
|
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt()
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package org.soulstone.overwatch.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
import org.osmdroid.util.BoundingBox
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
import org.soulstone.overwatch.data.settings.Settings
|
||||||
|
import org.soulstone.overwatch.fusion.ThreatLevel
|
||||||
|
import org.soulstone.overwatch.service.DetectionService
|
||||||
|
import org.soulstone.overwatch.ui.theme.ThreatColors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smaller "chat-bubble" version of the threat-map circle, hosted in a
|
||||||
|
* WindowManager overlay by [org.soulstone.overwatch.service.OverlayManager].
|
||||||
|
*
|
||||||
|
* Self-contained: pulls all of its data from the same companion-level
|
||||||
|
* StateFlows the in-app [MainScreen] uses (DetectionService.running / store /
|
||||||
|
* mapPoints / location) plus the proximity sliders from [Settings]. The
|
||||||
|
* caller doesn't pass any state — keeps the OverlayManager dumb.
|
||||||
|
*
|
||||||
|
* Tap and drag are handled at the View layer (OverlayManager's OnTouchListener);
|
||||||
|
* this composable is render-only.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OverlayBubble() {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val settings = remember(ctx) { Settings.get(ctx) }
|
||||||
|
|
||||||
|
val running by DetectionService.running.collectAsState()
|
||||||
|
val threat by DetectionService.store.threatLevel.collectAsState()
|
||||||
|
val userLocation by DetectionService.location.collectAsState()
|
||||||
|
val mapPoints by DetectionService.mapPoints.collectAsState()
|
||||||
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
|
val radius = max(deflockProx, citizenProx).toFloat()
|
||||||
|
|
||||||
|
val activeColor = when (threat) {
|
||||||
|
ThreatLevel.GREEN -> ThreatColors.Green
|
||||||
|
ThreatLevel.YELLOW -> ThreatColors.Yellow
|
||||||
|
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||||
|
ThreatLevel.RED -> ThreatColors.Red
|
||||||
|
}
|
||||||
|
|
||||||
|
val transition = rememberInfiniteTransition(label = "overlay-pulse")
|
||||||
|
val pulse by transition.animateFloat(
|
||||||
|
initialValue = 0.55f,
|
||||||
|
targetValue = 1.0f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "overlay-pulse"
|
||||||
|
)
|
||||||
|
|
||||||
|
val userDot = remember(ctx) { dotDrawable(ctx.resources, 30, DOT_USER_BLUE) }
|
||||||
|
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(140.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// The OverlayManager only attaches the bubble while running == true,
|
||||||
|
// but check anyway — paranoia keeps the bubble from rendering a stale
|
||||||
|
// map if a future code path lets the composition outlive the service.
|
||||||
|
val fix = userLocation
|
||||||
|
if (!running || fix == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
activeColor.copy(alpha = pulse),
|
||||||
|
activeColor.copy(alpha = pulse * 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { c ->
|
||||||
|
MapView(c).apply {
|
||||||
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
|
setMultiTouchControls(false)
|
||||||
|
setBuiltInZoomControls(false)
|
||||||
|
isClickable = false
|
||||||
|
isFocusable = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { map ->
|
||||||
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
|
map.overlays.clear()
|
||||||
|
for (p in mapPoints) {
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(p.lat, p.lon)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = flockDot
|
||||||
|
title = p.operator ?: p.manufacturer ?: "ALPR"
|
||||||
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(fix.latitude, fix.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = userDot
|
||||||
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val r = radius.toDouble().coerceAtLeast(50.0)
|
||||||
|
val latDegPerMeter = 1.0 / 111_000.0
|
||||||
|
val lonDegPerMeter = 1.0 /
|
||||||
|
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
||||||
|
val bbox = BoundingBox(
|
||||||
|
fix.latitude + r * latDegPerMeter,
|
||||||
|
fix.longitude + r * lonDegPerMeter,
|
||||||
|
fix.latitude - r * latDegPerMeter,
|
||||||
|
fix.longitude - r * lonDegPerMeter
|
||||||
|
)
|
||||||
|
map.post { map.zoomToBoundingBox(bbox, false, 0) }
|
||||||
|
map.invalidate()
|
||||||
|
},
|
||||||
|
onRelease = { map -> map.onDetach() }
|
||||||
|
)
|
||||||
|
// Tier scrim — same pulse alpha range as the in-app circle.
|
||||||
|
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(activeColor.copy(alpha = scrimAlpha))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.soulstone.overwatch.ui
|
package org.soulstone.overwatch.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -11,6 +14,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -27,6 +31,9 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -45,11 +52,14 @@ fun SettingsScreen(
|
|||||||
val ble by settings.bleEnabled.collectAsState()
|
val ble by settings.bleEnabled.collectAsState()
|
||||||
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 waze by settings.wazeEnabled.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 wazeProx by settings.wazeProximityM.collectAsState()
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
val theme by settings.themeMode.collectAsState()
|
val theme by settings.themeMode.collectAsState()
|
||||||
|
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||||
|
val overlay by settings.overlayEnabled.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -78,8 +88,8 @@ fun SettingsScreen(
|
|||||||
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
|
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
|
||||||
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("WAZE • Live police reports (gated)", waze) { settings.setWazeEnabled(it) }
|
|
||||||
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(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))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
Button(
|
Button(
|
||||||
@@ -109,21 +119,49 @@ fun SettingsScreen(
|
|||||||
SectionLabel("Proximity thresholds")
|
SectionLabel("Proximity thresholds")
|
||||||
SliderRow(
|
SliderRow(
|
||||||
label = "DeFlock alert distance",
|
label = "DeFlock alert distance",
|
||||||
valueLabel = "${deflockProx} m",
|
persistedValue = deflockProx,
|
||||||
value = deflockProx.toFloat(),
|
|
||||||
range = 50f..1600f,
|
range = 50f..1600f,
|
||||||
steps = 30,
|
steps = 30,
|
||||||
onChange = { settings.setDeflockProximityM(it.toInt()) }
|
onCommit = { settings.setDeflockProximityM(it) }
|
||||||
)
|
)
|
||||||
SliderRow(
|
SliderRow(
|
||||||
label = "Waze alert distance",
|
label = "Citizen alert distance",
|
||||||
valueLabel = "${wazeProx} m",
|
persistedValue = citizenProx,
|
||||||
value = wazeProx.toFloat(),
|
|
||||||
range = 100f..5000f,
|
range = 100f..5000f,
|
||||||
steps = 48,
|
steps = 48,
|
||||||
onChange = { settings.setWazeProximityM(it.toInt()) }
|
onCommit = { settings.setCitizenProximityM(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionLabel("Alerts")
|
||||||
|
SourceToggle("Vibrate on threat escalation", vibrate) { settings.setVibrateOnAlert(it) }
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionLabel("Display over other apps")
|
||||||
|
SourceToggle("Floating threat circle", overlay) { enabled ->
|
||||||
|
settings.setOverlayEnabled(enabled)
|
||||||
|
// Special-access perm: can't be granted via runtime prompt. Bounce
|
||||||
|
// the user to the system settings page for this app so they can
|
||||||
|
// approve. The DetectionService re-checks canDrawOverlays at show()
|
||||||
|
// time so a denied/revoked perm just means the bubble silently
|
||||||
|
// doesn't appear — no crash.
|
||||||
|
if (enabled && !AndroidSettings.canDrawOverlays(context)) {
|
||||||
|
val intent = Intent(
|
||||||
|
AndroidSettings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||||
|
Uri.parse("package:${context.packageName}")
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try { context.startActivity(intent) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlay && !AndroidSettings.canDrawOverlays(context)) {
|
||||||
|
Text(
|
||||||
|
"Permission needed — system page should have opened. If not, grant manually under Apps → OVERWATCH → Display over other apps.",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionLabel("Appearance")
|
SectionLabel("Appearance")
|
||||||
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
||||||
@@ -161,25 +199,35 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
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(
|
||||||
text = label,
|
text = label,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = true)
|
||||||
|
.padding(end = 12.dp)
|
||||||
)
|
)
|
||||||
Switch(checked = value, onCheckedChange = onChange)
|
Switch(checked = value, onCheckedChange = onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slider that commits the value to Settings only on drag-release. The label
|
||||||
|
* tracks the live drag position locally to avoid spamming SharedPreferences
|
||||||
|
* writes (and downstream StateFlow re-emissions) on every pixel of movement.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SliderRow(
|
private fun SliderRow(
|
||||||
label: String,
|
label: String,
|
||||||
valueLabel: String,
|
persistedValue: Int,
|
||||||
value: Float,
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
range: ClosedFloatingPointRange<Float>,
|
||||||
steps: Int,
|
steps: Int,
|
||||||
onChange: (Float) -> Unit
|
onCommit: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
|
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
|
||||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -192,15 +240,16 @@ private fun SliderRow(
|
|||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = valueLabel,
|
text = "${live.toInt()} m",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(
|
Slider(
|
||||||
value = value,
|
value = live,
|
||||||
onValueChange = onChange,
|
onValueChange = { live = it },
|
||||||
|
onValueChangeFinished = { onCommit(live.toInt()) },
|
||||||
valueRange = range,
|
valueRange = range,
|
||||||
steps = steps
|
steps = steps
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user