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"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 3
versionName = "0.1.2"
}
buildTypes {
@@ -42,7 +42,13 @@ class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { 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)
@@ -87,8 +93,14 @@ class MainActivity : ComponentActivity() {
)
}
Screen.SETTINGS -> {
val running by DetectionService.running.collectAsState()
SettingsScreen(
settings = settings,
isRunning = running,
onRestart = {
DetectionService.stop(this)
DetectionService.start(this)
},
onBack = { screen = Screen.MAIN }
)
}
@@ -109,12 +109,15 @@ class DeflockClient(context: Context) {
val out = ArrayList<AlprPoint>(arr.length())
for (i in 0 until arr.length()) {
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")
out.add(
AlprPoint(
id = o.optLong("id", 0L),
lat = o.optDouble("lat"),
lon = o.optDouble("lon"),
lat = lat,
lon = lon,
operator = tags?.optString("operator")?.ifBlank { null }
?: tags?.optString("surveillance:operator")?.ifBlank { null },
manufacturer = tags?.optString("manufacturer")?.ifBlank { null }
@@ -93,12 +93,15 @@ class WazeClient {
val loc = a.optJSONObject("location") ?: continue
val uuid = a.optString("uuid")
if (uuid.isBlank()) continue
val lat = loc.optDouble("y")
val lon = loc.optDouble("x")
if (lat.isNaN() || lon.isNaN()) continue
out.add(
Alert(
uuid = uuid,
subtype = a.optString("subtype").ifBlank { null },
lat = loc.optDouble("y"),
lon = loc.optDouble("x"),
lat = lat,
lon = lon,
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
confidence = a.optInt("confidence", 0),
reliability = a.optInt("reliability", 0),
@@ -181,11 +181,13 @@ class DetectionService : LifecycleService() {
private fun startInForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)
// 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
// screen off.
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
startForeground(NOTIFICATION_ID, notification, type)
} else {
startForeground(NOTIFICATION_ID, notification)
}
@@ -13,6 +13,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.Icon
import androidx.compose.material3.IconButton
@@ -35,6 +38,8 @@ import org.soulstone.overwatch.data.settings.Settings
@Composable
fun SettingsScreen(
settings: Settings,
isRunning: Boolean,
onRestart: () -> Unit,
onBack: () -> Unit
) {
val ble by settings.bleEnabled.collectAsState()
@@ -74,11 +79,29 @@ fun SettingsScreen(
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
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(
"Source toggles take effect on next Start.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(16.dp))
SectionLabel("Proximity thresholds")