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:
2026-04-28 21:56:27 -04:00
parent 5a7a9e90e4
commit 74f26439fc
10 changed files with 22 additions and 314 deletions
+3 -2
View File
@@ -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) |
| **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. 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. |
| **CITIZEN** | Real-time public-safety incidents (police-relevant only — fire/medical-only events filtered out) within configurable proximity, < 30 min old | `citizen.com/api/incident/trending` (bbox) polled every 60 s, then per-incident detail via `/api/incident/{id}` with an in-memory cache so each incident is fetched once per session. |
> **Why no Waze?** Waze added reCAPTCHA gating to its `live-map/api/georss` endpoint in 2025/2026. Mobile clients receive HTTP 403, and the only known workarounds (Selenium proxy on a home server, Waze for Cities partner program) aren't viable for a phone-deployed app. Citizen replaces it.
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources:
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "0.1.4"
versionCode = 6
versionName = "0.1.5"
}
buildTypes {
@@ -29,9 +29,6 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
@@ -40,10 +37,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
)
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
private val _citizenProximityM = MutableStateFlow(
prefs.getInt(KEY_CITIZEN_PROX, DEFAULT_CITIZEN_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
private val _themeMode = MutableStateFlow(
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
@@ -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 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) {
@@ -62,10 +58,10 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_deflockProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
fun setCitizenProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
prefs.edit { putInt(KEY_CITIZEN_PROX, clamped) }
_citizenProximityM.value = clamped
}
fun setThemeMode(mode: ThemeMode) {
@@ -78,14 +74,13 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private const val KEY_BLE = "src_ble"
private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_WAZE = "src_waze"
private const val KEY_CITIZEN = "src_citizen"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_THEME = "theme_mode"
const val DEFAULT_DEFLOCK_PROX = 200
const val DEFAULT_WAZE_PROX = 500
const val DEFAULT_CITIZEN_PROX = 500
@Volatile private var INSTANCE: Settings? = null
@@ -24,12 +24,11 @@ object ConfidenceEngine {
const val W_WIFI_SSID_GENERIC = 50
const val W_WIFI_SSID_FLOCK_FMT = 65
// Map / Waze (Phase 3 + 4)
// Map (Phase 3)
const val W_DEFLOCK_NEAR = 60 // <= 200m
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
const val W_WAZE_POLICE = 55
// Citizen (added when Waze went dark)
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
const val W_CITIZEN_INCIDENT = 55
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
@@ -66,16 +65,6 @@ object ConfidenceEngine {
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
* fire/medical filter is applied. */
data class CitizenObservation(
@@ -186,22 +175,6 @@ object ConfidenceEngine {
return Scored(score, methods.toString().trim(), label, isAxon)
}
fun scoreWaze(obs: WazeObservation): Scored {
// Plan baseline: 55 for any POLICE alert ≤500m & <10min old.
// Caller is responsible for applying the proximity + age gate before scoring.
var score = W_WAZE_POLICE
// Lightweight crowd-trust nudge: high reliability & high confidence each add a few points,
// capped well under the multi-method bonus so a corroborating BLE/WiFi hit still dominates.
if (obs.reliability >= 7) score += 5
if (obs.confidence >= 4) score += 5
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 {
var score = W_CITIZEN_INCIDENT
val tags = StringBuilder("citizen ")
@@ -26,20 +26,17 @@ object SourceHealth {
private val _ble = MutableStateFlow(Health())
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
}
@@ -48,7 +45,6 @@ object SourceHealth {
DetectionSource.BLE -> _ble
DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock
DetectionSource.WAZE -> _waze
DetectionSource.CITIZEN -> _citizen
}
target.value = Health(
@@ -62,7 +58,6 @@ object SourceHealth {
_ble.value = Health()
_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, 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.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.WazeScanner
import org.soulstone.overwatch.scan.WifiScanner
/**
@@ -80,13 +79,11 @@ class DetectionService : LifecycleService() {
private lateinit var wifiScanner: WifiScanner
private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner
private lateinit var wazeScanner: WazeScanner
private lateinit var citizenScanner: CitizenScanner
private var pruneJob: Job? = null
private var bleStarted = false
private var wifiStarted = false
private var deflockStarted = false
private var wazeStarted = false
private var citizenStarted = false
override fun onCreate() {
@@ -99,13 +96,9 @@ class DetectionService : LifecycleService() {
store, locationProvider, DeflockClient(this),
proximityMeters = { settings.deflockProximityM.value.toFloat() }
)
wazeScanner = WazeScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
)
citizenScanner = CitizenScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
proximityMeters = { settings.citizenProximityM.value.toFloat() }
)
createNotificationChannel()
}
@@ -135,7 +128,6 @@ class DetectionService : LifecycleService() {
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value ||
settings.wazeEnabled.value ||
settings.citizenEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
@@ -145,9 +137,6 @@ class DetectionService : LifecycleService() {
if (settings.deflockEnabled.value) {
deflockScanner.start(lifecycleScope); deflockStarted = true
}
if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true
}
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
@@ -168,7 +157,6 @@ class DetectionService : LifecycleService() {
if (bleStarted) { bleScanner.stop(); bleStarted = false }
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
locationProvider.stop()
store.clear()
@@ -45,10 +45,9 @@ fun SettingsScreen(
val ble by settings.bleEnabled.collectAsState()
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val theme by settings.themeMode.collectAsState()
Column(
@@ -78,7 +77,6 @@ fun SettingsScreen(
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(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) {
@@ -116,12 +114,12 @@ fun SettingsScreen(
onChange = { settings.setDeflockProximityM(it.toInt()) }
)
SliderRow(
label = "Waze alert distance",
valueLabel = "${wazeProx} m",
value = wazeProx.toFloat(),
label = "Citizen alert distance",
valueLabel = "${citizenProx} m",
value = citizenProx.toFloat(),
range = 100f..5000f,
steps = 48,
onChange = { settings.setWazeProximityM(it.toInt()) }
onChange = { settings.setCitizenProximityM(it.toInt()) }
)
Spacer(Modifier.height(16.dp))