Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9cafb3e67 | |||
| 508430e1e3 | |||
| 451376e497 | |||
| 246c738ee4 | |||
| aa34a1c518 |
@@ -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:
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "org.soulstone.overwatch"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 6
|
||||
versionName = "0.1.5"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -42,7 +42,13 @@ class MainActivity : ComponentActivity() {
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { result ->
|
||||
permissionsGranted.value = result.all { it.value }
|
||||
val allGranted = result.all { it.value }
|
||||
permissionsGranted.value = allGranted
|
||||
if (allGranted) {
|
||||
// First-run path: user just granted everything, kick off scanning
|
||||
// immediately so they don't have to tap START a second time.
|
||||
DetectionService.start(this)
|
||||
}
|
||||
}
|
||||
|
||||
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false)
|
||||
@@ -70,8 +76,8 @@ class MainActivity : ComponentActivity() {
|
||||
threat = threat,
|
||||
score = maxScore,
|
||||
events = events,
|
||||
canStart = granted || running,
|
||||
permissionMessage = if (!granted) "Bluetooth, WiFi + location permissions required" else null,
|
||||
canStart = true,
|
||||
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
|
||||
onStartStop = {
|
||||
if (running) {
|
||||
DetectionService.stop(this)
|
||||
@@ -87,8 +93,14 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
Screen.SETTINGS -> {
|
||||
val running by DetectionService.running.collectAsState()
|
||||
SettingsScreen(
|
||||
settings = settings,
|
||||
isRunning = running,
|
||||
onRestart = {
|
||||
DetectionService.stop(this)
|
||||
DetectionService.start(this)
|
||||
},
|
||||
onBack = { screen = Screen.MAIN }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,18 +29,18 @@ 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)
|
||||
@@ -50,7 +50,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
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 +58,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) {
|
||||
@@ -74,13 +74,13 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
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"
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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()) arr.optString(i)?.takeIf { it.isNotBlank() }?.let(out::add)
|
||||
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,145 @@
|
||||
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 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 {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,63 +48,94 @@ 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() }
|
||||
body to null
|
||||
} else {
|
||||
Log.w(TAG, "CDN returned $code for $tile")
|
||||
null
|
||||
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()
|
||||
}
|
||||
@@ -105,16 +144,21 @@ class DeflockClient(context: Context) {
|
||||
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 tags = o.optJSONObject("tags")
|
||||
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 = el.optJSONObject("tags")
|
||||
out.add(
|
||||
AlprPoint(
|
||||
id = o.optLong("id", 0L),
|
||||
lat = o.optDouble("lat"),
|
||||
lon = o.optDouble("lon"),
|
||||
id = el.optLong("id", 0L),
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
operator = tags?.optString("operator")?.ifBlank { null }
|
||||
?: tags?.optString("surveillance:operator")?.ifBlank { null },
|
||||
manufacturer = tags?.optString("manufacturer")?.ifBlank { null }
|
||||
@@ -126,7 +170,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,12 @@ class DeflockScanner(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DeflockScanner"
|
||||
private const val REFETCH_THRESHOLD_M = 1500f
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
private var lastTile: DeflockClient.TileKey? = null
|
||||
private var lastFetchLat: Double? = null
|
||||
private var lastFetchLon: Double? = null
|
||||
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
|
||||
|
||||
fun start(scope: CoroutineScope): Boolean {
|
||||
@@ -52,17 +53,36 @@ class DeflockScanner(
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
lastTile = null
|
||||
lastFetchLat = null
|
||||
lastFetchLon = null
|
||||
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)) {
|
||||
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
|
||||
is DeflockClient.FetchResult.Success -> {
|
||||
cachedPoints = result.points
|
||||
lastFetchLat = fix.latitude
|
||||
lastFetchLon = fix.longitude
|
||||
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Loaded ${cachedPoints.size} ALPRs around " +
|
||||
"(${fix.latitude}, ${fix.longitude})"
|
||||
)
|
||||
}
|
||||
is DeflockClient.FetchResult.Failed -> {
|
||||
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
|
||||
|
||||
@@ -91,4 +111,12 @@ class DeflockScanner(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRefetch(fix: Location): Boolean {
|
||||
val lat = lastFetchLat ?: return true
|
||||
val lon = lastFetchLon ?: return true
|
||||
val out = FloatArray(1)
|
||||
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
|
||||
return out[0] > REFETCH_THRESHOLD_M
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +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
|
||||
out.add(
|
||||
Alert(
|
||||
uuid = uuid,
|
||||
subtype = a.optString("subtype").ifBlank { null },
|
||||
lat = loc.optDouble("y"),
|
||||
lon = loc.optDouble("x"),
|
||||
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,10 +24,11 @@ import org.soulstone.overwatch.R
|
||||
import org.soulstone.overwatch.data.location.LocationProvider
|
||||
import org.soulstone.overwatch.data.settings.Settings
|
||||
import org.soulstone.overwatch.fusion.DetectionStore
|
||||
import org.soulstone.overwatch.fusion.SourceHealth
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -78,12 +79,12 @@ 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 bleStarted = false
|
||||
private var wifiStarted = false
|
||||
private var deflockStarted = false
|
||||
private var wazeStarted = false
|
||||
private var citizenStarted = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -95,9 +96,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()
|
||||
}
|
||||
@@ -116,6 +117,7 @@ class DetectionService : LifecycleService() {
|
||||
|
||||
private fun beginScanning() {
|
||||
if (_running.value) return
|
||||
SourceHealth.reset()
|
||||
startInForeground()
|
||||
if (settings.bleEnabled.value) {
|
||||
bleStarted = bleScanner.start()
|
||||
@@ -125,7 +127,8 @@ 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,8 +137,8 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,9 +157,10 @@ class DetectionService : LifecycleService() {
|
||||
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()
|
||||
SourceHealth.reset()
|
||||
pruneJob?.cancel()
|
||||
pruneJob = null
|
||||
_running.value = false
|
||||
@@ -181,11 +185,13 @@ class DetectionService : LifecycleService() {
|
||||
private fun startInForeground() {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
)
|
||||
// Android 14+ requires the runtime type to cover every capability
|
||||
// the service uses. We declare both in the manifest; pass both here
|
||||
// so location-using sources (DeFlock, Waze) keep working with the
|
||||
// screen off.
|
||||
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
startForeground(NOTIFICATION_ID, notification, type)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ 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
|
||||
@@ -50,6 +51,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
|
||||
|
||||
@@ -272,6 +274,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 +299,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 +312,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",
|
||||
|
||||
@@ -13,6 +13,9 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -35,14 +38,16 @@ import org.soulstone.overwatch.data.settings.Settings
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: Settings,
|
||||
isRunning: Boolean,
|
||||
onRestart: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
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()
|
||||
|
||||
Column(
|
||||
@@ -71,14 +76,32 @@ 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("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Source toggles take effect on next Start.",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (isRunning) {
|
||||
Button(
|
||||
onClick = onRestart,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Restart scan to apply",
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"Source toggles take effect on next Start.",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
SectionLabel("Proximity thresholds")
|
||||
@@ -91,12 +114,12 @@ fun SettingsScreen(
|
||||
onChange = { settings.setDeflockProximityM(it.toInt()) }
|
||||
)
|
||||
SliderRow(
|
||||
label = "Waze alert distance",
|
||||
valueLabel = "${wazeProx} m",
|
||||
value = wazeProx.toFloat(),
|
||||
label = "Citizen alert distance",
|
||||
valueLabel = "${citizenProx} m",
|
||||
value = citizenProx.toFloat(),
|
||||
range = 100f..5000f,
|
||||
steps = 48,
|
||||
onChange = { settings.setWazeProximityM(it.toInt()) }
|
||||
onChange = { settings.setCitizenProximityM(it.toInt()) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Reference in New Issue
Block a user