4 Commits

Author SHA1 Message Date
KaraZajac 2ff63da9b5 v0.1.6 — audit fixes (critical, moderate, minor) + UX polish
Critical
--------
- DetectionService: subscribe to threatLevel + top event flows; rebuild the
  foreground notification on every change so a locked-screen user sees
  escalations. Vibrate on upward tier transitions (escalating waveforms for
  YELLOW/ORANGE/RED), gated by Settings.vibrateOnAlert (default on).
- DetectionService: only mark _running=true if at least one scanner started;
  stopSelf() if everything was disabled or denied. Switch START_STICKY →
  START_NOT_STICKY so a system-killed service doesn't re-create into a
  stuck "running but not scanning" state.
- DeflockClient: detect Overpass timeout-in-body (`{"remark": "...timed
  out..."}`) and treat as failure — previously these 200-with-empty-elements
  responses got cached for 24 h, hiding ALPRs in that 5×5 km cell for the
  next day.
- DeflockScanner: record lastFetch coords + timestamp on BOTH success and
  failure, with a 60 s backoff window after a failed attempt. Previously
  `lastFetchLat` was only set on Success, so every subsequent location
  update would re-trigger a 30 s POST that collectLatest then cancelled —
  we'd never finish a fetch under sustained Overpass slowness.
- LocationProvider: stale-lastLocation race fix. The async `lastLocation`
  callback now only seeds `_location` if it's still null and we're still
  running — previously it could overwrite a fresher fix from
  requestLocationUpdates, or fire after stop() and resurrect _location with
  stale data.

Moderate
--------
- CitizenScanner: wait for the first non-null location with .first { } before
  starting the poll/delay loop. First Citizen poll now fires within seconds
  of the location fix, not up to 60 s after.
- MainScreen: when not running, show a muted gray circle with "IDLE" text
  instead of the same solid green look as "scanning, all clear" — the
  pulse animation was the only differentiator before.
- Compose state: rememberSaveable for the screen enum + bottom-sheet open
  state, so SETTINGS survives rotation.
- MainActivity: detect permanently-denied permissions (the user picked
  "don't ask again") via shouldShowRequestPermissionRationale. UI swaps the
  call-to-action to "Open app settings" which fires
  Settings.ACTION_APPLICATION_DETAILS_SETTINGS. onResume re-checks so a
  user returning from app settings is reflected immediately.

Improvements
------------
- BLE/WiFi scanners record SourceHealth.OK on a successful start (and
  FAILED with a specific reason on every short-circuit — disabled adapter,
  missing permission, etc.) so the drill-down sheet is honest about radio
  state, not just network state.
- DetectionEvent gains optional lat/lon (populated by DEFLOCK and CITIZEN);
  SourceRow shows a tap-to-open-Maps icon next to events with coordinates,
  firing a `geo:lat,lon?q=lat,lon(label)` Intent.
- SettingsScreen sliders use onValueChangeFinished — only commit to
  SharedPreferences on drag-release, not on every pixel of movement.
- New Settings.vibrateOnAlert toggle (default on) wired to a SettingsScreen
  row under a new "Alerts" section.

Minor
-----
- BleScanner iterates ALL manufacturer-data entries to find XUNTONG; only
  falls back to the first entry if no XUNTONG match is present. Previously
  we only inspected the first entry.
- Drop dead `?.` on JSONArray.optString in CitizenClient (returns String,
  never null).
- Remove unused rememberCoroutineScope in MainScreen.
- Update stale Phase/Waze references in DetectionService comments.
- Add VIBRATE permission to manifest.

versionCode 6 → 7, versionName 0.1.5 → 0.1.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:11:56 -04:00
KaraZajac c9cafb3e67 v0.1.5 — remove Waze entirely
Waze's reCAPTCHA gating on live-map/api/georss has no clean mobile
workaround, and the Citizen source added in v0.1.4 covers the same
threat model with better data. Keeping a permanently-failed source
visible was UI clutter — drop it.

Removed:
  - scan/WazeClient.kt and scan/WazeScanner.kt (deleted)
  - WAZE from DetectionSource enum
  - waze flow from SourceHealth (+ flowFor/record/reset cases)
  - WazeObservation + scoreWaze + W_WAZE_POLICE from ConfidenceEngine
  - wazeEnabled from Settings (+ KEY_WAZE)
  - WAZE row from SettingsScreen
  - wazeScanner from DetectionService

Renamed (Citizen now owns the proximity slider that Waze used to share):
  - Settings.wazeProximityM → citizenProximityM
  - Settings.setWazeProximityM → setCitizenProximityM
  - KEY_WAZE_PROX → KEY_CITIZEN_PROX
  - DEFAULT_WAZE_PROX → DEFAULT_CITIZEN_PROX (still 500)
  - SettingsScreen "Waze alert distance" → "Citizen alert distance"

Existing users will see the slider reset to 500 m default since the
SharedPreferences key changed.

versionCode 5 → 6, versionName 0.1.4 → 0.1.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:56:27 -04:00
KaraZajac 508430e1e3 v0.1.4 — Citizen.com as 5th detection source
Waze remains gated behind 2025/2026 reCAPTCHA on live-map; added Citizen
as a working alternative for police-presence signal. Citizen pulls from
911 + scanner traffic, returns rich incident data (lat/lon, timestamp,
severity level, responding precinct, title), and has no auth or
rate-limit gating.

New scan/CitizenClient.kt:
  - GET /api/incident/trending (bbox query → list of incident ids)
  - GET /api/incident/{id}      (full detail per id)
  - Sealed TrendingResult so the scanner can surface 4xx via SourceHealth.

New scan/CitizenScanner.kt:
  - 60s poll interval, 30-min freshness window
  - Per-id detail cache for the lifetime of a start/stop cycle —
    incidents are immutable, so each is fetched at most once per session
  - Title regex filter: drops pure fire/medical events that don't imply
    police presence; retains them when the title also names police action
  - Submits to the shared DetectionStore as DetectionSource.CITIZEN

