@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-beta.100

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 (144) hide show
  1. package/README.md +67 -25
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  13. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  14. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  22. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  31. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  32. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  42. package/build/BluetoothSdk.types.d.ts +84 -2
  43. package/build/BluetoothSdk.types.d.ts.map +1 -1
  44. package/build/BluetoothSdk.types.js.map +1 -1
  45. package/build/_internal.d.ts +2 -0
  46. package/build/_internal.d.ts.map +1 -1
  47. package/build/_internal.js +2 -0
  48. package/build/_internal.js.map +1 -1
  49. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  50. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  51. package/build/_private/BluetoothSdkModule.js +1 -1
  52. package/build/_private/BluetoothSdkModule.js.map +1 -1
  53. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  54. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  55. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  56. package/build/_private/sdkOtaManifest.d.ts +3 -12
  57. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  58. package/build/_private/sdkOtaManifest.js +5 -17
  59. package/build/_private/sdkOtaManifest.js.map +1 -1
  60. package/build/changelogs.d.ts +7 -0
  61. package/build/changelogs.d.ts.map +1 -0
  62. package/build/changelogs.js +44 -0
  63. package/build/changelogs.js.map +1 -0
  64. package/build/generated/changelogCatalog.d.ts +6 -0
  65. package/build/generated/changelogCatalog.d.ts.map +1 -0
  66. package/build/generated/changelogCatalog.js +8 -0
  67. package/build/generated/changelogCatalog.js.map +1 -0
  68. package/build/generated/releaseMetadata.d.ts +12 -0
  69. package/build/generated/releaseMetadata.d.ts.map +1 -0
  70. package/build/generated/releaseMetadata.js +10 -0
  71. package/build/generated/releaseMetadata.js.map +1 -0
  72. package/build/index.d.ts +1 -1
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +24 -2
  75. package/build/index.js.map +1 -1
  76. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  77. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  78. package/build/ota-server/MentraOtaServer.types.js +2 -0
  79. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  80. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  81. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  82. package/build/ota-server/MentraOtaServerModule.js +3 -0
  83. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  84. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  85. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  86. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  87. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  88. package/build/ota-server/index.d.ts +3 -0
  89. package/build/ota-server/index.d.ts.map +1 -0
  90. package/build/ota-server/index.js +3 -0
  91. package/build/ota-server/index.js.map +1 -0
  92. package/build/ota-transport/index.d.ts +67 -0
  93. package/build/ota-transport/index.d.ts.map +1 -0
  94. package/build/ota-transport/index.js +58 -0
  95. package/build/ota-transport/index.js.map +1 -0
  96. package/expo-module.config.json +3 -2
  97. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  98. package/ios/BluetoothSdkModule.swift +16 -2
  99. package/ios/LocalIPv4.swift +150 -0
  100. package/ios/LocalOtaServer.swift +309 -0
  101. package/ios/MentraBluetoothSDK.podspec +5 -1
  102. package/ios/MentraOtaServerModule.swift +147 -0
  103. package/ios/MentraPhotoReceiverModule.swift +1 -81
  104. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  105. package/ios/Source/Bridge.swift +43 -4
  106. package/ios/Source/DeviceManager.swift +154 -19
  107. package/ios/Source/DeviceStore.swift +2 -0
  108. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  109. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  110. package/ios/Source/MentraBluetoothSDK.swift +91 -6
  111. package/ios/Source/OtaManifest.swift +5 -7
  112. package/ios/Source/ReleaseChangelog.swift +65 -0
  113. package/ios/Source/audio/AudioModels.swift +17 -0
  114. package/ios/Source/camera/CameraModels.swift +1 -0
  115. package/ios/Source/events/BluetoothEvents.swift +27 -0
  116. package/ios/Source/sgcs/MentraLive.swift +512 -78
  117. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  118. package/ios/Source/sgcs/SGCManager.swift +11 -0
  119. package/ios/Source/status/DeviceStatus.swift +23 -0
  120. package/ios/Source/types/DeviceModels.swift +23 -2
  121. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  122. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  123. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  124. package/package.json +10 -1
  125. package/plugin/build/withIos.d.ts +3 -1
  126. package/plugin/build/withIos.js +22 -10
  127. package/scripts/public-ota-api.test.mjs +37 -0
  128. package/scripts/verify-release-package.mjs +34 -0
  129. package/scripts/write-release-metadata.mjs +154 -0
  130. package/scripts/write-release-metadata.test.mjs +29 -0
  131. package/src/BluetoothSdk.types.ts +97 -3
  132. package/src/_internal.ts +2 -0
  133. package/src/_private/BluetoothSdkModule.ts +5 -5
  134. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  135. package/src/_private/sdkOtaManifest.ts +5 -17
  136. package/src/changelogs.ts +44 -0
  137. package/src/generated/changelogCatalog.ts +9 -0
  138. package/src/generated/releaseMetadata.ts +20 -0
  139. package/src/index.ts +43 -2
  140. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  141. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  142. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  143. package/src/ota-server/index.ts +2 -0
  144. package/src/ota-transport/index.ts +116 -0
