@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.203

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 (63) hide show
  1. package/README.md +64 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. package/src/index.ts +11 -0
@@ -0,0 +1,64 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Process-wide barrier between Mentra Live GATT teardown and the next connection attempt.
5
+ *
6
+ * Android can keep the physical link alive after a disconnect request until it delivers the
7
+ * disconnected callback. Starting another GATT session before that callback can reuse the old link,
8
+ * including its already-consumed MTU exchange. Callers queue connection work here and release it
9
+ * only after every outstanding teardown has completed or timed out. A newer deferred connection
10
+ * replaces the previous one so reconnect retries cannot start multiple GATT sessions together.
11
+ */
12
+ internal class MentraLiveGattTeardownBarrier {
13
+ private val activeTeardowns = mutableSetOf<Long>()
14
+ private var waitingConnection: (() -> Unit)? = null
15
+ private var nextToken = 1L
16
+
17
+ @Synchronized
18
+ fun beginTeardown(): Long {
19
+ val token = nextToken++
20
+ activeTeardowns.add(token)
21
+ return token
22
+ }
23
+
24
+ /** Admission and execution are one operation; production callers all use the main looper. */
25
+ @Synchronized
26
+ fun runWhenIdle(work: () -> Unit) {
27
+ if (activeTeardowns.isEmpty()) work() else waitingConnection = work
28
+ }
29
+
30
+ @Synchronized
31
+ fun completeTeardown(token: Long) {
32
+ if (!activeTeardowns.remove(token) || activeTeardowns.isNotEmpty()) return
33
+ val ready = waitingConnection.also { waitingConnection = null }
34
+ ready?.invoke()
35
+ }
36
+ }
37
+
38
+ /** One-shot guard shared by the MTU callback and its watchdog fallback. */
39
+ internal class MentraLiveMtuSetupGate {
40
+ private var nextToken = 1L
41
+ private var pendingToken: Long? = null
42
+
43
+ @Synchronized
44
+ fun begin(): Long {
45
+ val token = nextToken++
46
+ pendingToken = token
47
+ return token
48
+ }
49
+
50
+ /** Returns true exactly once for the current setup operation. */
51
+ @Synchronized
52
+ fun complete(token: Long): Boolean {
53
+ if (pendingToken != token) {
54
+ return false
55
+ }
56
+ pendingToken = null
57
+ return true
58
+ }
59
+
60
+ @Synchronized
61
+ fun cancel() {
62
+ pendingToken = null
63
+ }
64
+ }
@@ -313,8 +313,10 @@ abstract class SGCManager {
313
313
 
314
314
  // Network Management
315
315
  abstract fun requestWifiScan(scanId: String?)
316
+ open fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean = false
316
317
  abstract fun sendWifiCredentials(ssid: String, password: String)
317
318
  abstract fun forgetWifiNetwork(ssid: String)
319
+ open fun forgetWifiNetwork(ssid: String, requestId: String?, sid: String?): Boolean = false
318
320
  abstract fun sendHotspotState(enabled: Boolean)
319
321
 
320
322
  /** Set glasses system clock (Mentra Live and G2; no-op on other devices). */
@@ -0,0 +1,76 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothGatt
4
+ import android.bluetooth.BluetoothGattCallback
5
+ import android.bluetooth.BluetoothGattCharacteristic
6
+ import android.bluetooth.BluetoothGattDescriptor
7
+
8
+ /**
9
+ * Binder callbacks only capture their arguments. Session validation and all callback effects run
10
+ * on the lifecycle owner, in the same queue as connect and teardown. Notification bytes must be
11
+ * copied before dispatch: Android reuses the mutable characteristic between notifications.
12
+ */
13
+ internal abstract class SerializedGattCallback(
14
+ private val enqueue: (() -> Unit) -> Unit,
15
+ private val isCurrent: (BluetoothGatt) -> Boolean,
16
+ ) : BluetoothGattCallback() {
17
+ private fun dispatch(gatt: BluetoothGatt, work: () -> Unit) {
18
+ enqueue { if (isCurrent(gatt)) work() }
19
+ }
20
+
21
+ final override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) =
22
+ dispatch(gatt) { handleConnectionStateChange(gatt, status, newState) }
23
+
24
+ final override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) =
25
+ dispatch(gatt) { handleServicesDiscovered(gatt, status) }
26
+
27
+ final override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) =
28
+ dispatch(gatt) { handlePhyUpdate(gatt, txPhy, rxPhy, status) }
29
+
30
+ final override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) =
31
+ dispatch(gatt) { handlePhyRead(gatt, txPhy, rxPhy, status) }
32
+
33
+ final override fun onCharacteristicRead(
34
+ gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int,
35
+ ) = dispatch(gatt) { handleCharacteristicRead(gatt, characteristic, status) }
36
+
37
+ final override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) =
38
+ dispatch(gatt) { handleReadRemoteRssi(gatt, rssi, status) }
39
+
40
+ final override fun onCharacteristicWrite(
41
+ gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int,
42
+ ) = dispatch(gatt) { handleCharacteristicWrite(gatt, characteristic, status) }
43
+
44
+ @Suppress("DEPRECATION")
45
+ final override fun onCharacteristicChanged(
46
+ gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
47
+ ) {
48
+ val value = characteristic.value?.copyOf() ?: return
49
+ dispatch(gatt) { handleCharacteristicChanged(gatt, characteristic, value) }
50
+ }
51
+
52
+ final override fun onCharacteristicChanged(
53
+ gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray,
54
+ ) {
55
+ val snapshot = value.copyOf()
56
+ dispatch(gatt) { handleCharacteristicChanged(gatt, characteristic, snapshot) }
57
+ }
58
+
59
+ final override fun onDescriptorWrite(
60
+ gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int,
61
+ ) = dispatch(gatt) { handleDescriptorWrite(gatt, descriptor, status) }
62
+
63
+ final override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) =
64
+ dispatch(gatt) { handleMtuChanged(gatt, mtu, status) }
65
+
66
+ protected abstract fun handleConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int)
67
+ protected abstract fun handleServicesDiscovered(gatt: BluetoothGatt, status: Int)
68
+ protected abstract fun handlePhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int)
69
+ protected abstract fun handlePhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int)
70
+ protected abstract fun handleCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int)
71
+ protected abstract fun handleReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int)
72
+ protected abstract fun handleCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int)
73
+ protected abstract fun handleCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, data: ByteArray)
74
+ protected abstract fun handleDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int)
75
+ protected abstract fun handleMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int)
76
+ }
@@ -189,6 +189,10 @@ data class VersionInfoResult(
189
189
  val appVersion: String,
190
190
  val packageName: String,
191
191
  val hotspotOtaVersion: Int,
192
+ val wifiForgetResultVersion: Int? = null,
193
+ val savedWifiNetworksVersion: Int? = null,
194
+ val versionInfoType: String? = null,
195
+ val sid: String? = null,
192
196
  ) {
193
197
  internal fun toMap(): Map<String, Any> =
194
198
  buildMap {
@@ -200,13 +204,16 @@ data class VersionInfoResult(
200
204
  systemTimeMs?.let { put("systemTimeMs", it) }
201
205
  put("otaVersionUrl", otaVersionUrl)
202
206
  put("appVersion", appVersion)
203
- // Only when known. requestVersionInfo() resolves from ANY version_info chunk, and
204
- // only chunk 1 carries package_name emitting "" from a chunk-3 resolution would
205
- // overwrite a known identity with "absent", which the OTA guard reads as stock.
207
+ // Only when known: per-chunk events may omit package identity. Emitting an empty
208
+ // value would overwrite a known identity, which the OTA guard uses to identify stock.
206
209
  if (packageName.isNotEmpty()) {
207
210
  put("packageName", packageName)
208
211
  }
209
212
  put("hotspotOtaVersion", hotspotOtaVersion)
213
+ wifiForgetResultVersion?.let { put("wifiForgetResultVersion", it) }
214
+ savedWifiNetworksVersion?.let { put("savedWifiNetworksVersion", it) }
215
+ versionInfoType?.let { put("versionInfoType", it) }
216
+ sid?.let { put("sid", it) }
210
217
  }
211
218
 
212
219
  companion object {
@@ -237,6 +244,12 @@ data class VersionInfoResult(
237
244
  packageName = stringValue(values, "packageName", "package_name") ?: "",
238
245
  hotspotOtaVersion =
239
246
  numberValue(values, "hotspotOtaVersion", "hotspot_ota_version") ?: 0,
247
+ wifiForgetResultVersion =
248
+ numberValue(values, "wifiForgetResultVersion", "wifi_forget_result_version"),
249
+ savedWifiNetworksVersion =
250
+ numberValue(values, "savedWifiNetworksVersion", "saved_wifi_networks_version"),
251
+ versionInfoType = stringValue(values, "versionInfoType", "version_info_type"),
252
+ sid = stringValue(values, "sid"),
240
253
  )
241
254
  }
242
255
  }
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class GlassesStatusClassicAudioTest {
7
+ @Test
8
+ fun `serializes connected Classic audio state for React Native`() {
9
+ val serialized =
10
+ GlassesStatus.fromMap(mapOf("bluetoothClassicConnected" to true)).toMap()
11
+
12
+ assertThat(serialized["bluetoothClassicConnected"]).isEqualTo(true)
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class MessageAckPolicyTest {
8
+ @Test
9
+ fun `every non-ack message id is acknowledged regardless of type`() {
10
+ assertEquals(17L, incomingGlassesMessageAckId("wifi_forget_result", 17L))
11
+ assertEquals(18L, incomingGlassesMessageAckId("saved_wifi_networks", 18L))
12
+ assertEquals(19L, incomingGlassesMessageAckId("future_terminal_type", 19L))
13
+ assertNull(incomingGlassesMessageAckId("msg_ack", 20L))
14
+ assertNull(incomingGlassesMessageAckId("wifi_forget_result", null))
15
+ }
16
+ }
@@ -0,0 +1,33 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+ import org.junit.runner.RunWith
7
+ import org.robolectric.RobolectricTestRunner
8
+ import org.robolectric.annotation.Config
9
+
10
+ @RunWith(RobolectricTestRunner::class)
11
+ @Config(sdk = [28])
12
+ class VersionInfoBridgeTest {
13
+ @Test
14
+ fun wireMetadataSurvivesBridgeNormalizationAndCompletesOnlyAfterBothChunks() {
15
+ val accumulator = VersionInfoResponseAccumulator("request-1")
16
+ val outcomes = mutableListOf<VersionInfoAccumulatorOutcome>()
17
+ val sink = Bridge.addEventSink { type, body ->
18
+ if (type == "version_info") outcomes.add(accumulator.accept(body))
19
+ }
20
+ val common = mapOf<String, Any>("request_id" to "request-1", "sid" to "asg-1", "chunkCount" to 2)
21
+ try {
22
+ Bridge.sendVersionInfo(common + mapOf("chunkIndex" to 1, "final" to false, "build_number" to "42"), "version_info_1")
23
+ Bridge.sendVersionInfo(common + mapOf("chunkIndex" to 2, "final" to true, "bes_fw_version" to "new"), "version_info_3")
24
+ assertEquals(VersionInfoAccumulatorOutcome.Waiting, outcomes[0])
25
+ val complete = outcomes[1] as VersionInfoAccumulatorOutcome.Complete
26
+ assertEquals("42", complete.result.buildNumber)
27
+ assertEquals("new", complete.result.besFirmwareVersion)
28
+ assertTrue(complete.result.toMap().keys.none { it.startsWith("_response") })
29
+ } finally {
30
+ Bridge.removeEventSink(sink)
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,169 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class VersionInfoResponseAccumulatorTest {
7
+ @Test
8
+ fun mergesCurrentChunksAndCompletesOnFirmwareChunk() {
9
+ val accumulator = VersionInfoResponseAccumulator("request-1")
10
+
11
+ assertThat(accumulator.accept(chunk("version_info_1", "request-1", "buildNumber" to "42")))
12
+ .isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
13
+
14
+ val complete =
15
+ accumulator.accept(
16
+ chunk(
17
+ "version_info_3",
18
+ "request-1",
19
+ "buildNumber" to "",
20
+ "besFirmwareVersion" to "26.8.27.0",
21
+ "mtkFirmwareVersion" to "MentraLive_20260709",
22
+ )
23
+ ) as VersionInfoAccumulatorOutcome.Complete
24
+
25
+ assertThat(complete.result.buildNumber).isEqualTo("42")
26
+ assertThat(complete.result.besFirmwareVersion).isEqualTo("26.8.27.0")
27
+ assertThat(complete.result.mtkFirmwareVersion).isEqualTo("MentraLive_20260709")
28
+ }
29
+
30
+ @Test
31
+ fun ignoresMismatchedAndTrailingStaleChunks() {
32
+ val accumulator = VersionInfoResponseAccumulator("request-1")
33
+
34
+ assertThat(accumulator.accept(chunk("version_info_1", "other", "buildNumber" to "old")))
35
+ .isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
36
+ assertThat(accumulator.accept(chunk("version_info_3", null, "besFirmwareVersion" to "stale")))
37
+ .isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
38
+ }
39
+
40
+ @Test
41
+ fun doesNotMixCorrelatedAndUncorrelatedSequences() {
42
+ val accumulator = VersionInfoResponseAccumulator("request-1")
43
+ accumulator.accept(chunk("version_info_1", "request-1", "buildNumber" to "42"))
44
+
45
+ assertThat(accumulator.accept(chunk("version_info_3", null, "besFirmwareVersion" to "stale")))
46
+ .isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
47
+
48
+ val complete =
49
+ accumulator.accept(
50
+ chunk("version_info_3", "request-1", "besFirmwareVersion" to "current")
51
+ ) as VersionInfoAccumulatorOutcome.Complete
52
+ assertThat(complete.result.besFirmwareVersion).isEqualTo("current")
53
+ }
54
+
55
+ @Test
56
+ fun staleUncorrelatedResponsesCannotReplaceCorrelatedSequence() {
57
+ val accumulator = VersionInfoResponseAccumulator("request-1")
58
+ accumulator.accept(chunk("version_info_1", "request-1", "buildNumber" to "42"))
59
+
60
+ assertThat(accumulator.accept(chunk("version_info_1", null, "buildNumber" to "stale")))
61
+ .isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
62
+ assertThat(accumulator.accept(chunk("version_info", null, "buildNumber" to "legacy")))
63
+ .isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
64
+
65
+ val complete =
66
+ accumulator.accept(
67
+ chunk("version_info_3", "request-1", "besFirmwareVersion" to "current")
68
+ ) as VersionInfoAccumulatorOutcome.Complete
69
+ assertThat(complete.result.buildNumber).isEqualTo("42")
70
+ assertThat(complete.result.besFirmwareVersion).isEqualTo("current")
71
+ }
72
+
73
+ @Test
74
+ fun repeatedFirstChunkResetsRatherThanMixingResponses() {
75
+ val accumulator = VersionInfoResponseAccumulator("request-1")
76
+ accumulator.accept(chunk("version_info_1", null, "appVersion" to "old"))
77
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to "43"))
78
+
79
+ val complete = (accumulator.accept(chunk("version_info_3", null, "besFirmwareVersion" to "new"))
80
+ as VersionInfoAccumulatorOutcome.Complete).result
81
+
82
+ assertThat(complete.appVersion).isEmpty()
83
+ assertThat(complete.buildNumber).isEqualTo("43")
84
+ assertThat(complete.besFirmwareVersion).isEqualTo("new")
85
+ }
86
+
87
+ @Test
88
+ fun legacySingleMessageCompletesImmediately() {
89
+ val accumulator = VersionInfoResponseAccumulator("request-1")
90
+
91
+ val complete =
92
+ accumulator.accept(chunk("version_info", null, "buildNumber" to "7"))
93
+ as VersionInfoAccumulatorOutcome.Complete
94
+
95
+ assertThat(complete.result.buildNumber).isEqualTo("7")
96
+ }
97
+
98
+ @Test
99
+ fun legacyFirstChunkDoesNotCompleteWithoutFinalChunk() {
100
+ val accumulator = VersionInfoResponseAccumulator("request-1")
101
+ assertThat(accumulator.accept(chunk("version_info_1", null, "buildNumber" to "8")))
102
+ .isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
103
+ }
104
+
105
+ @Test
106
+ fun modernFinalChunkWaitsForMissingFirstChunk() {
107
+ val accumulator = VersionInfoResponseAccumulator("request-1")
108
+ assertThat(accumulator.accept(chunk("version_info_3", "request-1", "besFirmwareVersion" to "new")))
109
+ .isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
110
+ val result = accumulator.accept(chunk("version_info_1", "request-1", "buildNumber" to "42"))
111
+ as VersionInfoAccumulatorOutcome.Complete
112
+ assertThat(result.result.buildNumber).isEqualTo("42")
113
+ assertThat(result.result.besFirmwareVersion).isEqualTo("new")
114
+ }
115
+
116
+ @Test
117
+ fun duplicateChunkDoesNotCountAsMissingChunk() {
118
+ val accumulator = VersionInfoResponseAccumulator("request-1")
119
+ val first = chunk("version_info_1", "request-1", "buildNumber" to "42")
120
+ assertThat(accumulator.accept(first)).isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
121
+ assertThat(accumulator.accept(first)).isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
122
+ }
123
+
124
+ @Test
125
+ fun rejectsMalformedModernMetadataAndMixedProcess() {
126
+ val accumulator = VersionInfoResponseAccumulator("request-1")
127
+ val first = chunk("version_info_1", "request-1", "buildNumber" to "42")
128
+ for ((key, value) in listOf(
129
+ VersionInfoResponseAccumulator.RESPONSE_COUNT_KEY to 2.5,
130
+ VersionInfoResponseAccumulator.RESPONSE_INDEX_KEY to true,
131
+ VersionInfoResponseAccumulator.RESPONSE_FINAL_KEY to true,
132
+ VersionInfoResponseAccumulator.RESPONSE_REQUEST_ID_KEY to "",
133
+ )) {
134
+ assertThat(accumulator.accept(first + (key to value))).isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
135
+ }
136
+ accumulator.accept(first)
137
+ val last = chunk("version_info_3", "request-1", "besFirmwareVersion" to "new")
138
+ assertThat(accumulator.accept(last + (VersionInfoResponseAccumulator.RESPONSE_SID_KEY to "process-2")))
139
+ .isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
140
+ assertThat(accumulator.accept(last)).isInstanceOf(VersionInfoAccumulatorOutcome.Complete::class.java)
141
+ }
142
+
143
+ @Test
144
+ fun correlatedResponseWithoutCompletenessMetadataCannotComplete() {
145
+ val accumulator = VersionInfoResponseAccumulator("request-1")
146
+ val event = mapOf<String, Any>(
147
+ VersionInfoResponseAccumulator.RESPONSE_CHUNK_KEY to "version_info_3",
148
+ VersionInfoResponseAccumulator.RESPONSE_REQUEST_ID_KEY to "request-1",
149
+ )
150
+ assertThat(accumulator.accept(event)).isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
151
+ }
152
+
153
+ private fun chunk(
154
+ type: String,
155
+ requestId: String?,
156
+ vararg values: Pair<String, Any>,
157
+ ): Map<String, Any> =
158
+ buildMap {
159
+ put(VersionInfoResponseAccumulator.RESPONSE_CHUNK_KEY, type)
160
+ requestId?.let {
161
+ put(VersionInfoResponseAccumulator.RESPONSE_REQUEST_ID_KEY, it)
162
+ put(VersionInfoResponseAccumulator.RESPONSE_INDEX_KEY, if (type == "version_info_1") 1 else 2)
163
+ put(VersionInfoResponseAccumulator.RESPONSE_COUNT_KEY, 2)
164
+ put(VersionInfoResponseAccumulator.RESPONSE_FINAL_KEY, type == "version_info_3")
165
+ put(VersionInfoResponseAccumulator.RESPONSE_SID_KEY, "process-1")
166
+ }
167
+ putAll(values)
168
+ }
169
+ }