Add OVERWATCH v0.1.0 — full detection engine + polish

Phase 1 (BLE), Phase 2 (WiFi BSSID/SSID), Phase 3 (DeFlock map proximity),
Phase 4 (Waze live POLICE alerts), and Phase 5 polish all wired through one
DetectionStore. Confidence engine scores 0-100; UI maps to 4-tier circle.

Polish:
  - Stylized two-line app title: [DЯΣΛMMΛKΣЯ] // 0VΣЯW4TCH
  - Modal bottom sheet for source drill-down (tap circle)
  - Settings screen: per-source toggles, proximity sliders, theme select
  - SharedPreferences-backed Settings with StateFlow exposure
  - DetectionService respects per-source toggles at start time
  - Scanners read proximity overrides via supplier lambdas

README documents all sources, architecture, build steps, permissions, and
the legal disclaimer.
This commit is contained in:
2026-04-28 21:10:57 -04:00
parent 1e195605df
commit 3574970a5f
45 changed files with 3283 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "org.soulstone.overwatch"
compileSdk = 35
defaultConfig {
applicationId = "org.soulstone.overwatch"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.location)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Default ProGuard config for OVERWATCH.
# No custom rules yet release build keeps minify off.
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE (Android 12+) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE legacy (Android 11 and below) -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Location (required for WiFi scan results pre-Android 13, and DeFlock proximity in Phase 3) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- WiFi (Phase 2) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="t" />
<!-- Networking (Phase 3 + 4) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Overwatch"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Overwatch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.DetectionService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice|location" />
</application>
</manifest>
@@ -0,0 +1,111 @@
package org.soulstone.overwatch
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
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.setValue
import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.service.DetectionService
import org.soulstone.overwatch.ui.MainScreen
import org.soulstone.overwatch.ui.SettingsScreen
import org.soulstone.overwatch.ui.theme.OverwatchTheme
class MainActivity : ComponentActivity() {
private val requiredPermissions: Array<String>
get() = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.BLUETOOTH)
add(Manifest.permission.BLUETOOTH_ADMIN)
}
// Location is needed pre-S for BLE, pre-T for WiFi scan results,
// and for Phase 3 DeFlock proximity.
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.NEARBY_WIFI_DEVICES)
add(Manifest.permission.POST_NOTIFICATIONS)
}
}.toTypedArray()
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
permissionsGranted.value = result.all { it.value }
}
private val permissionsGranted = androidx.compose.runtime.mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
permissionsGranted.value = checkAllPermissions()
val settings = Settings.get(this)
setContent {
val themeMode by settings.themeMode.collectAsState()
OverwatchTheme(mode = themeMode) {
var screen by remember { mutableStateOf(Screen.MAIN) }
when (screen) {
Screen.MAIN -> {
val running by DetectionService.running.collectAsState()
val events by DetectionService.store.events.collectAsState()
val threat by DetectionService.store.threatLevel.collectAsState()
val maxScore by DetectionService.store.maxScore.collectAsState()
val granted by permissionsGranted
MainScreen(
running = running,
threat = threat,
score = maxScore,
events = events,
canStart = granted || running,
permissionMessage = if (!granted) "Bluetooth, WiFi + location permissions required" else null,
onStartStop = {
if (running) {
DetectionService.stop(this)
} else {
if (granted) {
DetectionService.start(this)
} else {
permissionLauncher.launch(requiredPermissions)
}
}
},
onOpenSettings = { screen = Screen.SETTINGS }
)
}
Screen.SETTINGS -> {
SettingsScreen(
settings = settings,
onBack = { screen = Screen.MAIN }
)
}
}
}
}
}
override fun onResume() {
super.onResume()
permissionsGranted.value = checkAllPermissions()
}
private fun checkAllPermissions(): Boolean =
requiredPermissions.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
private enum class Screen { MAIN, SETTINGS }
}
@@ -0,0 +1,89 @@
package org.soulstone.overwatch.data.location
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Wraps [FusedLocationProviderClient] and exposes the latest fix as a [StateFlow].
*
* Update cadence: 15s desired, 5s minimum, BALANCED_POWER_ACCURACY (≈100m precision —
* good enough for ALPR proximity alerts ≤200m, easy on battery).
*/
class LocationProvider(private val context: Context) {
companion object {
private const val TAG = "LocationProvider"
private const val INTERVAL_MS = 15_000L
private const val MIN_INTERVAL_MS = 5_000L
}
private val client: FusedLocationProviderClient by lazy {
LocationServices.getFusedLocationProviderClient(context)
}
private val _location = MutableStateFlow<Location?>(null)
val location: StateFlow<Location?> = _location.asStateFlow()
private val request: LocationRequest = LocationRequest.Builder(
Priority.PRIORITY_BALANCED_POWER_ACCURACY,
INTERVAL_MS
)
.setMinUpdateIntervalMillis(MIN_INTERVAL_MS)
.setWaitForAccurateLocation(false)
.build()
private val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val fix = result.lastLocation ?: return
_location.value = fix
}
}
private var running = false
fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running) return true
if (!hasPermission()) {
Log.w(TAG, "ACCESS_FINE_LOCATION not granted")
return false
}
try {
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
client.lastLocation.addOnSuccessListener { last -> if (last != null) _location.value = last }
running = true
Log.i(TAG, "Location updates started")
return true
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting location updates", e)
return false
}
}
fun stop() {
if (!running) return
client.removeLocationUpdates(callback)
running = false
_location.value = null
Log.i(TAG, "Location updates stopped")
}
}
@@ -0,0 +1,93 @@
package org.soulstone.overwatch.data.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* App-wide user preferences. Backed by SharedPreferences (no DataStore dep).
*
* Each preference is exposed as a [StateFlow] for Compose to observe; mutators
* write through to disk and update the flow synchronously.
*
* Per-source toggles only take effect at the next Start cycle — flipping a
* source while scanning will NOT live-restart that scanner.
*/
class Settings private constructor(private val prefs: SharedPreferences) {
enum class ThemeMode { SYSTEM, DARK, LIGHT }
private val _bleEnabled = MutableStateFlow(prefs.getBoolean(KEY_BLE, true))
val bleEnabled: StateFlow<Boolean> = _bleEnabled.asStateFlow()
private val _wifiEnabled = MutableStateFlow(prefs.getBoolean(KEY_WIFI, true))
val wifiEnabled: StateFlow<Boolean> = _wifiEnabled.asStateFlow()
private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true))
val deflockEnabled: StateFlow<Boolean> = _deflockEnabled.asStateFlow()
private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true))
val wazeEnabled: StateFlow<Boolean> = _wazeEnabled.asStateFlow()
private val _deflockProximityM = MutableStateFlow(
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX)
)
val deflockProximityM: StateFlow<Int> = _deflockProximityM.asStateFlow()
private val _wazeProximityM = MutableStateFlow(
prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX)
)
val wazeProximityM: StateFlow<Int> = _wazeProximityM.asStateFlow()
private val _themeMode = MutableStateFlow(
ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name)
)
val themeMode: StateFlow<ThemeMode> = _themeMode.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 }
fun setWazeEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WAZE, v) }; _wazeEnabled.value = v }
fun setDeflockProximityM(v: Int) {
val clamped = v.coerceIn(50, 1600)
prefs.edit { putInt(KEY_DEFLOCK_PROX, clamped) }
_deflockProximityM.value = clamped
}
fun setWazeProximityM(v: Int) {
val clamped = v.coerceIn(100, 5000)
prefs.edit { putInt(KEY_WAZE_PROX, clamped) }
_wazeProximityM.value = clamped
}
fun setThemeMode(mode: ThemeMode) {
prefs.edit { putString(KEY_THEME, mode.name) }
_themeMode.value = mode
}
companion object {
private const val PREFS = "overwatch_settings"
private const val KEY_BLE = "src_ble"
private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_WAZE = "src_waze"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_WAZE_PROX = "waze_proximity_m"
private const val KEY_THEME = "theme_mode"
const val DEFAULT_DEFLOCK_PROX = 200
const val DEFAULT_WAZE_PROX = 500
@Volatile private var INSTANCE: Settings? = null
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
INSTANCE ?: Settings(
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
).also { INSTANCE = it }
}
}
}
@@ -0,0 +1,36 @@
package org.soulstone.overwatch.data.targets
/**
* Known BLE-bearing surveillance equipment OUI prefixes (first 3 octets of MAC).
*
* Sources:
* - flock-detection (24 prefixes — Flock, LiteOn, Cradlepoint, Murata, Espressif, Penguin BLE)
* - AxonCadabra (Axon body cam manufacturer prefix 00:25:DF)
*
* Match by lowercased "xx:xx:xx" prefix on the device MAC.
*/
object BleOuis {
/** Axon body cameras / dash cams (high-confidence target — flagged separately). */
const val AXON = "00:25:df"
/** All known BLE-emitting surveillance OUIs, including Axon. */
val ALL: Set<String> = setOf(
AXON,
// From flock-detection (24 prefixes — covers Flock direct + supply chain vendors)
"58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "f0:82:c0",
"1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", "3c:91:80",
"d8:f3:bc", "80:30:49", "14:5a:fc", "9c:2f:9d", "94:08:53",
"e4:aa:ea", "48:e7:29", "c8:c9:a3", "74:4c:a1", "70:c9:4e",
"04:0d:84", "08:3a:88", "a4:cf:12", "d8:a0:d8"
)
fun matches(mac: String): Boolean {
val lower = mac.lowercase()
if (lower.length < 8) return false
val prefix = lower.substring(0, 8)
return prefix in ALL
}
fun isAxon(mac: String): Boolean = mac.lowercase().startsWith(AXON)
}
@@ -0,0 +1,29 @@
package org.soulstone.overwatch.data.targets
/**
* BLE manufacturer-specific data signatures.
*
* Source: flock-detection.
* - Company ID 0x09C8 (XUNTONG, Raven manufacturer): score 60.
* - "TN" ASCII prefix in payload (Penguin/Flock TN serial e.g. TN72023022000771): +20.
*/
object Manufacturers {
const val XUNTONG_COMPANY_ID = 0x09C8
fun hasTnSerial(payload: ByteArray?): Boolean {
if (payload == null || payload.size < 2) return false
// Look for "TN" anywhere in the first ~20 bytes (post-header)
val limit = minOf(payload.size - 1, 20)
for (i in 0..limit) {
if (payload[i] == 'T'.code.toByte() && payload[i + 1] == 'N'.code.toByte()) {
// Followed by digits = high-confidence Penguin/Flock serial
if (i + 2 < payload.size) {
val c = payload[i + 2].toInt().toChar()
if (c in '0'..'9') return true
}
}
}
return false
}
}
@@ -0,0 +1,48 @@
package org.soulstone.overwatch.data.targets
/**
* String-pattern signatures: BLE local names + WiFi SSIDs.
* Sources: flock-detection (BLE names + SSID generic), flock-you (specific Flock-XXXX format).
*/
object Patterns {
/** BLE advertised local names that flag a target. Case-sensitive substring match. */
val BLE_NAME_PATTERNS: List<String> = listOf(
"FS Ext Battery",
"Penguin",
"Flock",
"Pigvision",
"FlockCam",
"FS-"
)
/** Generic SSID substrings (case-insensitive). */
val SSID_GENERIC: List<String> = listOf(
"flock", "FS_", "Penguin", "Pigvision", "FlockOS", "flocksafety", "FS Ext Battery"
)
/** Specific Flock SSID format: "Flock-" followed by exactly 4 hex digits. */
val SSID_FLOCK_REGEX = Regex("^Flock-[0-9A-Fa-f]{4}$")
fun bleNameMatch(name: String?): Boolean {
if (name.isNullOrBlank()) return false
return BLE_NAME_PATTERNS.any { name.contains(it, ignoreCase = false) }
}
/** Penguin post-March-2025 firmware advertises a bare 8-12 digit decimal ID. */
fun isPenguinNumeric(name: String?): Boolean {
if (name.isNullOrBlank()) return false
if (name.length !in 8..12) return false
return name.all { it.isDigit() }
}
fun ssidGenericMatch(ssid: String?): Boolean {
if (ssid.isNullOrBlank()) return false
return SSID_GENERIC.any { ssid.contains(it, ignoreCase = true) }
}
fun ssidFlockFormat(ssid: String?): Boolean {
if (ssid.isNullOrBlank()) return false
return SSID_FLOCK_REGEX.matches(ssid)
}
}
@@ -0,0 +1,32 @@
package org.soulstone.overwatch.data.targets
import java.util.UUID
/**
* Raven gunshot detector BLE service UUIDs across firmware revisions.
* Source: flock-detection (8 UUIDs spanning FW 1.1.x, 1.2.x, 1.3.x).
*
* 1+ UUID match: confidence 70. 3+ UUIDs match: confidence 90.
*/
object RavenUuids {
/** 16-bit service UUIDs expanded to full 128-bit form. */
val ALL: Set<UUID> = setOf(
uuid16("180a"), // Device Information (all FW)
uuid16("3100"), // GPS Location (1.2.x+)
uuid16("3200"), // Power Management (1.2.x+)
uuid16("3300"), // Network Status (1.2.x+)
uuid16("3400"), // Upload Statistics (1.3.x)
uuid16("3500"), // Error Diagnostics (1.3.x)
uuid16("1809"), // Health Thermometer (1.1.x)
uuid16("1819") // Location & Navigation (1.1.x)
)
fun countMatches(advertisedUuids: List<UUID>?): Int {
if (advertisedUuids.isNullOrEmpty()) return 0
return advertisedUuids.count { it in ALL }
}
private fun uuid16(short: String): UUID =
UUID.fromString("0000$short-0000-1000-8000-00805f9b34fb")
}
@@ -0,0 +1,29 @@
package org.soulstone.overwatch.data.targets
/**
* WiFi BSSID OUI prefixes for Flock Safety infrastructure.
*
* 31-prefix superset from flock-you (research by NitekryDPaul + DeFlockJoplin),
* plus the overlap with flock-detection's 24-prefix list.
*
* Note: Android's WifiManager only exposes BSSID; the addr1 / wildcard-probe
* tricks from flock-you's promiscuous mode aren't accessible — match BSSID only.
*/
object WifiOuis {
val ALL: Set<String> = setOf(
"70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "b8:35:32",
"14:5a:fc", "74:4c:a1", "08:3a:88", "9c:2f:9d", "c0:35:32",
"94:08:53", "e4:aa:ea", "f4:6a:dd", "f8:a2:d6", "24:b2:b9",
"00:f4:8d", "d0:39:57", "e8:d0:fc", "e0:4f:43", "b8:1e:a4",
"70:08:94", "58:8e:81", "ec:1b:bd", "3c:71:bf", "58:00:e3",
"90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12",
"82:6b:f2"
)
fun matches(bssid: String): Boolean {
val lower = bssid.lowercase()
if (lower.length < 8) return false
return lower.substring(0, 8) in ALL
}
}
@@ -0,0 +1,241 @@
package org.soulstone.overwatch.fusion
/**
* Confidence scoring — port of flock-detection's algorithm with weights from the OVERWATCH plan.
*
* One [BleObservation] (a single ScanResult) → one score. Multi-method bonus and RSSI bonuses
* apply within a single observation. Cross-source corroboration is handled at the [DetectionStore]
* level (multiple sources hitting the same area push the global max upward).
*/
object ConfidenceEngine {
// Single-method base weights (BLE)
const val W_BLE_OUI = 40
const val W_BLE_OUI_AXON = 80
const val W_BLE_NAME = 45
const val W_BLE_NAME_PENGUIN_NUMERIC = 15
const val W_BLE_MFG_XUNTONG = 60
const val W_BLE_TN_SERIAL_BONUS = 20 // added on top of mfg
const val W_BLE_RAVEN_UUID = 70
const val W_BLE_RAVEN_UUID_MULTI = 90 // 3+ UUIDs
// Single-method base weights (WiFi — wired in Phase 2)
const val W_WIFI_OUI = 40
const val W_WIFI_SSID_GENERIC = 50
const val W_WIFI_SSID_FLOCK_FMT = 65
// Map / Waze (Phase 3 + 4)
const val W_DEFLOCK_NEAR = 60 // <= 200m
const val W_DEFLOCK_VERY_NEAR = 85 // <= 50m
const val W_WAZE_POLICE = 55
// Bonuses
const val B_MULTI_METHOD = 20
const val B_STRONG_RSSI = 10 // > -50 dBm
const val B_STATIONARY = 15 // RSSI rise-peak-fall
/** What we observed about one BLE device on a single scan callback. */
data class BleObservation(
val mac: String,
val rssi: Int,
val deviceName: String?,
val advertisedUuids: List<java.util.UUID>?,
val manufacturerCompanyId: Int?,
val manufacturerPayload: ByteArray?,
val isStationary: Boolean = false
)
/** What we observed about one WiFi AP on a single scan result. */
data class WifiObservation(
val bssid: String,
val ssid: String?,
val rssi: Int,
val isStationary: Boolean = false
)
/** A DeFlock map ALPR observed within proximity threshold. */
data class DeflockObservation(
val osmId: Long,
val distanceMeters: Float,
val operator: String?,
val manufacturer: String?
)
/** A Waze POLICE alert observed within proximity + freshness thresholds. */
data class WazeObservation(
val uuid: String,
val distanceMeters: Float,
val ageMs: Long,
val confidence: Int, // raw 0-5
val reliability: Int, // raw 0-10
val subtype: String?
)
data class Scored(
val score: Int,
val methods: String,
val label: String,
/** True if the BLE OUI specifically matched Axon (drives the "Axon body cam" labeling). */
val isAxon: Boolean
)
fun scoreBle(obs: BleObservation): Scored {
var score = 0
val methods = StringBuilder()
var methodCount = 0
var ouiHit = false
var nameHit = false
var mfgHit = false
var ravenHit = false
var isAxon = false
// OUI prefix
if (org.soulstone.overwatch.data.targets.BleOuis.isAxon(obs.mac)) {
score += W_BLE_OUI_AXON
methods.append("axon_oui ")
ouiHit = true; isAxon = true
} else if (org.soulstone.overwatch.data.targets.BleOuis.matches(obs.mac)) {
score += W_BLE_OUI
methods.append("oui ")
ouiHit = true
}
if (ouiHit) methodCount++
// Device name patterns
if (org.soulstone.overwatch.data.targets.Patterns.bleNameMatch(obs.deviceName)) {
score += W_BLE_NAME
methods.append("name ")
nameHit = true
} else if (org.soulstone.overwatch.data.targets.Patterns.isPenguinNumeric(obs.deviceName)) {
score += W_BLE_NAME_PENGUIN_NUMERIC
methods.append("penguin_num ")
nameHit = true
}
if (nameHit) methodCount++
// Manufacturer-data signature
if (obs.manufacturerCompanyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID) {
score += W_BLE_MFG_XUNTONG
methods.append("mfg_0x09C8 ")
mfgHit = true
if (org.soulstone.overwatch.data.targets.Manufacturers.hasTnSerial(obs.manufacturerPayload)) {
score += W_BLE_TN_SERIAL_BONUS
methods.append("tn_serial ")
}
}
if (mfgHit) methodCount++
// Raven service UUIDs
val ravenCount = org.soulstone.overwatch.data.targets.RavenUuids.countMatches(obs.advertisedUuids)
if (ravenCount > 0) {
if (ravenCount >= 3) {
score += W_BLE_RAVEN_UUID_MULTI
methods.append("raven_multi ")
} else {
score += W_BLE_RAVEN_UUID
methods.append("raven_uuid ")
}
ravenHit = true
methodCount++
}
// Multi-method corroboration bonus
if (methodCount >= 2) {
score += B_MULTI_METHOD
methods.append("multi ")
}
// Strong RSSI (very close)
if (obs.rssi > -50) {
score += B_STRONG_RSSI
methods.append("strong_rssi ")
}
// Stationary RSSI trend
if (obs.isStationary) {
score += B_STATIONARY
methods.append("stationary ")
}
score = score.coerceAtMost(100)
val label = when {
isAxon -> "Axon body cam (${obs.mac})"
ravenHit -> "Raven gunshot detector (${obs.mac})"
!obs.deviceName.isNullOrBlank() -> "${obs.deviceName} (${obs.mac})"
else -> "Surveillance BLE (${obs.mac})"
}
return Scored(score, methods.toString().trim(), label, isAxon)
}
fun scoreWaze(obs: WazeObservation): Scored {
// Plan baseline: 55 for any POLICE alert ≤500m & <10min old.
// Caller is responsible for applying the proximity + age gate before scoring.
var score = W_WAZE_POLICE
// Lightweight crowd-trust nudge: high reliability & high confidence each add a few points,
// capped well under the multi-method bonus so a corroborating BLE/WiFi hit still dominates.
if (obs.reliability >= 7) score += 5
if (obs.confidence >= 4) score += 5
score = score.coerceAtMost(100)
val methods = "waze_police rel=${obs.reliability} conf=${obs.confidence}"
val ageMin = (obs.ageMs / 60_000L).toInt()
val sub = obs.subtype?.let { " ($it)" } ?: ""
val label = "Police report$sub @ ${obs.distanceMeters.toInt()}m, ${ageMin}min ago"
return Scored(score, methods, label, isAxon = false)
}
fun scoreDeflock(obs: DeflockObservation): Scored {
val score = if (obs.distanceMeters <= 50f) W_DEFLOCK_VERY_NEAR else W_DEFLOCK_NEAR
val rangeTag = if (obs.distanceMeters <= 50f) "deflock<=50m" else "deflock<=200m"
val descriptor = listOfNotNull(obs.manufacturer, obs.operator)
.joinToString(" / ").ifBlank { "ALPR" }
val label = "%s @ %dm (osm:%d)".format(descriptor, obs.distanceMeters.toInt(), obs.osmId)
return Scored(score, rangeTag, label, isAxon = false)
}
fun scoreWifi(obs: WifiObservation): Scored {
var score = 0
val methods = StringBuilder()
var methodCount = 0
val ouiHit = org.soulstone.overwatch.data.targets.WifiOuis.matches(obs.bssid)
if (ouiHit) {
score += W_WIFI_OUI
methods.append("oui ")
methodCount++
}
var ssidHit = false
if (org.soulstone.overwatch.data.targets.Patterns.ssidFlockFormat(obs.ssid)) {
score += W_WIFI_SSID_FLOCK_FMT
methods.append("ssid_flock ")
ssidHit = true
} else if (org.soulstone.overwatch.data.targets.Patterns.ssidGenericMatch(obs.ssid)) {
score += W_WIFI_SSID_GENERIC
methods.append("ssid_generic ")
ssidHit = true
}
if (ssidHit) methodCount++
if (methodCount >= 2) {
score += B_MULTI_METHOD
methods.append("multi ")
}
if (obs.rssi > -50) {
score += B_STRONG_RSSI
methods.append("strong_rssi ")
}
if (obs.isStationary) {
score += B_STATIONARY
methods.append("stationary ")
}
score = score.coerceAtMost(100)
val label = if (!obs.ssid.isNullOrBlank()) "${obs.ssid} (${obs.bssid})"
else "Surveillance WiFi (${obs.bssid})"
return Scored(score, methods.toString().trim(), label, isAxon = false)
}
}
@@ -0,0 +1,24 @@
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 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 timestampMs wall-clock millis when this event was produced
*/
data class DetectionEvent(
val source: DetectionSource,
val key: String,
val label: String,
val score: Int,
val matchedMethods: String,
val rssi: Int? = null,
val timestampMs: Long = System.currentTimeMillis()
) {
val level: ThreatLevel get() = ThreatLevel.fromScore(score)
}
@@ -0,0 +1,62 @@
package org.soulstone.overwatch.fusion
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* In-memory hub for detection events.
*
* - Keeps the most recent event per (source, key) — newer overwrites older.
* - Drops events older than [retentionMs] (default 5 min, mirrors flock-detection's dedup window).
* - Exposes [threatLevel] = the worst tier across all live events.
* - No persistence: per the user's spec, no detection-history DB.
*/
class DetectionStore(
private val retentionMs: Long = 5 * 60 * 1000L,
private val nowMs: () -> Long = System::currentTimeMillis
) {
private val _events = MutableStateFlow<List<DetectionEvent>>(emptyList())
val events: StateFlow<List<DetectionEvent>> = _events.asStateFlow()
private val _threatLevel = MutableStateFlow(ThreatLevel.GREEN)
val threatLevel: StateFlow<ThreatLevel> = _threatLevel.asStateFlow()
private val _maxScore = MutableStateFlow(0)
val maxScore: StateFlow<Int> = _maxScore.asStateFlow()
@Synchronized
fun submit(event: DetectionEvent) {
val cutoff = nowMs() - retentionMs
val merged = (_events.value + event)
.filter { it.timestampMs >= cutoff }
.groupBy { it.source to it.key }
.map { (_, list) -> list.maxByOrNull { it.timestampMs }!! }
.sortedByDescending { it.score }
_events.value = merged
recompute(merged)
}
@Synchronized
fun clear() {
_events.value = emptyList()
_threatLevel.value = ThreatLevel.GREEN
_maxScore.value = 0
}
@Synchronized
fun pruneExpired() {
val cutoff = nowMs() - retentionMs
val live = _events.value.filter { it.timestampMs >= cutoff }
if (live.size != _events.value.size) {
_events.value = live
recompute(live)
}
}
private fun recompute(live: List<DetectionEvent>) {
val max = live.maxOfOrNull { it.score } ?: 0
_maxScore.value = max
_threatLevel.value = ThreatLevel.fromScore(max)
}
}
@@ -0,0 +1,41 @@
package org.soulstone.overwatch.fusion
/**
* Tracks RSSI samples per device to detect a stationary signature.
*
* Ported from flock-detection (rssi_track_update / rssi_track_is_stationary):
* a fixed-installation camera produces a rise → peak → fall pattern as the
* observer walks past it. A handheld emitter (phone, etc.) does not.
*
* Algorithm:
* - Keep up to [windowSize] recent samples per key.
* - Find peak index. Stationary if peak is NOT at the edge AND
* range (peak - min(first, last)) >= [minRangeDb].
*/
class RssiTracker(
private val windowSize: Int = 15,
private val minRangeDb: Int = 6
) {
private val samples: MutableMap<String, ArrayDeque<Int>> = mutableMapOf()
@Synchronized
fun update(key: String, rssi: Int) {
val deque = samples.getOrPut(key) { ArrayDeque() }
deque.addLast(rssi)
while (deque.size > windowSize) deque.removeFirst()
}
@Synchronized
fun isStationary(key: String): Boolean {
val s = samples[key] ?: return false
if (s.size < 3) return false
val list = s.toList()
val peakIdx = list.indices.maxByOrNull { list[it] } ?: return false
if (peakIdx == 0 || peakIdx == list.lastIndex) return false
val edgeMin = minOf(list.first(), list.last())
return (list[peakIdx] - edgeMin) >= minRangeDb
}
@Synchronized
fun clear() = samples.clear()
}
@@ -0,0 +1,24 @@
package org.soulstone.overwatch.fusion
/**
* 4-tier threat classification. Maps directly to the green/yellow/orange/red UI circle.
* Thresholds ported from flock-detection's CONFIDENCE_* constants.
*/
enum class ThreatLevel(val minScore: Int) {
GREEN(0), // < 40 — nothing credible
YELLOW(40), // 40-69 — single weak indicator
ORANGE(70), // 70-84 — high confidence
RED(85); // 85+ — certain
companion object {
fun fromScore(score: Int): ThreatLevel = when {
score >= RED.minScore -> RED
score >= ORANGE.minScore -> ORANGE
score >= YELLOW.minScore -> YELLOW
else -> GREEN
}
}
}
/** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, WAZE }
@@ -0,0 +1,174 @@
package org.soulstone.overwatch.scan
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.targets.BleOuis
import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.RavenUuids
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker
/**
* BLE scanner — ported from AxonCadabra (scan side only; no advertise/fuzz).
*
* Strategy:
* - Run a low-latency unfiltered scan (cheap on modern Android).
* - In the callback, first reject anything that doesn't look like a candidate
* (no OUI hit, no name hit, no Raven UUID, no XUNTONG mfg) — saves CPU.
* - For candidates, build a [ConfidenceEngine.BleObservation] and score it.
* - Push to [DetectionStore] if score crosses ALARM_THRESHOLD (40).
*
* Permissions: caller must hold BLUETOOTH_SCAN (API 31+) or BLUETOOTH+LOCATION (legacy).
*/
class BleScanner(
private val context: Context,
private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker()
) {
companion object {
private const val TAG = "BleScanner"
private const val ALARM_THRESHOLD = 40
}
private val bluetoothAdapter: BluetoothAdapter? by lazy {
val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
mgr?.adapter
}
private var leScanner: BluetoothLeScanner? = null
private var running = false
private val scanSettings: ScanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.build()
/** True if the device supports BLE and the adapter is on. */
val isAvailable: Boolean
get() = bluetoothAdapter?.isEnabled == true
fun hasScanPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) ==
PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
}
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "BLE scan permission missing")
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
Log.i(TAG, "BLE scan started")
return true
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting scan", e)
return false
}
}
@SuppressLint("MissingPermission")
fun stop() {
if (!running) return
try {
leScanner?.stopScan(scanCallback)
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException stopping scan", e)
}
running = false
Log.i(TAG, "BLE scan stopped")
}
private val scanCallback = object : ScanCallback() {
@SuppressLint("MissingPermission")
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleResult(result)
}
override fun onBatchScanResults(results: List<ScanResult>) {
results.forEach { handleResult(it) }
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode")
running = false
}
}
@SuppressLint("MissingPermission")
private fun handleResult(result: ScanResult) {
val device = result.device
val mac = device.address ?: return
val name = try { device.name } catch (e: SecurityException) { null }
val record = result.scanRecord
val advertisedUuids = record?.serviceUuids?.map { it.uuid }
val mfgSpecific = record?.manufacturerSpecificData
var companyId: Int? = null
var payload: ByteArray? = null
if (mfgSpecific != null && mfgSpecific.size() > 0) {
companyId = mfgSpecific.keyAt(0)
payload = mfgSpecific.valueAt(0)
}
// Cheap pre-filter — drop devices that have zero target signals.
val candidate = BleOuis.matches(mac) ||
Patterns.bleNameMatch(name) ||
Patterns.isPenguinNumeric(name) ||
RavenUuids.countMatches(advertisedUuids) > 0 ||
companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID
if (!candidate) return
rssi.update(mac, result.rssi)
val obs = ConfidenceEngine.BleObservation(
mac = mac,
rssi = result.rssi,
deviceName = name,
advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId,
manufacturerPayload = payload,
isStationary = rssi.isStationary(mac)
)
val scored = ConfidenceEngine.scoreBle(obs)
if (scored.score < ALARM_THRESHOLD) return
store.submit(
DetectionEvent(
source = DetectionSource.BLE,
key = mac,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = result.rssi
)
)
}
}
@@ -0,0 +1,133 @@
package org.soulstone.overwatch.scan
import android.content.Context
import android.util.Log
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.floor
import org.json.JSONArray
/**
* Fetches DeFlock ALPR tile data from the public CDN, with a 24h on-disk cache.
*
* Tile scheme (from REFERENCES/deflock/serverless/alpr_cache):
* tile_lat = floor(lat / 20) * 20
* tile_lon = floor(lon / 20) * 20
* url = https://cdn.deflock.me/regions/{tile_lat}/{tile_lon}.json
* body = JSON array of { id: number, lat: number, lon: number, tags: {…} }
*
* 20° tiles → ≤16 tiles cover the entire globe; one user typically only ever touches one.
*/
class DeflockClient(context: Context) {
companion object {
private const val TAG = "DeflockClient"
private const val TILE_SIZE_DEG = 20
private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L
private const val CDN_BASE = "https://cdn.deflock.me/regions"
private const val USER_AGENT = "OVERWATCH/0.1 (+github.com/KaraZajac/OVERWATCH)"
private const val TIMEOUT_MS = 15_000
}
data class AlprPoint(
val id: Long,
val lat: Double,
val lon: Double,
val operator: String? = null,
val manufacturer: String? = null
)
data class TileKey(val tileLat: Int, val tileLon: Int) {
fun fileName() = "deflock_${tileLat}_${tileLon}.json"
}
private val cacheDir: File = File(context.cacheDir, "deflock").apply { mkdirs() }
fun tileFor(lat: Double, lon: Double): TileKey = TileKey(
tileLat = floor(lat / TILE_SIZE_DEG).toInt() * TILE_SIZE_DEG,
tileLon = floor(lon / TILE_SIZE_DEG).toInt() * TILE_SIZE_DEG
)
/** Returns parsed ALPR points for the tile; empty list on any failure (logged). */
suspend fun fetchTile(tile: TileKey): List<AlprPoint> = withContext(Dispatchers.IO) {
val cached = cachedJson(tile)
if (cached != null) {
return@withContext parseSafely(cached)
}
val downloaded = downloadTile(tile) ?: return@withContext emptyList()
try {
File(cacheDir, tile.fileName()).writeText(downloaded)
} catch (e: Exception) {
Log.w(TAG, "Failed to write tile cache for $tile: ${e.message}")
}
parseSafely(downloaded)
}
private fun cachedJson(tile: TileKey): String? {
val f = File(cacheDir, tile.fileName())
if (!f.exists()) return null
val age = System.currentTimeMillis() - f.lastModified()
if (age > CACHE_TTL_MS) return null
return try { f.readText() } catch (e: Exception) { null }
}
private fun downloadTile(tile: TileKey): String? {
val url = URL("$CDN_BASE/${tile.tileLat}/${tile.tileLon}.json")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json")
}
return try {
val code = conn.responseCode
if (code == 404) {
Log.i(TAG, "Tile $tile not present on CDN (no ALPRs in this region)")
"" // cache the empty result by writing an empty string
} else if (code in 200..299) {
conn.inputStream.bufferedReader().use { it.readText() }
} else {
Log.w(TAG, "CDN returned $code for $tile")
null
}
} catch (e: Exception) {
Log.w(TAG, "Download failed for $tile: ${e.message}")
null
} finally {
conn.disconnect()
}
}
private fun parseSafely(json: String): List<AlprPoint> {
if (json.isBlank()) return emptyList()
return try {
val arr = JSONArray(json)
val out = ArrayList<AlprPoint>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val tags = o.optJSONObject("tags")
out.add(
AlprPoint(
id = o.optLong("id", 0L),
lat = o.optDouble("lat"),
lon = o.optDouble("lon"),
operator = tags?.optString("operator")?.ifBlank { null }
?: tags?.optString("surveillance:operator")?.ifBlank { null },
manufacturer = tags?.optString("manufacturer")?.ifBlank { null }
?: tags?.optString("surveillance:manufacturer")?.ifBlank { null }
?: tags?.optString("brand")?.ifBlank { null }
?: tags?.optString("surveillance:brand")?.ifBlank { null }
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse tile JSON: ${e.message}")
emptyList()
}
}
}
@@ -0,0 +1,94 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
/**
* DeFlock orchestrator.
*
* Subscribes to [LocationProvider]; for each new fix, looks up the matching 20° tile
* (loaded from [DeflockClient] cache or downloaded once / 24h) and submits a
* detection event for every ALPR within [PROXIMITY_M].
*
* Tile-boundary edge case: at lat ≈ tile_lat or lon ≈ tile_lon ±0.002°, ALPRs across
* the boundary won't be visible until the user crosses it. Acceptable for v0.1 — a
* 5-tile fetch (current + 4 neighbours) is a polish item.
*/
class DeflockScanner(
private val store: DetectionStore,
private val locationProvider: LocationProvider,
private val client: DeflockClient,
private val proximityMeters: () -> Float = { 200f }
) {
companion object {
private const val TAG = "DeflockScanner"
}
private var job: Job? = null
private var lastTile: DeflockClient.TileKey? = null
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList()
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
locationProvider.location.collectLatest { fix ->
if (fix != null) handleFix(fix)
}
}
Log.i(TAG, "DeflockScanner started")
return true
}
fun stop() {
job?.cancel()
job = null
lastTile = null
cachedPoints = emptyList()
Log.i(TAG, "DeflockScanner stopped")
}
private suspend fun handleFix(fix: Location) {
val tile = client.tileFor(fix.latitude, fix.longitude)
if (tile != lastTile) {
cachedPoints = client.fetchTile(tile)
lastTile = tile
Log.i(TAG, "Loaded tile $tile with ${cachedPoints.size} ALPRs")
}
if (cachedPoints.isEmpty()) return
val limit = proximityMeters()
val out = FloatArray(1)
for (p in cachedPoints) {
Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out)
val dist = out[0]
if (dist > limit) continue
val obs = ConfidenceEngine.DeflockObservation(
osmId = p.id,
distanceMeters = dist,
operator = p.operator,
manufacturer = p.manufacturer
)
val scored = ConfidenceEngine.scoreDeflock(obs)
store.submit(
DetectionEvent(
source = DetectionSource.DEFLOCK,
key = "osm:${p.id}",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null
)
)
}
}
}
@@ -0,0 +1,115 @@
package org.soulstone.overwatch.scan
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Fetches Waze live-map alerts in a small bounding box around the user.
*
* Endpoint (recipe from REFERENCES/wazepolice):
* https://www.waze.com/live-map/api/georss?top=&bottom=&left=&right=&env=na&types=alerts
*
* Spoofs Chrome desktop headers — the public live-map endpoint requires Referer +
* a real-looking User-Agent, otherwise returns 403.
*
* Response shape:
* { "alerts": [
* { "uuid", "type": "POLICE", "subtype",
* "location": {"x": lon, "y": lat},
* "pubMillis", "reportedBy", "confidence" 0-5, "reliability" 0-10 } ] }
*/
class WazeClient {
companion object {
private const val TAG = "WazeClient"
private const val BASE = "https://www.waze.com/live-map/api/georss"
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private const val REFERER = "https://www.waze.com/live-map/"
private const val ORIGIN = "https://www.waze.com"
private const val TIMEOUT_MS = 10_000
/** Bounding box half-width in degrees — ~5.5 km N-S, varies E-W with latitude. */
private const val BBOX_HALF_DEG = 0.05
}
data class Alert(
val uuid: String,
val subtype: String?,
val lat: Double,
val lon: Double,
val pubMillis: Long,
val confidence: Int,
val reliability: Int,
val reportedBy: String?
)
suspend fun fetchPoliceNear(lat: Double, lon: Double): List<Alert> = withContext(Dispatchers.IO) {
val top = lat + BBOX_HALF_DEG
val bottom = lat - BBOX_HALF_DEG
val left = lon - BBOX_HALF_DEG
val right = lon + BBOX_HALF_DEG
val url = URL("$BASE?top=$top&bottom=$bottom&left=$left&right=$right&env=na&types=alerts")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
requestMethod = "GET"
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Referer", REFERER)
setRequestProperty("Origin", ORIGIN)
setRequestProperty("Accept", "application/json,text/javascript,*/*;q=0.8")
setRequestProperty("Accept-Language", "en-US,en;q=0.9")
}
try {
val code = conn.responseCode
if (code !in 200..299) {
Log.w(TAG, "Waze returned $code")
return@withContext emptyList()
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
parsePolice(body)
} catch (e: Exception) {
Log.w(TAG, "Waze fetch failed: ${e.message}")
emptyList()
} finally {
conn.disconnect()
}
}
private fun parsePolice(body: String): List<Alert> {
if (body.isBlank()) return emptyList()
return try {
val root = JSONObject(body)
val alerts = root.optJSONArray("alerts") ?: return emptyList()
val out = ArrayList<Alert>()
for (i in 0 until alerts.length()) {
val a = alerts.optJSONObject(i) ?: continue
if (a.optString("type") != "POLICE") continue
val loc = a.optJSONObject("location") ?: continue
val uuid = a.optString("uuid")
if (uuid.isBlank()) continue
out.add(
Alert(
uuid = uuid,
subtype = a.optString("subtype").ifBlank { null },
lat = loc.optDouble("y"),
lon = loc.optDouble("x"),
pubMillis = a.optLong("pubMillis", System.currentTimeMillis()),
confidence = a.optInt("confidence", 0),
reliability = a.optInt("reliability", 0),
reportedBy = a.optString("reportedBy").ifBlank { null }
)
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Waze response: ${e.message}")
emptyList()
}
}
}
@@ -0,0 +1,96 @@
package org.soulstone.overwatch.scan
import android.location.Location
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
/**
* Polls Waze every 60s for live POLICE alerts in a small bounding box around the
* current location, then submits any inside [PROXIMITY_M] and younger than [MAX_AGE_MS].
*
* Skips the poll cycle if location is not yet known. Network-only — no on-disk cache
* (data is real-time by definition).
*/
class WazeScanner(
private val store: DetectionStore,
private val locationProvider: LocationProvider,
private val client: WazeClient = WazeClient(),
private val proximityMeters: () -> Float = { 500f }
) {
companion object {
private const val TAG = "WazeScanner"
private const val POLL_INTERVAL_MS = 60_000L
private const val MAX_AGE_MS = 10L * 60L * 1000L
}
private var job: Job? = null
fun start(scope: CoroutineScope): Boolean {
if (job != null) return true
job = scope.launch {
while (isActive) {
val fix = locationProvider.location.value
if (fix != null) {
pollOnce(fix)
} else {
Log.d(TAG, "Skip poll — no location yet")
}
delay(POLL_INTERVAL_MS)
}
}
Log.i(TAG, "WazeScanner started (interval=${POLL_INTERVAL_MS}ms)")
return true
}
fun stop() {
job?.cancel()
job = null
Log.i(TAG, "WazeScanner stopped")
}
private suspend fun pollOnce(fix: Location) {
val alerts = client.fetchPoliceNear(fix.latitude, fix.longitude)
if (alerts.isEmpty()) return
val now = System.currentTimeMillis()
val limit = proximityMeters()
val out = FloatArray(1)
for (a in alerts) {
val age = now - a.pubMillis
if (age > MAX_AGE_MS) continue
Location.distanceBetween(fix.latitude, fix.longitude, a.lat, a.lon, out)
val dist = out[0]
if (dist > limit) continue
val obs = ConfidenceEngine.WazeObservation(
uuid = a.uuid,
distanceMeters = dist,
ageMs = age,
confidence = a.confidence,
reliability = a.reliability,
subtype = a.subtype
)
val scored = ConfidenceEngine.scoreWaze(obs)
store.submit(
DetectionEvent(
source = DetectionSource.WAZE,
key = a.uuid,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = null
)
)
}
}
}
@@ -0,0 +1,198 @@
package org.soulstone.overwatch.scan
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.ScanResult
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.WifiOuis
import org.soulstone.overwatch.fusion.ConfidenceEngine
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.RssiTracker
/**
* WiFi scanner — BSSID OUI + SSID-pattern matching via [WifiManager.getScanResults].
*
* Android 11+ throttles foreground apps to 4 scans per 2 minutes. We poll every 35s
* (≈3.4 scans / 2 min) and rely on the system to deliver SCAN_RESULTS_AVAILABLE_ACTION.
* If [WifiManager.startScan] returns false (throttled or radio busy) we still consume
* whatever cached results the next broadcast carries.
*
* The flock-you promiscuous-mode addr1 / wildcard-probe trick from the reference repo
* is **not portable to Android** — userspace can only see results WifiManager surfaces.
*/
class WifiScanner(
private val context: Context,
private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker()
) {
companion object {
private const val TAG = "WifiScanner"
private const val ALARM_THRESHOLD = 40
private const val SCAN_INTERVAL_MS = 35_000L
}
private val wifiManager: WifiManager? by lazy {
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
}
private var running = false
private var scanJob: Job? = null
private var receiverRegistered = false
private val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) return
val updated = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, true)
if (!updated) return
handleResults()
}
}
val isAvailable: Boolean
get() = wifiManager?.isWifiEnabled == true
fun hasScanPermission(): Boolean {
val locOk = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val nearbyOk = ContextCompat.checkSelfPermission(
context, Manifest.permission.NEARBY_WIFI_DEVICES
) == PackageManager.PERMISSION_GRANTED
return nearbyOk || locOk
}
return locOk
}
@SuppressLint("MissingPermission")
fun start(scope: CoroutineScope): Boolean {
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "WiFi scan permission missing")
return false
}
val mgr = wifiManager ?: run {
Log.w(TAG, "WifiManager unavailable")
return false
}
if (!mgr.isWifiEnabled) {
Log.w(TAG, "WiFi disabled — scanner won't return results")
// We still register the receiver so results arrive when the user enables WiFi.
}
registerReceiver()
running = true
scanJob = scope.launch {
while (isActive) {
try {
@Suppress("DEPRECATION")
val ok = mgr.startScan()
if (!ok) Log.d(TAG, "startScan returned false (throttled or radio busy)")
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting WiFi scan", e)
}
delay(SCAN_INTERVAL_MS)
}
}
Log.i(TAG, "WiFi scan started (interval=${SCAN_INTERVAL_MS}ms)")
return true
}
fun stop() {
if (!running) return
scanJob?.cancel()
scanJob = null
unregisterReceiver()
running = false
Log.i(TAG, "WiFi scan stopped")
}
private fun registerReceiver() {
if (receiverRegistered) return
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED
)
receiverRegistered = true
}
private fun unregisterReceiver() {
if (!receiverRegistered) return
try {
context.unregisterReceiver(receiver)
} catch (_: IllegalArgumentException) {
// already gone
}
receiverRegistered = false
}
@SuppressLint("MissingPermission")
private fun handleResults() {
val mgr = wifiManager ?: return
val results: List<ScanResult> = try {
mgr.scanResults ?: emptyList()
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException reading scanResults", e)
return
}
for (r in results) {
val bssid = r.BSSID ?: continue
val ssid = readSsid(r)
val candidate = WifiOuis.matches(bssid) ||
Patterns.ssidGenericMatch(ssid) ||
Patterns.ssidFlockFormat(ssid)
if (!candidate) continue
rssi.update(bssid, r.level)
val obs = ConfidenceEngine.WifiObservation(
bssid = bssid,
ssid = ssid,
rssi = r.level,
isStationary = rssi.isStationary(bssid)
)
val scored = ConfidenceEngine.scoreWifi(obs)
if (scored.score < ALARM_THRESHOLD) continue
store.submit(
DetectionEvent(
source = DetectionSource.WIFI,
key = bssid,
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = r.level
)
)
}
}
private fun readSsid(r: ScanResult): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val raw = r.wifiSsid?.toString() ?: return null
return raw.trim('"').ifBlank { null }
}
@Suppress("DEPRECATION")
val raw = r.SSID ?: return null
return raw.trim('"').ifBlank { null }
}
}
@@ -0,0 +1,227 @@
package org.soulstone.overwatch.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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.DetectionStore
import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.WazeScanner
import org.soulstone.overwatch.scan.WifiScanner
/**
* Foreground service that owns all scanners and the [DetectionStore].
*
* Phase 1 wires only [BleScanner]; phases 2-4 will register WiFi, DeFlock, Waze.
*
* 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).
*/
class DetectionService : LifecycleService() {
companion object {
private const val TAG = "DetectionService"
private const val CHANNEL_ID = "overwatch_detection"
private const val NOTIFICATION_ID = 0xBEEF
const val ACTION_START = "org.soulstone.overwatch.action.START"
const val ACTION_STOP = "org.soulstone.overwatch.action.STOP"
/** Single shared store — UI observes this. */
val store: DetectionStore = DetectionStore()
private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow()
fun start(context: Context) {
val intent = Intent(context, DetectionService::class.java).apply {
action = ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, DetectionService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
private lateinit var settings: Settings
private lateinit var bleScanner: BleScanner
private lateinit var wifiScanner: WifiScanner
private lateinit var locationProvider: LocationProvider
private lateinit var deflockScanner: DeflockScanner
private lateinit var wazeScanner: WazeScanner
private var pruneJob: Job? = null
private var bleStarted = false
private var wifiStarted = false
private var deflockStarted = false
private var wazeStarted = false
override fun onCreate() {
super.onCreate()
settings = Settings.get(this)
bleScanner = BleScanner(this, store)
wifiScanner = WifiScanner(this, store)
locationProvider = LocationProvider(this)
deflockScanner = DeflockScanner(
store, locationProvider, DeflockClient(this),
proximityMeters = { settings.deflockProximityM.value.toFloat() }
)
wazeScanner = WazeScanner(
store, locationProvider,
proximityMeters = { settings.wazeProximityM.value.toFloat() }
)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_START -> beginScanning()
ACTION_STOP -> {
endScanning()
stopSelf()
}
}
return START_STICKY
}
private fun beginScanning() {
if (_running.value) return
startInForeground()
if (settings.bleEnabled.value) {
bleStarted = bleScanner.start()
if (!bleStarted) Log.w(TAG, "BleScanner.start() returned false (permission/adapter)")
}
if (settings.wifiEnabled.value) {
wifiStarted = wifiScanner.start(lifecycleScope)
if (!wifiStarted) Log.w(TAG, "WifiScanner.start() returned false (permission/adapter)")
}
val needsLocation = settings.deflockEnabled.value || settings.wazeEnabled.value
if (needsLocation) {
val locOk = locationProvider.start()
if (!locOk) {
Log.w(TAG, "LocationProvider.start() returned false (permission)")
} else {
if (settings.deflockEnabled.value) {
deflockScanner.start(lifecycleScope); deflockStarted = true
}
if (settings.wazeEnabled.value) {
wazeScanner.start(lifecycleScope); wazeStarted = true
}
}
}
_running.value = true
pruneJob?.cancel()
pruneJob = lifecycleScope.launch {
while (true) {
delay(30_000)
store.pruneExpired()
}
}
}
private fun endScanning() {
if (!_running.value) return
if (bleStarted) { bleScanner.stop(); bleStarted = false }
if (wifiStarted) { wifiScanner.stop(); wifiStarted = false }
if (deflockStarted) { deflockScanner.stop(); deflockStarted = false }
if (wazeStarted) { wazeScanner.stop(); wazeStarted = false }
locationProvider.stop()
store.clear()
pruneJob?.cancel()
pruneJob = null
_running.value = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
override fun onDestroy() {
endScanning()
super.onDestroy()
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
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
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun buildNotification(): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pi = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java) ?: return
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
}
@@ -0,0 +1,336 @@
package org.soulstone.overwatch.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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 androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.ui.theme.ThreatColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
running: Boolean,
threat: ThreatLevel,
score: Int,
events: List<DetectionEvent>,
onStartStop: () -> Unit,
onOpenSettings: () -> Unit,
canStart: Boolean,
permissionMessage: String?
) {
var showSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetScope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 24.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "[DЯΣΛMMΛKΣЯ]",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Text(
text = " . //0VΣЯW4TCH",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
}
IconButton(onClick = onOpenSettings) {
Icon(
Icons.Filled.Settings,
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(40.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true })
Spacer(Modifier.height(12.dp))
Text(
text = "tap circle for source details",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
Spacer(Modifier.height(24.dp))
StatusText(running = running, threat = threat, score = score, events = events)
Spacer(Modifier.height(24.dp))
Button(
onClick = onStartStop,
enabled = canStart,
colors = ButtonDefaults.buttonColors(
containerColor = if (running) ThreatColors.Red else ThreatColors.Green,
contentColor = Color.White
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
) {
Text(
text = if (running) "STOP" else "START",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
if (permissionMessage != null) {
Spacer(Modifier.height(12.dp))
Text(
text = permissionMessage,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp
)
}
}
}
if (showSheet) {
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background
) {
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) {
Text(
text = "Detection sources",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(Modifier.height(8.dp))
SourcesPanel(events = events)
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Reverse
),
label = "pulse"
)
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.size(220.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
color.copy(alpha = alpha),
color.copy(alpha = alpha * 0.6f)
)
)
)
.clickable(onClick = onTap),
contentAlignment = Alignment.Center
) {
Text(
text = level.name,
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
)
}
}
@Composable
private fun StatusText(
running: Boolean,
threat: ThreatLevel,
score: Int,
events: List<DetectionEvent>
) {
val text = when {
!running -> "Idle — press START to begin scanning"
events.isEmpty() -> "All clear"
threat == ThreatLevel.GREEN -> "Scanning… (${events.size} weak signals)"
else -> {
val top = events.first()
"${top.label}${top.score}"
}
}
Text(
text = text,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace
)
if (running) {
Spacer(Modifier.height(4.dp))
Text(
text = "Max score: $score",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp
)
}
}
@Composable
private fun SourcesPanel(events: List<DetectionEvent>) {
Column(modifier = Modifier.fillMaxWidth()) {
DetectionSource.values().forEach { source ->
val sourceEvents = events.filter { it.source == source }
SourceRow(source = source, events = sourceEvents)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(8.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = source.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
val maxScore = events.maxOfOrNull { it.score } ?: 0
val statusColor = when {
maxScore >= ThreatLevel.RED.minScore -> ThreatColors.Red
maxScore >= ThreatLevel.ORANGE.minScore -> ThreatColors.Orange
maxScore >= ThreatLevel.YELLOW.minScore -> ThreatColors.Yellow
else -> ThreatColors.Green
}
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(statusColor)
)
}
if (events.isEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
text = "no detections",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp
)
} 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
)
}
if (events.size > 3) {
Text(
text = "+${events.size - 3} more",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp
)
}
}
}
}
}
@@ -0,0 +1,201 @@
package org.soulstone.overwatch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.soulstone.overwatch.data.settings.Settings
@Composable
fun SettingsScreen(
settings: Settings,
onBack: () -> Unit
) {
val ble by settings.bleEnabled.collectAsState()
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val theme by settings.themeMode.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
text = "Settings",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(Modifier.height(8.dp))
SectionLabel("Detection sources")
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
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))
Text(
"Source toggles take effect on next Start.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
SectionLabel("Proximity thresholds")
SliderRow(
label = "DeFlock alert distance",
valueLabel = "${deflockProx} m",
value = deflockProx.toFloat(),
range = 50f..1600f,
steps = 30,
onChange = { settings.setDeflockProximityM(it.toInt()) }
)
SliderRow(
label = "Waze alert distance",
valueLabel = "${wazeProx} m",
value = wazeProx.toFloat(),
range = 100f..5000f,
steps = 48,
onChange = { settings.setWazeProximityM(it.toInt()) }
)
Spacer(Modifier.height(16.dp))
SectionLabel("Appearance")
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
settings.setThemeMode(Settings.ThemeMode.SYSTEM)
}
ThemeRadio("Dark", theme == Settings.ThemeMode.DARK) {
settings.setThemeMode(Settings.ThemeMode.DARK)
}
ThemeRadio("Light", theme == Settings.ThemeMode.LIGHT) {
settings.setThemeMode(Settings.ThemeMode.LIGHT)
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text.uppercase(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 8.dp)
)
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
}
@Composable
private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Switch(checked = value, onCheckedChange = onChange)
}
}
@Composable
private fun SliderRow(
label: String,
valueLabel: String,
value: Float,
range: ClosedFloatingPointRange<Float>,
steps: Int,
onChange: (Float) -> Unit
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Text(
text = valueLabel,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = value,
onValueChange = onChange,
valueRange = range,
steps = steps
)
}
}
@Composable
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = onClick)
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
@@ -0,0 +1,52 @@
package org.soulstone.overwatch.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import org.soulstone.overwatch.data.settings.Settings
private val DarkColors = darkColorScheme(
primary = Color(0xFF1FAA59),
onPrimary = Color.White,
background = Color(0xFF0B0E12),
onBackground = Color(0xFFF4F6FA),
surface = Color(0xFF161A21),
onSurface = Color(0xFFF4F6FA),
surfaceVariant = Color(0xFF1E232C),
onSurfaceVariant = Color(0xFF9AA3B2)
)
private val LightColors = lightColorScheme(
primary = Color(0xFF1FAA59),
onPrimary = Color.White,
background = Color(0xFFF4F6FA),
onBackground = Color(0xFF0B0E12),
surface = Color.White,
onSurface = Color(0xFF0B0E12)
)
object ThreatColors {
val Green = Color(0xFF1FAA59)
val Yellow = Color(0xFFF4C20D)
val Orange = Color(0xFFF26B0F)
val Red = Color(0xFFD7263D)
}
@Composable
fun OverwatchTheme(
mode: Settings.ThemeMode = Settings.ThemeMode.DARK,
content: @Composable () -> Unit
) {
val dark = when (mode) {
Settings.ThemeMode.DARK -> true
Settings.ThemeMode.LIGHT -> false
Settings.ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
MaterialTheme(
colorScheme = if (dark) DarkColors else LightColors,
content = content
)
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1FAA59"
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
<path
android:fillColor="#F4F6FA"
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="threat_green">#1FAA59</color>
<color name="threat_yellow">#F4C20D</color>
<color name="threat_orange">#F26B0F</color>
<color name="threat_red">#D7263D</color>
<color name="bg_dark">#0B0E12</color>
<color name="bg_card">#161A21</color>
<color name="text_primary">#F4F6FA</color>
<color name="text_secondary">#9AA3B2</color>
</resources>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">[DЯΣΛMMΛKΣЯ] OVERWATCH</string>
<string name="title_line1">[DЯΣΛMMΛKΣЯ]</string>
<string name="title_line2"> . //0VΣЯW4TCH</string>
<string name="status_idle">Idle — press START to begin scanning</string>
<string name="status_scanning_clear">All clear</string>
<string name="status_scanning">Scanning…</string>
<string name="action_start">START</string>
<string name="action_stop">STOP</string>
<string name="notification_channel_name">DREAMMAKER / OVERWATCH detection</string>
<string name="notification_channel_desc">Foreground notification while scanning</string>
<string name="notification_title">OVERWATCH active</string>
<string name="notification_text">Scanning for nearby surveillance</string>
<string name="permission_required">Bluetooth + location permissions are required</string>
<string name="details_label">Detection sources</string>
<string name="settings_title">Settings</string>
<string name="settings_sources">Detection sources</string>
<string name="settings_proximity">Proximity thresholds</string>
<string name="settings_appearance">Appearance</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Overwatch" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@color/bg_dark</item>
<item name="android:navigationBarColor">@color/bg_dark</item>
<item name="android:windowBackground">@color/bg_dark</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
</cloud-backup>
</data-extraction-rules>