@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-beta.100

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 (144) hide show
  1. package/README.md +67 -25
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  13. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  14. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  22. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  31. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  32. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  42. package/build/BluetoothSdk.types.d.ts +84 -2
  43. package/build/BluetoothSdk.types.d.ts.map +1 -1
  44. package/build/BluetoothSdk.types.js.map +1 -1
  45. package/build/_internal.d.ts +2 -0
  46. package/build/_internal.d.ts.map +1 -1
  47. package/build/_internal.js +2 -0
  48. package/build/_internal.js.map +1 -1
  49. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  50. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  51. package/build/_private/BluetoothSdkModule.js +1 -1
  52. package/build/_private/BluetoothSdkModule.js.map +1 -1
  53. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  54. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  55. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  56. package/build/_private/sdkOtaManifest.d.ts +3 -12
  57. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  58. package/build/_private/sdkOtaManifest.js +5 -17
  59. package/build/_private/sdkOtaManifest.js.map +1 -1
  60. package/build/changelogs.d.ts +7 -0
  61. package/build/changelogs.d.ts.map +1 -0
  62. package/build/changelogs.js +44 -0
  63. package/build/changelogs.js.map +1 -0
  64. package/build/generated/changelogCatalog.d.ts +6 -0
  65. package/build/generated/changelogCatalog.d.ts.map +1 -0
  66. package/build/generated/changelogCatalog.js +8 -0
  67. package/build/generated/changelogCatalog.js.map +1 -0
  68. package/build/generated/releaseMetadata.d.ts +12 -0
  69. package/build/generated/releaseMetadata.d.ts.map +1 -0
  70. package/build/generated/releaseMetadata.js +10 -0
  71. package/build/generated/releaseMetadata.js.map +1 -0
  72. package/build/index.d.ts +1 -1
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +24 -2
  75. package/build/index.js.map +1 -1
  76. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  77. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  78. package/build/ota-server/MentraOtaServer.types.js +2 -0
  79. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  80. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  81. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  82. package/build/ota-server/MentraOtaServerModule.js +3 -0
  83. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  84. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  85. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  86. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  87. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  88. package/build/ota-server/index.d.ts +3 -0
  89. package/build/ota-server/index.d.ts.map +1 -0
  90. package/build/ota-server/index.js +3 -0
  91. package/build/ota-server/index.js.map +1 -0
  92. package/build/ota-transport/index.d.ts +67 -0
  93. package/build/ota-transport/index.d.ts.map +1 -0
  94. package/build/ota-transport/index.js +58 -0
  95. package/build/ota-transport/index.js.map +1 -0
  96. package/expo-module.config.json +3 -2
  97. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  98. package/ios/BluetoothSdkModule.swift +16 -2
  99. package/ios/LocalIPv4.swift +150 -0
  100. package/ios/LocalOtaServer.swift +309 -0
  101. package/ios/MentraBluetoothSDK.podspec +5 -1
  102. package/ios/MentraOtaServerModule.swift +147 -0
  103. package/ios/MentraPhotoReceiverModule.swift +1 -81
  104. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  105. package/ios/Source/Bridge.swift +43 -4
  106. package/ios/Source/DeviceManager.swift +154 -19
  107. package/ios/Source/DeviceStore.swift +2 -0
  108. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  109. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  110. package/ios/Source/MentraBluetoothSDK.swift +91 -6
  111. package/ios/Source/OtaManifest.swift +5 -7
  112. package/ios/Source/ReleaseChangelog.swift +65 -0
  113. package/ios/Source/audio/AudioModels.swift +17 -0
  114. package/ios/Source/camera/CameraModels.swift +1 -0
  115. package/ios/Source/events/BluetoothEvents.swift +27 -0
  116. package/ios/Source/sgcs/MentraLive.swift +512 -78
  117. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  118. package/ios/Source/sgcs/SGCManager.swift +11 -0
  119. package/ios/Source/status/DeviceStatus.swift +23 -0
  120. package/ios/Source/types/DeviceModels.swift +23 -2
  121. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  122. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  123. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  124. package/package.json +10 -1
  125. package/plugin/build/withIos.d.ts +3 -1
  126. package/plugin/build/withIos.js +22 -10
  127. package/scripts/public-ota-api.test.mjs +37 -0
  128. package/scripts/verify-release-package.mjs +34 -0
  129. package/scripts/write-release-metadata.mjs +154 -0
  130. package/scripts/write-release-metadata.test.mjs +29 -0
  131. package/src/BluetoothSdk.types.ts +97 -3
  132. package/src/_internal.ts +2 -0
  133. package/src/_private/BluetoothSdkModule.ts +5 -5
  134. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  135. package/src/_private/sdkOtaManifest.ts +5 -17
  136. package/src/changelogs.ts +44 -0
  137. package/src/generated/changelogCatalog.ts +9 -0
  138. package/src/generated/releaseMetadata.ts +20 -0
  139. package/src/index.ts +43 -2
  140. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  141. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  142. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  143. package/src/ota-server/index.ts +2 -0
  144. package/src/ota-transport/index.ts +116 -0
