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:
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "org.soulstone.overwatch"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 6
|
||||
versionName = "0.1.5"
|
||||
versionCode = 7
|
||||
versionName = "0.1.6"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<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" />
|
||||
|
||||
<application
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package org.soulstone.overwatch
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.soulstone.overwatch.data.settings.Settings
|
||||
import org.soulstone.overwatch.service.DetectionService
|
||||
@@ -44,6 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
) { result ->
|
||||
val allGranted = result.all { it.value }
|
||||
permissionsGranted.value = allGranted
|
||||
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
|
||||
if (allGranted) {
|
||||
// First-run path: user just granted everything, kick off scanning
|
||||
// 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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
permissionsGranted.value = checkAllPermissions()
|
||||
permanentlyDenied.value = false // reset on activity create
|
||||
val settings = Settings.get(this)
|
||||
|
||||
setContent {
|
||||
val themeMode by settings.themeMode.collectAsState()
|
||||
OverwatchTheme(mode = themeMode) {
|
||||
var screen by remember { mutableStateOf(Screen.MAIN) }
|
||||
var screen by rememberSaveable { mutableStateOf(Screen.MAIN) }
|
||||
|
||||
when (screen) {
|
||||
Screen.MAIN -> {
|
||||
@@ -70,6 +81,13 @@ class MainActivity : ComponentActivity() {
|
||||
val threat by DetectionService.store.threatLevel.collectAsState()
|
||||
val maxScore by DetectionService.store.maxScore.collectAsState()
|
||||
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(
|
||||
running = running,
|
||||
@@ -77,13 +95,17 @@ class MainActivity : ComponentActivity() {
|
||||
score = maxScore,
|
||||
events = events,
|
||||
canStart = true,
|
||||
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
|
||||
permissionMessage = message,
|
||||
showOpenAppSettings = denied && !granted,
|
||||
onOpenAppSettings = { openAppSettings() },
|
||||
onStartStop = {
|
||||
if (running) {
|
||||
DetectionService.stop(this)
|
||||
} else {
|
||||
if (granted) {
|
||||
DetectionService.start(this)
|
||||
} else if (denied) {
|
||||
openAppSettings()
|
||||
} else {
|
||||
permissionLauncher.launch(requiredPermissions)
|
||||
}
|
||||
@@ -111,7 +133,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun 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 =
|
||||
@@ -119,5 +144,23 @@ class MainActivity : ComponentActivity() {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ class LocationProvider(private val context: Context) {
|
||||
|
||||
private val callback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
if (!running) return
|
||||
val fix = result.lastLocation ?: return
|
||||
_location.value = fix
|
||||
}
|
||||
}
|
||||
|
||||
private var running = false
|
||||
@Volatile private var running = false
|
||||
|
||||
fun hasPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
@@ -68,12 +69,22 @@ class LocationProvider(private val context: Context) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
|
||||
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
|
||||
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")
|
||||
return true
|
||||
} catch (e: SecurityException) {
|
||||
running = false
|
||||
Log.e(TAG, "SecurityException starting location updates", e)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
)
|
||||
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 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 }
|
||||
@@ -69,6 +72,11 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
_themeMode.value = mode
|
||||
}
|
||||
|
||||
fun setVibrateOnAlert(v: Boolean) {
|
||||
prefs.edit { putBoolean(KEY_VIBRATE, v) }
|
||||
_vibrateOnAlert.value = v
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "overwatch_settings"
|
||||
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_CITIZEN_PROX = "citizen_proximity_m"
|
||||
private const val KEY_THEME = "theme_mode"
|
||||
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||
|
||||
const val DEFAULT_DEFLOCK_PROX = 200
|
||||
const val DEFAULT_CITIZEN_PROX = 500
|
||||
|
||||
@@ -4,11 +4,12 @@ package org.soulstone.overwatch.fusion
|
||||
* One observation from one source at one moment.
|
||||
*
|
||||
* @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 score 0-100 confidence assigned by the engine
|
||||
* @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
|
||||
*/
|
||||
data class DetectionEvent(
|
||||
@@ -18,7 +19,10 @@ data class DetectionEvent(
|
||||
val score: Int,
|
||||
val matchedMethods: String,
|
||||
val rssi: Int? = null,
|
||||
val lat: Double? = null,
|
||||
val lon: Double? = null,
|
||||
val timestampMs: Long = System.currentTimeMillis()
|
||||
) {
|
||||
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.DetectionStore
|
||||
import org.soulstone.overwatch.fusion.RssiTracker
|
||||
import org.soulstone.overwatch.fusion.SourceHealth
|
||||
|
||||
/**
|
||||
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
|
||||
@@ -79,18 +80,30 @@ class BleScanner(
|
||||
if (running) return true
|
||||
if (!hasScanPermission()) {
|
||||
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
|
||||
}
|
||||
val adapter = bluetoothAdapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
leScanner = adapter.bluetoothLeScanner ?: return false
|
||||
try {
|
||||
leScanner?.startScan(null, scanSettings, scanCallback)
|
||||
running = true
|
||||
SourceHealth.record(DetectionSource.BLE, ok = true)
|
||||
Log.i(TAG, "BLE scan started")
|
||||
return true
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException starting scan", e)
|
||||
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission revoked")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -120,6 +133,11 @@ class BleScanner(
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "BLE scan failed: $errorCode")
|
||||
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 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 payload: ByteArray? = null
|
||||
if (mfgSpecific != null && mfgSpecific.size() > 0) {
|
||||
companyId = mfgSpecific.keyAt(0)
|
||||
payload = mfgSpecific.valueAt(0)
|
||||
for (i in 0 until mfgSpecific.size()) {
|
||||
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.
|
||||
|
||||
@@ -64,7 +64,10 @@ class CitizenClient {
|
||||
val arr = JSONObject(raw.body).optJSONArray("results")
|
||||
?: return@withContext TrendingResult.Success(emptyList())
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
TrendingResult.Failed("parse: ${e.message}")
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -57,6 +58,9 @@ class CitizenScanner(
|
||||
fun start(scope: CoroutineScope): Boolean {
|
||||
if (job != null) return true
|
||||
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) {
|
||||
val fix = locationProvider.location.value
|
||||
if (fix != null) pollOnce(fix)
|
||||
@@ -137,7 +141,9 @@ class CitizenScanner(
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
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
|
||||
if (code in 200..299) {
|
||||
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
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "$endpoint returned $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> {
|
||||
if (json.isBlank()) return emptyList()
|
||||
return try {
|
||||
|
||||
@@ -32,11 +32,15 @@ class DeflockScanner(
|
||||
companion object {
|
||||
private const val TAG = "DeflockScanner"
|
||||
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 lastFetchLat: Double? = null
|
||||
private var lastFetchLon: Double? = null
|
||||
private var lastAttemptMs: Long = 0L
|
||||
private var lastAttemptOk: Boolean = false
|
||||
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
|
||||
|
||||
fun start(scope: CoroutineScope): Boolean {
|
||||
@@ -55,17 +59,23 @@ class DeflockScanner(
|
||||
job = null
|
||||
lastFetchLat = null
|
||||
lastFetchLon = null
|
||||
lastAttemptMs = 0L
|
||||
lastAttemptOk = false
|
||||
cachedPoints = emptyList()
|
||||
Log.i(TAG, "DeflockScanner stopped")
|
||||
}
|
||||
|
||||
private suspend fun handleFix(fix: Location) {
|
||||
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)) {
|
||||
is DeflockClient.FetchResult.Success -> {
|
||||
cachedPoints = result.points
|
||||
lastFetchLat = fix.latitude
|
||||
lastFetchLon = fix.longitude
|
||||
lastAttemptOk = true
|
||||
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -74,6 +84,7 @@ class DeflockScanner(
|
||||
)
|
||||
}
|
||||
is DeflockClient.FetchResult.Failed -> {
|
||||
lastAttemptOk = false
|
||||
SourceHealth.record(
|
||||
DetectionSource.DEFLOCK,
|
||||
ok = false,
|
||||
@@ -106,7 +117,9 @@ class DeflockScanner(
|
||||
label = scored.label,
|
||||
score = scored.score,
|
||||
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 {
|
||||
val lat = lastFetchLat ?: 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)
|
||||
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
|
||||
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.DetectionStore
|
||||
import org.soulstone.overwatch.fusion.RssiTracker
|
||||
import org.soulstone.overwatch.fusion.SourceHealth
|
||||
|
||||
/**
|
||||
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
|
||||
@@ -86,15 +87,23 @@ class WifiScanner(
|
||||
if (running) return true
|
||||
if (!hasScanPermission()) {
|
||||
Log.w(TAG, "WiFi scan permission missing")
|
||||
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "Permission missing")
|
||||
return false
|
||||
}
|
||||
val mgr = wifiManager ?: run {
|
||||
Log.w(TAG, "WifiManager unavailable")
|
||||
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "WifiManager unavailable")
|
||||
return false
|
||||
}
|
||||
if (!mgr.isWifiEnabled) {
|
||||
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.
|
||||
} else {
|
||||
SourceHealth.record(DetectionSource.WIFI, ok = true)
|
||||
}
|
||||
registerReceiver()
|
||||
running = true
|
||||
|
||||
@@ -9,6 +9,9 @@ import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
@@ -18,13 +21,16 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import org.soulstone.overwatch.MainActivity
|
||||
import org.soulstone.overwatch.R
|
||||
import org.soulstone.overwatch.data.location.LocationProvider
|
||||
import org.soulstone.overwatch.data.settings.Settings
|
||||
import org.soulstone.overwatch.fusion.DetectionEvent
|
||||
import org.soulstone.overwatch.fusion.DetectionStore
|
||||
import org.soulstone.overwatch.fusion.SourceHealth
|
||||
import org.soulstone.overwatch.fusion.ThreatLevel
|
||||
import org.soulstone.overwatch.scan.BleScanner
|
||||
import org.soulstone.overwatch.scan.CitizenScanner
|
||||
import org.soulstone.overwatch.scan.DeflockClient
|
||||
@@ -32,12 +38,18 @@ import org.soulstone.overwatch.scan.DeflockScanner
|
||||
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
|
||||
* companion-object state flows directly, which is what we do here for simplicity).
|
||||
* Returns START_NOT_STICKY so a system-killed service does not auto-restart
|
||||
* 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() {
|
||||
|
||||
@@ -81,10 +93,13 @@ class DetectionService : LifecycleService() {
|
||||
private lateinit var deflockScanner: DeflockScanner
|
||||
private lateinit var citizenScanner: CitizenScanner
|
||||
private var pruneJob: Job? = null
|
||||
private var observerJob: Job? = null
|
||||
private var bleStarted = false
|
||||
private var wifiStarted = false
|
||||
private var deflockStarted = 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() {
|
||||
super.onCreate()
|
||||
@@ -112,13 +127,17 @@ class DetectionService : LifecycleService() {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun beginScanning() {
|
||||
if (_running.value) return
|
||||
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) {
|
||||
bleStarted = bleScanner.start()
|
||||
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
|
||||
@@ -127,8 +146,7 @@ class DetectionService : LifecycleService() {
|
||||
wifiStarted = wifiScanner.start(lifecycleScope)
|
||||
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
||||
}
|
||||
val needsLocation = settings.deflockEnabled.value ||
|
||||
settings.citizenEnabled.value
|
||||
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
|
||||
if (needsLocation) {
|
||||
val locOk = locationProvider.start()
|
||||
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
|
||||
pruneJob?.cancel()
|
||||
pruneJob = lifecycleScope.launch {
|
||||
@@ -150,10 +177,23 @@ class DetectionService : LifecycleService() {
|
||||
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() {
|
||||
if (!_running.value) return
|
||||
if (!_running.value && !bleStarted && !wifiStarted && !deflockStarted && !citizenStarted) {
|
||||
return
|
||||
}
|
||||
_running.value = false
|
||||
if (bleStarted) { bleScanner.stop(); bleStarted = false }
|
||||
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
||||
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
||||
@@ -161,9 +201,9 @@ class DetectionService : LifecycleService() {
|
||||
locationProvider.stop()
|
||||
store.clear()
|
||||
SourceHealth.reset()
|
||||
pruneJob?.cancel()
|
||||
pruneJob = null
|
||||
_running.value = false
|
||||
pruneJob?.cancel(); pruneJob = null
|
||||
observerJob?.cancel(); observerJob = null
|
||||
lastNotifiedTier = ThreatLevel.GREEN
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@@ -182,12 +222,46 @@ class DetectionService : LifecycleService() {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun startInForeground() {
|
||||
val notification = buildNotification()
|
||||
private fun onTierChanged(tier: ThreatLevel, top: DetectionEvent?) {
|
||||
// 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) {
|
||||
// Android 14+ requires the runtime type to cover every capability
|
||||
// the service uses. We declare both in the manifest; pass both here
|
||||
// so location-using sources (DeFlock, Waze) keep working with the
|
||||
// Android 14+ requires the runtime type to cover every capability the
|
||||
// service uses. We declare both in the manifest; pass both here so
|
||||
// location-using sources (DeFlock, Citizen) keep working with the
|
||||
// screen off.
|
||||
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
||||
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 {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
@@ -205,14 +279,26 @@ class DetectionService : LifecycleService() {
|
||||
this, 0, openIntent,
|
||||
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)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text))
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pi)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setPriority(priority)
|
||||
.setOnlyAlertOnce(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,12 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.filled.Place
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -36,8 +40,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -65,11 +68,12 @@ fun MainScreen(
|
||||
onStartStop: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
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 sheetScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -159,6 +163,24 @@ fun MainScreen(
|
||||
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
|
||||
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.YELLOW -> ThreatColors.Yellow
|
||||
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||
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 pulse by transition.animateFloat(
|
||||
initialValue = if (animating) 0.6f else 1.0f,
|
||||
@@ -220,8 +250,8 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = level.name,
|
||||
color = Color.White,
|
||||
text = labelText,
|
||||
color = labelColor,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontFamily = FontFamily.Monospace
|
||||
@@ -329,14 +359,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
events.take(3).forEach { e ->
|
||||
Text(
|
||||
text = "${e.score} • ${e.label} • ${e.matchedMethods}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
events.take(3).forEach { e -> EventRow(e) }
|
||||
if (events.size > 3) {
|
||||
Text(
|
||||
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.collectAsState
|
||||
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.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -49,6 +52,7 @@ fun SettingsScreen(
|
||||
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||
val theme by settings.themeMode.collectAsState()
|
||||
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -107,21 +111,23 @@ fun SettingsScreen(
|
||||
SectionLabel("Proximity thresholds")
|
||||
SliderRow(
|
||||
label = "DeFlock alert distance",
|
||||
valueLabel = "${deflockProx} m",
|
||||
value = deflockProx.toFloat(),
|
||||
persistedValue = deflockProx,
|
||||
range = 50f..1600f,
|
||||
steps = 30,
|
||||
onChange = { settings.setDeflockProximityM(it.toInt()) }
|
||||
onCommit = { settings.setDeflockProximityM(it) }
|
||||
)
|
||||
SliderRow(
|
||||
label = "Citizen alert distance",
|
||||
valueLabel = "${citizenProx} m",
|
||||
value = citizenProx.toFloat(),
|
||||
persistedValue = citizenProx,
|
||||
range = 100f..5000f,
|
||||
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))
|
||||
SectionLabel("Appearance")
|
||||
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
|
||||
private fun SliderRow(
|
||||
label: String,
|
||||
valueLabel: String,
|
||||
value: Float,
|
||||
persistedValue: Int,
|
||||
range: ClosedFloatingPointRange<Float>,
|
||||
steps: Int,
|
||||
onChange: (Float) -> Unit
|
||||
onCommit: (Int) -> Unit
|
||||
) {
|
||||
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -190,15 +201,16 @@ private fun SliderRow(
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = valueLabel,
|
||||
text = "${live.toInt()} m",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
value = live,
|
||||
onValueChange = { live = it },
|
||||
onValueChangeFinished = { onCommit(live.toInt()) },
|
||||
valueRange = range,
|
||||
steps = steps
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user