@mentra/bluetooth-sdk 0.1.11 → 0.1.13

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 (80) hide show
  1. package/README.md +67 -11
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +182 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +73 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +10 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +83 -26
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +266 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +93 -10
  13. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
  14. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -13
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -1
  25. package/build/BluetoothSdk.types.d.ts +72 -10
  26. package/build/BluetoothSdk.types.d.ts.map +1 -1
  27. package/build/BluetoothSdk.types.js.map +1 -1
  28. package/build/_private/BluetoothSdkModule.d.ts +9 -7
  29. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  30. package/build/_private/BluetoothSdkModule.js +0 -2
  31. package/build/_private/BluetoothSdkModule.js.map +1 -1
  32. package/build/_private/photoRequestPayload.d.ts +3 -1
  33. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  34. package/build/_private/photoRequestPayload.js +43 -1
  35. package/build/_private/photoRequestPayload.js.map +1 -1
  36. package/build/debug.d.ts +3 -0
  37. package/build/debug.d.ts.map +1 -0
  38. package/build/debug.js +8 -0
  39. package/build/debug.js.map +1 -0
  40. package/build/index.d.ts +1 -1
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +16 -5
  43. package/build/index.js.map +1 -1
  44. package/ios/BluetoothSdkModule.swift +59 -64
  45. package/ios/Source/BluetoothSdkDefaults.swift +47 -0
  46. package/ios/Source/Bridge.swift +6 -0
  47. package/ios/Source/DeviceManager.swift +46 -32
  48. package/ios/Source/DeviceStore.swift +5 -1
  49. package/ios/Source/MentraBluetoothSDK.swift +357 -34
  50. package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
  51. package/ios/Source/ObservableStore.swift +11 -0
  52. package/ios/Source/OtaManifest.swift +170 -0
  53. package/ios/Source/camera/CameraModels.swift +267 -9
  54. package/ios/Source/controllers/ControllerManager.swift +2 -5
  55. package/ios/Source/controllers/R1.swift +2 -5
  56. package/ios/Source/events/BluetoothEvents.swift +3 -0
  57. package/ios/Source/internal/BluetoothAvailability.swift +20 -0
  58. package/ios/Source/internal/BluetoothSdkAnalytics.swift +160 -0
  59. package/ios/Source/sgcs/G1.swift +6 -5
  60. package/ios/Source/sgcs/G2.swift +20 -18
  61. package/ios/Source/sgcs/Mach1.swift +6 -5
  62. package/ios/Source/sgcs/MentraLive.swift +158 -38
  63. package/ios/Source/sgcs/MentraNex.swift +6 -5
  64. package/ios/Source/sgcs/SGCManager.swift +16 -7
  65. package/ios/Source/sgcs/Simulated.swift +7 -3
  66. package/ios/Source/status/DeviceStatus.swift +1 -1
  67. package/ios/Source/types/DeviceModels.swift +5 -1
  68. package/package.json +6 -1
  69. package/plugin/build/index.d.ts +4 -0
  70. package/plugin/build/withAndroid.d.ts +2 -3
  71. package/plugin/build/withAndroid.js +46 -0
  72. package/plugin/build/withIos.d.ts +2 -3
  73. package/plugin/build/withIos.js +32 -0
  74. package/src/BluetoothSdk.types.ts +80 -10
  75. package/src/_private/BluetoothSdkModule.ts +13 -10
  76. package/src/_private/photoRequestPayload.ts +45 -2
  77. package/src/debug.ts +9 -0
  78. package/src/index.ts +25 -6
  79. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
  80. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7405
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ object MentraBluetoothSdkDebug {
4
+ @JvmStatic
5
+ fun setOtaVersionUrl(
6
+ sdk: MentraBluetoothSdk,
7
+ otaVersionUrl: String,
8
+ ) {
9
+ sdk.setOtaVersionUrl(otaVersionUrl)
10
+ }
11
+
12
+ @JvmStatic
13
+ fun getOtaVersionUrl(sdk: MentraBluetoothSdk): String = sdk.getOtaVersionUrl()
14
+ }
@@ -53,6 +53,26 @@ class ObservableStore {
53
53
  listeners.forEach { it(normalizedCategory, mapOf(key to value)) }
54
54
  }
55
55
 
