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:
@@ -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 {
|
||||
|
||||
@@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
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)
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Health> = _ble.asStateFlow()
|
||||
val wifi: StateFlow<Health> = _wifi.asStateFlow()
|
||||
val deflock: StateFlow<Health> = _deflock.asStateFlow()
|
||||
val waze: StateFlow<Health> = _waze.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.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user