@mentra/bluetooth-sdk 3.3.0-dev.419 → 3.3.0-dev.421

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +7 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +17 -3
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +11 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleEvidenceLog.kt +454 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +21 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +230 -32
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +13 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +41 -6
  11. package/android/src/test/java/com/mentra/bluetoothsdk/WifiScanEvidenceTest.kt +117 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleEvidenceLogTest.kt +282 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveEvidenceTest.kt +603 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/utils/MessageChunkReassemblerOriginTest.kt +51 -0
  17. package/build/BluetoothSdk.types.d.ts +10 -0
  18. package/build/BluetoothSdk.types.d.ts.map +1 -1
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +9 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js +2 -0
  23. package/build/_private/BluetoothSdkModule.js.map +1 -1
  24. package/build/_private/diagnosticRender.d.ts +12 -0
  25. package/build/_private/diagnosticRender.d.ts.map +1 -0
  26. package/build/_private/diagnosticRender.js +27 -0
  27. package/build/_private/diagnosticRender.js.map +1 -0
  28. package/build/generated/releaseMetadata.js +5 -5
  29. package/build/generated/releaseMetadata.js.map +1 -1
  30. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  31. package/ios/Source/DeviceManager.swift +1 -1
  32. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  33. package/ios/Source/sgcs/MentraLive.swift +5 -1
  34. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  35. package/package.json +2 -2
  36. package/src/BluetoothSdk.types.ts +10 -0
  37. package/src/_private/BluetoothSdkModule.ts +13 -0
  38. package/src/_private/diagnosticRender.ts +35 -0
  39. package/src/generated/releaseMetadata.ts +5 -5
@@ -3,6 +3,7 @@ package com.mentra.bluetoothsdk.utils;
3
3
  import com.mentra.bluetoothsdk.utils.NativeLog;
4
4
 
5
5
  import java.util.Map;
6
+ import java.util.Objects;
6
7
  import java.util.concurrent.ConcurrentHashMap;
7
8
 
