@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.
- package/README.md +67 -25
- package/android/build.gradle +7 -4
- package/android/lc3Lib/build.gradle +18 -4
- package/android/silero/build.gradle +9 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +84 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +3 -12
- package/build/_private/sdkOtaManifest.d.ts.map +1 -1
- package/build/_private/sdkOtaManifest.js +5 -17
- package/build/_private/sdkOtaManifest.js.map +1 -1
- package/build/changelogs.d.ts +7 -0
- package/build/changelogs.d.ts.map +1 -0
- package/build/changelogs.js +44 -0
- package/build/changelogs.js.map +1 -0
- package/build/generated/changelogCatalog.d.ts +6 -0
- package/build/generated/changelogCatalog.d.ts.map +1 -0
- package/build/generated/changelogCatalog.js +8 -0
- package/build/generated/changelogCatalog.js.map +1 -0
- package/build/generated/releaseMetadata.d.ts +12 -0
- package/build/generated/releaseMetadata.d.ts.map +1 -0
- package/build/generated/releaseMetadata.js +10 -0
- package/build/generated/releaseMetadata.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +24 -2
- package/build/index.js.map +1 -1
- package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
- package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServer.types.js +2 -0
- package/build/ota-server/MentraOtaServer.types.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
- package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.js +3 -0
- package/build/ota-server/MentraOtaServerModule.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.js +12 -0
- package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
- package/build/ota-server/index.d.ts +3 -0
- package/build/ota-server/index.d.ts.map +1 -0
- package/build/ota-server/index.js +3 -0
- package/build/ota-server/index.js.map +1 -0
- package/build/ota-transport/index.d.ts +67 -0
- package/build/ota-transport/index.d.ts.map +1 -0
- package/build/ota-transport/index.js +58 -0
- package/build/ota-transport/index.js.map +1 -0
- package/expo-module.config.json +3 -2
- package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
- package/ios/BluetoothSdkModule.swift +16 -2
- package/ios/LocalIPv4.swift +150 -0
- package/ios/LocalOtaServer.swift +309 -0
- package/ios/MentraBluetoothSDK.podspec +5 -1
- package/ios/MentraOtaServerModule.swift +147 -0
- package/ios/MentraPhotoReceiverModule.swift +1 -81
- package/ios/Source/BluetoothSdkDefaults.swift +4 -1
- package/ios/Source/Bridge.swift +43 -4
- package/ios/Source/DeviceManager.swift +154 -19
- package/ios/Source/DeviceStore.swift +2 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
- package/ios/Source/MentraBluetoothSDK.swift +91 -6
- package/ios/Source/OtaManifest.swift +5 -7
- package/ios/Source/ReleaseChangelog.swift +65 -0
- package/ios/Source/audio/AudioModels.swift +17 -0
- package/ios/Source/camera/CameraModels.swift +1 -0
- package/ios/Source/events/BluetoothEvents.swift +27 -0
- package/ios/Source/sgcs/MentraLive.swift +512 -78
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
- package/ios/Source/sgcs/SGCManager.swift +11 -0
- package/ios/Source/status/DeviceStatus.swift +23 -0
- package/ios/Source/types/DeviceModels.swift +23 -2
- package/ios/Source/utils/BleJsonCompact.swift +73 -71
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
- package/ios/Tests/ReleaseChangelogTests.swift +13 -0
- package/package.json +10 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/public-ota-api.test.mjs +37 -0
- package/scripts/verify-release-package.mjs +34 -0
- package/scripts/write-release-metadata.mjs +154 -0
- package/scripts/write-release-metadata.test.mjs +29 -0
- package/src/BluetoothSdk.types.ts +97 -3
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +5 -5
- package/src/_private/MentraLocalNetworkModule.ts +1 -1
- package/src/_private/sdkOtaManifest.ts +5 -17
- package/src/changelogs.ts +44 -0
- package/src/generated/changelogCatalog.ts +9 -0
- package/src/generated/releaseMetadata.ts +20 -0
- package/src/index.ts +43 -2
- package/src/ota-server/MentraOtaServer.types.ts +10 -0
- package/src/ota-server/MentraOtaServerModule.ts +23 -0
- package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
- package/src/ota-server/index.ts +2 -0
- 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
|
+
}
|
package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
|
120
|
-
// This avoids falling back to dataSync (which has a
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
163
|
+
Bridge.log("ForegroundService: Added connectedDevice (has network control permission)")
|
|
128
164
|
}
|
|
129
165
|
} else {
|
|
130
|
-
Bridge.log("ForegroundService: No
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 {
|