v0.5.0 — Waze police source via key-protected proxy

Re-adds the WAZE detection source (removed in v0.1.5 when Waze reCAPTCHA-gated
its live-map endpoint). Data comes from OpenWeb Ninja's hosted Waze feed, but
the paid API key never ships in the app: a Caddy reverse proxy at
api.blackflagintel.com injects the key server-side, and the app authenticates
with a scoped X-App-Token entered in Settings and stored encrypted via the
Android Keystore (SecureStore). No credential is baked into the APK.

- scan/WazeClient.kt, scan/WazeScanner.kt: proxy client + 4-min poller,
  200-alert page + client-side POLICE filter, 45-min freshness window.
- data/settings/SecureStore.kt: Keystore AES/GCM at-rest store (no dependency).
- Settings: encrypted wazeProxyToken + "Waze police feed" token field.
- ConfidenceEngine.scoreWaze, SourceHealth/DetectionSource WAZE, service wiring.
- Removes the BuildConfig/local.properties key baking entirely.

versionCode 15 -> 16, versionName 0.4.0 -> 0.5.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jgJnzMr2bdNuWcDX2xRUR
This commit is contained in:
KaraZajac
2026-07-13 00:26:07 -04:00
parent fc67d3d203
commit 26c4248282
13 changed files with 670 additions and 12 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
versionCode = 14
versionName = "0.3.2"
versionCode = 16
versionName = "0.5.0"
}
buildTypes {
@@ -99,7 +99,8 @@ class MainActivity : ComponentActivity() {
// source is briefly toggled.
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val mapRadiusM = maxOf(deflockProx, citizenProx).toFloat()
val wazeProx by settings.wazeProximityM.collectAsState()
val mapRadiusM = maxOf(deflockProx, citizenProx, wazeProx).toFloat()
val granted by permissionsGranted
val denied by permanentlyDenied
@@ -0,0 +1,89 @@
package org.soulstone.overwatch.data.settings
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Tiny at-rest secret store for on-device credentials (the Waze proxy token).
*
* Values are AES/GCM-encrypted with a key held in the Android Keystore (hardware
* -backed where available and never exportable), and the ciphertext is kept in
* app-private SharedPreferences. This protects the token beyond plain prefs —
* against device backups, rooted-device extraction, and forensic access — so a
* shared/published APK carries no credential and a compromised backup yields
* nothing usable.
*
* No third-party dependency (Jetpack Security's EncryptedSharedPreferences is in
* maintenance limbo); this is ~40 lines of standard Keystore AES/GCM instead.
*/
object SecureStore {
private const val TAG = "SecureStore"
private const val PREFS = "overwatch_secure"
private const val KEY_ALIAS = "overwatch_secret_key"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_LEN = 12
private const val GCM_TAG_BITS = 128
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}
/** Encrypt + persist [value] under [name]. Empty string clears the entry. */
fun put(context: Context, name: String, value: String) {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (value.isEmpty()) {
prefs.edit { remove(name) }
return
}
try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
// Prepend the (12-byte) IV so decryption is self-contained.
val blob = Base64.encodeToString(cipher.iv + ct, Base64.NO_WRAP)
prefs.edit { putString(name, blob) }
} catch (e: Exception) {
Log.w(TAG, "Failed to store secret '$name': ${e.message}")
}
}
/** Decrypt the value stored under [name], or null if absent/undecryptable. */
fun get(context: Context, name: String): String? {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val blob = prefs.getString(name, null) ?: return null
return try {
val bytes = Base64.decode(blob, Base64.NO_WRAP)
val iv = bytes.copyOfRange(0, IV_LEN)
val ct = bytes.copyOfRange(IV_LEN, bytes.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(ct), Charsets.UTF_8)
} catch (e: Exception) {
Log.w(TAG, "Failed to read secret '$name': ${e.message}")
null
}
}
}
@@ -16,7 +16,10 @@ import kotlinx.coroutines.flow.asStateFlow
* Per-source toggles only take effect at the next Start cycle — flipping a
* source while scanning will NOT live-restart that scanner.
*/
class Settings private constructor(private val prefs: SharedPreferences) {
class Settings private constructor(
private val prefs: SharedPreferences,
private val appContext: Context
) {
enum class ThemeMode { SYSTEM, DARK, LIGHT }
@@ -32,6 +35,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
@@ -45,6 +51,17 @@ class Settings private constructor(private val prefs: SharedPreferences) {
)
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
// Shared secret the app presents to the api.blackflagintel.com proxy, which
// holds the real OpenWeb Ninja key server-side. Stored encrypted (Keystore),
// never baked into the APK, so a published build carries no usable credential.
private val _wazeProxyToken = MutableStateFlow(SecureStore.get(appContext, KEY_WAZE_TOKEN) ?: "")
val wazeProxyToken: StateFlow<String> = _wazeProxyToken.asStateFlow()
private val _themeMode = MutableStateFlow(
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
)
@@ -60,6 +77,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 setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v }
fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
fun setDeflockProximityM(v: Int) {
@@ -74,6 +92,18 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_citizenProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
}
fun setWazeProxyToken(v: String) {
val t = v.trim()
SecureStore.put(appContext, KEY_WAZE_TOKEN, t)
_wazeProxyToken.value = t
}
fun setThemeMode(mode: ThemeMode) {
prefs.edit { putString(KEY_THEME, mode.name) }
_themeMode.value = mode
@@ -95,21 +125,26 @@ 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_CITIZEN = "src_citizen"
private const val KEY_WAZE = "src_waze"
private const val KEY_MIC = "src_mic"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_WAZE_TOKEN = "waze_proxy_token"
private const val KEY_THEME = "theme_mode"
private const val KEY_VIBRATE = "vibrate_on_alert"
private const val KEY_OVERLAY = "overlay_enabled"
const val DEFAULT_DEFLOCK_PROX = 200
const val DEFAULT_CITIZEN_PROX = 500
const val DEFAULT_WAZE_PROX = 500
@Volatile private var INSTANCE: Settings? = null
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
INSTANCE ?: Settings(
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE),
context.applicationContext
).also { INSTANCE = it }
}
}
@@ -28,11 +28,14 @@ object ConfidenceEngine {
const val W_DEFLOCK_NEAR = 60 // <= 200m
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
// Citizen (real-time incident feed)
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
// Waze (live POLICE alerts via the OpenWeb Ninja hosted feed)
const val W_WAZE_POLICE = 55
// Bonuses
const val B_MULTI_METHOD = 20
const val B_STRONG_RSSI = 10 // > -50 dBm
@@ -90,6 +93,16 @@ object ConfidenceEngine {
val precinct: 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?
)
data class Scored(
val score: Int,
val methods: String,
@@ -206,6 +219,22 @@ object ConfidenceEngine {
return Scored(score, tags.toString().trim(), label, isAxon = false)
}
fun scoreWaze(obs: WazeObservation): Scored {
// Baseline 55 for any POLICE alert within the proximity + age gate the
// caller already applied. Small crowd-trust nudges for high reliability
// and high confidence, capped well under the multi-method bonus so a
// corroborating BLE/WiFi/DeFlock hit still dominates the global tier.
var score = W_WAZE_POLICE
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 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,12 +27,14 @@ object SourceHealth {
private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health())
private val _waze = MutableStateFlow(Health())
private val _mic = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow()
val waze: StateFlow<Health> = _waze.asStateFlow()
val mic: StateFlow<Health> = _mic.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
@@ -40,6 +42,7 @@ object SourceHealth {
DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock
DetectionSource.CITIZEN -> citizen
DetectionSource.WAZE -> waze
DetectionSource.MIC -> mic
}
@@ -49,6 +52,7 @@ object SourceHealth {
DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock
DetectionSource.CITIZEN -> _citizen
DetectionSource.WAZE -> _waze
DetectionSource.MIC -> _mic
}
target.value = Health(
@@ -63,6 +67,7 @@ object SourceHealth {
_wifi.value = Health()
_deflock.value = Health()
_citizen.value = Health()
_waze.value = Health()
_mic.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, CITIZEN, MIC }
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, WAZE, MIC }
@@ -0,0 +1,225 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.cos
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches live Waze POLICE alerts through the OVERWATCH proxy at
* `api.blackflagintel.com` (a Caddy vhost on the ASTROPHAGE box). The proxy
* holds the real OpenWeb Ninja API key server-side and injects it; the app only
* presents a scoped, revocable `X-App-Token`. So the valuable key never ships in
* the APK — a leaked/decompiled build carries only the proxy token, which works
* against this one endpoint and can be rotated on the server in seconds.
*
* GET https://api.blackflagintel.com/waze/alerts-and-jams
* ?bottom_left=<minLat>,<minLon>&top_right=<maxLat>,<maxLon>&max_alerts=200
* Header: X-App-Token: <token from encrypted Settings, never baked into the APK>
*
* The token is entered once in Settings and stored encrypted (see [SecureStore]);
* an empty token means the source is unconfigured — [isConfigured] is false and
* the scanner surfaces that instead of calling out.
*
* Two verified quirks of the upstream feed drive the request/parse shape:
* - It echoes but does NOT honor an `alert_types` filter, and defaults to
* `max_alerts=20`, so POLICE reports get crowded out by HAZARD/ROAD_CLOSED.
* We pull the full page (200 = server ceiling) and filter to POLICE here.
* - Response envelope is `{ "data": { "alerts": [...], "jams": [...] } }`;
* each alert carries `alert_id`, `type`, `subtype` (nullable), `latitude`,
* `longitude`, `alert_confidence` (0-5), `alert_reliability` (0-10), and an
* ISO-8601 `publish_datetime_utc`. The parser also accepts Waze-native names
* (`location.y`/`confidence`/`pubMillis`) so a minor upstream change to the
* shape doesn't silently zero out detections.
*/
class WazeClient(
private val appToken: () -> String = { "" }
) {
companion object {
private const val TAG = "WazeClient"
private const val BASE = "https://api.blackflagintel.com/waze/alerts-and-jams"
private const val TIMEOUT_MS = 10_000
/** Never sweep a box tighter than this, so alerts the user is driving
* toward are already fetched by the time the precise proximity filter
* (applied in the scanner) starts including them. */
private const val BBOX_MIN_RADIUS_M = 800.0
}
/** True when a proxy token is set. False → source is unconfigured. */
val isConfigured: Boolean get() = appToken().isNotBlank()
data class Alert(
val uuid: String,
val subtype: String?,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val confidence: Int, // 0-5
val reliability: Int // 0-10
)
/** Outcome — distinguishes "no police alerts in area" from "couldn't reach the feed." */
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,
radiusMeters: Float
): FetchResult = withContext(Dispatchers.IO) {
if (!isConfigured) return@withContext FetchResult.Failed("Proxy token not set")
val r = radiusMeters.toDouble().coerceAtLeast(BBOX_MIN_RADIUS_M)
val latDelta = r / 111_000.0
val lonDelta = r / (111_000.0 * cos(Math.toRadians(lat)).coerceAtLeast(0.01))
// Double.toString() is locale-independent (always '.'), so string-building
// the coordinates avoids any comma-decimal-separator surprise.
val bottomLeft = "${lat - latDelta},${lon - lonDelta}"
val topRight = "${lat + latDelta},${lon + lonDelta}"
// max_alerts=200 (the server ceiling), not an alert_types filter — see
// the class KDoc. The proximity-sized bbox keeps the real alert count
// well under 200, so POLICE entries are never truncated away.
val url = URL(
"$BASE?bottom_left=${enc(bottomLeft)}&top_right=${enc(topRight)}&max_alerts=200"
)
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("X-App-Token", appToken())
setRequestProperty("Accept", "application/json")
}
try {
when (val code = conn.responseCode) {
in 200..299 -> {
val body = conn.inputStream.bufferedReader().use { it.readText() }
FetchResult.Success(parsePolice(body))
}
401, 403 -> {
Log.w(TAG, "Waze proxy rejected token ($code)")
FetchResult.Failed("Proxy rejected token (HTTP $code)")
}
429 -> {
Log.w(TAG, "Waze feed rate-limited (429)")
FetchResult.Failed("Rate limited (HTTP 429)")
}
else -> {
Log.w(TAG, "Waze feed returned $code")
FetchResult.Failed("HTTP $code")
}
}
} catch (e: Exception) {
Log.w(TAG, "Waze feed 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 alerts = extractAlerts(JSONObject(body)) ?: return emptyList()
val out = ArrayList<Alert>(alerts.length())
for (i in 0 until alerts.length()) {
val a = alerts.optJSONObject(i) ?: continue
// We request alert_types=POLICE server-side; this is a belt-and-
// suspenders guard in case the envelope also carries other types.
val type = firstString(a, "type", "alertType")
if (type != null && !type.equals("POLICE", ignoreCase = true)) continue
val loc = a.optJSONObject("location")
val lat = doubleOrNull(a, "latitude") ?: loc?.let { doubleOrNull(it, "y") } ?: continue
val lon = doubleOrNull(a, "longitude") ?: loc?.let { doubleOrNull(it, "x") } ?: continue
if (lat.isNaN() || lon.isNaN()) continue
val uuid = firstString(a, "alert_id", "uuid", "id")
?: "$lat,$lon,${firstLong(a, "publish_datetime_utc") ?: i}"
out.add(
Alert(
uuid = uuid,
subtype = firstString(a, "subtype"),
lat = lat,
lon = lon,
pubMillis = parseTimeMillis(a),
confidence = firstInt(a, "alert_confidence", "confidence") ?: 0,
reliability = firstInt(a, "alert_reliability", "reliability") ?: 0
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Waze feed response: ${e.message}")
emptyList()
}
}
/** Locate the alerts array across the known envelope variants. */
private fun extractAlerts(root: JSONObject): JSONArray? {
root.optJSONArray("alerts")?.let { return it }
when (val data = root.opt("data")) {
is JSONArray -> return data
is JSONObject -> data.optJSONArray("alerts")?.let { return it }
}
root.optJSONObject("result")?.optJSONArray("alerts")?.let { return it }
return null
}
/** publish_datetime_utc is an ISO-8601 string; fall back to epoch-millis
* fields, then to "now" so a missing timestamp never drops an alert. */
private fun parseTimeMillis(a: JSONObject): Long {
val iso = firstString(a, "publish_datetime_utc", "pub_datetime_utc")
if (iso != null) {
try {
return Instant.parse(iso).toEpochMilli()
} catch (_: Exception) {
try {
return LocalDateTime.parse(iso.replace(' ', 'T'))
.toInstant(ZoneOffset.UTC).toEpochMilli()
} catch (_: Exception) { /* fall through */ }
}
}
return firstLong(a, "pubMillis", "pub_millis") ?: System.currentTimeMillis()
}
private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8")
private fun firstString(o: JSONObject, vararg keys: String): String? {
for (k in keys) {
val v = o.optString(k, "")
if (v.isNotBlank() && v != "null") return v
}
return null
}
private fun doubleOrNull(o: JSONObject, key: String): Double? {
if (!o.has(key) || o.isNull(key)) return null
val v = o.optDouble(key, Double.NaN)
return if (v.isNaN()) null else v
}
private fun firstInt(o: JSONObject, vararg keys: String): Int? {
for (k in keys) if (o.has(k) && !o.isNull(k)) return o.optInt(k, 0)
return null
}
private fun firstLong(o: JSONObject, vararg keys: String): Long? {
for (k in keys) if (o.has(k) && !o.isNull(k)) return o.optLong(k, 0L)
return null
}
}
@@ -0,0 +1,155 @@
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.flow.first
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 the OpenWeb Ninja Waze feed for live POLICE alerts around the current
* location, then submits any inside [proximityMeters] and younger than
* [MAX_AGE_MS].
*
* Poll cadence is deliberately slow — the feed is a metered paid API and lags
* live Waze by ~20 min anyway, so a 4-min poll loses nothing and keeps request
* volume (and pay-as-you-go cost, ~$0.005/req) low at ~15 req/active-hour. Kept
* just under the DetectionStore's 5-min retention so a persistent alert (a
* standing checkpoint) is re-submitted before it can expire and flicker out. If
* no API key is configured the loop records the source as unreachable (with a
* clear reason) and skips the network call rather than hammering a 401.
*
* The last fetched alert set is cached so [refresh] can re-evaluate against a
* moved proximity slider without a network refetch (mirrors CitizenScanner).
*/
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 = 240_000L
// The hosted feed only lists still-active alerts but lags live Waze, so
// real police sightings routinely arrive already 20-30 min old. A 10-min
// cutoff (fine for the old direct-live feed) would drop nearly all of
// them; 45 min matches what the feed actually serves as "current."
private const val MAX_AGE_MS = 45L * 60L * 1000L
}
private var job: Job? = null
private var lastAlerts: List<WazeClient.Alert> = emptyList()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
// Wait for the first fix so the opening poll fires as soon as we have
// a location instead of after a full interval.
locationProvider.location.first { it != null }
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) pollOnce(fix)
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms, configured=${client.isConfigured})")
return true
}
fun stop() {
job?.cancel()
job = null
lastAlerts = emptyList()
Log.i(TAG, "WazeScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
if (!client.isConfigured) {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Proxy token not set — add it in Settings"
)
return
}
when (val result = client.fetchPoliceNear(fix.latitude, fix.longitude, proximityMeters())) {
is WazeClient.FetchResult.Failed -> {
SourceHealth.record(
DetectionSource.WAZE,
ok = false,
message = "Waze feed unreachable: ${result.reason}"
)
}
is WazeClient.FetchResult.Success -> {
SourceHealth.record(DetectionSource.WAZE, ok = true)
lastAlerts = result.alerts
// Deliberately no clearSource() here: re-submitting refreshes
// still-present alerts by key (dedup) and lets vanished ones age
// out via the store's 5-min TTL. Clearing every poll would briefly
// drop the tier and re-raise it, double-firing the escalation
// vibration each cycle. (Mirrors CitizenScanner.)
emitProximityEvents(fix, result.alerts)
}
}
}
/**
* Re-evaluate the last fetched alert set against the current proximity + age
* thresholds and latest fix, without a network refetch. Used when the user
* moves the Waze proximity slider.
*/
fun refresh() {
val fix = locationProvider.location.value ?: return
store.clearSource(DetectionSource.WAZE)
emitProximityEvents(fix, lastAlerts)
}
private fun emitProximityEvents(fix: Location, alerts: List<WazeClient.Alert>) {
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 scored = ConfidenceEngine.scoreWaze(
ConfidenceEngine.WazeObservation(
uuid = a.uuid,
distanceMeters = dist,
ageMs = age,
confidence = a.confidence,
reliability = a.reliability,
subtype = a.subtype
)
)
store.submit(
DetectionEvent(
source = DetectionSource.WAZE,
key = "waze:${a.uuid}",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null,
lat = a.lat,
lon = a.lon
)
)
}
}
}
@@ -39,6 +39,8 @@ import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
import org.soulstone.overwatch.scan.WazeClient
import org.soulstone.overwatch.scan.WazeScanner
import org.soulstone.overwatch.scan.WifiScanner
/**
@@ -105,6 +107,7 @@ class DetectionService : LifecycleService() {
private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner
private lateinit var citizenScanner: CitizenScanner
private lateinit var wazeScanner: WazeScanner
private lateinit var overlayManager: OverlayManager
private var pruneJob: Job? = null
private var observerJob: Job? = null
@@ -112,11 +115,13 @@ class DetectionService : LifecycleService() {
private var locationJob: Job? = null
private var deflockProxJob: Job? = null
private var citizenProxJob: Job? = null
private var wazeProxJob: Job? = null
private var overlayJob: Job? = null
private var bleStarted = false
private var wifiStarted = false
private var deflockStarted = false
private var citizenStarted = false
private var wazeStarted = false
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
@@ -134,6 +139,11 @@ class DetectionService : LifecycleService() {
store, locationProvider,
proximityMeters = { settings.citizenProximityM.value.toFloat() }
)
wazeScanner = WazeScanner(
store, locationProvider,
client = WazeClient(appToken = { settings.wazeProxyToken.value }),
proximityMeters = { settings.wazeProximityM.value.toFloat() }
)
overlayManager = OverlayManager(
context = this,
// User dragged the bubble onto the X — flip the persisted toggle
@@ -172,7 +182,8 @@ class DetectionService : LifecycleService() {
wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value ||
settings.wazeEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
if (!locOk) {
@@ -184,10 +195,13 @@ class DetectionService : LifecycleService() {
if (settings.citizenEnabled.value) {
citizenScanner.start(lifecycleScope); citizenStarted = true
}
if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true
}
}
}
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted || wazeStarted
if (!anyStarted) {
Log.w(TAG, "No scanner started — endScanning + stopSelf")
endScanning()
@@ -257,6 +271,12 @@ class DetectionService : LifecycleService() {
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
}
}
wazeProxJob?.cancel()
if (wazeStarted) {
wazeProxJob = lifecycleScope.launch {
settings.wazeProximityM.drop(1).collect { wazeScanner.refresh() }
}
}
// Floating threat-circle overlay — observe the toggle and show/hide
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
@@ -278,6 +298,7 @@ class DetectionService : LifecycleService() {
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
locationProvider.stop()
store.clear()
SourceHealth.reset()
@@ -287,6 +308,7 @@ class DetectionService : LifecycleService() {
locationJob?.cancel(); locationJob = null
deflockProxJob?.cancel(); deflockProxJob = null
citizenProxJob?.cancel(); citizenProxJob = null
wazeProxJob?.cancel(); wazeProxJob = null
overlayJob?.cancel(); overlayJob = null
overlayManager.hide()
_mapPoints.value = emptyList()
@@ -17,6 +17,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -26,14 +28,19 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
@@ -53,9 +60,12 @@ fun SettingsScreen(
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val mic by settings.micEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val wazeToken by settings.wazeProxyToken.collectAsState()
val theme by settings.themeMode.collectAsState()
val vibrate by settings.vibrateOnAlert.collectAsState()
val overlay by settings.overlayEnabled.collectAsState()
@@ -89,6 +99,7 @@ fun SettingsScreen(
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
SourceToggle("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp))
if (isRunning) {
@@ -131,6 +142,24 @@ fun SettingsScreen(
steps = 48,
onCommit = { settings.setCitizenProximityM(it) }
)
SliderRow(
label = "Waze alert distance",
persistedValue = wazeProx,
range = 100f..5000f,
steps = 48,
onCommit = { settings.setWazeProximityM(it) }
)
Spacer(Modifier.height(16.dp))
SectionLabel("Waze police feed")
Text(
"Needs a proxy token (api.blackflagintel.com). Stored encrypted on-device — never in the app package.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 4.dp)
)
TokenField(currentToken = wazeToken, onSave = { settings.setWazeProxyToken(it) })
Spacer(Modifier.height(16.dp))
SectionLabel("Alerts")
@@ -256,6 +285,63 @@ private fun SliderRow(
}
}
/**
* Masked entry for the Waze proxy token. Commits on Save (persisted encrypted
* via Settings/SecureStore), with a show/hide toggle and a set/unset status line.
*/
@Composable
private fun TokenField(currentToken: String, onSave: (String) -> Unit) {
var text by remember(currentToken) { mutableStateOf(currentToken) }
var visible by remember { mutableStateOf(false) }
val isSet = currentToken.isNotBlank()
Column(modifier = Modifier.padding(vertical = 4.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
label = { Text("Proxy token", fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
visualTransformation =
if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(
if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (visible) "Hide token" else "Show token"
)
}
},
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace, fontSize = 13.sp
),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isSet) "Token set — Waze feed enabled" else "No token — Waze feed off",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f, fill = true)
)
Button(
onClick = { onSave(text) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp)
) {
Text("Save", fontSize = 13.sp, fontFamily = FontFamily.Monospace)
}
}
}
}
@Composable
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
Row(