@@ -0,0 +1,82 @@
1
+ package com.mentra.bluetoothsdk.otaserver
2
+
3
+ import java.io.File
4
+ import java.net.HttpURLConnection
5
+ import java.net.URL
6
+ import org.assertj.core.api.Assertions.assertThat
7
+ import org.junit.After
8
+ import org.junit.Before
9
+ import org.junit.Test
10
+
11
+ class LocalOtaServerTest {
12
+ private lateinit var server: LocalOtaServer
13
+ private lateinit var artifactFile: File
14
+ private var port = 0
15
+
16
+ private val manifest = """{"apps":{"com.mentra.asg_client":{"versionCode":1}}}"""
17
+ private val artifactBytes = ByteArray(1024) { (it % 251).toByte() }
18
+ private val sha = "a".repeat(64)
19
+
20
+ @Before
21
+ fun startServer() {
22
+ artifactFile = File.createTempFile("ota-artifact", ".bin").apply {
23
+ writeBytes(artifactBytes)
24
+ }
25
+ server = LocalOtaServer(onLog = {})
26
+ server.configure(manifest, mapOf(sha to artifactFile))
27
+ port = server.start("127.0.0.1", 0)
28
+ }
29
+
30
+ @After
31
+ fun stopServer() {
32
+ server.close()
33
+ artifactFile.delete()
34
+ }
35
+
36
+ @Test
37
+ fun `serves the configured manifest`() {
38
+ val connection = open("/version.json")
39
+ assertThat(connection.responseCode).isEqualTo(200)
40
+ assertThat(connection.contentType).isEqualTo("application/json")
41
+ assertThat(connection.inputStream.readBytes().decodeToString()).isEqualTo(manifest)
42
+ }
43
+
44
+ @Test
45
+ fun `streams a full artifact`() {
46
+ val connection = open("/artifacts/$sha")
47
+ assertThat(connection.responseCode).isEqualTo(200)
48
+ assertThat(connection.inputStream.readBytes()).isEqualTo(artifactBytes)
49
+ }
50
+
51
+ @Test
52
+ fun `returns 404 for unknown artifacts`() {
53
+ val connection = open("/artifacts/${"b".repeat(64)}")
54
+ assertThat(connection.responseCode).isEqualTo(404)
55
+ }
56
+
57
+ @Test
58
+ fun `rejects non-GET methods`() {
59
+ val connection = open("/version.json")
60
+ connection.requestMethod = "POST"
61
+ connection.doOutput = true
62
+ connection.outputStream.use { it.write("{}".toByteArray()) }
63
+ assertThat(connection.responseCode).isEqualTo(405)
64
+ }
65
+
66
+ @Test
67
+ fun `reconfigures while running`() {
68
+ server.configure("""{"updated":true}""", emptyMap())
69
+ val manifestConnection = open("/version.json")
70
+ assertThat(manifestConnection.inputStream.readBytes().decodeToString())
71
+ .isEqualTo("""{"updated":true}""")
72
+ val artifactConnection = open("/artifacts/$sha")
73
+ assertThat(artifactConnection.responseCode).isEqualTo(404)
74
+ }
75
+
76
+ private fun open(path: String): HttpURLConnection {
77
+ val connection = URL("http://127.0.0.1:$port$path").openConnection() as HttpURLConnection
78
+ connection.connectTimeout = 5_000
79
+ connection.readTimeout = 5_000
80
+ return connection
81
+ }
82
+ }
@@ -0,0 +1,69 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import android.content.pm.ServiceInfo
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class ForegroundServiceTypeTest {
9
+ @Test
10
+ fun `bootstrap starts as dataSync`() {
11
+ assertEquals(
12
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
13
+ ForegroundService.bootstrapServiceType(),
14
+ )
15
+ }
16
+
17
+ @Test
18
+ fun `preferred types remove dataSync once connectedDevice is available`() {
19
+ val serviceType =
20
+ ForegroundService.preferredServiceType(
21
+ hasConnectedDeviceAccess = true,
22
+ hasMicrophoneAccess = false,
23
+ hasLocationAccess = false,
24
+ includeMediaPlayback = false,
25
+ )
26
+
27
+ assertTrue(
28
+ serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE != 0,
29
+ )
30
+ assertEquals(
31
+ 0,
32
+ serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
33
+ )
34
+ }
35
+
36
+ @Test
37
+ fun `preferred types retain dataSync only when no better type is available`() {
38
+ val serviceType =
39
+ ForegroundService.preferredServiceType(
40
+ hasConnectedDeviceAccess = false,
41
+ hasMicrophoneAccess = false,
42
+ hasLocationAccess = false,
43
+ includeMediaPlayback = false,
44
+ )
45
+
46
+ assertEquals(
47
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
48
+ serviceType,
49
+ )
50
+ }
51
+
52
+ @Test
53
+ fun `production mask never carries dataSync with media playback`() {
54
+ val serviceType =
55
+ ForegroundService.preferredServiceType(
56
+ hasConnectedDeviceAccess = true,
57
+ hasMicrophoneAccess = true,
58
+ hasLocationAccess = true,
59
+ )
60
+
61
+ assertEquals(
62
+ 0,
63
+ serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
64
+ )
65
+ assertTrue(
66
+ serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK != 0,
67
+ )
68
+ }
69
+ }
@@ -0,0 +1,73 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class BesOtaHeartbeatGuardTest {
8
+ @Test
9
+ fun leaseSuppressesUntilDeadlineAndThenExpires() {
10
+ val guard = BesOtaHeartbeatGuard(120_000)
11
+ guard.refresh(1_000)
12
+
13
+ assertTrue(guard.shouldSuppress(120_999))
14
+ assertFalse(guard.shouldSuppress(121_000))
15
+ }
16
+
17
+ @Test
18
+ fun progressRenewsLease() {
19
+ val guard = BesOtaHeartbeatGuard(120_000)
20
+ guard.refresh(1_000)
21
+ guard.refresh(60_000)
22
+
23
+ assertTrue(guard.shouldSuppress(121_000))
24
+ assertFalse(guard.shouldSuppress(180_000))
25
+ }
26
+
27
+ @Test
28
+ fun terminalStatusClearsLeaseImmediately() {
29
+ val guard = BesOtaHeartbeatGuard(120_000)
30
+ guard.refresh(1_000)
31
+ guard.clear()
32
+
33
+ assertFalse(guard.shouldSuppress(1_001))
34
+ }
35
+
36
+ @Test
37
+ fun besInstallStatusArmsLease() {
38
+ val guard = BesOtaHeartbeatGuard(120_000)
39
+
40
+ guard.observeOtaStatus("BES", "INSTALL", "in_progress", 1_000)
41
+
42
+ assertTrue(guard.shouldSuppress(120_999))
43
+ }
44
+
45
+ @Test
46
+ fun legacyBesInstallStatusUsesSameLease() {
47
+ val guard = BesOtaHeartbeatGuard(120_000)
48
+
49
+ guard.observeOtaStatus("bes", "install", "in_progress", 5_000)
50
+
51
+ assertTrue(guard.shouldSuppress(124_999))
52
+ }
53
+
54
+ @Test
55
+ fun unrelatedStatusDoesNotClearActiveLease() {
56
+ val guard = BesOtaHeartbeatGuard(120_000)
57
+ guard.observeOtaStatus("bes", "install", "in_progress", 1_000)
58
+
59
+ guard.observeOtaStatus("apk", "install", "complete", 2_000)
60
+
61
+ assertTrue(guard.shouldSuppress(120_999))
62
+ }
63
+
64
+ @Test
65
+ fun besStepCompleteClearsLease() {
66
+ val guard = BesOtaHeartbeatGuard(120_000)
67
+ guard.observeOtaStatus("bes", "install", "in_progress", 1_000)
68
+
69
+ guard.observeOtaStatus("bes", "install", "step_complete", 2_000)
70
+
71
+ assertFalse(guard.shouldSuppress(2_001))
72
+ }
73
+ }
@@ -0,0 +1,67 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class BesOtaProgressMapperTest {
8
+ @Test
9
+ fun explicitSuccessIsTerminal() {
10
+ val mapping = mapBesOtaProgress("success", 0, null)
11
+
12
+ assertEquals("FINISHED", mapping.status)
13
+ assertEquals(100, mapping.progress)
14
+ assertNull(mapping.errorMessage)
15
+ }
16
+
17
+ @Test
18
+ fun hundredPercentUpdateIsAlsoNonterminal() {
19
+ val mapping = mapBesOtaProgress("update", 100, null)
20
+
21
+ assertEquals("PROGRESS", mapping.status)
22
+ assertEquals(95, mapping.progress)
23
+ }
24
+
25
+ @Test
26
+ fun roundedHundredPercentUpdateIsCappedBelowTerminal() {
27
+ val mapping = mapBesOtaProgress("update", 98, null)
28
+
29
+ assertEquals("PROGRESS", mapping.status)
30
+ assertEquals(95, mapping.progress)
31
+ }
32
+
33
+ @Test
34
+ fun explicitBesErrorRemainsTerminalFailure() {
35
+ val mapping = mapBesOtaProgress("error", 65, "CRC failed")
36
+
37
+ assertEquals("FAILED", mapping.status)
38
+ assertEquals(65, mapping.progress)
39
+ assertEquals("CRC failed", mapping.errorMessage)
40
+ }
41
+
42
+ @Test
43
+ fun blankBesErrorUsesStableFallback() {
44
+ val mapping = mapBesOtaProgress("error", 65, " ")
45
+
46
+ assertEquals("FAILED", mapping.status)
47
+ assertEquals("BES update failed", mapping.errorMessage)
48
+ }
49
+
50
+ @Test
51
+ fun ordinaryUpdateProgressRemainsNonterminal() {
52
+ val mapping = mapBesOtaProgress("update", 40, null)
53
+
54
+ assertEquals("PROGRESS", mapping.status)
55
+ assertEquals(40, mapping.progress)
56
+ assertNull(mapping.errorMessage)
57
+ }
58
+
59
+ @Test
60
+ fun failAliasRemainsTerminalFailure() {
61
+ val mapping = mapBesOtaProgress("fail", 25, "apply failed")
62
+
63
+ assertEquals("FAILED", mapping.status)
64
+ assertEquals(25, mapping.progress)
65
+ assertEquals("apply failed", mapping.errorMessage)
66
+ }
67
+ }
@@ -0,0 +1,39 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class MentraLiveGattCharacteristicsTest {
8
+ @Test
9
+ fun `accepts the core characteristic pair without requiring optional characteristics`() {
10
+ assertTrue(
11
+ hasRequiredMentraLiveCoreCharacteristics(
12
+ hasRxCharacteristic = true,
13
+ hasTxCharacteristic = true,
14
+ )
15
+ )
16
+ }
17
+
18
+ @Test
19
+ fun `rejects an incomplete core characteristic pair`() {
20
+ assertFalse(
21
+ hasRequiredMentraLiveCoreCharacteristics(
22
+ hasRxCharacteristic = false,
23
+ hasTxCharacteristic = true,
24
+ )
25
+ )
26
+ assertFalse(
27
+ hasRequiredMentraLiveCoreCharacteristics(
28
+ hasRxCharacteristic = true,
29
+ hasTxCharacteristic = false,
30
+ )
31
+ )
32
+ assertFalse(
33
+ hasRequiredMentraLiveCoreCharacteristics(
34
+ hasRxCharacteristic = false,
35
+ hasTxCharacteristic = false,
36
+ )
37
+ )
38
+ }
39
+ }
@@ -0,0 +1,133 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class MentraLivePairingAdvertisementParserTest {
10
+ @Test
11
+ fun connectedPairingRecoveryRequiresExplicitPendingTarget() {
12
+ assertFalse(
13
+ isPendingMentraLivePairingTarget(
14
+ connectedName = "MENTRA_LIVE_BLE_OWNER",
15
+ connectedAddress = "AA:BB:CC:DD:EE:FF",
16
+ pendingName = "",
17
+ pendingAddress = "",
18
+ )
19
+ )
20
+ assertTrue(
21
+ isPendingMentraLivePairingTarget(
22
+ connectedName = "MENTRA_LIVE_BLE_TARGET",
23
+ connectedAddress = "AA:BB:CC:DD:EE:FF",
24
+ pendingName = "MENTRA_LIVE_BLE_TARGET",
25
+ pendingAddress = "",
26
+ )
27
+ )
28
+ assertTrue(
29
+ isPendingMentraLivePairingTarget(
30
+ connectedName = "MENTRA_LIVE_BLE_TARGET",
31
+ connectedAddress = "AA:BB:CC:DD:EE:FF",
32
+ pendingName = "OTHER",
33
+ pendingAddress = "aa:bb:cc:dd:ee:ff",
34
+ )
35
+ )
36
+ assertFalse(
37
+ isPendingMentraLivePairingTarget(
38
+ connectedName = "MENTRA_LIVE_BLE_TARGET",
39
+ connectedAddress = "AA:BB:CC:DD:EE:FF",
40
+ pendingName = "MENTRA_LIVE_BLE_TARGET",
41
+ pendingAddress = "11:22:33:44:55:66",
42
+ )
43
+ )
44
+ }
45
+
46
+ @Test
47
+ fun connectedPairingRecoveryRequiresActiveGattConnection() {
48
+ assertFalse(
49
+ shouldRecoverConnectedMentraLivePairingTarget(
50
+ isConnected = false,
51
+ connectedName = "MENTRA_LIVE_BLE_TARGET",
52
+ connectedAddress = "AA:BB:CC:DD:EE:FF",
53
+ pendingName = "MENTRA_LIVE_BLE_TARGET",
54
+ pendingAddress = "AA:BB:CC:DD:EE:FF",
55
+ )
56
+ )
57
+ assertTrue(
58
+ shouldRecoverConnectedMentraLivePairingTarget(
59
+ isConnected = true,
60
+ connectedName = "MENTRA_LIVE_BLE_TARGET",
61
+ connectedAddress = "AA:BB:CC:DD:EE:FF",
62
+ pendingName = "MENTRA_LIVE_BLE_TARGET",
63
+ pendingAddress = "AA:BB:CC:DD:EE:FF",
64
+ )
65
+ )
66
+ }
67
+
68
+ @Test
69
+ fun parsesMarkedSecurePairingAdvertisement() {
70
+ val result = MentraLivePairingAdvertisementParser.parse(securePayload(pairingFlag = 1))
71
+
72
+ assertEquals("1234", result?.pairingCode)
73
+ assertTrue(result?.pairingMode == true)
74
+ }
75
+
76
+ @Test
77
+ fun parsesMarkedOwnedAdvertisement() {
78
+ val result = MentraLivePairingAdvertisementParser.parse(securePayload(pairingFlag = 0))
79
+
80
+ assertEquals("1234", result?.pairingCode)
81
+ assertFalse(result?.pairingMode == true)
82
+ }
83
+
84
+ @Test
85
+ fun rejectsLegacyPayloadThatMatchesOldVersionCapabilityHeuristic() {
86
+ val legacyPayload = ByteArray(27)
87
+ legacyPayload[5] = 0
88
+ legacyPayload[6] = 1
89
+ legacyPayload[7] = 1
90
+ legacyPayload[8] = 0x34
91
+ legacyPayload[9] = 0x12
92
+
93
+ assertNull(MentraLivePairingAdvertisementParser.parse(legacyPayload))
94
+ }
95
+
96
+ @Test
97
+ fun rejectsUnmarkedFirstGenerationSecureTrailer() {
98
+ val unmarkedPayload = securePayload(pairingFlag = 1)
99
+ unmarkedPayload[10] = 0x11
100
+ unmarkedPayload[11] = 0x22
101
+
102
+ assertNull(MentraLivePairingAdvertisementParser.parse(unmarkedPayload))
103
+ }
104
+
105
+ @Test
106
+ fun rejectsEveryLegacyVersionAndCapabilityCombination() {
107
+ for (version in 0..255) {
108
+ for (capability in 0..255) {
109
+ val legacyPayload = ByteArray(27)
110
+ legacyPayload[5] = 1
111
+ legacyPayload[6] = version.toByte()
112
+ legacyPayload[7] = capability.toByte()
113
+ legacyPayload[10] = 0x4D
114
+ // Offset 11 is padding in the legacy format, so it cannot contain the second
115
+ // non-zero marker byte.
116
+ legacyPayload[11] = 0
117
+ assertNull(MentraLivePairingAdvertisementParser.parse(legacyPayload))
118
+ }
119
+ }
120
+ }
121
+
122
+ private fun securePayload(pairingFlag: Int): ByteArray {
123
+ return ByteArray(27).also {
124
+ it[5] = pairingFlag.toByte()
125
+ it[6] = 2
126
+ it[7] = 1
127
+ it[8] = 0x34
128
+ it[9] = 0x12
129
+ it[10] = 0x4D
130
+ it[11] = 0x50
131
+ }
132
+ }
133
+ }
@@ -43,10 +43,13 @@ public class BleJsonCompactTest {
43
43
  assertEquals("photo_status", compact.getString("t"));
44
44
  assertEquals("p1", compact.getString("r"));
45
45
  assertEquals(3, compact.getInt("s"));
46
- assertEquals(100L, compact.getLong("ts"));
47
- assertFalse(compact.has("rc"));
46
+ assertEquals(1_700_000_000_100L, compact.getLong("timestamp"));
47
+ assertFalse(compact.has("ts"));
48
+ assertTrue(compact.has("rc"));
49
+ assertFalse(compact.getBoolean("rc"));
48
50
  assertEquals(1, compact.getJSONObject("cm").getInt("aes"));
49
51
  assertEquals(1, compact.getJSONObject("cm").getInt("src"));
52
+ assertTrue(compact.toString().length() < input.toString().length());
50
53
  }
