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.
This commit is contained in:
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 7
|
||||||
versionName = "0.1.5"
|
versionName = "0.1.6"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Vibration on threat-tier escalation -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package org.soulstone.overwatch
|
package org.soulstone.overwatch
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import org.soulstone.overwatch.data.settings.Settings
|
import org.soulstone.overwatch.data.settings.Settings
|
||||||
import org.soulstone.overwatch.service.DetectionService
|
import org.soulstone.overwatch.service.DetectionService
|
||||||
@@ -44,6 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
) { result ->
|
) { result ->
|
||||||
val allGranted = result.all { it.value }
|
val allGranted = result.all { it.value }
|
||||||
permissionsGranted.value = allGranted
|
permissionsGranted.value = allGranted
|
||||||
|
permanentlyDenied.value = !allGranted && !anyMissingCanStillAsk()
|
||||||
if (allGranted) {
|
if (allGranted) {
|
||||||
// First-run path: user just granted everything, kick off scanning
|
// First-run path: user just granted everything, kick off scanning
|
||||||
// immediately so they don't have to tap START a second time.
|
// 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
permissionsGranted.value = checkAllPermissions()
|
permissionsGranted.value = checkAllPermissions()
|
||||||
|
permanentlyDenied.value = false // reset on activity create
|
||||||
val settings = Settings.get(this)
|
val settings = Settings.get(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val themeMode by settings.themeMode.collectAsState()
|
val themeMode by settings.themeMode.collectAsState()
|
||||||
OverwatchTheme(mode = themeMode) {
|
OverwatchTheme(mode = themeMode) {
|
||||||
var screen by remember { mutableStateOf(Screen.MAIN) }
|
var screen by rememberSaveable { mutableStateOf(Screen.MAIN) }
|
||||||
|
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.MAIN -> {
|
Screen.MAIN -> {
|
||||||
@@ -70,6 +81,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
val threat by DetectionService.store.threatLevel.collectAsState()
|
val threat by DetectionService.store.threatLevel.collectAsState()
|
||||||
val maxScore by DetectionService.store.maxScore.collectAsState()
|
val maxScore by DetectionService.store.maxScore.collectAsState()
|
||||||
val granted by permissionsGranted
|
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(
|
MainScreen(
|
||||||
running = running,
|
running = running,
|
||||||
@@ -77,13 +95,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
score = maxScore,
|
score = maxScore,
|
||||||
events = events,
|
events = events,
|
||||||
canStart = true,
|
canStart = true,
|
||||||
permissionMessage = if (!granted) "Tap START to grant Bluetooth, WiFi + location permissions" else null,
|
permissionMessage = message,
|
||||||
|
showOpenAppSettings = denied && !granted,
|
||||||
|
onOpenAppSettings = { openAppSettings() },
|
||||||
onStartStop = {
|
onStartStop = {
|
||||||
if (running) {
|
if (running) {
|
||||||
DetectionService.stop(this)
|
DetectionService.stop(this)
|
||||||
} else {
|
} else {
|
||||||
if (granted) {
|
if (granted) {
|
||||||
DetectionService.start(this)
|
DetectionService.start(this)
|
||||||
|
} else if (denied) {
|
||||||
|
openAppSettings()
|
||||||
} else {
|
} else {
|
||||||
permissionLauncher.launch(requiredPermissions)
|
permissionLauncher.launch(requiredPermissions)
|
||||||
}
|
}
|
||||||
@@ -111,7 +133,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.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 =
|
private fun checkAllPermissions(): Boolean =
|
||||||
@@ -119,5 +144,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
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 }
|
private enum class Screen { MAIN, SETTINGS }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ class LocationProvider(private val context: Context) {
|
|||||||
|
|
||||||
private val callback = object : LocationCallback() {
|
private val callback = object : LocationCallback() {
|
||||||
override fun onLocationResult(result: LocationResult) {
|
override fun onLocationResult(result: LocationResult) {
|
||||||
|
if (!running) return
|
||||||
val fix = result.lastLocation ?: return
|
val fix = result.lastLocation ?: return
|
||||||
_location.value = fix
|
_location.value = fix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var running = false
|
@Volatile private var running = false
|
||||||
|
|
||||||
fun hasPermission(): Boolean =
|
fun hasPermission(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
@@ -68,12 +69,22 @@ class LocationProvider(private val context: Context) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
|
|
||||||
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
|
|
||||||
running = true
|
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")
|
Log.i(TAG, "Location updates started")
|
||||||
return true
|
return true
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
running = false
|
||||||
Log.e(TAG, "SecurityException starting location updates", e)
|
Log.e(TAG, "SecurityException starting location updates", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
)
|
)
|
||||||
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
|
||||||
|
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
|
||||||
|
|
||||||
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
|
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 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 }
|
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
|
_themeMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVibrateOnAlert(v: Boolean) {
|
||||||
|
prefs.edit { putBoolean(KEY_VIBRATE, v) }
|
||||||
|
_vibrateOnAlert.value = v
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS = "overwatch_settings"
|
private const val PREFS = "overwatch_settings"
|
||||||
private const val KEY_BLE = "src_ble"
|
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_DEFLOCK_PROX = "deflock_proximity_m"
|
||||||
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
|
private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
|
||||||
private const val KEY_THEME = "theme_mode"
|
private const val KEY_THEME = "theme_mode"
|
||||||
|
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||||
|
|
||||||
const val DEFAULT_DEFLOCK_PROX = 200
|
const val DEFAULT_DEFLOCK_PROX = 200
|
||||||
const val DEFAULT_CITIZEN_PROX = 500
|
const val DEFAULT_CITIZEN_PROX = 500
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ package org.soulstone.overwatch.fusion
|
|||||||
* One observation from one source at one moment.
|
* One observation from one source at one moment.
|
||||||
*
|
*
|
||||||
* @param source which scanner produced this
|
* @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 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 score 0-100 confidence assigned by the engine
|
||||||
* @param matchedMethods space-separated short tags for what triggered ("axon_oui mfg_0x09C8 tn_serial")
|
* @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
|
* @param timestampMs wall-clock millis when this event was produced
|
||||||
*/
|
*/
|
||||||
data class DetectionEvent(
|
data class DetectionEvent(
|
||||||
@@ -18,7 +19,10 @@ data class DetectionEvent(
|
|||||||
val score: Int,
|
val score: Int,
|
||||||
val matchedMethods: String,
|
val matchedMethods: String,
|
||||||
val rssi: Int? = null,
|
val rssi: Int? = null,
|
||||||
|
val lat: Double? = null,
|
||||||
|
val lon: Double? = null,
|
||||||
val timestampMs: Long = System.currentTimeMillis()
|
val timestampMs: Long = System.currentTimeMillis()
|
||||||
) {
|
) {
|
||||||
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
|
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
|
||||||
|
val hasGeo: Boolean get() = lat != null && lon != null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
|
|||||||
import org.soulstone.overwatch.fusion.DetectionSource
|
import org.soulstone.overwatch.fusion.DetectionSource
|
||||||
import org.soulstone.overwatch.fusion.DetectionStore
|
import org.soulstone.overwatch.fusion.DetectionStore
|
||||||
import org.soulstone.overwatch.fusion.RssiTracker
|
import org.soulstone.overwatch.fusion.RssiTracker
|
||||||
|
import org.soulstone.overwatch.fusion.SourceHealth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
|
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
|
||||||
@@ -79,18 +80,30 @@ class BleScanner(
|
|||||||
if (running) return true
|
if (running) return true
|
||||||
if (!hasScanPermission()) {
|
if (!hasScanPermission()) {
|
||||||
Log.w(TAG, "BLE scan permission missing")
|
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
|
return false
|
||||||
}
|
}
|
||||||
val adapter = bluetoothAdapter ?: return false
|
|
||||||
if (!adapter.isEnabled) return false
|
|
||||||
leScanner = adapter.bluetoothLeScanner ?: return false
|
|
||||||
try {
|
try {
|
||||||
leScanner?.startScan(null, scanSettings, scanCallback)
|
leScanner?.startScan(null, scanSettings, scanCallback)
|
||||||
running = true
|
running = true
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = true)
|
||||||
Log.i(TAG, "BLE scan started")
|
Log.i(TAG, "BLE scan started")
|
||||||
return true
|
return true
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "SecurityException starting scan", e)
|
Log.e(TAG, "SecurityException starting scan", e)
|
||||||
|
SourceHealth.record(DetectionSource.BLE, ok = false, message = "Permission revoked")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +133,11 @@ class BleScanner(
|
|||||||
override fun onScanFailed(errorCode: Int) {
|
override fun onScanFailed(errorCode: Int) {
|
||||||
Log.e(TAG, "BLE scan failed: $errorCode")
|
Log.e(TAG, "BLE scan failed: $errorCode")
|
||||||
running = false
|
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 advertisedUuids = record?.serviceUuids?.map { it.uuid }
|
||||||
val mfgSpecific = record?.manufacturerSpecificData
|
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 companyId: Int? = null
|
||||||
var payload: ByteArray? = null
|
var payload: ByteArray? = null
|
||||||
if (mfgSpecific != null && mfgSpecific.size() > 0) {
|
if (mfgSpecific != null && mfgSpecific.size() > 0) {
|
||||||
companyId = mfgSpecific.keyAt(0)
|
for (i in 0 until mfgSpecific.size()) {
|
||||||
payload = mfgSpecific.valueAt(0)
|
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.
|
// Cheap pre-filter — drop devices that have zero target signals.
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ class CitizenClient {
|
|||||||
val arr = JSONObject(raw.body).optJSONArray("results")
|
val arr = JSONObject(raw.body).optJSONArray("results")
|
||||||
?: return@withContext TrendingResult.Success(emptyList())
|
?: return@withContext TrendingResult.Success(emptyList())
|
||||||
val out = ArrayList<String>(arr.length())
|
val out = ArrayList<String>(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)
|
TrendingResult.Success(out)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
TrendingResult.Failed("parse: ${e.message}")
|
TrendingResult.Failed("parse: ${e.message}")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.util.Log
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.soulstone.overwatch.data.location.LocationProvider
|
import org.soulstone.overwatch.data.location.LocationProvider
|
||||||
@@ -57,6 +58,9 @@ class CitizenScanner(
|
|||||||
fun start(scope: CoroutineScope): Boolean {
|
fun start(scope: CoroutineScope): Boolean {
|
||||||
if (job != null) return true
|
if (job != null) return true
|
||||||
job = scope.launch {
|
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) {
|
while (isActive) {
|
||||||
val fix = locationProvider.location.value
|
val fix = locationProvider.location.value
|
||||||
if (fix != null) pollOnce(fix)
|
if (fix != null) pollOnce(fix)
|
||||||
@@ -137,7 +141,9 @@ class CitizenScanner(
|
|||||||
label = scored.label,
|
label = scored.label,
|
||||||
score = scored.score,
|
score = scored.score,
|
||||||
matchedMethods = scored.methods,
|
matchedMethods = scored.methods,
|
||||||
rssi = null
|
rssi = null,
|
||||||
|
lat = incident.lat,
|
||||||
|
lon = incident.lon
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,15 @@ class DeflockClient(context: Context) {
|
|||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
if (code in 200..299) {
|
if (code in 200..299) {
|
||||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
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 {
|
} else {
|
||||||
Log.w(TAG, "$endpoint returned $code")
|
Log.w(TAG, "$endpoint returned $code")
|
||||||
null to "HTTP $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<AlprPoint> {
|
private fun parseSafely(json: String): List<AlprPoint> {
|
||||||
if (json.isBlank()) return emptyList()
|
if (json.isBlank()) return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ class DeflockScanner(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DeflockScanner"
|
private const val TAG = "DeflockScanner"
|
||||||
private const val REFETCH_THRESHOLD_M = 1500f
|
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 job: Job? = null
|
||||||
private var lastFetchLat: Double? = null
|
private var lastFetchLat: Double? = null
|
||||||
private var lastFetchLon: Double? = null
|
private var lastFetchLon: Double? = null
|
||||||
|
private var lastAttemptMs: Long = 0L
|
||||||
|
private var lastAttemptOk: Boolean = false
|
||||||
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
|
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
|
||||||
|
|
||||||
fun start(scope: CoroutineScope): Boolean {
|
fun start(scope: CoroutineScope): Boolean {
|
||||||
@@ -55,17 +59,23 @@ class DeflockScanner(
|
|||||||
job = null
|
job = null
|
||||||
lastFetchLat = null
|
lastFetchLat = null
|
||||||
lastFetchLon = null
|
lastFetchLon = null
|
||||||
|
lastAttemptMs = 0L
|
||||||
|
lastAttemptOk = false
|
||||||
cachedPoints = emptyList()
|
cachedPoints = emptyList()
|
||||||
Log.i(TAG, "DeflockScanner stopped")
|
Log.i(TAG, "DeflockScanner stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleFix(fix: Location) {
|
private suspend fun handleFix(fix: Location) {
|
||||||
if (shouldRefetch(fix)) {
|
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)) {
|
when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
|
||||||
is DeflockClient.FetchResult.Success -> {
|
is DeflockClient.FetchResult.Success -> {
|
||||||
cachedPoints = result.points
|
cachedPoints = result.points
|
||||||
lastFetchLat = fix.latitude
|
lastAttemptOk = true
|
||||||
lastFetchLon = fix.longitude
|
|
||||||
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
|
SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
@@ -74,6 +84,7 @@ class DeflockScanner(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is DeflockClient.FetchResult.Failed -> {
|
is DeflockClient.FetchResult.Failed -> {
|
||||||
|
lastAttemptOk = false
|
||||||
SourceHealth.record(
|
SourceHealth.record(
|
||||||
DetectionSource.DEFLOCK,
|
DetectionSource.DEFLOCK,
|
||||||
ok = false,
|
ok = false,
|
||||||
@@ -106,7 +117,9 @@ class DeflockScanner(
|
|||||||
label = scored.label,
|
label = scored.label,
|
||||||
score = scored.score,
|
score = scored.score,
|
||||||
matchedMethods = scored.methods,
|
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 {
|
private fun shouldRefetch(fix: Location): Boolean {
|
||||||
val lat = lastFetchLat ?: return true
|
val lat = lastFetchLat ?: return true
|
||||||
val lon = lastFetchLon ?: 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)
|
val out = FloatArray(1)
|
||||||
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
|
Location.distanceBetween(lat, lon, fix.latitude, fix.longitude, out)
|
||||||
return out[0] > REFETCH_THRESHOLD_M
|
return out[0] > REFETCH_THRESHOLD_M
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import org.soulstone.overwatch.fusion.DetectionEvent
|
|||||||
import org.soulstone.overwatch.fusion.DetectionSource
|
import org.soulstone.overwatch.fusion.DetectionSource
|
||||||
import org.soulstone.overwatch.fusion.DetectionStore
|
import org.soulstone.overwatch.fusion.DetectionStore
|
||||||
import org.soulstone.overwatch.fusion.RssiTracker
|
import org.soulstone.overwatch.fusion.RssiTracker
|
||||||
|
import org.soulstone.overwatch.fusion.SourceHealth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
|
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
|
||||||
@@ -86,15 +87,23 @@ class WifiScanner(
|
|||||||
if (running) return true
|
if (running) return true
|
||||||
if (!hasScanPermission()) {
|
if (!hasScanPermission()) {
|
||||||
Log.w(TAG, "WiFi scan permission missing")
|
Log.w(TAG, "WiFi scan permission missing")
|
||||||
|
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "Permission missing")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val mgr = wifiManager ?: run {
|
val mgr = wifiManager ?: run {
|
||||||
Log.w(TAG, "WifiManager unavailable")
|
Log.w(TAG, "WifiManager unavailable")
|
||||||
|
SourceHealth.record(DetectionSource.WIFI, ok = false, message = "WifiManager unavailable")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!mgr.isWifiEnabled) {
|
if (!mgr.isWifiEnabled) {
|
||||||
Log.w(TAG, "WiFi disabled — scanner won't return results")
|
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.
|
// We still register the receiver so results arrive when the user enables WiFi.
|
||||||
|
} else {
|
||||||
|
SourceHealth.record(DetectionSource.WIFI, ok = true)
|
||||||
}
|
}
|
||||||
registerReceiver()
|
registerReceiver()
|
||||||
running = true
|
running = true
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import android.content.Intent
|
|||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
@@ -18,13 +21,16 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.soulstone.overwatch.MainActivity
|
import org.soulstone.overwatch.MainActivity
|
||||||
import org.soulstone.overwatch.R
|
import org.soulstone.overwatch.R
|
||||||
import org.soulstone.overwatch.data.location.LocationProvider
|
import org.soulstone.overwatch.data.location.LocationProvider
|
||||||
import org.soulstone.overwatch.data.settings.Settings
|
import org.soulstone.overwatch.data.settings.Settings
|
||||||
|
import org.soulstone.overwatch.fusion.DetectionEvent
|
||||||
import org.soulstone.overwatch.fusion.DetectionStore
|
import org.soulstone.overwatch.fusion.DetectionStore
|
||||||
import org.soulstone.overwatch.fusion.SourceHealth
|
import org.soulstone.overwatch.fusion.SourceHealth
|
||||||
|
import org.soulstone.overwatch.fusion.ThreatLevel
|
||||||
import org.soulstone.overwatch.scan.BleScanner
|
import org.soulstone.overwatch.scan.BleScanner
|
||||||
import org.soulstone.overwatch.scan.CitizenScanner
|
import org.soulstone.overwatch.scan.CitizenScanner
|
||||||
import org.soulstone.overwatch.scan.DeflockClient
|
import org.soulstone.overwatch.scan.DeflockClient
|
||||||
@@ -32,12 +38,18 @@ import org.soulstone.overwatch.scan.DeflockScanner
|
|||||||
import org.soulstone.overwatch.scan.WifiScanner
|
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
|
* Returns START_NOT_STICKY so a system-killed service does not auto-restart
|
||||||
* companion-object state flows directly, which is what we do here for simplicity).
|
* 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() {
|
class DetectionService : LifecycleService() {
|
||||||
|
|
||||||
@@ -81,10 +93,13 @@ class DetectionService : LifecycleService() {
|
|||||||
private lateinit var deflockScanner: DeflockScanner
|
private lateinit var deflockScanner: DeflockScanner
|
||||||
private lateinit var citizenScanner: CitizenScanner
|
private lateinit var citizenScanner: CitizenScanner
|
||||||
private var pruneJob: Job? = null
|
private var pruneJob: Job? = null
|
||||||
|
private var observerJob: Job? = null
|
||||||
private var bleStarted = false
|
private var bleStarted = false
|
||||||
private var wifiStarted = false
|
private var wifiStarted = false
|
||||||
private var deflockStarted = false
|
private var deflockStarted = false
|
||||||
private var citizenStarted = 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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -112,13 +127,17 @@ class DetectionService : LifecycleService() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun beginScanning() {
|
private fun beginScanning() {
|
||||||
if (_running.value) return
|
if (_running.value) return
|
||||||
SourceHealth.reset()
|
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) {
|
if (settings.bleEnabled.value) {
|
||||||
bleStarted = bleScanner.start()
|
bleStarted = bleScanner.start()
|
||||||
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
|
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
|
||||||
@@ -127,8 +146,7 @@ class DetectionService : LifecycleService() {
|
|||||||
wifiStarted = wifiScanner.start(lifecycleScope)
|
wifiStarted = wifiScanner.start(lifecycleScope)
|
||||||
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
|
||||||
}
|
}
|
||||||
val needsLocation = settings.deflockEnabled.value ||
|
val needsLocation = settings.deflockEnabled.value || settings.citizenEnabled.value
|
||||||
settings.citizenEnabled.value
|
|
||||||
if (needsLocation) {
|
if (needsLocation) {
|
||||||
val locOk = locationProvider.start()
|
val locOk = locationProvider.start()
|
||||||
if (!locOk) {
|
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
|
_running.value = true
|
||||||
pruneJob?.cancel()
|
pruneJob?.cancel()
|
||||||
pruneJob = lifecycleScope.launch {
|
pruneJob = lifecycleScope.launch {
|
||||||
@@ -150,10 +177,23 @@ class DetectionService : LifecycleService() {
|
|||||||
store.pruneExpired()
|
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() {
|
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 (bleStarted) { bleScanner.stop(); bleStarted = false }
|
||||||
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
|
||||||
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
|
||||||
@@ -161,9 +201,9 @@ class DetectionService : LifecycleService() {
|
|||||||
locationProvider.stop()
|
locationProvider.stop()
|
||||||
store.clear()
|
store.clear()
|
||||||
SourceHealth.reset()
|
SourceHealth.reset()
|
||||||
pruneJob?.cancel()
|
pruneJob?.cancel(); pruneJob = null
|
||||||
pruneJob = null
|
observerJob?.cancel(); observerJob = null
|
||||||
_running.value = false
|
lastNotifiedTier = ThreatLevel.GREEN
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
} else {
|
} else {
|
||||||
@@ -182,12 +222,46 @@ class DetectionService : LifecycleService() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startInForeground() {
|
private fun onTierChanged(tier: ThreatLevel, top: DetectionEvent?) {
|
||||||
val notification = buildNotification()
|
// 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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
// Android 14+ requires the runtime type to cover every capability
|
// Android 14+ requires the runtime type to cover every capability the
|
||||||
// the service uses. We declare both in the manifest; pass both here
|
// service uses. We declare both in the manifest; pass both here so
|
||||||
// so location-using sources (DeFlock, Waze) keep working with the
|
// location-using sources (DeFlock, Citizen) keep working with the
|
||||||
// screen off.
|
// screen off.
|
||||||
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
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 {
|
val openIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
@@ -205,14 +279,26 @@ class DetectionService : LifecycleService() {
|
|||||||
this, 0, openIntent,
|
this, 0, openIntent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
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)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle(getString(R.string.notification_title))
|
.setContentTitle(title)
|
||||||
.setContentText(getString(R.string.notification_text))
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setContentIntent(pi)
|
.setContentIntent(pi)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(priority)
|
||||||
|
.setOnlyAlertOnce(false)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -36,8 +40,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -65,11 +68,12 @@ fun MainScreen(
|
|||||||
onStartStop: () -> Unit,
|
onStartStop: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
canStart: Boolean,
|
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 sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val sheetScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -159,6 +163,24 @@ fun MainScreen(
|
|||||||
fontSize = 13.sp
|
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
|
@Composable
|
||||||
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
|
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.GREEN -> ThreatColors.Green
|
||||||
ThreatLevel.YELLOW -> ThreatColors.Yellow
|
ThreatLevel.YELLOW -> ThreatColors.Yellow
|
||||||
ThreatLevel.ORANGE -> ThreatColors.Orange
|
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||||
ThreatLevel.RED -> ThreatColors.Red
|
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 transition = rememberInfiniteTransition(label = "pulse")
|
||||||
val pulse by transition.animateFloat(
|
val pulse by transition.animateFloat(
|
||||||
initialValue = if (animating) 0.6f else 1.0f,
|
initialValue = if (animating) 0.6f else 1.0f,
|
||||||
@@ -220,8 +250,8 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = level.name,
|
text = labelText,
|
||||||
color = Color.White,
|
color = labelColor,
|
||||||
fontSize = 28.sp,
|
fontSize = 28.sp,
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
@@ -329,14 +359,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
events.take(3).forEach { e ->
|
events.take(3).forEach { e -> EventRow(e) }
|
||||||
Text(
|
|
||||||
text = "${e.score} • ${e.label} • ${e.matchedMethods}",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (events.size > 3) {
|
if (events.size > 3) {
|
||||||
Text(
|
Text(
|
||||||
text = "+${events.size - 3} more",
|
text = "+${events.size - 3} more",
|
||||||
@@ -348,3 +371,40 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -49,6 +52,7 @@ fun SettingsScreen(
|
|||||||
val deflockProx by settings.deflockProximityM.collectAsState()
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
val citizenProx by settings.citizenProximityM.collectAsState()
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
val theme by settings.themeMode.collectAsState()
|
val theme by settings.themeMode.collectAsState()
|
||||||
|
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -107,21 +111,23 @@ fun SettingsScreen(
|
|||||||
SectionLabel("Proximity thresholds")
|
SectionLabel("Proximity thresholds")
|
||||||
SliderRow(
|
SliderRow(
|
||||||
label = "DeFlock alert distance",
|
label = "DeFlock alert distance",
|
||||||
valueLabel = "${deflockProx} m",
|
persistedValue = deflockProx,
|
||||||
value = deflockProx.toFloat(),
|
|
||||||
range = 50f..1600f,
|
range = 50f..1600f,
|
||||||
steps = 30,
|
steps = 30,
|
||||||
onChange = { settings.setDeflockProximityM(it.toInt()) }
|
onCommit = { settings.setDeflockProximityM(it) }
|
||||||
)
|
)
|
||||||
SliderRow(
|
SliderRow(
|
||||||
label = "Citizen alert distance",
|
label = "Citizen alert distance",
|
||||||
valueLabel = "${citizenProx} m",
|
persistedValue = citizenProx,
|
||||||
value = citizenProx.toFloat(),
|
|
||||||
range = 100f..5000f,
|
range = 100f..5000f,
|
||||||
steps = 48,
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionLabel("Appearance")
|
SectionLabel("Appearance")
|
||||||
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
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
|
@Composable
|
||||||
private fun SliderRow(
|
private fun SliderRow(
|
||||||
label: String,
|
label: String,
|
||||||
valueLabel: String,
|
persistedValue: Int,
|
||||||
value: Float,
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
range: ClosedFloatingPointRange<Float>,
|
||||||
steps: Int,
|
steps: Int,
|
||||||
onChange: (Float) -> Unit
|
onCommit: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
|
var live by remember(persistedValue) { mutableFloatStateOf(persistedValue.toFloat()) }
|
||||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -190,15 +201,16 @@ private fun SliderRow(
|
|||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = valueLabel,
|
text = "${live.toInt()} m",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(
|
Slider(
|
||||||
value = value,
|
value = live,
|
||||||
onValueChange = onChange,
|
onValueChange = { live = it },
|
||||||
|
onValueChangeFinished = { onCommit(live.toInt()) },
|
||||||
valueRange = range,
|
valueRange = range,
|
||||||
steps = steps
|
steps = steps
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user