Skip to content

Commit e9cc12e

Browse files
committed
fix: guard apple ble identity classification
1 parent 18b4498 commit e9cc12e

15 files changed

Lines changed: 443 additions & 21 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package io.blueeye.core.data.classifier
2+
3+
import io.blueeye.core.data.classifier.pipeline.NameClassifier
4+
import io.blueeye.core.model.DeviceType
5+
6+
internal object AppleIdentityConflictGuard {
7+
fun preferredNameTypeForConflict(
8+
name: String?,
9+
candidateType: DeviceType,
10+
): DeviceType? {
11+
val nameType = NameClassifier.classify(name)
12+
return nameType.takeIf {
13+
hasConflict(
14+
name = name,
15+
nameType = it,
16+
candidateType = candidateType,
17+
)
18+
}
19+
}
20+
21+
fun hasLabelConflict(
22+
name: String?,
23+
label: String?,
24+
): Boolean {
25+
val candidateType = typeForAppleLabel(label)
26+
if (candidateType == DeviceType.UNKNOWN) return false
27+
return preferredNameTypeForConflict(name, candidateType) != null
28+
}
29+
30+
fun hasNameFamilyConflict(
31+
firstName: String?,
32+
secondName: String?,
33+
): Boolean {
34+
if (!isAppleIdentityName(firstName) || !isAppleIdentityName(secondName)) return false
35+
36+
val firstType = NameClassifier.classify(firstName)
37+
val secondType = NameClassifier.classify(secondName)
38+
return firstType != DeviceType.UNKNOWN &&
39+
secondType != DeviceType.UNKNOWN &&
40+
firstType != secondType
41+
}
42+
43+
fun typeForAppleLabel(label: String?): DeviceType =
44+
label
45+
?.takeIf { it.isNotBlank() }
46+
?.lowercase()
47+
?.let(::typeForNormalizedAppleLabel)
48+
?: DeviceType.UNKNOWN
49+
50+
private fun typeForNormalizedAppleLabel(normalized: String): DeviceType =
51+
if (normalized == "mac") {
52+
DeviceType.LAPTOP
53+
} else {
54+
APPLE_LABEL_TYPE_RULES
55+
.firstOrNull { rule -> rule.keywords.any(normalized::contains) }
56+
?.deviceType
57+
?: DeviceType.UNKNOWN
58+
}
59+
60+
private fun hasConflict(
61+
name: String?,
62+
nameType: DeviceType,
63+
candidateType: DeviceType,
64+
): Boolean =
65+
isAppleIdentityName(name) &&
66+
nameType != DeviceType.UNKNOWN &&
67+
candidateType != DeviceType.UNKNOWN &&
68+
nameType != candidateType
69+
70+
private fun isAppleIdentityName(name: String?): Boolean {
71+
if (name.isNullOrBlank()) return false
72+
val normalized = name.lowercase()
73+
74+
return APPLE_IDENTITY_KEYWORDS.any(normalized::contains)
75+
}
76+
77+
private val APPLE_IDENTITY_KEYWORDS =
78+
listOf(
79+
"airpods",
80+
"airpod",
81+
"beats",
82+
"powerbeats",
83+
"iphone",
84+
"ipad",
85+
"macbook",
86+
"imac",
87+
"mac mini",
88+
"mac studio",
89+
"apple watch",
90+
"homepod",
91+
"apple tv",
92+
)
93+
94+
private val APPLE_LABEL_TYPE_RULES =
95+
listOf(
96+
AppleLabelTypeRule(
97+
keywords = listOf("airpods", "airpod", "beats", "powerbeats"),
98+
deviceType = DeviceType.HEADPHONES,
99+
),
100+
AppleLabelTypeRule(
101+
keywords = listOf("iphone"),
102+
deviceType = DeviceType.PHONE,
103+
),
104+
AppleLabelTypeRule(
105+
keywords = listOf("ipad"),
106+
deviceType = DeviceType.TABLET,
107+
),
108+
AppleLabelTypeRule(
109+
keywords = listOf("macbook", "mac desktop", "mac mini", "mac studio", "imac"),
110+
deviceType = DeviceType.LAPTOP,
111+
),
112+
AppleLabelTypeRule(
113+
keywords = listOf("apple watch"),
114+
deviceType = DeviceType.WEARABLE,
115+
),
116+
AppleLabelTypeRule(
117+
keywords = listOf("homepod", "apple tv"),
118+
deviceType = DeviceType.AUDIO_VIDEO,
119+
),
120+
AppleLabelTypeRule(
121+
keywords = listOf("airtag", "find my", "findmy"),
122+
deviceType = DeviceType.TRACKER,
123+
),
124+
)
125+
126+
private data class AppleLabelTypeRule(
127+
val keywords: List<String>,
128+
val deviceType: DeviceType,
129+
)
130+
}

