@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.
- package/README.md +27 -12
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
- package/build/BluetoothSdk.types.d.ts +100 -22
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +2 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +3 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +3 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +31 -4
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +26 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +7 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +20 -0
- package/build/_private/sdkOtaManifest.d.ts.map +1 -0
- package/build/_private/sdkOtaManifest.js +28 -0
- package/build/_private/sdkOtaManifest.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +113 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +87 -21
- package/ios/Source/DeviceManager.swift +118 -20
- package/ios/Source/DeviceStore.swift +4 -0
- package/ios/Source/MentraBluetoothSDK.swift +326 -33
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +26 -6
- package/ios/Source/internal/BleTraceLogger.swift +1 -1
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
- package/ios/Source/sgcs/Ar99.swift +2451 -0
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +478 -61
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +68 -2
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +13 -0
- package/ios/Source/utils/BleJsonCompact.swift +8 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +106 -24
- package/src/_internal.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +83 -18
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +9 -2
- package/src/_private/photoRequestPayload.ts +13 -4
- package/src/_private/sdkOtaManifest.ts +31 -0
- 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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
}
|