v0.5.11 — Android 15/16 + display-cutout compatibility, BLE screen-off fix

Two separate compatibility bugs, both reproduced before fixing.

**Edge-to-edge.** Android 15 draws apps edge-to-edge whether or not they ask
for anything targeting SDK 35, and the statusBarColor/navigationBarColor theme
attributes this app relied on became no-ops at the same time. Reproduced on a
fresh Android 16 emulator with a punch-hole cutout: the shipped v0.5.11-1 drew
"OVERWATCH" inside the status bar next to the clock, buried the settings gear
under the wifi and battery icons where it could not be tapped, and jammed the
permission hint into the gesture bar. MainActivity now calls enableEdgeToEdge()
explicitly — so the behaviour is the same on older releases rather than
shifting under the user on an OS upgrade — and both screens pad themselves with
WindowInsets.safeDrawing, which covers the system bars and the cutout. The
background still runs edge to edge; only content is held clear. The overlay
bubble states LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT rather than relying on it,
since a draggable free-floating window could otherwise park under a camera hole.

**BLE with the screen off.** 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 — it is a stack rule, not a
process-lifetime one. This app called startScan(null, ...) while promising to
keep watching from a pocket, so BLE detection was silently dead in exactly the
case that matters. A ScanFilter cannot express an OUI prefix, so the primary
MAC-prefix method genuinely cannot run with the screen off; what can be named
precisely still can. The scanner now switches on ACTION_SCREEN_ON/OFF:
unfiltered while the screen is on, and a filtered scan (Raven service UUIDs,
the XUNTONG manufacturer id, mic-target company ids) while it is off, capped at
16 filters because slots are a hardware resource and a silently empty scan is
the worst failure this app has. The drill-down says so rather than hiding it.

Verified on Android 16 (API 36) and Android 14: foreground service starts with
types=0x18, all five scanners run, screen off logs "filtered scan (16 filters)"
and screen on returns to unfiltered, zero scan failures, zero crashes.

SOURCES.md gains a platform-constraints section covering these plus the BLE
5-starts-per-30s limit, WiFi scan throttling, and WifiManager.startScan()'s
deprecation and planned removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsfSpnwuxCkcXKkD9XH8SR
This commit is contained in:
KaraZajac
2026-09-17 00:13:46 -04:00
co-authored by Claude Opus 5
parent 19622b87e1
commit 76faa840b1
10 changed files with 261 additions and 13 deletions
+2 -2
View File
@@ -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) |
+61 -2
View File
@@ -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/`):
+2 -2
View File
@@ -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
@@ -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) {
@@ -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<Int> = 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
@@ -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<ScanFilter> {
val out = ArrayList<ScanFilter>()
// 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) {
@@ -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()
@@ -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
@@ -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)
) {
+4 -4
View File
@@ -53,7 +53,7 @@
<div class="container hero-inner">
<div class="hero-eyebrow">
<span class="dot dot-pulse"></span>
v0.5.10 · Android · passive
v0.5.11 · Android · passive
</div>
<h1 class="hero-title">
<span class="display-wordmark">OVERWATCH</span>
@@ -75,7 +75,7 @@
<div class="stat-chip"><span class="num" data-target="6">0</span><span>fused sources</span></div>
<div class="stat-chip stat-vfy"><span class="num" data-target="4">0</span><span>threat tiers</span></div>
<div class="stat-chip"><span class="num" data-target="0">0</span><span>packets sent</span></div>
<div class="stat-chip"><span class="num" data-target="25">0</span><span>releases</span></div>
<div class="stat-chip"><span class="num" data-target="26">0</span><span>releases</span></div>
</div>
<div class="cta-row">
@@ -432,7 +432,7 @@
<ul>
<li>Grab the latest debug-signed APK from
<a href="https://github.com/KaraZajac/OVERWATCH/releases/latest">Releases</a>
(currently <strong>v0.5.10</strong>).</li>
(currently <strong>v0.5.11</strong>).</li>
</ul>
</div>
<div class="tl-col tl-active">
@@ -501,7 +501,7 @@
transmit, probe, jam, or interfere with any device or network.
</p>
<p class="footer-meta">
v0.5.10 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
v0.5.11 · <a href="https://github.com/KaraZajac/OVERWATCH">github.com/KaraZajac/OVERWATCH</a>
</p>
<div class="onion-line">
<span class="onion-label">Also on Tor</span>