@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +38 -2
  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 +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1174 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  27. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  33. package/build/BluetoothSdk.types.d.ts +61 -2
  34. package/build/BluetoothSdk.types.d.ts.map +1 -1
  35. package/build/BluetoothSdk.types.js.map +1 -1
  36. package/build/_internal.d.ts +2 -0
  37. package/build/_internal.d.ts.map +1 -1
  38. package/build/_internal.js +2 -0
  39. package/build/_internal.js.map +1 -1
  40. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  41. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  42. package/build/_private/BluetoothSdkModule.js +1 -1
  43. package/build/_private/BluetoothSdkModule.js.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  45. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  47. package/build/_private/sdkOtaManifest.d.ts +3 -12
  48. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  49. package/build/_private/sdkOtaManifest.js +5 -17
  50. package/build/_private/sdkOtaManifest.js.map +1 -1
  51. package/build/generated/releaseMetadata.d.ts +12 -0
  52. package/build/generated/releaseMetadata.d.ts.map +1 -0
  53. package/build/generated/releaseMetadata.js +10 -0
  54. package/build/generated/releaseMetadata.js.map +1 -0
  55. package/build/index.d.ts +1 -1
  56. package/build/index.d.ts.map +1 -1
  57. package/build/index.js +10 -2
  58. package/build/index.js.map +1 -1
  59. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  60. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  61. package/build/ota-server/MentraOtaServer.types.js +2 -0
  62. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  63. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  64. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.js +3 -0
  66. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  68. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  70. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  71. package/build/ota-server/index.d.ts +3 -0
  72. package/build/ota-server/index.d.ts.map +1 -0
  73. package/build/ota-server/index.js +3 -0
  74. package/build/ota-server/index.js.map +1 -0
  75. package/expo-module.config.json +3 -2
  76. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  77. package/ios/BluetoothSdkModule.swift +16 -2
  78. package/ios/LocalIPv4.swift +150 -0
  79. package/ios/LocalOtaServer.swift +309 -0
  80. package/ios/MentraBluetoothSDK.podspec +5 -1
  81. package/ios/MentraOtaServerModule.swift +147 -0
  82. package/ios/MentraPhotoReceiverModule.swift +1 -81
  83. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  84. package/ios/Source/Bridge.swift +47 -3
  85. package/ios/Source/DeviceManager.swift +135 -10
  86. package/ios/Source/DeviceStore.swift +2 -0
  87. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  88. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  89. package/ios/Source/OtaManifest.swift +5 -7
  90. package/ios/Source/audio/AudioModels.swift +17 -0
  91. package/ios/Source/camera/CameraModels.swift +1 -0
  92. package/ios/Source/events/BluetoothEvents.swift +27 -0
  93. package/ios/Source/sgcs/MentraLive.swift +439 -30
  94. package/ios/Source/sgcs/SGCManager.swift +11 -0
  95. package/ios/Source/status/DeviceStatus.swift +23 -0
  96. package/ios/Source/types/DeviceModels.swift +23 -2
  97. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  98. package/package.json +5 -1
  99. package/plugin/build/withIos.d.ts +3 -1
  100. package/plugin/build/withIos.js +22 -10
  101. package/scripts/verify-release-package.mjs +34 -0
  102. package/scripts/write-release-metadata.mjs +154 -0
  103. package/scripts/write-release-metadata.test.mjs +29 -0
  104. package/src/BluetoothSdk.types.ts +72 -3
  105. package/src/_internal.ts +2 -0
  106. package/src/_private/BluetoothSdkModule.ts +5 -5
  107. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  108. package/src/_private/sdkOtaManifest.ts +5 -17
  109. package/src/generated/releaseMetadata.ts +20 -0
  110. package/src/index.ts +19 -2
  111. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  112. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  113. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  114. package/src/ota-server/index.ts +2 -0
@@ -5,7 +5,8 @@ import android.net.Network
5
5
  import android.net.NetworkCapabilities
