Skip to content

Commit 96ac953

Browse files
committed
fix: reduce tracker false positives in radar
1 parent ffdf9a1 commit 96ac953

10 files changed

Lines changed: 109 additions & 46 deletions

File tree

core/data/src/main/java/io/blueeye/core/data/evidence/DeviceEvidenceFactory.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,8 @@ private fun DeviceEntity.hasKnownTrackerSignal(): Boolean {
650650
val hasStrongNonTrackerIdentity =
651651
!lastDeviceName.isNullOrBlank() &&
652652
deviceType in STRONG_NON_TRACKER_DEVICE_TYPES
653+
val hasNonTrackerName = lastDeviceName.isKnownNonTrackerName()
654+
if (hasNonTrackerName) return false
653655

654656
return isKnownTrackerType() ||
655657
(
@@ -676,6 +678,12 @@ private fun DeviceEntity.trackerDeviceType(): DeviceType {
676678
private fun String?.isKnownTrackerBeacon(): Boolean =
677679
this != null && TRACKER_BEACON_KEYWORDS.any { keyword -> contains(keyword, ignoreCase = true) }
678680

681+
private fun String?.isKnownNonTrackerName(): Boolean {
682+
if (isNullOrBlank()) return false
683+
684+
return NON_TRACKER_NAME_KEYWORDS.any { keyword -> contains(keyword, ignoreCase = true) }
685+
}
686+
679687
private val KNOWN_TRACKER_TYPES =
680688
setOf(
681689
DeviceType.AIRTAG,
@@ -709,3 +717,17 @@ private val TRACKER_BEACON_KEYWORDS =
709717
AIRTAG_TRACKER_KEYWORDS +
710718
TILE_TRACKER_KEYWORDS +
711719
SAMSUNG_TAG_TRACKER_KEYWORDS
720+
721+
private val NON_TRACKER_NAME_KEYWORDS =
722+
listOf(
723+
"[tv]",
724+
" tv",
725+
"tv ",
726+
"television",
727+
"samsung 5 series",
728+
"samsung q",
729+
"signage",
730+
"monitor",
731+
"display",
732+
"webos",
733+
)

core/data/src/main/java/io/blueeye/core/data/repository/handler/ble/BleScanHandler.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,6 @@ class BleScanHandler @Inject constructor(
269269

270270
val shouldAlert = alertDecisionEngine.shouldAlert(
271271
isIgnored = isIgnored,
272-
isKnownTracker = isKnownTracker,
273272
userHasMoved = userHasMoved,
274273
isZastane = isBaselineDevice,
275274
trackingStatus = result.status
@@ -353,7 +352,7 @@ private fun FollowMeScoreCalculator.ScoreResult.toFollowMeAlertConfidence(
353352
when {
354353
status == TrackingStatus.DANGEROUS -> DetectionConfidence.HIGH
355354
status == TrackingStatus.SUSPICIOUS -> DetectionConfidence.MEDIUM
356-
isKnownTracker -> DetectionConfidence.HIGH
355+
isKnownTracker -> DetectionConfidence.LOW
357356
else -> DetectionConfidence.LOW
358357
}
359358

core/data/src/main/java/io/blueeye/core/data/repository/handler/ble/DeviceTypeResolver.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,6 @@ object DeviceTypeResolver {
5959
vendorType: DeviceType,
6060
scanType: DeviceType,
6161
): DeviceType {
62-
return when {
63-
vendorType != DeviceType.UNKNOWN -> vendorType
64-
scanType != DeviceType.UNKNOWN -> scanType
65-
else -> DeviceType.UNKNOWN
66-
}
62+
return listOf(vendorType, scanType).maxByOrNull { getPriority(it) } ?: DeviceType.UNKNOWN
6763
}
6864
}

core/data/src/main/java/io/blueeye/core/data/repository/handler/ble/ScanResultClassifier.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,10 @@ class ScanResultClassifier @Inject constructor(
6262
scanType = ctx.deviceType,
6363
)
6464
} else {
65-
// For new devices, prefer vendor type over scan type
66-
if (ctx.vendorDeviceType != DeviceType.UNKNOWN) {
67-
ctx.vendorDeviceType
68-
} else {
69-
ctx.deviceType
70-
}
65+
DeviceTypeResolver.resolveForNew(
66+
vendorType = ctx.vendorDeviceType,
67+
scanType = ctx.deviceType,
68+
)
7169
}
7270
}
7371

core/data/src/main/java/io/blueeye/core/data/tracker/alert/AlertDecisionEngine.kt

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,38 +18,32 @@ class AlertDecisionEngine @Inject constructor() {
1818
*
1919
* Decision matrix:
2020
* 1. Device is ignored → NO alert (user whitelist)
21-
* 2. Device is AirTag/Tile/SmartTag → alert eligible from tracker evidence
22-
* 3. User hasn't moved → NO movement-pattern alert
23-
* 4. Device was seen before movement (zastane) → NO alert (baseline device)
24-
* 5. Score indicates high or suspicious evidence → alert based on status
21+
* 2. User hasn't moved → NO movement-pattern alert
22+
* 3. Device was seen before movement (zastane) → NO alert (baseline device)
23+
* 4. Score indicates high or suspicious evidence → alert based on status
2524
*
2625
* @param isIgnored User manually marked device as safe
27-
* @param isKnownTracker AirTag, Tile, Samsung SmartTag
2826
* @param userHasMoved User moved >50m since session start
2927
* @param isZastane Device was seen before user started moving
3028
* @param trackingStatus Calculated tracking status from score
3129
* @return true if alert should be triggered
3230
*/
3331
fun shouldAlert(
3432
isIgnored: Boolean,
35-
isKnownTracker: Boolean,
3633
userHasMoved: Boolean,
3734
isZastane: Boolean,
3835
trackingStatus: TrackingStatus
3936
): Boolean {
4037
// Rule 1: Ignored devices never alert
4138
if (isIgnored) return false
4239

43-
// Rule 2: Known trackers are alert eligible from tracker evidence.
44-
if (isKnownTracker) return true
45-
46-
// Rule 3: No movement = no movement-pattern alerts.
40+
// Rule 2: No movement = no movement-pattern alerts.
4741
if (!userHasMoved) return false
4842

49-
// Rule 4: Baseline devices don't alert
43+
// Rule 3: Baseline devices don't alert
5044
if (isZastane) return false
5145

52-
// Rule 5: Alert based on status
46+
// Rule 4: Alert based on status. Known tracker type is evidence, not an alert by itself.
5347
return trackingStatus == TrackingStatus.DANGEROUS ||
5448
trackingStatus == TrackingStatus.SUSPICIOUS
5549
}
@@ -66,11 +60,11 @@ class AlertDecisionEngine @Inject constructor() {
6660
): String {
6761
return when {
6862
isIgnored -> "Device is manually ignored"
69-
isKnownTracker -> "Known tracker device (AirTag/Tile/SmartTag)"
7063
!userHasMoved -> "User hasn't moved - alerts disabled"
7164
isZastane -> "Device was present before movement (baseline)"
7265
trackingStatus == TrackingStatus.DANGEROUS -> "High follow-me score needs evidence review"
7366
trackingStatus == TrackingStatus.SUSPICIOUS -> "Possible follow-me pattern needs evidence review"
67+
isKnownTracker -> "Known tracker evidence present without movement-pattern alert"
7468
else -> "Low risk - no alert needed"
7569
}
7670
}

core/data/src/test/java/io/blueeye/core/data/tracker/alert/AlertDecisionEngineTest.kt

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ class AlertDecisionEngineTest {
2424
fun `ignored device - never alerts even if dangerous`() {
2525
val result = engine.shouldAlert(
2626
isIgnored = true,
27-
isKnownTracker = false,
2827
userHasMoved = true,
2928
isZastane = false,
3029
trackingStatus = TrackingStatus.DANGEROUS
@@ -35,39 +34,47 @@ class AlertDecisionEngineTest {
3534
@Test
3635
fun `ignored known tracker - still no alert`() {
3736
val result = engine.shouldAlert(
38-
isIgnored = true,
39-
isKnownTracker = true, // Even AirTag!
37+
isIgnored = true, // Even AirTag!
4038
userHasMoved = true,
4139
isZastane = false,
4240
trackingStatus = TrackingStatus.DANGEROUS
4341
)
4442
assertFalse("Ignored known tracker should not alert", result)
4543
}
4644

47-
// ==================== RULE 2: Known trackers ALWAYS alert ====================
45+
// ==================== RULE 2: Movement context is required ====================
4846

4947
@Test
50-
fun `known tracker - alerts immediately even without movement`() {
48+
fun `known tracker - does not alert without movement pattern`() {
5149
val result = engine.shouldAlert(
5250
isIgnored = false,
53-
isKnownTracker = true,
5451
userHasMoved = false, // User at home
5552
isZastane = false,
5653
trackingStatus = TrackingStatus.SAFE // Even low score
5754
)
58-
assertTrue("Known tracker should alert regardless of movement/score", result)
55+
assertFalse("Known tracker evidence alone should not alert", result)
5956
}
6057

6158
@Test
62-
fun `known tracker - alerts even if zastane`() {
59+
fun `known tracker - baseline safe device does not alert`() {
6360
val result = engine.shouldAlert(
6461
isIgnored = false,
65-
isKnownTracker = true,
6662
userHasMoved = true,
6763
isZastane = true, // Was there before
64+
trackingStatus = TrackingStatus.SAFE
65+
)
66+
assertFalse("Baseline known tracker should not alert without movement-pattern status", result)
67+
}
68+
69+
@Test
70+
fun `known tracker with suspicious movement pattern alerts`() {
71+
val result = engine.shouldAlert(
72+
isIgnored = false,
73+
userHasMoved = true,
74+
isZastane = false,
6875
trackingStatus = TrackingStatus.SUSPICIOUS
6976
)
70-
assertTrue("Known tracker alerts even if it was baseline", result)
77+
assertTrue("Known tracker with suspicious movement evidence should alert", result)
7178
}
7279

7380
// ==================== RULE 3: No movement = no alerts ====================
@@ -76,7 +83,6 @@ class AlertDecisionEngineTest {
7683
fun `user at home - no alerts for suspicious devices`() {
7784
val result = engine.shouldAlert(
7885
isIgnored = false,
79-
isKnownTracker = false,
8086
userHasMoved = false,
8187
isZastane = false,
8288
trackingStatus = TrackingStatus.SUSPICIOUS
@@ -88,7 +94,6 @@ class AlertDecisionEngineTest {
8894
fun `user at home - no alerts for dangerous devices`() {
8995
val result = engine.shouldAlert(
9096
isIgnored = false,
91-
isKnownTracker = false,
9297
userHasMoved = false,
9398
isZastane = false,
9499
trackingStatus = TrackingStatus.DANGEROUS
@@ -102,7 +107,6 @@ class AlertDecisionEngineTest {
102107
fun `zastane device - no alert after movement`() {
103108
val result = engine.shouldAlert(
104109
isIgnored = false,
105-
isKnownTracker = false,
106110
userHasMoved = true,
107111
isZastane = true, // Was there before user moved
108112
trackingStatus = TrackingStatus.DANGEROUS
@@ -116,7 +120,6 @@ class AlertDecisionEngineTest {
116120
fun `dangerous device after movement - alerts`() {
117121
val result = engine.shouldAlert(
118122
isIgnored = false,
119-
isKnownTracker = false,
120123
userHasMoved = true,
121124
isZastane = false,
122125
trackingStatus = TrackingStatus.DANGEROUS
@@ -128,7 +131,6 @@ class AlertDecisionEngineTest {
128131
fun `suspicious device after movement - alerts`() {
129132
val result = engine.shouldAlert(
130133
isIgnored = false,
131-
isKnownTracker = false,
132134
userHasMoved = true,
133135
isZastane = false,
134136
trackingStatus = TrackingStatus.SUSPICIOUS
@@ -140,7 +142,6 @@ class AlertDecisionEngineTest {
140142
fun `safe device after movement - no alert`() {
141143
val result = engine.shouldAlert(
142144
isIgnored = false,
143-
isKnownTracker = false,
144145
userHasMoved = true,
145146
isZastane = false,
146147
trackingStatus = TrackingStatus.SAFE
@@ -165,6 +166,6 @@ class AlertDecisionEngineTest {
165166
isIgnored = false, isKnownTracker = true, userHasMoved = false,
166167
isZastane = false, trackingStatus = TrackingStatus.SAFE
167168
)
168-
assertTrue(explanation.contains("tracker"))
169+
assertTrue(explanation.contains("moved"))
169170
}
170171
}

core/data/src/test/kotlin/io/blueeye/core/data/evidence/DeviceEvidenceFactoryTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,27 @@ class DeviceEvidenceFactoryTest {
655655
assertTrue(evidence.any { it.source == EvidenceSource.RAW_PAYLOAD && it.confidence == DetectionConfidence.LOW })
656656
}
657657

658+
@Test
659+
fun `named samsung tv is not tracker evidence even when stale type is tag`() {
660+
val evidence =
661+
DeviceEvidenceFactory.build(
662+
device(
663+
mac = "AA:BB:CC:11:22:33",
664+
name = "[TV] Samsung 5 Series (32)",
665+
).copy(
666+
deviceType = DeviceType.TAG,
667+
beaconType = "Samsung SmartTag",
668+
),
669+
)
670+
671+
assertTrue(
672+
evidence.none {
673+
it.confidence == DetectionConfidence.MEDIUM &&
674+
it.reasonText.contains("Bluetooth tracker accessory")
675+
},
676+
)
677+
}
678+
658679
@Test
659680
fun `domain device exposes deterministic evidence`() {
660681
val domainDevice =

core/data/src/test/kotlin/io/blueeye/core/data/repository/handler/ble/BleScanHandlerWatchlistReturnTest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,6 @@ class BleScanHandlerWatchlistReturnTest {
271271
whenever(
272272
alertDecisionEngine.shouldAlert(
273273
isIgnored = false,
274-
isKnownTracker = false,
275274
userHasMoved = true,
276275
isZastane = false,
277276
trackingStatus = TrackingStatus.SUSPICIOUS,
@@ -436,7 +435,6 @@ class BleScanHandlerWatchlistReturnTest {
436435
)
437436
verify(alertDecisionEngine, never()).shouldAlert(
438437
isIgnored = any(),
439-
isKnownTracker = any(),
440438
userHasMoved = any(),
441439
isZastane = any(),
442440
trackingStatus = any(),
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.blueeye.core.data.repository.handler.ble
2+
3+
import io.blueeye.core.model.DeviceType
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
class DeviceTypeResolverTest {
8+
@Test
9+
fun `new device keeps strong scan identity over generic tracker vendor signal`() {
10+
val resolved =
11+
DeviceTypeResolver.resolveForNew(
12+
vendorType = DeviceType.TAG,
13+
scanType = DeviceType.TV,
14+
)
15+
16+
assertEquals(DeviceType.TV, resolved)
17+
}
18+
19+
@Test
20+
fun `new device still uses vendor type when scan identity is unknown`() {
21+
val resolved =
22+
DeviceTypeResolver.resolveForNew(
23+
vendorType = DeviceType.SAMSUNG_TAG,
24+
scanType = DeviceType.UNKNOWN,
25+
)
26+
27+
assertEquals(DeviceType.SAMSUNG_TAG, resolved)
28+
}
29+
}

feature/radar/src/main/java/io/blueeye/feature/radar/presentation/RadarScreen.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,12 @@ fun RadarScreen(
233233
RadarDeviceItem(
234234
item = item,
235235
onClick = { onDeviceClick(item.fingerprint) },
236-
onFavoriteClick = { viewModel.toggleFavorite(it) },
236+
onFavoriteClick = { device ->
237+
viewModel.toggleFavorite(device)
238+
if (!device.isInWatchlist) {
239+
selectedSectionView = RadarSectionViewType.WATCHLIST
240+
}
241+
},
237242
onIgnoreClick = { viewModel.toggleIgnore(it) },
238243
onCalibrationClick = { device, label ->
239244
viewModel.updateCalibrationLabel(device, label)

0 commit comments

Comments
 (0)