51
54
 
52
55
  @Test
@@ -73,7 +76,7 @@ public class BleJsonCompactTest {
73
76
  }
74
77
 
75
78
  @Test
76
- public void resolvedConfigDiffOmitsRepeatPayload() throws Exception {
79
+ public void resolvedConfigIsAlwaysSent() throws Exception {
77
80
  BleJsonCompact.markSessionConnected(1_000L);
78
81
  JSONObject config = new JSONObject("{\"source\":\"sdk\",\"transferMethod\":\"auto\"}");
79
82
  JSONObject first =
@@ -99,13 +102,97 @@ public class BleJsonCompactTest {
99
102
  JSONObject secondWire = BleJsonCompact.encode(second);
100
103
 
101
104
  assertTrue(firstWire.has("resolvedConfig"));
102
- assertFalse(secondWire.has("resolvedConfig"));
103
- assertEquals(
104
- BleJsonCompact.hashConfig(config),
105
- secondWire.getString(BleJsonCompact.KEY_RESOLVED_CONFIG_HASH));
105
+ assertTrue(secondWire.has("resolvedConfig"));
106
+ assertFalse(secondWire.has(BleJsonCompact.KEY_RESOLVED_CONFIG_HASH));
106
107
 
108
+ BleJsonCompact.markSessionConnected(9_000L);
107
109
  JSONObject restoredSecond = BleJsonCompact.decode(secondWire);
108
110
  assertEquals("sdk", restoredSecond.getJSONObject("resolvedConfig").getString("source"));
111
+ assertEquals(1_200L, restoredSecond.getLong("timestamp"));
112
+ }
113
+
114
+ @Test
115
+ public void legacyResolvedConfigHashStillDecodesWhenCached() throws Exception {
116
+ JSONObject config = new JSONObject("{\"source\":\"sdk\",\"transferMethod\":\"auto\"}");
117
+ JSONObject full =
118
+ new JSONObject(
119
+ "{\"t\":\"stream_status\",\"s\":\"streaming\",\"resolvedConfig\":"
120
+ + config
121
+ + "}");
122
+ BleJsonCompact.decode(full);
123
+ JSONObject hashOnly =
124
+ new JSONObject(
125
+ "{\"t\":\"stream_status\",\"s\":\"streaming\",\"rch\":\""
126
+ + BleJsonCompact.hashConfig(config)
127
+ + "\"}");
128
+
129
+ JSONObject restored = BleJsonCompact.decode(hashOnly);
130
+
131
+ assertEquals("sdk", restored.getJSONObject("resolvedConfig").getString("source"));
132
+ assertFalse(restored.has(BleJsonCompact.KEY_RESOLVED_CONFIG_HASH));
133
+ }
134
+
135
+ @Test
136
+ public void legacyResolvedConfigCacheMissKeepsHashForDiagnostics() throws Exception {
137
+ JSONObject hashOnly =
138
+ new JSONObject(
139
+ "{\"t\":\"stream_status\",\"s\":\"streaming\",\"rch\":\"deadbeef\"}");
140
+
141
+ JSONObject restored = BleJsonCompact.decode(hashOnly);
142
+
143
+ assertEquals("deadbeef", restored.getString(BleJsonCompact.KEY_RESOLVED_CONFIG_HASH));
144
+ assertFalse(restored.has("resolvedConfig"));
145
+ }
146
+
147
+ @Test
148
+ public void ambiguousNestedWireKeysFallBackToVerboseJson() throws Exception {
149
+ JSONObject input =
150
+ new JSONObject(
151
+ "{\"type\":\"stream_status\",\"payload\":{"
152
+ + "\"s\":\"literal\",\"kind\":0,\"source\":1}}");
153
+
154
+ JSONObject wire = BleJsonCompact.encode(input);
155
+
156
+ assertTrue(wire.has("type"));
157
+ assertFalse(wire.has("t"));
158
+ assertEquals("literal", wire.getJSONObject("payload").getString("s"));
159
+ assertEquals(0, wire.getJSONObject("payload").getInt("kind"));
160
+ assertEquals(1, wire.getJSONObject("payload").getInt("source"));
161
+ JSONObject restored = BleJsonCompact.decodeIfSupported(wire);
162
+ assertEquals("literal", restored.getJSONObject("payload").getString("s"));
163
+ assertEquals(0, restored.getJSONObject("payload").getInt("kind"));
164
+ assertEquals(1, restored.getJSONObject("payload").getInt("source"));
165
+ }
166
+
167
+ @Test
168
+ public void numericEnumFieldsFallBackToVerboseJson() throws Exception {
169
+ JSONObject input =
170
+ new JSONObject(
171
+ "{\"type\":\"stream_status\",\"payload\":{\"kind\":0,\"source\":1}}");
172
+
173
+ JSONObject wire = BleJsonCompact.encode(input);
174
+
175
+ assertTrue(wire.has("type"));
176
+ assertFalse(wire.has("t"));
177
+ assertEquals(0, wire.getJSONObject("payload").getInt("kind"));
178
+ assertEquals(1, wire.getJSONObject("payload").getInt("source"));
179
+ }
180
+
181
+ @Test
182
+ public void absoluteTimestampSurvivesDifferentSessionEpochs() throws Exception {
183
+ BleJsonCompact.markSessionConnected(1_000L);
184
+ JSONObject input =
185
+ new JSONObject(
186
+ "{\"type\":\"stream_status\",\"status\":\"streaming\","
187
+ + "\"timestamp\":1700000000123}");
188
+
189
+ JSONObject wire = BleJsonCompact.encode(input);
190
+ BleJsonCompact.markSessionConnected(9_000L);
191
+ JSONObject restored = BleJsonCompact.decode(wire);
192
+
193
+ assertEquals(1_700_000_000_123L, wire.getLong("timestamp"));
194
+ assertFalse(wire.has("ts"));
195
+ assertEquals(1_700_000_000_123L, restored.getLong("timestamp"));
109
196
  }
110
197
 
111
198
  @Test
@@ -135,6 +222,60 @@ public class BleJsonCompactTest {
135
222
  assertEquals("wifi_scan_result", BleJsonCompact.encode(wifiScan).getString("t"));
136
223
  }
137
224
 
225
+ @Test
226
+ public void startStreamPreservesDisabledSound() throws Exception {
227
+ JSONObject start =
228
+ new JSONObject(
229
+ "{\"type\":\"start_stream\",\"streamUrl\":\"https://example.test/whip\","
230
+ + "\"sound\":false}");
231
+
232
+ JSONObject wire = BleJsonCompact.encode(start);
233
+
234
+ assertEquals("start_stream", wire.getString("t"));
235
+ assertTrue(wire.has("sound"));
236
+ assertFalse(wire.getBoolean("sound"));
237
+ assertFalse(BleJsonCompact.decode(wire).getBoolean("sound"));
238
+ }
239
+
240
+ @Test
241
+ public void jsonValuesRoundTripAtEveryDepth() throws Exception {
242
+ JSONObject status =
243
+ new JSONObject(
244
+ "{\"type\":\"stream_status\",\"status\":\"stopped\","
245
+ + "\"streaming\":false,\"reconnecting\":false,"
246
+ + "\"resolvedConfig\":{\"audio\":{\"echoCancellation\":false,"
247
+ + "\"noiseSuppression\":false}},"
248
+ + "\"nullValue\":null,\"emptyObject\":{},\"emptyArray\":[]}");
249
+
250
+ JSONObject wire = BleJsonCompact.encode(status);
251
+
252
+ assertTrue(wire.has("streaming"));
253
+ assertFalse(wire.getBoolean("streaming"));
254
+ assertTrue(wire.has("rc"));
255
+ assertFalse(wire.getBoolean("rc"));
256
+ JSONObject wireAudio = wire.getJSONObject("resolvedConfig").getJSONObject("audio");
257
+ assertTrue(wireAudio.has("echoCancellation"));
258
+ assertFalse(wireAudio.getBoolean("echoCancellation"));
259
+ assertTrue(wireAudio.has("noiseSuppression"));
260
+ assertFalse(wireAudio.getBoolean("noiseSuppression"));
261
+ assertTrue(wire.has("nullValue"));
262
+ assertTrue(wire.isNull("nullValue"));
263
+ assertEquals(0, wire.getJSONObject("emptyObject").length());
264
+ assertEquals(0, wire.getJSONArray("emptyArray").length());
265
+
266
+ JSONObject restored = BleJsonCompact.decode(wire);
267
+ assertFalse(restored.getBoolean("streaming"));
268
+ assertFalse(restored.getBoolean("reconnecting"));
269
+ JSONObject restoredAudio =
270
+ restored.getJSONObject("resolvedConfig").getJSONObject("audio");
271
+ assertFalse(restoredAudio.getBoolean("echoCancellation"));
272
+ assertFalse(restoredAudio.getBoolean("noiseSuppression"));
273
+ assertTrue(restored.has("nullValue"));
274
+ assertTrue(restored.isNull("nullValue"));
275
+ assertEquals(0, restored.getJSONObject("emptyObject").length());
276
+ assertEquals(0, restored.getJSONArray("emptyArray").length());
277
+ }
278
+
138
279
  @Test
139
280
  public void streamTelemetryRoundTripsWithCompactKeys() throws Exception {
140
281
  JSONObject status =