@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.
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +17 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleEvidenceLog.kt +454 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +230 -32
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +41 -6
- package/android/src/test/java/com/mentra/bluetoothsdk/WifiScanEvidenceTest.kt +117 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleEvidenceLogTest.kt +282 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveEvidenceTest.kt +603 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/MessageChunkReassemblerOriginTest.kt +51 -0
- package/build/BluetoothSdk.types.d.ts +10 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +9 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/diagnosticRender.d.ts +12 -0
- package/build/_private/diagnosticRender.d.ts.map +1 -0
- package/build/_private/diagnosticRender.js +27 -0
- package/build/_private/diagnosticRender.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/MentraLive.swift +5 -1
- package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +10 -0
- package/src/_private/BluetoothSdkModule.ts +13 -0
- package/src/_private/diagnosticRender.ts +35 -0
- 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
|
+
}
|