Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ research/
scripts/.lookup-cache/

# Machine JDK path belongs in JAVA_HOME, not the tree

# Working screenshots and scripts
/*.png
install_fieldwatch.*
62 changes: 62 additions & 0 deletions app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ object AdvPayloadDecoder {
0x004C -> decodeApple(bytes)
0x0006 -> decodeMicrosoft(bytes)
0x0157 -> decodeAltBeacon(bytes)
0x0075 -> decodeSamsung(bytes)
0x00E0 -> listOf(Field("Google manufacturer data", "${bytes.size} bytes"))
else -> emptyList()
}
Expand All @@ -32,6 +33,7 @@ object AdvPayloadDecoder {
return when (short) {
0xFE2C -> decodeFastPair(bytes)
0xFEAA -> decodeEddystone(bytes)
0xCBF0 -> decodeMeshtastic(bytes)
else -> emptyList()
}
}
Expand Down Expand Up @@ -95,8 +97,29 @@ object AdvPayloadDecoder {
}
}
}
for (rec in mfg) {
if (rec.companyId == 0x0075) {
val bytes = hexToBytes(rec.dataHex) ?: continue
if (bytes.size >= 4) {
out += RoleHint(
"tag",
"a Samsung Galaxy SmartTag or SmartThings device",
"Samsung SmartThings Find advertisement (0x0075).",
7,
)
}
}
}
for (sd in device.facts.serviceData) {
when (uuid16(sd.uuid)) {
0xCBF0 -> {
out += RoleHint(
"mesh",
"a Meshtastic LoRa mesh node",
"Meshtastic off-grid mesh radio service (UUID 0xCBF0).",
9,
)
}
0xFE2C -> {
val bytes = hexToBytes(sd.dataHex) ?: continue
if (bytes.size == 3) {
Expand Down Expand Up @@ -133,6 +156,14 @@ object AdvPayloadDecoder {
}
}
}
if (device.serviceUuids.any { uuid16(it) == 0xCBF0 } && out.none { it.bucket == "mesh" }) {
out += RoleHint(
"mesh",
"a Meshtastic LoRa mesh node",
"Meshtastic off-grid mesh radio service (UUID 0xCBF0).",
9,
)
}
return out
}

Expand Down Expand Up @@ -620,6 +651,37 @@ object AdvPayloadDecoder {
return emptyList()
}

private fun decodeSamsung(bytes: ByteArray): List<Field> {
if (bytes.isEmpty()) return listOf(Field("Samsung payload", "empty"))
val out = ArrayList<Field>(4)
out += Field("Manufacturer", "Samsung Electronics (0x0075)")
if (bytes.size >= 6) {
val tagType = when (bytes[0].toInt() and 0xFF) {
0x01 -> "Galaxy SmartTag (SmartThings Find)"
0x02 -> "Galaxy SmartTag2"
0x42 -> "SmartThings Find Offline Beacon"
else -> "Samsung SmartThings Accessory"
}
out += Field("Product", tagType)
out += Field("SmartThings payload", "${bytes.size} bytes: ${bytes.toHexUpper()}")
} else {
out += Field("Samsung data", "${bytes.size} bytes: ${bytes.toHexUpper()}")
}
return out
}

private fun decodeMeshtastic(bytes: ByteArray): List<Field> {
val out = ArrayList<Field>(4)
out += Field("Network", "Meshtastic LoRa Mesh")
out += Field("Service UUID", "0xCBF0")
if (bytes.isNotEmpty()) {
out += Field("Node broadcast", "${bytes.size} bytes: ${bytes.toHexUpper()}")
} else {
out += Field("Role", "Meshtastic node companion service")
}
return out
}

private fun modelId24(bytes: ByteArray): Int =
((bytes[0].toInt() and 0xFF) shl 16) or
((bytes[1].toInt() and 0xFF) shl 8) or
Expand Down
96 changes: 93 additions & 3 deletions app/src/main/java/app/fieldwatch/domain/RadarPlot.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package app.fieldwatch.domain

import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin

/**
* Polar radius on Classic radar. Stronger RSSI sits closer to YOU.
* [zoom] > 1 stretches the plot so loud radios spread out and weak ones
* leave the disc. Angle is not part of this mapping.
* Polar radius and angular placement on Classic radar.
* Stronger RSSI sits closer to YOU.
* [zoom] > 1 stretches the plot so loud radios spread out and weak ones leave the disc.
*
* For devices broadcasting georeferenced coordinates (e.g. ASTM F3411 Remote ID drones),
* contactAngle computes the true forward geographic azimuth / bearing relative to North.
* Radios without coordinates fall back to a stable deterministic hash angle from their MAC.
*/
object RadarPlot {
const val MIN_ZOOM = 1f
Expand All @@ -18,4 +26,86 @@ object RadarPlot {

fun onDisc(rssi: Int, maxR: Float, zoom: Float): Boolean =
radius(rssi, maxR, zoom) <= maxR + 0.5f

/**
* Deterministic fallback angle based on MAC address in radians.
*/
fun macAngle(mac: String): Double {
val hash = mac.hashCode()
return ((hash ushr 1) % 360) * Math.PI / 180.0
}

/**
* Calculates the true forward azimuth / geographic initial bearing in degrees [0, 360)
* from (fromLat, fromLon) to (toLat, toLon) using spherical navigation.
*/
fun initialBearing(fromLat: Double, fromLon: Double, toLat: Double, toLon: Double): Double {
val phi1 = Math.toRadians(fromLat)
val phi2 = Math.toRadians(toLat)
val deltaLambda = Math.toRadians(toLon - fromLon)

val y = sin(deltaLambda) * cos(phi2)
val x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(deltaLambda)
val theta = atan2(y, x)
return (Math.toDegrees(theta) + 360.0) % 360.0
}

/**
* Converts a geographic bearing (0 deg = North) to polar screen angle in radians
* for a canvas where +X is East (right) and +Y is South (down):
* North (0 deg) -> -PI/2 (top)
* East (90 deg) -> 0 (right)
* South (180 deg) -> +PI/2 (bottom)
* West (270 deg) -> PI (left)
*/
fun bearingToScreenAngle(bearingDeg: Double): Double {
var deg = (bearingDeg - 90.0) % 360.0
if (deg > 180.0) deg -= 360.0
if (deg < -180.0) deg += 360.0
return Math.toRadians(deg)
}

/**
* Determines whether the sighting has a valid true geographic bearing calculation.
*/
fun hasTrueBearing(
device: Sighting,
refLat: Double? = device.latitude,
refLon: Double? = device.longitude,
): Boolean {
val targetLat = device.payloadLat
val targetLon = device.payloadLon
return targetLat != null && targetLon != null && refLat != null && refLon != null &&
PayloadLocation.validCoord(targetLat, targetLon) && PayloadLocation.validCoord(refLat, refLon)
}

/**
* Returns the angular placement in radians for drawing on the radar canvas.
*/
fun contactAngle(
device: Sighting,
refLat: Double? = device.latitude,
refLon: Double? = device.longitude,
): Double {
if (hasTrueBearing(device, refLat, refLon)) {
val bearing = initialBearing(refLat!!, refLon!!, device.payloadLat!!, device.payloadLon!!)
return bearingToScreenAngle(bearing)
}
return macAngle(device.mac)
}

/**
* Returns the contact's polar position in degrees [0, 360) for sweep fade / phosphor timing.
*/
fun contactDegrees(
device: Sighting,
refLat: Double? = device.latitude,
refLon: Double? = device.longitude,
): Float {
if (hasTrueBearing(device, refLat, refLon)) {
val bearing = initialBearing(refLat!!, refLon!!, device.payloadLat!!, device.payloadLon!!)
return bearing.toFloat()
}
return ((device.mac.hashCode() ushr 1) % 360).toFloat()
}
}
104 changes: 104 additions & 0 deletions app/src/main/java/app/fieldwatch/domain/RfAnomalyDetector.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package app.fieldwatch.domain

/**
* Tactical RF anomaly detection engine.
*
* Detects adversarial radio phenomena in real-time, such as:
* - High-rate BLE advertising spam floods (Flipper Zero, ESP32 Marauder, Bruce, SourApple).
*
* These attacks broadcast rapid bursts of spoofed pairing frames (AirPods popups, FastPair,
* SwiftPair) with rotating randomized MAC addresses, designed to cause notification spam,
* denial-of-service, or confusion.
*/
object RfAnomalyDetector {

data class SpamAssessment(
val isSpamAttackActive: Boolean,
val burstCount: Int,
val primaryVector: String? = null,
val details: String? = null,
)

/**
* Assesses a collection of sightings for active BLE advertising spam attacks.
*
* @param sightings The active list of sightings.
* @param now Current timestamp in milliseconds.
* @param windowMs Observation window (default 5 seconds).
* @param thresholdPerWindow Minimum count of ephemeral pairing frames in the window to trigger alert.
*/
fun assessBleSpam(
sightings: Collection<Sighting>,
now: Long = System.currentTimeMillis(),
windowMs: Long = 5_000L,
thresholdPerWindow: Int = 12,
): SpamAssessment {
val windowStart = now - windowMs

// Filter sightings first heard in this window with low hitCount (ephemeral transient MACs)
val recentEphemerals = sightings.filter { s ->
s.kind == RadioKind.BLE &&
s.firstSeen >= windowStart &&
s.hitCount <= 3
}

if (recentEphemerals.size < thresholdPerWindow) {
return SpamAssessment(isSpamAttackActive = false, burstCount = recentEphemerals.size)
}

var applePairingCount = 0
var fastPairCount = 0
var microsoftCount = 0

for (s in recentEphemerals) {
val mfg = s.facts.mfgRecords.ifEmpty {
s.manufacturerId?.let { listOf(MfgRecord(it, s.manufacturerDataHex)) } ?: emptyList()
}
if (mfg.any { it.companyId == 0x004C }) {
applePairingCount++
}
if (mfg.any { it.companyId == 0x0006 }) {
microsoftCount++
}
if (s.facts.serviceData.any { sd -> uuid16(sd.uuid) == 0xFE2C }) {
fastPairCount++
}
}

val maxVectorCount = maxOf(applePairingCount, fastPairCount, microsoftCount)
if (maxVectorCount < 6 && recentEphemerals.size < thresholdPerWindow * 2) {
return SpamAssessment(isSpamAttackActive = false, burstCount = recentEphemerals.size)
}

val primaryVector = when {
applePairingCount >= fastPairCount && applePairingCount >= microsoftCount ->
"Apple Proximity Pairing / AirDrop Flood"
fastPairCount >= applePairingCount && fastPairCount >= microsoftCount ->
"Google FastPair Flood"
microsoftCount >= 6 ->
"Microsoft SwiftPair Flood"
else -> "Multi-Vector BLE Pairing Flood"
}

val rateSec = recentEphemerals.size.toDouble() / (windowMs.toDouble() / 1000.0)
val details = "High burst rate (%.1f ads/sec) of ephemeral pairing beacons. Characteristic signature of Flipper Zero or ESP32 Marauder tools."
.format(rateSec)

return SpamAssessment(
isSpamAttackActive = true,
burstCount = recentEphemerals.size,
primaryVector = primaryVector,
details = details,
)
}

private fun uuid16(uuid: String): Int? {
val hex = uuid.filter { it.isLetterOrDigit() }.uppercase()
return when {
hex.length == 4 -> hex.toIntOrNull(16)
hex.length == 32 && hex.startsWith("0000") && hex.endsWith("00001000800000805F9B34FB") ->
hex.substring(4, 8).toIntOrNull(16)
else -> null
}
}
}
14 changes: 13 additions & 1 deletion app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,19 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) {
HeaderCount(state.wifiNow, Icons.Outlined.Wifi, "Wi-Fi")
HeaderCount(state.bleNow, Icons.Outlined.Bluetooth, "BLE")
HeaderCount(state.namedNow, Icons.Outlined.Hub, "signatures")
if (state.throttleHint.isNotBlank()) {
if (state.rfThreatHint.isNotBlank()) {
Text(
"· ${state.rfThreatHint}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
} else if (state.throttleHint.isNotBlank()) {
Text(
"· ${state.throttleHint}",
style = MaterialTheme.typography.labelSmall,
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import app.fieldwatch.domain.FamilyVerdict
import app.fieldwatch.domain.LogRadio
import app.fieldwatch.domain.RadioBookmarks
import app.fieldwatch.domain.RadioKind
import app.fieldwatch.domain.RfAnomalyDetector
import app.fieldwatch.domain.RssiSample
import app.fieldwatch.domain.Sighting
import app.fieldwatch.domain.SignatureCandidate
Expand Down Expand Up @@ -133,6 +134,7 @@ data class FieldwatchUi(
val namedNow: Int = 0,
val logLines: Long = 0,
val throttleHint: String = "",
val rfThreatHint: String = "",
val hiddenKnown: Int = 0,
val arrivalsLearning: Boolean = false,
val displayPaused: Boolean = false,
Expand Down Expand Up @@ -298,6 +300,12 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati
.thenByDescending { it.sortRssi(config.settings.strengthSort, windowMs, now) }
},
)
val spamAssessment = RfAnomalyDetector.assessBleSpam(labeled, now)
val threatHint = if (spamAssessment.isSpamAttackActive) {
"⚠️ BLE FLOOD (${spamAssessment.burstCount})"
} else {
""
}
FieldwatchUi(
devices = labeled,
filtered = filtered,
Expand All @@ -315,6 +323,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati
namedNow = stats.namedNow,
logLines = stats.logLines,
throttleHint = stats.throttleHint,
rfThreatHint = threatHint,
hiddenKnown = hiddenKnown,
arrivalsLearning = learning,
operatorSpanM = if (moveCtx.ready || config.filter.movingWithYou) {
Expand Down
Loading