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:
@@ -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
|
> advertise/fuzz code from one of the reference projects is intentionally
|
||||||
> excluded.
|
> 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) |
|
| **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. |
|
| **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. |
|
| **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
|
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:
|
||||||
@@ -102,6 +103,7 @@ Requires:
|
|||||||
# 1) Copy the example local.properties and point sdk.dir at your install
|
# 1) Copy the example local.properties and point sdk.dir at your install
|
||||||
cp local.properties.example local.properties
|
cp local.properties.example local.properties
|
||||||
# edit local.properties → sdk.dir=/Users/<you>/Library/Android/sdk
|
# edit local.properties → sdk.dir=/Users/<you>/Library/Android/sdk
|
||||||
|
# (Waze needs no build config — paste the proxy token into Settings in-app)
|
||||||
|
|
||||||
# 2) Make sure JAVA_HOME is JDK 21
|
# 2) Make sure JAVA_HOME is JDK 21
|
||||||
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
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.
|
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
|
Changes take effect on the next Start. While scanning, a **Restart scan to
|
||||||
apply** button appears that does `stop()` + `start()` in one tap.
|
apply** button appears that does `stop()` + `start()` in one tap.
|
||||||
- **Proximity thresholds** (sliders commit on release, not per-pixel):
|
- **Proximity thresholds** (sliders commit on release, not per-pixel):
|
||||||
- DeFlock: 50 m – 1600 m (default 200 m)
|
- DeFlock: 50 m – 1600 m (default 200 m)
|
||||||
- Citizen: 100 m – 5000 m (default 500 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**:
|
- **Alerts**:
|
||||||
- Vibrate on threat escalation (default on)
|
- Vibrate on threat escalation (default on)
|
||||||
- **Appearance**: System / Dark / Light (default Dark)
|
- **Appearance**: System / Dark / Light (default Dark)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 14
|
versionCode = 16
|
||||||
versionName = "0.3.2"
|
versionName = "0.5.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
// source is briefly toggled.
|
// source is briefly toggled.
|
||||||
val deflockProx by settings.deflockProximityM.collectAsState()
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
val citizenProx by settings.citizenProximityM.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 granted by permissionsGranted
|
||||||
val denied by permanentlyDenied
|
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
|
* Per-source toggles only take effect at the next Start cycle — flipping a
|
||||||
* source while scanning will NOT live-restart that scanner.
|
* 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 }
|
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))
|
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
|
||||||
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
|
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))
|
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
|
||||||
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
|
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
|
||||||
|
|
||||||
@@ -45,6 +51,17 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
)
|
)
|
||||||
val citizenProximityM: StateFlow<Int> = _citizenProximityM.asStateFlow()
|
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(
|
private val _themeMode = MutableStateFlow(
|
||||||
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
|
||||||
)
|
)
|
||||||
@@ -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 setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
|
||||||
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
|
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
|
||||||
fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
|
fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
|
||||||
|
fun 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 setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
|
||||||
|
|
||||||
fun setDeflockProximityM(v: Int) {
|
fun setDeflockProximityM(v: Int) {
|
||||||
@@ -74,6 +92,18 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
_citizenProximityM.value = clamped
|
_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) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
prefs.edit { putString(KEY_THEME, mode.name) }
|
prefs.edit { putString(KEY_THEME, mode.name) }
|
||||||
_themeMode.value = mode
|
_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_WIFI = "src_wifi"
|
||||||
private const val KEY_DEFLOCK = "src_deflock"
|
private const val KEY_DEFLOCK = "src_deflock"
|
||||||
private const val KEY_CITIZEN = "src_citizen"
|
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_MIC = "src_mic"
|
||||||
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
|
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
|
||||||
private const val KEY_CITIZEN_PROX = "citizen_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_THEME = "theme_mode"
|
||||||
private const val KEY_VIBRATE = "vibrate_on_alert"
|
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||||
private const val KEY_OVERLAY = "overlay_enabled"
|
private const val KEY_OVERLAY = "overlay_enabled"
|
||||||
|
|
||||||
const val DEFAULT_DEFLOCK_PROX = 200
|
const val DEFAULT_DEFLOCK_PROX = 200
|
||||||
const val DEFAULT_CITIZEN_PROX = 500
|
const val DEFAULT_CITIZEN_PROX = 500
|
||||||
|
const val DEFAULT_WAZE_PROX = 500
|
||||||
|
|
||||||
@Volatile private var INSTANCE: Settings? = null
|
@Volatile private var INSTANCE: Settings? = null
|
||||||
|
|
||||||
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
|
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Settings(
|
INSTANCE ?: Settings(
|
||||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE),
|
||||||
|
context.applicationContext
|
||||||
).also { INSTANCE = it }
|
).also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ object ConfidenceEngine {
|
|||||||
const val W_DEFLOCK_NEAR = 60 // <= 200m
|
const val W_DEFLOCK_NEAR = 60 // <= 200m
|
||||||
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
|
||||||
|
|
||||||
// Citizen (replaces Waze; Waze's reCAPTCHA gating made it unreachable)
|
// Citizen (real-time incident feed)
|
||||||
const val W_CITIZEN_INCIDENT = 55
|
const val W_CITIZEN_INCIDENT = 55
|
||||||
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
|
const val B_CITIZEN_LEVEL_BUMP = 5 // level >= 2
|
||||||
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
|
const val B_CITIZEN_POLICE_TITLE = 5 // title contains a police-action keyword
|
||||||
|
|
||||||
|
// Waze (live POLICE alerts via the OpenWeb Ninja hosted feed)
|
||||||
|
const val W_WAZE_POLICE = 55
|
||||||
|
|
||||||
// Bonuses
|
// Bonuses
|
||||||
const val B_MULTI_METHOD = 20
|
const val B_MULTI_METHOD = 20
|
||||||
const val B_STRONG_RSSI = 10 // > -50 dBm
|
const val B_STRONG_RSSI = 10 // > -50 dBm
|
||||||
@@ -90,6 +93,16 @@ object ConfidenceEngine {
|
|||||||
val precinct: String?
|
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(
|
data class Scored(
|
||||||
val score: Int,
|
val score: Int,
|
||||||
val methods: String,
|
val methods: String,
|
||||||
@@ -206,6 +219,22 @@ object ConfidenceEngine {
|
|||||||
return Scored(score, tags.toString().trim(), label, isAxon = false)
|
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 {
|
fun scoreDeflock(obs: DeflockObservation): Scored {
|
||||||
val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR
|
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"
|
val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m"
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ object SourceHealth {
|
|||||||
private val _wifi = MutableStateFlow(Health())
|
private val _wifi = MutableStateFlow(Health())
|
||||||
private val _deflock = MutableStateFlow(Health())
|
private val _deflock = MutableStateFlow(Health())
|
||||||
private val _citizen = MutableStateFlow(Health())
|
private val _citizen = MutableStateFlow(Health())
|
||||||
|
private val _waze = MutableStateFlow(Health())
|
||||||
private val _mic = MutableStateFlow(Health())
|
private val _mic = MutableStateFlow(Health())
|
||||||
|
|
||||||
val ble: StateFlow<Health> = _ble.asStateFlow()
|
val ble: StateFlow<Health> = _ble.asStateFlow()
|
||||||
val wifi: StateFlow<Health> = _wifi.asStateFlow()
|
val wifi: StateFlow<Health> = _wifi.asStateFlow()
|
||||||
val deflock: StateFlow<Health> = _deflock.asStateFlow()
|
val deflock: StateFlow<Health> = _deflock.asStateFlow()
|
||||||
val citizen: StateFlow<Health> = _citizen.asStateFlow()
|
val citizen: StateFlow<Health> = _citizen.asStateFlow()
|
||||||
|
val waze: StateFlow<Health> = _waze.asStateFlow()
|
||||||
val mic: StateFlow<Health> = _mic.asStateFlow()
|
val mic: StateFlow<Health> = _mic.asStateFlow()
|
||||||
|
|
||||||
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
|
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
|
||||||
@@ -40,6 +42,7 @@ object SourceHealth {
|
|||||||
DetectionSource.WIFI -> wifi
|
DetectionSource.WIFI -> wifi
|
||||||
DetectionSource.DEFLOCK -> deflock
|
DetectionSource.DEFLOCK -> deflock
|
||||||
DetectionSource.CITIZEN -> citizen
|
DetectionSource.CITIZEN -> citizen
|
||||||
|
DetectionSource.WAZE -> waze
|
||||||
DetectionSource.MIC -> mic
|
DetectionSource.MIC -> mic
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ object SourceHealth {
|
|||||||
DetectionSource.WIFI -> _wifi
|
DetectionSource.WIFI -> _wifi
|
||||||
DetectionSource.DEFLOCK -> _deflock
|
DetectionSource.DEFLOCK -> _deflock
|
||||||
DetectionSource.CITIZEN -> _citizen
|
DetectionSource.CITIZEN -> _citizen
|
||||||
|
DetectionSource.WAZE -> _waze
|
||||||
DetectionSource.MIC -> _mic
|
DetectionSource.MIC -> _mic
|
||||||
}
|
}
|
||||||
target.value = Health(
|
target.value = Health(
|
||||||
@@ -63,6 +67,7 @@ object SourceHealth {
|
|||||||
_wifi.value = Health()
|
_wifi.value = Health()
|
||||||
_deflock.value = Health()
|
_deflock.value = Health()
|
||||||
_citizen.value = Health()
|
_citizen.value = Health()
|
||||||
|
_waze.value = Health()
|
||||||
_mic.value = Health()
|
_mic.value = Health()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Logical signal channel — used in the drill-down UI. */
|
/** Logical signal channel — used in the drill-down UI. */
|
||||||
enum class DetectionSource { BLE, WIFI, DEFLOCK, 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.DeflockClient
|
||||||
import org.soulstone.overwatch.scan.DeflockScanner
|
import org.soulstone.overwatch.scan.DeflockScanner
|
||||||
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
|
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
|
import org.soulstone.overwatch.scan.WifiScanner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +107,7 @@ class DetectionService : LifecycleService() {
|
|||||||
private lateinit var locationProvider: LocationProvider
|
private lateinit var locationProvider: LocationProvider
|
||||||
private lateinit var deflockScanner: DeflockScanner
|
private lateinit var deflockScanner: DeflockScanner
|
||||||
private lateinit var citizenScanner: CitizenScanner
|
private lateinit var citizenScanner: CitizenScanner
|
||||||
|
private lateinit var wazeScanner: WazeScanner
|
||||||
private lateinit var overlayManager: OverlayManager
|
private lateinit var overlayManager: OverlayManager
|
||||||
private var pruneJob: Job? = null
|
private var pruneJob: Job? = null
|
||||||
private var observerJob: Job? = null
|
private var observerJob: Job? = null
|
||||||
@@ -112,11 +115,13 @@ class DetectionService : LifecycleService() {
|
|||||||
private var locationJob: Job? = null
|
private var locationJob: Job? = null
|
||||||
private var deflockProxJob: Job? = null
|
private var deflockProxJob: Job? = null
|
||||||
private var citizenProxJob: Job? = null
|
private var citizenProxJob: Job? = null
|
||||||
|
private var wazeProxJob: Job? = null
|
||||||
private var overlayJob: Job? = null
|
private var overlayJob: Job? = null
|
||||||
private var bleStarted = false
|
private var bleStarted = false
|
||||||
private var wifiStarted = false
|
private var wifiStarted = false
|
||||||
private var deflockStarted = false
|
private var deflockStarted = false
|
||||||
private var citizenStarted = false
|
private var citizenStarted = false
|
||||||
|
private var wazeStarted = false
|
||||||
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
|
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
|
||||||
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
|
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
|
||||||
|
|
||||||
@@ -134,6 +139,11 @@ class DetectionService : LifecycleService() {
|
|||||||
store, locationProvider,
|
store, locationProvider,
|
||||||
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
||||||
)
|
)
|
||||||
|
wazeScanner = WazeScanner(
|
||||||
|
store, locationProvider,
|
||||||
|
client = WazeClient(appToken = { settings.wazeProxyToken.value }),
|
||||||
|
proximityMeters = { settings.wazeProximityM.value.toFloat() }
|
||||||
|
)
|
||||||
overlayManager = OverlayManager(
|
overlayManager = OverlayManager(
|
||||||
context = this,
|
context = this,
|
||||||
// User dragged the bubble onto the X — flip the persisted toggle
|
// User dragged the bubble onto the X — flip the persisted toggle
|
||||||
@@ -172,7 +182,8 @@ class DetectionService : LifecycleService() {
|
|||||||
wifiStarted = wifiScanner.start(lifecycleScope)
|
wifiStarted = wifiScanner.start(lifecycleScope)
|
||||||
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
||||||
}
|
}
|
||||||
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
|
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value ||
|
||||||
|
settings.wazeEnabled.value
|
||||||
if (needsLocation) {
|
if (needsLocation) {
|
||||||
val locOk = locationProvider.start()
|
val locOk = locationProvider.start()
|
||||||
if (!locOk) {
|
if (!locOk) {
|
||||||
@@ -184,10 +195,13 @@ class DetectionService : LifecycleService() {
|
|||||||
if (settings.citizenEnabled.value) {
|
if (settings.citizenEnabled.value) {
|
||||||
citizenScanner.start(lifecycleScope); citizenStarted = true
|
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) {
|
if (!anyStarted) {
|
||||||
Log.w(TAG, "No scanner started — endScanning + stopSelf")
|
Log.w(TAG, "No scanner started — endScanning + stopSelf")
|
||||||
endScanning()
|
endScanning()
|
||||||
@@ -257,6 +271,12 @@ class DetectionService : LifecycleService() {
|
|||||||
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
|
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
|
// Floating threat-circle overlay — observe the toggle and show/hide
|
||||||
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
|
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
|
||||||
@@ -278,6 +298,7 @@ class DetectionService : LifecycleService() {
|
|||||||
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
||||||
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
||||||
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
|
if (citizenStarted) { citizenScanner.stop(); citizenStarted = false }
|
||||||
|
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
|
||||||
locationProvider.stop()
|
locationProvider.stop()
|
||||||
store.clear()
|
store.clear()
|
||||||
SourceHealth.reset()
|
SourceHealth.reset()
|
||||||
@@ -287,6 +308,7 @@ class DetectionService : LifecycleService() {
|
|||||||
locationJob?.cancel(); locationJob = null
|
locationJob?.cancel(); locationJob = null
|
||||||
deflockProxJob?.cancel(); deflockProxJob = null
|
deflockProxJob?.cancel(); deflockProxJob = null
|
||||||
citizenProxJob?.cancel(); citizenProxJob = null
|
citizenProxJob?.cancel(); citizenProxJob = null
|
||||||
|
wazeProxJob?.cancel(); wazeProxJob = null
|
||||||
overlayJob?.cancel(); overlayJob = null
|
overlayJob?.cancel(); overlayJob = null
|
||||||
overlayManager.hide()
|
overlayManager.hide()
|
||||||
_mapPoints.value = emptyList()
|
_mapPoints.value = emptyList()
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -26,14 +28,19 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -53,9 +60,12 @@ fun SettingsScreen(
|
|||||||
val wifi by settings.wifiEnabled.collectAsState()
|
val wifi by settings.wifiEnabled.collectAsState()
|
||||||
val deflock by settings.deflockEnabled.collectAsState()
|
val deflock by settings.deflockEnabled.collectAsState()
|
||||||
val citizen by settings.citizenEnabled.collectAsState()
|
val citizen by settings.citizenEnabled.collectAsState()
|
||||||
|
val waze by settings.wazeEnabled.collectAsState()
|
||||||
val mic by settings.micEnabled.collectAsState()
|
val mic by settings.micEnabled.collectAsState()
|
||||||
val deflockProx by settings.deflockProximityM.collectAsState()
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
val citizenProx by settings.citizenProximityM.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 theme by settings.themeMode.collectAsState()
|
||||||
val vibrate by settings.vibrateOnAlert.collectAsState()
|
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||||
val overlay by settings.overlayEnabled.collectAsState()
|
val overlay by settings.overlayEnabled.collectAsState()
|
||||||
@@ -89,6 +99,7 @@ fun SettingsScreen(
|
|||||||
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
|
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
|
||||||
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
|
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
|
||||||
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(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) }
|
SourceToggle("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
@@ -131,6 +142,24 @@ fun SettingsScreen(
|
|||||||
steps = 48,
|
steps = 48,
|
||||||
onCommit = { settings.setCitizenProximityM(it) }
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionLabel("Alerts")
|
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
|
@Composable
|
||||||
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -14,3 +14,8 @@ sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk
|
|||||||
# in your shell profile
|
# in your shell profile
|
||||||
# (b) put the line below into ~/.gradle/gradle.properties (NOT project gradle.properties):
|
# (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
|
# 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user