v0.1.6 — audit fixes (critical, moderate, minor) + UX polish

Critical
--------
- DetectionService: subscribe to threatLevel + top event flows; rebuild the
  foreground notification on every change so a locked-screen user sees
  escalations. Vibrate on upward tier transitions (escalating waveforms for
  YELLOW/ORANGE/RED), gated by Settings.vibrateOnAlert (default on).
- DetectionService: only mark _running=true if at least one scanner started;
  stopSelf() if everything was disabled or denied. Switch START_STICKY →
  START_NOT_STICKY so a system-killed service doesn't re-create into a
  stuck "running but not scanning" state.
- DeflockClient: detect Overpass timeout-in-body (`{"remark": "...timed
  out..."}`) and treat as failure — previously these 200-with-empty-elements
  responses got cached for 24 h, hiding ALPRs in that 5×5 km cell for the
  next day.
- DeflockScanner: record lastFetch coords + timestamp on BOTH success and
  failure, with a 60 s backoff window after a failed attempt. Previously
  `lastFetchLat` was only set on Success, so every subsequent location
  update would re-trigger a 30 s POST that collectLatest then cancelled —
  we'd never finish a fetch under sustained Overpass slowness.
- LocationProvider: stale-lastLocation race fix. The async `lastLocation`
  callback now only seeds `_location` if it's still null and we're still
  running — previously it could overwrite a fresher fix from
  requestLocationUpdates, or fire after stop() and resurrect _location with
  stale data.

Moderate
--------
- CitizenScanner: wait for the first non-null location with .first { } before
  starting the poll/delay loop. First Citizen poll now fires within seconds
  of the location fix, not up to 60 s after.
- MainScreen: when not running, show a muted gray circle with "IDLE" text
  instead of the same solid green look as "scanning, all clear" — the
  pulse animation was the only differentiator before.
- Compose state: rememberSaveable for the screen enum + bottom-sheet open
  state, so SETTINGS survives rotation.
- MainActivity: detect permanently-denied permissions (the user picked
  "don't ask again") via shouldShowRequestPermissionRationale. UI swaps the
  call-to-action to "Open app settings" which fires
  Settings.ACTION_APPLICATION_DETAILS_SETTINGS. onResume re-checks so a
  user returning from app settings is reflected immediately.

Improvements
------------
- BLE/WiFi scanners record SourceHealth.OK on a successful start (and
  FAILED with a specific reason on every short-circuit — disabled adapter,
  missing permission, etc.) so the drill-down sheet is honest about radio
  state, not just network state.
- DetectionEvent gains optional lat/lon (populated by DEFLOCK and CITIZEN);
  SourceRow shows a tap-to-open-Maps icon next to events with coordinates,
  firing a `geo:lat,lon?q=lat,lon(label)` Intent.
- SettingsScreen sliders use onValueChangeFinished — only commit to
  SharedPreferences on drag-release, not on every pixel of movement.
- New Settings.vibrateOnAlert toggle (default on) wired to a SettingsScreen
  row under a new "Alerts" section.

Minor
-----
- BleScanner iterates ALL manufacturer-data entries to find XUNTONG; only
  falls back to the first entry if no XUNTONG match is present. Previously
  we only inspected the first entry.
- Drop dead `?.` on JSONArray.optString in CitizenClient (returns String,
  never null).
- Remove unused rememberCoroutineScope in MainScreen.
- Update stale Phase/Waze references in DetectionService comments.
- Add VIBRATE permission to manifest.

