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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user