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

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 (31) hide show
  1. package/README.md +19 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
  12. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  18. package/build/generated/releaseMetadata.js +5 -5
  19. package/build/generated/releaseMetadata.js.map +1 -1
  20. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  21. package/ios/Source/Bridge.swift +15 -1
  22. package/ios/Source/DeviceManager.swift +7 -3
  23. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  24. package/ios/Source/MentraBluetoothSDK.swift +32 -6
  25. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  26. package/ios/Source/sgcs/MentraLive.swift +9 -2
  27. package/ios/Source/status/DeviceStatus.swift +2 -3
  28. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  29. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  30. package/package.json +1 -1
  31. package/src/generated/releaseMetadata.ts +5 -5
@@ -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
+ }
@@ -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
+ }
@@ -200,9 +200,8 @@ data class VersionInfoResult(
200
200
  systemTimeMs?.let { put("systemTimeMs", it) }
201
201
  put("otaVersionUrl", otaVersionUrl)
202
202
  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.
203
+ // Only when known: per-chunk events may omit package identity. Emitting an empty
204
+ // value would overwrite a known identity, which the OTA guard uses to identify stock.
206
205
  if (packageName.isNotEmpty()) {
207
206
  put("packageName", packageName)
208
207
  }
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import android.Manifest
4
+ import android.content.ComponentName
5
+ import android.content.Intent
6
+ import android.content.pm.ServiceInfo
7
+ import android.location.LocationManager
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Test
10
+ import org.junit.runner.RunWith
11
+ import org.robolectric.Robolectric
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.RuntimeEnvironment
14
+ import org.robolectric.Shadows.shadowOf
15
+ import org.robolectric.annotation.Config
16
+ import org.robolectric.util.ReflectionHelpers
17
+
18
+ @RunWith(RobolectricTestRunner::class)
19
+ @Config(sdk = [33])
20
+ class ForegroundServiceManifestTest {
21
+ @Test
22
+ fun `host manifest controls startup and resume even with all permissions granted`() {
23
+ val application = RuntimeEnvironment.getApplication()
24
+ val component = ComponentName(application, ForegroundService::class.java)
25
+ val info = application.packageManager.getServiceInfo(component, 0)
26
+ ReflectionHelpers.setField(info, "mForegroundServiceType", ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
27
+ shadowOf(application.packageManager).addOrUpdateService(info)
28
+ shadowOf(application).grantPermissions(
29
+ Manifest.permission.BLUETOOTH_CONNECT,
30
+ Manifest.permission.RECORD_AUDIO,
31
+ Manifest.permission.ACCESS_FINE_LOCATION,
32
+ )
33
+ shadowOf(application.getSystemService(LocationManager::class.java)).setLocationEnabled(true)
34
+
35
+ val controller = Robolectric.buildService(ForegroundService::class.java).create()
36
+ val service = controller.get()
37
+ try {
38
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, service.foregroundServiceType)
39
+ service.onStartCommand(Intent(ForegroundService.ACTION_REFRESH_TYPES), 0, 1)
40
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, service.foregroundServiceType)
41
+ service.onStartCommand(null, 0, 2)
42
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, service.foregroundServiceType)
43
+ } finally {
44
+ controller.destroy()
45
+ }
46
+ }
47
+
48
+ @Test
49
+ fun `default library manifest retains the MentraOS startup type`() {
50
+ val controller = Robolectric.buildService(ForegroundService::class.java).create()
51
+ try {
52
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, controller.get().foregroundServiceType)
53
+ } finally {
54
+ controller.destroy()
55
+ }
56
+ }
57
+ }
@@ -6,6 +6,58 @@ import org.junit.Assert.assertTrue
6
6
  import org.junit.Test
7
7
 
