@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.1

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 (114) hide show
  1. package/README.md +27 -12
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/AndroidManifest.xml +3 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
  9. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
  13. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
  14. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  31. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  32. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
  33. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  34. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  37. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  46. package/build/BluetoothSdk.types.d.ts +100 -22
  47. package/build/BluetoothSdk.types.d.ts.map +1 -1
  48. package/build/BluetoothSdk.types.js +2 -1
  49. package/build/BluetoothSdk.types.js.map +1 -1
  50. package/build/_internal.d.ts +3 -0
  51. package/build/_internal.d.ts.map +1 -1
  52. package/build/_internal.js +3 -0
  53. package/build/_internal.js.map +1 -1
  54. package/build/_private/BluetoothSdkModule.d.ts +31 -4
  55. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  56. package/build/_private/BluetoothSdkModule.js +26 -3
  57. package/build/_private/BluetoothSdkModule.js.map +1 -1
  58. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  59. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  60. package/build/_private/MentraLocalNetworkModule.js +3 -0
  61. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  62. package/build/_private/cameraRequestPayload.d.ts +1 -1
  63. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  64. package/build/_private/cameraRequestPayload.js +7 -0
  65. package/build/_private/cameraRequestPayload.js.map +1 -1
  66. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  67. package/build/_private/photoRequestPayload.js +10 -0
  68. package/build/_private/photoRequestPayload.js.map +1 -1
  69. package/build/_private/sdkOtaManifest.d.ts +20 -0
  70. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  71. package/build/_private/sdkOtaManifest.js +28 -0
  72. package/build/_private/sdkOtaManifest.js.map +1 -0
  73. package/build/index.d.ts +1 -1
  74. package/build/index.d.ts.map +1 -1
  75. package/build/index.js +11 -1
  76. package/build/index.js.map +1 -1
  77. package/expo-module.config.json +1 -0
  78. package/ios/BluetoothSdkModule.swift +113 -2
  79. package/ios/Source/BluetoothSdkDefaults.swift +2 -1
  80. package/ios/Source/Bridge.swift +87 -21
  81. package/ios/Source/DeviceManager.swift +118 -20
  82. package/ios/Source/DeviceStore.swift +4 -0
  83. package/ios/Source/MentraBluetoothSDK.swift +326 -33
  84. package/ios/Source/OtaManifest.swift +47 -4
  85. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  86. package/ios/Source/camera/CameraModels.swift +26 -6
  87. package/ios/Source/internal/BleTraceLogger.swift +1 -1
  88. package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
  89. package/ios/Source/sgcs/Ar99.swift +2451 -0
  90. package/ios/Source/sgcs/G1.swift +1 -1
  91. package/ios/Source/sgcs/G2.swift +19 -7
  92. package/ios/Source/sgcs/Mach1.swift +1 -1
  93. package/ios/Source/sgcs/MentraLive.swift +478 -61
  94. package/ios/Source/sgcs/MentraNex.swift +1 -1
  95. package/ios/Source/sgcs/Nimo.swift +1 -1
  96. package/ios/Source/sgcs/SGCManager.swift +13 -2
  97. package/ios/Source/sgcs/Simulated.swift +1 -1
  98. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  99. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  100. package/ios/Source/streaming/StreamModels.swift +68 -2
  101. package/ios/Source/tts/TTSTools.swift +1 -1
  102. package/ios/Source/types/DeviceModels.swift +13 -0
  103. package/ios/Source/utils/BleJsonCompact.swift +8 -0
  104. package/ios/Source/utils/Constants.swift +2 -0
  105. package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
  106. package/package.json +1 -2
  107. package/src/BluetoothSdk.types.ts +106 -24
  108. package/src/_internal.ts +3 -0
  109. package/src/_private/BluetoothSdkModule.ts +83 -18
  110. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  111. package/src/_private/cameraRequestPayload.ts +9 -2
  112. package/src/_private/photoRequestPayload.ts +13 -4
  113. package/src/_private/sdkOtaManifest.ts +31 -0
  114. package/src/index.ts +13 -1