versionCode 6 → 7, versionName 0.1.5 → 0.1.6.
This commit is contained in:
2026-04-28 22:11:56 -04:00
parent 74f26439fc
commit d8670f4c32
15 changed files with 385 additions and 72 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 6 versionCode = 7
versionName = "0.1.5" versionName = "0.1.6"
} }
buildTypes { buildTypes {
+3
View File
@@ -35,6 +35,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Vibration on threat-tier escalation -->
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application <application
@@ -1,17 +1,22 @@
package org.soulstone.overwatch package org.soulstone.overwatch
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.service.DetectionService import org.soulstone.overwatch.service.DetectionService
@@ -44,6 +49,7 @@ class MainActivity : ComponentActivity() {
) { result -> ) { result ->
val allGranted = result.all { it.value } val allGranted = result.all { it.value }
permissionsGranted.value = allGranted permissionsGranted.value = allGranted
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
if (allGranted) { if (allGranted) {
// First-run path: user just granted everything, kick off scanning // First-run path: user just granted everything, kick off scanning
// immediately so they don't have to tap START a second time. // immediately so they don't have to tap START a second time.
@@ -51,17 +57,22 @@ class MainActivity : ComponentActivity() {
} }
} }
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false) private val permissionsGranted = mutableStateOf(false)
/** True when at least one required permission is denied AND the system says
* we can no longer prompt for it (user picked "don't ask again"). The UI
* swaps the START button's call-to-action for an "Open app settings" link. */
private val permanentlyDenied = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
permissionsGranted.value = checkAllPermissions() permissionsGranted.value = checkAllPermissions()
permanentlyDenied.value = false // reset on activity create
val settings = Settings.get(this) val settings = Settings.get(this)
setContent { setContent {
val themeMode by settings.themeMode.collectAsState() val themeMode by settings.themeMode.collectAsState()
OverwatchTheme(mode = themeMode) { OverwatchTheme(mode = themeMode) {
var screen by remember { mutableStateOf(Screen.MAIN) } var screen by rememberSaveable { mutableStateOf(Screen.MAIN) }
when (screen) { when (screen) {
Screen.MAIN -> { Screen.MAIN -> {
@@ -70,6 +81,13 @@ class MainActivity : ComponentActivity() {
val threat by DetectionService.store.threatLevel.collectAsState() val threat by DetectionService.store.threatLevel.collectAsState()
val maxScore by DetectionService.store.maxScore.collectAsState() val maxScore by DetectionService.store.maxScore.collectAsState()
val granted by permissionsGranted val granted by permissionsGranted
val denied by permanentlyDenied
val message = when {
granted -> null
denied -> "Permissions permanently denied — open app settings to grant"
else -> "Tap START to grant Bluetooth, WiFi + location permissions"
}
MainScreen( MainScreen(
running = running, running = running,
@@ -77,13 +95,17 @@ class MainActivity : ComponentActivity() {
score = maxScore, score = maxScore,
events = events, events = events,
canStart = true, canStart = true,
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null, permissionMessage = message,
showOpenAppSettings = denied && !granted,
onOpenAppSettings = { openAppSettings() },
onStartStop = { onStartStop = {
if (running) { if (running) {
DetectionService.stop(this) DetectionService.stop(this)
} else { } else {
if (granted) { if (granted) {
DetectionService.start(this) DetectionService.start(this)
} else if (denied) {
openAppSettings()
} else { } else {
permissionLauncher.launch(requiredPermissions) permissionLauncher.launch(requiredPermissions)
} }
@@ -111,7 +133,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
permissionsGranted.value = checkAllPermissions() // User may have granted permissions in app settings while we were paused.
val nowGranted = checkAllPermissions()
permissionsGranted.value = nowGranted
if (nowGranted) permanentlyDenied.value = false
} }
private fun checkAllPermissions(): Boolean = private fun checkAllPermissions(): Boolean =
@@ -119,5 +144,23 @@ class MainActivity : ComponentActivity() {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
} }
/** True if at least one missing permission is still askable via the system
* prompt. False means everything missing was denied with "don't ask again". */
private fun anyMissingCanStillAsk(): Boolean {
val missing = requiredPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) return true
return missing.any { ActivityCompat.shouldShowRequestPermissionRationale(this, it) }
}
private fun openAppSettings() {
val intent = Intent(
AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private enum class Screen { MAIN, SETTINGS } private enum class Screen { MAIN, SETTINGS }
} }
@@ -49,12 +49,13 @@ class LocationProvider(private val context: Context) {
private val callback = object : LocationCallback() { private val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) { override fun onLocationResult(result: LocationResult) {
if (!running) return
val fix = result.lastLocation ?: return val fix = result.lastLocation ?: return
_location.value = fix _location.value = fix
} }
} }
private var running = false @Volatile private var running = false
fun hasPermission(): Boolean = fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
@@ -68,12 +69,22 @@ class LocationProvider(private val context: Context) {
return false return false
} }
try { try {
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
running = true running = true
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
// Seed with the cached lastLocation only if (a) we haven't already
// received a fresh fix from requestLocationUpdates and (b) we're
// still running by the time the listener fires. Otherwise the
// listener can race and either overwrite a fresh fix with a stale
// one or fire after stop().
client.lastLocation.addOnSuccessListener { last ->
if (running && last != null && _location.value == null) {
_location.value = last
}
}
Log.i(TAG, "Location updates started") Log.i(TAG, "Location updates started")
return true return true
} catch (e: SecurityException) { } catch (e: SecurityException) {
running = false
Log.e(TAG, "SecurityException starting location updates", e) Log.e(TAG, "SecurityException starting location updates", e)
return false return false
} }
@@ -47,6 +47,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
) )
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow() val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v } fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v } fun 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 }
@@ -69,6 +72,11 @@ class Settings private constructor(private val prefs: SharedPreferences) {
_themeMode.value = mode _themeMode.value = mode
} }
fun setVibrateOnAlert(v: Boolean) {
prefs.edit { putBoolean(KEY_VIBRATE, v) }
_vibrateOnAlert.value = v
}
companion object { companion object {
private const val PREFS = "overwatch_settings" private const val PREFS = "overwatch_settings"
private const val KEY_BLE = "src_ble" private const val KEY_BLE = "src_ble"
@@ -78,6 +86,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
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_THEME = "theme_mode" private const val KEY_THEME = "theme_mode"
private const val KEY_VIBRATE = "vibrate_on_alert"
const val DEFAULT_DEFLOCK_PROX = 200 const val DEFAULT_DEFLOCK_PROX = 200
const val DEFAULT_CITIZEN_PROX = 500 const val DEFAULT_CITIZEN_PROX = 500
@@ -4,11 +4,12 @@ package org.soulstone.overwatch.fusion
* One observation from one source at one moment. * One observation from one source at one moment.
* *
* @param source which scanner produced this * @param source which scanner produced this
* @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Waze) * @param key stable per-device identifier (MAC for BLE/WiFi, OSM id for DeFlock, uuid for Citizen)
* @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B") * @param label short human-readable description shown in the drill-down ("Axon body cam", "FS-1A2B")
* @param score 0-100 confidence assigned by the engine * @param score 0-100 confidence assigned by the engine
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial") * @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
* @param rssi signal strength if applicable (BLE/WiFi); null for map/Waze sources * @param rssi signal strength if applicable (BLE/WiFi); null for map/Citizen sources
* @param lat / lon real-world coordinates for events that have them (DEFLOCK, CITIZEN); null for radio-only sources
* @param timestampMs wall-clock millis when this event was produced * @param timestampMs wall-clock millis when this event was produced
*/ */
data class DetectionEvent( data class DetectionEvent(
@@ -18,7 +19,10 @@ data class DetectionEvent(
val score: Int, val score: Int,
val matchedMethods: String, val matchedMethods: String,
val rssi: Int? = null, val rssi: Int? = null,
val lat: Double? = null,
val lon: Double? = null,
val timestampMs: Long = System.currentTimeMillis() val timestampMs: Long = System.currentTimeMillis()
) { ) {
val level: ThreatLevel get() = ThreatLevel.fromScore(score) val level: ThreatLevel get() = ThreatLevel.fromScore(score)
val hasGeo: Boolean get() = lat != null && lon != null
} }
@@ -21,6 +21,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker import org.soulstone.overwatch.fusion.RssiTracker
import org.soulstone.overwatch.fusion.SourceHealth
/** /**
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz). * BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
@@ -79,18 +80,30 @@ class BleScanner(
if (running) return true if (running) return true
if (!hasScanPermission()) { if (!hasScanPermission()) {
Log.w(TAG, "BLE scan permission missing") Log.w(TAG, "BLE scan permission missing")
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission missing")
return false
}
val adapter = bluetoothAdapter ?: run {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE not supported")
return false
}
if (!adapter.isEnabled) {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Bluetooth disabled")
return false
}
leScanner = adapter.bluetoothLeScanner ?: run {
SourceHealth.record(DetectionSource.BLE, ok = false, message = "BLE scanner unavailable")
return false return false
} }
val adapter = bluetoothAdapter ?: return false
if (!adapter.isEnabled) return false
leScanner = adapter.bluetoothLeScanner ?: return false
try { try {
leScanner?.startScan(null, scanSettings, scanCallback) leScanner?.startScan(null, scanSettings, scanCallback)
running = true running = true
SourceHealth.record(DetectionSource.BLE, ok = true)
Log.i(TAG, "BLE scan started") Log.i(TAG, "BLE scan started")
return true return true
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting scan", e) Log.e(TAG, "SecurityException starting scan", e)
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission revoked")
return false return false
} }
} }
@@ -120,6 +133,11 @@ class BleScanner(
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode") Log.e(TAG, "BLE scan failed: $errorCode")
running = false running = false
SourceHealth.record(
DetectionSource.BLE,
ok = false,
message = "BLE scan failed (code $errorCode)"
)
} }
} }
@@ -132,11 +150,23 @@ class BleScanner(
val advertisedUuids = record?.serviceUuids?.map { it.uuid } val advertisedUuids = record?.serviceUuids?.map { it.uuid }
val mfgSpecific = record?.manufacturerSpecificData val mfgSpecific = record?.manufacturerSpecificData
// Iterate ALL manufacturer-data entries; some devices advertise multiple
// and XUNTONG might not be the first one. Prefer the XUNTONG match if
// present, otherwise fall back to the first entry so we still surface
// *some* mfg signal in the observation.
var companyId: Int? = null var companyId: Int? = null
var payload: ByteArray? = null var payload: ByteArray? = null
if (mfgSpecific != null && mfgSpecific.size() > 0) { if (mfgSpecific != null && mfgSpecific.size() > 0) {
companyId = mfgSpecific.keyAt(0) for (i in 0 until mfgSpecific.size()) {
payload = mfgSpecific.valueAt(0) val cid = mfgSpecific.keyAt(i)
val data = mfgSpecific.valueAt(i)
if (cid == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID) {
companyId = cid
payload = data
break
}
if (companyId == null) { companyId = cid; payload = data }
}
} }
// Cheap pre-filter — drop devices that have zero target signals. // Cheap pre-filter — drop devices that have zero target signals.
@@ -64,7 +64,10 @@ class CitizenClient {
val arr = JSONObject(raw.body).optJSONArray("results") val arr = JSONObject(raw.body).optJSONArray("results")
?: return@withContext TrendingResult.Success(emptyList()) ?: return@withContext TrendingResult.Success(emptyList())
val out = ArrayList<String>(arr.length()) val out = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) arr.optString(i)?.takeIf { it.isNotBlank() }?.let(out::add) for (i in 0 until arr.length()) {
val id = arr.optString(i)
if (id.isNotBlank()) out.add(id)
}
TrendingResult.Success(out) TrendingResult.Success(out)
} catch (e: Exception) { } catch (e: Exception) {
TrendingResult.Failed("parse: ${e.message}") TrendingResult.Failed("parse: ${e.message}")
@@ -5,6 +5,7 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
@@ -57,6 +58,9 @@ class CitizenScanner(
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
if (job != null) return true if (job != null) return true
job = scope.launch { job = scope.launch {
// Wait for the first non-null location fix so the first poll fires
// immediately when location arrives, instead of after a 60 s delay.
locationProvider.location.first { it != null }
while (isActive) { while (isActive) {
val fix = locationProvider.location.value val fix = locationProvider.location.value
if (fix != null) pollOnce(fix) if (fix != null) pollOnce(fix)
@@ -137,7 +141,9 @@ class CitizenScanner(
label = scored.label, label = scored.label,
score = scored.score, score = scored.score,
matchedMethods = scored.methods, matchedMethods = scored.methods,
rssi = null rssi = null,
lat = incident.lat,
lon = incident.lon
) )
) )
} }
@@ -128,7 +128,15 @@ class DeflockClient(context: Context) {
val code = conn.responseCode val code = conn.responseCode
if (code in 200..299) { if (code in 200..299) {
val body = conn.inputStream.bufferedReader().use { it.readText() } val body = conn.inputStream.bufferedReader().use { it.readText() }
// Overpass returns HTTP 200 with `{"remark": "runtime error: Query timed out..."}`
// when the query exceeded server-side limits. Body has elements:[]; treat as
// failure so we don't poison the 24h cache with empty results.
if (looksLikeOverpassTimeout(body)) {
Log.w(TAG, "$endpoint returned 200 with timeout/runtime-limit remark")
null to "Overpass timeout"
} else {
body to null body to null
}
} else { } else {
Log.w(TAG, "$endpoint returned $code") Log.w(TAG, "$endpoint returned $code")
null to "HTTP $code" null to "HTTP $code"
@@ -141,6 +149,16 @@ class DeflockClient(context: Context) {
} }
} }
private fun looksLikeOverpassTimeout(body: String): Boolean {
if (!body.contains("remark", ignoreCase = true)) return false
val lower = body.lowercase()
return lower.contains("timed out") ||
lower.contains("timeout") ||
lower.contains("runtime error") ||
lower.contains("runtime limit exceeded") ||
lower.contains("rate_limited")
}
private fun parseSafely(json: String): List<AlprPoint> { private fun parseSafely(json: String): List<AlprPoint> {
if (json.isBlank()) return emptyList() if (json.isBlank()) return emptyList()
return try { return try {
@@ -32,11 +32,15 @@ class DeflockScanner(
companion object { companion object {
private const val TAG = "DeflockScanner" private const val TAG = "DeflockScanner"
private const val REFETCH_THRESHOLD_M = 1500f private const val REFETCH_THRESHOLD_M = 1500f
/** Don't retry an Overpass POST within this window after a failure. */
private const val FAILURE_BACKOFF_MS = 60_000L
} }
private var job: Job? = null private var job: Job? = null
private var lastFetchLat: Double? = null private var lastFetchLat: Double? = null
private var lastFetchLon: Double? = null private var lastFetchLon: Double? = null
private var lastAttemptMs: Long = 0L
private var lastAttemptOk: Boolean = false
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList() private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
@@ -55,17 +59,23 @@ class DeflockScanner(
job = null job = null
lastFetchLat = null lastFetchLat = null
lastFetchLon = null lastFetchLon = null
lastAttemptMs = 0L
lastAttemptOk = false
cachedPoints = emptyList() cachedPoints = emptyList()
Log.i(TAG, "DeflockScanner stopped") Log.i(TAG, "DeflockScanner stopped")
} }
private suspend fun handleFix(fix: Location) { private suspend fun handleFix(fix: Location) {
if (shouldRefetch(fix)) { if (shouldRefetch(fix)) {
// Mark the attempt before the network call so a concurrent location
// tick doesn't trigger a parallel re-fetch of the same area.
lastFetchLat = fix.latitude
lastFetchLon = fix.longitude
lastAttemptMs = System.currentTimeMillis()
when (val result = client.fetchAround(fix.latitude, fix.longitude)) { when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is DeflockClient.FetchResult.Success -> { is DeflockClient.FetchResult.Success -> {
cachedPoints = result.points cachedPoints = result.points
lastFetchLat = fix.latitude lastAttemptOk = true
lastFetchLon = fix.longitude
SourceHealth.record(DetectionSource.DEFLOCK, ok = true) SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i( Log.i(
TAG, TAG,
@@ -74,6 +84,7 @@ class DeflockScanner(
) )
} }
is DeflockClient.FetchResult.Failed -> { is DeflockClient.FetchResult.Failed -> {
lastAttemptOk = false
SourceHealth.record( SourceHealth.record(
DetectionSource.DEFLOCK, DetectionSource.DEFLOCK,
ok = false, ok = false,
@@ -106,7 +117,9 @@ class DeflockScanner(
label = scored.label, label = scored.label,
score = scored.score, score = scored.score,
matchedMethods = scored.methods, matchedMethods = scored.methods,
rssi = null rssi = null,
lat = p.lat,
lon = p.lon
) )
) )
} }
@@ -115,6 +128,12 @@ class DeflockScanner(
private fun shouldRefetch(fix: Location): Boolean { private fun shouldRefetch(fix: Location): Boolean {
val lat = lastFetchLat ?: return true val lat = lastFetchLat ?: return true
val lon = lastFetchLon ?: return true val lon = lastFetchLon ?: return true
// After a failed attempt, hold off for FAILURE_BACKOFF_MS even if the
// user hasn't moved — avoids hammering Overpass when it's struggling.
if (!lastAttemptOk &&
System.currentTimeMillis() - lastAttemptMs < FAILURE_BACKOFF_MS) {
return false
}
val out = FloatArray(1) val out = FloatArray(1)
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out) Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
return out[0] > REFETCH_THRESHOLD_M return out[0] > REFETCH_THRESHOLD_M
@@ -24,6 +24,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker import org.soulstone.overwatch.fusion.RssiTracker
import org.soulstone.overwatch.fusion.SourceHealth
/** /**
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults]. * WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
@@ -86,15 +87,23 @@ class WifiScanner(
if (running) return true if (running) return true
if (!hasScanPermission()) { if (!hasScanPermission()) {
Log.w(TAG, "WiFi scan permission missing") Log.w(TAG, "WiFi scan permission missing")
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "Permission missing")
return false return false
} }
val mgr = wifiManager ?: run { val mgr = wifiManager ?: run {
Log.w(TAG, "WifiManager unavailable") Log.w(TAG, "WifiManager unavailable")
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "WifiManager unavailable")
return false return false
} }
if (!mgr.isWifiEnabled) { if (!mgr.isWifiEnabled) {
Log.w(TAG, "WiFi disabled — scanner won't return results") Log.w(TAG, "WiFi disabled — scanner won't return results")
SourceHealth.record(
DetectionSource.WIFI, ok = false,
message = "WiFi disabled — enable in system settings"
)
// We still register the receiver so results arrive when the user enables WiFi. // We still register the receiver so results arrive when the user enables WiFi.
} else {
SourceHealth.record(DetectionSource.WIFI, ok = true)
} }
registerReceiver() registerReceiver()
running = true running = true
@@ -9,6 +9,9 @@ import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
@@ -18,13 +21,16 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.MainActivity import org.soulstone.overwatch.MainActivity
import org.soulstone.overwatch.R import org.soulstone.overwatch.R
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.BleScanner import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.scan.DeflockClient
@@ -32,12 +38,18 @@ import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.WifiScanner import org.soulstone.overwatch.scan.WifiScanner
/** /**
* Foreground service that owns all scanners and the [DetectionStore]. * Foreground service that owns all four scanners (BLE, WiFi, DeFlock, Citizen)
* and the [DetectionStore]. UI observes companion-object state flows directly.
* *
* Phase 1 wires only [BleScanner]; phases 2-4 will register WiFi, DeFlock, Waze. * Responsibilities beyond scanner orchestration:
* - Updates the foreground notification on every threat-tier change so a
* locked-screen user sees escalations.
* - Vibrates on upward tier transitions (gated by Settings.vibrateOnAlert).
* - Resets [SourceHealth] on start/stop.
* *
* The service is a singleton at runtime — UI binds to it (or observes the * Returns START_NOT_STICKY so a system-killed service does not auto-restart
* companion-object state flows directly, which is what we do here for simplicity). * into a zombie state where the notification disappears but `_running` stays
* stale. The user explicitly starts and stops; auto-restart isn't needed.
*/ */
class DetectionService : LifecycleService() { class DetectionService : LifecycleService() {
@@ -81,10 +93,13 @@ class DetectionService : LifecycleService() {
private lateinit var deflockScanner: DeflockScanner private lateinit var deflockScanner: DeflockScanner
private lateinit var citizenScanner: CitizenScanner private lateinit var citizenScanner: CitizenScanner
private var pruneJob: Job? = null private var pruneJob: Job? = null
private var observerJob: 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
/** Last threat tier the notification displayed; tracks upward transitions for vibration. */
private var lastNotifiedTier: ThreatLevel = ThreatLevel.GREEN
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -112,13 +127,17 @@ class DetectionService : LifecycleService() {
stopSelf() stopSelf()
} }
} }
return START_STICKY return START_NOT_STICKY
} }
private fun beginScanning() { private fun beginScanning() {
if (_running.value) return if (_running.value) return
SourceHealth.reset() SourceHealth.reset()
startInForeground() lastNotifiedTier = ThreatLevel.GREEN
// Bring up the foreground notification BEFORE any scanner so we don't
// accidentally call startForeground after work has already begun.
startInForeground(ThreatLevel.GREEN, topEvent = null)
if (settings.bleEnabled.value) { if (settings.bleEnabled.value) {
bleStarted = bleScanner.start() bleStarted = bleScanner.start()
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)") if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
@@ -127,8 +146,7 @@ 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 || val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
settings.citizenEnabled.value
if (needsLocation) { if (needsLocation) {
val locOk = locationProvider.start() val locOk = locationProvider.start()
if (!locOk) { if (!locOk) {
@@ -142,6 +160,15 @@ class DetectionService : LifecycleService() {
} }
} }
} }
val anyStarted = bleStarted || wifiStarted || deflockStarted || citizenStarted
if (!anyStarted) {
Log.w(TAG, "No scanner started — endScanning + stopSelf")
endScanning()
stopSelf()
return
}
_running.value = true _running.value = true
pruneJob?.cancel() pruneJob?.cancel()
pruneJob = lifecycleScope.launch { pruneJob = lifecycleScope.launch {
@@ -150,10 +177,23 @@ class DetectionService : LifecycleService() {
store.pruneExpired() store.pruneExpired()
} }
} }
observerJob?.cancel()
observerJob = lifecycleScope.launch {
// Watch threat tier + the top event together; rebuild the notification
// on either change. Vibrate only when the tier ratchets upward.
store.threatLevel.combine(store.events) { tier, events ->
tier to events.firstOrNull()
}.collect { (tier, top) ->
onTierChanged(tier, top)
}
}
} }
private fun endScanning() { private fun endScanning() {
if (!_running.value) return if (!_running.value && !bleStarted && !wifiStarted && !deflockStarted && !citizenStarted) {
return
}
_running.value = false
if (bleStarted) { bleScanner.stop(); bleStarted = false } if (bleStarted) { bleScanner.stop(); bleStarted = false }
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false } if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false } if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
@@ -161,9 +201,9 @@ class DetectionService : LifecycleService() {
locationProvider.stop() locationProvider.stop()
store.clear() store.clear()
SourceHealth.reset() SourceHealth.reset()
pruneJob?.cancel() pruneJob?.cancel(); pruneJob = null
pruneJob = null observerJob?.cancel(); observerJob = null
_running.value = false lastNotifiedTier = ThreatLevel.GREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
} else { } else {
@@ -182,12 +222,46 @@ class DetectionService : LifecycleService() {
return null return null
} }
private fun startInForeground() { private fun onTierChanged(tier: ThreatLevel, top: DetectionEvent?) {
val notification = buildNotification() // Re-issue the foreground notification with the current tier + top event
// so a locked-screen user sees the escalation even without opening the app.
val notification = buildNotification(tier, top)
val mgr = getSystemService(NotificationManager::class.java) ?: return
mgr.notify(NOTIFICATION_ID, notification)
if (tier.ordinal > lastNotifiedTier.ordinal && settings.vibrateOnAlert.value) {
vibrateForTier(tier)
}
lastNotifiedTier = tier
}
private fun vibrateForTier(tier: ThreatLevel) {
val v = currentVibrator() ?: return
val effect = when (tier) {
ThreatLevel.YELLOW -> VibrationEffect.createOneShot(120, VibrationEffect.DEFAULT_AMPLITUDE)
ThreatLevel.ORANGE -> VibrationEffect.createWaveform(longArrayOf(0, 180, 100, 180), -1)
ThreatLevel.RED -> VibrationEffect.createWaveform(
longArrayOf(0, 250, 120, 250, 120, 400), -1
)
ThreatLevel.GREEN -> return
}
try { v.vibrate(effect) } catch (e: Exception) { Log.w(TAG, "vibrate failed: ${e.message}") }
}
private fun currentVibrator(): Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun startInForeground(tier: ThreatLevel, topEvent: DetectionEvent?) {
val notification = buildNotification(tier, topEvent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ requires the runtime type to cover every capability // Android 14+ requires the runtime type to cover every capability the
// the service uses. We declare both in the manifest; pass both here // service uses. We declare both in the manifest; pass both here so
// so location-using sources (DeFlock, Waze) keep working with the // location-using sources (DeFlock, Citizen) keep working with the
// screen off. // screen off.
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
@@ -197,7 +271,7 @@ class DetectionService : LifecycleService() {
} }
} }
private fun buildNotification(): Notification { private fun buildNotification(tier: ThreatLevel, topEvent: DetectionEvent?): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply { val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
@@ -205,14 +279,26 @@ class DetectionService : LifecycleService() {
this, 0, openIntent, this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
val title = "OVERWATCH • ${tier.name}"
val text = topEvent?.let { "${it.score}${it.label}" }
?: getString(R.string.notification_text)
// Higher importance for ORANGE/RED so the system surfaces it more
// aggressively (heads-up notification, etc.). The channel was created
// with LOW; on supported versions this priority is best-effort.
val priority = when (tier) {
ThreatLevel.RED -> NotificationCompat.PRIORITY_HIGH
ThreatLevel.ORANGE -> NotificationCompat.PRIORITY_DEFAULT
else -> NotificationCompat.PRIORITY_LOW
}
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title)) .setContentTitle(title)
.setContentText(getString(R.string.notification_text)) .setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view) .setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true) .setOngoing(true)
.setContentIntent(pi) .setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(priority)
.setOnlyAlertOnce(false)
.build() .build()
} }
@@ -19,8 +19,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -36,8 +40,7 @@ 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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -65,11 +68,12 @@ fun MainScreen(
onStartStop: () -> Unit, onStartStop: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
canStart: Boolean, canStart: Boolean,
permissionMessage: String? permissionMessage: String?,
showOpenAppSettings: Boolean = false,
onOpenAppSettings: () -> Unit = {}
) { ) {
var showSheet by remember { mutableStateOf(false) } var showSheet by rememberSaveable { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetScope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -159,6 +163,24 @@ fun MainScreen(
fontSize = 13.sp fontSize = 13.sp
) )
} }
if (showOpenAppSettings) {
Spacer(Modifier.height(8.dp))
Button(
onClick = onOpenAppSettings,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Open app settings",
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
} }
} }
@@ -186,12 +208,20 @@ fun MainScreen(
@Composable @Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) { private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = when (level) { // When the scanner isn't running, deliberately use a muted color and IDLE
// text so the user can tell at a glance whether they're scanning. Without
// this, idle and "scanning, all clear" both render as solid green.
val idleColor = MaterialTheme.colorScheme.surfaceVariant
val activeColor = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red ThreatLevel.RED -> ThreatColors.Red
} }
val color = if (animating) activeColor else idleColor
val labelText = if (animating) level.name else "IDLE"
val labelColor = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "pulse") val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat( val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f, initialValue = if (animating) 0.6f else 1.0f,
@@ -220,8 +250,8 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = level.name, text = labelText,
color = Color.White, color = labelColor,
fontSize = 28.sp, fontSize = 28.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
@@ -329,14 +359,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
) )
} else { } else {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
events.take(3).forEach { e -> events.take(3).forEach { e -> EventRow(e) }
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
if (events.size > 3) { if (events.size > 3) {
Text( Text(
text = "+${events.size - 3} more", text = "+${events.size - 3} more",
@@ -348,3 +371,40 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
} }
} }
} }
@Composable
private fun EventRow(e: DetectionEvent) {
val ctx = LocalContext.current
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f, fill = true)
)
if (e.hasGeo) {
IconButton(
onClick = {
val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(ctx.packageManager) != null) {
ctx.startActivity(intent)
}
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Place,
contentDescription = "Open in Maps",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
}
}
@@ -27,6 +27,9 @@ 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.remember
import androidx.compose.runtime.setValue
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
@@ -49,6 +52,7 @@ fun SettingsScreen(
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 theme by settings.themeMode.collectAsState() val theme by settings.themeMode.collectAsState()
val vibrate by settings.vibrateOnAlert.collectAsState()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -107,21 +111,23 @@ fun SettingsScreen(
SectionLabel("Proximity thresholds") SectionLabel("Proximity thresholds")
SliderRow( SliderRow(
label = "DeFlock alert distance", label = "DeFlock alert distance",
valueLabel = "${deflockProx} m", persistedValue = deflockProx,
value = deflockProx.toFloat(),
range = 50f..1600f, range = 50f..1600f,
steps = 30, steps = 30,
onChange = { settings.setDeflockProximityM(it.toInt()) } onCommit = { settings.setDeflockProximityM(it) }
) )
SliderRow( SliderRow(
label = "Citizen alert distance", label = "Citizen alert distance",
valueLabel = "${citizenProx} m", persistedValue = citizenProx,
value = citizenProx.toFloat(),
range = 100f..5000f, range = 100f..5000f,
steps = 48, steps = 48,
onChange = { settings.setCitizenProximityM(it.toInt()) } onCommit = { settings.setCitizenProximityM(it) }
) )
Spacer(Modifier.height(16.dp))
SectionLabel("Alerts")
SourceToggle("Vibrate on threat escalation", vibrate) { settings.setVibrateOnAlert(it) }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionLabel("Appearance") SectionLabel("Appearance")
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) { ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
@@ -169,15 +175,20 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
} }
} }
/**
* Slider that commits the value to Settings only on drag-release. The label
* tracks the live drag position locally to avoid spamming SharedPreferences
* writes (and downstream StateFlow re-emissions) on every pixel of movement.
*/
@Composable @Composable
private fun SliderRow( private fun SliderRow(
label: String, label: String,
valueLabel: String, persistedValue: Int,
value: Float,
range: ClosedFloatingPointRange<Float>, range: ClosedFloatingPointRange<Float>,
steps: Int, steps: Int,
onChange: (Float) -> Unit onCommit: (Int) -> Unit
) { ) {
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
Column(modifier = Modifier.padding(vertical = 4.dp)) { Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -190,15 +201,16 @@ private fun SliderRow(
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
) )
Text( Text(
text = valueLabel, text = "${live.toInt()} m",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp, fontSize = 13.sp,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
) )
} }
Slider( Slider(
value = value, value = live,
onValueChange = onChange, onValueChange = { live = it },
onValueChangeFinished = { onCommit(live.toInt()) },
valueRange = range, valueRange = range,
steps = steps steps = steps
) )