56
+ fun remove(category: String, key: String) {
57
+ val normalizedCategory: String
58
+ val updatedSnapshot: Map<String, Any>
59
+ val listeners: List<(String, Map<String, Any>) -> Unit>
60
+
61
+ synchronized(this) {
62
+ normalizedCategory = normalizeCategory(category)
63
+ val fullKey = "$normalizedCategory.$key"
64
+ if (!values.containsKey(fullKey)) return // already absent, no notification needed
65
+ values.remove(fullKey)
66
+ val prefix = "$normalizedCategory."
67
+ updatedSnapshot = values.filterKeys { it.startsWith(prefix) }
68
+ .mapKeys { it.key.removePrefix(prefix) }
69
+ listeners = emitListeners.values.toList()
70
+ }
71
+
72
+ // Emit the full updated category snapshot so UI listeners can clear the removed key.
73
+ listeners.forEach { it(normalizedCategory, updatedSnapshot) }
74
+ }
75
+
56
76
  @Synchronized
57
77
  fun get(category: String, key: String): Any? = values["${normalizeCategory(category)}.$key"]
58
78
 
@@ -0,0 +1,153 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import java.net.HttpURLConnection
4
+ import java.net.URI
5
+ import java.net.URL
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+
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
+ // Keep prod as the legacy-device fallback: pre-override ASG builds ignore
13
+ // ota_start.ota_version_url and use their compiled MentraOS default.
14
+ const val PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
15
+
16
+ fun defaultOtaVersionUrl(): String {
17
+ val sdkVersion = BuildConfig.SDK_VERSION.trim()
18
+ if (sdkVersion.isBlank() || sdkVersion == "unspecified") {
19
+ throw BluetoothException(
20
+ "missing_sdk_version",
21
+ "Cannot determine Bluetooth SDK version for the default OTA manifest URL.",
22
+ )
23
+ }
24
+ return "$SDK_OTA_RELEASE_BASE_URL/bluetooth-sdk-$sdkVersion-version.json"
25
+ }
26
+ }
27
+
28
+ internal object OtaManifestChecker {
29
+ private const val ASG_CLIENT_PACKAGE = "com.mentra.asg_client"
30
+
31
+ fun normalizeHttpUrl(value: String): String {
32
+ val trimmed = value.trim()
33
+ if (trimmed.isEmpty()) {
34
+ throw BluetoothException("invalid_ota_url", "OTA version URL must be a non-empty http(s) URL.")
35
+ }
36
+
37
+ val uri =
38
+ try {
39
+ URI(trimmed)
40
+ } catch (error: Exception) {
41
+ throw BluetoothException("invalid_ota_url", "OTA version URL must be a valid http(s) URL.", error)
42
+ }
43
+
44
+ val scheme = uri.scheme?.lowercase()
45
+ if ((scheme != "http" && scheme != "https") || uri.host.isNullOrBlank()) {
46
+ throw BluetoothException("invalid_ota_url", "OTA version URL must be an http(s) URL.")
47
+ }
48
+
49
+ return uri.toString()
50
+ }
51
+
52
+ fun fetch(otaVersionUrl: String): JSONObject {
53
+ val connection = URL(otaVersionUrl).openConnection() as HttpURLConnection
54
+ connection.connectTimeout = 15_000
55
+ connection.readTimeout = 15_000
56
+ connection.requestMethod = "GET"
57
+ return try {
58
+ val status = connection.responseCode
59
+ if (status !in 200..299) {
60
+ throw BluetoothException("ota_manifest_request_failed", "OTA manifest request failed with HTTP $status.")
61
+ }
62
+ JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
63
+ } finally {
64
+ connection.disconnect()
65
+ }
66
+ }
67
+
68
+ fun hasUpdate(
69
+ currentBuildNumber: String,
70
+ currentMtkVersion: String,
71
+ currentBesVersion: String,
72
+ manifest: JSONObject,
73
+ ): Boolean =
74
+ hasApkUpdate(currentBuildNumber, manifest) ||
75
+ hasMtkUpdate(manifest.optJSONArray("mtk_patches"), currentMtkVersion) ||
76
+ hasBesUpdate(manifest.optJSONObject("bes_firmware"), currentBesVersion)
77
+
78
+ fun hasMtkPatches(manifest: JSONObject): Boolean =
79
+ (manifest.optJSONArray("mtk_patches")?.length() ?: 0) > 0
80
+
81
+ fun hasBesFirmware(manifest: JSONObject): Boolean =
82
+ manifest.optJSONObject("bes_firmware") != null
83
+
84
+ private fun latestAppInfo(manifest: JSONObject): JSONObject {
85
+ val apps = manifest.optJSONObject("apps")
86
+ val app = apps?.optJSONObject(ASG_CLIENT_PACKAGE)
87
+ if (app != null && app.hasNumber("versionCode")) {
88
+ return app
89
+ }
90
+
91
+ if (manifest.hasNumber("versionCode")) {
92
+ return manifest
93
+ }
94
+
95
+ throw BluetoothException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
96
+ }
97
+
98
+ private fun hasApkUpdate(currentBuildNumber: String, manifest: JSONObject): Boolean {
99
+ val currentVersion =
100
+ currentBuildNumber.toLongOrNull()
101
+ ?: throw BluetoothException(
102
+ "invalid_glasses_version",
103
+ "Cannot check OTA update because glasses build number is invalid.",
104
+ )
105
+ val serverVersion = latestAppInfo(manifest).requiredLong("versionCode")
106
+ return serverVersion > currentVersion
107
+ }
108
+
109
+ private fun hasMtkUpdate(patches: JSONArray?, currentVersion: String): Boolean {
110
+ if (patches == null || patches.length() == 0) return false
111
+ if (currentVersion.isBlank()) return false
112
+
113
+ for (index in 0 until patches.length()) {
114
+ val patch = patches.optJSONObject(index) ?: continue
115
+ val startFirmware = patch.optString("start_firmware", "")
116
+ if (startFirmware == currentVersion) return true
117
+ val serverDate = if (startFirmware.contains("_")) startFirmware.substringAfterLast("_") else startFirmware
118
+ if (serverDate == currentVersion) return true
119
+ }
120
+ return false
121
+ }
122
+
123
+ private fun hasBesUpdate(besFirmware: JSONObject?, currentVersion: String): Boolean {
124
+ if (besFirmware == null) return false
125
+ val serverVersion = besFirmware.optString("version", "")
126
+ if (serverVersion.isBlank()) {
127
+ throw BluetoothException("invalid_ota_manifest", "OTA manifest bes_firmware.version is missing.")
128
+ }
129
+ if (currentVersion.isBlank()) return true
130
+ return compareVersions(serverVersion, currentVersion) > 0
131
+ }
132
+
133
+ private fun compareVersions(version1: String, version2: String): Int {
134
+ if (version1.contains(".") && version2.contains(".")) {
135
+ val parts1 = version1.split(".")
136
+ val parts2 = version2.split(".")
137
+ val maxLength = maxOf(parts1.size, parts2.size)
138
+ for (index in 0 until maxLength) {
139
+ val value1 = parts1.getOrNull(index)?.toIntOrNull() ?: 0
140
+ val value2 = parts2.getOrNull(index)?.toIntOrNull() ?: 0
141
+ if (value1 != value2) return value1 - value2
142
+ }
143
+ return 0
144
+ }
145
+ return version1.compareTo(version2)
146
+ }
147
+
148
+ private fun JSONObject.hasNumber(key: String): Boolean = !isNull(key) && opt(key) is Number
149
+
150
+ private fun JSONObject.requiredLong(key: String): Long =
151
+ (opt(key) as? Number)?.toLong()
152
+ ?: throw BluetoothException("invalid_ota_manifest", "OTA manifest is missing ASG app versionCode.")
153
+ }
@@ -1,28 +1,46 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
+ import org.json.JSONObject
4
+
3
5
  enum class PhotoSize(val value: String) {
4
- SMALL("small"),
6
+ LOW("low"),
5
7
  MEDIUM("medium"),
6
- LARGE("large"),
7
- FULL("full");
8
+ HIGH("high"),
9
+ MAX("max");
8
10
 
9
11
  companion object {
10
12
  @JvmStatic
11
- fun fromValue(value: String?): PhotoSize =
12
- values().firstOrNull { it.value == value } ?: MEDIUM
13
+ fun normalizeLegacy(value: String?): String =
14
+ when (value) {
15
+ "small" -> LOW.value
16
+ "large" -> HIGH.value
17
+ "full" -> MAX.value
18
+ else -> value ?: MEDIUM.value
19
+ }
20
+
21
+ @JvmStatic
22
+ fun fromValue(value: String?): PhotoSize {
23
+ val normalized = normalizeLegacy(value)
24
+ return values().firstOrNull { it.value == normalized } ?: MEDIUM
25
+ }
13
26
  }
14
27
  }
