Skip to content

Commit aa1e45e

Browse files
committed
fix: avoid fast pair samsung misclassification
1 parent dbce7be commit aa1e45e

8 files changed

Lines changed: 109 additions & 16 deletions

File tree

core/data/src/main/java/io/blueeye/core/data/classifier/pipeline/NameClassifier.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ object NameClassifier {
1616

1717
return checkHeadphones(lowerName)
1818
?: checkWearables(lowerName)
19+
?: checkTrackers(lowerName)
1920
?: checkPhones(lowerName)
2021
?: checkComputers(lowerName)
2122
?: checkTv(lowerName)
22-
?: checkTrackers(lowerName)
2323
?: DeviceType.UNKNOWN
2424
}
2525

core/data/src/main/java/io/blueeye/core/data/classifier/vendor/strategy/SamsungStrategy.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ constructor(
1414
) : VendorStrategy {
1515
override fun canHandle(input: VendorScanInput): Boolean {
1616
return input.hasManufacturer(ManufacturerIds.SAMSUNG) ||
17-
input.hasServiceUuid(SMARTTHINGS_FIND_UUID) ||
18-
input.hasServiceUuid(QUICK_SHARE_UUID)
17+
input.hasServiceUuid(SMARTTHINGS_FIND_UUID)
1918
}
2019

2120
override fun decode(input: VendorScanInput): VendorScanResult {
@@ -30,7 +29,7 @@ constructor(
3029
if (deviceType == DeviceType.UNKNOWN) deviceType = DeviceType.SAMSUNG_TAG
3130
}
3231

33-
if (input.hasServiceUuid(QUICK_SHARE_UUID)) {
32+
if (input.hasManufacturer(ManufacturerIds.SAMSUNG) && input.hasServiceUuid(QUICK_SHARE_UUID)) {
3433
extraInfo += "Quick Share / Ecosystem. "
3534
if (deviceType == DeviceType.UNKNOWN) deviceType = DeviceType.PHONE
3635
}

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

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ object DeviceTypeResolver {
2525
modelNumber: String?,
2626
vendorType: DeviceType,
2727
scanType: DeviceType,
28+
nameType: DeviceType,
2829
): DeviceType {
2930
// 1. Model-based classification is the strongest identity signal.
3031
if (modelNumber != null) {
@@ -40,7 +41,8 @@ object DeviceTypeResolver {
4041
// - Specific > Generic
4142

4243
val candidates = listOf(existingType, vendorType, scanType)
43-
return candidates.maxByOrNull { getPriority(it) } ?: DeviceType.UNKNOWN
44+
val resolved = candidates.maxByOrNull { getPriority(it) } ?: DeviceType.UNKNOWN
45+
return resolveExplicitNameConflict(nameType, resolved)
4446
}
4547

4648
private fun getPriority(type: DeviceType): Int {
@@ -58,7 +60,44 @@ object DeviceTypeResolver {
5860
fun resolveForNew(
5961
vendorType: DeviceType,
6062
scanType: DeviceType,
63+
nameType: DeviceType,
6164
): DeviceType {
62-
return listOf(vendorType, scanType).maxByOrNull { getPriority(it) } ?: DeviceType.UNKNOWN
65+
val resolved = listOf(vendorType, scanType).maxByOrNull { getPriority(it) } ?: DeviceType.UNKNOWN
66+
return resolveExplicitNameConflict(nameType, resolved)
6367
}
68+
69+
private fun resolveExplicitNameConflict(
70+
nameType: DeviceType,
71+
resolvedType: DeviceType,
72+
): DeviceType {
73+
if (nameType == DeviceType.UNKNOWN) return resolvedType
74+
if (resolvedType == DeviceType.UNKNOWN || resolvedType == nameType) return nameType
75+
76+
return if (nameType in EXPLICIT_NAME_TYPES && resolvedType in CONSUMER_IDENTITY_TYPES) {
77+
nameType
78+
} else {
79+
resolvedType
80+
}
81+
}
82+
83+
private val EXPLICIT_NAME_TYPES =
84+
setOf(
85+
DeviceType.HEADPHONES,
86+
DeviceType.SPEAKER,
87+
DeviceType.TV,
88+
DeviceType.PHONE,
89+
DeviceType.TABLET,
90+
DeviceType.LAPTOP,
91+
DeviceType.PC,
92+
DeviceType.CONSOLE,
93+
DeviceType.WEARABLE,
94+
DeviceType.WATCH,
95+
DeviceType.AIRTAG,
96+
DeviceType.TILE,
97+
DeviceType.SAMSUNG_TAG,
98+
DeviceType.TRACKER,
99+
DeviceType.TAG,
100+
)
101+
102+
private val CONSUMER_IDENTITY_TYPES = EXPLICIT_NAME_TYPES + DeviceType.AUDIO_VIDEO
64103
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ class ScanResultClassifier @Inject constructor(
6060
modelNumber = existing.modelNumber,
6161
vendorType = ctx.vendorDeviceType,
6262
scanType = ctx.deviceType,
63+
nameType = deviceClassifier.classifyByName(ctx.sanitizedName ?: ctx.name),
6364
)
6465
} else {
6566
DeviceTypeResolver.resolveForNew(
6667
vendorType = ctx.vendorDeviceType,
6768
scanType = ctx.deviceType,
69+
nameType = deviceClassifier.classifyByName(ctx.sanitizedName ?: ctx.name),
6870
)
6971
}
7072
}

core/data/src/test/kotlin/io/blueeye/core/data/classifier/vendor/strategy/SamsungStrategyTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.blueeye.core.data.classifier.vendor.VendorScanInput
44
import io.blueeye.core.decoders.parser.samsung.SamsungManufacturerParser
55
import io.blueeye.core.model.DeviceType
66
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertFalse
78
import org.junit.Assert.assertTrue
89
import org.junit.Test
910
import org.mockito.kotlin.mock
@@ -31,7 +32,20 @@ class SamsungStrategyTest {
3132
verifyNoInteractions(manufacturerParser)
3233
}
3334

35+
@Test
36+
fun `Fast Pair service uuid alone is not treated as Samsung Quick Share`() {
37+
val input =
38+
VendorScanInput(
39+
manufacturerRecords = emptyMap(),
40+
serviceUuids = listOf(FAST_PAIR_UUID),
41+
)
42+
43+
assertFalse(strategy.canHandle(input))
44+
verifyNoInteractions(manufacturerParser)
45+
}
46+
3447
private companion object {
48+
private const val FAST_PAIR_UUID = "0000fe2c-0000-1000-8000-00805f9b34fb"
3549
private const val SMARTTHINGS_FIND_UUID = "0000fd5a-0000-1000-8000-00805f9b34fb"
3650
}
3751
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class DeviceTypeResolverTest {
1111
DeviceTypeResolver.resolveForNew(
1212
vendorType = DeviceType.TAG,
1313
scanType = DeviceType.TV,
14+
nameType = DeviceType.TV,
1415
)
1516

1617
assertEquals(DeviceType.TV, resolved)
@@ -22,8 +23,21 @@ class DeviceTypeResolverTest {
2223
DeviceTypeResolver.resolveForNew(
2324
vendorType = DeviceType.SAMSUNG_TAG,
2425
scanType = DeviceType.UNKNOWN,
26+
nameType = DeviceType.UNKNOWN,
2527
)
2628

2729
assertEquals(DeviceType.SAMSUNG_TAG, resolved)
2830
}
31+
32+
@Test
33+
fun `explicit audio name wins over generic phone vendor signal`() {
34+
val resolved =
35+
DeviceTypeResolver.resolveForNew(
36+
vendorType = DeviceType.PHONE,
37+
scanType = DeviceType.HEADPHONES,
38+
nameType = DeviceType.HEADPHONES,
39+
)
40+
41+
assertEquals(DeviceType.HEADPHONES, resolved)
42+
}
2943
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,27 @@ internal object RadarUiCardOrder {
44
val comparator: Comparator<RadarUiItem> =
55
compareByDescending<RadarUiItem> { it.isInWatchlist }
66
.thenByDescending { it.isNew }
7+
.thenByDescending { signalBucket(it.signalInfo.rssi) }
8+
.thenByDescending { normalizedRssi(it.signalInfo.rssi) }
9+
.thenByDescending { it.device.lastSeenAt }
710
.thenByDescending { it.device.firstSeenAt }
811
.thenBy { it.displayName.lowercase() }
912
.thenBy { it.fingerprint }
13+
14+
private fun signalBucket(rssi: Int): Int =
15+
when {
16+
rssi == UNKNOWN_RSSI -> 0
17+
rssi >= STRONG_RSSI -> 3
18+
rssi >= MEDIUM_RSSI -> 2
19+
rssi >= WEAK_RSSI -> 1
20+
else -> 0
21+
}
22+
23+
private fun normalizedRssi(rssi: Int): Int =
24+
if (rssi == UNKNOWN_RSSI) Int.MIN_VALUE else rssi
25+
26+
private const val STRONG_RSSI = -60
27+
private const val MEDIUM_RSSI = -80
28+
private const val WEAK_RSSI = -90
29+
private const val UNKNOWN_RSSI = 0
1030
}

feature/radar/src/test/java/io/blueeye/feature/radar/presentation/RadarUiCardOrderTest.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,25 @@ import org.junit.Test
99

1010
class RadarUiCardOrderTest {
1111
@Test
12-
fun `card order stays stable when rssi changes`() {
13-
val firstScan =
12+
fun `strong nearby devices sort before newer weak devices`() {
13+
val sorted =
1414
listOf(
15-
item(fingerprint = "bravo", displayName = "Bravo", rssi = -42),
16-
item(fingerprint = "alpha", displayName = "Alpha", rssi = -88),
15+
item(fingerprint = "weak-new", displayName = "A weak", rssi = -93, firstSeenAt = NOW + 2_000),
16+
item(fingerprint = "strong-old", displayName = "B strong", rssi = -52, firstSeenAt = NOW),
1717
).sortedWith(RadarUiCardOrder.comparator)
1818

19-
val nextScan =
19+
assertEquals(listOf("strong-old", "weak-new"), sorted.map { it.fingerprint })
20+
}
21+
22+
@Test
23+
fun `same signal bucket uses rssi before recency`() {
24+
val sorted =
2025
listOf(
21-
item(fingerprint = "bravo", displayName = "Bravo", rssi = -88),
22-
item(fingerprint = "alpha", displayName = "Alpha", rssi = -42),
26+
item(fingerprint = "medium-weaker-new", displayName = "A medium", rssi = -78, firstSeenAt = NOW + 2_000),
27+
item(fingerprint = "medium-stronger-old", displayName = "B medium", rssi = -65, firstSeenAt = NOW),
2328
).sortedWith(RadarUiCardOrder.comparator)
2429

25-
assertEquals(listOf("alpha", "bravo"), firstScan.map { it.fingerprint })
26-
assertEquals(firstScan.map { it.fingerprint }, nextScan.map { it.fingerprint })
30+
assertEquals(listOf("medium-stronger-old", "medium-weaker-new"), sorted.map { it.fingerprint })
2731
}
2832

2933
@Test
@@ -44,6 +48,7 @@ class RadarUiCardOrderTest {
4448
rssi: Int = -60,
4549
isInWatchlist: Boolean = false,
4650
isNew: Boolean = false,
51+
firstSeenAt: Long = NOW,
4752
): RadarUiItem =
4853
RadarUiMapper.mapToUi(
4954
device =
@@ -64,7 +69,7 @@ class RadarUiCardOrderTest {
6469
userNotes = null,
6570
alertSound = false,
6671
alertVibration = false,
67-
firstSeenAt = NOW,
72+
firstSeenAt = firstSeenAt,
6873
lastSeenAt = NOW,
6974
rssi = rssi,
7075
encounterCount = 1,

0 commit comments

Comments
 (0)