@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.
- package/README.md +64 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +318 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -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 +16 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
- 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/WifiRequestResolutionTest.kt +271 -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/ClassicAudioConnectionTrackerTest.kt +131 -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/BluetoothSdk.types.d.ts +62 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +3 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +78 -1
- package/ios/Source/DeviceManager.swift +13 -6
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +397 -99
- package/ios/Source/WifiRequestResolution.swift +251 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
- package/ios/Source/sgcs/MentraLive.swift +179 -37
- package/ios/Source/sgcs/SGCManager.swift +13 -1
- package/ios/Source/status/DeviceStatus.swift +30 -3
- package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
- package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +76 -1
- package/src/_private/BluetoothSdkModule.ts +4 -1
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +11 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
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 WifiRequestResolutionTest {
|
|
10
|
+
@Test
|
|
11
|
+
fun `future fractional and malformed capabilities are unsupported not legacy`() {
|
|
12
|
+
for (version in listOf(0, 2, -1, 1.5, "1", true)) {
|
|
13
|
+
val capabilities = WifiSessionCapabilities()
|
|
14
|
+
capabilities.reset("sid")
|
|
15
|
+
capabilities.applyVersionInfo1(mapOf("wifiForgetResultVersion" to version, "savedWifiNetworksVersion" to version))
|
|
16
|
+
assertEquals(WifiRequestMode.UNSUPPORTED, capabilities.forgetMode())
|
|
17
|
+
assertEquals(WifiRequestMode.UNSUPPORTED, capabilities.savedNetworksMode())
|
|
18
|
+
}
|
|
19
|
+
val missingSession = WifiSessionCapabilities()
|
|
20
|
+
missingSession.applyVersionInfo1(mapOf("wifiForgetResultVersion" to 1))
|
|
21
|
+
assertEquals(WifiRequestMode.UNSUPPORTED, missingSession.forgetMode())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
fun `raw response rejects partial tuples and coercion before normalization`() {
|
|
26
|
+
val tuple = mapOf<String, Any>("protocol_version" to 1, "requestId" to "request", "sid" to "sid")
|
|
27
|
+
assertTrue(wifiResponseEnvelopeIsValid(tuple, false))
|
|
28
|
+
for (key in tuple.keys) assertFalse(wifiResponseEnvelopeIsValid(tuple - key, true))
|
|
29
|
+
for (version in listOf(0, 2, 1.5, "1", true)) {
|
|
30
|
+
assertFalse(wifiResponseEnvelopeIsValid(tuple + ("protocol_version" to version), true))
|
|
31
|
+
}
|
|
32
|
+
assertFalse(wifiResponseEnvelopeIsValid(tuple + ("connected" to 0), true))
|
|
33
|
+
assertFalse(wifiResponseEnvelopeIsValid(tuple + ("dispatched" to true), true))
|
|
34
|
+
assertTrue(wifiResponseEnvelopeIsValid(mapOf("dispatched" to true), true))
|
|
35
|
+
assertFalse(wifiResponseEnvelopeIsValid(mapOf("requestId" to "", "dispatched" to true), true))
|
|
36
|
+
assertFalse(wifiResponseEnvelopeIsValid(emptyMap(), false))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun `semantic results omit transport metadata`() {
|
|
41
|
+
val data = mapOf<String, Any>("requestId" to "request", "sid" to "sid", "protocolVersion" to 1,
|
|
42
|
+
"ssid" to "AP", "outcome" to "confirmed", "networks" to listOf("AP"))
|
|
43
|
+
val forbidden = setOf("mode", "capabilityVersion", "requestId", "sid", "protocolVersion")
|
|
44
|
+
assertTrue(parseWifiForgetResult("request", "sid", "AP", 1, data)!!.toMap().keys.intersect(forbidden).isEmpty())
|
|
45
|
+
assertTrue(parseSavedWifiNetworks("request", "sid", 1, data)!!.toMap().keys.intersect(forbidden).isEmpty())
|
|
46
|
+
assertNull(parseWifiForgetResult("request", "sid", "AP", 1, data + ("protocolVersion" to 1.5)))
|
|
47
|
+
assertNull(parseSavedWifiNetworks("request", "sid", 1, data + ("protocolVersion" to 1.5)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
fun `capabilities are unknown per session until version info finalizes them`() {
|
|
52
|
+
val capabilities = WifiSessionCapabilities()
|
|
53
|
+
capabilities.reset("sid-1")
|
|
54
|
+
|
|
55
|
+
assertEquals(WifiRequestMode.DISCOVERING, capabilities.forgetMode())
|
|
56
|
+
assertEquals(WifiRequestMode.DISCOVERING, capabilities.savedNetworksMode())
|
|
57
|
+
capabilities.applyVersionInfo1(mapOf("sid" to "sid-1", "wifiForgetResultVersion" to 1))
|
|
58
|
+
|
|
59
|
+
assertEquals(WifiProtocolCapability.Supported(1), capabilities.forgetResult)
|
|
60
|
+
assertEquals(WifiProtocolCapability.Legacy, capabilities.savedNetworks)
|
|
61
|
+
assertEquals(WifiRequestMode.MODERN, capabilities.forgetMode())
|
|
62
|
+
assertEquals(WifiRequestMode.LEGACY, capabilities.savedNetworksMode())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun `new session resets capabilities and advances epoch`() {
|
|
67
|
+
val capabilities = WifiSessionCapabilities()
|
|
68
|
+
capabilities.reset("sid-1")
|
|
69
|
+
capabilities.applyVersionInfo1(
|
|
70
|
+
mapOf("wifiForgetResultVersion" to 1, "savedWifiNetworksVersion" to 1)
|
|
71
|
+
)
|
|
72
|
+
val oldEpoch = capabilities.epoch
|
|
73
|
+
|
|
74
|
+
capabilities.reset("sid-2")
|
|
75
|
+
|
|
76
|
+
assertTrue(capabilities.epoch > oldEpoch)
|
|
77
|
+
assertEquals("sid-2", capabilities.sessionId)
|
|
78
|
+
assertEquals(WifiProtocolCapability.Unknown, capabilities.forgetResult)
|
|
79
|
+
assertEquals(WifiProtocolCapability.Unknown, capabilities.savedNetworks)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun `saved request snapshot cannot mix mode with a later session`() {
|
|
84
|
+
val capabilities = WifiSessionCapabilities()
|
|
85
|
+
capabilities.reset("sid-1")
|
|
86
|
+
capabilities.applyVersionInfo1(mapOf("savedWifiNetworksVersion" to 1))
|
|
87
|
+
val first = capabilities.savedNetworksRequestSnapshot()
|
|
88
|
+
|
|
89
|
+
capabilities.reset("sid-2")
|
|
90
|
+
val second = capabilities.savedNetworksRequestSnapshot()
|
|
91
|
+
|
|
92
|
+
assertEquals(WifiRequestMode.MODERN, first.mode)
|
|
93
|
+
assertEquals("sid-1", first.sessionId)
|
|
94
|
+
assertEquals(WifiRequestMode.DISCOVERING, second.mode)
|
|
95
|
+
assertEquals("sid-2", second.sessionId)
|
|
96
|
+
assertTrue(second.epoch > first.epoch)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test
|
|
100
|
+
fun `SSID validation rejects blank input without changing identity`() {
|
|
101
|
+
assertFalse(wifiSsidIsValid(" "))
|
|
102
|
+
assertTrue(wifiSsidIsValid(" Field AP "))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Test
|
|
106
|
+
fun `forget result requires exact id session ssid and protocol`() {
|
|
107
|
+
val exact =
|
|
108
|
+
mapOf<String, Any>(
|
|
109
|
+
"requestId" to "forget-1",
|
|
110
|
+
"sid" to "sid-1",
|
|
111
|
+
"ssid" to " Field AP ",
|
|
112
|
+
"protocolVersion" to 1,
|
|
113
|
+
"outcome" to "dispatched",
|
|
114
|
+
"connected" to false,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assertNull(parseWifiForgetResult("other", "sid-1", " Field AP ", 1, exact))
|
|
118
|
+
assertNull(parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 1, exact - "requestId"))
|
|
119
|
+
assertNull(parseWifiForgetResult("forget-1", "other", " Field AP ", 1, exact))
|
|
120
|
+
assertNull(parseWifiForgetResult("forget-1", "sid-1", "Field AP", 1, exact))
|
|
121
|
+
assertNull(parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 2, exact))
|
|
122
|
+
assertEquals(
|
|
123
|
+
WifiForgetOutcome.DISPATCHED,
|
|
124
|
+
parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 1, exact)?.outcome,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
fun `forget parser preserves each honest terminal outcome`() {
|
|
130
|
+
listOf("confirmed", "dispatched", "not_found", "unsupported", "failed").forEach { outcome ->
|
|
131
|
+
val parsed =
|
|
132
|
+
parseWifiForgetResult(
|
|
133
|
+
"forget-1",
|
|
134
|
+
"sid-1",
|
|
135
|
+
"AP",
|
|
136
|
+
1,
|
|
137
|
+
mapOf(
|
|
138
|
+
"requestId" to "forget-1",
|
|
139
|
+
"sid" to "sid-1",
|
|
140
|
+
"ssid" to "AP",
|
|
141
|
+
"protocolVersion" to 1,
|
|
142
|
+
"outcome" to outcome,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
assertEquals(outcome, parsed?.outcome?.wireValue)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@Test
|
|
150
|
+
fun `missing connectivity snapshot remains unknown`() {
|
|
151
|
+
val parsed =
|
|
152
|
+
parseWifiForgetResult(
|
|
153
|
+
"forget-1",
|
|
154
|
+
"sid-1",
|
|
155
|
+
"AP",
|
|
156
|
+
1,
|
|
157
|
+
mapOf(
|
|
158
|
+
"requestId" to "forget-1",
|
|
159
|
+
"sid" to "sid-1",
|
|
160
|
+
"ssid" to "AP",
|
|
161
|
+
"protocolVersion" to 1,
|
|
162
|
+
"outcome" to "dispatched",
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
assertNull(parsed?.connected)
|
|
167
|
+
assertFalse(parsed?.toMap()?.containsKey("connected") ?: true)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Test
|
|
171
|
+
fun `legacy forget is explicitly unverified`() {
|
|
172
|
+
val result =
|
|
173
|
+
legacyWifiForgetResult(
|
|
174
|
+
"AP",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
assertFalse(result.toMap().keys.any { it in setOf("mode", "capabilityVersion", "requestId", "sid") })
|
|
178
|
+
assertEquals(WifiForgetOutcome.LEGACY_UNVERIFIED, result.outcome)
|
|
179
|
+
assertNull(result.connected)
|
|
180
|
+
assertNull(result.currentSsid)
|
|
181
|
+
assertNull(result.localIp)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
fun `saved list preserves exact identity and requires correlation tuple`() {
|
|
186
|
+
val exact =
|
|
187
|
+
mapOf<String, Any>(
|
|
188
|
+
"requestId" to "saved-1",
|
|
189
|
+
"sid" to "sid-1",
|
|
190
|
+
"protocolVersion" to 1,
|
|
191
|
+
"outcome" to "confirmed",
|
|
192
|
+
"networks" to listOf(" Field AP ", "", "Field AP", " Field AP "),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
assertNull(parseSavedWifiNetworks("other", "sid-1", 1, exact))
|
|
196
|
+
assertNull(parseSavedWifiNetworks("saved-1", "sid-1", 1, exact - "requestId"))
|
|
197
|
+
assertNull(parseSavedWifiNetworks("saved-1", "other", 1, exact))
|
|
198
|
+
assertNull(parseSavedWifiNetworks("saved-1", "sid-1", 2, exact))
|
|
199
|
+
assertEquals(
|
|
200
|
+
listOf(" Field AP ", "Field AP"),
|
|
201
|
+
parseSavedWifiNetworks("saved-1", "sid-1", 1, exact)?.networks,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
val failure =
|
|
205
|
+
parseSavedWifiNetworks(
|
|
206
|
+
"saved-1",
|
|
207
|
+
"sid-1",
|
|
208
|
+
1,
|
|
209
|
+
exact + ("outcome" to "failed") + ("error" to "backend_failed") + ("networks" to emptyList<String>()),
|
|
210
|
+
)
|
|
211
|
+
assertEquals(SavedWifiNetworksOutcome.FAILED, failure?.outcome)
|
|
212
|
+
assertEquals("backend_failed", failure?.error)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@Test
|
|
217
|
+
fun `raw forget event preserves modern and legacy wire truth`() {
|
|
218
|
+
val modern =
|
|
219
|
+
normalizeWifiForgetResultEvent(
|
|
220
|
+
"forget-1",
|
|
221
|
+
"sid-1",
|
|
222
|
+
"AP",
|
|
223
|
+
1,
|
|
224
|
+
"dispatched",
|
|
225
|
+
null,
|
|
226
|
+
false,
|
|
227
|
+
"",
|
|
228
|
+
"",
|
|
229
|
+
null,
|
|
230
|
+
)
|
|
231
|
+
val legacy =
|
|
232
|
+
normalizeWifiForgetResultEvent(
|
|
233
|
+
"",
|
|
234
|
+
"",
|
|
235
|
+
"AP",
|
|
236
|
+
0,
|
|
237
|
+
"",
|
|
238
|
+
true,
|
|
239
|
+
false,
|
|
240
|
+
"",
|
|
241
|
+
"",
|
|
242
|
+
null,
|
|
243
|
+
)
|
|
244
|
+
val withoutSnapshot =
|
|
245
|
+
normalizeWifiForgetResultEvent(
|
|
246
|
+
"forget-unknown",
|
|
247
|
+
"sid-1",
|
|
248
|
+
"AP",
|
|
249
|
+
1,
|
|
250
|
+
"dispatched",
|
|
251
|
+
null,
|
|
252
|
+
null,
|
|
253
|
+
"",
|
|
254
|
+
"",
|
|
255
|
+
null,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
assertEquals("modern", modern?.get("mode"))
|
|
259
|
+
assertEquals("dispatched", modern?.get("outcome"))
|
|
260
|
+
assertEquals("legacy", legacy?.get("mode"))
|
|
261
|
+
assertEquals(true, legacy?.get("dispatched"))
|
|
262
|
+
assertFalse(legacy?.containsKey("sid") ?: true)
|
|
263
|
+
assertFalse(withoutSnapshot?.containsKey("connected") ?: true)
|
|
264
|
+
assertNull(
|
|
265
|
+
normalizeWifiForgetResultEvent(
|
|
266
|
+
"bad", "", "AP", 0, "", null, false, "", "", null
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
}
|
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(
|
package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class ClassicAudioConnectionTrackerTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun `available connected profile is sufficient even if the other query fails or never answers`() {
|
|
9
|
+
assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to true)))
|
|
10
|
+
.containsExactly(ClassicAudioProfile.A2DP)
|
|
11
|
+
assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.HEADSET to true)))
|
|
12
|
+
.containsExactly(ClassicAudioProfile.HEADSET)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
fun `missing profile never fabricates a disconnect during a profile switch`() {
|
|
17
|
+
assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to false))).isNull()
|
|
18
|
+
assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to false,
|
|
19
|
+
ClassicAudioProfile.HEADSET to false))).isEmpty()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private val address = "AA:BB:CC:DD:EE:FF"
|
|
23
|
+
|
|
24
|
+
private fun snapshot(tracker: ClassicAudioConnectionTracker, vararg profiles: ClassicAudioProfile) {
|
|
25
|
+
val ticket = tracker.beginSnapshot(address)!!
|
|
26
|
+
assertThat(tracker.applySnapshot(ticket, address, profiles.toSet())).isTrue()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `publishes complete A2DP and HFP snapshots without intermediate false negative`() {
|
|
31
|
+
val changes = mutableListOf<Boolean>()
|
|
32
|
+
val tracker = ClassicAudioConnectionTracker(changes::add)
|
|
33
|
+
tracker.setTarget(address)
|
|
34
|
+
snapshot(tracker, ClassicAudioProfile.A2DP)
|
|
35
|
+
snapshot(tracker, ClassicAudioProfile.HEADSET)
|
|
36
|
+
snapshot(tracker)
|
|
37
|
+
assertThat(changes).containsExactly(true, false)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test
|
|
41
|
+
fun `address comparison is case insensitive`() {
|
|
42
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
43
|
+
tracker.setTarget(address.lowercase())
|
|
44
|
+
snapshot(tracker, ClassicAudioProfile.A2DP)
|
|
45
|
+
assertThat(tracker.connected).isTrue()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun `another device cannot request a snapshot`() {
|
|
50
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
51
|
+
tracker.setTarget(address)
|
|
52
|
+
assertThat(tracker.beginSnapshot("11:22:33:44:55:66")).isNull()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun `late result for previous device cannot replace current profiles`() {
|
|
57
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
58
|
+
tracker.setTarget(address)
|
|
59
|
+
val ticket = tracker.beginSnapshot(address)!!
|
|
60
|
+
tracker.setTarget("11:22:33:44:55:66")
|
|
61
|
+
assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
|
|
62
|
+
assertThat(tracker.connected).isFalse()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun `old response cannot resurrect reconnected session with same MAC`() {
|
|
67
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
68
|
+
tracker.setTarget(address)
|
|
69
|
+
val ticket = tracker.beginSnapshot(address)!!
|
|
70
|
+
tracker.invalidate(address)
|
|
71
|
+
tracker.setTarget(address)
|
|
72
|
+
snapshot(tracker)
|
|
73
|
+
assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
|
|
74
|
+
assertThat(tracker.connected).isFalse()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun `newer query invalidates in flight snapshot even before its response arrives`() {
|
|
79
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
80
|
+
tracker.setTarget(address)
|
|
81
|
+
val first = tracker.beginSnapshot(address)!!
|
|
82
|
+
val second = tracker.beginSnapshot(address)!!
|
|
83
|
+
assertThat(tracker.applySnapshot(first, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
|
|
84
|
+
assertThat(tracker.applySnapshot(second, address, emptySet())).isTrue()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun `late disconnect snapshot cannot overwrite newer connected snapshot`() {
|
|
89
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
90
|
+
tracker.setTarget(address)
|
|
91
|
+
val old = tracker.beginSnapshot(address)!!
|
|
92
|
+
snapshot(tracker, ClassicAudioProfile.HEADSET)
|
|
93
|
+
assertThat(tracker.applySnapshot(old, address, emptySet())).isFalse()
|
|
94
|
+
assertThat(tracker.connected).isTrue()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test
|
|
98
|
+
fun `clear invalidates queries but retains target for retry`() {
|
|
99
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
100
|
+
tracker.setTarget(address)
|
|
101
|
+
val old = tracker.beginSnapshot(address)!!
|
|
102
|
+
assertThat(tracker.clear(address)).isTrue()
|
|
103
|
+
assertThat(tracker.applySnapshot(old, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
|
|
104
|
+
snapshot(tracker, ClassicAudioProfile.A2DP)
|
|
105
|
+
assertThat(tracker.connected).isTrue()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
fun `reset clears truth and prevents pending results from applying`() {
|
|
110
|
+
val changes = mutableListOf<Boolean>()
|
|
111
|
+
val tracker = ClassicAudioConnectionTracker(changes::add)
|
|
112
|
+
tracker.setTarget(address)
|
|
113
|
+
snapshot(tracker, ClassicAudioProfile.A2DP)
|
|
114
|
+
val ticket = tracker.beginSnapshot(address)!!
|
|
115
|
+
tracker.reset()
|
|
116
|
+
assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
|
|
117
|
+
assertThat(tracker.beginSnapshot(address)).isNull()
|
|
118
|
+
assertThat(changes).containsExactly(true, false)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test
|
|
122
|
+
fun `stale snapshot cannot emit audio routing notifications`() {
|
|
123
|
+
val tracker = ClassicAudioConnectionTracker {}
|
|
124
|
+
tracker.setTarget(address)
|
|
125
|
+
val old = tracker.beginSnapshot(address)!!
|
|
126
|
+
tracker.setTarget(address)
|
|
127
|
+
var notifications = 0
|
|
128
|
+
tracker.applySnapshot(old, address, setOf(ClassicAudioProfile.A2DP)) { notifications++ }
|
|
129
|
+
assertThat(notifications).isZero()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -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
|
+
}
|