v0.1.2 — audit fixes

Critical:
  - DetectionService.startInForeground now passes
    FOREGROUND_SERVICE_TYPE_LOCATION OR'd with TYPE_CONNECTED_DEVICE on
    Android 14+. Without this, the system silently revoked location access
    once the screen locked, breaking DeFlock + Waze for foreground-service
    use (the whole point of the foreground service).
  - DeflockClient and WazeClient now skip JSON entries whose lat/lon parse
    to NaN. Previously NaN flowed into Location.distanceBetween, the
    NaN > limit check returned false (IEEE 754), and we submitted a
    full-confidence detection labeled "@0m" — instant false-positive RED
    from a single malformed map entry.

UX:
  - First-run permission flow auto-starts scanning after the user grants
    everything; no second tap on START required.
  - Settings shows a "Restart scan to apply" button when toggling sources
    while scanning. Source toggle changes used to silently no-op until
    the next manual stop+start.

versionCode 1 → 3, versionName 0.1.0 → 0.1.2.
This commit is contained in:
2026-04-28 21:22:20 -04:00
parent 88e6f52ce7
commit 6c57297f58
6 changed files with 60 additions and 17 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 3
versionName = "0.1.0" versionName = "0.1.2"
} }
buildTypes { buildTypes {
@@ -42,7 +42,13 @@ class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { result -> ) { result ->
permissionsGranted.value = result.all { it.value } val allGranted = result.all { it.value }
permissionsGranted.value = allGranted
if (allGranted) {
// First-run path: user just granted everything, kick off scanning
// immediately so they don't have to tap START a second time.
DetectionService.start(this)
}
} }
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false) private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false)
@@ -87,8 +93,14 @@ class MainActivity : ComponentActivity() {
) )
} }
Screen.SETTINGS -> { Screen.SETTINGS -> {
val running by DetectionService.running.collectAsState()
SettingsScreen( SettingsScreen(
settings = settings, settings = settings,
isRunning = running,
onRestart = {
DetectionService.stop(this)
DetectionService.start(this)
},
onBack = { screen = Screen.MAIN } onBack = { screen = Screen.MAIN }
) )
} }
@@ -109,12 +109,15 @@ class DeflockClient(context: Context) {
val out = ArrayList<AlprPoint>(arr.length()) val out = ArrayList<AlprPoint>(arr.length())
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
val lat = o.optDouble("lat")
val lon = o.optDouble("lon")
if (lat.isNaN() || lon.isNaN()) continue
val tags = o.optJSONObject("tags") val tags = o.optJSONObject("tags")
out.add( out.add(
AlprPoint( AlprPoint(
id = o.optLong("id", 0L), id = o.optLong("id", 0L),
lat = o.optDouble("lat"), lat = lat,
lon = o.optDouble("lon"), lon = lon,
operator = tags?.optString("operator")?.ifBlank { null } operator = tags?.optString("operator")?.ifBlank { null }
?: tags?.optString("surveillance:operator")?.ifBlank { null }, ?: tags?.optString("surveillance:operator")?.ifBlank { null },
manufacturer = tags?.optString("manufacturer")?.ifBlank { null } manufacturer = tags?.optString("manufacturer")?.ifBlank { null }
@@ -93,12 +93,15 @@ class WazeClient {
val loc = a.optJSONObject("location") ?: continue val loc = a.optJSONObject("location") ?: continue
val uuid = a.optString("uuid") val uuid = a.optString("uuid")
if (uuid.isBlank()) continue if (uuid.isBlank()) continue
val lat = loc.optDouble("y")
val lon = loc.optDouble("x")
if (lat.isNaN() || lon.isNaN()) continue
out.add( out.add(
Alert( Alert(
uuid = uuid, uuid = uuid,
subtype = a.optString("subtype").ifBlank { null }, subtype = a.optString("subtype").ifBlank { null },
lat = loc.optDouble("y"), lat = lat,
lon = loc.optDouble("x"), lon = lon,
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()), pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
confidence = a.optInt("confidence", 0), confidence = a.optInt("confidence", 0),
reliability = a.optInt("reliability", 0), reliability = a.optInt("reliability", 0),
@@ -181,11 +181,13 @@ class DetectionService : LifecycleService() {
private fun startInForeground() { private fun startInForeground() {
val notification = buildNotification() val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground( // Android 14+ requires the runtime type to cover every capability
NOTIFICATION_ID, // the service uses. We declare both in the manifest; pass both here
notification, // so location-using sources (DeFlock, Waze) keep working with the
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE // screen off.
) val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
startForeground(NOTIFICATION_ID, notification, type)
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} }
@@ -13,6 +13,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -35,6 +38,8 @@ import org.soulstone.overwatch.data.settings.Settings
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
settings: Settings, settings: Settings,
isRunning: Boolean,
onRestart: () -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val ble by settings.bleEnabled.collectAsState() val ble by settings.bleEnabled.collectAsState()
@@ -74,11 +79,29 @@ fun SettingsScreen(
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) } SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (isRunning) {
Button(
onClick = onRestart,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Restart scan to apply",
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
} else {
Text( Text(
"Source toggles take effect on next Start.", "Source toggles take effect on next Start.",
fontSize = 11.sp, fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionLabel("Proximity thresholds") SectionLabel("Proximity thresholds")