3 Commits

Author SHA1 Message Date
KaraZajac 451376e497 v0.1.3 — DeFlock via Overpass + per-source health UI
The cdn.deflock.me CDN is gated behind Cloudflare bot mitigation that
mobile HTTP clients can't pass. The live deflock-app Flutter client
abandoned that path; it POSTs Overpass-QL queries directly to
overpass.deflock.org (with overpass-api.de as a fallback). Verified by
hitting the same endpoint from curl — 22 ALPRs returned for the
Springfield VA bbox, matching the user's screenshot of the working app.

DeflockClient rewrite:
  - POST [out:json][timeout:25];(node[surveillance][type=ALPR](bbox););out body;
  - 5 km half-width bbox around the user
  - 24h on-disk cache keyed by 0.05° grid cell (revisits don't refetch)
  - Returns sealed FetchResult: Success(points) | Failed(reason)

DeflockScanner update:
  - Replaces 20° tile concept with distance-based refetch (1.5 km threshold)
  - Records SourceHealth on each fetch outcome

Waze: reCAPTCHA gating confirmed. WazeClient.fetchPoliceNear now returns
sealed FetchResult; WazeScanner records SourceHealth.FAILED with
"Upstream blocked (HTTP 403)" so the user sees why no Waze data is
flowing instead of silent zeros.

New fusion/SourceHealth.kt — per-source MutableStateFlow registry,
record(source, ok, message) + reset() called on service start/stop.

UI: SourceRow in the bottom-sheet drill-down now shows the health
message in orange when status = FAILED instead of "no detections".

versionCode 3 → 4, versionName 0.1.2 → 0.1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:36:47 -04:00
KaraZajac 246c738ee4 v0.1.2 — audit fixes
Critical:
  - DetectionService.startInForeground now passes
    FOREGROUND_SERVICE_TYPE_LOCATION OR'd with TYPE_CONNECTED_DEVICE on
    Android 14+. Without this, the system silently revoked location access
    once the screen locked, breaking DeFlock + Waze for foreground-service
    use (the whole point of the foreground service).
  - DeflockClient and WazeClient now skip JSON entries whose lat/lon parse
    to NaN. Previously NaN flowed into Location.distanceBetween, the
    NaN > limit check returned false (IEEE 754), and we submitted a
    full-confidence detection labeled "@0m" — instant false-positive RED
    from a single malformed map entry.

UX:
  - First-run permission flow auto-starts scanning after the user grants
    everything; no second tap on START required.
  - Settings shows a "Restart scan to apply" button when toggling sources
    while scanning. Source toggle changes used to silently no-op until
    the next manual stop+start.

versionCode 1 → 3, versionName 0.1.0 → 0.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:22:20 -04:00
KaraZajac aa34a1c518 Fix START button being disabled before permissions are granted
The button was gated on `granted || running`, but the only thing that
triggers the permission request is tapping the button — catch-22 that
left first-time users with no way to grant permissions.

Always enable the button when not running; the onStartStop handler already
routes correctly (start scanning if granted, else launch the permission
request flow). Updated the helper text to point at this directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:15:42 -04:00
11 changed files with 312 additions and 92 deletions
+2 -2
View File
@@ -19,8 +19,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) | Public CDN tile fetch from `cdn.deflock.me`, 24h on-disk cache | | **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 | | **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. |
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 = 1 versionCode = 4
versionName = "0.1.0" versionName = "0.1.3"
} }
buildTypes { buildTypes {
@@ -42,7 +42,13 @@ class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { result -> ) { result ->
permissionsGranted.value = result.all { it.value } val allGranted = result.all { it.value }
permissionsGranted.value = allGranted
if (allGranted) {
// First-run path: user just granted everything, kick off scanning
// immediately so they don't have to tap START a second time.
DetectionService.start(this)
}
} }
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false) private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false)
@@ -70,8 +76,8 @@ class MainActivity : ComponentActivity() {
threat = threat, threat = threat,
score = maxScore, score = maxScore,
events = events, events = events,
canStart = granted || running, canStart = true,
permissionMessage = if (!granted) "Bluetooth, WiFi + location permissions required" else null, permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
onStartStop = { onStartStop = {
if (running) { if (running) {
DetectionService.stop(this) DetectionService.stop(this)
@@ -87,8 +93,14 @@ class MainActivity : ComponentActivity() {
) )
} }
Screen.SETTINGS -> { Screen.SETTINGS -> {
val running by DetectionService.running.collectAsState()
SettingsScreen( SettingsScreen(
settings = settings, settings = settings,
isRunning = running,
onRestart = {
DetectionService.stop(this)
DetectionService.start(this)
},
onBack = { screen = Screen.MAIN } onBack = { screen = Screen.MAIN }
) )
} }
@@ -0,0 +1,63 @@
package org.soulstone.overwatch.fusion
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Per-source upstream-health registry.
*
* Network sources (DEFLOCK, WAZE) record OK/FAILED so the UI can distinguish
* "scanned, found nothing" from "couldn't reach the data source." BLE/WIFI
* are radio-only and don't currently report; they default to UNKNOWN, which
* the UI treats the same as OK.
*/
object SourceHealth {
enum class Status { UNKNOWN, OK, FAILED }
data class Health(
val status: Status = Status.UNKNOWN,
val lastFetchMs: Long = 0L,
/** Short reason shown in the UI when status = FAILED. */
val message: String? = null
)
private val _ble = MutableStateFlow(Health())
private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health())
private val _waze = 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()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock
DetectionSource.WAZE -> waze
}
fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
val target = when (source) {
DetectionSource.BLE -> _ble
DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock
DetectionSource.WAZE -> _waze
}
target.value = Health(
status = if (ok) Status.OK else Status.FAILED,
lastFetchMs = System.currentTimeMillis(),
message = message
)
}
fun reset() {
_ble.value = Health()
_wifi.value = Health()
_deflock.value = Health()
_waze.value = Health()
}
}
@@ -5,31 +5,39 @@ import android.util.Log
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder
import kotlin.math.floor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.floor import org.json.JSONObject
import org.json.JSONArray
/** /**
* Fetches DeFlock ALPR tile data from the public CDN, with a 24h on-disk cache. * Fetches DeFlock ALPR data from the Overpass API (matching the live deflock-app
* Flutter client). The earlier `cdn.deflock.me/regions/...json` path is now
* gated behind Cloudflare bot mitigation that we cannot pass from a mobile HTTP
* client.
* *
* Tile scheme (from REFERENCES/deflock/serverless/alpr_cache): * Strategy:
* tile_lat = floor(lat / 20) * 20 * - POST an Overpass-QL query for `man_made=surveillance + surveillance:type=ALPR`
* tile_lon = floor(lon / 20) * 20 * inside a small bbox around the user.
* url = https://cdn.deflock.me/regions/{tile_lat}/{tile_lon}.json * - Try `overpass.deflock.org` first (less rate-limited for this use case),
* body = JSON array of { id: number, lat: number, lon: number, tags: {…} } * fall back to public `overpass-api.de`.
* * - Cache the JSON response on disk by 0.05° grid cell (24h TTL). Revisits to
* 20° tiles → ≤16 tiles cover the entire globe; one user typically only ever touches one. * the same cell don't re-hit the API.
*/ */
class DeflockClient(context: Context) { class DeflockClient(context: Context) {
companion object { companion object {
private const val TAG = "DeflockClient" private const val TAG = "DeflockClient"
private const val TILE_SIZE_DEG = 20 private const val FETCH_RADIUS_DEG = 0.05 // ~5.5 km half-width bbox
private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L
private const val CDN_BASE = "https://cdn.deflock.me/regions"
private const val USER_AGENT = "OVERWATCH/0.1 (+github.com/KaraZajac/OVERWATCH)" private const val USER_AGENT = "OVERWATCH/0.1 (+github.com/KaraZajac/OVERWATCH)"
private const val TIMEOUT_MS = 15_000 private const val TIMEOUT_MS = 30_000
private const val OVERPASS_QUERY_TIMEOUT_S = 25
private val ENDPOINTS = listOf(
"https://overpass.deflock.org/api/interpreter",
"https://overpass-api.de/api/interpreter"
)
} }
data class AlprPoint( data class AlprPoint(
@@ -40,63 +48,94 @@ class DeflockClient(context: Context) {
val manufacturer: String? = null val manufacturer: String? = null
) )
data class TileKey(val tileLat: Int, val tileLon: Int) { /** Outcome of a fetch — distinguishes "no ALPRs in area" from "couldn't reach the API." */
fun fileName() = "deflock_${tileLat}_${tileLon}.json" sealed class FetchResult {
data class Success(val points: List<AlprPoint>) : FetchResult()
data class Failed(val reason: String) : FetchResult()
} }
private val cacheDir: File = File(context.cacheDir, "deflock").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "deflock").apply { mkdirs() }
fun tileFor(lat: Double, lon: Double): TileKey = TileKey( suspend fun fetchAround(lat: Double, lon: Double): FetchResult = withContext(Dispatchers.IO) {
tileLat = floor(lat / TILE_SIZE_DEG).toInt() * TILE_SIZE_DEG, val key = cacheKeyFor(lat, lon)
tileLon = floor(lon / TILE_SIZE_DEG).toInt() * TILE_SIZE_DEG val cached = cachedJson(key)
)
/** Returns parsed ALPR points for the tile; empty list on any failure (logged). */
suspend fun fetchTile(tile: TileKey): List<AlprPoint> = withContext(Dispatchers.IO) {
val cached = cachedJson(tile)
if (cached != null) { if (cached != null) {
return@withContext parseSafely(cached) Log.d(TAG, "Cache hit for $key")
return@withContext FetchResult.Success(parseSafely(cached))
}
val south = lat - FETCH_RADIUS_DEG
val north = lat + FETCH_RADIUS_DEG
val west = lon - FETCH_RADIUS_DEG
val east = lon + FETCH_RADIUS_DEG
val query = buildQuery(south, west, north, east)
val (body, lastError) = downloadFromAny(query)
if (body == null) {
return@withContext FetchResult.Failed(lastError ?: "Network error")
} }
val downloaded = downloadTile(tile) ?: return@withContext emptyList()
try { try {
File(cacheDir, tile.fileName()).writeText(downloaded) File(cacheDir, "$key.json").writeText(body)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to write tile cache for $tile: ${e.message}") Log.w(TAG, "Failed to write cache for $key: ${e.message}")
} }
parseSafely(downloaded) FetchResult.Success(parseSafely(body))
} }
private fun cachedJson(tile: TileKey): String? { private fun cacheKeyFor(lat: Double, lon: Double): String {
val f = File(cacheDir, tile.fileName()) // 0.05° grid cell. Two consecutive points within the same cell get the
// same cache key, so micro-movements don't refetch.
val latStep = floor(lat / FETCH_RADIUS_DEG).toInt()
val lonStep = floor(lon / FETCH_RADIUS_DEG).toInt()
return "deflock_${latStep}_${lonStep}"
}
private fun cachedJson(key: String): String? {
val f = File(cacheDir, "$key.json")
if (!f.exists()) return null if (!f.exists()) return null
val age = System.currentTimeMillis() - f.lastModified() if (System.currentTimeMillis() - f.lastModified() > CACHE_TTL_MS) return null
if (age > CACHE_TTL_MS) return null
return try { f.readText() } catch (e: Exception) { null } return try { f.readText() } catch (e: Exception) { null }
} }
private fun downloadTile(tile: TileKey): String? { private fun buildQuery(south: Double, west: Double, north: Double, east: Double): String =
val url = URL("$CDN_BASE/${tile.tileLat}/${tile.tileLon}.json") "[out:json][timeout:$OVERPASS_QUERY_TIMEOUT_S];" +
"(node[\"man_made\"=\"surveillance\"][\"surveillance:type\"=\"ALPR\"]" +
"($south,$west,$north,$east););out body;"
/** Try each endpoint in order until one returns 2xx. Returns body + last error message. */
private fun downloadFromAny(query: String): Pair<String?, String?> {
var lastError: String? = null
for (endpoint in ENDPOINTS) {
val (body, err) = postQuery(endpoint, query)
if (body != null) return body to null
lastError = err
}
return null to lastError
}
private fun postQuery(endpoint: String, query: String): Pair<String?, String?> {
val url = URL(endpoint)
val conn = (url.openConnection() as HttpURLConnection).apply { val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS readTimeout = TIMEOUT_MS
requestMethod = "GET" requestMethod = "POST"
doOutput = true
setRequestProperty("User-Agent", USER_AGENT) setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
} }
return try { return try {
val payload = "data=" + URLEncoder.encode(query, "UTF-8")
conn.outputStream.use { it.write(payload.toByteArray()) }
val code = conn.responseCode val code = conn.responseCode
if (code == 404) { if (code in 200..299) {
Log.i(TAG, "Tile $tile not present on CDN (no ALPRs in this region)") val body = conn.inputStream.bufferedReader().use { it.readText() }
"" // cache the empty result by writing an empty string body to null
} else if (code in 200..299) {
conn.inputStream.bufferedReader().use { it.readText() }
} else { } else {
Log.w(TAG, "CDN returned $code for $tile") Log.w(TAG, "$endpoint returned $code")
null null to "HTTP $code"
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Download failed for $tile: ${e.message}") Log.w(TAG, "$endpoint failed: ${e.message}")
null null to (e.message ?: e.javaClass.simpleName)
} finally { } finally {
conn.disconnect() conn.disconnect()
} }
@@ -105,16 +144,21 @@ class DeflockClient(context: Context) {
private fun parseSafely(json: String): List<AlprPoint> { private fun parseSafely(json: String): List<AlprPoint> {
if (json.isBlank()) return emptyList() if (json.isBlank()) return emptyList()
return try { return try {
val arr = JSONArray(json) val root = JSONObject(json)
val out = ArrayList<AlprPoint>(arr.length()) val elements = root.optJSONArray("elements") ?: return emptyList()
for (i in 0 until arr.length()) { val out = ArrayList<AlprPoint>(elements.length())
val o = arr.getJSONObject(i) for (i in 0 until elements.length()) {
val tags = o.optJSONObject("tags") val el = elements.optJSONObject(i) ?: continue
if (el.optString("type") != "node") continue
val lat = el.optDouble("lat")
val lon = el.optDouble("lon")
if (lat.isNaN() || lon.isNaN()) continue
val tags = el.optJSONObject("tags")
out.add( out.add(
AlprPoint( AlprPoint(
id = o.optLong("id", 0L), id = el.optLong("id", 0L),
lat = o.optDouble("lat"), lat = lat,
lon = o.optDouble("lon"), lon = lon,
operator = tags?.optString("operator")?.ifBlank { null } operator = tags?.optString("operator")?.ifBlank { null }
?: tags?.optString("surveillance:operator")?.ifBlank { null }, ?: tags?.optString("surveillance:operator")?.ifBlank { null },
manufacturer = tags?.optString("manufacturer")?.ifBlank { null } manufacturer = tags?.optString("manufacturer")?.ifBlank { null }
@@ -126,7 +170,7 @@ class DeflockClient(context: Context) {
} }
out out
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to parse tile JSON: ${e.message}") Log.w(TAG, "Failed to parse Overpass response: ${e.message}")
emptyList() emptyList()
} }
} }
@@ -11,17 +11,16 @@ import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth
/** /**
* DeFlock orchestrator. * DeFlock orchestrator.
* *
* Subscribes to [LocationProvider]; for each new fix, looks up the matching 20° tile * Subscribes to [LocationProvider]; when the user has moved more than
* (loaded from [DeflockClient] cache or downloaded once / 24h) and submits a * [REFETCH_THRESHOLD_M] from the last fetch center (or there is no last
* detection event for every ALPR within [PROXIMITY_M]. * center), runs an Overpass query via [DeflockClient] for the surrounding
* * 5-km bbox. For each cached ALPR within [proximityMeters], submits a
* Tile-boundary edge case: at lat ≈ tile_lat or lon ≈ tile_lon ±0.002°, ALPRs across * detection event.
* the boundary won't be visible until the user crosses it. Acceptable for v0.1 — a
* 5-tile fetch (current + 4 neighbours) is a polish item.
*/ */
class DeflockScanner( class DeflockScanner(
private val store: DetectionStore, private val store: DetectionStore,
@@ -32,10 +31,12 @@ class DeflockScanner(
companion object { companion object {
private const val TAG = "DeflockScanner" private const val TAG = "DeflockScanner"
private const val REFETCH_THRESHOLD_M = 1500f
} }
private var job: Job? = null private var job: Job? = null
private var lastTile: DeflockClient.TileKey? = null private var lastFetchLat: Double? = null
private var lastFetchLon: Double? = null
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList() private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
@@ -52,17 +53,36 @@ class DeflockScanner(
fun stop() { fun stop() {
job?.cancel() job?.cancel()
job = null job = null
lastTile = null lastFetchLat = null
lastFetchLon = null
cachedPoints = emptyList() cachedPoints = emptyList()
Log.i(TAG, "DeflockScanner stopped") Log.i(TAG, "DeflockScanner stopped")
} }
private suspend fun handleFix(fix: Location) { private suspend fun handleFix(fix: Location) {
val tile = client.tileFor(fix.latitude, fix.longitude) if (shouldRefetch(fix)) {
if (tile != lastTile) { when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
cachedPoints = client.fetchTile(tile) is DeflockClient.FetchResult.Success -> {
lastTile = tile cachedPoints = result.points
Log.i(TAG, "Loaded tile $tile with ${cachedPoints.size} ALPRs") lastFetchLat = fix.latitude
lastFetchLon = fix.longitude
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i(
TAG,
"Loaded ${cachedPoints.size} ALPRs around " +
"(${fix.latitude}, ${fix.longitude})"
)
}
is DeflockClient.FetchResult.Failed -> {
SourceHealth.record(
DetectionSource.DEFLOCK,
ok = false,
message = "Overpass unreachable: ${result.reason}"
)
Log.w(TAG, "Overpass fetch failed: ${result.reason}")
// Keep using cachedPoints (may be empty on first failure).
}
}
} }
if (cachedPoints.isEmpty()) return if (cachedPoints.isEmpty()) return
@@ -91,4 +111,12 @@ class DeflockScanner(
) )
} }
} }
private fun shouldRefetch(fix: Location): Boolean {
val lat = lastFetchLat ?: return true
val lon = lastFetchLon ?: return true
val out = FloatArray(1)
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
return out[0] > REFETCH_THRESHOLD_M
}
} }
@@ -48,7 +48,13 @@ class WazeClient {
val reportedBy: String? val reportedBy: String?
) )
suspend fun fetchPoliceNear(lat: Double, lon: Double): List<Alert> = withContext(Dispatchers.IO) { /** 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 top = lat + BBOX_HALF_DEG
val bottom = lat - BBOX_HALF_DEG val bottom = lat - BBOX_HALF_DEG
val left = lon - BBOX_HALF_DEG val left = lon - BBOX_HALF_DEG
@@ -67,15 +73,22 @@ class WazeClient {
} }
try { try {
val code = conn.responseCode 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) { if (code !in 200..299) {
Log.w(TAG, "Waze returned $code") Log.w(TAG, "Waze returned $code")
return@withContext emptyList() return@withContext FetchResult.Failed("HTTP $code")
} }
val body = conn.inputStream.bufferedReader().use { it.readText() } val body = conn.inputStream.bufferedReader().use { it.readText() }
parsePolice(body) FetchResult.Success(parsePolice(body))
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Waze fetch failed: ${e.message}") Log.w(TAG, "Waze fetch failed: ${e.message}")
emptyList() FetchResult.Failed(e.message ?: e.javaClass.simpleName)
} finally { } finally {
conn.disconnect() conn.disconnect()
} }
@@ -93,12 +106,15 @@ class WazeClient {
val loc = a.optJSONObject("location") ?: continue val loc = a.optJSONObject("location") ?: continue
val uuid = a.optString("uuid") val uuid = a.optString("uuid")
if (uuid.isBlank()) continue if (uuid.isBlank()) continue
val lat = loc.optDouble("y")
val lon = loc.optDouble("x")
if (lat.isNaN() || lon.isNaN()) continue
out.add( out.add(
Alert( Alert(
uuid = uuid, uuid = uuid,
subtype = a.optString("subtype").ifBlank { null }, subtype = a.optString("subtype").ifBlank { null },
lat = loc.optDouble("y"), lat = lat,
lon = loc.optDouble("x"), lon = lon,
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()), pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
confidence = a.optInt("confidence", 0), confidence = a.optInt("confidence", 0),
reliability = a.optInt("reliability", 0), reliability = a.optInt("reliability", 0),
@@ -12,6 +12,7 @@ import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore 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 * Polls Waze every 60s for live POLICE alerts in a small bounding box around the
@@ -59,7 +60,21 @@ class WazeScanner(
} }
private suspend fun pollOnce(fix: Location) { private suspend fun pollOnce(fix: Location) {
val alerts = client.fetchPoliceNear(fix.latitude, fix.longitude) 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 if (alerts.isEmpty()) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val limit = proximityMeters() val limit = proximityMeters()
@@ -24,6 +24,7 @@ import org.soulstone.overwatch.R
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.settings.Settings 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.scan.BleScanner import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner import org.soulstone.overwatch.scan.DeflockScanner
@@ -116,6 +117,7 @@ class DetectionService : LifecycleService() {
private fun beginScanning() { private fun beginScanning() {
if (_running.value) return if (_running.value) return
SourceHealth.reset()
startInForeground() startInForeground()
if (settings.bleEnabled.value) { if (settings.bleEnabled.value) {
bleStarted = bleScanner.start() bleStarted = bleScanner.start()
@@ -157,6 +159,7 @@ class DetectionService : LifecycleService() {
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false } if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
locationProvider.stop() locationProvider.stop()
store.clear() store.clear()
SourceHealth.reset()
pruneJob?.cancel() pruneJob?.cancel()
pruneJob = null pruneJob = null
_running.value = false _running.value = false
@@ -181,11 +184,13 @@ class DetectionService : LifecycleService() {
private fun startInForeground() { private fun startInForeground() {
val notification = buildNotification() val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground( // Android 14+ requires the runtime type to cover every capability
NOTIFICATION_ID, // the service uses. We declare both in the manifest; pass both here
notification, // so location-using sources (DeFlock, Waze) keep working with the
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE // screen off.
) val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
startForeground(NOTIFICATION_ID, notification, type)
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} }
@@ -33,6 +33,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -50,6 +51,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.ui.theme.ThreatColors import org.soulstone.overwatch.ui.theme.ThreatColors
@@ -272,6 +274,9 @@ private fun SourcesPanel(events: List<DetectionEvent>) {
@Composable @Composable
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) { private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
val health by SourceHealth.flowFor(source).collectAsState()
val unreachable = health.status == SourceHealth.Status.FAILED
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -294,6 +299,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
) )
val maxScore = events.maxOfOrNull { it.score } ?: 0 val maxScore = events.maxOfOrNull { it.score } ?: 0
val statusColor = when { val statusColor = when {
unreachable -> MaterialTheme.colorScheme.onSurfaceVariant
maxScore >= ThreatLevel.RED.minScore -> ThreatColors.Red maxScore >= ThreatLevel.RED.minScore -> ThreatColors.Red
maxScore >= ThreatLevel.ORANGE.minScore -> ThreatColors.Orange maxScore >= ThreatLevel.ORANGE.minScore -> ThreatColors.Orange
maxScore >= ThreatLevel.YELLOW.minScore -> ThreatColors.Yellow maxScore >= ThreatLevel.YELLOW.minScore -> ThreatColors.Yellow
@@ -306,7 +312,15 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
.background(statusColor) .background(statusColor)
) )
} }
if (events.isEmpty()) { if (unreachable) {
Spacer(Modifier.height(4.dp))
Text(
text = health.message ?: "Source unavailable",
color = ThreatColors.Orange,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
} else if (events.isEmpty()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = "no detections", text = "no detections",
@@ -13,6 +13,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -35,6 +38,8 @@ import org.soulstone.overwatch.data.settings.Settings
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
settings: Settings, settings: Settings,
isRunning: Boolean,
onRestart: () -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val ble by settings.bleEnabled.collectAsState() val ble by settings.bleEnabled.collectAsState()
@@ -74,11 +79,29 @@ fun SettingsScreen(
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) } SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( if (isRunning) {
"Source toggles take effect on next Start.", Button(
fontSize = 11.sp, onClick = onRestart,
color = MaterialTheme.colorScheme.onSurfaceVariant colors = ButtonDefaults.buttonColors(
) containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Restart scan to apply",
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
} else {
Text(
"Source toggles take effect on next Start.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionLabel("Proximity thresholds") SectionLabel("Proximity thresholds")