From d8670f4c3216e6cddd48845ce2d4bd0b1e86ebeb Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Tue, 28 Apr 2026 22:11:56 -0400 Subject: [PATCH] =?UTF-8?q?v0.1.6=20=E2=80=94=20audit=20fixes=20(critical,?= =?UTF-8?q?=20moderate,=20minor)=20+=20UX=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 3 + .../org/soulstone/overwatch/MainActivity.kt | 53 +++++++- .../data/location/LocationProvider.kt | 17 ++- .../overwatch/data/settings/Settings.kt | 9 ++ .../overwatch/fusion/DetectionEvent.kt | 8 +- .../soulstone/overwatch/scan/BleScanner.kt | 40 +++++- .../soulstone/overwatch/scan/CitizenClient.kt | 5 +- .../overwatch/scan/CitizenScanner.kt | 8 +- .../soulstone/overwatch/scan/DeflockClient.kt | 20 ++- .../overwatch/scan/DeflockScanner.kt | 25 +++- .../soulstone/overwatch/scan/WifiScanner.kt | 9 ++ .../overwatch/service/DetectionService.kt | 128 +++++++++++++++--- .../org/soulstone/overwatch/ui/MainScreen.kt | 92 ++++++++++--- .../soulstone/overwatch/ui/SettingsScreen.kt | 36 +++-- 15 files changed, 385 insertions(+), 72 deletions(-) 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 )