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.
This commit is contained in:
2026-04-28 21:48:54 -04:00
parent 00584f58c9
commit 5a7a9e90e4
10 changed files with 343 additions and 7 deletions
+2 -1
View File
@@ -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) | | **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) | | **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. | | **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. |
| **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 Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources: the maximum live score across all sources:
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 5
versionName = "0.1.3" versionName = "0.1.4"
} }
buildTypes { buildTypes {
@@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true)) private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow() 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( private val _deflockProximityM = MutableStateFlow(
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX) 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 setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v } fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v } fun 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) { fun setDeflockProximityM(v: Int) {
val clamped = v.coerceIn(50, 1600) 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_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock" private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_WAZE = "src_waze" private const val KEY_WAZE = "src_waze"
private const val KEY_CITIZEN = "src_citizen"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m" private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m" private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_THEME = "theme_mode" private const val KEY_THEME = "theme_mode"
@@ -29,6 +29,11 @@ object ConfidenceEngine {
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
const val W_WAZE_POLICE = 55 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 // Bonuses
const val B_MULTI_METHOD = 20 const val B_MULTI_METHOD = 20
const val B_STRONG_RSSI = 10 // > -50 dBm const val B_STRONG_RSSI = 10 // > -50 dBm
@@ -71,6 +76,18 @@ object ConfidenceEngine {
val subtype: String? 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( data class Scored(
val score: Int, val score: Int,
val methods: String, val methods: String,
@@ -185,6 +202,24 @@ object ConfidenceEngine {
return Scored(score, methods, label, isAxon = false) 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 { fun scoreDeflock(obs: DeflockObservation): Scored {
val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR 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" val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m"
@@ -27,17 +27,20 @@ object SourceHealth {
private val _wifi = MutableStateFlow(Health()) private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health()) private val _deflock = MutableStateFlow(Health())
private val _waze = MutableStateFlow(Health()) private val _waze = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow() val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow() val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow() val deflock: StateFlow<Health> = _deflock.asStateFlow()
val waze: StateFlow<Health> = _waze.asStateFlow() val waze: StateFlow<Health> = _waze.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) { fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock DetectionSource.DEFLOCK -> deflock
DetectionSource.WAZE -> waze DetectionSource.WAZE -> waze
DetectionSource.CITIZEN -> citizen
} }
fun record(source: DetectionSource, ok: Boolean, message: String? = null) { fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
@@ -46,6 +49,7 @@ object SourceHealth {
DetectionSource.WIFI -> _wifi DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock DetectionSource.DEFLOCK -> _deflock
DetectionSource.WAZE -> _waze DetectionSource.WAZE -> _waze
DetectionSource.CITIZEN -> _citizen
} }
target.value = Health( target.value = Health(
status = if (ok) Status.OK else Status.FAILED, status = if (ok) Status.OK else Status.FAILED,
@@ -59,5 +63,6 @@ object SourceHealth {
_wifi.value = Health() _wifi.value = Health()
_deflock.value = Health() _deflock.value = Health()
_waze.value = Health() _waze.value = Health()
_citizen.value = Health()
} }
} }
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
} }
/** Logical signal channel — used in the drill-down UI. */ /** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE } enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE, 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
)
)
}
}
}
@@ -26,6 +26,7 @@ import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.scan.BleScanner import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.WazeScanner import org.soulstone.overwatch.scan.WazeScanner
@@ -80,11 +81,13 @@ class DetectionService : LifecycleService() {
private lateinit var locationProvider: LocationProvider private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner private lateinit var deflockScanner: DeflockScanner
private lateinit var wazeScanner: WazeScanner private lateinit var wazeScanner: WazeScanner
private lateinit var citizenScanner: CitizenScanner
private var pruneJob: Job? = null private var pruneJob: Job? = null
private var bleStarted = false private var bleStarted = false
private var wifiStarted = false private var wifiStarted = false
private var deflockStarted = false private var deflockStarted = false
private var wazeStarted = false private var wazeStarted = false
private var citizenStarted = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -100,6 +103,10 @@ class DetectionService : LifecycleService() {
store, locationProvider, store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() } proximityMeters = { settings.wazeProximityM.value.toFloat() }
) )
citizenScanner = CitizenScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
)
createNotificationChannel() createNotificationChannel()
} }
@@ -127,7 +134,9 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope) wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)") if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
} }
val needsLocation = settings.deflockEnabled.value || settings.wazeEnabled.value val needsLocation = settings.deflockEnabled.value ||
settings.wazeEnabled.value ||
settings.citizenEnabled.value
if (needsLocation) { if (needsLocation) {
val locOk = locationProvider.start() val locOk = locationProvider.start()
if (!locOk) { if (!locOk) {
@@ -139,6 +148,9 @@ class DetectionService : LifecycleService() {
if (settings.wazeEnabled.value) { if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true wazeScanner.start(lifecycleScope); wazeStarted = true
} }
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
} }
} }
_running.value = true _running.value = true
@@ -157,6 +169,7 @@ class DetectionService : LifecycleService() {
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false } if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false } if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false } if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
locationProvider.stop() locationProvider.stop()
store.clear() store.clear()
SourceHealth.reset() SourceHealth.reset()
@@ -46,6 +46,7 @@ fun SettingsScreen(
val wifi by settings.wifiEnabled.collectAsState() val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState() val deflock by settings.deflockEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState() val waze by settings.wazeEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState() val deflockProx by settings.deflockProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState() val wazeProx by settings.wazeProximityM.collectAsState()
val theme by settings.themeMode.collectAsState() val theme by settings.themeMode.collectAsState()
@@ -76,8 +77,9 @@ fun SettingsScreen(
SectionLabel("Detection sources") SectionLabel("Detection sources")
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) } SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) } SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(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)) Spacer(Modifier.height(8.dp))
if (isRunning) { if (isRunning) {
Button( Button(