diff --git a/README.md b/README.md index 5f9e7e4..df5e202 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ 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) | 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. | -| **WAZE** | Live `POLICE` reports within configurable proximity (default 500 m) and < 10 min old | `live-map/api/georss` polled every 60 s with a small bbox around the user. **Note:** Waze added reCAPTCHA gating to this endpoint in 2025/2026; mobile clients now receive HTTP 403. The bottom-sheet drill-down surfaces this as a per-source health indicator. | +| **WAZE** | Live `POLICE` reports within configurable proximity (default 500 m) and < 10 min old | `live-map/api/georss` polled every 60 s with a small bbox around the user. **Note:** Waze added reCAPTCHA gating to this endpoint in 2025/2026; mobile clients now receive HTTP 403. Source stays wired and surfaces the failure in the drill-down sheet so it's never silently empty. | +| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. Pulled in to replace Waze's coverage gap. | Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is the maximum live score across all sources: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index abeb03f..d420542 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "org.soulstone.overwatch" minSdk = 26 targetSdk = 35 - versionCode = 4 - versionName = "0.1.3" + versionCode = 5 + versionName = "0.1.4" } buildTypes { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt index ae2b967..091d136 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt @@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) { private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true)) val wazeEnabled: StateFlow = _wazeEnabled.asStateFlow() + private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true)) + val citizenEnabled: StateFlow = _citizenEnabled.asStateFlow() + private val _deflockProximityM = MutableStateFlow( prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX) ) @@ -51,6 +54,7 @@ class Settings private constructor(private val prefs: SharedPreferences) { fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v } fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v } fun 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) @@ -75,6 +79,7 @@ class Settings private constructor(private val prefs: SharedPreferences) { private const val KEY_WIFI = "src_wifi" private const val KEY_DEFLOCK = "src_deflock" private const val KEY_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_THEME = "theme_mode" diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt index b731166..02d3767 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt @@ -29,6 +29,11 @@ object ConfidenceEngine { const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m const val W_WAZE_POLICE = 55 + // Citizen (added when Waze went dark) + 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 const val B_STRONG_RSSI = 10 // > -50 dBm @@ -71,6 +76,18 @@ object ConfidenceEngine { val subtype: 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 level: Int, // 0-5 severity (Citizen's own scale) + val title: String, + val isPoliceTitled: Boolean, + val precinct: String? + ) + data class Scored( val score: Int, val methods: String, @@ -185,6 +202,24 @@ object ConfidenceEngine { return Scored(score, methods, label, isAxon = false) } + 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 ageMin = (obs.ageMs / 60_000L).toInt() + 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 { val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m" diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt index 5b1e738..2d9e036 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt @@ -27,17 +27,20 @@ object SourceHealth { private val _wifi = MutableStateFlow(Health()) private val _deflock = MutableStateFlow(Health()) private val _waze = MutableStateFlow(Health()) + private val _citizen = MutableStateFlow(Health()) val ble: StateFlow = _ble.asStateFlow() val wifi: StateFlow = _wifi.asStateFlow() val deflock: StateFlow = _deflock.asStateFlow() val waze: StateFlow = _waze.asStateFlow() + val citizen: StateFlow = _citizen.asStateFlow() fun flowFor(source: DetectionSource): StateFlow = when (source) { DetectionSource.BLE -> ble DetectionSource.WIFI -> wifi DetectionSource.DEFLOCK -> deflock DetectionSource.WAZE -> waze + DetectionSource.CITIZEN -> citizen } fun record(source: DetectionSource, ok: Boolean, message: String? = null) { @@ -46,6 +49,7 @@ object SourceHealth { DetectionSource.WIFI -> _wifi DetectionSource.DEFLOCK -> _deflock DetectionSource.WAZE -> _waze + DetectionSource.CITIZEN -> _citizen } target.value = Health( status = if (ok) Status.OK else Status.FAILED, @@ -59,5 +63,6 @@ object SourceHealth { _wifi.value = Health() _deflock.value = Health() _waze.value = Health() + _citizen.value = Health() } } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt index 99a95f7..3a4f22a 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt @@ -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, WAZE, CITIZEN } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt new file mode 100644 index 0000000..7c26ec9 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt @@ -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": ["", ...] } + * + * 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) : 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(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() + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt new file mode 100644 index 0000000..71b7578 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt @@ -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() + + 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) { + // 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 + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt index 91001a7..bedf8a8 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -26,6 +26,7 @@ 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 @@ -80,11 +81,13 @@ class DetectionService : LifecycleService() { 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() @@ -100,6 +103,10 @@ class DetectionService : LifecycleService() { store, locationProvider, proximityMeters = { settings.wazeProximityM.value.toFloat() } ) + citizenScanner = CitizenScanner( + store, locationProvider, + proximityMeters = { settings.wazeProximityM.value.toFloat() } + ) createNotificationChannel() } @@ -127,7 +134,9 @@ 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.wazeEnabled.value || + settings.citizenEnabled.value if (needsLocation) { val locOk = locationProvider.start() if (!locOk) { @@ -139,6 +148,9 @@ class DetectionService : LifecycleService() { if (settings.wazeEnabled.value) { wazeScanner.start(lifecycleScope); wazeStarted = true } + if (settings.citizenEnabled.value) { + citizenScanner.start(lifecycleScope); citizenStarted = true + } } } _running.value = true @@ -157,6 +169,7 @@ class DetectionService : LifecycleService() { 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() diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt index 033c08e..4ecfa2b 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -46,6 +46,7 @@ fun SettingsScreen( 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 theme by settings.themeMode.collectAsState() @@ -76,8 +77,9 @@ 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("WAZE • Live police reports (gated)", waze) { settings.setWazeEnabled(it) } + SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) } Spacer(Modifier.height(8.dp)) if (isRunning) { Button(