diff --git a/README.md b/README.md index 16f3677..497c9e4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ on upward escalations — you don't have to be looking at the screen. > advertise/fuzz code from one of the reference projects is intentionally > excluded. -Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload). +Latest release: [v0.5.0](https://github.com/KaraZajac/OVERWATCH/releases) (debug-signed APK, sideload). --- @@ -25,8 +25,9 @@ Latest release: [v0.1.7](https://github.com/KaraZajac/OVERWATCH/releases) (debug | **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. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. | | **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. First poll fires immediately on the first location fix. | +| **WAZE** | User-reported `POLICE` alerts still active in the feed within configurable proximity (default 500 m), up to ~45 min old | `api.blackflagintel.com/waze/alerts-and-jams` — the OVERWATCH proxy (Caddy) that injects the OpenWeb Ninja key server-side and forwards to their hosted Waze scrape, sidestepping the reCAPTCHA gating that 403s direct `live-map/api/georss` calls. The app authenticates with an `X-App-Token` entered in Settings (encrypted on-device); the paid key never ships in the APK. Polled every ~4 min. Upstream ignores type filtering and caps at 200 alerts, so the client pulls the full page and filters to `POLICE` itself. Alerts carry confidence (0–5) + reliability (0–10); high values nudge the score up. No token → source shows "not configured" in the drill-down. | -> **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 as the police-presence source. +> **Waze is back (v0.4.0+), via a key-protected proxy.** Waze reCAPTCHA-gated its `live-map/api/georss` endpoint in 2025/2026 — automated calls get HTTP 403 regardless of IP or headless-vs-headful browser (it scores browser *reputation*, verified by direct testing), which is why v0.1.5 removed the original integration and why no free scraper survives. OVERWATCH reads Waze POLICE alerts through [OpenWeb Ninja](https://www.openwebninja.com)'s hosted feed (pay-as-you-go ~$0.005/req, ≈ $1–3/mo at the 4-min poll). To keep the paid key off every device, the app doesn't hold it: a Caddy reverse proxy at `api.blackflagintel.com` injects the key server-side, and the app authenticates with a scoped, revocable `X-App-Token` entered in Settings (stored encrypted via the Android Keystore). The Waze for Cities partner feed was ruled out — it excludes POLICE and is agency-only. Waze complements Citizen: denser for roadway stops / speed traps. Every observation is scored 0–100 by `ConfidenceEngine`. The on-screen tier is the maximum live score across all sources: @@ -102,6 +103,7 @@ Requires: # 1) Copy the example local.properties and point sdk.dir at your install cp local.properties.example local.properties # edit local.properties → sdk.dir=/Users//Library/Android/sdk +# (Waze needs no build config — paste the proxy token into Settings in-app) # 2) Make sure JAVA_HOME is JDK 21 export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home @@ -140,12 +142,16 @@ so you can grant manually. Tap the gear icon in the top-right. -- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen independently. +- **Detection sources**: toggle BLE / WiFi / DeFlock / Citizen / Waze independently. Changes take effect on the next Start. While scanning, a **Restart scan to apply** button appears that does `stop()` + `start()` in one tap. - **Proximity thresholds** (sliders commit on release, not per-pixel): - DeFlock: 50 m – 1600 m (default 200 m) - Citizen: 100 m – 5000 m (default 500 m) + - Waze: 100 m – 5000 m (default 500 m) +- **Waze police feed**: paste the `api.blackflagintel.com` proxy token. It's + stored encrypted on-device (Android Keystore), never baked into the APK; the + paid OpenWeb Ninja key stays server-side on the proxy. Empty = Waze source off. - **Alerts**: - Vibrate on threat escalation (default on) - **Appearance**: System / Dark / Light (default Dark) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index badc49e..0c79afb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt index d4e604d..d3024ba 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt @@ -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 diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/SecureStore.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/SecureStore.kt new file mode 100644 index 0000000..3ff341a --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/SecureStore.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt index d5edae7..a1066ab 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt @@ -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 = _citizenEnabled.asStateFlow() + private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true)) + val wazeEnabled: StateFlow = _wazeEnabled.asStateFlow() + private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true)) val micEnabled: StateFlow = _micEnabled.asStateFlow() @@ -45,6 +51,17 @@ class Settings private constructor(private val prefs: SharedPreferences) { ) val citizenProximityM: StateFlow = _citizenProximityM.asStateFlow() + private val _wazeProximityM = MutableStateFlow( + prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX) + ) + val wazeProximityM: StateFlow = _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 = _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 } } } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt index 326b4d2..af9fdbd 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt @@ -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" diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt index ff27551..476af2e 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt @@ -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 = _ble.asStateFlow() val wifi: StateFlow = _wifi.asStateFlow() val deflock: StateFlow = _deflock.asStateFlow() val citizen: StateFlow = _citizen.asStateFlow() + val waze: StateFlow = _waze.asStateFlow() val mic: StateFlow = _mic.asStateFlow() fun flowFor(source: DetectionSource): StateFlow = 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() } } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt index 75066dc..b30a560 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt @@ -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 } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt new file mode 100644 index 0000000..b0d7389 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt @@ -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=,&top_right=,&max_alerts=200 + * Header: X-App-Token: + * + * 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) : 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 { + if (body.isBlank()) return emptyList() + return try { + val alerts = extractAlerts(JSONObject(body)) ?: return emptyList() + val out = ArrayList(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 + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt new file mode 100644 index 0000000..6717b14 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt @@ -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 = 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) { + 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 + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt index abc46d0..4b659aa 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -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() diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt index 8ffde6f..a9ac1d5 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -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( diff --git a/local.properties.example b/local.properties.example index 12faf36..92c2563 100644 --- a/local.properties.example +++ b/local.properties.example @@ -14,3 +14,8 @@ sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk # in your shell profile # (b) put the line below into ~/.gradle/gradle.properties (NOT project gradle.properties): # org.gradle.java.home=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + +# The Waze police source no longer uses an app-side key. The OpenWeb Ninja key +# lives only on the api.blackflagintel.com proxy (server-side); the app calls +# that proxy with a token pasted into Settings (stored encrypted on-device). +# Nothing to configure here for Waze.