8
8
  class ForegroundServiceTypeTest {
9
+ @Test
10
+ fun `connected-device-only host bootstraps without dataSync`() {
11
+ assertEquals(
12
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
13
+ ForegroundService.bootstrapServiceType(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE),
14
+ )
15
+ }
16
+
17
+ @Test
18
+ fun `permissions cannot enable types excluded by the host manifest`() {
19
+ assertEquals(
20
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
21
+ ForegroundService.preferredServiceType(
22
+ hasConnectedDeviceAccess = true,
23
+ hasMicrophoneAccess = true,
24
+ hasLocationAccess = true,
25
+ declaredTypes = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
26
+ ),
27
+ )
28
+ }
29
+
30
+ @Test
31
+ fun `restricted host fallback never adds undeclared dataSync`() {
32
+ assertEquals(
33
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
34
+ ForegroundService.preferredServiceType(
35
+ hasConnectedDeviceAccess = false,
36
+ hasMicrophoneAccess = false,
37
+ hasLocationAccess = false,
38
+ declaredTypes = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
39
+ ),
40
+ )
41
+ }
42
+
43
+ @Test
44
+ fun `MentraOS retains all eligible long-running types`() {
45
+ assertEquals(
46
+ ForegroundService.DEFAULT_SERVICE_TYPES and
47
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC.inv(),
48
+ ForegroundService.preferredServiceType(
49
+ hasConnectedDeviceAccess = true,
50
+ hasMicrophoneAccess = true,
51
+ hasLocationAccess = true,
52
+ ),
53
+ )
54
+ }
55
+
56
+ @Test(expected = IllegalArgumentException::class)
57
+ fun `host without a valid bootstrap type fails explicitly`() {
58
+ ForegroundService.bootstrapServiceType(ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
59
+ }
60
+
9
61
  @Test
10
62
  fun `bootstrap starts as dataSync`() {
11
63
  assertEquals(
@@ -0,0 +1,94 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCallback
6
+ import android.bluetooth.BluetoothGattCharacteristic
7
+ import android.bluetooth.BluetoothGattDescriptor
8
+ import java.util.UUID
9
+ import org.junit.Assert.assertArrayEquals
10
+ import org.junit.Assert.assertEquals
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+ import org.robolectric.RuntimeEnvironment
15
+ import org.robolectric.annotation.Config
16
+
17
+ @RunWith(RobolectricTestRunner::class)
18
+ @Config(sdk = [28])
19
+ class MentraLiveGattCallbackTest {
20
+ private fun gatt(): BluetoothGatt =
21
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice("AA:BB:CC:DD:EE:FF")
22
+ .connectGatt(RuntimeEnvironment.getApplication(), false, object : BluetoothGattCallback() {})
23
+
24
+ private class RecordingCallback(
25
+ enqueue: (() -> Unit) -> Unit,
26
+ isCurrent: (BluetoothGatt) -> Boolean = { true },
27
+ ) : SerializedGattCallback(enqueue, isCurrent) {
28
+ val events = mutableListOf<String>()
29
+ val packets = mutableListOf<ByteArray>()
30
+ override fun handleConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { events.add("connection") }
31
+ override fun handleServicesDiscovered(gatt: BluetoothGatt, status: Int) { events.add("services") }
32
+ override fun handlePhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) { events.add("phyUpdate") }
33
+ override fun handlePhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) { events.add("phyRead") }
34
+ override fun handleCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { events.add("read") }
35
+ override fun handleReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { events.add("rssi") }
36
+ override fun handleCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { events.add("write") }
37
+ override fun handleCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, data: ByteArray) { packets.add(data) }
38
+ override fun handleDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { events.add("descriptor") }
39
+ override fun handleMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { events.add("mtu") }
40
+ }
41
+
42
+ @Test
43
+ fun `all callbacks defer effects until the lifecycle owner executes them`() {
44
+ val queue = ArrayDeque<() -> Unit>()
45
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
46
+ val gatt = gatt()
47
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
48
+ val descriptor = BluetoothGattDescriptor(UUID.randomUUID(), 0)
49
+ callback.onConnectionStateChange(gatt, 0, 2)
50
+ callback.onServicesDiscovered(gatt, 0)
51
+ callback.onPhyUpdate(gatt, 1, 1, 0)
52
+ callback.onPhyRead(gatt, 1, 1, 0)
53
+ callback.onCharacteristicRead(gatt, characteristic, 0)
54
+ callback.onReadRemoteRssi(gatt, -50, 0)
55
+ callback.onCharacteristicWrite(gatt, characteristic, 0)
56
+ callback.onDescriptorWrite(gatt, descriptor, 0)
57
+ callback.onMtuChanged(gatt, 512, 0)
58
+ assertEquals(emptyList<String>(), callback.events)
59
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
60
+ assertEquals(listOf("connection", "services", "phyUpdate", "phyRead", "read", "rssi", "write", "descriptor", "mtu"), callback.events)
61
+ }
62
+
63
+ @Test
64
+ @Suppress("DEPRECATION")
65
+ fun `queued notifications retain their own bytes when Android reuses characteristic`() {
66
+ val queue = ArrayDeque<() -> Unit>()
67
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
68
+ val gatt = gatt()
69
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
70
+ val buffer = byteArrayOf(1, 2)
71
+ characteristic.value = buffer
72
+ callback.onCharacteristicChanged(gatt, characteristic)
73
+ buffer[0] = 3
74
+ callback.onCharacteristicChanged(gatt, characteristic)
75
+ buffer[0] = 4
76
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
77
+ assertArrayEquals(byteArrayOf(1, 2), callback.packets[0])
78
+ assertArrayEquals(byteArrayOf(3, 2), callback.packets[1])
79
+ }
80
+
81
+ @Test
82
+ fun `replacement invalidates callbacks already waiting on the lifecycle queue`() {
83
+ val queue = ArrayDeque<() -> Unit>()
84
+ var epoch = 1
85
+ val callback = RecordingCallback({ work -> queue.addLast(work) }, { epoch == 1 })
86
+ val gatt = gatt()
87
+ callback.onConnectionStateChange(gatt, 0, 0)
88
+ callback.onServicesDiscovered(gatt, 0)
89
+ callback.onMtuChanged(gatt, 512, 0)
90
+ epoch = 2
91
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
92
+ assertEquals(emptyList<String>(), callback.events)
93
+ }
94
+ }