core/data/src/main/java/io/blueeye/core/data/classifier/DeviceClassifier.kt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,24 +63,29 @@ class DeviceClassifier @Inject constructor(
6363
* Comprehensive BLE classification using all available data sources.
6464
*
6565
* Priority order:
66-
* 1. Apple Continuity Protocol (AirTag, AirPods detection)
67-
* 2. Service UUIDs (Tile, beacons, fitness)
68-
* 3. Service/manufacturer-data fingerprints
69-
* 4. BLE Appearance
70-
* 5. Device Name heuristics
71-
* 6. Vendor Name fallback
66+
* 1. Apple family names when Apple payload decoding conflicts with the visible identity
67+
* 2. Apple Continuity Protocol (AirTag, AirPods detection)
68+
* 3. Service UUIDs (Tile, beacons, fitness)
69+
* 4. Service/manufacturer-data fingerprints
70+
* 5. BLE Appearance
71+
* 6. Device Name heuristics
72+
* 7. Vendor Name fallback
7273
*
7374
* @param input Classification input data
7475
* @return Best-effort DeviceType classification
7576
*/
7677
fun classifyBle(input: BleClassificationInput): DeviceType {
7778
var result = DeviceType.UNKNOWN
79+
val nameType = classifyByName(input.deviceName)
7880

7981
// 1. Apple Continuity Protocol (highest priority for Apple devices)
8082
val appleManufacturerData = input.manufacturerRecords[ManufacturerIds.APPLE]
8183
if (appleManufacturerData != null) {
8284
val appleInfo = appleContinuityParser.parse(appleManufacturerData)
8385
result = mapAppleToDeviceType(appleInfo)
86+
AppleIdentityConflictGuard
87+
.preferredNameTypeForConflict(input.deviceName, result)
88+
?.let { result = it }
8489
}
8590

8691
// 2. Service UUIDs (trackers, fitness, beacons)
@@ -91,6 +96,9 @@ class DeviceClassifier @Inject constructor(
9196
// 3. Precise service/manufacturer-data fingerprints
9297
if (result == DeviceType.UNKNOWN) {
9398
result = classifyKnownFingerprint(input)
99+
AppleIdentityConflictGuard
100+
.preferredNameTypeForConflict(input.deviceName, result)
101+
?.let { result = it }
94102
}
95103

96104
// 4. BLE Appearance
@@ -100,7 +108,7 @@ class DeviceClassifier @Inject constructor(
100108

101109
// 5. Device Name heuristics
102110
if (result == DeviceType.UNKNOWN) {
103-
result = classifyByName(input.deviceName)
111+
result = nameType
104112
}
105113

106114
// 6. Vendor Name fallback

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.blueeye.core.data.repository.handler.ble
22

33
import android.util.Log
4+
import io.blueeye.core.data.classifier.AppleIdentityConflictGuard
45
import io.blueeye.core.data.classifier.chipset.ChipsetIdentifier
56
import io.blueeye.core.data.db.dao.DeviceDao
67
import io.blueeye.core.data.db.dao.SignalSampleDao
@@ -49,14 +50,18 @@ class DevicePersister @Inject constructor(
4950
classifier: ScanResultClassifier,
5051
): Boolean {
5152
val candidateType = classifier.resolveType(ctx)
52-
val resolvedType = priorityHelper.resolveBetterType(existing.deviceType, candidateType)
53+
val bestName = NameUtils.resolveBestName(existing.lastDeviceName, ctx.name ?: ctx.vendorModel ?: ctx.beaconType)
54+
val nameCorrectedType =
55+
AppleIdentityConflictGuard.preferredNameTypeForConflict(
56+
name = bestName,
57+
candidateType = existing.deviceType,
58+
)
59+
val resolvedType = nameCorrectedType ?: priorityHelper.resolveBetterType(existing.deviceType, candidateType)
5360

5461
val hasNewName = ctx.name != null && ctx.name != existing.lastDeviceName
5562
val hasNewType = resolvedType != existing.deviceType
5663
val isTactical = ctx.isTactical
5764

58-
val bestName = NameUtils.resolveBestName(existing.lastDeviceName, ctx.name ?: ctx.vendorModel ?: ctx.beaconType)
59-
6065
val params = ThrottleParams(
6166
mac = ctx.mac,
6267
currentRssi = ctx.validRssi,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.blueeye.core.data.repository.handler.ble.enricher
22

33
import android.util.Log
4+
import io.blueeye.core.data.classifier.AppleIdentityConflictGuard
45
import io.blueeye.core.data.repository.handler.ble.ScanDataContext
56
import io.blueeye.core.data.tracker.analysis.SpoofingDetector
67
import io.blueeye.core.data.tracker.fingerprint.KnownDeviceFingerprints
@@ -26,6 +27,15 @@ class FingerprintEnricher @Inject constructor(
2627
private fun detectFingerprint(ctx: ScanDataContext) {
2728
val fingerprintModel = resolveFingerprintModel(ctx)
2829
if (fingerprintModel != null) {
30+
if (AppleIdentityConflictGuard.hasLabelConflict(ctx.sanitizedName ?: ctx.name, fingerprintModel)) {
31+
Log.d(
32+
TAG,
33+
"Ignoring conflicting known-device fingerprint for ${ctx.mac}: " +
34+
"name=${ctx.sanitizedName ?: ctx.name}, model=$fingerprintModel",
35+
)
36+
return
37+
}
38+
2939
ctx.fingerprintModel = fingerprintModel
3040

3141
if (spoofingDetector.onDeviceIdentified(ctx.timestamp, fingerprintModel)) {

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.blueeye.core.data.repository.handler.ble.enricher
22

3+
import android.util.Log
4+
import io.blueeye.core.data.classifier.AppleIdentityConflictGuard
35
import io.blueeye.core.data.classifier.BeaconDecoderManager
46
import io.blueeye.core.data.repository.handler.ble.ScanDataContext
57
import javax.inject.Inject
@@ -12,21 +14,41 @@ class SensorEnricher @Inject constructor(
1214
) : ScanEnricher {
1315

1416
override fun enrich(ctx: ScanDataContext) {
15-
ctx.sensorData =
17+
val decodedSensorData =
1618
beaconDecoderManager.decode(
1719
mac = ctx.mac,
1820
manufacturerRecords = ctx.manufacturerRecords(),
1921
serviceUuids = ctx.serviceUuids,
2022
rawData = ctx.rawData,
2123
)
2224

25+
if (AppleIdentityConflictGuard.hasLabelConflict(
26+
name = ctx.sanitizedName ?: ctx.name,
27+
label = decodedSensorData?.beaconType,
28+
)
29+
) {
30+
Log.d(
31+
TAG,
32+
"Ignoring conflicting Apple beacon payload for ${ctx.mac}: " +
33+
"name=${ctx.sanitizedName ?: ctx.name}, beacon=${decodedSensorData?.beaconType}",
34+
)
35+
ctx.sensorData = null
36+
return
37+
}
38+
39+
ctx.sensorData = decodedSensorData
40+
2341
// Use beacon type from sensor data if available
2442
if (ctx.sensorData?.beaconType != null) {
2543
ctx.beaconType = ctx.sensorData?.beaconType
2644
}
2745

2846
if (ctx.sensorData != null) {
29-
android.util.Log.d("DeviceEnricher", "Decoded: ${ctx.sensorData?.rawData} | Type: ${ctx.beaconType}")
47+
Log.d(TAG, "Decoded: ${ctx.sensorData?.rawData} | Type: ${ctx.beaconType}")
3048
}
3149
}
50+
51+
private companion object {
52+
private const val TAG = "DeviceEnricher"
53+
}
3254
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.blueeye.core.data.repository.handler.ble.enricher
22

3+
import android.util.Log
4+
import io.blueeye.core.data.classifier.AppleIdentityConflictGuard
35
import io.blueeye.core.data.classifier.vendor.VendorStrategyFactory
46
import io.blueeye.core.data.repository.handler.ble.ScanDataContext
57
import io.blueeye.core.model.DeviceType
@@ -25,6 +27,18 @@ class VendorEnricher @Inject constructor(
2527
)
2628

2729
if (vendorResult != null) {
30+
if (AppleIdentityConflictGuard.preferredNameTypeForConflict(
31+
name = ctx.sanitizedName ?: ctx.name,
32+
candidateType = vendorResult.deviceType,
33+
) != null
34+
) {
35+
Log.d(
36+
TAG,
37+
"Ignoring conflicting Apple vendor payload for ${ctx.mac}: " +
38+
"name=${ctx.sanitizedName ?: ctx.name}, model=${vendorResult.modelName}",
39+
)
40+
return
41+
}
2842
if (vendorResult.deviceType != DeviceType.UNKNOWN) {
2943
ctx.vendorDeviceType = vendorResult.deviceType
3044
}
@@ -45,4 +59,8 @@ class VendorEnricher @Inject constructor(
4559
}
4660
}
4761
}
62+
63+
private companion object {
64+
private const val TAG = "VendorEnricher"
65+
}
4866
}

0 commit comments

Comments
 (0)