15
28
 
16
29
  enum class ButtonPhotoSize(val value: String) {
17
- SMALL("small"),
30
+ LOW("low"),
18
31
  MEDIUM("medium"),
19
- LARGE("large"),
32
+ HIGH("high"),
20
33
  MAX("max");
21
34
 
22
35
  companion object {
23
36
  @JvmStatic
24
- fun fromValue(value: String?): ButtonPhotoSize =
25
- values().firstOrNull { it.value == value } ?: MEDIUM
37
+ fun normalizeLegacy(value: String?): String = PhotoSize.normalizeLegacy(value)
38
+
39
+ @JvmStatic
40
+ fun fromValue(value: String?): ButtonPhotoSize {
41
+ val normalized = normalizeLegacy(value)
42
+ return values().firstOrNull { it.value == normalized } ?: MEDIUM
43
+ }
26
44
  }
27
45
  }
28
46
 
@@ -39,7 +57,18 @@ enum class PhotoCompression(val value: String) {
39
57
  }
40
58
 
41
59
  data class ButtonPhotoSettings(
42
- val size: ButtonPhotoSize,
60
+ val size: ButtonPhotoSize? = null,
61
+ val mfnr: Boolean? = null,
62
+ val zsl: Boolean? = null,
63
+ val noiseReduction: Boolean? = null,
64
+ val edgeEnhancement: Boolean? = null,
65
+ val ispDigitalGain: Int? = null,
66
+ val ispAnalogGain: String? = null,
67
+ val aeExposureDivisor: Int? = null,
68
+ val isoCap: Int? = null,
69
+ val compress: String? = null,
70
+ val sound: Boolean? = null,
71
+ val resetCaptureTuning: Boolean = false,
43
72
  )
