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:
+12
@@ -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/
|
||||||
|
|||||||
@@ -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 1–5 (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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# Default ProGuard config for OVERWATCH.
|
||||||
|
# No custom rules yet — release build keeps minify off.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user