@@ -0,0 +1,333 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.net.ConnectivityManager
4
+ import android.net.Network
5
+ import android.net.NetworkCapabilities
6
+ import android.net.NetworkRequest
7
+ import android.net.wifi.WifiNetworkSpecifier
8
+ import android.os.Build
9
+ import android.util.Base64
10
+ import android.util.Log
11
+ import expo.modules.kotlin.Promise
12
+ import expo.modules.kotlin.modules.Module
13
+ import expo.modules.kotlin.modules.ModuleDefinition
14
+ import java.io.File
15
+ import java.io.FileOutputStream
16
+ import java.net.HttpURLConnection
17
+ import java.net.URL
18
+ import java.util.concurrent.ConcurrentHashMap
19
+ import java.util.concurrent.Executors
20
+
21
+ /** MentraOS-internal transport for glasses-local traffic without process network binding. */
22
+ class MentraLocalNetworkModule : Module() {
23
+ private companion object {
24
+ const val TAG = "MentraLocalNetwork"
25
+ }
26
+
27
+ private val lock = Any()
28
+ private val executor = Executors.newCachedThreadPool()
29
+ private val activeConnections = ConcurrentHashMap<String, HttpURLConnection>()
30
+ private var localNetwork: Network? = null
31
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
32
+ private var pendingConnectPromise: Promise? = null
33
+
34
+ override fun definition() = ModuleDefinition {
35
+ Name("MentraLocalNetwork")
36
+ Events("downloadProgress", "networkLost")
37
+
38
+ AsyncFunction("connect") { ssid: String, password: String, promise: Promise ->
39
+ connect(ssid, password, promise)
40
+ }
41
+
42
+ AsyncFunction("request") {
43
+ requestId: String,
44
+ url: String,
45
+ method: String,
46
+ headers: Map<String, String>,
47
+ body: String?,
48
+ timeoutMs: Int,
49
+ promise: Promise ->
50
+ executor.execute { performRequest(requestId, url, method, headers, body, timeoutMs, promise) }
51
+ }
52
+
53
+ AsyncFunction("download") {
54
+ requestId: String,
55
+ url: String,
56
+ destination: String,
57
+ headers: Map<String, String>,
58
+ connectionTimeoutMs: Int,
59
+ readTimeoutMs: Int,
60
+ promise: Promise ->
61
+ executor.execute {
62
+ performDownload(
63
+ requestId,
64
+ url,
65
+ destination,
66
+ headers,
67
+ connectionTimeoutMs,
68
+ readTimeoutMs,
69
+ promise,
70
+ )
71
+ }
72
+ }
73
+
74
+ AsyncFunction("cancel") { requestId: String -> cancel(requestId) }
75
+ AsyncFunction("disconnect") { disconnect() }
76
+
77
+ OnDestroy { disconnect() }
78
+ }
79
+
80
+ private fun connectivityManager(): ConnectivityManager =
81
+ requireNotNull(appContext.reactContext?.getSystemService(ConnectivityManager::class.java)) {
82
+ "ConnectivityManager is unavailable"
83
+ }
84
+
85
+ private fun connect(ssid: String, password: String, promise: Promise) {
86
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
87
+ promise.reject("ERR_UNSUPPORTED_ANDROID", "Scoped local WiFi requires Android 10 or newer", null)
88
+ return
89
+ }
90
+
91
+ disconnect()
92
+ val manager = connectivityManager()
93
+ val specifier =
94
+ WifiNetworkSpecifier.Builder()
95
+ .setSsid(ssid)
96
+ .setWpa2Passphrase(password)
97
+ .build()
98
+ val request =
99
+ NetworkRequest.Builder()
100
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
101
+ .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
102
+ .setNetworkSpecifier(specifier)
103
+ .build()
104
+
105
+ val callback =
106
+ object : ConnectivityManager.NetworkCallback() {
107
+ override fun onAvailable(network: Network) {
108
+ val connectPromise: Promise?
109
+ synchronized(lock) {
110
+ if (networkCallback !== this) {
111
+ Log.d(TAG, "Ignoring onAvailable from a stale network request")
112
+ return
113
+ }
114
+ localNetwork = network
115
+ connectPromise = pendingConnectPromise
116
+ pendingConnectPromise = null
117
+ }
118
+ Log.i(
119
+ TAG,
120
+ "Captured glasses WiFi network=$network processBoundNetwork=${manager.boundNetworkForProcess}",
121
+ )
122
+ connectPromise?.resolve(mapOf("connected" to true, "ssid" to ssid))
123
+ }
124
+
125
+ override fun onUnavailable() {
126
+ synchronized(lock) {
127
+ if (networkCallback !== this) {
128
+ Log.d(TAG, "Ignoring onUnavailable from a stale network request")
129
+ return
130
+ }
131
+ }
132
+ rejectPendingConnect("ERR_LOCAL_WIFI_UNAVAILABLE", "Could not connect to $ssid")
133
+ clearNetworkCallback(this)
134
+ }
135
+
136
+ override fun onLost(network: Network) {
137
+ synchronized(lock) {
138
+ if (networkCallback !== this || localNetwork != network) {
139
+ Log.d(TAG, "Ignoring onLost from a stale network request")
140
+ return
141
+ }
142
+ localNetwork = null
143
+ }
144
+ cancelAll()
145
+ clearNetworkCallback(this)
146
+ sendEvent("networkLost", mapOf("ssid" to ssid))
147
+ }
148
+ }
149
+
150
+ synchronized(lock) {
151
+ pendingConnectPromise = promise
152
+ networkCallback = callback
153
+ }
154
+ try {
155
+ manager.requestNetwork(request, callback, 30_000)
156
+ } catch (error: Exception) {
157
+ clearNetworkCallback(callback)
158
+ rejectPendingConnect("ERR_LOCAL_WIFI_REQUEST", error.message ?: "Local WiFi request failed", error)
159
+ }
160
+ }
161
+
162
+ private fun performRequest(
163
+ requestId: String,
164
+ url: String,
165
+ method: String,
166
+ headers: Map<String, String>,
167
+ body: String?,
168
+ timeoutMs: Int,
169
+ promise: Promise,
170
+ ) {
171
+ var connection: HttpURLConnection? = null
172
+ try {
173
+ connection = openConnection(requestId, url)
174
+ connection.requestMethod = method
175
+ connection.connectTimeout = timeoutMs
176
+ connection.readTimeout = timeoutMs
177
+ headers.forEach(connection::setRequestProperty)
178
+ if (body != null) {
179
+ connection.doOutput = true
180
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
181
+ }
182
+ val status = connection.responseCode
183
+ val stream = if (status >= 400) connection.errorStream else connection.inputStream
184
+ val bytes = stream?.use { it.readBytes() } ?: ByteArray(0)
185
+ promise.resolve(
186
+ mapOf(
187
+ "status" to status,
188
+ "headers" to responseHeaders(connection),
189
+ "bodyBase64" to Base64.encodeToString(bytes, Base64.NO_WRAP),
190
+ ),
191
+ )
192
+ } catch (error: Exception) {
193
+ promise.reject("ERR_LOCAL_HTTP", error.message ?: "Local HTTP request failed", error)
194
+ } finally {
195
+ activeConnections.remove(requestId)
196
+ connection?.disconnect()
197
+ }
198
+ }
199
+
200
+ private fun performDownload(
201
+ requestId: String,
202
+ url: String,
203
+ destination: String,
204
+ headers: Map<String, String>,
205
+ connectionTimeoutMs: Int,
206
+ readTimeoutMs: Int,
207
+ promise: Promise,
208
+ ) {
209
+ var connection: HttpURLConnection? = null
210
+ try {
211
+ connection = openConnection(requestId, url)
212
+ connection.requestMethod = "GET"
213
+ connection.connectTimeout = connectionTimeoutMs
214
+ connection.readTimeout = readTimeoutMs
215
+ headers.forEach(connection::setRequestProperty)
216
+ val status = connection.responseCode
217
+ if (status !in 200..299) throw IllegalStateException("HTTP $status")
218
+ val contentLength = connection.contentLengthLong
219
+ val responseHeaders = responseHeaders(connection)
220
+
221
+ // Match RNFS's begin callback contract before the first response byte. Gallery
222
+ // resumable downloads need the real status and Content-Range to validate segments.
223
+ sendEvent(
224
+ "downloadProgress",
225
+ mapOf(
226
+ "requestId" to requestId,
227
+ "bytesWritten" to 0L,
228
+ "contentLength" to contentLength,
229
+ "statusCode" to status,
230
+ "headers" to responseHeaders,
231
+ ),
232
+ )
233
+
234
+ val destinationFile = File(destination)
235
+ destinationFile.parentFile?.mkdirs()
236
+ var bytesWritten = 0L
237
+ connection.inputStream.use { input ->
238
+ FileOutputStream(destinationFile, false).use { output ->
239
+ val buffer = ByteArray(64 * 1024)
240
+ while (true) {
241
+ val count = input.read(buffer)
242
+ if (count < 0) break
243
+ output.write(buffer, 0, count)
244
+ bytesWritten += count
245
+ sendEvent(
246
+ "downloadProgress",
247
+ mapOf(
248
+ "requestId" to requestId,
249
+ "bytesWritten" to bytesWritten,
250
+ "contentLength" to contentLength,
251
+ ),
252
+ )
253
+ }
254
+ }
255
+ }
256
+ promise.resolve(
257
+ mapOf(
258
+ "statusCode" to status,
259
+ "bytesWritten" to bytesWritten,
260
+ "headers" to responseHeaders,
261
+ ),
262
+ )
263
+ } catch (error: Exception) {
264
+ promise.reject("ERR_LOCAL_DOWNLOAD", error.message ?: "Local download failed", error)
265
+ } finally {
266
+ activeConnections.remove(requestId)
267
+ connection?.disconnect()
268
+ }
269
+ }
270
+
271
+ private fun openConnection(requestId: String, url: String): HttpURLConnection {
272
+ val network = synchronized(lock) { localNetwork }
273
+ ?: throw IllegalStateException("Local WiFi is not connected")
274
+ val connection = network.openConnection(URL(url)) as HttpURLConnection
275
+ activeConnections[requestId] = connection
276
+ return connection
277
+ }
278
+
279
+ private fun responseHeaders(connection: HttpURLConnection): Map<String, String> =
280
+ connection.headerFields
281
+ .filterKeys { it != null }
282
+ .mapValues { (_, values) -> values.joinToString(", ") }
283
+
284
+ private fun cancel(requestId: String) {
285
+ activeConnections.remove(requestId)?.disconnect()
286
+ }
287
+
288
+ private fun cancelAll() {
289
+ activeConnections.keys.toList().forEach(::cancel)
290
+ }
291
+
292
+ private fun disconnect() {
293
+ cancelAll()
294
+ val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
295
+ val callback: ConnectivityManager.NetworkCallback?
296
+ synchronized(lock) {
297
+ callback = networkCallback
298
+ networkCallback = null
299
+ localNetwork = null
300
+ }
301
+ if (callback != null && manager != null) {
302
+ try {
303
+ manager.unregisterNetworkCallback(callback)
304
+ } catch (_: IllegalArgumentException) {
305
+ // Already unregistered by the platform.
306
+ }
307
+ }
308
+ rejectPendingConnect("ERR_LOCAL_WIFI_CANCELLED", "Local WiFi connection cancelled")
309
+ }
310
+
311
+ private fun clearNetworkCallback(callback: ConnectivityManager.NetworkCallback) {
312
+ val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
313
+ synchronized(lock) {
314
+ if (networkCallback == callback) networkCallback = null
315
+ }
316
+ if (manager != null) {
317
+ try {
318
+ manager.unregisterNetworkCallback(callback)
319
+ } catch (_: IllegalArgumentException) {
320
+ // Callback was not registered or has already been removed.
321
+ }
322
+ }
323
+ }
324
+
325
+ private fun rejectPendingConnect(code: String, message: String, cause: Throwable? = null) {
326
+ val promise: Promise?
327
+ synchronized(lock) {
328
+ promise = pendingConnectPromise
329
+ pendingConnectPromise = null
330
+ }
331
+ promise?.reject(code, message, cause)
332
+ }
333
+ }
@@ -70,8 +70,13 @@ internal object OtaManifestChecker {
70
70
  currentMtkVersion: String,
71
71
  currentBesVersion: String,
72
72
  manifest: JSONObject,
73
+ // Downgrade floor: a non-positive value (the default) disables downgrades entirely,
74
+ // matching ASG's fail-closed DowngradeGate and the engine. OEM SDK consumers that do
75
+ // not set a floor therefore get upgrade-only behavior and are never told a downgrade is
76
+ // available that the glasses would refuse.
77
+ downgradeFloorVersionCode: Long = 0L,
73
78
  ): Boolean =