44
73
 
45
74
  data class ButtonVideoRecordingSettings(
@@ -145,6 +174,15 @@ data class PhotoRequest @JvmOverloads constructor(
145
174
  val exposureTimeNs: Double? = null,
146
175
  /** Sensor ISO for this capture only. Only used when exposureTimeNs enables manual exposure. */
147
176
  val iso: Int? = null,
177
+ val aeExposureDivisor: Int? = null,
178
+ val isoCap: Int? = null,
179
+ val noiseReduction: Boolean? = null,
180
+ val edgeEnhancement: Boolean? = null,
181
+ val mfnr: Boolean? = null,
182
+ val zsl: Boolean? = null,
183
+ val ispDigitalGain: Int? = null,
184
+ val ispAnalogGain: String? = null,
185
+ val resetCaptureTuning: Boolean? = null,
148
186
  ) {
149
187
  companion object {
150
188
  /** Mirrors iOS `BluetoothSdkModule` defaults for keys omitted from the JS bridge. */
@@ -167,6 +205,12 @@ data class PhotoRequest @JvmOverloads constructor(
167
205
  }
168
206
  else -> null
169
207
  }
208
+ val aeDivisor =
209
+ numberValue(values, "aeExposureDivisor")?.takeIf { it > 1 }
210
+ val isoCap = numberValue(values, "isoCap")?.takeIf { it > 0 }
211
+ val ispDigitalGain = numberValue(values, "ispDigitalGain")
212
+ val ispAnalogGain = stringValue(values, "ispAnalogGain")
213
+
170
214
  return PhotoRequest(
171
215
  requestId = stringValue(values, "requestId", "request_id").orEmpty(),
172
216
  appId = stringValue(values, "appId", "app_id").orEmpty(),
@@ -179,8 +223,32 @@ data class PhotoRequest @JvmOverloads constructor(
179
223
  sound = boolValue(values, "sound") ?: true,
180
224
  exposureTimeNs = exposureTimeNs,
181
225
  iso = iso,
226
+ aeExposureDivisor = aeDivisor,
227
+ isoCap = isoCap,
228
+ noiseReduction = boolValue(values, "noiseReduction"),
229
+ edgeEnhancement = boolValue(values, "edgeEnhancement"),
230
+ mfnr = boolValue(values, "mfnr"),
231
+ zsl = boolValue(values, "zsl"),
232
+ ispDigitalGain = ispDigitalGain,
233
+ ispAnalogGain = ispAnalogGain,
182
234
  )
183
235
  }
236
+
237
+ /** Adds optional scan-mode tuning fields to a `take_photo` JSON payload. */
238
+ @JvmStatic
239
+ fun appendScanFields(
240
+ json: JSONObject,
241
+ request: PhotoRequest,
242
+ ) {
243
+ request.aeExposureDivisor?.let { json.put("aeExposureDivisor", it) }
244
+ request.isoCap?.let { json.put("isoCap", it) }
245
+ request.noiseReduction?.let { json.put("noiseReduction", it) }
246
+ request.edgeEnhancement?.let { json.put("edgeEnhancement", it) }
247
+ request.mfnr?.let { json.put("mfnr", it) }
248
+ request.zsl?.let { json.put("zsl", it) }
249
+ request.ispDigitalGain?.let { json.put("ispDigitalGain", it) }
250
+ request.ispAnalogGain?.let { json.put("ispAnalogGain", it) }
251
+ }
184
252
  }
185
253
  }