8
9
  /**
@@ -17,7 +18,22 @@ public class MessageChunkReassembler {
17
18
 
18
19
  private final Map<Integer, BinarySession> activeBinarySessions = new ConcurrentHashMap<>();
19
20
 
21
+ /**
22
+ * Sessions whose fragments came from different receive origins (connection or wire epoch).
23
+ * Such a session is discarded rather than reassembled across the boundary.
24
+ */
25
+ private long mixedOriginDrops = 0;
26
+
27
+ public long getMixedOriginDrops() {
28
+ return mixedOriginDrops;
29
+ }
30
+
20
31
  public byte[] addBinaryFragment(int msgId, int fragIdx, int fragCount, byte[] data) {
32
+ return addBinaryFragment(msgId, fragIdx, fragCount, data, null);
33
+ }
34
+
35
+ /** Like {@link #addBinaryFragment(int, int, int, byte[])}; fragments of one message must share {@code origin}. */
36
+ public byte[] addBinaryFragment(int msgId, int fragIdx, int fragCount, byte[] data, Object origin) {
21
37
  cleanupTimedOutSessions();
22
38
 
23
39
  if (msgId < 0 || fragCount <= 0 || fragIdx < 0 || fragIdx >= fragCount || data == null) {
@@ -33,11 +49,16 @@ public class MessageChunkReassembler {
33
49
 
34
50
  BinarySession session = activeBinarySessions.compute(msgId, (ignored, existingSession) -> {
35
51
  if (existingSession == null) {
36
- return new BinarySession(msgId, fragCount);
52
+ return new BinarySession(msgId, fragCount, origin);
53
+ }
54
+ if (!Objects.equals(existingSession.origin, origin)) {
55
+ NativeLog.w(TAG, "Receive origin changed for msgId " + msgId + ", discarding session");
56
+ mixedOriginDrops++;
57
+ return new BinarySession(msgId, fragCount, origin);
37
58
  }
38
59
  if (existingSession.fragCount != fragCount) {
39
60
  NativeLog.w(TAG, "fragCount mismatch for msgId " + msgId + ", resetting session");
40
- return new BinarySession(msgId, fragCount);
61
+ return new BinarySession(msgId, fragCount, origin);
41
62
  }
42
63
  return existingSession;
43
64
  });
@@ -58,6 +79,11 @@ public class MessageChunkReassembler {
58
79
  }
59
80
 
60
81
  public String addChunk(String chunkId, int chunkIndex, int totalChunks, String data) {
82
+ return addChunk(chunkId, chunkIndex, totalChunks, data, null);
83
+ }
84
+
85
+ /** Like {@link #addChunk(String, int, int, String)}; chunks of one message must share {@code origin}. */
86
+ public String addChunk(String chunkId, int chunkIndex, int totalChunks, String data, Object origin) {
61
87
  cleanupTimedOutSessions();
62
88
 
63
89
  if (chunkId == null || chunkId.isEmpty() || totalChunks <= 0 || chunkIndex < 0
@@ -74,12 +100,17 @@ public class MessageChunkReassembler {
74
100
 
75
101
  ChunkSession session = activeSessions.compute(chunkId, (ignored, existingSession) -> {
76
102
  if (existingSession == null) {
77
- return new ChunkSession(chunkId, totalChunks);
103
+ return new ChunkSession(chunkId, totalChunks, origin);
104
+ }
105
+ if (!Objects.equals(existingSession.origin, origin)) {
106
+ NativeLog.w(TAG, "Receive origin changed for " + chunkId + ", discarding session");
107
+ mixedOriginDrops++;
108
+ return new ChunkSession(chunkId, totalChunks, origin);
78
109
  }
79
110
  if (existingSession.totalChunks != totalChunks) {
80
111
  NativeLog.w(TAG, "totalChunks mismatch for " + chunkId + " (expected "
81
112
  + existingSession.totalChunks + ", got " + totalChunks + "), resetting session");
82
- return new ChunkSession(chunkId, totalChunks);
113
+ return new ChunkSession(chunkId, totalChunks, origin);
83
114
  }
84
115
  return existingSession;
85
116
  });
@@ -146,8 +177,10 @@ public class MessageChunkReassembler {
146
177
  final int totalChunks;
147
178
  final long createdAt;
148
179
  final Map<Integer, String> chunks;
180
+ final Object origin;
149
181
 
150
- ChunkSession(String chunkId, int totalChunks) {
182
+ ChunkSession(String chunkId, int totalChunks, Object origin) {
183
+ this.origin = origin;
151
184
  this.chunkId = chunkId;
152
185
  this.totalChunks = totalChunks;
153
186
  this.createdAt = System.currentTimeMillis();
@@ -184,8 +217,10 @@ public class MessageChunkReassembler {
184
217
  final int fragCount;
185
218
  final long createdAt;
186
219
  final Map<Integer, byte[]> fragments;
220
+ final Object origin;
187
221
 
188
- BinarySession(int msgId, int fragCount) {
222
+ BinarySession(int msgId, int fragCount, Object origin) {
223
+ this.origin = origin;
189
224
  this.msgId = msgId;
190
225
  this.fragCount = fragCount;
191
226
  this.createdAt = System.currentTimeMillis();
@@ -0,0 +1,117 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import com.mentra.bluetoothsdk.debug.BleEvidenceLog
5
+ import com.mentra.bluetoothsdk.sgcs.G1PairingTest
6
+ import kotlinx.coroutines.CoroutineStart
7
+ import kotlinx.coroutines.async
8
+ import kotlinx.coroutines.runBlocking
9
+ import kotlinx.coroutines.yield
10
+ import org.json.JSONObject
11
+ import org.junit.After
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertFalse
14
+ import org.junit.Assert.assertTrue
15
+ import org.junit.Before
16
+ import org.junit.Test
17
+ import org.junit.runner.RunWith
18
+ import org.robolectric.RobolectricTestRunner
19
+ import org.robolectric.annotation.Config
20
+
21
+ /**
22
+ * Runs the production MentraBluetoothSdk.requestWifiScan and its Bridge chunk handling. Chunks
23
+ * enter through Bridge.updateWifiScanResults, the same call MentraLive makes after decoding.
24
+ */
25
+ @RunWith(RobolectricTestRunner::class)
26
+ @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class], instrumentedPackages = ["com.mentra.lc3Lib"])
27
+ class WifiScanEvidenceTest {
28
+ private lateinit var sdk: MentraBluetoothSdk
29
+
30
+ @Before fun setup() {
31
+ BleEvidenceLog.resetForTest()
32
+ sdk = MentraBluetoothSdk.create(ApplicationProvider.getApplicationContext(), object : MentraBluetoothSdkListener {})
33
+ }
34
+
35
+ @After fun close() = sdk.close()
36
+
37
+ private fun records(kind: String): List<JSONObject> {
38
+ val array = BleEvidenceLog.snapshot(0, null).getJSONArray("records")
39
+ return (0 until array.length()).map { array.getJSONObject(it) }.filter { it.getString("kind") == kind }
40
+ }
41
+
42
+ private fun freshScanId() = records("scan_request").single { it.getString("mode") == "fresh" }.getString("scanId")
43
+
44
+ private fun chunk(scanId: String?, ssid: String, complete: Boolean) =
45
+ Bridge.updateWifiScanResults(
46
+ listOf(mapOf("ssid" to ssid, "requiresPassword" to true, "signalStrength" to -40)),
47
+ complete,
48
+ scanId,
49
+ BleEvidenceLog.scanChunk(null, scanId, emptyList(), complete, entriesParsed = true),
50
+ )
51
+
52
+ @Test
53
+ fun `explicit completion of the fresh request is the only complete terminal`() = runBlocking {
54
+ val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
55
+ val scanId = freshScanId()
56
+ chunk(scanId, "LabAP", complete = false)
57
+ yield()
58
+ assertFalse(scan.isCompleted)
59
+ assertTrue("streamed chunks are not completion", records("scan_terminal").isEmpty())
60
+
61
+ chunk(scanId, "Other", complete = true)
62
+ assertEquals(listOf("LabAP", "Other"), scan.await().map { it.ssid })
63
+ val terminal = records("scan_terminal").single()
64
+ assertEquals(scanId, terminal.getString("scanId"))
65
+ assertEquals("complete", terminal.getString("result"))
66
+ }
67
+
68
+ @Test
69
+ fun `a joined caller is recorded as joining the older request, not a fresh scan`() = runBlocking {
70
+ val first = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
71
+ val joined = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
72
+ val scanId = freshScanId()
73
+ val requests = records("scan_request")
74
+ assertEquals(listOf("fresh", "joined"), requests.map { it.getString("mode") })
75
+ assertEquals(listOf(scanId, scanId), requests.map { it.getString("scanId") })
76
+
77
+ chunk(scanId, "LabAP", complete = true)
78
+ assertEquals(first.await(), joined.await())
79
+ }
80
+
81
+ @Test
82
+ fun `a chunk for another scan id neither resolves nor completes the fresh request`() = runBlocking {
83
+ val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
84
+ val scanId = freshScanId()
85
+ chunk("scan-older", "Stale", complete = true)
86
+ yield()
87
+ assertFalse(scan.isCompleted)
88
+ assertTrue(records("scan_terminal").isEmpty())
89
+
90
+ chunk(scanId, "LabAP", complete = true)
91
+ assertEquals(listOf("LabAP"), scan.await().map { it.ssid })
92
+ }
93
+
94
+ @Test
95
+ fun `legacy uncorrelated results are never a correlated completion`() = runBlocking {
96
+ val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
97
+ val scanId = freshScanId()
98
+ chunk(null, "Legacy", complete = true)
99
+ assertEquals(listOf("Legacy"), scan.await().map { it.ssid })
100
+ val terminal = records("scan_terminal").single()
101
+ assertEquals(scanId, terminal.getString("scanId"))
102
+ assertEquals("legacy_uncorrelated", terminal.getString("result"))
103
+ }
104
+
105
+ @Test
106
+ fun `a timeout that returns partial results is recorded as partial, not complete`() = runBlocking {
107
+ val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
108
+ val scanId = freshScanId()
109
+ chunk(scanId, "LabAP", complete = false)
110
+ // Customer behavior is unchanged: the promise still resolves with the partial list.
111
+ assertEquals(listOf("LabAP"), scan.await().map { it.ssid })
112
+ val terminal = records("scan_terminal").single()
113
+ assertEquals(scanId, terminal.getString("scanId"))
114
+ assertEquals("timeout_partial", terminal.getString("result"))
115
+ assertEquals(1, terminal.getInt("networks"))
116
+ }
117
+ }
@@ -0,0 +1,282 @@
1
+ package com.mentra.bluetoothsdk.debug
2
+
3
+ import android.os.Handler
4
+ import android.os.HandlerThread
5
+ import android.os.Looper
6
+ import org.json.JSONObject
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertNotEquals
10
+ import org.junit.Assert.assertNull
11
+ import org.junit.Assert.assertTrue
12
+ import org.junit.Before
13
+ import org.junit.Test
14
+ import org.junit.runner.RunWith
15
+ import org.robolectric.RobolectricTestRunner
16
+ import org.robolectric.annotation.Config
17
+
18
+ @RunWith(RobolectricTestRunner::class)
19
+ @Config(sdk = [33])
20
+ class BleEvidenceLogTest {
21
+ @Before fun reset() = BleEvidenceLog.resetForTest()
22
+
23
+ private fun records(snapshot: JSONObject) =
24
+ snapshot.getJSONArray("records").let { array -> (0 until array.length()).map { array.getJSONObject(it) } }
25
+
26
+ @Test
27
+ fun `ids derive from one stream and a strict sequence`() {
28
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
29
+ val first = BleEvidenceLog.battery(origin, 57, "battery_status", null)
30
+ val second = BleEvidenceLog.battery(origin, 57, "battery_status", null)
31
+
32
+ assertEquals("${BleEvidenceLog.streamId}:2", first)
33
+ assertEquals("${BleEvidenceLog.streamId}:3", second)
34
+ val snapshot = BleEvidenceLog.snapshot(0, null)
35
+ assertEquals("ok", snapshot.getString("outcome"))
36
+ assertEquals(listOf(1L, 2L, 3L), records(snapshot).map { it.getLong("seq") })
37
+ assertEquals(3L, snapshot.getLong("fenceSeq"))
38
+ // Percent-only sources never claim PMU charging.
39
+ assertFalse(records(snapshot)[1].has("pmuCharging"))
40
+ }
41
+
42
+ @Test
43
+ fun `eviction is an explicit gap, never a clean interval`() {
44
+ repeat(BleEvidenceLog.CAPACITY + 88) { BleEvidenceLog.receiveRejected("stale_gatt", null) }
45
+
46
+ val gap = BleEvidenceLog.snapshot(0, null)
47
+ assertEquals("gap", gap.getString("outcome"))
48
+ assertEquals(88L, gap.getLong("droppedCount"))
49
+ assertEquals(89L, gap.getLong("firstRetainedSeq"))
50
+ assertEquals("ok", BleEvidenceLog.snapshot(88, null).getString("outcome"))
51
+ }
52
+
53
+ @Test
54
+ fun `cursor from another process or the future is stale`() {
55
+ BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
56
+ val oldStream = BleEvidenceLog.streamId
57
+ BleEvidenceLog.resetForTest()
58
+
59
+ val stale = BleEvidenceLog.snapshot(1, null)
60
+ assertEquals("stale_cursor", stale.getString("outcome"))
61
+ assertNotEquals(oldStream, stale.getString("streamId"))
62
+ assertEquals(0, stale.getJSONArray("records").length())
63
+ }
64
+
65
+ @Test
66
+ fun `malformed requests are refused`() {
67
+ assertEquals("invalid_request", BleEvidenceLog.snapshot(null, null).getString("outcome"))
68
+ assertEquals("invalid_request", BleEvidenceLog.snapshot(0, "LabAP").getString("outcome"))
69
+ }
70
+
71
+ @Test
72
+ fun `scan snapshot reports only the requested target and never an SSID`() {
73
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
74
+ BleEvidenceLog.scanChunk(
75
+ origin,
76
+ "scan-1",
77
+ listOf(
78
+ mapOf("ssid" to "LabAP", "requiresPassword" to true),
79
+ mapOf("ssid" to "Neighbor", "requiresPassword" to false),
80
+ mapOf("ssid" to "Stringly", "requiresPassword" to "true"),
81
+ ),
82
+ complete = true,
83
+ entriesParsed = true,
84
+ )
85
+
86
+ val targeted = BleEvidenceLog.snapshot(0, BleEvidenceLog.sha256Hex("LabAP"))
87
+ val chunk = records(targeted).single { it.getString("kind") == "scan_chunk" }
88
+ assertTrue(chunk.getBoolean("targetSeen"))
89
+ assertTrue(chunk.getBoolean("targetRequiresPassword"))
90
+ assertEquals(3, chunk.getInt("networks"))
91
+ assertFalse(targeted.toString().contains("LabAP"))
92
+ assertFalse(targeted.toString().contains("Neighbor"))
93
+
94
+ val absent = records(BleEvidenceLog.snapshot(0, BleEvidenceLog.sha256Hex("Other"))).last()
95
+ assertFalse(absent.getBoolean("targetSeen"))
96
+ assertFalse(absent.has("targetRequiresPassword"))
97
+
98
+ // Only an exact boolean counts as the security value.
99
+ val nonBoolean = records(BleEvidenceLog.snapshot(0, BleEvidenceLog.sha256Hex("Stringly"))).last()
100
+ assertTrue(nonBoolean.getBoolean("targetSeen"))
101
+ assertTrue(nonBoolean.isNull("targetRequiresPassword"))
102
+
103
+ assertFalse(records(BleEvidenceLog.snapshot(0, null)).last().has("targetSeen"))
104
+ }
105
+
106
+ private fun chunkRecord(target: String) =
107
+ records(BleEvidenceLog.snapshot(0, BleEvidenceLog.sha256Hex(target))).last { it.getString("kind") == "scan_chunk" }
108
+
109
+ @Test
110
+ fun `a target beyond retained digests is unknown, never absent`() {
111
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
112
+ // 70 unique networks: only the first 64 digests are retained.
113
+ val networks = (1..70).map { mapOf("ssid" to "Net$it", "requiresPassword" to (it % 2 == 0)) }
114
+ BleEvidenceLog.scanChunk(origin, "scan-many", networks, complete = true, entriesParsed = true)
115
+
116
+ val beyond = chunkRecord("Net70")
117
+ assertTrue("target at index 70 must not be reported absent", beyond.isNull("targetSeen"))
118
+ assertEquals("incomplete", beyond.getString("targetCoverage"))
119
+ assertFalse(beyond.has("targetRequiresPassword"))
120
+ assertEquals(70, beyond.getInt("networks"))
121
+
122
+ // A retained target is still exact even when the chunk is truncated.
123
+ val covered = chunkRecord("Net2")
124
+ assertTrue(covered.getBoolean("targetSeen"))
125
+ assertTrue(covered.getBoolean("targetRequiresPassword"))
126
+ assertEquals("incomplete", covered.getString("targetCoverage"))
127
+
128
+ // Neither SSIDs nor unrelated digests are serialized.
129
+ val serialized = BleEvidenceLog.snapshot(0, BleEvidenceLog.sha256Hex("Net70")).toString()
130
+ assertFalse(serialized.contains("Net"))
131
+ assertFalse(serialized.contains(BleEvidenceLog.sha256Hex("Net1")))
132
+ }
133
+
134
+ @Test
135
+ fun `absence is reported only with complete coverage`() {
136
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
137
+ val networks = (1..64).map { mapOf("ssid" to "Net$it", "requiresPassword" to true) }
138
+ BleEvidenceLog.scanChunk(origin, "scan-full", networks, complete = true, entriesParsed = true)
139
+
140
+ val absent = chunkRecord("LabAP")
141
+ assertEquals("complete", absent.getString("targetCoverage"))
142
+ assertFalse(absent.getBoolean("targetSeen"))
143
+ val present = chunkRecord("Net64")
144
+ assertTrue(present.getBoolean("targetSeen"))
145
+ assertEquals("complete", present.getString("targetCoverage"))
146
+ }
147
+
148
+ @Test
149
+ fun `entries without an SSID or with conflicting duplicates are not certified`() {
150
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
151
+ BleEvidenceLog.scanChunk(
152
+ origin,
153
+ "scan-odd",
154
+ listOf(
155
+ mapOf("requiresPassword" to true),
156
+ mapOf("ssid" to "Dup", "requiresPassword" to true),
157
+ mapOf("ssid" to "Dup", "requiresPassword" to false),
158
+ ),
159
+ complete = true,
160
+ entriesParsed = true,
161
+ )
162
+ val missing = chunkRecord("LabAP")
163
+ assertEquals("incomplete", missing.getString("targetCoverage"))
164
+ assertTrue(missing.isNull("targetSeen"))
165
+ val duplicate = chunkRecord("Dup")
166
+ assertTrue(duplicate.getBoolean("targetSeen"))
167
+ assertTrue(duplicate.isNull("targetRequiresPassword"))
168
+ }
169
+
170
+ @Test
171
+ fun `a partially parsed chunk certifies neither absence nor security`() {
172
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
173
+ BleEvidenceLog.scanChunk(
174
+ origin,
175
+ "scan-partial",
176
+ listOf(mapOf("ssid" to "Other", "requiresPassword" to false)),
177
+ complete = true,
178
+ entriesParsed = false,
179
+ )
180
+ val absent = chunkRecord("LabAP")
181
+ assertTrue(absent.isNull("targetSeen"))
182
+ assertEquals("incomplete", absent.getString("targetCoverage"))
183
+ assertFalse(absent.getBoolean("entriesParsed"))
184
+ val seen = chunkRecord("Other")
185
+ assertTrue(seen.getBoolean("targetSeen"))
186
+ assertTrue(seen.isNull("targetRequiresPassword"))
187
+ }
188
+
189
+ @Test
190
+ fun `send-queue generations are ordered boundaries`() {
191
+ val first = BleEvidenceLog.bleGeneration(0, 0)
192
+ val origin = BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
193
+ BleEvidenceLog.scanSend("scan-1", "queued", 0, 1, origin.connection, first)
194
+ BleEvidenceLog.connectionClosed(origin.connection, "remote_disconnect")
195
+ val second = BleEvidenceLog.bleGeneration(0, 1)
196
+
197
+ val snapshot = BleEvidenceLog.snapshot(0, null)
198
+ assertEquals(second, snapshot.getJSONObject("current").getLong("writeGeneration"))
199
+ val send = records(snapshot).single { it.getString("kind") == "scan_send" }
200
+ assertEquals(first, send.getLong("writeGeneration"))
201
+ assertEquals(listOf(0L, 1L), records(snapshot).filter { it.getString("kind") == "ble_generation" }.map { it.getLong("generation") })
202
+ }
203
+
204
+ @Test
205
+ fun `render markers accept only allowlisted surfaces, bounded values and ids of this stream`() {
206
+ val id = BleEvidenceLog.battery(BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01"), 57, "battery_status", null)
207
+
208
+ assertFalse(BleEvidenceLog.uiRender("debug_panel", listOf(id), 1))
209
+ assertFalse(BleEvidenceLog.uiRender(BleEvidenceLog.SURFACE_BATTERY, listOf(id), 101))
210
+ assertTrue(
211
+ BleEvidenceLog.uiRender(
212
+ BleEvidenceLog.SURFACE_BATTERY,
213
+ listOf(id, "other-stream:2", "${BleEvidenceLog.streamId}:999"),
214
+ 57,
215
+ )
216
+ )
217
+ val render = records(BleEvidenceLog.snapshot(0, null)).last()
218
+ assertEquals("ui_render", render.getString("kind"))
219
+ assertEquals(listOf(id), (0 until render.getJSONArray("eventIds").length()).map { render.getJSONArray("eventIds").getString(it) })
220
+ assertEquals(2, render.getInt("rejectedEventIds"))
221
+ assertEquals(57, render.getInt("value"))
222
+ }
223
+
224
+ @Test
225
+ fun `an out-of-range render value withdraws the surface without storing the value`() {
226
+ val id = BleEvidenceLog.battery(BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01"), 57, "battery_status", null)
227
+ assertTrue(BleEvidenceLog.uiRender(BleEvidenceLog.SURFACE_BATTERY, listOf(id), 57))
228
+
229
+ for ((surface, value) in listOf(
230
+ BleEvidenceLog.SURFACE_BATTERY to 150,
231
+ BleEvidenceLog.SURFACE_BATTERY to -1,
232
+ BleEvidenceLog.SURFACE_WIFI_SCAN to 501,
233
+ BleEvidenceLog.SURFACE_WIFI_SCAN to Int.MAX_VALUE,
234
+ )) {
235
+ // The value is not accepted, but the surface's earlier marker must not stay current.
236
+ assertFalse(BleEvidenceLog.uiRender(surface, listOf(id), value))
237
+ val withdrawal = records(BleEvidenceLog.snapshot(0, null)).last()
238
+ assertEquals("ui_render", withdrawal.getString("kind"))
239
+ assertEquals(surface, withdrawal.getString("surface"))
240
+ assertEquals(0, withdrawal.getJSONArray("eventIds").length())
241
+ assertTrue(withdrawal.isNull("value"))
242
+ assertFalse(withdrawal.toString().contains(value.toString()))
243
+ }
244
+
245
+ // Boundary values stay qualifying markers; the next valid reading restores provenance.
246
+ assertTrue(BleEvidenceLog.uiRender(BleEvidenceLog.SURFACE_BATTERY, listOf(id), 100))
247
+ assertTrue(BleEvidenceLog.uiRender(BleEvidenceLog.SURFACE_WIFI_SCAN, emptyList(), 500))
248
+ val restored = records(BleEvidenceLog.snapshot(0, null)).last { it.getString("surface") == BleEvidenceLog.SURFACE_BATTERY }
249
+ assertEquals(100, restored.getInt("value"))
250
+ assertEquals(1, restored.getJSONArray("eventIds").length())
251
+
252
+ // An unknown surface has nothing to withdraw and is still refused without a record.
253
+ val before = BleEvidenceLog.snapshot(0, null).getLong("fenceSeq")
254
+ assertFalse(BleEvidenceLog.uiRender("debug_panel", listOf(id), 1))
255
+ assertEquals(before, BleEvidenceLog.snapshot(0, null).getLong("fenceSeq"))
256
+ }
257
+
258
+ @Test
259
+ fun `dump answers only its command and reports an unavailable owner`() {
260
+ BleEvidenceLog.connectionAccepted("AA:BB:CC:DD:EE:01")
261
+ val main = Handler(Looper.getMainLooper())
262
+
263
+ assertNull(BleEvidenceLog.dump(arrayOf("--other"), main))
264
+ assertNull(BleEvidenceLog.dump(null, main))
265
+
266
+ val line = BleEvidenceLog.dump(arrayOf(BleEvidenceLog.DUMP_COMMAND, "afterSeq=0"), main)!!
267
+ assertTrue(line.startsWith(BleEvidenceLog.OUTPUT_PREFIX))
268
+ val json = JSONObject(line.removePrefix(BleEvidenceLog.OUTPUT_PREFIX))
269
+ assertEquals("ok", json.getString("outcome"))
270
+ assertEquals("AA:BB:CC:DD:EE:01", json.getJSONObject("current").getString("peerMac"))
271
+
272
+ val unknownArg = BleEvidenceLog.dump(arrayOf(BleEvidenceLog.DUMP_COMMAND, "afterSeq=0", "eval=1"), main)!!
273
+ assertEquals("invalid_request", JSONObject(unknownArg.removePrefix(BleEvidenceLog.OUTPUT_PREFIX)).getString("outcome"))
274
+
275
+ val dead = HandlerThread("dead-owner").apply { start() }
276
+ val deadHandler = Handler(dead.looper)
277
+ dead.quitSafely()
278
+ dead.join()
279
+ val unavailable = BleEvidenceLog.dump(arrayOf(BleEvidenceLog.DUMP_COMMAND, "afterSeq=0"), deadHandler, 50)!!
280
+ assertEquals("unavailable", JSONObject(unavailable.removePrefix(BleEvidenceLog.OUTPUT_PREFIX)).getString("outcome"))
281
+ }
282
+ }
@@ -0,0 +1,69 @@
1
+ package com.mentra.bluetoothsdk.debug
2
+
3
+ import java.util.UUID
4
+ import org.json.JSONArray
5
+ import org.json.JSONObject
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
8
+ import org.junit.Test
9
+ import org.junit.runner.RunWith
10
+ import org.robolectric.RobolectricTestRunner
11
+ import org.robolectric.annotation.Config
12
+ import org.robolectric.shadows.ShadowLog
13
+
14
+ @RunWith(RobolectricTestRunner::class)
15
+ @Config(sdk = [28])
16
+ class BleTraceLoggerTest {
17
+ @Test
18
+ fun wifiScanPreservesBooleanSecurityWhileRedactingCredentials() {
19
+ val secret = UUID.randomUUID().toString()
20
+ val networks = JSONArray()
21
+ for (secured in listOf(true, false)) {
22
+ networks.put(
23
+ JSONObject()
24
+ .put("ssid", if (secured) "secured-fixture" else "open-fixture")
25
+ .put("requiresPassword", secured)
26
+ .put("signalStrength", -45)
27
+ .put("password", secret)
28
+ .put("token", secret)
29
+ )
30
+ }
31
+ ShadowLog.clear()
32
+ BleTraceLogger.logJson(
33
+ "glasses_to_phone",
34
+ "sdk_ble_event",
35
+ JSONObject().put("type", "wifi_scan_result").put("networks_neo", networks),
36
+ )
37
+ val message = ShadowLog.getLogsForTag("MentraBleTrace").single().msg
38
+ assertFalse(message.contains(secret))
39
+ val actual = JSONObject(message.substringAfter("payload=")).getJSONArray("networks_neo")
40
+ for ((index, secured) in listOf(true, false).withIndex()) {
41
+ val item = actual.getJSONObject(index)
42
+ assertEquals(secured, item.get("requiresPassword"))
43
+ assertEquals(-45, item.getInt("signalStrength"))
44
+ assertEquals("<redacted>", item.getString("password"))
45
+ assertEquals("<redacted>", item.getString("token"))
46
+ }
47
+ }
48
+
49
+ @Test
50
+ fun exceptionDoesNotExposeNonBooleanOrOtherPasswordFields() {
51
+ val secret = UUID.randomUUID().toString()
52
+ ShadowLog.clear()
53
+ BleTraceLogger.logJson(
54
+ "glasses_to_phone",
55
+ "sdk_ble_event",
56
+ JSONObject()
57
+ .put("type", "wifi_scan_result")
58
+ .put("requiresPassword", secret)
59
+ .put("password", true)
60
+ .put("RequiresPassword", false),
61
+ )
62
+ val message = ShadowLog.getLogsForTag("MentraBleTrace").single().msg
63
+ assertFalse(message.contains(secret))
64
+ val actual = JSONObject(message.substringAfter("payload="))
65
+ for (key in listOf("requiresPassword", "password", "RequiresPassword")) {
66
+ assertEquals("<redacted>", actual.getString(key))
67
+ }
68
+ }
69
+ }