6
6
  import android.net.NetworkRequest
7
7
  import android.net.wifi.WifiNetworkSpecifier
8
- import android.os.Build
8
+ import android.os.Handler
9
+ import android.os.Looper
9
10
  import android.util.Base64
10
11
  import android.util.Log
11
12
  import expo.modules.kotlin.Promise
@@ -14,6 +15,7 @@ import expo.modules.kotlin.modules.ModuleDefinition
14
15
  import java.io.File
15
16
  import java.io.FileOutputStream
16
17
  import java.net.HttpURLConnection
18
+ import java.net.Inet4Address
17
19
  import java.net.URL
18
20
  import java.util.concurrent.ConcurrentHashMap
19
21
  import java.util.concurrent.Executors
@@ -22,14 +24,18 @@ import java.util.concurrent.Executors
22
24
  class MentraLocalNetworkModule : Module() {
23
25
  private companion object {
24
26
  const val TAG = "MentraLocalNetwork"
27
+ const val NETWORK_READINESS_TIMEOUT_MS = 5_000L
25
28
  }
26
29
 
27
30
  private val lock = Any()
28
31
  private val executor = Executors.newCachedThreadPool()
32
+ private val handler = Handler(Looper.getMainLooper())
29
33
  private val activeConnections = ConcurrentHashMap<String, HttpURLConnection>()
34
+ private val networkReadiness = ScopedNetworkReadiness<Network>()
30
35
  private var localNetwork: Network? = null
31
36
  private var networkCallback: ConnectivityManager.NetworkCallback? = null
32
37
  private var pendingConnectPromise: Promise? = null
38
+ private var pendingReadinessTimeout: Runnable? = null
33
39
 
34
40
  override fun definition() = ModuleDefinition {
35
41
  Name("MentraLocalNetwork")
@@ -74,7 +80,9 @@ class MentraLocalNetworkModule : Module() {
74
80
  AsyncFunction("cancel") { requestId: String -> cancel(requestId) }
75
81
  AsyncFunction("disconnect") { disconnect() }
76
82
 
77
- OnDestroy { disconnect() }
83
+ OnDestroy {
84
+ disconnect()
85
+ }
78
86
  }
79
87
 
80
88
  private fun connectivityManager(): ConnectivityManager =
@@ -83,11 +91,6 @@ class MentraLocalNetworkModule : Module() {
83
91
  }
84
92
 
85
93
  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
94
  disconnect()
92
95
  val manager = connectivityManager()
93
96
  val specifier =
@@ -105,21 +108,55 @@ class MentraLocalNetworkModule : Module() {
105
108
  val callback =
106
109
  object : ConnectivityManager.NetworkCallback() {
107
110
  override fun onAvailable(network: Network) {
108
- val connectPromise: Promise?
109
111
  synchronized(lock) {
110
112
  if (networkCallback !== this) {
111
113
  Log.d(TAG, "Ignoring onAvailable from a stale network request")
112
114
  return
113
115
  }
116
+ networkReadiness.onAvailable(network)
117
+ }
118
+ Log.i(
119
+ TAG,
120
+ "Captured glasses WiFi network=$network; waiting for foreground capability " +
121
+ "processBoundNetwork=${manager.boundNetworkForProcess}",
122
+ )
123
+ scheduleReadinessTimeout(this, ssid)
124
+ }
125
+
126
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
127
+ val connectPromise: Promise?
128
+ synchronized(lock) {
129
+ if (networkCallback !== this || localNetwork != null || pendingConnectPromise == null) return
130
+ if (!networkReadiness.isReady(
131
+ network,
132
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND),
133
+ )
134
+ ) {
135
+ return
136
+ }
114
137
  localNetwork = network
115
138
  connectPromise = pendingConnectPromise
116
139
  pendingConnectPromise = null
140
+ cancelReadinessTimeoutLocked()
117
141
  }
118
142
  Log.i(
119
143
  TAG,
120
- "Captured glasses WiFi network=$network processBoundNetwork=${manager.boundNetworkForProcess}",
144
+ "Glasses WiFi network=$network is foreground and ready " +
145
+ "processBoundNetwork=${manager.boundNetworkForProcess}",
146
+ )
147
+ val localAddress =
148
+ manager.getLinkProperties(network)
149
+ ?.linkAddresses
150
+ ?.firstOrNull { it.address is Inet4Address }
151
+ ?.address
152
+ ?.hostAddress
153
+ connectPromise?.resolve(
154
+ buildMap<String, Any> {
155
+ put("connected", true)
156
+ put("ssid", ssid)
157
+ if (!localAddress.isNullOrBlank()) put("localAddress", localAddress)
158
+ },
121
159
  )
122
- connectPromise?.resolve(mapOf("connected" to true, "ssid" to ssid))
123
160
  }
124
161
 
125
162
  override fun onUnavailable() {
@@ -135,13 +172,17 @@ class MentraLocalNetworkModule : Module() {
135
172
 
136
173
  override fun onLost(network: Network) {
137
174
  synchronized(lock) {
138
- if (networkCallback !== this || localNetwork != network) {
175
+ if (networkCallback !== this ||
176
+ (localNetwork != network && !networkReadiness.contains(network))
177
+ ) {
139
178
  Log.d(TAG, "Ignoring onLost from a stale network request")
140
179
  return
141
180
  }
142
181
  localNetwork = null
182
+ networkReadiness.clear()
143
183
  }
144
184
  cancelAll()
185
+ rejectPendingConnect("ERR_LOCAL_WIFI_LOST", "Lost connection to $ssid")
145
186
  clearNetworkCallback(this)
146
187
  sendEvent("networkLost", mapOf("ssid" to ssid))
147
188
  }
@@ -289,6 +330,39 @@ class MentraLocalNetworkModule : Module() {
289
330
  activeConnections.keys.toList().forEach(::cancel)
290
331
  }
291
332
 
333
+ private fun scheduleReadinessTimeout(
334
+ callback: ConnectivityManager.NetworkCallback,
335
+ ssid: String,
336
+ ) {
337
+ val timeout =
338
+ Runnable {
339
+ val connectPromise = synchronized(lock) {
340
+ if (networkCallback !== callback || pendingConnectPromise == null) return@Runnable
341
+ pendingReadinessTimeout = null
342
+ val promise = pendingConnectPromise
343
+ pendingConnectPromise = null
344
+ promise
345
+ }
346
+ connectPromise?.reject(
347
+ "ERR_LOCAL_WIFI_NOT_READY",
348
+ "Connected to $ssid but the network did not become ready",
349
+ null,
350
+ )
351
+ clearNetworkCallback(callback)
352
+ }
353
+ synchronized(lock) {
354
+ if (networkCallback !== callback || pendingConnectPromise == null) return
355
+ cancelReadinessTimeoutLocked()
356
+ pendingReadinessTimeout = timeout
357
+ }
358
+ handler.postDelayed(timeout, NETWORK_READINESS_TIMEOUT_MS)
359
+ }
360
+
361
+ private fun cancelReadinessTimeoutLocked() {
362
+ pendingReadinessTimeout?.let(handler::removeCallbacks)
363
+ pendingReadinessTimeout = null
364
+ }
365
+
292
366
  private fun disconnect() {
293
367
  cancelAll()
294
368
  val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
@@ -297,6 +371,8 @@ class MentraLocalNetworkModule : Module() {
297
371
  callback = networkCallback
298
372
  networkCallback = null
299
373
  localNetwork = null
374
+ networkReadiness.clear()
375
+ cancelReadinessTimeoutLocked()
300
376
  }
301
377
  if (callback != null && manager != null) {
302
378
  try {
@@ -311,7 +387,12 @@ class MentraLocalNetworkModule : Module() {
311
387
  private fun clearNetworkCallback(callback: ConnectivityManager.NetworkCallback) {
312
388
  val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
313
389
  synchronized(lock) {
314
- if (networkCallback == callback) networkCallback = null
390
+ if (networkCallback == callback) {
391
+ networkCallback = null
392
+ localNetwork = null
393
+ networkReadiness.clear()
394
+ cancelReadinessTimeoutLocked()
395
+ }
315
396
  }
316
397
  if (manager != null) {
317
398
  try {
@@ -7,21 +7,19 @@ import org.json.JSONArray
7
7
  import org.json.JSONObject
8
8
 
9
9
  internal object OtaManifestDefaults {
10
- private const val SDK_OTA_RELEASE_BASE_URL =
11
- "https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota"
12
10
  // ASG builds before 39 ignore ota_start.ota_version_url, so SDK checks must
13
11
  // use the same legacy production manifest those glasses will install from.
14
12
  const val LEGACY_PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
15
13
 
16
14
  fun defaultOtaVersionUrl(): String {
17
- val sdkVersion = BuildConfig.SDK_VERSION.trim()
18
- if (sdkVersion.isBlank() || sdkVersion == "unspecified") {
15
+ val manifestUrl = GeneratedReleaseMetadata.OTA_MANIFEST_URL.trim()
16
+ if (manifestUrl.isBlank()) {
19
17
  throw BluetoothSdkException(
20
- "missing_sdk_version",
21
- "Cannot determine Bluetooth SDK version for the default OTA manifest URL.",
18
+ "ota_manifest_unconfigured",
19
+ "This source-built Bluetooth SDK has no embedded OTA manifest. Configure one with the debug API.",
22
20
  )
23
21
  }
24
- return "$SDK_OTA_RELEASE_BASE_URL/bluetooth-sdk-$sdkVersion-version.json"
22
+ return manifestUrl
25
23
  }
26
24
  }
27
25
 
@@ -0,0 +1,17 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal class ScopedNetworkReadiness<T> {
4
+ private var candidate: T? = null
5
+
6
+ fun onAvailable(network: T) {
7
+ candidate = network
8
+ }
9
+
10
+ fun isReady(network: T, isForeground: Boolean): Boolean = candidate == network && isForeground
11
+
12
+ fun contains(network: T): Boolean = candidate == network
13
+
14
+ fun clear() {
15
+ candidate = null
16
+ }
17
+ }
@@ -94,6 +94,21 @@ data class MicLc3Event(
94
94
  }
95
95
  }
96
96
 
97
+ internal data class MicHealth(
98
+ val sequenceGapEvents: Long,
99
+ val decodeFailures: Long,
100
+ val lastLc3ReceivedAt: Long?,
101
+ val lastPcmProducedAt: Long?,
102
+ ) {
103
+ fun toMap(): Map<String, Any> =
104
+ buildMap {
105
+ put("sequenceGapEvents", sequenceGapEvents)
106
+ put("decodeFailures", decodeFailures)
107
+ lastLc3ReceivedAt?.let { put("lastLc3ReceivedAt", it) }
108
+ lastPcmProducedAt?.let { put("lastPcmProducedAt", it) }
109
+ }
110
+ }
111
+
97
112
  data class LocalTranscriptionEvent(
98
113
  val text: String,
99
114
  val isFinal: Boolean,
@@ -39,6 +39,29 @@ data class SpeakingStatusEvent(
39
39
  val values: Map<String, Any>,
40
40
  )
41
41
 
42
+ data class MicHealthEvent(
43
+ val reason: String,
44
+ val sequenceGapEvents: Long,
45
+ val decodeFailures: Long,
46
+ val lastLc3ReceivedAt: Long?,
47
+ val lastPcmProducedAt: Long?,
48
+ val timestamp: Long,
49
+ val values: Map<String, Any>,
50
+ ) {
51
+ companion object {
52
+ internal fun fromMap(values: Map<String, Any>): MicHealthEvent =
53
+ MicHealthEvent(
54
+ reason = stringValue(values, "reason") ?: "",
55
+ sequenceGapEvents = longValue(values, "sequenceGapEvents") ?: 0,
56
+ decodeFailures = longValue(values, "decodeFailures") ?: 0,
57
+ lastLc3ReceivedAt = longValue(values, "lastLc3ReceivedAt"),
58
+ lastPcmProducedAt = longValue(values, "lastPcmProducedAt"),
59
+ timestamp = longValue(values, "timestamp") ?: System.currentTimeMillis(),
60
+ values = values,
61
+ )
62
+ }
63
+ }
64
+
42
65
  data class OtaStartAckEvent(
43
66
  val timestamp: Long?,
44
67
  val values: Map<String, Any>,
@@ -144,6 +167,7 @@ interface MentraBluetoothSdkListener {
144
167
  fun onVersionInfo(event: VersionInfoResult) {}
145
168
  fun onMicPcm(event: MicPcmEvent) {}
146
169
  fun onMicLc3(event: MicLc3Event) {}
170
+ fun onMicHealth(event: MicHealthEvent) {}
147
171
  fun onLocalTranscription(event: LocalTranscriptionEvent) {}
148
172
  fun onDefaultDeviceChanged(device: Device?) {}
149
173
  fun onLog(message: String) {}
@@ -0,0 +1,57 @@
1
+ package com.mentra.bluetoothsdk.net
2
+
3
+ import java.net.Inet4Address
4
+ import java.net.NetworkInterface
5
+
6
+ /**
7
+ * Best-effort local IPv4 discovery for phone-hosted servers the glasses reach over WiFi
8
+ * (photo upload receiver, hotspot-served OTA). Prefers WiFi-ish interfaces carrying RFC1918
9
+ * addresses — under a glasses-hotspot session wlan0 holds the 192.168.43.x client address.
10
+ */
11
+ object LocalIpv4 {
12
+ fun bestLocalIpv4Address(): String? {
13
+ val wifiPrivateCandidates = mutableListOf<Inet4Address>()
14
+ val wifiCandidates = mutableListOf<Inet4Address>()
15
+ val privateCandidates = mutableListOf<Inet4Address>()
16
+ val otherCandidates = mutableListOf<Inet4Address>()
17
+ val interfaces = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
18
+ for (networkInterface in interfaces) {
19
+ if (!networkInterface.isUp || networkInterface.isLoopback) {
20
+ continue
21
+ }
22
+ val addresses = networkInterface.inetAddresses.toList()
23
+ .filterIsInstance<Inet4Address>()
24
+ .filter { address ->
25
+ !address.isLoopbackAddress &&
26
+ !address.isLinkLocalAddress
27
+ }
28
+ if (isWifiInterface(networkInterface.name)) {
29
+ wifiPrivateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
30
+ wifiCandidates += addresses
31
+ } else {
32
+ privateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
33
+ otherCandidates += addresses
34
+ }
35
+ }
36
+
37
+ return (
38
+ wifiPrivateCandidates.firstOrNull() ?:
39
+ wifiCandidates.firstOrNull() ?:
40
+ privateCandidates.firstOrNull() ?:
41
+ otherCandidates.firstOrNull()
42
+ )?.hostAddress
43
+ }
44
+
45
+ private fun isWifiInterface(name: String): Boolean {
46
+ return name.startsWith("wlan") ||
47
+ name.startsWith("p2p") ||
48
+ name.startsWith("ap") ||
49
+ name.startsWith("swlan")
50
+ }
51
+
52
+ private fun isPrivateIpv4(host: String): Boolean {
53
+ return host.startsWith("192.168.") ||
54
+ host.startsWith("10.") ||
55
+ host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"))
56
+ }
57
+ }
@@ -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
+ }