74
- hasApkUpdate(currentBuildNumber, manifest) ||
79
+ hasApkUpdate(currentBuildNumber, manifest, downgradeFloorVersionCode) ||
75
80
  hasMtkUpdate(manifest.optJSONArray("mtk_patches"), currentMtkVersion) ||
76
81
  hasBesUpdate(manifest.optJSONObject("bes_firmware"), currentBesVersion)
77
82
 
@@ -95,15 +100,50 @@ internal object OtaManifestChecker {
95
100
  throw BluetoothSdkException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
96
101
  }
97
102
 
98
- private fun hasApkUpdate(currentBuildNumber: String, manifest: JSONObject): Boolean {
103
+ private fun hasApkUpdate(
104
+ currentBuildNumber: String,
105
+ manifest: JSONObject,
106
+ downgradeFloorVersionCode: Long,
107
+ ): Boolean {
99
108
  val currentVersion =
100
109
  currentBuildNumber.toLongOrNull()
101
110
  ?: throw BluetoothSdkException(
102
111
  "invalid_glasses_version",
103
112
  "Cannot check OTA update because glasses build number is invalid.",
104
113
  )
114
+ // Only apps-shaped manifests are exact pins. A legacy top-level manifest (versionCode at
115
+ // the root, no apps entry) is NOT a pin and stays strictly upgrade-only, matching the
116
+ // engine TS checker — otherwise a build newer than such a manifest would report a false
117
+ // downgrade the JS path ignores.
118
+ val isExactPin = manifest.optJSONObject("apps")?.optJSONObject(ASG_CLIENT_PACKAGE)?.hasNumber("versionCode") == true
105
119
  val serverVersion = latestAppInfo(manifest).requiredLong("versionCode")
106
- return serverVersion > currentVersion
120
+ if (!isExactPin) {
121
+ return serverVersion > currentVersion
122
+ }
123
+ // Exact pin: any mismatch is an update in either direction (downgrades take the
124
+ // uninstall-then-reinstall detour on the glasses). A non-positive pin is only legitimate
125
+ // in the frozen legacy rescue manifests that pre-39 glasses are checked against; for
126
+ // modern glasses it means the manifest cannot verify anything, and that must surface as an
127
+ // error — never as "no update".
128
+ if (serverVersion <= 0) {
129
+ if (currentVersion >= 39) {
130
+ throw BluetoothSdkException(
131
+ "invalid_ota_manifest",
132
+ "OTA manifest ASG pin is missing or zero; cannot verify update state.",
133
+ )
134
+ }
135
+ return false
136
+ }
137
+ if (serverVersion > currentVersion) {
138
+ return true
139
+ }
140
+ // Exact match: already on the pin, no update.
141
+ if (serverVersion == currentVersion) {
142
+ return false
143
+ }
144
+ // Downgrade: only actionable at/above the enabled floor; a non-positive floor disables
145
+ // downgrades (fail closed, matching ASG and the engine).
146
+ return downgradeFloorVersionCode > 0 && serverVersion >= downgradeFloorVersionCode
107
147
  }
108
148
 
109
149
  private fun hasMtkUpdate(patches: JSONArray?, currentVersion: String): Boolean {
@@ -110,14 +110,15 @@ class CameraFov @JvmOverloads constructor(
110
110
  companion object {
111
111
  const val MIN_FOV = 62
112
112
  const val MAX_FOV = 118
113
- const val DEFAULT_FOV = 102
113
+ const val DEFAULT_FOV = 118
114
114
  const val NARROW_FOV = 82
115
+ const val STANDARD_FOV = 102
115
116
  @JvmField
116
117
  val DEFAULT_ROI_POSITION = CameraRoiPosition.CENTER
117
118
  @JvmField
118
119
  val NARROW = CameraFov(NARROW_FOV, DEFAULT_ROI_POSITION)
119
120
  @JvmField
120
- val STANDARD = CameraFov(DEFAULT_FOV, DEFAULT_ROI_POSITION)
121
+ val STANDARD = CameraFov(STANDARD_FOV, DEFAULT_ROI_POSITION)
121
122
  @JvmField
122
123
  val WIDE = CameraFov(MAX_FOV, DEFAULT_ROI_POSITION)
123
124
  }
@@ -200,8 +201,22 @@ data class PhotoRequest @JvmOverloads constructor(
200
201
  val ispAnalogGain: String? = null,
201
202
  val resetCaptureTuning: Boolean? = null,
202
203
  val mode: PhotoMode = PhotoMode.PHOTO,
204
+ /** `direct` disables BLE fallback; `ble` skips direct upload; `auto` tries both. */
205
+ val transferMethod: String = "auto",
203
206
  ) {
204
207
  companion object {
208
+ private fun transferMethodFromValue(value: Any?): String {
209
+ if (value == null) return "auto"
210
+ val raw = value as? String
211
+ ?: throw IllegalArgumentException(
212
+ "Invalid transferMethod ${value::class.java.simpleName}. Expected auto, direct, or ble."
213
+ )
214
+ return raw.takeIf { it == "auto" || it == "direct" || it == "ble" }
215
+ ?: throw IllegalArgumentException(
216
+ "Invalid transferMethod \"$raw\". Expected auto, direct, or ble."
217
+ )
218
+ }
219
+
205
220
  /** Mirrors iOS `BluetoothSdkModule` defaults for keys omitted from the JS bridge. */
206
221
  @JvmStatic
207
222
  fun fromMap(values: Map<String, Any>): PhotoRequest {
@@ -238,6 +253,7 @@ data class PhotoRequest @JvmOverloads constructor(
238
253
  save = boolValue(values, "save", "saveToGallery") ?: false,
239
254
  sound = boolValue(values, "sound") ?: true,
240
255
  mode = PhotoMode.fromValue(stringValue(values, "mode")),
256
+ transferMethod = transferMethodFromValue(values["transferMethod"]),
241
257
  exposureTimeNs = exposureTimeNs,
242
258
  iso = iso,
243
259
  aeExposureDivisor = aeDivisor,
@@ -12,7 +12,7 @@ object BleTraceLogger {
12
12
  private const val MAX_PAYLOAD_CHARS = 3000
13
13
  private const val K900_TYPE = "k900"
14
14
  private val sensitiveKeyParts =
15
- listOf("password", "pass", "token", "secret", "authorization", "auth", "email")
15
+ listOf("password", "pass", "token", "secret", "authorization", "auth", "email", "serial")
16
16
 
17
17
  @JvmStatic
18
18
  fun logJson(direction: String, layer: String, payload: JSONObject?, bytes: Int? = null) {
@@ -6,6 +6,7 @@ import android.app.Service
6
6
  import android.content.Intent
7
7
  import android.content.pm.PackageManager
8
8
  import android.content.pm.ServiceInfo
9
+ import android.location.LocationManager
9
10
  import android.os.Build
10
11
  import android.os.IBinder
11
12
  import androidx.core.app.NotificationCompat
@@ -17,8 +18,12 @@ class ForegroundService : Service() {
17
18
  companion object {
18
19
  const val CHANNEL_ID = "MentraServiceChannel"
19
20
  const val NOTIFICATION_ID = 1001
21
+ const val ACTION_REFRESH_TYPES =
22
+ "com.mentra.bluetoothsdk.services.action.REFRESH_FOREGROUND_SERVICE_TYPES"
20
23
  }
21
24
 
25
+ private var locationTypeRequested = false
26
+
22
27
  override fun onCreate() {
23
28
  super.onCreate()
24
29
  Bridge.log("ForegroundService: onCreate() called")
@@ -34,6 +39,11 @@ class ForegroundService : Service() {
34
39
  "service_start_command",
35
40
  mapOf("action" to intent?.action, "flags" to flags, "startId" to startId)
36
41
  )
42
+ if (intent?.action == ACTION_REFRESH_TYPES) {
43
+ // This action is only sent while the host Activity is foregrounded. Android 14+
44
+ // rejects adding a while-in-use location type from the background.
45
+ locationTypeRequested = true
46
+ }
37
47
  // Re-check permissions in case they changed
38
48
  startForegroundWithAutoDetectedType()
39
49
  return START_STICKY
@@ -71,6 +81,14 @@ class ForegroundService : Service() {
71
81
 
72
82
  var serviceType = 0
73
83
 
84
+ // Audio prompts can be initiated by glasses while the host Activity is
85
+ // backgrounded. mediaPlayback has no runtime prerequisite, so keep it
86
+ // active on the existing Mentra service for the entire connected
87
+ // session rather than trying to launch a second service after a wake
88
+ // phrase arrives.
89
+ serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
90
+ Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
91
+
74
92
  // Check Bluetooth permissions
75
93
  val hasBluetoothPermission =
76
94
  when {
@@ -124,6 +142,28 @@ class ForegroundService : Service() {
124
142
  Bridge.log("ForegroundService: No microphone permission")
125
143
  }
126
144
 
145
+ val hasLocationPermission =
146
+ ContextCompat.checkSelfPermission(
147
+ this,
148
+ android.Manifest.permission.ACCESS_FINE_LOCATION
149
+ ) == PackageManager.PERMISSION_GRANTED ||
150
+ ContextCompat.checkSelfPermission(
151
+ this,
152
+ android.Manifest.permission.ACCESS_COARSE_LOCATION
153
+ ) == PackageManager.PERMISSION_GRANTED
154
+ val locationManager = getSystemService(LocationManager::class.java)
155
+ val isLocationEnabled = locationManager?.isLocationEnabled == true
156
+
157
+ if (locationTypeRequested && hasLocationPermission && isLocationEnabled) {
158
+ serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
159
+ Bridge.log("ForegroundService: Added location (has foreground location permission)")
160
+ } else {
161
+ Bridge.log(
162
+ "ForegroundService: No location type " +
163
+ "(requested=$locationTypeRequested, permission=$hasLocationPermission, enabled=$isLocationEnabled)"
164
+ )
165
+ }
166
+
127
167
  // Only use dataSync as absolute last resort fallback if no other types were added.
128
168
  // WARNING: dataSync has a 6-hour timeout on Android 14+ which will crash the app.
129
169
  // This should rarely happen since CHANGE_NETWORK_STATE is a normal permission.
@@ -141,11 +181,20 @@ class ForegroundService : Service() {
141
181
  val hasConnectedDevice =
142
182
  (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) != 0
143
183
  val hasMicrophone = (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) != 0
184
+ val hasLocation = (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) != 0
185
+ val hasMediaPlayback =
186
+ (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) != 0
144
187
 
145
188
  return when {
189
+ hasConnectedDevice && hasMicrophone && hasLocation ->
190
+ "Glasses, microphone & location active"
191
+ hasConnectedDevice && hasLocation -> "Smart glasses & location active"
192
+ hasMicrophone && hasLocation -> "Microphone & location active"
193
+ hasLocation -> "Location active"
146
194
  hasConnectedDevice && hasMicrophone -> "Glasses & microphone active"
147
195
  hasConnectedDevice -> "Smart glasses connected"
148
196
  hasMicrophone -> "Microphone active"
197
+ hasMediaPlayback -> "Audio playback active"
149
198
  else -> "Syncing data"
150
199
  }
151
200
  }
@@ -160,6 +209,10 @@ class ForegroundService : Service() {
160
209
  types.add("connectedDevice")
161
210
  if (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE != 0)
162
211
  types.add("microphone")
212
+ if (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0)
213
+ types.add("location")
214
+ if (serviceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK != 0)
215
+ types.add("mediaPlayback")
163
216
 
164
217
  return types.joinToString("|")
165
218
  }