diff --git a/README.md b/README.md index eff598e..a401dd9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Website: **[overwatch.netslum.io](https://overwatch.netslum.io)** · Latest re | 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 — plus 18 IEEE-verified surveillance-vendor OUIs (ShotSpotter, WatchGuard/Motorola, Verkada, Avigilon Alta, Axis body cams, FLIR, Hanwha, March Networks, GeoVision, Mobotix, Sunell) with vendor-named labels | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. Police-exclusive OUIs (WatchGuard, ShotSpotter) score ORANGE on sight, same rationale as Axon. | +| **BLE** | Bluetooth-LE advertisements: vendor MAC OUIs (Axon, Flock Penguin / Raven, XUNTONG mfg id `0x09C8`, "TN" serial pattern), Raven service UUIDs, device-name patterns — plus 18 IEEE-verified surveillance-vendor OUIs (ShotSpotter, WatchGuard/Motorola, Verkada, Avigilon Alta, Axis body cams, FLIR, Hanwha, March Networks, GeoVision, Mobotix, Sunell) with vendor-named labels | Local radio scan (BLE callback API). Iterates every manufacturer-specific data entry to find XUNTONG, not just the first. **Screen off:** Android suspends unfiltered scans, so the scanner switches to a filtered scan (Raven UUIDs, XUNTONG, mic company ids); OUI-prefix and name matching resume when the screen is on — see [SOURCES.md §5.1](SOURCES.md). Police-exclusive OUIs (WatchGuard, ShotSpotter) score ORANGE on sight, same rationale as Axon. | | **WiFi** | BSSID OUI prefixes for Flock infrastructure (31-prefix superset) + the same 18 vendor OUIs (WatchGuard 4RE in-car APs, Openpath/Alta readers, WiFi-capable cameras), `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 the detection radius (default 500 m), scored by how close each one actually is | POST to Overpass API (`overpass.deflock.org` → fallback `overpass-api.de`) for `man_made=surveillance + surveillance:type=ALPR` in a 5 km bbox; 24 h on-disk cache by 0.05° grid cell. Refetches when the user moves > 1.5 km from the last fetch center. Backoffs after Overpass failures; treats `{"remark": "...timed out..."}` 200-responses as failure so timeouts don't poison the cache. | | **WAZE** | User-reported `POLICE` alerts still active in the feed within the detection radius (default 500 m), up to ~45 min old, scored by distance and age | `api.openwebninja.com/waze/alerts-and-jams` — [OpenWeb Ninja](https://www.openwebninja.com)'s hosted Waze feed, called directly with **your own API key** (`X-API-Key`) entered in Settings and stored encrypted on-device. Sidesteps the reCAPTCHA gating that 403s direct `live-map/api/georss` calls. Polled every ~4 min with `alert_types=POLICE&max_jams=0` (server-side filtering, ~1.5 KB/poll), and the client re-filters by type so a silent upstream change can't let other alert types through. Alerts carry confidence (0–5) + reliability (0–10); high values nudge the score up. No key → source shows "not configured" in the drill-down. | @@ -255,7 +255,7 @@ without an uninstall. | `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 Overpass, OpenWeb Ninja Waze feed, ADS-B aircraft feeds | -| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off | +| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE`, `FOREGROUND_SERVICE_LOCATION` | Keep scanning with the screen off (note: a foreground service does *not* exempt BLE from the unfiltered-scan screen-off rule — see [SOURCES.md §5.1](SOURCES.md)) | | `POST_NOTIFICATIONS` (API 33+) | Foreground-service notification | | `VIBRATE` | Haptic alert on threat-tier escalation | | `SYSTEM_ALERT_WINDOW` | Optional floating threat-circle overlay (special-access; granted via system settings) | diff --git a/SOURCES.md b/SOURCES.md index 6a06726..0c1d413 100644 --- a/SOURCES.md +++ b/SOURCES.md @@ -405,7 +405,66 @@ ordinary traffic passing overhead never raises an alert. --- -## 5. Testing notes +## 5. Platform constraints that shape the radio sources + +The two local-radio sources are limited by OS policy far more than by the +hardware, and the limits are not obvious from the APIs. + +### 5.1 Unfiltered BLE scans are suspended when the screen goes off + +**Since Android 8.1, the Bluetooth stack stops delivering results for a scan +started with no `ScanFilter` once the screen turns off, and a foreground +service does not exempt it** — it is a stack rule, not a process-lifetime one. +For an app whose entire promise is "keep watching while it's in your pocket", +that is the worst possible silent failure: `startScan` returns success, the +service stays alive, the notification keeps updating, and no BLE result ever +arrives. + +The catch is that **a `ScanFilter` cannot express an OUI prefix.** It can match +an exact address, an exact name, a service UUID, or manufacturer data — and +OUI-prefix matching is OVERWATCH's primary BLE method (Axon `00:25:df`, the 24 +Flock prefixes, the 18 vendor prefixes). There is no filter that means "any MAC +starting with these three octets", and no filter that means "any device". + +So the scanner switches strategy on `ACTION_SCREEN_ON` / `ACTION_SCREEN_OFF`: + +| Screen | Scan | Covers | Loses | +|---|---|---|---| +| on | unfiltered | everything — OUI, name, UUID, manufacturer | — | +| off | filtered (≤16) | Raven service UUIDs, XUNTONG manufacturer id, mic-target company ids | OUI prefixes, name substrings | + +Filter slots are a hardware resource and chipsets differ; a common allocation is +16. Overflow is meant to fall back to software filtering, but since a scan that +silently returns nothing is this app's worst failure mode, the list is capped at +16 with surveillance signatures ordered ahead of consumer ones. The BLE row in +the drill-down states the reduced mode while the screen is off rather than +hiding it, and Flock ALPR coverage is unaffected in that window because the map +source does not depend on the radio. + +### 5.2 Other limits worth knowing + +- **BLE start-rate limit** — 5 `startScan` calls per 30 s per app. Exceed it and + the scan *appears* to start but delivers nothing. Screen transitions are rare + enough to stay clear of it. +- **WiFi scan throttling** — since Android 9, foreground apps get 4 scans per + 2 minutes (background: 1 per 30 min). OVERWATCH polls every 35 s ≈ 3.4 per + 2 min, deliberately just under. +- **`WifiManager.startScan()` is deprecated** and Google has stated the ability + for apps to trigger scans will be removed in a future release. The scanner + already treats it as best-effort: it registers for + `SCAN_RESULTS_AVAILABLE_ACTION` and reads whatever the system last scanned, so + when the trigger stops working the source degrades to system-paced results + rather than failing. +- **Promiscuous-mode tricks are not portable.** flock-you's `addr1` and + wildcard-probe techniques need monitor mode; a userspace Android app sees only + what `WifiManager` surfaces, which is BSSID and SSID. +- **Android 14+ foreground-service types** are mandatory: + `connectedDevice` for the radio scanners and `location` for the map/feed + sources. Both are declared and both are passed at `startForeground` time. + +--- + +## 6. Testing notes Emulator GPS is the main obstacle to testing the position-driven sources, and it has a trap worth recording: @@ -438,7 +497,7 @@ methods, which is easy to transpose. --- -## 6. Provenance +## 7. Provenance Reference projects studied while building (kept under a gitignored `REFERENCES/`): diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 653794e..7381a1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "org.soulstone.overwatch" minSdk = 26 targetSdk = 35 - versionCode = 26 - versionName = "0.5.10" + versionCode = 27 + versionName = "0.5.11" } // Fixed debug keystore committed to the repo (a debug key is non-secret — its diff --git a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt index 256cff9..2e7e47a 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt @@ -11,6 +11,7 @@ import android.provider.Settings as AndroidSettings import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -79,6 +80,15 @@ class MainActivity : ComponentActivity() { permanentlyDenied.value = false // reset on activity create val settings = Settings.get(this) + // Android 15 (API 35) draws apps edge-to-edge whether or not they ask, + // for anything targeting 35 — the old statusBarColor/navigationBarColor + // theme attributes became no-ops at the same time. Opting in explicitly + // makes the behaviour identical on older releases instead of the layout + // shifting underneath the user on an upgrade; every screen then pads + // itself with WindowInsets.safeDrawing, which covers the system bars + // *and* a camera cutout. + enableEdgeToEdge() + setContent { val themeMode by settings.themeMode.collectAsState() OverwatchTheme(mode = themeMode) { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt index 85c3b38..eb0a252 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt @@ -138,6 +138,16 @@ object MicTargets { return null } + /** + * Every company id this object recognises. Exposed so BleScanner can turn + * them into ScanFilters for screen-off scanning, where an unfiltered scan + * is silently suspended by the Bluetooth stack. + */ + val COMPANY_IDS: Set = setOf( + MFG_AMAZON, MFG_GOOGLE, MFG_YINGXIN, + MFG_META, MFG_META_TECH, MFG_LUXOTTICA, MFG_SNAP, MFG_VUZIX + ) + fun matchManufacturer(companyId: Int?): Family? = when (companyId) { MFG_AMAZON -> Family.ECHO MFG_GOOGLE -> Family.GOOGLE diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt index d079cb2..21c7367 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt @@ -6,10 +6,15 @@ import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager +import android.os.ParcelUuid import android.os.Build import android.util.Log import androidx.core.content.ContextCompat @@ -34,6 +39,27 @@ import org.soulstone.overwatch.fusion.SourceHealth * - For candidates, build a [ConfidenceEngine.BleObservation] and score it. * - Push to [DetectionStore] if score crosses ALARM_THRESHOLD (40). * + * **Screen-off behaviour.** Since Android 8.1 the Bluetooth stack stops + * delivering results for scans started with no [ScanFilter] once the screen + * turns off, and a foreground service does not exempt it — this is a stack + * rule, not a process-lifetime one. An unfiltered scan therefore goes silent + * exactly when this app is most useful: in a pocket, screen locked. + * + * So the scanner swaps strategies on screen state: + * - **screen on** — unfiltered, full coverage (OUI prefixes, name substrings, + * service UUIDs, manufacturer data). + * - **screen off** — filtered, which keeps delivering. A [ScanFilter] can only + * express an exact address, an exact name, a service UUID or manufacturer + * data; there is no way to express an **OUI prefix**, so MAC-prefix and + * name-substring matching genuinely cannot run with the screen off. What + * survives is what can be named precisely: Raven's service UUIDs and the + * XUNTONG manufacturer id, plus the mic-target company ids when that source + * is on. + * + * The gap is real and deliberate rather than hidden — [SourceHealth] says so on + * the BLE row while the screen is off. Flock ALPR cameras stay covered in that + * window by the map source, which is unaffected. + * * Permissions: caller must hold BLUETOOTH_SCAN (API 31+) or BLUETOOTH+LOCATION (legacy). */ class BleScanner( @@ -47,6 +73,8 @@ class BleScanner( companion object { private const val TAG = "BleScanner" private const val ALARM_THRESHOLD = 40 + /** Conservative ceiling on hardware scan-filter slots. */ + private const val MAX_SCAN_FILTERS = 16 } private val bluetoothAdapter: BluetoothAdapter? by lazy { @@ -56,6 +84,83 @@ class BleScanner( private var leScanner: BluetoothLeScanner? = null private var running = false + private var screenReceiverRegistered = false + /** True while the current scan is the filtered, screen-off variant. */ + @Volatile private var filteredMode = false + + /** + * Filters that keep results flowing with the screen off. Only signatures a + * ScanFilter can actually express — see the class KDoc for what this + * necessarily leaves out. + */ + private fun screenOffFilters(): List { + val out = ArrayList() + // Surveillance signatures first: if the list has to be trimmed below, + // consumer gear is what should fall off the end, not a Raven detector. + out.add( + ScanFilter.Builder() + .setManufacturerData( + org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID, + // Empty data + empty mask matches any payload for that id. + ByteArray(0), ByteArray(0) + ).build() + ) + for (uuid in RavenUuids.ALL) { + out.add(ScanFilter.Builder().setServiceUuid(ParcelUuid(uuid)).build()) + } + if (micEnabled()) { + for (id in MicTargets.COMPANY_IDS) { + out.add( + ScanFilter.Builder() + .setManufacturerData(id, ByteArray(0), ByteArray(0)).build() + ) + } + } + // Filter slots are a hardware resource and chipsets differ — a common + // allocation is 16. Overflow is supposed to fall back to software + // filtering, but a scan that silently returns nothing is the worst + // failure this app can have, so cap rather than gamble. + if (out.size > MAX_SCAN_FILTERS) { + Log.w(TAG, "trimming ${out.size} filters to $MAX_SCAN_FILTERS") + return out.subList(0, MAX_SCAN_FILTERS).toList() + } + return out + } + + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_OFF -> applyMode(filtered = true) + Intent.ACTION_SCREEN_ON -> applyMode(filtered = false) + } + } + } + + /** Restart the scan in the other mode. Screen transitions are rare, so this + * stays well clear of the stack's 5-starts-per-30s ceiling. */ + @SuppressLint("MissingPermission") + private fun applyMode(filtered: Boolean) { + if (!running || filteredMode == filtered) return + val scanner = leScanner ?: return + try { + scanner.stopScan(scanCallback) + val filters = if (filtered) screenOffFilters() else null + scanner.startScan(filters, scanSettings, scanCallback) + filteredMode = filtered + if (filtered) { + Log.i(TAG, "screen off — filtered scan (${filters?.size} filters)") + SourceHealth.record( + DetectionSource.BLE, ok = true, + message = "Screen off — filtered scan; OUI/name matching resumes when the screen is on" + ) + } else { + Log.i(TAG, "screen on — unfiltered scan") + SourceHealth.record(DetectionSource.BLE, ok = true) + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException switching scan mode", e) + } + } private val scanSettings: ScanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) @@ -99,10 +204,28 @@ class BleScanner( return false } try { - leScanner?.startScan(null, scanSettings, scanCallback) + // Start in whichever mode matches the screen right now: the service + // can be started from a notification action with the screen already + // off, and an unfiltered scan there would deliver nothing at all. + val screenOn = try { + (context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager) + ?.isInteractive != false + } catch (e: Exception) { true } + filteredMode = !screenOn + leScanner?.startScan( + if (filteredMode) screenOffFilters() else null, + scanSettings, + scanCallback + ) running = true - SourceHealth.record(DetectionSource.BLE, ok = true) - Log.i(TAG, "BLE scan started") + registerScreenReceiver() + SourceHealth.record( + DetectionSource.BLE, ok = true, + message = if (filteredMode) + "Screen off — filtered scan; OUI/name matching resumes when the screen is on" + else null + ) + Log.i(TAG, "BLE scan started (filtered=$filteredMode)") return true } catch (e: SecurityException) { Log.e(TAG, "SecurityException starting scan", e) @@ -120,9 +243,33 @@ class BleScanner( Log.e(TAG, "SecurityException stopping scan", e) } running = false + filteredMode = false + unregisterScreenReceiver() Log.i(TAG, "BLE scan stopped") } + private fun registerScreenReceiver() { + if (screenReceiverRegistered) return + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + // Screen on/off are protected system broadcasts, so this must be an + // exported-style registration; ContextCompat's NOT_EXPORTED flag would + // drop them on API 34+. + androidx.core.content.ContextCompat.registerReceiver( + context, screenReceiver, filter, + androidx.core.content.ContextCompat.RECEIVER_EXPORTED + ) + screenReceiverRegistered = true + } + + private fun unregisterScreenReceiver() { + if (!screenReceiverRegistered) return + try { context.unregisterReceiver(screenReceiver) } catch (_: IllegalArgumentException) { } + screenReceiverRegistered = false + } + private val scanCallback = object : ScanCallback() { @SuppressLint("MissingPermission") override fun onScanResult(callbackType: Int, result: ScanResult) { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt index 4227a11..5bd697d 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt @@ -1,5 +1,6 @@ package org.soulstone.overwatch.service +import android.os.Build import android.annotation.SuppressLint import android.content.Context import android.content.Intent @@ -120,6 +121,15 @@ class OverlayManager( WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT ).apply { + // Keep the bubble out of a camera cutout. DEFAULT is already the + // platform behaviour in portrait, but it is stated here because the + // bubble is user-draggable and free-floating: on a punch-hole or + // notch display the alternative (ALWAYS) would happily park the + // threat circle underneath the camera. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } gravity = Gravity.TOP or Gravity.START x = (INITIAL_X_DP * density).toInt() y = (INITIAL_Y_DP * density).toInt() diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt index 5cf449f..5ca7479 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt @@ -12,6 +12,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -93,6 +96,9 @@ fun MainScreen( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + // Background first, then insets: the colour still runs under the + // status bar and the cutout, only the content is held clear. + .windowInsetsPadding(WindowInsets.safeDrawing) .padding(horizontal = 24.dp) ) { // Box (rather than Row + SpaceBetween) so the title is truly centered diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt index 37db70e..9edeced 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -8,6 +8,9 @@ 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.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -72,6 +75,9 @@ fun SettingsScreen( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + // Inset before the scroll container so content scrolls inside the + // safe area rather than under the bars or a cutout. + .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp, vertical = 8.dp) ) { diff --git a/docs/index.html b/docs/index.html index a271362..00885ab 100644 --- a/docs/index.html +++ b/docs/index.html @@ -53,7 +53,7 @@
- v0.5.10 · Android · passive + v0.5.11 · Android · passive

OVERWATCH @@ -75,7 +75,7 @@
0fused sources
0threat tiers
0packets sent
-
0releases
+
0releases

@@ -432,7 +432,7 @@
  • Grab the latest debug-signed APK from Releases - (currently v0.5.10).
  • + (currently v0.5.11).
@@ -501,7 +501,7 @@ transmit, probe, jam, or interfere with any device or network.

Also on Tor