@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.
- package/README.md +19 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -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/Bridge.swift +15 -1
- package/ios/Source/DeviceManager.swift +7 -3
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +32 -6
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
- package/ios/Source/sgcs/MentraLive.swift +9 -2
- package/ios/Source/status/DeviceStatus.swift +2 -3
- package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
- package/package.json +1 -1
- 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
|
|
204
|
-
//
|
|
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
|
+
}
|
package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt
ADDED
|
@@ -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
|
+
}
|