ConfidenceEngine.scoreCitizen:
  - Base 55 (matches the old W_WAZE_POLICE weight)
  - +5 if level >= 2 (Citizen's own severity)
  - +5 if title contains police-action keyword (police/officer/arrest/
    swat/tactical/raid/pursuit/stop/search warrant)

Settings: new citizenEnabled toggle (default on); UI row in
SettingsScreen. SourceHealth has a new flow for CITIZEN. DetectionService
starts the scanner alongside the others when location is available.

Continued investigation of Waze / Google Maps police APIs:
  - Waze SDK (hewliyang/waze-traffic-api): wraps the same blocked endpoint
  - ddd/google_maps reverse-engineering: locations only, no incidents
  - Google Maps Platform: no public incidents API (just displays Waze data internally)
  - TomTom Traffic Incidents: traffic-only, no police presence
  - Waze for Cities partner feed: real but requires being a city/police agency

versionCode 4 → 5, versionName 0.1.3 → 0.1.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:48:54 -04:00
KaraZajac 451376e497 v0.1.3 — DeFlock via Overpass + per-source health UI
The cdn.deflock.me CDN is gated behind Cloudflare bot mitigation that
mobile HTTP clients can't pass. The live deflock-app Flutter client
abandoned that path; it POSTs Overpass-QL queries directly to
overpass.deflock.org (with overpass-api.de as a fallback). Verified by
hitting the same endpoint from curl — 22 ALPRs returned for the
Springfield VA bbox, matching the user's screenshot of the working app.

DeflockClient rewrite:
  - POST [out:json][timeout:25];(node[surveillance][type=ALPR](bbox););out body;
  - 5 km half-width bbox around the user
  - 24h on-disk cache keyed by 0.05° grid cell (revisits don't refetch)
  - Returns sealed FetchResult: Success(points) | Failed(reason)

DeflockScanner update:
  - Replaces 20° tile concept with distance-based refetch (1.5 km threshold)
  - Records SourceHealth on each fetch outcome

Waze: reCAPTCHA gating confirmed. WazeClient.fetchPoliceNear now returns
sealed FetchResult; WazeScanner records SourceHealth.FAILED with
"Upstream blocked (HTTP 403)" so the user sees why no Waze data is
flowing instead of silent zeros.

New fusion/SourceHealth.kt — per-source MutableStateFlow registry,
record(source, ok, message) + reset() called on service start/stop.

UI: SourceRow in the bottom-sheet drill-down now shows the health
message in orange when status = FAILED instead of "no detections".

versionCode 3 → 4, versionName 0.1.2 → 0.1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:36:47 -04:00
21 changed files with 930 additions and 396 deletions
+4 -2
View File
@@ -19,8 +19,10 @@ camera, or police presence near you.
|---|---|---|
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API) |
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | Public CDN tile fetch from `cdn.deflock.me`, 24h on-disk cache |
| **WAZE** | Live `POLICE` reports within configurable proximity (default 500 m) and < 10 min old | `live-map/api/georss` polled every 60 s with a small bbox around the user |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. |
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. |
> **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.
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources:
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "0.1.2"
versionCode = 7
versionName = "0.1.6"
}
buildTypes {
+3
View File
@@ -35,6 +35,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Vibration on threat-tier escalation -->
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
@@ -1,17 +1,22 @@
package org.soulstone.overwatch
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.service.DetectionService
@@ -44,6 +49,7 @@ class MainActivity : ComponentActivity() {
) { result ->
val allGranted = result.all { it.value }
permissionsGranted.value = allGranted
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
if (allGranted) {
// First-run path: user just granted everything, kick off scanning
// immediately so they don't have to tap START a second time.
@@ -51,17 +57,22 @@ 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?) {
super.onCreate(savedInstanceState)
permissionsGranted.value = checkAllPermissions()
permanentlyDenied.value = false // reset on activity create
val settings = Settings.get(this)
setContent {
val themeMode by settings.themeMode.collectAsState()
OverwatchTheme(mode = themeMode) {
var screen by remember { mutableStateOf(Screen.MAIN) }
var screen by rememberSaveable { mutableStateOf(Screen.MAIN) }
when (screen) {
Screen.MAIN -> {
@@ -70,6 +81,13 @@ class MainActivity : ComponentActivity() {
val threat by DetectionService.store.threatLevel.collectAsState()
val maxScore by DetectionService.store.maxScore.collectAsState()
val granted by permissionsGranted
val denied by permanentlyDenied
val message = when {
granted -> null
denied -> "Permissions permanently denied — open app settings to grant"
else -> "Tap START to grant Bluetooth, WiFi + location permissions"
}
MainScreen(
running = running,
@@ -77,13 +95,17 @@ class MainActivity : ComponentActivity() {
score = maxScore,
events = events,
canStart = true,
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
permissionMessage = message,
showOpenAppSettings = denied && !granted,
onOpenAppSettings = { openAppSettings() },
onStartStop = {
if (running) {
DetectionService.stop(this)
} else {
if (granted) {
DetectionService.start(this)
} else if (denied) {
openAppSettings()
} else {
permissionLauncher.launch(requiredPermissions)
}
@@ -111,7 +133,10 @@ class MainActivity : ComponentActivity() {
override fun 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 =
@@ -119,5 +144,23 @@ class MainActivity : ComponentActivity() {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
/** True if at least one missing permission is still askable via the system
* prompt. False means everything missing was denied with "don't ask again". */
private fun anyMissingCanStillAsk(): Boolean {
val missing = requiredPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) return true
return missing.any { ActivityCompat.shouldShowRequestPermissionRationale(this, it) }
}
private fun openAppSettings() {
val intent = Intent(
AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private enum class Screen { MAIN, SETTINGS }
}
@@ -49,12 +49,13 @@ class LocationProvider(private val context: Context) {
private val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
if (!running) return
val fix = result.lastLocation ?: return
_location.value = fix
}
}
private var running = false
@Volatile private var running = false
fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
@@ -68,12 +69,22 @@ class LocationProvider(private val context: Context) {
return false
}
try {
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
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")
return true
} catch (e: SecurityException) {
running = false
Log.e(TAG, "SecurityException starting location updates", e)
return false
}
@@ -29,28 +29,31 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
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))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _deflockProximityM = MutableStateFlow(
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX)
)
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
private val _citizenProximityM = MutableStateFlow(
prefs.getInt(KEY_CITIZEN_PROX, DEFAULT_CITIZEN_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
private val _themeMode = MutableStateFlow(
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
)
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
fun 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 setDeflockProximityM(v: Int) {
val clamped = v.coerceIn(50, 1600)
@@ -58,10 +61,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_deflockProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
fun setCitizenProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
prefs.edit { putInt(KEY_CITIZEN_PROX, clamped) }
_citizenProximityM.value = clamped
}
fun setThemeMode(mode: ThemeMode) {
@@ -69,18 +72,24 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_themeMode.value = mode
}
fun setVibrateOnAlert(v: Boolean) {
prefs.edit { putBoolean(KEY_VIBRATE, v) }
_vibrateOnAlert.value = v
}
companion object {
private const val PREFS = "overwatch_settings"
private const val KEY_BLE = "src_ble"
private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_WAZE = "src_waze"
private const val KEY_CITIZEN = "src_citizen"
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_VIBRATE = "vibrate_on_alert"
const val DEFAULT_DEFLOCK_PROX = 200
const val DEFAULT_WAZE_PROX = 500
const val DEFAULT_CITIZEN_PROX = 500
@Volatile private var INSTANCE: Settings? = null
@@ -24,10 +24,14 @@ object ConfidenceEngine {
const val W_WIFI_SSID_GENERIC = 50
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_VERY_NEAR = 85 // <= 50m
const val W_WAZE_POLICE = 55
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
const val W_CITIZEN_INCIDENT = 55
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
// Bonuses
const val B_MULTI_METHOD = 20
@@ -61,14 +65,16 @@ object ConfidenceEngine {
val manufacturer: String?
)
/** A Waze POLICE alert observed within proximity + freshness thresholds. */
data class WazeObservation(
val uuid: String,
/** A Citizen incident observed within proximity + freshness, after the
* fire/medical filter is applied. */
data class CitizenObservation(
val incidentId: String,
val distanceMeters: Float,
val ageMs: Long,
val confidence: Int, // raw 0-5
val reliability: Int, // raw 0-10
val subtype: String?
val level: Int, // 0-5 severity (Citizen's own scale)
val title: String,
val isPoliceTitled: Boolean,
val precinct: String?
)
data class Scored(
@@ -169,20 +175,22 @@ object ConfidenceEngine {
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
fun scoreCitizen(obs: CitizenObservation): Scored {
var score = W_CITIZEN_INCIDENT
val tags = StringBuilder("citizen ")
if (obs.level >= 2) {
score += B_CITIZEN_LEVEL_BUMP
tags.append("L${obs.level} ")
}
if (obs.isPoliceTitled) {
score += B_CITIZEN_POLICE_TITLE
tags.append("police_title ")
}
if (!obs.precinct.isNullOrBlank()) tags.append("precinct=${obs.precinct} ")
score = score.coerceAtMost(100)
val 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)
val label = "${obs.title} @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago"
return Scored(score, tags.toString().trim(), label, isAxon = false)
}
fun scoreDeflock(obs: DeflockObservation): Scored {
@@ -4,11 +4,12 @@ package org.soulstone.overwatch.fusion
* One observation from one source at one moment.
*
* @param source which scanner produced this
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for 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 score 0-100 confidence assigned by the engine
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
* @param rssi signal strength if applicable (BLE/WiFi); null for map/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
*/
data class DetectionEvent(
@@ -18,7 +19,10 @@ data class DetectionEvent(
val score: Int,
val matchedMethods: String,
val rssi: Int? = null,
val lat: Double? = null,
val lon: Double? = null,
val timestampMs: Long = System.currentTimeMillis()
) {
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
val hasGeo: Boolean get() = lat != null && lon != null
}
@@ -0,0 +1,63 @@
package org.soulstone.overwatch.fusion
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Per-source upstream-health registry.
*
* Network sources (DEFLOCK, WAZE) record OK/FAILED so the UI can distinguish
* "scanned, found nothing" from "couldn't reach the data source." BLE/WIFI
* are radio-only and don't currently report; they default to UNKNOWN, which
* the UI treats the same as OK.
*/
object SourceHealth {
enum class Status { UNKNOWN, OK, FAILED }
data class Health(
val status: Status = Status.UNKNOWN,
val lastFetchMs: Long = 0L,
/** Short reason shown in the UI when status = FAILED. */
val message: String? = null
)
private val _ble = MutableStateFlow(Health())
private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock
DetectionSource.CITIZEN -> citizen
}
fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
val target = when (source) {
DetectionSource.BLE -> _ble
DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock
DetectionSource.CITIZEN -> _citizen
}
target.value = Health(
status = if (ok) Status.OK else Status.FAILED,
lastFetchMs = System.currentTimeMillis(),
message = message
)
}
fun reset() {
_ble.value = Health()
_wifi.value = Health()
_deflock.value = Health()
_citizen.value = Health()
}
}
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
}
/** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE }
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN }
@@ -21,6 +21,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker
import org.soulstone.overwatch.fusion.SourceHealth
/**
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
@@ -79,18 +80,30 @@ class BleScanner(
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "BLE scan permission missing")
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission missing")
return false
}
val adapter = bluetoothAdapter ?: run {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE not supported")
return false
}
if (!adapter.isEnabled) {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Bluetooth disabled")
return false
}
leScanner = adapter.bluetoothLeScanner ?: run {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE scanner unavailable")
return false
}
val adapter = bluetoothAdapter ?: return false
if (!adapter.isEnabled) return false
leScanner = adapter.bluetoothLeScanner ?: return false
try {
leScanner?.startScan(null, scanSettings, scanCallback)
running = true
SourceHealth.record(DetectionSource.BLE, ok = true)
Log.i(TAG, "BLE scan started")
return true
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting scan", e)
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission revoked")
return false
}
}
@@ -120,6 +133,11 @@ class BleScanner(
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode")
running = false
SourceHealth.record(
DetectionSource.BLE,
ok = false,
message = "BLE scan failed (code $errorCode)"
)
}
}
@@ -132,11 +150,23 @@ class BleScanner(
val advertisedUuids = record?.serviceUuids?.map { it.uuid }
val mfgSpecific = record?.manufacturerSpecificData
// Iterate ALL manufacturer-data entries; some devices advertise multiple
// and XUNTONG might not be the first one. Prefer the XUNTONG match if
// present, otherwise fall back to the first entry so we still surface
// *some* mfg signal in the observation.
var companyId: Int? = null
var payload: ByteArray? = null
if (mfgSpecific != null && mfgSpecific.size() > 0) {
companyId = mfgSpecific.keyAt(0)
payload = mfgSpecific.valueAt(0)
for (i in 0 until mfgSpecific.size()) {
val cid = mfgSpecific.keyAt(i)
val data = mfgSpecific.valueAt(i)
if (cid == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID) {
companyId = cid
payload = data
break
}
if (companyId == null) { companyId = cid; payload = data }
}
}
// Cheap pre-filter — drop devices that have zero target signals.
@@ -0,0 +1,133 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Public Citizen.com endpoints (verified 2026-04-29):
*
* GET /api/incident/trending?lowerLatitude=&upperLatitude=&lowerLongitude=&upperLongitude=&limit=20
* → { "results": ["<incidentId>", ...] }
*
* GET /api/incident/{id}
* → { "title", "level", "ll": [lat, lon], "ts" (ms), "police", "raw", ... }
*
* No auth, no rate-limit headers observed. Be a good citizen (heh) — only fetch
* detail for IDs we haven't already seen.
*/
class CitizenClient {
companion object {
private const val TAG = "CitizenClient"
private const val BASE = "https://citizen.com/api/incident"
private const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/121.0.0.0 Mobile Safari/537.36"
private const val TIMEOUT_MS = 10_000
/** Bounding-box half-width in degrees — ~5.5 km N-S, varies E-W. */
private const val BBOX_HALF_DEG = 0.05
private const val LIMIT = 30
}
data class Incident(
val id: String,
val title: String,
val level: Int,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val precinct: String?
)
sealed class TrendingResult {
data class Success(val ids: List<String>) : TrendingResult()
data class Failed(val reason: String) : TrendingResult()
}
suspend fun trendingNear(lat: Double, lon: Double): TrendingResult = withContext(Dispatchers.IO) {
val top = lat + BBOX_HALF_DEG
val bottom = lat - BBOX_HALF_DEG
val left = lon - BBOX_HALF_DEG
val right = lon + BBOX_HALF_DEG
val url = URL(
"$BASE/trending?lowerLatitude=$bottom&upperLatitude=$top" +
"&lowerLongitude=$left&upperLongitude=$right&limit=$LIMIT"
)
when (val raw = httpGetJson(url)) {
is RawResult.Success -> {
try {
val arr = JSONObject(raw.body).optJSONArray("results")
?: return@withContext TrendingResult.Success(emptyList())
val out = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) {
val id = arr.optString(i)
if (id.isNotBlank()) out.add(id)
}
TrendingResult.Success(out)
} catch (e: Exception) {
TrendingResult.Failed("parse: ${e.message}")
}
}
is RawResult.Failed -> TrendingResult.Failed(raw.reason)
}
}
/** Returns null on any failure (parse, network, missing fields). */
suspend fun fetchIncident(id: String): Incident? = withContext(Dispatchers.IO) {
val url = URL("$BASE/$id")
val body = (httpGetJson(url) as? RawResult.Success)?.body ?: return@withContext null
try {
val o = JSONObject(body)
val ll = o.optJSONArray("ll")
val lat = ll?.optDouble(0) ?: o.optDouble("latitude")
val lon = ll?.optDouble(1) ?: o.optDouble("longitude")
if (lat.isNaN() || lon.isNaN()) return@withContext null
Incident(
id = id,
title = o.optString("title").ifBlank { "Citizen incident" },
level = o.optInt("level", 0),
lat = lat,
lon = lon,
pubMillis = o.optLong("ts", System.currentTimeMillis()),
precinct = o.optString("police").ifBlank { null }
)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Citizen incident $id: ${e.message}")
null
}
}
private sealed class RawResult {
data class Success(val body: String) : RawResult()
data class Failed(val reason: String) : RawResult()
}
private fun httpGetJson(url: URL): RawResult {
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json,*/*")
}
return try {
val code = conn.responseCode
if (code in 200..299) {
RawResult.Success(conn.inputStream.bufferedReader().use { it.readText() })
} else {
Log.w(TAG, "$url returned $code")
RawResult.Failed("HTTP $code")
}
} catch (e: Exception) {
Log.w(TAG, "$url failed: ${e.message}")
RawResult.Failed(e.message ?: e.javaClass.simpleName)
} finally {
conn.disconnect()
}
}
}
@@ -0,0 +1,151 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* Polls Citizen.com for nearby active incidents, filters out pure fire/medical
* (no police presence implied), and submits a detection event for each
* remaining incident inside [proximityMeters] and younger than [MAX_AGE_MS].
*
* Detail responses are cached in-memory by incident id for the life of the
* scanner — Citizen incidents don't mutate after creation, so we only need to
* fetch each id once per session.
*/
class CitizenScanner(
private val store: DetectionStore,
private val locationProvider: LocationProvider,
private val client: CitizenClient = CitizenClient(),
private val proximityMeters: () -> Float = { 500f }
) {
companion object {
private const val TAG = "CitizenScanner"
private const val POLL_INTERVAL_MS = 60_000L
private const val MAX_AGE_MS = 30L * 60L * 1000L
/** Skip incidents whose title is purely fire/medical with no police implication. */
private val FIRE_MEDICAL_RX = Regex(
"\\b(fire|smoke|gas\\s+(odor|leak)|medical|cardiac|ambulance|" +
"ems|injury|alarm|odor)\\b",
RegexOption.IGNORE_CASE
)
/** Title contains an explicit police-action keyword → score bump. */
private val POLICE_TITLE_RX = Regex(
"\\b(police|officer|patrol|arrest|swat|tactical|raid|pursuit|" +
"stop|search\\s+warrant)\\b",
RegexOption.IGNORE_CASE
)
}
private var job: Job? = null
/** Detail cache for the lifetime of one start/stop cycle. */
private val incidentCache = mutableMapOf<String, CitizenClient.Incident>()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
// Wait for the first non-null location fix so the first poll fires
// immediately when location arrives, instead of after a 60 s delay.
locationProvider.location.first { it != null }
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) pollOnce(fix)
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "CitizenScanner started (interval=${POLL_INTERVAL_MS}ms)")
return true
}
fun stop() {
job?.cancel()
job = null
incidentCache.clear()
Log.i(TAG, "CitizenScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
when (val trending = client.trendingNear(fix.latitude, fix.longitude)) {
is CitizenClient.TrendingResult.Failed -> {
SourceHealth.record(
DetectionSource.CITIZEN,
ok = false,
message = "Citizen unreachable: ${trending.reason}"
)
return
}
is CitizenClient.TrendingResult.Success -> {
SourceHealth.record(DetectionSource.CITIZEN, ok = true)
handleIds(fix, trending.ids)
}
}
}
private suspend fun handleIds(fix: Location, ids: List<String>) {
// Drop cache entries that no longer appear in the trending list (resolved).
incidentCache.keys.retainAll(ids.toSet())
val now = System.currentTimeMillis()
val limit = proximityMeters()
val out = FloatArray(1)
for (id in ids) {
val incident = incidentCache[id] ?: client.fetchIncident(id)?.also {
incidentCache[id] = it
} ?: continue
// Title-based pre-filter: drop pure fire/medical events.
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
!POLICE_TITLE_RX.containsMatchIn(incident.title)) {
continue
}
val age = now - incident.pubMillis
if (age > MAX_AGE_MS) continue
Location.distanceBetween(
fix.latitude, fix.longitude,
incident.lat, incident.lon,
out
)
val dist = out[0]
if (dist > limit) continue
val obs = ConfidenceEngine.CitizenObservation(
incidentId = incident.id,
distanceMeters = dist,
ageMs = age,
level = incident.level,
title = incident.title,
isPoliceTitled = POLICE_TITLE_RX.containsMatchIn(incident.title),
precinct = incident.precinct
)
val scored = ConfidenceEngine.scoreCitizen(obs)
store.submit(
DetectionEvent(
source = DetectionSource.CITIZEN,
key = "citizen:${incident.id}",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = incident.lat,
lon = incident.lon
)
)
}
}
}
@@ -5,31 +5,39 @@ import android.util.Log
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import kotlin.math.floor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.floor
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches DeFlock ALPR tile data from the public CDN, with a 24h on-disk cache.
* Fetches DeFlock ALPR data from the Overpass API (matching the live deflock-app
* Flutter client). The earlier `cdn.deflock.me/regions/...json` path is now
* gated behind Cloudflare bot mitigation that we cannot pass from a mobile HTTP
* client.
*
* Tile scheme (from REFERENCES/deflock/serverless/alpr_cache):
* tile_lat = floor(lat / 20) * 20
* tile_lon = floor(lon / 20) * 20
* url = https://cdn.deflock.me/regions/{tile_lat}/{tile_lon}.json
* body = JSON array of { id: number, lat: number, lon: number, tags: {…} }
*
* 20° tiles → ≤16 tiles cover the entire globe; one user typically only ever touches one.
* Strategy:
* - POST an Overpass-QL query for `man_made=surveillance + surveillance:type=ALPR`
* inside a small bbox around the user.
* - Try `overpass.deflock.org` first (less rate-limited for this use case),
* fall back to public `overpass-api.de`.
* - Cache the JSON response on disk by 0.05° grid cell (24h TTL). Revisits to
* the same cell don't re-hit the API.
*/
class DeflockClient(context: Context) {
companion object {
private const val TAG = "DeflockClient"
private const val TILE_SIZE_DEG = 20
private const val FETCH_RADIUS_DEG = 0.05 // ~5.5 km half-width bbox
private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L
private const val CDN_BASE = "https://cdn.deflock.me/regions"
private const val USER_AGENT = "OVERWATCH/0.1 (+github.com/KaraZajac/OVERWATCH)"
private const val TIMEOUT_MS = 15_000
private const val TIMEOUT_MS = 30_000
private const val OVERPASS_QUERY_TIMEOUT_S = 25
private val ENDPOINTS = listOf(
"https://overpass.deflock.org/api/interpreter",
"https://overpass-api.de/api/interpreter"
)
}
data class AlprPoint(
@@ -40,82 +48,133 @@ class DeflockClient(context: Context) {
val manufacturer: String? = null
)
data class TileKey(val tileLat: Int, val tileLon: Int) {
fun fileName() = "deflock_${tileLat}_${tileLon}.json"
/** Outcome of a fetch — distinguishes "no ALPRs in area" from "couldn't reach the API." */
sealed class FetchResult {
data class Success(val points: List<AlprPoint>) : FetchResult()
data class Failed(val reason: String) : FetchResult()
}
private val cacheDir: File = File(context.cacheDir, "deflock").apply { mkdirs() }
fun tileFor(lat: Double, lon: Double): TileKey = TileKey(
tileLat = floor(lat / TILE_SIZE_DEG).toInt() * TILE_SIZE_DEG,
tileLon = floor(lon / TILE_SIZE_DEG).toInt() * TILE_SIZE_DEG
)
/** Returns parsed ALPR points for the tile; empty list on any failure (logged). */
suspend fun fetchTile(tile: TileKey): List<AlprPoint> = withContext(Dispatchers.IO) {
val cached = cachedJson(tile)
suspend fun fetchAround(lat: Double, lon: Double): FetchResult = withContext(Dispatchers.IO) {
val key = cacheKeyFor(lat, lon)
val cached = cachedJson(key)
if (cached != null) {
return@withContext parseSafely(cached)
Log.d(TAG, "Cache hit for $key")
return@withContext FetchResult.Success(parseSafely(cached))
}
val south = lat - FETCH_RADIUS_DEG
val north = lat + FETCH_RADIUS_DEG
val west = lon - FETCH_RADIUS_DEG
val east = lon + FETCH_RADIUS_DEG
val query = buildQuery(south, west, north, east)
val (body, lastError) = downloadFromAny(query)
if (body == null) {
return@withContext FetchResult.Failed(lastError ?: "Network error")
}
val downloaded = downloadTile(tile) ?: return@withContext emptyList()
try {
File(cacheDir, tile.fileName()).writeText(downloaded)
File(cacheDir, "$key.json").writeText(body)
} catch (e: Exception) {
Log.w(TAG, "Failed to write tile cache for $tile: ${e.message}")
Log.w(TAG, "Failed to write cache for $key: ${e.message}")
}
parseSafely(downloaded)
FetchResult.Success(parseSafely(body))
}
private fun cachedJson(tile: TileKey): String? {
val f = File(cacheDir, tile.fileName())
private fun cacheKeyFor(lat: Double, lon: Double): String {
// 0.05° grid cell. Two consecutive points within the same cell get the
// same cache key, so micro-movements don't refetch.
val latStep = floor(lat / FETCH_RADIUS_DEG).toInt()
val lonStep = floor(lon / FETCH_RADIUS_DEG).toInt()
return "deflock_${latStep}_${lonStep}"
}
private fun cachedJson(key: String): String? {
val f = File(cacheDir, "$key.json")
if (!f.exists()) return null
val age = System.currentTimeMillis() - f.lastModified()
if (age > CACHE_TTL_MS) return null
if (System.currentTimeMillis() - f.lastModified() > CACHE_TTL_MS) return null
return try { f.readText() } catch (e: Exception) { null }
}
private fun downloadTile(tile: TileKey): String? {
val url = URL("$CDN_BASE/${tile.tileLat}/${tile.tileLon}.json")
private fun buildQuery(south: Double, west: Double, north: Double, east: Double): String =
"[out:json][timeout:$OVERPASS_QUERY_TIMEOUT_S];" +
"(node[\"man_made\"=\"surveillance\"][\"surveillance:type\"=\"ALPR\"]" +
"($south,$west,$north,$east););out body;"
/** Try each endpoint in order until one returns 2xx. Returns body + last error message. */
private fun downloadFromAny(query: String): Pair<String?, String?> {
var lastError: String? = null
for (endpoint in ENDPOINTS) {
val (body, err) = postQuery(endpoint, query)
if (body != null) return body to null
lastError = err
}
return null to lastError
}
private fun postQuery(endpoint: String, query: String): Pair<String?, String?> {
val url = URL(endpoint)
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
requestMethod = "POST"
doOutput = true
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
setRequestProperty("Accept", "application/json")
}
return try {
val payload = "data=" + URLEncoder.encode(query, "UTF-8")
conn.outputStream.use { it.write(payload.toByteArray()) }
val code = conn.responseCode
if (code == 404) {
Log.i(TAG, "Tile $tile not present on CDN (no ALPRs in this region)")
"" // cache the empty result by writing an empty string
} else if (code in 200..299) {
conn.inputStream.bufferedReader().use { it.readText() }
if (code in 200..299) {
val body = conn.inputStream.bufferedReader().use { it.readText() }
// Overpass returns HTTP 200 with `{"remark": "runtime error: Query timed out..."}`
// when the query exceeded server-side limits. Body has elements:[]; treat as
// failure so we don't poison the 24h cache with empty results.
if (looksLikeOverpassTimeout(body)) {
Log.w(TAG, "$endpoint returned 200 with timeout/runtime-limit remark")
null to "Overpass timeout"
} else {
Log.w(TAG, "CDN returned $code for $tile")
null
body to null
}
} else {
Log.w(TAG, "$endpoint returned $code")
null to "HTTP $code"
}
} catch (e: Exception) {
Log.w(TAG, "Download failed for $tile: ${e.message}")
null
Log.w(TAG, "$endpoint failed: ${e.message}")
null to (e.message ?: e.javaClass.simpleName)
} finally {
conn.disconnect()
}
}
private fun looksLikeOverpassTimeout(body: String): Boolean {
if (!body.contains("remark", ignoreCase = true)) return false
val lower = body.lowercase()
return lower.contains("timed out") ||
lower.contains("timeout") ||
lower.contains("runtime error") ||
lower.contains("runtime limit exceeded") ||
lower.contains("rate_limited")
}
private fun parseSafely(json: String): List<AlprPoint> {
if (json.isBlank()) return emptyList()
return try {
val arr = JSONArray(json)
val out = ArrayList<AlprPoint>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val lat = o.optDouble("lat")
val lon = o.optDouble("lon")
val root = JSONObject(json)
val elements = root.optJSONArray("elements") ?: return emptyList()
val out = ArrayList<AlprPoint>(elements.length())
for (i in 0 until elements.length()) {
val el = elements.optJSONObject(i) ?: continue
if (el.optString("type") != "node") continue
val lat = el.optDouble("lat")
val lon = el.optDouble("lon")
if (lat.isNaN() || lon.isNaN()) continue
val tags = o.optJSONObject("tags")
val tags = el.optJSONObject("tags")
out.add(
AlprPoint(
id = o.optLong("id", 0L),
id = el.optLong("id", 0L),
lat = lat,
lon = lon,
operator = tags?.optString("operator")?.ifBlank { null }
@@ -129,7 +188,7 @@ class DeflockClient(context: Context) {
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse tile JSON: ${e.message}")
Log.w(TAG, "Failed to parse Overpass response: ${e.message}")
emptyList()
}
}
@@ -11,17 +11,16 @@ import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/**
* DeFlock orchestrator.
*
* Subscribes to [LocationProvider]; for each new fix, looks up the matching 20° tile
* (loaded from [DeflockClient] cache or downloaded once / 24h) and submits a
* detection event for every ALPR within [PROXIMITY_M].
*
* Tile-boundary edge case: at lat ≈ tile_lat or lon ≈ tile_lon ±0.002°, ALPRs across
* the boundary won't be visible until the user crosses it. Acceptable for v0.1 — a
* 5-tile fetch (current + 4 neighbours) is a polish item.
* Subscribes to [LocationProvider]; when the user has moved more than
* [REFETCH_THRESHOLD_M] from the last fetch center (or there is no last
* center), runs an Overpass query via [DeflockClient] for the surrounding
* 5-km bbox. For each cached ALPR within [proximityMeters], submits a
* detection event.
*/
class DeflockScanner(
private val store: DetectionStore,
@@ -32,10 +31,16 @@ class DeflockScanner(
companion object {
private const val TAG = "DeflockScanner"
private const val REFETCH_THRESHOLD_M = 1500f
/** Don't retry an Overpass POST within this window after a failure. */
private const val FAILURE_BACKOFF_MS = 60_000L
}
private var job: Job? = null
private var lastTile: DeflockClient.TileKey? = null
private var lastFetchLat: Double? = null
private var lastFetchLon: Double? = null
private var lastAttemptMs: Long = 0L
private var lastAttemptOk: Boolean = false
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
fun start(scope: CoroutineScope): Boolean {
@@ -52,17 +57,43 @@ class DeflockScanner(
fun stop() {
job?.cancel()
job = null
lastTile = null
lastFetchLat = null
lastFetchLon = null
lastAttemptMs = 0L
lastAttemptOk = false
cachedPoints = emptyList()
Log.i(TAG, "DeflockScanner stopped")
}
private suspend fun handleFix(fix: Location) {
val tile = client.tileFor(fix.latitude, fix.longitude)
if (tile != lastTile) {
cachedPoints = client.fetchTile(tile)
lastTile = tile
Log.i(TAG, "Loaded tile $tile with ${cachedPoints.size} ALPRs")
if (shouldRefetch(fix)) {
// Mark the attempt before the network call so a concurrent location
// tick doesn't trigger a parallel re-fetch of the same area.
lastFetchLat = fix.latitude
lastFetchLon = fix.longitude
lastAttemptMs = System.currentTimeMillis()
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is DeflockClient.FetchResult.Success -> {
cachedPoints = result.points
lastAttemptOk = true
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i(
TAG,
"Loaded ${cachedPoints.size} ALPRs around " +
"(${fix.latitude}, ${fix.longitude})"
)
}
is DeflockClient.FetchResult.Failed -> {
lastAttemptOk = false
SourceHealth.record(
DetectionSource.DEFLOCK,
ok = false,
message = "Overpass unreachable: ${result.reason}"
)
Log.w(TAG, "Overpass fetch failed: ${result.reason}")
// Keep using cachedPoints (may be empty on first failure).
}
}
}
if (cachedPoints.isEmpty()) return
@@ -86,9 +117,25 @@ class DeflockScanner(
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null
rssi = null,
lat = p.lat,
lon = p.lon
)
)
}
}
private fun shouldRefetch(fix: Location): Boolean {
val lat = lastFetchLat ?: return true
val lon = lastFetchLon ?: return true
// After a failed attempt, hold off for FAILURE_BACKOFF_MS even if the
// user hasn't moved — avoids hammering Overpass when it's struggling.
if (!lastAttemptOk &&
System.currentTimeMillis() - lastAttemptMs < FAILURE_BACKOFF_MS) {
return false
}
val out = FloatArray(1)
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
return out[0] > REFETCH_THRESHOLD_M
}
}
@@ -1,118 +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?
)
suspend fun fetchPoliceNear(lat: Double, lon: Double): List<Alert> = withContext(Dispatchers.IO) {
val top = lat + BBOX_HALF_DEG
val bottom = lat - BBOX_HALF_DEG
val left = lon - BBOX_HALF_DEG
val right = lon + BBOX_HALF_DEG
val url = URL("$BASE?top=$top&bottom=$bottom&left=$left&right=$right&env=na&types=alerts")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
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 !in 200..299) {
Log.w(TAG, "Waze returned $code")
return@withContext emptyList()
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
parsePolice(body)
} catch (e: Exception) {
Log.w(TAG, "Waze fetch failed: ${e.message}")
emptyList()
} 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,96 +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
/**
* 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 alerts = client.fetchPoliceNear(fix.latitude, fix.longitude)
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
)
)
}
}
}
@@ -24,6 +24,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker
import org.soulstone.overwatch.fusion.SourceHealth
/**
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
@@ -86,15 +87,23 @@ class WifiScanner(
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "WiFi scan permission missing")
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "Permission missing")
return false
}
val mgr = wifiManager ?: run {
Log.w(TAG, "WifiManager unavailable")
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "WifiManager unavailable")
return false
}
if (!mgr.isWifiEnabled) {
Log.w(TAG, "WiFi disabled — scanner won't return results")
SourceHealth.record(
DetectionSource.WIFI, ok = false,
message = "WiFi disabled — enable in system settings"
)
// We still register the receiver so results arrive when the user enables WiFi.
} else {
SourceHealth.record(DetectionSource.WIFI, ok = true)
}
registerReceiver()
running = true
@@ -9,6 +9,9 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
@@ -18,25 +21,35 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.soulstone.overwatch.MainActivity
import org.soulstone.overwatch.R
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.WazeScanner
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
* companion-object state flows directly, which is what we do here for simplicity).
* Returns START_NOT_STICKY so a system-killed service does not auto-restart
* into a zombie state where the notification disappears but `_running` stays
* stale. The user explicitly starts and stops; auto-restart isn't needed.
*/
class DetectionService : LifecycleService() {
@@ -78,12 +91,15 @@ class DetectionService : LifecycleService() {
private lateinit var wifiScanner: WifiScanner
private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner
private lateinit var wazeScanner: WazeScanner
private lateinit var citizenScanner: CitizenScanner
private var pruneJob: Job? = null
private var observerJob: Job? = null
private var bleStarted = false
private var wifiStarted = false
private var deflockStarted = false
private var wazeStarted = 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() {
super.onCreate()
@@ -95,9 +111,9 @@ class DetectionService : LifecycleService() {
store, locationProvider, DeflockClient(this),
proximityMeters = { settings.deflockProximityM.value.toFloat() }
)
wazeScanner = WazeScanner(
citizenScanner = CitizenScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
proximityMeters = { settings.citizenProximityM.value.toFloat() }
)
createNotificationChannel()
}
@@ -111,12 +127,17 @@ class DetectionService : LifecycleService() {
stopSelf()
}
}
return START_STICKY
return START_NOT_STICKY
}
private fun beginScanning() {
if (_running.value) return
startInForeground()
SourceHealth.reset()
lastNotifiedTier = ThreatLevel.GREEN
// Bring up the foreground notification BEFORE any scanner so we don't
// accidentally call startForeground after work has already begun.
startInForeground(ThreatLevel.GREEN, topEvent = null)
if (settings.bleEnabled.value) {
bleStarted = bleScanner.start()
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
@@ -125,7 +146,7 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value || settings.wazeEnabled.value
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
if (!locOk) {
@@ -134,11 +155,20 @@ class DetectionService : LifecycleService() {
if (settings.deflockEnabled.value) {
deflockScanner.start(lifecycleScope); deflockStarted = true
}
if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
}
}
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted
if (!anyStarted) {
Log.w(TAG, "No scanner started — endScanning + stopSelf")
endScanning()
stopSelf()
return
}
_running.value = true
pruneJob?.cancel()
pruneJob = lifecycleScope.launch {
@@ -147,19 +177,33 @@ class DetectionService : LifecycleService() {
store.pruneExpired()
}
}
observerJob?.cancel()
observerJob = lifecycleScope.launch {
// Watch threat tier + the top event together; rebuild the notification
// on either change. Vibrate only when the tier ratchets upward.
store.threatLevel.combine(store.events) { tier, events ->
tier to events.firstOrNull()
}.collect { (tier, top) ->
onTierChanged(tier, top)
}
}
}
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 (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
locationProvider.stop()
store.clear()
pruneJob?.cancel()
pruneJob = null
_running.value = false
SourceHealth.reset()
pruneJob?.cancel(); pruneJob = null
observerJob?.cancel(); observerJob = null
lastNotifiedTier = ThreatLevel.GREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@@ -178,12 +222,46 @@ class DetectionService : LifecycleService() {
return null
}
private fun startInForeground() {
val notification = buildNotification()
private fun onTierChanged(tier: ThreatLevel, top: DetectionEvent?) {
// Re-issue the foreground notification with the current tier + top event
// so a locked-screen user sees the escalation even without opening the app.
val notification = buildNotification(tier, top)
val mgr = getSystemService(NotificationManager::class.java) ?: return
mgr.notify(NOTIFICATION_ID, notification)
if (tier.ordinal > lastNotifiedTier.ordinal && settings.vibrateOnAlert.value) {
vibrateForTier(tier)
}
lastNotifiedTier = tier
}
private fun vibrateForTier(tier: ThreatLevel) {
val v = currentVibrator() ?: return
val effect = when (tier) {
ThreatLevel.YELLOW -> VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)
ThreatLevel.ORANGE -> VibrationEffect.createWaveform(longArrayOf(0, 180, 100, 180), -1)
ThreatLevel.RED -> VibrationEffect.createWaveform(
longArrayOf(0, 250, 120, 250, 120, 400), -1
)
ThreatLevel.GREEN -> return
}
try { v.vibrate(effect) } catch (e: Exception) { Log.w(TAG, "vibrate failed: ${e.message}") }
}
private fun currentVibrator(): Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun startInForeground(tier: ThreatLevel, topEvent: DetectionEvent?) {
val notification = buildNotification(tier, topEvent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ requires the runtime type to cover every capability
// the service uses. We declare both in the manifest; pass both here
// so location-using sources (DeFlock, Waze) keep working with the
// Android 14+ requires the runtime type to cover every capability the
// service uses. We declare both in the manifest; pass both here so
// location-using sources (DeFlock, Citizen) keep working with the
// screen off.
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -193,7 +271,7 @@ class DetectionService : LifecycleService() {
}
}
private fun buildNotification(): Notification {
private fun buildNotification(tier: ThreatLevel, topEvent: DetectionEvent?): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@@ -201,14 +279,26 @@ class DetectionService : LifecycleService() {
this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val title = "OVERWATCH • ${tier.name}"
val text = topEvent?.let { "${it.score}${it.label}" }
?: getString(R.string.notification_text)
// Higher importance for ORANGE/RED so the system surfaces it more
// aggressively (heads-up notification, etc.). The channel was created
// with LOW; on supported versions this priority is best-effort.
val priority = when (tier) {
ThreatLevel.RED -> NotificationCompat.PRIORITY_HIGH
ThreatLevel.ORANGE -> NotificationCompat.PRIORITY_DEFAULT
else -> NotificationCompat.PRIORITY_LOW
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setPriority(priority)
.setOnlyAlertOnce(false)
.build()
}
@@ -19,8 +19,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -33,10 +37,10 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -50,6 +54,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.ui.theme.ThreatColors
@@ -63,11 +68,12 @@ fun MainScreen(
onStartStop: () -> Unit,
onOpenSettings: () -> Unit,
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 sheetScope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -157,6 +163,24 @@ fun MainScreen(
fontSize = 13.sp
)
}
if (showOpenAppSettings) {
Spacer(Modifier.height(8.dp))
Button(
onClick = onOpenAppSettings,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Open app settings",
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
@@ -184,12 +208,20 @@ fun MainScreen(
@Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = when (level) {
// When the scanner isn't running, deliberately use a muted color and IDLE
// text so the user can tell at a glance whether they're scanning. Without
// this, idle and "scanning, all clear" both render as solid green.
val idleColor = MaterialTheme.colorScheme.surfaceVariant
val activeColor = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
val color = if (animating) activeColor else idleColor
val labelText = if (animating) level.name else "IDLE"
val labelColor = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f,
@@ -218,8 +250,8 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
contentAlignment = Alignment.Center
) {
Text(
text = level.name,
color = Color.White,
text = labelText,
color = labelColor,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
@@ -272,6 +304,9 @@ private fun SourcesPanel(events: List<DetectionEvent>) {
@Composable
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
val health by SourceHealth.flowFor(source).collectAsState()
val unreachable = health.status == SourceHealth.Status.FAILED
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -294,6 +329,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
)
val maxScore = events.maxOfOrNull { it.score } ?: 0
val statusColor = when {
unreachable -> MaterialTheme.colorScheme.onSurfaceVariant
maxScore >= ThreatLevel.RED.minScore -> ThreatColors.Red
maxScore >= ThreatLevel.ORANGE.minScore -> ThreatColors.Orange
maxScore >= ThreatLevel.YELLOW.minScore -> ThreatColors.Yellow
@@ -306,7 +342,15 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
.background(statusColor)
)
}
if (events.isEmpty()) {
if (unreachable) {
Spacer(Modifier.height(4.dp))
Text(
text = health.message ?: "Source unavailable",
color = ThreatColors.Orange,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
} else if (events.isEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
text = "no detections",
@@ -315,14 +359,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
)
} else {
Spacer(Modifier.height(4.dp))
events.take(3).forEach { e ->
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
events.take(3).forEach { e -> EventRow(e) }
if (events.size > 3) {
Text(
text = "+${events.size - 3} more",
@@ -334,3 +371,40 @@ 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 = {
val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(ctx.packageManager) != null) {
ctx.startActivity(intent)
}
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Place,
contentDescription = "Open in Maps",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
}
}
@@ -27,6 +27,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
@@ -45,10 +48,11 @@ fun SettingsScreen(
val ble by settings.bleEnabled.collectAsState()
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val citizen by settings.citizenEnabled.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 vibrate by settings.vibrateOnAlert.collectAsState()
Column(
modifier = Modifier
@@ -76,8 +80,8 @@ fun SettingsScreen(
SectionLabel("Detection sources")
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZENReal-time incident feed", citizen) { settings.setCitizenEnabled(it) }
Spacer(Modifier.height(8.dp))
if (isRunning) {
Button(
@@ -107,21 +111,23 @@ fun SettingsScreen(
SectionLabel("Proximity thresholds")
SliderRow(
label = "DeFlock alert distance",
valueLabel = "${deflockProx} m",
value = deflockProx.toFloat(),
persistedValue = deflockProx,
range = 50f..1600f,
steps = 30,
onChange = { settings.setDeflockProximityM(it.toInt()) }
onCommit = { settings.setDeflockProximityM(it) }
)
SliderRow(
label = "Waze alert distance",
valueLabel = "${wazeProx} m",
value = wazeProx.toFloat(),
label = "Citizen alert distance",
persistedValue = citizenProx,
range = 100f..5000f,
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("Appearance")
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
@@ -169,15 +175,20 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
}
}
/**
* Slider that commits the value to Settings only on drag-release. The label
* tracks the live drag position locally to avoid spamming SharedPreferences
* writes (and downstream StateFlow re-emissions) on every pixel of movement.
*/
@Composable
private fun SliderRow(
label: String,
valueLabel: String,
value: Float,
persistedValue: Int,
range: ClosedFloatingPointRange<Float>,
steps: Int,
onChange: (Float) -> Unit
onCommit: (Int) -> Unit
) {
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -190,15 +201,16 @@ private fun SliderRow(
fontFamily = FontFamily.Monospace
)
Text(
text = valueLabel,
text = "${live.toInt()} m",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = value,
onValueChange = onChange,
value = live,
onValueChange = { live = it },
onValueChangeFinished = { onCommit(live.toInt()) },
valueRange = range,
steps = steps
)