186
254
 
@@ -227,6 +295,8 @@ data class VideoRecordingRequest(
227
295
  val width: Int = 0,
228
296
  val height: Int = 0,
229
297
  val fps: Int = 0,
298
+ // Optional auto-stop timer in minutes; 0 = record until stopped/interrupted.
299
+ val maxRecordingTimeMinutes: Int = 0,
230
300
  )
231
301
 
232
302
  data class VideoRecordingStatusEvent(
@@ -240,6 +310,19 @@ data class VideoRecordingStatusEvent(
240
310
  val data: Map<String, Any>? get() = stringMapValue(values["data"])
241
311
  }
242
312
 
313
+ data class MediaUploadEvent(
314
+ val values: Map<String, Any>,
315
+ ) {
316
+ val type: String get() = stringValue(values, "type").orEmpty()
317
+ val requestId: String get() = stringValue(values, "requestId").orEmpty()
318
+ val mediaUrl: String? get() = stringValue(values, "mediaUrl")
319
+ val errorMessage: String? get() = stringValue(values, "errorMessage")
320
+ val mediaType: Int? get() = numberValue(values, "mediaType")
321
+ val timestamp: Long get() = longValue(values, "timestamp") ?: System.currentTimeMillis()
322
+ val isSuccess: Boolean get() = type == "media_success"
323
+ val isVideo: Boolean get() = mediaType == 2
324
+ }
325
+
243
326
  data class GalleryStatusEvent(
244
327
  val values: Map<String, Any>,
245
328
  )
@@ -2,6 +2,7 @@ package com.mentra.bluetoothsdk.controllers
2
2
 
3
3
  import com.mentra.bluetoothsdk.BluetoothSdkDefaults
4
4
  import com.mentra.bluetoothsdk.DeviceStore
5
+ import com.mentra.bluetoothsdk.PhotoRequest
5
6
 
6
7
  abstract class ControllerManager {
7
8
  @JvmField var type: String = ""
@@ -15,19 +16,7 @@ abstract class ControllerManager {
15
16
  abstract fun sendJson(jsonOriginal: Map<String, Any>, wakeUp: Boolean, requireAck: Boolean)
16
17
 
17
18
  // Camera & Media
18
- abstract fun requestPhoto(
19
- requestId: String,
20
- appId: String,
21
- size: String?,
22
- webhookUrl: String?,
23
- authToken: String?,
24
- compress: String?,
25
- flash: Boolean,
26
- save: Boolean,
27
- sound: Boolean,
28
- exposureTimeNs: Long?,
29
- iso: Int?,
30
- )
19
+ abstract fun requestPhoto(request: PhotoRequest)
31
20
  abstract fun startStream(message: Map<String, Any>)
32
21
  abstract fun stopStream()
33
22
  abstract fun sendStreamKeepAlive(message: Map<String, Any>)
@@ -87,7 +76,7 @@ abstract class ControllerManager {
87
76
  abstract fun sendWifiCredentials(ssid: String, password: String)
88
77
  abstract fun forgetWifiNetwork(ssid: String)
89
78
  abstract fun sendHotspotState(enabled: Boolean)
90
- abstract fun sendOtaStart()
79
+ abstract fun sendOtaStart(otaVersionUrl: String? = null)
91
80
 
92
81
  // User Context (for crash reporting)
93
82
  abstract fun sendUserEmailToGlasses(email: String)
@@ -1,5 +1,6 @@
1
1
  package com.mentra.bluetoothsdk.controllers
2
2
 
3
+ import com.mentra.bluetoothsdk.PhotoRequest
3
4
  import android.Manifest
4
5
  import android.bluetooth.BluetoothAdapter
5
6
  import android.bluetooth.BluetoothDevice
@@ -861,11 +862,7 @@ class R1 : ControllerManager() {
861
862
  override fun setMicEnabled(enabled: Boolean) {}
862
863
  override fun sortMicRanking(list: MutableList<String>): MutableList<String> = list
863
864
  override fun sendJson(jsonOriginal: Map<String, Any>, wakeUp: Boolean, requireAck: Boolean) {}
864
- override fun requestPhoto(
865
- requestId: String, appId: String, size: String?, webhookUrl: String?,
866
- authToken: String?, compress: String?, flash: Boolean, save: Boolean, sound: Boolean,
867
- exposureTimeNs: Long?, iso: Int?,
868
- ) {}
865
+ override fun requestPhoto(request: PhotoRequest) {}
869
866
  override fun startVideoRecording(requestId: String, save: Boolean, flash: Boolean, sound: Boolean) {}
870
867
  override fun stopVideoRecording(requestId: String) {}
871
868
  override fun startStream(message: Map<String, Any>) {}
@@ -901,7 +898,7 @@ class R1 : ControllerManager() {
901
898
  override fun sendWifiCredentials(ssid: String, password: String) {}
902
899
  override fun forgetWifiNetwork(ssid: String) {}
903
900
  override fun sendHotspotState(enabled: Boolean) {}
904
- override fun sendOtaStart() {}
901
+ override fun sendOtaStart(otaVersionUrl: String?) {}
905
902
  override fun sendUserEmailToGlasses(email: String) {}
906
903
  override fun queryGalleryStatus() {}
907
904
  override fun sendGalleryMode() {}
@@ -154,6 +154,7 @@ interface MentraBluetoothSdkListener {
154
154
  fun onPhotoResponse(event: PhotoResponseEvent) {}
155
155
  fun onPhotoStatus(event: PhotoStatusEvent) {}
156
156
  fun onVideoRecordingStatus(event: VideoRecordingStatusEvent) {}
157
+ fun onMediaUpload(event: MediaUploadEvent) {}
157
158
  fun onRgbLedControlResponse(event: RgbLedControlResponseEvent) {}
158
159
  fun onStreamStatus(event: StreamStatusEvent) {}
159
160
  fun onKeepAliveAck(event: KeepAliveAckEvent) {}
@@ -49,6 +49,7 @@ class PhoneMic private constructor(private val context: Context) {
49
49
  private const val FOCUS_REGAIN_DELAY_MS = 500L
50
50
  private const val SAMSUNG_MIC_TEST_DELAY_MS = 500L
51
51
  private const val MIC_SWITCH_DELAY_MS = 300L // Time for DeviceManager to switch mics
52
+ private const val AUDIO_SOURCE_HOTWORD = 1999
52
53
  }
53
54
 
54
55
  // Audio recording components
@@ -185,7 +186,42 @@ class PhoneMic private constructor(private val context: Context) {
185
186
  }
186
187
 
187
188
  fun isRecordingWithMode(mode: String): Boolean {
188
- return isRecording.get() && currentMicMode == mode
189
+ if (!isRecording.get()) {
190
+ return false
191
+ }
192
+
193
+ val record = audioRecord
194
+ val activelyRecording =
195
+ try {
196
+ record?.recordingState == AudioRecord.RECORDSTATE_RECORDING
197
+ } catch (e: Exception) {
198
+ Bridge.log("MIC: Failed to read AudioRecord state: ${e.message}")
199
+ false
200
+ }
201
+
202
+ if (!activelyRecording) {
203
+ Bridge.log("MIC: Stale recording state detected, cleaning up")
204
+ cleanUpRecording()
205
+ abandonAudioFocus()
206
+ currentMicMode = ""
207
+ return false
208
+ }
209
+
210
+ return currentMicMode == mode
211
+ }
212
+
213
+ fun hasBlockingMicInterruption(): Boolean {
214
+ val callActive =
215
+ isPhoneCallActive ||
216
+ try {
217
+ (telephonyManager?.callState ?: TelephonyManager.CALL_STATE_IDLE) !=
218
+ TelephonyManager.CALL_STATE_IDLE
219
+ } catch (e: Exception) {
220
+ Bridge.log("MIC: Failed to read telephony call state: ${e.message}")
221
+ true
222
+ }
223
+
224
+ return callActive || isExternalAudioActive
189
225
  }
190
226
 
191
227
  /**
@@ -544,7 +580,23 @@ class PhoneMic private constructor(private val context: Context) {
544
580
  audioRecord?.let { ourAudioSessionIds.add(it.audioSessionId) }
545
581
 
546
582
  // Start recording
547
- audioRecord?.startRecording()
583
+ val record = audioRecord ?: return false
584
+ try {
585
+ record.startRecording()
586
+ } catch (e: Exception) {
587
+ Bridge.log("MIC: AudioRecord.startRecording() failed: ${e.message}")
588
+ audioRecord?.release()
589
+ audioRecord = null
590
+ return false
591
+ }
592
+
593
+ if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
594
+ Bridge.log("MIC: AudioRecord did not enter recording state")
595
+ record.release()
596
+ audioRecord = null
597
+ return false
598
+ }
599
+
548
600
  isRecording.set(true)
549
601
 
550
602
  // Start recording thread
@@ -577,15 +629,22 @@ class PhoneMic private constructor(private val context: Context) {
577
629
 
578
630
  val audioBuffer = ShortArray(bufferSize / 2)
579
631
 
632
+ var unexpectedStopReason: String? = null
633
+
580
634
  while (isRecording.get()) {
581
635
  // Capture local reference to avoid use-after-free if cleanUpRecording()
582
636
  // releases the AudioRecord while we're in read()
583
- val record = audioRecord ?: break
637
+ val record = audioRecord
638
+ if (record == null) {
639
+ unexpectedStopReason = "AudioRecord missing"
640
+ break
641
+ }
584
642
 
585
643
  val readResult = try {
586
644
  record.read(audioBuffer, 0, audioBuffer.size)
587
645
  } catch (e: Exception) {
588
646
  Bridge.log("MIC: AudioRecord.read() exception: ${e.message}")
647
+ unexpectedStopReason = "AudioRecord.read() exception: ${e.message}"
589
648
  break
590
649
  }
591
650
 
@@ -603,9 +662,16 @@ class PhoneMic private constructor(private val context: Context) {
603
662
  } else if (readResult < 0) {
604
663
  // AudioRecord error (ERROR, ERROR_INVALID_OPERATION, ERROR_BAD_VALUE, ERROR_DEAD_OBJECT)
605
664
  Bridge.log("MIC: AudioRecord.read() returned error: $readResult")
665
+ unexpectedStopReason = "AudioRecord.read() error: $readResult"
606
666
  break
607
667
  }
608
668
  }
669
+
670
+ unexpectedStopReason?.let { reason ->
671
+ if (isRecording.get()) {
672
+ mainHandler.post { handleUnexpectedRecordingStop(reason) }
673
+ }
674
+ }
609
675
  }
610
676
  .apply {
611
677
  name = "AudioRecordingThread"
@@ -613,6 +679,25 @@ class PhoneMic private constructor(private val context: Context) {
613
679
  }
614
680
  }
615
681
 
682
+ private fun handleUnexpectedRecordingStop(reason: String) {
683
+ if (!isRecording.compareAndSet(true, false)) {
684
+ return
685
+ }
686
+
687
+ Bridge.log("MIC: Recording stopped unexpectedly: $reason")
688
+ cleanUpRecording()
689
+ abandonAudioFocus()
690
+
691
+ if (audioManager.isBluetoothScoOn) {
692
+ audioManager.stopBluetoothSco()
693
+ audioManager.isBluetoothScoOn = false
694
+ }
695
+
696
+ audioManager.mode = AudioManager.MODE_NORMAL
697
+ currentMicMode = ""
698
+ notifyDeviceManager("recording_stopped", getAvailableInputDevices().values.toList())
699
+ }
700
+
616
701
  private fun cleanUpRecording() {
617
702
  isRecording.set(false)
618
703
 
@@ -778,7 +863,8 @@ class PhoneMic private constructor(private val context: Context) {
778
863
  // Filter out our own recordings
779
864
  val otherAppRecordings =
780
865
  configs.filter { config ->
781
- !ourAudioSessionIds.contains(config.clientAudioSessionId)
866
+ !ourAudioSessionIds.contains(config.clientAudioSessionId) &&
867
+ config.clientAudioSource != AUDIO_SOURCE_HOTWORD
782
868
  }
783
869
 
784
870
  val wasExternalActive = isExternalAudioActive