From 3574970a5fdaaddaaf43e3f262c692505e30ab63 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Tue, 28 Apr 2026 21:10:57 -0400 Subject: [PATCH] =?UTF-8?q?Add=20OVERWATCH=20v0.1.0=20=E2=80=94=20full=20d?= =?UTF-8?q?etection=20engine=20+=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 12 + README.md | 146 ++++++++ app/build.gradle.kts | 66 ++++ app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 68 ++++ .../org/soulstone/overwatch/MainActivity.kt | 111 ++++++ .../data/location/LocationProvider.kt | 89 +++++ .../overwatch/data/settings/Settings.kt | 93 +++++ .../overwatch/data/targets/BleOuis.kt | 36 ++ .../overwatch/data/targets/Manufacturers.kt | 29 ++ .../overwatch/data/targets/Patterns.kt | 48 +++ .../overwatch/data/targets/RavenUuids.kt | 32 ++ .../overwatch/data/targets/WifiOuis.kt | 29 ++ .../overwatch/fusion/ConfidenceEngine.kt | 241 +++++++++++++ .../overwatch/fusion/DetectionEvent.kt | 24 ++ .../overwatch/fusion/DetectionStore.kt | 62 ++++ .../soulstone/overwatch/fusion/RssiTracker.kt | 41 +++ .../soulstone/overwatch/fusion/ThreatLevel.kt | 24 ++ .../soulstone/overwatch/scan/BleScanner.kt | 174 +++++++++ .../soulstone/overwatch/scan/DeflockClient.kt | 133 +++++++ .../overwatch/scan/DeflockScanner.kt | 94 +++++ .../soulstone/overwatch/scan/WazeClient.kt | 115 ++++++ .../soulstone/overwatch/scan/WazeScanner.kt | 96 +++++ .../soulstone/overwatch/scan/WifiScanner.kt | 198 +++++++++++ .../overwatch/service/DetectionService.kt | 227 ++++++++++++ .../org/soulstone/overwatch/ui/MainScreen.kt | 336 ++++++++++++++++++ .../soulstone/overwatch/ui/SettingsScreen.kt | 201 +++++++++++ .../org/soulstone/overwatch/ui/theme/Theme.kt | 52 +++ .../res/drawable/ic_launcher_foreground.xml | 13 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 11 + app/src/main/res/values/strings.xml | 21 ++ app/src/main/res/values/themes.xml | 8 + app/src/main/res/xml/backup_rules.xml | 3 + .../main/res/xml/data_extraction_rules.xml | 5 + build.gradle.kts | 5 + gradle.properties | 8 + gradle/libs.versions.toml | 28 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++++++++++++ gradlew.bat | 93 +++++ local.properties.example | 16 + settings.gradle.kts | 28 ++ 45 files changed, 3283 insertions(+) create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/targets/BleOuis.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/targets/Manufacturers.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/targets/Patterns.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/targets/RavenUuids.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/data/targets/WifiOuis.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/fusion/RssiTracker.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt create mode 100644 app/src/main/kotlin/org/soulstone/overwatch/ui/theme/Theme.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 local.properties.example create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore index 83cb435..5cb7091 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,15 @@ __pycache__/ *.pyc .idea/ .vscode/ + +# Android / Gradle +.gradle/ +.kotlin/ +local.properties +*.iml +.cxx/ +captures/ +app/build/ +app/release/ +**/build/ +**/release/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..323079e --- /dev/null +++ b/README.md @@ -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//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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..382dff1 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..1de2509 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Default ProGuard config for OVERWATCH. +# No custom rules yet — release build keeps minify off. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fc97338 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt new file mode 100644 index 0000000..e7008e4 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt @@ -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 + 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 } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt new file mode 100644 index 0000000..72d25ff --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/location/LocationProvider.kt @@ -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(null) + val location: StateFlow = _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") + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt new file mode 100644 index 0000000..ae2b967 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt @@ -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 = _bleEnabled.asStateFlow() + + private val _wifiEnabled = MutableStateFlow(prefs.getBoolean(KEY_WIFI, true)) + val wifiEnabled: StateFlow = _wifiEnabled.asStateFlow() + + private val _deflockEnabled = MutableStateFlow(prefs.getBoolean(KEY_DEFLOCK, true)) + val deflockEnabled: StateFlow = _deflockEnabled.asStateFlow() + + private val _wazeEnabled = MutableStateFlow(prefs.getBoolean(KEY_WAZE, true)) + val wazeEnabled: StateFlow = _wazeEnabled.asStateFlow() + + private val _deflockProximityM = MutableStateFlow( + prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX) + ) + val deflockProximityM: StateFlow = _deflockProximityM.asStateFlow() + + private val _wazeProximityM = MutableStateFlow( + prefs.getInt(KEY_WAZE_PROX, DEFAULT_WAZE_PROX) + ) + val wazeProximityM: StateFlow = _wazeProximityM.asStateFlow() + + private val _themeMode = MutableStateFlow( + ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.DARK.name) ?: ThemeMode.DARK.name) + ) + val themeMode: StateFlow = _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 } + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/BleOuis.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/BleOuis.kt new file mode 100644 index 0000000..12b2852 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/BleOuis.kt @@ -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 = 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) +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/Manufacturers.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/Manufacturers.kt new file mode 100644 index 0000000..27b7436 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/Manufacturers.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/Patterns.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/Patterns.kt new file mode 100644 index 0000000..99b2a48 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/Patterns.kt @@ -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 = listOf( + "FS Ext Battery", + "Penguin", + "Flock", + "Pigvision", + "FlockCam", + "FS-" + ) + + /** Generic SSID substrings (case-insensitive). */ + val SSID_GENERIC: List = 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) + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/RavenUuids.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/RavenUuids.kt new file mode 100644 index 0000000..e8b5bc2 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/RavenUuids.kt @@ -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 = 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?): 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") +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/WifiOuis.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/WifiOuis.kt new file mode 100644 index 0000000..bbdf21f --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/WifiOuis.kt @@ -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 = 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 + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt new file mode 100644 index 0000000..b731166 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt @@ -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?, + 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) + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt new file mode 100644 index 0000000..bc905e5 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionEvent.kt @@ -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) +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt new file mode 100644 index 0000000..d4e8800 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/DetectionStore.kt @@ -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>(emptyList()) + val events: StateFlow> = _events.asStateFlow() + + private val _threatLevel = MutableStateFlow(ThreatLevel.GREEN) + val threatLevel: StateFlow = _threatLevel.asStateFlow() + + private val _maxScore = MutableStateFlow(0) + val maxScore: StateFlow = _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) { + val max = live.maxOfOrNull { it.score } ?: 0 + _maxScore.value = max + _threatLevel.value = ThreatLevel.fromScore(max) + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/RssiTracker.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/RssiTracker.kt new file mode 100644 index 0000000..d7502c4 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/RssiTracker.kt @@ -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> = 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() +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt new file mode 100644 index 0000000..99a95f7 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt @@ -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 } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt new file mode 100644 index 0000000..5a02b6d --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt @@ -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) { + 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 + ) + ) + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt new file mode 100644 index 0000000..11e7cee --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockClient.kt @@ -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 = 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 { + if (json.isBlank()) return emptyList() + return try { + val arr = JSONArray(json) + val out = ArrayList(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() + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt new file mode 100644 index 0000000..c17bcca --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt @@ -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 = 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 + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt new file mode 100644 index 0000000..bd6343e --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeClient.kt @@ -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 = 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 { + if (body.isBlank()) return emptyList() + return try { + val root = JSONObject(body) + val alerts = root.optJSONArray("alerts") ?: return emptyList() + val out = ArrayList() + 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() + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt new file mode 100644 index 0000000..32505d7 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WazeScanner.kt @@ -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 + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt new file mode 100644 index 0000000..ecf4ddc --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt @@ -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 = 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 } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt new file mode 100644 index 0000000..da193ed --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -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 = _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) + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt new file mode 100644 index 0000000..888183f --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt @@ -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, + 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 +) { + 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) { + 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) { + 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 + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt new file mode 100644 index 0000000..44bc1df --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -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, + 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 + ) + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/theme/Theme.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/theme/Theme.kt new file mode 100644 index 0000000..067c0c3 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3e4e766 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ad95a53 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ad95a53 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8c31e08 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #1FAA59 + #F4C20D + #F26B0F + #D7263D + #0B0E12 + #161A21 + #F4F6FA + #9AA3B2 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..41e346d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + [DЯΣΛMMΛKΣЯ] OVERWATCH + [DЯΣΛMMΛKΣЯ] + . //0VΣЯW4TCH + Idle — press START to begin scanning + All clear + Scanning… + START + STOP + DREAMMAKER / OVERWATCH detection + Foreground notification while scanning + OVERWATCH active + Scanning for nearby surveillance + Bluetooth + location permissions are required + Detection sources + Settings + Detection sources + Proximity thresholds + Appearance + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..958800a --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..a608293 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..288f690 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9deb573 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..39b65de --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..38483d7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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
    EsLPbq@b?0%dSFfh6##JDFBKiMtQGi0ga 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 '} + 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/local.properties.example b/local.properties.example new file mode 100644 index 0000000..12faf36 --- /dev/null +++ b/local.properties.example @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..de863d3 --- /dev/null +++ b/settings.gradle.kts @@ -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")