diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4c7bb45..f94fa76 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
- versionCode = 6
- versionName = "0.1.5"
+ versionCode = 7
+ versionName = "0.1.6"
}
buildTypes {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fc97338..e1cd96a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -35,6 +35,9 @@
+
+
+
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 }
}
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt
index 72d25ff..f81853b 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt
@@ -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
}
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt
index 6083f43..a5dad79 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt
@@ -47,6 +47,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
)
val themeMode: StateFlow = _themeMode.asStateFlow()
+ private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
+ val vibrateOnAlert: StateFlow = _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
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt
index bc905e5..a64dec9 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt
@@ -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
}
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt
index 5a02b6d..dc59a48 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt
@@ -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.
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt
index 7c26ec9..660702d 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenClient.kt
@@ -64,7 +64,10 @@ class CitizenClient {
val arr = JSONObject(raw.body).optJSONArray("results")
?: return@withContext TrendingResult.Success(emptyList())
val out = ArrayList(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}")
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt
index 71b7578..6dd3440 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/CitizenScanner.kt
@@ -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
)
)
}
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt
index f19615a..04221c3 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt
@@ -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 {
if (json.isBlank()) return emptyList()
return try {
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt
index ffd167b..e09b731 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt
@@ -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 = 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
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt
index ecf4ddc..456814b 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt
@@ -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
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt
index 4cde857..abf77ec 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt
@@ -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()
}
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt
index f17b1bd..d76d3e6 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt
@@ -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) {
)
} 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) {
}
}
}
+
+@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)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt
index 4f9f827..0556c79 100644
--- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt
@@ -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,
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
)