diff --git a/.gitignore b/.gitignore index b0e79a8..5f9be43 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt b/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt index ea83004..2104d20 100644 --- a/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt +++ b/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt @@ -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() } @@ -32,6 +33,7 @@ object AdvPayloadDecoder { return when (short) { 0xFE2C -> decodeFastPair(bytes) 0xFEAA -> decodeEddystone(bytes) + 0xCBF0 -> decodeMeshtastic(bytes) else -> emptyList() } } @@ -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) { @@ -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 } @@ -620,6 +651,37 @@ object AdvPayloadDecoder { return emptyList() } + private fun decodeSamsung(bytes: ByteArray): List { + if (bytes.isEmpty()) return listOf(Field("Samsung payload", "empty")) + val out = ArrayList(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 { + val out = ArrayList(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 diff --git a/app/src/main/java/app/fieldwatch/domain/RadarPlot.kt b/app/src/main/java/app/fieldwatch/domain/RadarPlot.kt index 44a8a0e..04966f2 100644 --- a/app/src/main/java/app/fieldwatch/domain/RadarPlot.kt +++ b/app/src/main/java/app/fieldwatch/domain/RadarPlot.kt @@ -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 @@ -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() + } } diff --git a/app/src/main/java/app/fieldwatch/domain/RfAnomalyDetector.kt b/app/src/main/java/app/fieldwatch/domain/RfAnomalyDetector.kt new file mode 100644 index 0000000..845f606 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/domain/RfAnomalyDetector.kt @@ -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, + 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 + } + } +} diff --git a/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt b/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt index b971439..3fa8b8b 100644 --- a/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt +++ b/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt @@ -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, diff --git a/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt b/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt index a640ea1..583b517 100644 --- a/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt +++ b/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt @@ -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 @@ -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, @@ -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, @@ -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) { diff --git a/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt b/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt index 53ac0d0..f72d127 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt @@ -599,7 +599,7 @@ private fun radarPoint( rssi: Double = device.rssi.toDouble(), zoom: Float = 1f, ): Offset { - val angle = radarAngle(device.mac) + val angle = RadarPlot.contactAngle(device) val dist = radarRadius(rssi, maxR, zoom) return Offset( center.x + (cos(angle) * dist).toFloat(), @@ -607,6 +607,15 @@ private fun radarPoint( ) } +private fun sweepBehindDegrees(sweepDeg: Float, device: Sighting): Float { + val blip = RadarPlot.contactDegrees(device) + var beam = (270f + sweepDeg) % 360f + if (beam < 0f) beam += 360f + var behind = beam - blip + while (behind < 0f) behind += 360f + return behind +} + private fun sweepBehindDegrees(sweepDeg: Float, mac: String): Float { val blip = ((mac.hashCode() ushr 1) % 360).toFloat() var beam = (270f + sweepDeg) % 360f @@ -657,9 +666,24 @@ private fun DrawScope.drawRadarContact( gone: Boolean, flashElapsedMs: Long?, alertedRing: Color? = null, + trueBearing: Boolean = false, ) { val rad = if (named) 11f else 7f if (named && !gone) drawCircle(color.copy(alpha = 0.28f), radius = 20f, center = pos) + if (trueBearing && !gone) { + val d = rad + 5f + drawPath( + path = androidx.compose.ui.graphics.Path().apply { + moveTo(pos.x, pos.y - d) + lineTo(pos.x + d, pos.y) + lineTo(pos.x, pos.y + d) + lineTo(pos.x - d, pos.y) + close() + }, + color = color, + style = Stroke(width = 1.8f), + ) + } if (flashElapsedMs == null) { drawCircle(color, radius = rad, center = pos) drawCircle(Color.Black.copy(alpha = 0.35f), radius = rad, center = pos, style = Stroke(1.4f)) @@ -832,6 +856,16 @@ private fun RadarView( drawLine(ring.copy(alpha = 0.4f), Offset(c.x - maxR, c.y), Offset(c.x + maxR, c.y), 2f) drawLine(ring.copy(alpha = 0.4f), Offset(c.x, c.y - maxR), Offset(c.x, c.y + maxR), 2f) + val cardStyle = ringStyle.copy(fontWeight = FontWeight.Bold, color = ring.copy(alpha = 0.85f)) + val nLayout = measurer.measure("N", cardStyle) + val sLayout = measurer.measure("S", cardStyle) + val eLayout = measurer.measure("E", cardStyle) + val wLayout = measurer.measure("W", cardStyle) + drawText(nLayout, topLeft = Offset(c.x - nLayout.size.width / 2f, c.y - maxR + 4f)) + drawText(sLayout, topLeft = Offset(c.x - sLayout.size.width / 2f, c.y + maxR - sLayout.size.height - 4f)) + drawText(eLayout, topLeft = Offset(c.x + maxR - eLayout.size.width - 6f, c.y - eLayout.size.height / 2f)) + drawText(wLayout, topLeft = Offset(c.x - maxR + 6f, c.y - wLayout.size.height / 2f)) + rotate(sweep.floatValue, c) { drawRadarSweep(c, maxR, beam, night) } @@ -843,7 +877,7 @@ private fun RadarView( if (!RadarPlot.onDisc(plotRssi.toInt(), maxR, z)) return null val pos = radarPoint(device, c, maxR, plotRssi, z) val named = device.fleetIds.isNotEmpty() - val paint = if (persist) sweepPaint(sweepBehindDegrees(sweep.floatValue, device.mac)) else 1f + val paint = if (persist) sweepPaint(sweepBehindDegrees(sweep.floatValue, device)) else 1f val alpha = (if (device.gone) 0.45f else 1f) * (0.35f + 0.65f * paint) val color = (device.fleetIds.firstOrNull() ?.let { Color(Palette.color(vm.fleetColor(it))) } @@ -854,8 +888,9 @@ private fun RadarView( } devices.forEach { device -> val (pos, color, named) = contact(device) ?: return@forEach + val trueBearing = RadarPlot.hasTrueBearing(device) if (device.key !in flashKeys && device.key !in alertedKeys) { - drawRadarContact(pos, color, named, device.gone, null) + drawRadarContact(pos, color, named, device.gone, null, trueBearing = trueBearing) } val rad = if (named) 11f else 7f val label = when { @@ -880,13 +915,15 @@ private fun RadarView( if (device.key in flashKeys || device.key !in alertedKeys) return@forEach val (pos, color, named) = contact(device, persist = false) ?: return@forEach val ring = phosphor.copy(alpha = if (device.gone) 0.45f else 1f) - drawRadarContact(pos, color, named, device.gone, null, alertedRing = ring) + val trueBearing = RadarPlot.hasTrueBearing(device) + drawRadarContact(pos, color, named, device.gone, null, alertedRing = ring, trueBearing = trueBearing) } devices.forEach { device -> if (device.key !in flashKeys) return@forEach val (pos, color, named) = contact(device, persist = false) ?: return@forEach val started = flashAt[device.key] ?: now - drawRadarContact(pos, color, named, device.gone, now - started) + val trueBearing = RadarPlot.hasTrueBearing(device) + drawRadarContact(pos, color, named, device.gone, now - started, trueBearing = trueBearing) } drawCircle(youColor, radius = 7f, center = c) diff --git a/app/src/test/java/app/fieldwatch/domain/AdvPayloadDecoderTest.kt b/app/src/test/java/app/fieldwatch/domain/AdvPayloadDecoderTest.kt index 70ae685..270b23c 100644 --- a/app/src/test/java/app/fieldwatch/domain/AdvPayloadDecoderTest.kt +++ b/app/src/test/java/app/fieldwatch/domain/AdvPayloadDecoderTest.kt @@ -125,4 +125,52 @@ class AdvPayloadDecoderTest { val tesla = bleSighting(DefaultCatalog.TESLA_IBEACON_MFG_PREFIX + "00010002C5") assertTrue(AdvPayloadDecoder.roleHints(tesla).any { it.label.contains("Tesla") }) } + + @Test + fun samsungSmartTagDecodesManufacturerPayload() { + val fields = AdvPayloadDecoder.decodeManufacturer(MfgRecord(0x0075, "010203040506070809")) + val byLabel = fields.associate { it.label to it.value } + assertEquals("Samsung Electronics (0x0075)", byLabel["Manufacturer"]) + assertEquals("Galaxy SmartTag (SmartThings Find)", byLabel["Product"]) + } + + @Test + fun meshtasticDecodesServicePayload() { + val fields = AdvPayloadDecoder.decodeService(ServiceDataRecord("CBF0", "01020304")) + val byLabel = fields.associate { it.label to it.value } + assertEquals("Meshtastic LoRa Mesh", byLabel["Network"]) + assertEquals("0xCBF0", byLabel["Service UUID"]) + } + + @Test + fun roleHintsDetectMeshtasticNode() { + val sighting = Sighting( + key = "BLE:AA:BB:CC:DD:EE:11", + kind = RadioKind.BLE, + mac = "AA:BB:CC:DD:EE:11", + name = "CustomNodeCallsign", + rssi = -65, + rssiMin = -65, + rssiMax = -65, + channel = 0, + frequencyMhz = 0, + vendor = null, + randomized = false, + hiddenSsid = false, + serviceUuids = listOf("0000CBF0-0000-1000-8000-00805F9B34FB"), + manufacturerId = null, + manufacturerDataHex = "", + rawHex = "", + extras = "", + firstSeen = 1L, + lastSeen = 1L, + hitCount = 1, + fleetIds = emptySet(), + rssiHistory = emptyList(), + presence = emptyList(), + facts = RadioFacts(serviceData = listOf(ServiceDataRecord("CBF0", "AABBCCDD"))), + ) + val hints = AdvPayloadDecoder.roleHints(sighting) + assertTrue(hints.any { it.bucket == "mesh" && it.label.contains("Meshtastic") }) + } } diff --git a/app/src/test/java/app/fieldwatch/domain/RadarPlotTest.kt b/app/src/test/java/app/fieldwatch/domain/RadarPlotTest.kt index 8293e62..41dc563 100644 --- a/app/src/test/java/app/fieldwatch/domain/RadarPlotTest.kt +++ b/app/src/test/java/app/fieldwatch/domain/RadarPlotTest.kt @@ -4,6 +4,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.math.PI class RadarPlotTest { private val maxR = 1000f @@ -33,4 +34,96 @@ class RadarPlotTest { assertEquals(RadarPlot.radius(-50, maxR, 1f), RadarPlot.radius(-50, maxR, 0.2f), 0.01f) assertEquals(RadarPlot.radius(-50, maxR, 4f), RadarPlot.radius(-50, maxR, 9f), 0.01f) } + + @Test + fun geographicBearingCalculatesCardinalDirections() { + // Due North + val north = RadarPlot.initialBearing(0.0, 0.0, 1.0, 0.0) + assertEquals(0.0, north, 0.1) + assertEquals(-PI / 2.0, RadarPlot.bearingToScreenAngle(north), 0.01) + + // Due East + val east = RadarPlot.initialBearing(0.0, 0.0, 0.0, 1.0) + assertEquals(90.0, east, 0.1) + assertEquals(0.0, RadarPlot.bearingToScreenAngle(east), 0.01) + + // Due South + val south = RadarPlot.initialBearing(1.0, 0.0, 0.0, 0.0) + assertEquals(180.0, south, 0.1) + assertEquals(PI / 2.0, RadarPlot.bearingToScreenAngle(south), 0.01) + + // Due West + val west = RadarPlot.initialBearing(0.0, 1.0, 0.0, 0.0) + assertEquals(270.0, west, 0.1) + } + + @Test + fun trueBearingAppliedWhenPayloadCoordinatesExist() { + val droneSighting = Sighting( + key = "BLE:11:22:33:44:55:66", + kind = RadioKind.BLE, + mac = "11:22:33:44:55:66", + name = "DJI Mavic", + rssi = -60, + rssiMin = -60, + rssiMax = -60, + channel = 0, + frequencyMhz = 0, + vendor = null, + randomized = false, + hiddenSsid = false, + serviceUuids = emptyList(), + manufacturerId = null, + manufacturerDataHex = "", + rawHex = "", + extras = "", + firstSeen = 1L, + lastSeen = 1L, + hitCount = 1, + fleetIds = emptySet(), + rssiHistory = emptyList(), + presence = emptyList(), + latitude = 18.4861, + longitude = -69.9312, + payloadLat = 18.4961, // North of phone + payloadLon = -69.9312, + ) + + assertTrue(RadarPlot.hasTrueBearing(droneSighting)) + val angle = RadarPlot.contactAngle(droneSighting) + // North corresponds to -PI/2 on screen + assertEquals(-PI / 2.0, angle, 0.05) + } + + @Test + fun fallbackToMacHashWhenNoCoordinates() { + val radio = Sighting( + key = "BLE:AA:BB:CC:DD:EE:FF", + kind = RadioKind.BLE, + mac = "AA:BB:CC:DD:EE:FF", + name = "Beacon", + rssi = -70, + rssiMin = -70, + rssiMax = -70, + channel = 0, + frequencyMhz = 0, + vendor = null, + randomized = false, + hiddenSsid = false, + serviceUuids = emptyList(), + manufacturerId = null, + manufacturerDataHex = "", + rawHex = "", + extras = "", + firstSeen = 1L, + lastSeen = 1L, + hitCount = 1, + fleetIds = emptySet(), + rssiHistory = emptyList(), + presence = emptyList(), + ) + + assertFalse(RadarPlot.hasTrueBearing(radio)) + assertEquals(RadarPlot.macAngle("AA:BB:CC:DD:EE:FF"), RadarPlot.contactAngle(radio), 0.001) + } } diff --git a/app/src/test/java/app/fieldwatch/domain/RfAnomalyDetectorTest.kt b/app/src/test/java/app/fieldwatch/domain/RfAnomalyDetectorTest.kt new file mode 100644 index 0000000..26339ba --- /dev/null +++ b/app/src/test/java/app/fieldwatch/domain/RfAnomalyDetectorTest.kt @@ -0,0 +1,84 @@ +package app.fieldwatch.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RfAnomalyDetectorTest { + + private fun mockEphemeralBle( + index: Int, + companyId: Int? = null, + serviceUuid: String? = null, + firstSeen: Long = 10_000L, + ): Sighting { + val mac = "02:00:00:00:00:%02X".format(index) + return Sighting( + key = "BLE:$mac", + kind = RadioKind.BLE, + mac = mac, + name = "", + rssi = -65, + rssiMin = -65, + rssiMax = -65, + channel = 37, + frequencyMhz = 2402, + vendor = null, + randomized = true, + hiddenSsid = false, + serviceUuids = serviceUuid?.let { listOf(it) } ?: emptyList(), + manufacturerId = companyId, + manufacturerDataHex = companyId?.let { "0707010F2051981100" } ?: "", + rawHex = "", + extras = "", + firstSeen = firstSeen, + lastSeen = firstSeen + 100, + hitCount = 1, + fleetIds = emptySet(), + rssiHistory = emptyList(), + presence = emptyList(), + facts = RadioFacts( + mfgRecords = companyId?.let { listOf(MfgRecord(it, "0707010F2051981100")) } ?: emptyList(), + serviceData = serviceUuid?.let { listOf(ServiceDataRecord(it, "010203")) } ?: emptyList(), + ), + ) + } + + @Test + fun quietEnvironmentReturnsNoSpamAlert() { + val normalSightings = listOf( + mockEphemeralBle(1, companyId = 0x004C), + mockEphemeralBle(2, companyId = 0x004C), + ) + val result = RfAnomalyDetector.assessBleSpam(normalSightings, now = 10_500L) + assertFalse(result.isSpamAttackActive) + assertEquals(2, result.burstCount) + } + + @Test + fun highRateApplePairingFloodTriggersThreatAlert() { + // Generate 20 ephemeral devices in a 2-second burst with Apple Continuity (0x004C) + val spamBurst = (1..20).map { i -> + mockEphemeralBle(i, companyId = 0x004C, firstSeen = 9_500L) + } + + val result = RfAnomalyDetector.assessBleSpam(spamBurst, now = 10_000L, windowMs = 5_000L) + assertTrue(result.isSpamAttackActive) + assertEquals(20, result.burstCount) + assertTrue(result.primaryVector?.contains("Apple") == true) + assertTrue(result.details?.contains("Flipper Zero") == true) + } + + @Test + fun fastPairFloodIdentifiesGoogleVector() { + // Generate 16 ephemeral FastPair devices (0xFE2C) + val fastPairBurst = (1..16).map { i -> + mockEphemeralBle(i, serviceUuid = "FE2C", firstSeen = 9_800L) + } + + val result = RfAnomalyDetector.assessBleSpam(fastPairBurst, now = 10_000L, windowMs = 5_000L) + assertTrue(result.isSpamAttackActive) + assertTrue(result.primaryVector?.contains("FastPair") == true) + } +}