@@ -0,0 +1,304 @@
1
+ package com.mentra.bluetoothsdk.otaserver
2
+
3
+ import com.mentra.bluetoothsdk.debug.BleTraceLogger
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.Job
7
+ import kotlinx.coroutines.SupervisorJob
8
+ import kotlinx.coroutines.cancel
9
+ import kotlinx.coroutines.isActive
10
+ import kotlinx.coroutines.launch
11
+ import java.io.BufferedInputStream
12
+ import java.io.ByteArrayOutputStream
13
+ import java.io.EOFException
14
+ import java.io.File
15
+ import java.io.InputStream
16
+ import java.io.OutputStream
17
+ import java.net.InetSocketAddress
18
+ import java.net.ServerSocket
19
+ import java.net.Socket
20
+ import java.net.SocketException
21
+ import java.nio.charset.StandardCharsets
22
+ import java.util.Locale
23
+ import java.util.concurrent.Semaphore
24
+ import org.json.JSONObject
25
+
26
+ /**
27
+ * Static HTTP server for hotspot-served glasses OTA (OS-1676).
28
+ *
29
+ * Serves the rewritten OTA manifest at /version.json and pre-downloaded artifacts at
30
+ * /artifacts/<sha256> to the glasses over the glasses-hotspot link. Same hand-rolled
31
+ * socket structure as [LocalPhotoUploadServer] —
32
+ * no third-party HTTP dependency in the SDK.
33
+ */
34
+ class LocalOtaServer(
35
+ private val onLog: (String) -> Unit,
36
+ ) : AutoCloseable {
37
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
38
+ private var serverSocket: ServerSocket? = null
39
+ private var serverJob: Job? = null
40
+ private val clientSlots = Semaphore(MAX_ACTIVE_CLIENTS)
41
+
42
+ @Volatile
43
+ private var manifestJson: String = "{}"
44
+
45
+ @Volatile
46
+ private var artifacts: Map<String, File> = emptyMap()
47
+
48
+ @Volatile
49
+ var running: Boolean = false
50
+ private set
51
+
52
+ val activePort: Int?
53
+ get() = serverSocket
54
+ ?.takeIf { running && !it.isClosed }
55
+ ?.localPort
56
+
57
+ /** Swap the served manifest and artifact set. Safe while the server is running. */
58
+ fun configure(manifestJson: String, artifacts: Map<String, File>) {
59
+ this.manifestJson = manifestJson
60
+ this.artifacts = artifacts
61
+ }
62
+
63
+ fun start(host: String, port: Int): Int {
64
+ val activeSocket = serverSocket
65
+ if (running && activeSocket != null && !activeSocket.isClosed && activeSocket.localPort == port) {
66
+ onLog("Already listening on $host:${activeSocket.localPort}")
67
+ return activeSocket.localPort
68
+ }
69
+ stop()
70
+ val socket = ServerSocket()
71
+ try {
72
+ socket.reuseAddress = true
73
+ socket.bind(InetSocketAddress(host, port))
74
+ serverSocket = socket
75
+ running = true
76
+ serverJob = scope.launch {
77
+ acceptLoop(socket)
78
+ }
79
+ } catch (error: Throwable) {
80
+ try {
81
+ socket.close()
82
+ } catch (_: Throwable) {
83
+ }
84
+ throw error
85
+ }
86
+ onLog("OTA server listening on $host:${socket.localPort}")
87
+ return socket.localPort
88
+ }
89
+
90
+ fun stop() {
91
+ running = false
92
+ serverJob?.cancel()
93
+ serverJob = null
94
+ try {
95
+ serverSocket?.close()
96
+ } catch (_: Throwable) {
97
+ }
98
+ serverSocket = null
99
+ }
100
+
101
+ override fun close() {
102
+ stop()
103
+ scope.cancel()
104
+ }
105
+
106
+ private fun acceptLoop(socket: ServerSocket) {
107
+ while (scope.isActive && !socket.isClosed) {
108
+ try {
109
+ val client = socket.accept()
110
+ if (!clientSlots.tryAcquire()) {
111
+ rejectClient(client)
112
+ continue
113
+ }
114
+ scope.launch {
115
+ try {
116
+ handleClient(client)
117
+ } finally {
118
+ clientSlots.release()
119
+ }
120
+ }
121
+ } catch (error: SocketException) {
122
+ if (running) onLog("Accept failed: ${error.message}")
123
+ } catch (error: Throwable) {
124
+ if (running) onLog("Accept failed: ${error.message ?: error::class.java.simpleName}")
125
+ }
126
+ }
127
+ }
128
+
129
+ private fun handleClient(socket: Socket) {
130
+ socket.use { client ->
131
+ val requestStartMs = System.currentTimeMillis()
132
+ client.soTimeout = SOCKET_TIMEOUT_MS
133
+ val input = BufferedInputStream(client.getInputStream())
134
+ val output = client.getOutputStream()
135
+ var request: HttpRequest? = null
136
+ var status = 0
137
+ var responseBytes = 0L
138
+
139
+ try {
140
+ val headerText = readHeaders(input).toString(StandardCharsets.ISO_8859_1)
141
+ request = HttpRequest.parse(headerText)
142
+ onLog("${request.method} ${request.path} from ${client.inetAddress.hostAddress}")
143
+
144
+ if (request.method != "GET") {
145
+ status = 405
146
+ writeJson(output, status, """{"ok":false,"error":"method_not_allowed"}""")
147
+ return
148
+ }
149
+
150
+ when {
151
+ request.path == "/version.json" -> {
152
+ val body = manifestJson.toByteArray(StandardCharsets.UTF_8)
153
+ status = 200
154
+ responseBytes = body.size.toLong()
155
+ writeBody(output, status, "application/json", body)
156
+ }
157
+ request.path.startsWith("/artifacts/") -> {
158
+ val key = request.path.removePrefix("/artifacts/")
159
+ val file = artifacts[key]
160
+ if (file == null || !file.isFile) {
161
+ status = 404
162
+ writeJson(output, status, """{"ok":false,"error":"artifact_not_found"}""")
163
+ } else {
164
+ status = 200
165
+ responseBytes = file.length()
166
+ writeFile(output, file)
167
+ }
168
+ }
169
+ else -> {
170
+ status = 404
171
+ writeJson(output, status, """{"ok":false,"error":"not_found"}""")
172
+ }
173
+ }
174
+ } catch (error: Throwable) {
175
+ onLog("request failed: ${error.message ?: error::class.java.simpleName}")
176
+ if (status == 0) {
177
+ status = 500
178
+ try {
179
+ writeJson(output, status, """{"ok":false,"error":"server_error"}""")
180
+ } catch (_: Throwable) {
181
+ }
182
+ }
183
+ } finally {
184
+ trace(client, request, status, responseBytes, requestStartMs)
185
+ }
186
+ }
187
+ }
188
+
189
+ private fun writeFile(output: OutputStream, file: File) {
190
+ val fileLength = file.length()
191
+ output.write("HTTP/1.1 200 OK\r\n".toByteArray(StandardCharsets.US_ASCII))
192
+ output.write("Content-Type: application/octet-stream\r\n".toByteArray(StandardCharsets.US_ASCII))
193
+ output.write("Content-Length: $fileLength\r\n".toByteArray(StandardCharsets.US_ASCII))
194
+ output.write("Connection: close\r\n\r\n".toByteArray(StandardCharsets.US_ASCII))
195
+ file.inputStream().use { fileInput ->
196
+ val buffer = ByteArray(STREAM_CHUNK_BYTES)
197
+ var remaining = fileLength
198
+ while (remaining > 0) {
199
+ val read = fileInput.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt())
200
+ if (read <= 0) throw EOFException("artifact truncated while streaming")
201
+ output.write(buffer, 0, read)
202
+ remaining -= read
203
+ }
204
+ }
205
+ output.flush()
206
+ }
207
+
208
+ private fun rejectClient(socket: Socket) {
209
+ socket.use { client ->
210
+ try {
211
+ writeJson(client.getOutputStream(), 503, """{"ok":false,"error":"too_many_connections"}""")
212
+ } catch (_: Throwable) {
213
+ }
214
+ }
215
+ }
216
+
217
+ private fun readHeaders(input: InputStream): ByteArray {
218
+ val out = ByteArrayOutputStream()
219
+ val delimiter = byteArrayOf(13, 10, 13, 10)
220
+ var matched = 0
221
+ while (true) {
222
+ val byte = input.read()
223
+ if (byte == -1) throw EOFException("Socket closed before HTTP headers completed")
224
+ out.write(byte)
225
+ matched = if (byte.toByte() == delimiter[matched]) {
226
+ matched + 1
227
+ } else if (byte.toByte() == delimiter[0]) {
228
+ 1
229
+ } else {
230
+ 0
231
+ }
232
+ if (matched == delimiter.size) return out.toByteArray()
233
+ if (out.size() > MAX_HEADER_BYTES) throw IllegalArgumentException("HTTP headers too large")
234
+ }
235
+ }
236
+
237
+ private fun writeJson(output: OutputStream, status: Int, body: String) {
238
+ writeBody(output, status, "application/json", body.toByteArray(StandardCharsets.UTF_8))
239
+ }
240
+
241
+ private fun writeBody(
242
+ output: OutputStream,
243
+ status: Int,
244
+ contentType: String,
245
+ body: ByteArray,
246
+ ) {
247
+ val reason = when (status) {
248
+ 200 -> "OK"
249
+ 404 -> "Not Found"
250
+ 405 -> "Method Not Allowed"
251
+ 503 -> "Service Unavailable"
252
+ else -> "Internal Server Error"
253
+ }
254
+ output.write("HTTP/1.1 $status $reason\r\n".toByteArray(StandardCharsets.US_ASCII))
255
+ output.write("Content-Type: $contentType\r\n".toByteArray(StandardCharsets.US_ASCII))
256
+ output.write("Content-Length: ${body.size}\r\n".toByteArray(StandardCharsets.US_ASCII))
257
+ output.write("Connection: close\r\n\r\n".toByteArray(StandardCharsets.US_ASCII))
258
+ output.write(body)
259
+ output.flush()
260
+ }
261
+
262
+ private fun trace(
263
+ client: Socket,
264
+ request: HttpRequest?,
265
+ status: Int,
266
+ responseBytes: Long,
267
+ startMs: Long,
268
+ ) {
269
+ val payload = JSONObject()
270
+ try {
271
+ payload.put("type", "ota_server_request")
272
+ payload.put("remoteHost", client.inetAddress?.hostAddress ?: "")
273
+ payload.put("method", request?.method ?: "")
274
+ payload.put("path", request?.path ?: "")
275
+ payload.put("statusCode", status)
276
+ payload.put("responseBytes", responseBytes)
277
+ payload.put("durationMs", System.currentTimeMillis() - startMs)
278
+ BleTraceLogger.logJson("phone_to_wifi", "wifi_http_output", payload, null)
279
+ } catch (_: Throwable) {
280
+ }
281
+ }
282
+
283
+ private data class HttpRequest(
284
+ val method: String,
285
+ val path: String,
286
+ ) {
287
+ companion object {
288
+ fun parse(headerText: String): HttpRequest {
289
+ val lines = headerText.split("\r\n").filter { it.isNotBlank() }
290
+ val requestParts = lines.firstOrNull()?.split(" ").orEmpty()
291
+ val method = requestParts.getOrNull(0)?.uppercase(Locale.US).orEmpty()
292
+ val path = requestParts.getOrNull(1)?.substringBefore("?").orEmpty()
293
+ return HttpRequest(method, path)
294
+ }
295
+ }
296
+ }
297
+
298
+ companion object {
299
+ private const val MAX_HEADER_BYTES = 64 * 1024
300
+ private const val MAX_ACTIVE_CLIENTS = 2
301
+ private const val SOCKET_TIMEOUT_MS = 20_000
302
+ private const val STREAM_CHUNK_BYTES = 64 * 1024
303
+ }
304
+ }
@@ -0,0 +1,120 @@
1
+ package com.mentra.bluetoothsdk.otaserver
2
+
3
+ import android.util.Log
4
+ import com.mentra.bluetoothsdk.net.LocalIpv4
5
+ import expo.modules.kotlin.modules.Module
6
+ import expo.modules.kotlin.modules.ModuleDefinition
7
+ import java.io.File
8
+
9
+ /**
10
+ * Expo surface for the hotspot-served OTA file server (OS-1676).
11
+ *
12
+ * JS hands over the rewritten manifest body and a map of sha256 -> local file path, and gets
13
+ * back the base URL the glasses should be pointed at via ota_start. The optional host
14
+ * argument lets the caller pass the authoritative scoped-network address (from
15
+ * MentraLocalNetwork's LinkProperties); otherwise interface scanning is used.
16
+ */
17
+ class MentraOtaServerModule : Module() {
18
+ private var otaServer: LocalOtaServer? = null
19
+
20
+ override fun definition() = ModuleDefinition {
21
+ Name("MentraOtaServer")
22
+
23
+ AsyncFunction("startOtaServer") { manifestJson: String, artifactPaths: Map<String, String>, host: String? ->
24
+ startOtaServer(manifestJson, artifactPaths, host)
25
+ }
26
+
27
+ AsyncFunction("stopOtaServer") {
28
+ stopOtaServerInternal()
29
+ }
30
+
31
+ // API parity with iOS. Android hotspot OTA obtains the authoritative address
32
+ // directly from the scoped Network and does not call this fallback.
33
+ AsyncFunction("waitForWifiAddress") { _: String, _: Int ->
34
+ LocalIpv4.bestLocalIpv4Address()
35
+ ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
36
+ }
37
+
38
+ OnDestroy {
39
+ closeOtaServerInternal()
40
+ }
41
+ }
42
+
43
+ @Synchronized
44
+ private fun startOtaServer(
45
+ manifestJson: String,
46
+ artifactPaths: Map<String, String>,
47
+ hostOverride: String?,
48
+ ): Map<String, Any> {
49
+ val artifacts = artifactPaths.mapValues { (_, path) ->
50
+ File(path.removePrefix("file://"))
51
+ }
52
+ artifacts.forEach { (key, file) ->
53
+ if (!file.isFile) {
54
+ throw IllegalArgumentException("Artifact $key not found at ${file.absolutePath}")
55
+ }
56
+ }
57
+
58
+ val server = otaServer ?: LocalOtaServer(
59
+ onLog = { message -> emitStatus(message) },
60
+ ).also {
61
+ otaServer = it
62
+ }
63
+ server.configure(manifestJson, artifacts)
64
+
65
+ val host = hostOverride?.takeIf { it.isNotBlank() }
66
+ ?: LocalIpv4.bestLocalIpv4Address()
67
+ ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
68
+
69
+ server.activePort?.let { activePort ->
70
+ emitStatus("OTA server ready at http://$host:$activePort/version.json")
71
+ return serverResult(host, activePort)
72
+ }
73
+
74
+ var lastError: Throwable? = null
75
+ for (port in OTA_PORTS) {
76
+ try {
77
+ val actualPort = server.start(host, port)
78
+ emitStatus("OTA server ready at http://$host:$actualPort/version.json")
79
+ return serverResult(host, actualPort)
80
+ } catch (error: Throwable) {
81
+ lastError = error
82
+ emitStatus("Port $port unavailable: ${error.message ?: error::class.java.simpleName}")
83
+ }
84
+ }
85
+
86
+ throw IllegalStateException(
87
+ "Could not start OTA server: ${lastError?.message ?: "all ports unavailable"}",
88
+ )
89
+ }
90
+
91
+ @Synchronized
92
+ private fun stopOtaServerInternal() {
93
+ otaServer?.stop()
94
+ emitStatus("OTA server stopped")
95
+ }
96
+
97
+ @Synchronized
98
+ private fun closeOtaServerInternal() {
99
+ otaServer?.close()
100
+ otaServer = null
101
+ }
102
+
103
+ private fun emitStatus(message: String) {
104
+ Log.d(TAG, message)
105
+ }
106
+
107
+ private fun serverResult(host: String, port: Int): Map<String, Any> {
108
+ return mapOf(
109
+ "baseUrl" to "http://$host:$port",
110
+ "manifestUrl" to "http://$host:$port/version.json",
111
+ "host" to host,
112
+ "port" to port,
113
+ )
114
+ }
115
+
116
+ private companion object {
117
+ const val TAG = "MentraOtaServer"
118
+ val OTA_PORTS = listOf(8791, 8792, 8793, 8794)
119
+ }
120
+ }
@@ -4,11 +4,10 @@ import android.content.Context
4
4
  import android.net.Uri
