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
+12
View File
@@ -15,3 +15,15 @@ __pycache__/
*.pyc *.pyc
.idea/ .idea/
.vscode/ .vscode/
# Android / Gradle
.gradle/
.kotlin/
local.properties
*.iml
.cxx/
captures/
app/build/
app/release/
**/build/
**/release/
+146
View File
@@ -0,0 +1,146 @@
# [DЯΣΛMMΛKΣЯ]
##    . //0VΣЯW4TCH
A native Android (Kotlin) **passive surveillance-detection** app. Open it, hit
**START**, and a circle turns **green / yellow / orange / red** depending on
how confident the engine is that there's a Flock Safety ALPR, an Axon body
camera, or police presence near you.
> **Passive defense only.** OVERWATCH only listens — it does not transmit,
> probe, jam, or interfere with any device or network. The Axon
> advertise/fuzz code from one of the reference projects is intentionally
> excluded.
---
## What it detects
| Source | What it looks at | Where it comes from |
|---|---|---|
| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns | Local radio scan (BLE callback API) |
| **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset), `Flock-XXXX` and other generic SSID patterns | `WifiManager.getScanResults()` polled every 35 s (just under the Android 11+ 4-scans/2-min throttle) |
| **DEFLOCK** | Crowdsourced ALPR locations within configurable proximity (default 200 m) | Public CDN tile fetch from `cdn.deflock.me`, 24h on-disk cache |
| **WAZE** | Live `POLICE` reports within configurable proximity (default 500 m) and < 10 min old | `live-map/api/georss` polled every 60 s with a small bbox around the user |
Every observation is scored 0-100 by `ConfidenceEngine`. The on-screen tier is
the maximum live score across all sources:
```
GREEN < 40 nothing credible
YELLOW 40 69 single weak indicator
ORANGE 70 84 high confidence
RED 85 + certain
```
The user-facing circle uses the full 4-tier mapping. Cross-source corroboration
naturally pushes the global max upward (a BLE OUI hit *and* a DeFlock map
match in the same area produce a higher tier than either alone).
---
## Architecture
```
ui/MainScreen.kt circle + START/STOP + tap-to-open bottom sheet
ui/SettingsScreen.kt per-source toggles, distance sliders, theme
service/DetectionService.kt foreground service — owns scanners + store
scan/BleScanner.kt BLE callback scanner
scan/WifiScanner.kt WifiManager poller + SCAN_RESULTS receiver
scan/DeflockClient.kt CDN tile fetch + 24h cache
scan/DeflockScanner.kt location-driven proximity check
scan/WazeClient.kt live-map/api/georss bbox fetch
scan/WazeScanner.kt 60s poller + age/distance gate
fusion/ConfidenceEngine.kt scoring (one place)
fusion/RssiTracker.kt rise-peak-fall stationary-signal detector
fusion/DetectionStore.kt in-memory dedup, 5-min retention
data/location/LocationProvider.kt FusedLocationProviderClient wrapper
data/settings/Settings.kt SharedPreferences-backed StateFlow settings
data/targets/ BleOuis, WifiOuis, RavenUuids, Patterns, Manufacturers
```
No detection-history database. All state is in-memory and clears on stop, by
design.
---
## Build & install
Requires:
- **JDK 21** (Android Gradle Plugin 8.7.x rejects JDK 26)
- **Android Studio** with SDK Platform 34 + Build-Tools 34.x + Platform-Tools
```sh
# 1) Copy the example local.properties and point sdk.dir at your install
cp local.properties.example local.properties
# edit local.properties → sdk.dir=/Users/<you>/Library/Android/sdk
# 2) Make sure JAVA_HOME is JDK 21
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# 3) Build & install on a connected device with USB debugging
./gradlew :app:installDebug
```
Or download the latest signed APK from
[Releases](https://github.com/KaraZajac/OVERWATCH/releases).
---
## Permissions
| Permission | Why |
|---|---|
| `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+) | BLE scanning |
| `BLUETOOTH`, `BLUETOOTH_ADMIN` (≤ API 30) | BLE scanning, legacy |
| `ACCESS_FINE_LOCATION` | Required for BLE pre-S, WiFi pre-T, and DeFlock proximity |
| `NEARBY_WIFI_DEVICES` (API 33+) | WiFi scan results without using location |
| `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` | Trigger and read scan results |
| `INTERNET`, `ACCESS_NETWORK_STATE` | DeFlock CDN + Waze API |
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off |
| `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification |
Requested at runtime when you press START for the first time.
---
## Settings
Tap the gear icon in the top-right.
- **Detection sources**: toggle BLE / WiFi / DeFlock / Waze independently. Takes
effect on next Start.
- **Proximity thresholds**:
- DeFlock: 50 m 1600 m (default 200 m)
- Waze: 100 m 5000 m (default 500 m)
- **Appearance**: System / Dark / Light (default Dark)
---
## Reference repos studied while building
These live under `REFERENCES/` (gitignored):
- **AxonCadabra** — BLE scanner skeleton (scan side only; advertise/fuzz code excluded)
- **flock-detection** — confidence-scoring algorithm (highest reusability), RSSI rise-peak-fall, OUIs + UUIDs + patterns
- **flock-you** — 31-OUI WiFi superset (promiscuous-mode tricks not portable to Android)
- **deflock** + **deflock-app** — CDN tile scheme, proximity-alert pattern
- **wazepolice** — live-map/api/georss recipe, Chrome header spoofing
---
## Status
Phases 15 (skeleton, BLE, WiFi, DeFlock, Waze, polish) complete as of v0.1.0.
Field-test-ready, not yet field-validated.
## License
Personal use. Reference repos retain their own licenses; do not redistribute
their code as part of this project.
## Disclaimer
Tool for situational awareness about deployed surveillance infrastructure in
public spaces. Local laws regarding electronic surveillance, RF monitoring, and
police-tracking apps vary — your responsibility to know what's legal where you
are.
+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>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+8
View File
@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+28
View File
@@ -0,0 +1,28 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.13.1"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
material3 = "1.3.1"
playServicesLocation = "21.3.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
# Copy this file to local.properties (which is gitignored) and edit the paths
# for your machine.
# Android SDK location. Required for any Android build.
# Android Studio's setup wizard installs to ~/Library/Android/sdk by default.
# Alternative: brew install --cask android-commandlinetools (path would be
# /usr/local/share/android-commandlinetools or /opt/homebrew/share/android-commandlinetools).
sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk
# JDK 21 home. AGP 8.7.x supports JDK 17 / 21 — JDK 26 is too new for AGP.
# This project's gradle wrapper runs on whatever JAVA_HOME points to.
# Either:
# (a) export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# in your shell profile
# (b) put the line below into ~/.gradle/gradle.properties (NOT project gradle.properties):
# org.gradle.java.home=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
+28
View File
@@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "OVERWATCH"
include(":app")