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"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "0.1.5"
versionCode = 7
versionName = "0.1.6"
}
buildTypes {
+3
View File
@@ -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() }
body to null
// 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
)