5
5
  import android.util.Log
6
6
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
7
+ import com.mentra.bluetoothsdk.net.LocalIpv4
7
8
  import expo.modules.kotlin.exception.Exceptions
8
9
  import expo.modules.kotlin.modules.Module
9
10
  import expo.modules.kotlin.modules.ModuleDefinition
10
- import java.net.Inet4Address
11
- import java.net.NetworkInterface
12
11
 
13
12
  class MentraPhotoReceiverModule : Module() {
14
13
  private var photoUploadServer: LocalPhotoUploadServer? = null
@@ -45,7 +44,7 @@ class MentraPhotoReceiverModule : Module() {
45
44
  photoUploadServer = it
46
45
  }
47
46
 
48
- val host = bestLocalIpv4Address()
47
+ val host = LocalIpv4.bestLocalIpv4Address()
49
48
  ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
50
49
 
51
50
  server.activePort?.let { activePort ->
@@ -128,39 +127,6 @@ class MentraPhotoReceiverModule : Module() {
128
127
  ?: throw Exceptions.ReactContextLost()
129
128
  }
130
129
 
131
- private fun bestLocalIpv4Address(): String? {
132
- val wifiPrivateCandidates = mutableListOf<Inet4Address>()
133
- val wifiCandidates = mutableListOf<Inet4Address>()
134
- val privateCandidates = mutableListOf<Inet4Address>()
135
- val otherCandidates = mutableListOf<Inet4Address>()
136
- val interfaces = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
137
- for (networkInterface in interfaces) {
138
- if (!networkInterface.isUp || networkInterface.isLoopback) {
139
- continue
140
- }
141
- val addresses = networkInterface.inetAddresses.toList()
142
- .filterIsInstance<Inet4Address>()
143
- .filter { address ->
144
- !address.isLoopbackAddress &&
145
- !address.isLinkLocalAddress
146
- }
147
- if (isWifiInterface(networkInterface.name)) {
148
- wifiPrivateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
149
- wifiCandidates += addresses
150
- } else {
151
- privateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
152
- otherCandidates += addresses
153
- }
154
- }
155
-
156
- return (
157
- wifiPrivateCandidates.firstOrNull() ?:
158
- wifiCandidates.firstOrNull() ?:
159
- privateCandidates.firstOrNull() ?:
160
- otherCandidates.firstOrNull()
161
- )?.hostAddress
162
- }
163
-
164
130
  private fun receiverEndpoint(host: String, port: Int): ReceiverEndpoint {
165
131
  return ReceiverEndpoint(
166
132
  uploadUrl = "http://$host:$port/upload",
@@ -177,19 +143,6 @@ class MentraPhotoReceiverModule : Module() {
177
143
  )
178
144
  }
179
145
 
180
- private fun isWifiInterface(name: String): Boolean {
181
- return name.startsWith("wlan") ||
182
- name.startsWith("p2p") ||
183
- name.startsWith("ap") ||
184
- name.startsWith("swlan")
185
- }
186
-
187
- private fun isPrivateIpv4(host: String): Boolean {
188
- return host.startsWith("192.168.") ||
189
- host.startsWith("10.") ||
190
- host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"))
191
- }
192
-
193
146
  private companion object {
194
147
  const val TAG = "MentraPhotoReceiver"
195
148
  val PHOTO_PORTS = listOf(8787, 8788, 8789, 8790)
@@ -20,6 +20,35 @@ class ForegroundService : Service() {
20
20
  const val NOTIFICATION_ID = 1001
21
21
  const val ACTION_REFRESH_TYPES =
22
22
  "com.mentra.bluetoothsdk.services.action.REFRESH_FOREGROUND_SERVICE_TYPES"
23
+
24
+ internal fun bootstrapServiceType(): Int =
25
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
26
+
27
+ internal fun preferredServiceType(
28
+ hasConnectedDeviceAccess: Boolean,
29
+ hasMicrophoneAccess: Boolean,
30
+ hasLocationAccess: Boolean,
31
+ includeMediaPlayback: Boolean = true,
32
+ ): Int {
33
+ var serviceType = 0
34
+
35
+ if (includeMediaPlayback) {
36
+ serviceType =
37
+ serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
38
+ }
39
+ if (hasConnectedDeviceAccess) {
40
+ serviceType =
41
+ serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
42
+ }
43
+ if (hasMicrophoneAccess) {
44
+ serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
45
+ }
46
+ if (hasLocationAccess) {
47
+ serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
48
+ }
49
+
50
+ return if (serviceType == 0) bootstrapServiceType() else serviceType
51
+ }
23
52
  }
24
53
 
25
54
  private var locationTypeRequested = false
@@ -28,7 +57,11 @@ class ForegroundService : Service() {
28
57
  super.onCreate()
29
58
  Bridge.log("ForegroundService: onCreate() called")
30
59
  BleTraceLogger.logLifecycle(this, "ForegroundService", "service_create")
31
- startForegroundWithAutoDetectedType()
60
+ // Enter the foreground immediately with a type that has no runtime
61
+ // prerequisites. onStartCommand() replaces this bootstrap type with the
62
+ // eligible long-running types, deliberately omitting dataSync so Android 15's
63
+ // six-hour dataSync timer no longer applies.
64
+ startForegroundWithType(bootstrapServiceType())
32
65
  }
33
66
 
34
67
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -50,8 +83,10 @@ class ForegroundService : Service() {
50
83
  }
51
84
 
52
85
  private fun startForegroundWithAutoDetectedType() {
53
- val serviceType = detectServiceType()
86
+ startForegroundWithType(detectServiceType())
87
+ }
54
88
 
89
+ private fun startForegroundWithType(serviceType: Int) {
55
90
  createNotificationChannel()
56
91
 
57
92
  val notification =
@@ -65,7 +100,7 @@ class ForegroundService : Service() {
65
100
 
66
101
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
67
102
  Bridge.log(
68
- "ForegroundService: Starting with auto-detected type: ${getServiceTypeName(serviceType)}"
103
+ "ForegroundService: Starting with type: ${getServiceTypeName(serviceType)}"
69
104
  )
70
105
  startForeground(NOTIFICATION_ID, notification, serviceType)
71
106
  } else {
@@ -79,14 +114,11 @@ class ForegroundService : Service() {
79
114
  return 0 // No service types before Android Q
80
115
  }
81
116
 
82
- var serviceType = 0
83
-
84
117
  // Audio prompts can be initiated by glasses while the host Activity is
85
118
  // backgrounded. mediaPlayback has no runtime prerequisite, so keep it
86
119
  // active on the existing Mentra service for the entire connected
87
120
  // session rather than trying to launch a second service after a wake
88
121
  // phrase arrives.
89
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
90
122
  Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
91
123
 
92
124
  // Check Bluetooth permissions
@@ -106,28 +138,32 @@ class ForegroundService : Service() {
106
138
  }
107
139
  }
108
140
 
109
- // Check CHANGE_NETWORK_STATE permission - this is a "normal" permission that is
110
- // auto-granted at install time, but we check it defensively in case this changes.
111
- // This permission can satisfy the connectedDevice FGS requirement without needing
112
- // BLUETOOTH_CONNECT, which is a "dangerous" permission that users can deny.
113
- val hasNetworkStatePermission =
141
+ // These normal permissions satisfy the connectedDevice FGS prerequisite without
142
+ // waiting for a runtime Bluetooth grant. CHANGE_WIFI_STATE is declared by the SDK;
143
+ // CHANGE_NETWORK_STATE is also accepted when a host app already declares it.
144
+ val hasConnectedDeviceManifestPermission =
114
145
  ContextCompat.checkSelfPermission(
115
146
  this,
116
147
  android.Manifest.permission.CHANGE_NETWORK_STATE
117
- ) == PackageManager.PERMISSION_GRANTED
148
+ ) == PackageManager.PERMISSION_GRANTED ||
149
+ ContextCompat.checkSelfPermission(
150
+ this,
151
+ android.Manifest.permission.CHANGE_WIFI_STATE
152
+ ) == PackageManager.PERMISSION_GRANTED
118
153
 
119
- // Use connectedDevice if we have EITHER Bluetooth OR network state permission.
120
- // This avoids falling back to dataSync (which has a 6-hour timeout on Android 14+)
154
+ // Use connectedDevice if we have either Bluetooth or a qualifying normal permission.
155
+ // This avoids falling back to dataSync (which has a six-hour timeout on Android 15+)
121
156
  // when users deny Bluetooth permissions.
122
- if (hasBluetoothPermission || hasNetworkStatePermission) {
123
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
157
+ val hasConnectedDeviceAccess =
158
+ hasBluetoothPermission || hasConnectedDeviceManifestPermission
159
+ if (hasConnectedDeviceAccess) {
124
160
  if (hasBluetoothPermission) {
125
161
  Bridge.log("ForegroundService: Added connectedDevice (has Bluetooth permission)")
126
162
  } else {
127
- Bridge.log("ForegroundService: Added connectedDevice (has CHANGE_NETWORK_STATE permission)")
163
+ Bridge.log("ForegroundService: Added connectedDevice (has network control permission)")
128
164
  }
129
165
  } else {
130
- Bridge.log("ForegroundService: No Bluetooth or network state permission for connectedDevice")
166
+ Bridge.log("ForegroundService: No qualifying permission for connectedDevice")
131
167
  }
132
168
 
133
169
  // Check microphone permission
@@ -136,7 +172,6 @@ class ForegroundService : Service() {
136
172
  PackageManager.PERMISSION_GRANTED
137
173
 
138
174
  if (hasMicPermission) {
139
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
140
175
  Bridge.log("ForegroundService: Added microphone (has RECORD_AUDIO permission)")
141
176
  } else {
142
177
  Bridge.log("ForegroundService: No microphone permission")
@@ -154,8 +189,8 @@ class ForegroundService : Service() {
154
189
  val locationManager = getSystemService(LocationManager::class.java)
155
190
  val isLocationEnabled = locationManager?.isLocationEnabled == true
156
191
 
157
- if (locationTypeRequested && hasLocationPermission && isLocationEnabled) {
158
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
192
+ val hasLocationAccess = locationTypeRequested && hasLocationPermission && isLocationEnabled
193
+ if (hasLocationAccess) {
159
194
  Bridge.log("ForegroundService: Added location (has foreground location permission)")
160
195
  } else {
161
196
  Bridge.log(
@@ -164,15 +199,11 @@ class ForegroundService : Service() {
164
199
  )
165
200
  }
166
201
 
167
- // Only use dataSync as absolute last resort fallback if no other types were added.
168
- // WARNING: dataSync has a 6-hour timeout on Android 14+ which will crash the app.
169
- // This should rarely happen since CHANGE_NETWORK_STATE is a normal permission.
170
- if (serviceType == 0) {
171
- serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
172
- Bridge.log("ForegroundService: WARNING - Using dataSync as fallback (6-hour timeout on Android 14+)")
173
- }
174
-
175
- return serviceType
202
+ return preferredServiceType(
203
+ hasConnectedDeviceAccess = hasConnectedDeviceAccess,
204
+ hasMicrophoneAccess = hasMicPermission,
205
+ hasLocationAccess = hasLocationAccess,
206
+ )
176
207
  }
177
208
 
178
209
  private fun getNotificationText(serviceType: Int): String {