@mentra/bluetooth-sdk 0.1.12 → 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.
- package/README.md +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +51 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +73 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +190 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdkDebug.kt +14 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +20 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +153 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +78 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +3 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +3 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +90 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +3835 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +20 -23
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/{Mach1.java → Mach1.kt} +517 -560
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +8712 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +11 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +7 -14
- package/build/BluetoothSdk.types.d.ts +39 -9
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +8 -6
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +0 -2
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts +3 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +43 -1
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/debug.d.ts +3 -0
- package/build/debug.d.ts.map +1 -0
- package/build/debug.js +8 -0
- package/build/debug.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -5
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +38 -57
- package/ios/Source/BluetoothSdkDefaults.swift +23 -3
- package/ios/Source/DeviceManager.swift +37 -26
- package/ios/Source/DeviceStore.swift +5 -1
- package/ios/Source/MentraBluetoothSDK.swift +197 -26
- package/ios/Source/MentraBluetoothSDKDebug.swift +12 -0
- package/ios/Source/ObservableStore.swift +11 -0
- package/ios/Source/OtaManifest.swift +170 -0
- package/ios/Source/camera/CameraModels.swift +218 -8
- package/ios/Source/controllers/ControllerManager.swift +2 -5
- package/ios/Source/controllers/R1.swift +2 -5
- package/ios/Source/sgcs/G1.swift +6 -5
- package/ios/Source/sgcs/G2.swift +20 -18
- package/ios/Source/sgcs/Mach1.swift +6 -5
- package/ios/Source/sgcs/MentraLive.swift +129 -33
- package/ios/Source/sgcs/MentraNex.swift +6 -5
- package/ios/Source/sgcs/SGCManager.swift +3 -5
- package/ios/Source/sgcs/Simulated.swift +7 -3
- package/ios/Source/status/DeviceStatus.swift +1 -1
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +40 -9
- package/src/_private/BluetoothSdkModule.ts +8 -9
- package/src/_private/photoRequestPayload.ts +45 -2
- package/src/debug.ts +9 -0
- package/src/index.ts +23 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +0 -3974
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +0 -7434
package/README.md
CHANGED
|
@@ -325,7 +325,7 @@ React Native narrows returned values to success shapes where the raw listener ev
|
|
|
325
325
|
| `startVideoRecording(...)` | `VideoRecordingStartedStatusEvent` with `success: true` and `status: "recording_started"`. | Rejects on `success: false` statuses such as `already_recording`, send failure, or timeout. |
|
|
326
326
|
| `stopVideoRecording(...)` | `VideoRecordingStoppedStatusEvent` with `success: true` and `status: "recording_stopped"`. When a webhook URL is supplied, resolves after the video upload succeeds. | Rejects on `success: false` statuses such as `not_recording`, webhook upload failure, send failure, or timeout. |
|
|
327
327
|
| `rgbLedControl(...)` | `RgbLedControlSuccessResponseEvent` with `state: "success"`. | Rejects when raw `rgb_led_control_response.state === "error"` or the response times out. |
|
|
328
|
-
| `checkForOtaUpdate()`
|
|
328
|
+
| `checkForOtaUpdate()` | `boolean`, true when the configured OTA manifest has an ASG APK, MTK, or BES update for the connected glasses; false only when the manifest was checked successfully and no update is available. | Rejects when the glasses are disconnected, version info is unavailable, the manifest cannot be fetched, or the manifest response is invalid/missing required ASG app version fields. |
|
|
329
329
|
|
|
330
330
|
Android and iOS async APIs use `BluetoothException` / `BluetoothError` for the same error paths. Their returned event structs are the successful response in normal `try`/`await` code, while raw listener/delegate events still include both success and error payloads.
|
|
331
331
|
|
|
@@ -335,9 +335,16 @@ Android and iOS async APIs use `BluetoothException` / `BluetoothError` for the s
|
|
|
335
335
|
|
|
336
336
|
Mentra Live firmware owns the OTA flow. The SDK mirrors the MentraOS app commands and events:
|
|
337
337
|
|
|
338
|
-
- `checkForOtaUpdate()`
|
|
339
|
-
- `startOtaUpdate()` sends `ota_start` and resolves with the ASG start ack after your app presents the update and the user accepts it.
|
|
340
|
-
|
|
338
|
+
- `checkForOtaUpdate()` fetches the configured manifest and resolves with `true` when an ASG APK, MTK, or BES update is available.
|
|
339
|
+
- `startOtaUpdate()` sends `ota_start` with the same configured manifest URL and resolves with the ASG start ack after your app presents the update and the user accepts it.
|
|
340
|
+
|
|
341
|
+
The default manifest is derived from the SDK version:
|
|
342
|
+
`https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota/bluetooth-sdk-<sdkVersion>-version.json`.
|
|
343
|
+
Each published SDK version points at a durable ASG client APK and firmware
|
|
344
|
+
manifest that were built for that SDK release. Pre-wall-clock ASG builds that
|
|
345
|
+
ignore `ota_start.ota_version_url` are checked against the URL they advertise,
|
|
346
|
+
or the production default if they do not advertise one, so the app does not
|
|
347
|
+
prompt for an update the glasses cannot install.
|
|
341
348
|
|
|
342
349
|
```ts
|
|
343
350
|
import BluetoothSdk from '@mentra/bluetooth-sdk'
|
|
@@ -346,9 +353,9 @@ BluetoothSdk.addListener('ota_status', (event) => {
|
|
|
346
353
|
console.log(`OTA ${event.status}: ${event.overall_percent}%`)
|
|
347
354
|
})
|
|
348
355
|
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
const userAccepted = await promptUserToInstallUpdate(
|
|
356
|
+
const hasUpdate = await BluetoothSdk.checkForOtaUpdate()
|
|
357
|
+
if (hasUpdate) {
|
|
358
|
+
const userAccepted = await promptUserToInstallUpdate() // your app's UI
|
|
352
359
|
if (userAccepted) {
|
|
353
360
|
const startAck = await BluetoothSdk.startOtaUpdate()
|
|
354
361
|
console.log('OTA start acknowledged', startAck.timestamp)
|
|
@@ -425,7 +432,7 @@ export function HardwareEventLogger() {
|
|
|
425
432
|
|
|
426
433
|
For non-React modules, `BluetoothSdk.addListener(...)` is the low-level subscription API. Keep the returned subscription and call `remove()` when the listener is no longer needed.
|
|
427
434
|
|
|
428
|
-
Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `wifi_scan_result`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `settings_ack`, `version_info`, `stream_status`, `
|
|
435
|
+
Common event names include `button_press`, `touch_event`, `head_up`, `battery_status`, `wifi_status_change`, `wifi_scan_result`, `hotspot_status_change`, `photo_status`, `photo_response`, `gallery_status`, `settings_ack`, `version_info`, `stream_status`, `ota_start_ack`, `ota_status`, `mic_pcm`, `mic_lc3`, `local_transcription`, `rgb_led_control_response`, `audio_connected`, `audio_disconnected`, and `log`.
|
|
429
436
|
|
|
430
437
|
React Native event payload fields usually use camelCase. OTA events intentionally mirror the glasses firmware field names, such as `overall_percent` and `version_name`. For example, `touch_event` includes `gestureName`, `wifi_scan_result` includes `networks` and `scanComplete`, `version_info` matches the `requestVersionInfo()` result shape, `photo_response` success includes `uploadUrl` and may include webhook-returned `photoUrl`, `statusUrl`, `contentType`, and `fileSizeBytes`, and `gallery_status` includes `hasContent`, `cameraBusy`, and optional `cameraBusyReason`. `photo_status` reports intermediate photo states such as `accepted`, `queued`, `configuring`, `capturing`, `captured`, `compressing`, `ble_fallback_compression`, `uploading`, `ready_for_transfer`, `transferring`, and `failed`; the `configuring` event includes `resolvedConfig` with the effective JPEG dimensions, quality, requested size, transfer method, compression, and manual exposure fields when present. The `capturing` event may include `requestedCaptureConfig` and `meteredPreview`; the `captured` event may include `captureMetadata` with the HAL-applied exposure, ISO, frame duration, and AE state. `mic_pcm` includes `sampleRate`, `bitsPerSample`, `channels`, and `encoding`; `mic_lc3` includes `sampleRate`, `channels`, `encoding`, `frameDurationMs`, `frameSizeBytes`, `bitrate`, and `packetizedFromGlasses`.
|
|
431
438
|
|
|
@@ -458,7 +465,7 @@ For bare native iOS apps, use the public SwiftPM repository:
|
|
|
458
465
|
https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
|
|
459
466
|
```
|
|
460
467
|
|
|
461
|
-
|
|
468
|
+
Select version `0.1.13`, then add the `MentraBluetoothSDK` product to your app target.
|
|
462
469
|
|
|
463
470
|
For local SDK development, add this package folder directly in Xcode:
|
|
464
471
|
|
|
@@ -441,8 +441,37 @@ class BluetoothSdkModule : Module() {
|
|
|
441
441
|
sdk?.setVoiceActivityDetectionEnabled(enabled)
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
AsyncFunction("
|
|
445
|
-
|
|
444
|
+
AsyncFunction("setButtonPhotoCaptureSettings") { params: Map<String, Any?> ->
|
|
445
|
+
val size = (params["size"] as? String)?.let { ButtonPhotoSize.fromValue(it) }
|
|
446
|
+
val mfnr = params["mfnr"] as? Boolean
|
|
447
|
+
val zsl = params["zsl"] as? Boolean
|
|
448
|
+
val noiseReduction = params["noiseReduction"] as? Boolean
|
|
449
|
+
val edgeEnhancement = params["edgeEnhancement"] as? Boolean
|
|
450
|
+
val ispDigitalGain = (params["ispDigitalGain"] as? Number)?.toInt()
|
|
451
|
+
val ispAnalogGain = params["ispAnalogGain"] as? String
|
|
452
|
+
val aeExposureDivisor = (params["aeExposureDivisor"] as? Number)?.toInt()
|
|
453
|
+
val isoCap = (params["isoCap"] as? Number)?.toInt()
|
|
454
|
+
val compress = params["compress"] as? String
|
|
455
|
+
val sound = params["sound"] as? Boolean
|
|
456
|
+
val resetCaptureTuning = params["resetCaptureTuning"] as? Boolean == true
|
|
457
|
+
requireSdk()
|
|
458
|
+
.setButtonPhotoSettings(
|
|
459
|
+
ButtonPhotoSettings(
|
|
460
|
+
size = size,
|
|
461
|
+
mfnr = mfnr,
|
|
462
|
+
zsl = zsl,
|
|
463
|
+
noiseReduction = noiseReduction,
|
|
464
|
+
edgeEnhancement = edgeEnhancement,
|
|
465
|
+
ispDigitalGain = ispDigitalGain,
|
|
466
|
+
ispAnalogGain = ispAnalogGain,
|
|
467
|
+
aeExposureDivisor = aeExposureDivisor,
|
|
468
|
+
isoCap = isoCap,
|
|
469
|
+
compress = compress,
|
|
470
|
+
sound = sound,
|
|
471
|
+
resetCaptureTuning = resetCaptureTuning,
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
.values
|
|
446
475
|
}
|
|
447
476
|
|
|
448
477
|
AsyncFunction("setButtonVideoRecordingSettings") { width: Int, height: Int, fps: Int ->
|
|
@@ -483,7 +512,26 @@ class BluetoothSdkModule : Module() {
|
|
|
483
512
|
|
|
484
513
|
// MARK: - OTA Commands
|
|
485
514
|
|
|
486
|
-
|
|
515
|
+
Function("setOtaVersionUrl") { otaVersionUrl: String ->
|
|
516
|
+
requireSdk().setOtaVersionUrl(otaVersionUrl)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
Function("getOtaVersionUrl") { requireSdk().getOtaVersionUrl() }
|
|
520
|
+
|
|
521
|
+
// Runs on Dispatchers.IO, not the shared Expo AsyncFunctionQueue:
|
|
522
|
+
// manifest fetches and version waits can block for several seconds.
|
|
523
|
+
AsyncFunction("checkForOtaUpdate") Coroutine { ->
|
|
524
|
+
withContext(Dispatchers.IO) { requireSdk().checkForOtaUpdate() }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
AsyncFunction("startOtaUpdate") { otaVersionUrl: String? ->
|
|
528
|
+
val sdk = requireSdk()
|
|
529
|
+
if (otaVersionUrl.isNullOrBlank()) {
|
|
530
|
+
sdk.startOtaUpdate().values
|
|
531
|
+
} else {
|
|
532
|
+
sdk.startOtaUpdate(otaVersionUrl).values
|
|
533
|
+
}
|
|
534
|
+
}
|
|
487
535
|
|
|
488
536
|
AsyncFunction("sendOtaQueryStatus") { requireSdk().sendOtaQueryStatus().values }
|
|
489
537
|
|
|
@@ -247,6 +247,7 @@ class DeviceManager {
|
|
|
247
247
|
private var audioOutputFormat: AudioOutputFormat = AudioOutputFormat.LC3
|
|
248
248
|
private var lastLc3Event: Long? = null
|
|
249
249
|
private var micReinitRunnable: Runnable? = null
|
|
250
|
+
private var systemMicAvailabilityRecheckRunnable: Runnable? = null
|
|
250
251
|
|
|
251
252
|
// VAD
|
|
252
253
|
private val vadBuffer = mutableListOf<ByteArray>()
|
|
@@ -358,6 +359,37 @@ class DeviceManager {
|
|
|
358
359
|
}
|
|
359
360
|
}
|
|
360
361
|
|
|
362
|
+
private fun scheduleSystemMicAvailabilityRecheck(reason: String) {
|
|
363
|
+
if (systemMicAvailabilityRecheckRunnable != null) {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
val recheck =
|
|
368
|
+
object : Runnable {
|
|
369
|
+
override fun run() {
|
|
370
|
+
systemMicAvailabilityRecheckRunnable = null
|
|
371
|
+
|
|
372
|
+
if (!micEnabled || !systemMicUnavailable) {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
val stillBlocked = phoneMic?.hasBlockingMicInterruption() ?: true
|
|
377
|
+
if (stillBlocked) {
|
|
378
|
+
scheduleSystemMicAvailabilityRecheck(reason)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
systemMicUnavailable = false
|
|
383
|
+
Bridge.log("MAN: MIC_UNAVAILABLE: FALSE recheck_after_$reason")
|
|
384
|
+
appendLog("MAN: MIC_UNAVAILABLE: FALSE recheck_after_$reason")
|
|
385
|
+
updateMicState()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
systemMicAvailabilityRecheckRunnable = recheck
|
|
390
|
+
mainHandler.postDelayed(recheck, 2_000)
|
|
391
|
+
}
|
|
392
|
+
|
|
361
393
|
// MARK: - Unique (Android)
|
|
362
394
|
private fun setupPermissionMonitoring() {
|
|
363
395
|
val context = Bridge.getContext()
|
|
@@ -947,6 +979,7 @@ class DeviceManager {
|
|
|
947
979
|
systemMicUnavailable = true
|
|
948
980
|
Bridge.log("MAN: MIC_UNAVAILABLE: TRUE external_app_recording")
|
|
949
981
|
appendLog("MAN: MIC_UNAVAILABLE: TRUE external_app_recording")
|
|
982
|
+
scheduleSystemMicAvailabilityRecheck("external_app_recording")
|
|
950
983
|
}
|
|
951
984
|
"audio_focus_available" -> {
|
|
952
985
|
// Audio focus is available again
|
|
@@ -965,6 +998,7 @@ class DeviceManager {
|
|
|
965
998
|
systemMicUnavailable = true
|
|
966
999
|
Bridge.log("MAN: MIC_UNAVAILABLE: TRUE phone_call_interruption")
|
|
967
1000
|
appendLog("MAN: MIC_UNAVAILABLE: TRUE phone_call_interruption")
|
|
1001
|
+
scheduleSystemMicAvailabilityRecheck("phone_call_interruption")
|
|
968
1002
|
}
|
|
969
1003
|
"phone_call_ended" -> {
|
|
970
1004
|
// Phone call ended - mark mic as available again
|
|
@@ -977,12 +1011,14 @@ class DeviceManager {
|
|
|
977
1011
|
systemMicUnavailable = true
|
|
978
1012
|
Bridge.log("MAN: MIC_UNAVAILABLE: TRUE phone_call_active")
|
|
979
1013
|
appendLog("MAN: MIC_UNAVAILABLE: TRUE phone_call_active")
|
|
1014
|
+
scheduleSystemMicAvailabilityRecheck("phone_call_active")
|
|
980
1015
|
}
|
|
981
1016
|
"audio_focus_denied" -> {
|
|
982
1017
|
// Another app has audio focus
|
|
983
1018
|
systemMicUnavailable = true
|
|
984
1019
|
Bridge.log("MAN: MIC_UNAVAILABLE: TRUE audio_focus_denied")
|
|
985
1020
|
appendLog("MAN: MIC_UNAVAILABLE: TRUE audio_focus_denied")
|
|
1021
|
+
scheduleSystemMicAvailabilityRecheck("audio_focus_denied")
|
|
986
1022
|
}
|
|
987
1023
|
"permission_denied" -> {
|
|
988
1024
|
// Microphone permission not granted
|
|
@@ -1010,6 +1046,9 @@ class DeviceManager {
|
|
|
1010
1046
|
// systemMicUnavailable = false
|
|
1011
1047
|
Bridge.log("MAN: MIC_UNAVAILABLE: UNKNOWN recording_stopped")
|
|
1012
1048
|
appendLog("MAN: MIC_UNAVAILABLE: UNKNOWN recording_stopped")
|
|
1049
|
+
if (systemMicUnavailable) {
|
|
1050
|
+
scheduleSystemMicAvailabilityRecheck("recording_stopped")
|
|
1051
|
+
}
|
|
1013
1052
|
}
|
|
1014
1053
|
else -> {
|
|
1015
1054
|
// Other route changes (headset plug/unplug, BT connect/disconnect, etc.)
|
|
@@ -1380,9 +1419,9 @@ class DeviceManager {
|
|
|
1380
1419
|
* Send OTA start command to glasses. Called when user approves an update (onboarding or
|
|
1381
1420
|
* background mode). Triggers glasses to begin download and installation.
|
|
1382
1421
|
*/
|
|
1383
|
-
fun sendOtaStart() {
|
|
1422
|
+
fun sendOtaStart(otaVersionUrl: String? = null) {
|
|
1384
1423
|
Bridge.log("MAN: 📱 Sending OTA start command to glasses")
|
|
1385
|
-
(sgc as? MentraLive)?.sendOtaStart()
|
|
1424
|
+
(sgc as? MentraLive)?.sendOtaStart(otaVersionUrl)
|
|
1386
1425
|
}
|
|
1387
1426
|
|
|
1388
1427
|
fun sendOtaQueryStatus() {
|
|
@@ -1396,8 +1435,26 @@ class DeviceManager {
|
|
|
1396
1435
|
}
|
|
1397
1436
|
|
|
1398
1437
|
fun sendButtonPhotoSettings(requestId: String, size: String) {
|
|
1438
|
+
sendButtonPhotoSettings(requestId, ButtonPhotoSettings(ButtonPhotoSize.fromValue(size)))
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
fun sendButtonPhotoSettings(requestId: String, settings: ButtonPhotoSettings) {
|
|
1399
1442
|
val live = sgc as? MentraLive ?: throw IllegalStateException("unsupported_device")
|
|
1400
|
-
live.sendButtonPhotoSettings(
|
|
1443
|
+
live.sendButtonPhotoSettings(
|
|
1444
|
+
requestId,
|
|
1445
|
+
settings.size?.value,
|
|
1446
|
+
settings.mfnr,
|
|
1447
|
+
settings.zsl,
|
|
1448
|
+
settings.noiseReduction,
|
|
1449
|
+
settings.edgeEnhancement,
|
|
1450
|
+
settings.ispDigitalGain,
|
|
1451
|
+
settings.ispAnalogGain,
|
|
1452
|
+
settings.aeExposureDivisor,
|
|
1453
|
+
settings.isoCap,
|
|
1454
|
+
settings.compress,
|
|
1455
|
+
settings.sound,
|
|
1456
|
+
settings.resetCaptureTuning == true,
|
|
1457
|
+
)
|
|
1401
1458
|
}
|
|
1402
1459
|
|
|
1403
1460
|
fun sendButtonVideoRecordingSettings(requestId: String, width: Int, height: Int, fps: Int) {
|
|
@@ -1538,38 +1595,31 @@ class DeviceManager {
|
|
|
1538
1595
|
updateMicState()
|
|
1539
1596
|
}
|
|
1540
1597
|
|
|
1541
|
-
fun requestPhoto(
|
|
1542
|
-
requestId: String,
|
|
1543
|
-
appId: String,
|
|
1544
|
-
size: String,
|
|
1545
|
-
webhookUrl: String,
|
|
1546
|
-
authToken: String?,
|
|
1547
|
-
compress: String,
|
|
1548
|
-
flash: Boolean,
|
|
1549
|
-
save: Boolean,
|
|
1550
|
-
sound: Boolean,
|
|
1551
|
-
exposureTimeNs: Double? = null,
|
|
1552
|
-
iso: Int? = null,
|
|
1553
|
-
) {
|
|
1598
|
+
fun requestPhoto(request: PhotoRequest) {
|
|
1554
1599
|
val exposureNs: Long? =
|
|
1555
|
-
exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
|
|
1600
|
+
request.exposureTimeNs?.takeIf { it.isFinite() && it > 0 }?.let { v ->
|
|
1556
1601
|
when {
|
|
1557
1602
|
v > Long.MAX_VALUE.toDouble() -> Long.MAX_VALUE
|
|
1558
1603
|
else -> v.toLong()
|
|
1559
1604
|
}
|
|
1560
1605
|
}
|
|
1561
|
-
val manualIso = if (exposureNs != null) iso?.takeIf { it > 0 } else null
|
|
1606
|
+
val manualIso = if (exposureNs != null) request.iso?.takeIf { it > 0 } else null
|
|
1607
|
+
val routed =
|
|
1608
|
+
request.copy(
|
|
1609
|
+
exposureTimeNs = exposureNs?.toDouble(),
|
|
1610
|
+
iso = manualIso,
|
|
1611
|
+
)
|
|
1562
1612
|
Bridge.log(
|
|
1563
|
-
"MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=$requestId appId=$appId size=$size compress=$compress flash=$flash save=$save sound=$sound exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
|
|
1613
|
+
"MAN: PHOTO PIPELINE [4/6] DeviceManager.requestPhoto requestId=${routed.requestId} appId=${routed.appId} size=${routed.size.value} compress=${routed.compress.value} flash=${routed.flash} save=${routed.save} sound=${routed.sound} exposureTimeNs=$exposureNs iso=${manualIso ?: "auto"} aeDivisor=${routed.aeExposureDivisor} isoCap=${routed.isoCap} sgc=${sgc?.javaClass?.simpleName ?: "null"}"
|
|
1564
1614
|
)
|
|
1565
1615
|
val activeSgc = sgc
|
|
1566
1616
|
if (activeSgc == null) {
|
|
1567
1617
|
Bridge.log(
|
|
1568
|
-
"MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=$requestId"
|
|
1618
|
+
"MAN: PHOTO PIPELINE — sgc is null (glasses not connected); dropping requestId=${routed.requestId}"
|
|
1569
1619
|
)
|
|
1570
1620
|
return
|
|
1571
1621
|
}
|
|
1572
|
-
activeSgc.requestPhoto(
|
|
1622
|
+
activeSgc.requestPhoto(routed)
|
|
1573
1623
|
}
|
|
1574
1624
|
|
|
1575
1625
|
fun rgbLedControl(
|
|
@@ -1781,6 +1831,8 @@ class DeviceManager {
|
|
|
1781
1831
|
|
|
1782
1832
|
micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
1783
1833
|
micReinitRunnable = null
|
|
1834
|
+
systemMicAvailabilityRecheckRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
1835
|
+
systemMicAvailabilityRecheckRunnable = null
|
|
1784
1836
|
|
|
1785
1837
|
// Clean up transcriber resources
|
|
1786
1838
|
transcriber?.shutdown()
|
|
@@ -126,7 +126,7 @@ object DeviceStore {
|
|
|
126
126
|
store.set("bluetooth", "gallery_mode", true)
|
|
127
127
|
store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
|
|
128
128
|
store.set("bluetooth", "screen_disabled", false)
|
|
129
|
-
store.set("bluetooth", "button_photo_size", "
|
|
129
|
+
store.set("bluetooth", "button_photo_size", "max")
|
|
130
130
|
store.set("bluetooth", "button_camera_led", true)
|
|
131
131
|
store.set("bluetooth", "button_max_recording_time", 10)
|
|
132
132
|
store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
|
|
@@ -45,6 +45,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
45
45
|
private var pendingWifiStatus: PendingWifiStatusRequest? = null
|
|
46
46
|
private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
|
|
47
47
|
private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
|
|
48
|
+
@Volatile private var configuredOtaVersionUrl: String? = null
|
|
48
49
|
|
|
49
50
|
init {
|
|
50
51
|
listeners.add(listener)
|
|
@@ -68,6 +69,9 @@ class MentraBluetoothSdk private constructor(
|
|
|
68
69
|
private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
|
|
69
70
|
private const val STREAM_START_TIMEOUT_MS = 30_000L
|
|
70
71
|
private const val STREAM_STOP_TIMEOUT_MS = 15_000L
|
|
72
|
+
private const val OTA_BES_VERSION_WAIT_MS = 5_000L
|
|
73
|
+
private const val OTA_MTK_VERSION_WAIT_MS = 2_000L
|
|
74
|
+
private const val OTA_VERSION_POLL_MS = 100L
|
|
71
75
|
private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
|
|
72
76
|
private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
|
|
73
77
|
|
|
@@ -470,16 +474,59 @@ class MentraBluetoothSdk private constructor(
|
|
|
470
474
|
DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
|
|
471
475
|
}
|
|
472
476
|
|
|
473
|
-
fun setButtonPhotoSettings(
|
|
477
|
+
fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
|
|
474
478
|
performSettingsCommand(
|
|
475
479
|
setting = "button_photo",
|
|
476
|
-
updateStore = { _ ->
|
|
477
|
-
|
|
480
|
+
updateStore = { _ ->
|
|
481
|
+
if (settings.resetCaptureTuning) {
|
|
482
|
+
// Clear all stored scan-tuning keys from phone cache
|
|
483
|
+
listOf(
|
|
484
|
+
"button_photo_mfnr",
|
|
485
|
+
"button_photo_zsl",
|
|
486
|
+
"button_photo_noise_reduction",
|
|
487
|
+
"button_photo_edge_enhancement",
|
|
488
|
+
"button_photo_isp_digital_gain",
|
|
489
|
+
"button_photo_isp_analog_gain",
|
|
490
|
+
"button_photo_ae_exposure_divisor",
|
|
491
|
+
"button_photo_iso_cap",
|
|
492
|
+
"button_photo_compress",
|
|
493
|
+
"button_photo_sound",
|
|
494
|
+
).forEach { key ->
|
|
495
|
+
DeviceStore.store.remove(ObservableStore.BLUETOOTH_CATEGORY, key)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// Only update size if explicitly provided; omitted size = leave stored value unchanged
|
|
499
|
+
settings.size?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", it.value) }
|
|
500
|
+
settings.mfnr?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_mfnr", it) }
|
|
501
|
+
settings.zsl?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl", it) }
|
|
502
|
+
settings.noiseReduction?.let {
|
|
503
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_noise_reduction", it)
|
|
504
|
+
}
|
|
505
|
+
settings.edgeEnhancement?.let {
|
|
506
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_edge_enhancement", it)
|
|
507
|
+
}
|
|
508
|
+
settings.ispDigitalGain?.let {
|
|
509
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_digital_gain", it)
|
|
510
|
+
}
|
|
511
|
+
settings.ispAnalogGain?.let {
|
|
512
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_analog_gain", it)
|
|
513
|
+
}
|
|
514
|
+
settings.aeExposureDivisor?.let {
|
|
515
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_ae_exposure_divisor", it)
|
|
516
|
+
}
|
|
517
|
+
settings.isoCap?.let {
|
|
518
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_iso_cap", it)
|
|
519
|
+
}
|
|
520
|
+
settings.compress?.let {
|
|
521
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it)
|
|
522
|
+
}
|
|
523
|
+
settings.sound?.let {
|
|
524
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_sound", it)
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
|
|
478
528
|
)
|
|
479
529
|
|
|
480
|
-
fun setButtonPhotoSettings(settings: ButtonPhotoSettings): SettingsAckEvent =
|
|
481
|
-
setButtonPhotoSettings(size = settings.size)
|
|
482
|
-
|
|
483
530
|
fun setButtonVideoRecordingSettings(width: Int, height: Int, fps: Int): SettingsAckEvent =
|
|
484
531
|
performSettingsCommand(
|
|
485
532
|
setting = "button_video_recording",
|
|
@@ -677,19 +724,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
677
724
|
val pending = PendingResponse<PhotoResponseEvent>("photo request ${request.requestId}")
|
|
678
725
|
pendingPhotoRequests[request.requestId] = pending
|
|
679
726
|
try {
|
|
680
|
-
deviceManager.requestPhoto(
|
|
681
|
-
request.requestId,
|
|
682
|
-
request.appId,
|
|
683
|
-
request.size.value,
|
|
684
|
-
request.webhookUrl,
|
|
685
|
-
request.authToken,
|
|
686
|
-
request.compress.value,
|
|
687
|
-
request.flash,
|
|
688
|
-
request.save,
|
|
689
|
-
request.sound,
|
|
690
|
-
request.exposureTimeNs,
|
|
691
|
-
request.iso,
|
|
692
|
-
)
|
|
727
|
+
deviceManager.requestPhoto(request)
|
|
693
728
|
return pending.await()
|
|
694
729
|
} finally {
|
|
695
730
|
pendingPhotoRequests.remove(request.requestId, pending)
|
|
@@ -871,8 +906,41 @@ class MentraBluetoothSdk private constructor(
|
|
|
871
906
|
}
|
|
872
907
|
}
|
|
873
908
|
|
|
874
|
-
|
|
875
|
-
|
|
909
|
+
internal fun setOtaVersionUrl(otaVersionUrl: String) {
|
|
910
|
+
configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
|
|
914
|
+
|
|
915
|
+
/** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
|
|
916
|
+
fun checkForOtaUpdate(): Boolean {
|
|
917
|
+
val status = getFreshGlassesStatus()
|
|
918
|
+
if (!status.connected) {
|
|
919
|
+
throw BluetoothException(
|
|
920
|
+
"glasses_not_connected",
|
|
921
|
+
"Cannot check OTA update because glasses are not connected.",
|
|
922
|
+
)
|
|
923
|
+
}
|
|
924
|
+
if (status.buildNumber.isBlank()) {
|
|
925
|
+
throw BluetoothException(
|
|
926
|
+
"missing_glasses_version",
|
|
927
|
+
"Cannot check OTA update because glasses build number is unavailable.",
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
val manifestUrl = resolveOtaVersionUrl(status)
|
|
932
|
+
val manifest = OtaManifestChecker.fetch(manifestUrl)
|
|
933
|
+
val otaStatus = waitForOtaManifestStatus(status, manifest)
|
|
934
|
+
return OtaManifestChecker.hasUpdate(
|
|
935
|
+
otaStatus.buildNumber,
|
|
936
|
+
otaStatus.mtkFirmwareVersion,
|
|
937
|
+
otaStatus.besFirmwareVersion,
|
|
938
|
+
manifest,
|
|
939
|
+
)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Ask connected Mentra Live glasses to report the current OTA install/session status. */
|
|
943
|
+
private fun queryOtaStatus(): OtaQueryResult =
|
|
876
944
|
performOtaQuery("OTA status query") {
|
|
877
945
|
deviceManager.sendOtaQueryStatus()
|
|
878
946
|
}
|
|
@@ -905,6 +973,11 @@ class MentraBluetoothSdk private constructor(
|
|
|
905
973
|
|
|
906
974
|
/** Start the OTA flow after your app has presented the available update to the user. */
|
|
907
975
|
fun startOtaUpdate(): OtaStartAckEvent {
|
|
976
|
+
val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
|
|
977
|
+
return startOtaUpdate(otaVersionUrl)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
|
|
908
981
|
val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
|
|
909
982
|
synchronized(oneShotLock) {
|
|
910
983
|
if (pendingOtaStart != null) {
|
|
@@ -916,7 +989,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
916
989
|
pendingOtaStart = pending
|
|
917
990
|
}
|
|
918
991
|
try {
|
|
919
|
-
deviceManager.sendOtaStart()
|
|
992
|
+
deviceManager.sendOtaStart(otaVersionUrl)
|
|
920
993
|
return pending.await()
|
|
921
994
|
} finally {
|
|
922
995
|
synchronized(oneShotLock) {
|
|
@@ -927,15 +1000,98 @@ class MentraBluetoothSdk private constructor(
|
|
|
927
1000
|
}
|
|
928
1001
|
}
|
|
929
1002
|
|
|
930
|
-
|
|
931
|
-
|
|
1003
|
+
internal fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
|
|
1004
|
+
startOtaCommand(otaVersionUrl)
|
|
1005
|
+
|
|
1006
|
+
internal fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
|
|
1007
|
+
|
|
1008
|
+
/** Re-run the glasses-side OTA version check after an internal clock-skew recovery. */
|
|
1009
|
+
@JvmSynthetic
|
|
1010
|
+
internal fun retryOtaVersionCheck(): OtaQueryResult =
|
|
932
1011
|
performOtaQuery("OTA version retry") {
|
|
933
1012
|
deviceManager.retryOtaVersionCheck()
|
|
934
1013
|
}
|
|
935
1014
|
|
|
936
|
-
|
|
1015
|
+
private fun getFreshGlassesStatus(): GlassesStatus {
|
|
1016
|
+
val status = getRawGlassesStatus()
|
|
1017
|
+
if (!status.connected || status.buildNumber.isNotBlank()) {
|
|
1018
|
+
return status
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return try {
|
|
1022
|
+
val versionInfo = requestVersionInfo()
|
|
1023
|
+
status.copy(
|
|
1024
|
+
androidVersion = versionInfo.androidVersion.ifBlank { status.androidVersion },
|
|
1025
|
+
firmwareVersion = versionInfo.firmwareVersion.ifBlank { status.firmwareVersion },
|
|
1026
|
+
besFirmwareVersion = versionInfo.besFirmwareVersion.ifBlank { status.besFirmwareVersion },
|
|
1027
|
+
mtkFirmwareVersion = versionInfo.mtkFirmwareVersion.ifBlank { status.mtkFirmwareVersion },
|
|
1028
|
+
buildNumber = versionInfo.buildNumber.ifBlank { status.buildNumber },
|
|
1029
|
+
systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
|
|
1030
|
+
otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
|
|
1031
|
+
appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
|
|
1032
|
+
)
|
|
1033
|
+
} catch (_: Throwable) {
|
|
1034
|
+
status
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private fun waitForOtaManifestStatus(
|
|
1039
|
+
initialStatus: GlassesStatus,
|
|
1040
|
+
manifest: org.json.JSONObject,
|
|
1041
|
+
): GlassesStatus {
|
|
1042
|
+
var status = initialStatus
|
|
1043
|
+
if (OtaManifestChecker.hasBesFirmware(manifest) && status.besFirmwareVersion.isBlank()) {
|
|
1044
|
+
status = waitForGlassesStatus(status, OTA_BES_VERSION_WAIT_MS) {
|
|
1045
|
+
!it.connected || it.besFirmwareVersion.isNotBlank()
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (OtaManifestChecker.hasMtkPatches(manifest) && status.mtkFirmwareVersion.isBlank()) {
|
|
1050
|
+
status = waitForGlassesStatus(status, OTA_MTK_VERSION_WAIT_MS) {
|
|
1051
|
+
!it.connected || it.mtkFirmwareVersion.isNotBlank()
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (!status.connected) {
|
|
1056
|
+
throw BluetoothException(
|
|
1057
|
+
"glasses_not_connected",
|
|
1058
|
+
"Cannot check OTA update because glasses disconnected.",
|
|
1059
|
+
)
|
|
1060
|
+
}
|
|
1061
|
+
return status
|
|
1062
|
+
}
|
|
937
1063
|
|
|
938
|
-
|
|
1064
|
+
private fun waitForGlassesStatus(
|
|
1065
|
+
initialStatus: GlassesStatus,
|
|
1066
|
+
timeoutMs: Long,
|
|
1067
|
+
isReady: (GlassesStatus) -> Boolean,
|
|
1068
|
+
): GlassesStatus {
|
|
1069
|
+
val deadline = System.currentTimeMillis() + timeoutMs
|
|
1070
|
+
var status = initialStatus
|
|
1071
|
+
while (System.currentTimeMillis() < deadline) {
|
|
1072
|
+
status = getRawGlassesStatus()
|
|
1073
|
+
if (isReady(status)) return status
|
|
1074
|
+
val remainingMs = deadline - System.currentTimeMillis()
|
|
1075
|
+
if (remainingMs <= 0L) break
|
|
1076
|
+
Thread.sleep(minOf(OTA_VERSION_POLL_MS, remainingMs))
|
|
1077
|
+
}
|
|
1078
|
+
return getRawGlassesStatus()
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
private fun resolveOtaVersionUrl(status: GlassesStatus): String {
|
|
1082
|
+
val deviceUrl = status.otaVersionUrl.trim()
|
|
1083
|
+
if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
|
|
1084
|
+
return deviceUrl.ifBlank { OtaManifestDefaults.PROD_OTA_VERSION_URL }
|
|
1085
|
+
}
|
|
1086
|
+
// SDK consumers are pinned to the manifest built for their SDK version.
|
|
1087
|
+
// A future glasses-advertised URL should not silently change that pairing.
|
|
1088
|
+
return configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
|
|
1092
|
+
val parsed = buildNumber.toIntOrNull()
|
|
1093
|
+
return parsed != null && parsed < 100_000
|
|
1094
|
+
}
|
|
939
1095
|
|
|
940
1096
|
internal fun sendShutdown() {
|
|
941
1097
|
deviceManager.sendShutdown()
|
|
@@ -1333,6 +1489,14 @@ class MentraBluetoothSdk private constructor(
|
|
|
1333
1489
|
return streamId to pending
|
|
1334
1490
|
}
|
|
1335
1491
|
if (pendingStreamStarts.size == 1) {
|
|
1492
|
+
// A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
|
|
1493
|
+
// stream (their stop ack carries no streamId), not a verdict on the
|
|
1494
|
+
// pending start — a start_stream that replaces a running publisher
|
|
1495
|
+
// emits exactly this sequence (stopped -> initializing -> streaming).
|
|
1496
|
+
// Attributing it here would reject a start that is about to succeed.
|
|
1497
|
+
if (event.state == StreamState.STOPPED) {
|
|
1498
|
+
return null
|
|
1499
|
+
}
|
|
1336
1500
|
val entry = pendingStreamStarts.entries.first()
|
|
1337
1501
|
return entry.key to entry.value
|
|
1338
1502
|
}
|
|
@@ -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
|
|