v0.1.5 — remove Waze entirely
Waze's reCAPTCHA gating on live-map/api/georss has no clean mobile workaround, and the Citizen source added in v0.1.4 covers the same threat model with better data. Keeping a permanently-failed source visible was UI clutter — drop it. Removed: - scan/WazeClient.kt and scan/WazeScanner.kt (deleted) - WAZE from DetectionSource enum - waze flow from SourceHealth (+ flowFor/record/reset cases) - WazeObservation + scoreWaze + W_WAZE_POLICE from ConfidenceEngine - wazeEnabled from Settings (+ KEY_WAZE) - WAZE row from SettingsScreen - wazeScanner from DetectionService Renamed (Citizen now owns the proximity slider that Waze used to share): - Settings.wazeProximityM → citizenProximityM - Settings.setWazeProximityM → setCitizenProximityM - KEY_WAZE_PROX → KEY_CITIZEN_PROX - DEFAULT_WAZE_PROX → DEFAULT_CITIZEN_PROX (still 500) - SettingsScreen "Waze alert distance" → "Citizen alert distance" Existing users will see the slider reset to 500 m default since the SharedPreferences key changed. versionCode 5 → 6, versionName 0.1.4 → 0.1.5.
This commit is contained in:
@@ -20,8 +20,9 @@ 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. 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. |
|
||||||
| **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. |
|
|
||||||
|
> **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
|
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:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.1.4"
|
versionName = "0.1.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
|
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
|
||||||
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
|
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))
|
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
|
||||||
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
|
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
|
||||||
|
|
||||||
@@ -40,10 +37,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
)
|
)
|
||||||
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
|
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
|
||||||
|
|
||||||
private val _wazeProximityM = MutableStateFlow(
|
private val _citizenProximityM = MutableStateFlow(
|
||||||
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
|
prefs.getInt(KEY_CITIZEN_PROX, DEFAULT_CITIZEN_PROX)
|
||||||
)
|
)
|
||||||
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
|
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
|
||||||
|
|
||||||
private val _themeMode = MutableStateFlow(
|
private val _themeMode = MutableStateFlow(
|
||||||
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
||||||
@@ -53,7 +50,6 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
|
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 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 setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
|
fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
|
||||||
|
|
||||||
fun setDeflockProximityM(v: Int) {
|
fun setDeflockProximityM(v: Int) {
|
||||||
@@ -62,10 +58,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
_deflockProximityM.value = clamped
|
_deflockProximityM.value = clamped
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWazeProximityM(v: Int) {
|
fun setCitizenProximityM(v: Int) {
|
||||||
val clamped = v.coerceIn(100, 5000)
|
val clamped = v.coerceIn(100, 5000)
|
||||||
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
|
prefs.edit { putInt(KEY_CITIZEN_PROX, clamped) }
|
||||||
_wazeProximityM.value = clamped
|
_citizenProximityM.value = clamped
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
@@ -78,14 +74,13 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
private const val KEY_BLE = "src_ble"
|
private const val KEY_BLE = "src_ble"
|
||||||
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_CITIZEN = "src_citizen"
|
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_CITIZEN_PROX = "citizen_proximity_m"
|
||||||
private const val KEY_THEME = "theme_mode"
|
private const val KEY_THEME = "theme_mode"
|
||||||
|
|
||||||
const val DEFAULT_DEFLOCK_PROX = 200
|
const val DEFAULT_DEFLOCK_PROX = 200
|
||||||
const val DEFAULT_WAZE_PROX = 500
|
const val DEFAULT_CITIZEN_PROX = 500
|
||||||
|
|
||||||
@Volatile private var INSTANCE: Settings? = null
|
@Volatile private var INSTANCE: Settings? = null
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,11 @@ object ConfidenceEngine {
|
|||||||
const val W_WIFI_SSID_GENERIC = 50
|
const val W_WIFI_SSID_GENERIC = 50
|
||||||
const val W_WIFI_SSID_FLOCK_FMT = 65
|
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_NEAR = 60 // <= 200m
|
||||||
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
||||||
const val W_WAZE_POLICE = 55
|
|
||||||
|
|
||||||
// Citizen (added when Waze went dark)
|
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
|
||||||
const val W_CITIZEN_INCIDENT = 55
|
const val W_CITIZEN_INCIDENT = 55
|
||||||
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
|
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
|
||||||
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
|
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
|
||||||
@@ -66,16 +65,6 @@ object ConfidenceEngine {
|
|||||||
val manufacturer: String?
|
val manufacturer: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A Waze POLICE alert observed within proximity + freshness thresholds. */
|
|
||||||
data class WazeObservation(
|
|
||||||
val uuid: String,
|
|
||||||
val distanceMeters: Float,
|
|
||||||
val ageMs: Long,
|
|
||||||
val confidence: Int, // raw 0-5
|
|
||||||
val reliability: Int, // raw 0-10
|
|
||||||
val subtype: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A Citizen incident observed within proximity + freshness, after the
|
/** A Citizen incident observed within proximity + freshness, after the
|
||||||
* fire/medical filter is applied. */
|
* fire/medical filter is applied. */
|
||||||
data class CitizenObservation(
|
data class CitizenObservation(
|
||||||
@@ -186,22 +175,6 @@ object ConfidenceEngine {
|
|||||||
return Scored(score, methods.toString().trim(), label, isAxon)
|
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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scoreCitizen(obs: CitizenObservation): Scored {
|
fun scoreCitizen(obs: CitizenObservation): Scored {
|
||||||
var score = W_CITIZEN_INCIDENT
|
var score = W_CITIZEN_INCIDENT
|
||||||
val tags = StringBuilder("citizen ")
|
val tags = StringBuilder("citizen ")
|
||||||
|
|||||||
@@ -26,20 +26,17 @@ object SourceHealth {
|
|||||||
private val _ble = MutableStateFlow(Health())
|
private val _ble = MutableStateFlow(Health())
|
||||||
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 _citizen = 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 citizen: StateFlow<Health> = _citizen.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.CITIZEN -> citizen
|
DetectionSource.CITIZEN -> citizen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +45,6 @@ object SourceHealth {
|
|||||||
DetectionSource.BLE -> _ble
|
DetectionSource.BLE -> _ble
|
||||||
DetectionSource.WIFI -> _wifi
|
DetectionSource.WIFI -> _wifi
|
||||||
DetectionSource.DEFLOCK -> _deflock
|
DetectionSource.DEFLOCK -> _deflock
|
||||||
DetectionSource.WAZE -> _waze
|
|
||||||
DetectionSource.CITIZEN -> _citizen
|
DetectionSource.CITIZEN -> _citizen
|
||||||
}
|
}
|
||||||
target.value = Health(
|
target.value = Health(
|
||||||
@@ -62,7 +58,6 @@ object SourceHealth {
|
|||||||
_ble.value = Health()
|
_ble.value = Health()
|
||||||
_wifi.value = Health()
|
_wifi.value = Health()
|
||||||
_deflock.value = Health()
|
_deflock.value = Health()
|
||||||
_waze.value = Health()
|
|
||||||
_citizen.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, CITIZEN }
|
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN }
|
||||||
|
|||||||
@@ -1,131 +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?
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Outcome — distinguishes "no police alerts in area" from "couldn't reach Waze." */
|
|
||||||
sealed class FetchResult {
|
|
||||||
data class Success(val alerts: List<Alert>) : FetchResult()
|
|
||||||
data class Failed(val reason: String) : FetchResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchPoliceNear(lat: Double, lon: Double): FetchResult = 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 == 403) {
|
|
||||||
// Waze added reCAPTCHA gating to live-map in 2025/2026; mobile
|
|
||||||
// clients can no longer hit this endpoint without browser-level
|
|
||||||
// automation. Surface this distinctly so the UI can say so.
|
|
||||||
Log.w(TAG, "Waze returned 403 (upstream reCAPTCHA gating)")
|
|
||||||
return@withContext FetchResult.Failed("Upstream blocked (HTTP 403)")
|
|
||||||
}
|
|
||||||
if (code !in 200..299) {
|
|
||||||
Log.w(TAG, "Waze returned $code")
|
|
||||||
return@withContext FetchResult.Failed("HTTP $code")
|
|
||||||
}
|
|
||||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
FetchResult.Success(parsePolice(body))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Waze fetch failed: ${e.message}")
|
|
||||||
FetchResult.Failed(e.message ?: e.javaClass.simpleName)
|
|
||||||
} finally {
|
|
||||||
conn.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parsePolice(body: String): List<Alert> {
|
|
||||||
if (body.isBlank()) return emptyList()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(body)
|
|
||||||
val alerts = root.optJSONArray("alerts") ?: return emptyList()
|
|
||||||
val out = ArrayList<Alert>()
|
|
||||||
for (i in 0 until alerts.length()) {
|
|
||||||
val a = alerts.optJSONObject(i) ?: continue
|
|
||||||
if (a.optString("type") != "POLICE") continue
|
|
||||||
val loc = a.optJSONObject("location") ?: continue
|
|
||||||
val uuid = a.optString("uuid")
|
|
||||||
if (uuid.isBlank()) continue
|
|
||||||
val lat = loc.optDouble("y")
|
|
||||||
val lon = loc.optDouble("x")
|
|
||||||
if (lat.isNaN() || lon.isNaN()) continue
|
|
||||||
out.add(
|
|
||||||
Alert(
|
|
||||||
uuid = uuid,
|
|
||||||
subtype = a.optString("subtype").ifBlank { null },
|
|
||||||
lat = lat,
|
|
||||||
lon = lon,
|
|
||||||
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
|
|
||||||
confidence = a.optInt("confidence", 0),
|
|
||||||
reliability = a.optInt("reliability", 0),
|
|
||||||
reportedBy = a.optString("reportedBy").ifBlank { null }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
out
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to parse Waze response: ${e.message}")
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +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
|
|
||||||
import org.soulstone.overwatch.fusion.SourceHealth
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 result = client.fetchPoliceNear(fix.latitude, fix.longitude)
|
|
||||||
val alerts = when (result) {
|
|
||||||
is WazeClient.FetchResult.Success -> {
|
|
||||||
SourceHealth.record(DetectionSource.WAZE, ok = true)
|
|
||||||
result.alerts
|
|
||||||
}
|
|
||||||
is WazeClient.FetchResult.Failed -> {
|
|
||||||
SourceHealth.record(
|
|
||||||
DetectionSource.WAZE,
|
|
||||||
ok = false,
|
|
||||||
message = "Waze unreachable: ${result.reason}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ import org.soulstone.overwatch.scan.BleScanner
|
|||||||
import org.soulstone.overwatch.scan.CitizenScanner
|
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.WifiScanner
|
import org.soulstone.overwatch.scan.WifiScanner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,13 +79,11 @@ class DetectionService : LifecycleService() {
|
|||||||
private lateinit var wifiScanner: WifiScanner
|
private lateinit var wifiScanner: WifiScanner
|
||||||
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 citizenScanner: CitizenScanner
|
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 citizenStarted = false
|
private var citizenStarted = false
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -99,13 +96,9 @@ class DetectionService : LifecycleService() {
|
|||||||
store, locationProvider, DeflockClient(this),
|
store, locationProvider, DeflockClient(this),
|
||||||
proximityMeters = { settings.deflockProximityM.value.toFloat() }
|
proximityMeters = { settings.deflockProximityM.value.toFloat() }
|
||||||
)
|
)
|
||||||
wazeScanner = WazeScanner(
|
|
||||||
store, locationProvider,
|
|
||||||
proximityMeters = { settings.wazeProximityM.value.toFloat() }
|
|
||||||
)
|
|
||||||
citizenScanner = CitizenScanner(
|
citizenScanner = CitizenScanner(
|
||||||
store, locationProvider,
|
store, locationProvider,
|
||||||
proximityMeters = { settings.wazeProximityM.value.toFloat() }
|
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
||||||
)
|
)
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
@@ -135,7 +128,6 @@ class DetectionService : LifecycleService() {
|
|||||||
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 ||
|
val needsLocation = settings.deflockEnabled.value ||
|
||||||
settings.wazeEnabled.value ||
|
|
||||||
settings.citizenEnabled.value
|
settings.citizenEnabled.value
|
||||||
if (needsLocation) {
|
if (needsLocation) {
|
||||||
val locOk = locationProvider.start()
|
val locOk = locationProvider.start()
|
||||||
@@ -145,9 +137,6 @@ class DetectionService : LifecycleService() {
|
|||||||
if (settings.deflockEnabled.value) {
|
if (settings.deflockEnabled.value) {
|
||||||
deflockScanner.start(lifecycleScope); deflockStarted = true
|
deflockScanner.start(lifecycleScope); deflockStarted = true
|
||||||
}
|
}
|
||||||
if (settings.wazeEnabled.value) {
|
|
||||||
wazeScanner.start(lifecycleScope); wazeStarted = true
|
|
||||||
}
|
|
||||||
if (settings.citizenEnabled.value) {
|
if (settings.citizenEnabled.value) {
|
||||||
citizenScanner.start(lifecycleScope); citizenStarted = true
|
citizenScanner.start(lifecycleScope); citizenStarted = true
|
||||||
}
|
}
|
||||||
@@ -168,7 +157,6 @@ class DetectionService : LifecycleService() {
|
|||||||
if (bleStarted) { bleScanner.stop(); bleStarted = false }
|
if (bleStarted) { bleScanner.stop(); bleStarted = false }
|
||||||
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 (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
|
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
|
||||||
locationProvider.stop()
|
locationProvider.stop()
|
||||||
store.clear()
|
store.clear()
|
||||||
|
|||||||
@@ -45,10 +45,9 @@ fun SettingsScreen(
|
|||||||
val ble by settings.bleEnabled.collectAsState()
|
val ble by settings.bleEnabled.collectAsState()
|
||||||
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 citizen by settings.citizenEnabled.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 citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
val theme by settings.themeMode.collectAsState()
|
val theme by settings.themeMode.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -78,7 +77,6 @@ fun SettingsScreen(
|
|||||||
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 (Overpass)", deflock) { settings.setDeflockEnabled(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) }
|
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
@@ -116,12 +114,12 @@ fun SettingsScreen(
|
|||||||
onChange = { settings.setDeflockProximityM(it.toInt()) }
|
onChange = { settings.setDeflockProximityM(it.toInt()) }
|
||||||
)
|
)
|
||||||
SliderRow(
|
SliderRow(
|
||||||
label = "Waze alert distance",
|
label = "Citizen alert distance",
|
||||||
valueLabel = "${wazeProx} m",
|
valueLabel = "${citizenProx} m",
|
||||||
value = wazeProx.toFloat(),
|
value = citizenProx.toFloat(),
|
||||||
range = 100f..5000f,
|
range = 100f..5000f,
|
||||||
steps = 48,
|
steps = 48,
|
||||||
onChange = { settings.setWazeProximityM(it.toInt()) }
|
onChange = { settings.setCitizenProximityM(it.toInt()) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|||||||
Reference in New Issue
Block a user