@mentra/bluetooth-sdk 3.2.0-dev.233 → 3.2.0-dev.245
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 +48 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -23
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +44 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +33 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +14 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +31 -6
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +32 -0
- package/build/BluetoothSdk.types.d.ts +14 -6
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +5 -1
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +3 -0
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +4 -0
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6 -1
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +1 -1
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +52 -25
- package/ios/Source/camera/CameraModels.swift +29 -15
- package/ios/Source/sgcs/MentraLive.swift +35 -4
- package/ios/Tests/PhotoCompressionTests.swift +21 -0
- package/package.json +4 -2
- package/src/BluetoothSdk.types.ts +15 -4
- package/src/_private/photoRequestPayload.ts +5 -1
- package/src/generated/changelogCatalog.ts +4 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +6 -1
package/README.md
CHANGED
|
@@ -583,13 +583,34 @@ const photo = await BluetoothSdk.requestPhoto({
|
|
|
583
583
|
console.log("photo delivered", photo.photoUrl ?? photo.uploadUrl, photo.fileSizeBytes)
|
|
584
584
|
```
|
|
585
585
|
|
|
586
|
-
`requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives within 30 seconds. Photo requests use this longer operation-specific deadline because max-quality BLE fallback can legitimately exceed the 15-second deadline used by ordinary commands. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. The webhook should accept multipart form data with a `photo` file and `requestId`. If `authToken` is provided, the uploader adds `Authorization: Bearer <token>`. The camera light is always enabled for photo capture.
|
|
586
|
+
`requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives within 30 seconds (110 seconds when `presend_thumbnail` is enabled). Photo requests use this longer operation-specific deadline because max-quality BLE fallback can legitimately exceed the 15-second deadline used by ordinary commands. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. The webhook should accept multipart form data with a `photo` file and `requestId`. If `authToken` is provided, the uploader adds `Authorization: Bearer <token>`. The camera light is always enabled for photo capture.
|
|
587
|
+
|
|
588
|
+
Set `presend_thumbnail: true` to receive a JPEG preview over Bluetooth before the
|
|
589
|
+
full photo (default: `false`). Listen for `photo_status` with
|
|
590
|
+
`status: "thumbnail_received"`; `thumbnailUrl` is a JPEG data URI and
|
|
591
|
+
`fileSizeBytes` is the preview size. The preview preserves aspect ratio, is at
|
|
592
|
+
most 500 pixels on its longest edge, never upscales, and uses JPEG quality 50.
|
|
593
|
+
Preview pixels match the full delivered image's display orientation, including
|
|
594
|
+
EXIF rotation/mirroring on direct uploads; no EXIF-aware preview renderer is needed.
|
|
595
|
+
It does not resolve `requestPhoto()` or replace the full webhook delivery.
|
|
596
|
+
The opted-in end-to-end deadline is 110 seconds: capture (45) + preview
|
|
597
|
+
transfer/retries and ACK (30) + full delivery (30) + response transit margin (5).
|
|
598
|
+
Both native SDK facades mirror ASG's constants; the glasses job watchdog is 105
|
|
599
|
+
seconds. Progress and preview ACK never restart these timers.
|
|
600
|
+
An upload target is required; save-only capture does not support previews.
|
|
601
|
+
Compatible ASG firmware is required; older firmware may ignore this option.
|
|
602
|
+
|
|
603
|
+
On the BLE delivery path, full-image encoding overlaps preview transmission,
|
|
604
|
+
but the full image is sent only after the phone acknowledges the preview.
|
|
605
|
+
Preview send failure or a missing acknowledgement fails the request rather than
|
|
606
|
+
starting a competing transfer. ASG timing logs use `BlePhotoTiming`; thumbnail
|
|
607
|
+
transmission and CPU processing overlap, so their durations are not additive.
|
|
587
608
|
|
|
588
609
|
For one-shot manual capture tuning, pass `exposureTimeNs` and `iso` together. `exposureTimeNs` is sensor exposure time in nanoseconds; `iso` is sensor ISO. If `exposureTimeNs` is omitted, `null`, invalid, or unsupported by the connected glasses, the camera uses auto exposure and ignores `iso`.
|
|
589
610
|
|
|
590
|
-
To own and explicitly release a warm camera, pass a request ID to `warmUpCamera({requestId, ...})`, then call `stopCameraWarmUp(requestId)` when the foreground UI closes. Warm holds default to 15 seconds and are capped at
|
|
611
|
+
To own and explicitly release a warm camera, pass a request ID to `warmUpCamera({requestId, ...})`, then call `stopCameraWarmUp(requestId)` when the foreground UI closes. Warm holds default to 15 seconds and are capped at 5 minutes. Stopping while the camera is opening rejects the pending warm-up with `camera_warm_up_cancelled`; stopping after `ready` emits `stopped`. Compatible ready leases share the camera and expire independently.
|
|
591
612
|
|
|
592
|
-
Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 62-118 degrees; ROI position is `"center"`, `"bottom"`, or `"top"`. You can also call `setCameraFov({preset: "narrow" | "standard" | "wide"})`; presets map to 82, 102, and 118 degrees with center ROI. The returned `CameraFovResult` resolves only after the ASG client reports that the setting was applied to camera hardware after the restart cooldown, and the promise rejects if the glasses report an error, persist the setting without hardware application, or time out. Raw `settings_ack` events remain available through `addListener("settings_ack", ...)` for diagnostic fields such as `hardwareApplied`. Treat FOV as a framing/ROI control; output resolution and effective detail can vary by capture path, firmware, and camera mode.
|
|
613
|
+
Use `setCameraFov({fov, roiPosition})` to configure Mentra Live camera field of view and crop position. FOV is clamped to 62-118 degrees; ROI position is `"center"`, `"bottom"`, or `"top"`. You can also call `setCameraFov({preset: "narrow" | "standard" | "wide"})`; presets map to 82, 102, and 118 degrees with center ROI. FOV calls use a 45-second deadline covering one active and one coalesced pending update on ASG, including camera re-registration. Replaced pending commands reject with `fov_superseded`. The returned `CameraFovResult` resolves only after the ASG client reports that the setting was applied to camera hardware after the restart cooldown, and the promise rejects if the glasses report an error, persist the setting without hardware application, or time out. Raw `settings_ack` events remain available through `addListener("settings_ack", ...)` for diagnostic fields such as `hardwareApplied`. Treat FOV as a framing/ROI control; output resolution and effective detail can vary by capture path, firmware, and camera mode.
|
|
593
614
|
|
|
594
615
|
The missing/factory persistent FOV base is 102 degrees with centered ROI; existing saved values are not migrated. Use `setCameraFovOverride({leaseId, fov, roiPosition, ttlMs})` for a memory-only crop and `releaseCameraFovOverride(leaseId)` to restore the persistent base. A stale release is a safe no-op, and refreshing the same lease/config extends its TTL without another HAL restart.
|
|
595
616
|
|
|
@@ -726,3 +747,27 @@ secrets, not in the repository.
|
|
|
726
747
|
## Starter Example App
|
|
727
748
|
|
|
728
749
|
The [Mentra Bluetooth SDK Starter Kit](https://github.com/Mentra-Community/Mentra-Bluetooth-SDK-Starter-Kit) includes starter example apps for Android, iOS, and React Native / Expo. The React Native starter demonstrates scan/connect, display, camera photo upload, RTMP/SRT/WebRTC streaming, Wi-Fi/hotspot, microphone PCM, RGB LED, gallery mode, and console event inspection.
|
|
750
|
+
|
|
751
|
+
BLE JPEG photo compression supports `none` (Q95), `low` (Q88), `medium` (Q78), and `high` (Q60) on updated glasses firmware. Omitted compression defaults to `none`. Values are sent unchanged and invalid values are rejected. Pixel limits are controlled separately by `size`.
|
|
752
|
+
|
|
753
|
+
### Breaking change: photo compression
|
|
754
|
+
|
|
755
|
+
Photo compression now accepts exactly `none`, `low`, `medium`, and `high`, sent
|
|
756
|
+
unchanged across the Mentra Miniapp SDK, Bluetooth SDKs, and glasses.
|
|
757
|
+
The default is `none` everywhere. Glasses encode these as JPEG Q95, Q88, Q78,
|
|
758
|
+
and Q60 respectively; `size` controls dimensions separately. `none` still uses
|
|
759
|
+
lossy JPEG encoding. Unknown values are rejected.
|
|
760
|
+
|
|
761
|
+
Cloud only allocates upload/download URLs: it takes no photo options and ignores
|
|
762
|
+
any request body. Photo compression is handled by the phone and glasses.
|
|
763
|
+
|
|
764
|
+
The former `heavy` spelling is removed; callers must use `high`. Android's native
|
|
765
|
+
default changes from `medium` to `none`. Existing miniapps may therefore produce
|
|
766
|
+
different JPEG quality or payload sizes. There are no compatibility aliases or wire translations.
|
|
767
|
+
|
|
768
|
+
Photo compression omission is distinct from an explicit `null`: requests default
|
|
769
|
+
omission to `none`, and partial preset updates leave an omitted compression field
|
|
770
|
+
unchanged. A supplied `null` or other invalid value is rejected. Stored presets
|
|
771
|
+
containing removed values are invalid; replace their compression with one of the
|
|
772
|
+
four supported values before replaying the complete preset. There is no automatic
|
|
773
|
+
migration or substitution. Fresh valid preset updates remain available.
|
|
@@ -643,7 +643,7 @@ class BluetoothSdkModule : Module() {
|
|
|
643
643
|
|
|
644
644
|
@Suppress("DEPRECATION")
|
|
645
645
|
SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
|
|
646
|
-
requireSdk().setPhotoCaptureDefaults(
|
|
646
|
+
requireSdk().setPhotoCaptureDefaults(PhotoCaptureDefaults.fromMap(params)).values
|
|
647
647
|
}
|
|
648
648
|
|
|
649
649
|
SdkCoroutineFunction("setVideoRecordingDefaults") { width: Int, height: Int, fps: Int ->
|
|
@@ -698,12 +698,7 @@ class BluetoothSdkModule : Module() {
|
|
|
698
698
|
SdkCoroutineFunction("queryGalleryStatus") { -> requireSdk().queryGalleryStatus().values }
|
|
699
699
|
|
|
700
700
|
SdkCoroutineFunction("requestPhoto") { params: Map<String, Any?> ->
|
|
701
|
-
|
|
702
|
-
val sanitized =
|
|
703
|
-
params.mapNotNull { (key, value) ->
|
|
704
|
-
if (value == null) null else key to value
|
|
705
|
-
}.toMap()
|
|
706
|
-
val req = PhotoRequest.fromMap(sanitized)
|
|
701
|
+
val req = PhotoRequest.fromMap(params)
|
|
707
702
|
Bridge.log(
|
|
708
703
|
"NATIVE: PHOTO PIPELINE [3/6] BluetoothSdk.requestPhoto requestId=${req.requestId} size=${req.size} mode=${req.mode.value} compress=${req.compress} sound=${req.sound} exposureTimeNs=${req.exposureTimeNs} iso=${req.iso}"
|
|
709
704
|
)
|
|
@@ -1044,22 +1039,6 @@ private fun Map<String, Any>?.toMentraDevice(): Device? {
|
|
|
1044
1039
|
)
|
|
1045
1040
|
}
|
|
1046
1041
|
|
|
1047
|
-
private fun Map<String, Any?>.toPhotoCaptureDefaults(): PhotoCaptureDefaults =
|
|
1048
|
-
PhotoCaptureDefaults(
|
|
1049
|
-
size = (this["size"] as? String)?.let { PhotoSize.fromValue(it) },
|
|
1050
|
-
mfnr = this["mfnr"] as? Boolean,
|
|
1051
|
-
zsl = this["zsl"] as? Boolean,
|
|
1052
|
-
noiseReduction = this["noiseReduction"] as? Boolean,
|
|
1053
|
-
edgeEnhancement = this["edgeEnhancement"] as? Boolean,
|
|
1054
|
-
ispDigitalGain = (this["ispDigitalGain"] as? Number)?.toInt(),
|
|
1055
|
-
ispAnalogGain = this["ispAnalogGain"] as? String,
|
|
1056
|
-
aeExposureDivisor = (this["aeExposureDivisor"] as? Number)?.toInt(),
|
|
1057
|
-
isoCap = (this["isoCap"] as? Number)?.toInt(),
|
|
1058
|
-
compress = this["compress"] as? String,
|
|
1059
|
-
sound = this["sound"] as? Boolean,
|
|
1060
|
-
resetCaptureTuning = this["resetCaptureTuning"] as? Boolean == true,
|
|
1061
|
-
)
|
|
1062
|
-
|
|
1063
1042
|
private fun Map<String, Any>?.toMentraConnectOptions(): ConnectOptions {
|
|
1064
1043
|
val values = this ?: return ConnectOptions()
|
|
1065
1044
|
return ConnectOptions(
|
|
@@ -4,5 +4,6 @@ package com.mentra.bluetoothsdk
|
|
|
4
4
|
internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
|
|
5
5
|
listOf(
|
|
6
6
|
ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
|
|
7
|
+
ReleaseChangelog(version = "3.1.1", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
|
|
7
8
|
ReleaseChangelog(version = "3.1.0", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
|
|
8
9
|
)
|
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.2.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.0-dev.245"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.245"
|
|
8
|
+
const val SOURCE_COMMIT: String = "979b6f01635a92d0061160e9007ccb8be313b06e"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.245.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "b9e030e64254b2d39e89a4e446032ec2d996702a5f248d71a5bad390f99ef914"
|
|
11
11
|
}
|
|
@@ -19,6 +19,8 @@ import java.util.concurrent.atomic.AtomicLong
|
|
|
19
19
|
import kotlinx.coroutines.CancellationException
|
|
20
20
|
import kotlinx.coroutines.CompletableDeferred
|
|
21
21
|
import kotlinx.coroutines.delay
|
|
22
|
+
import kotlinx.coroutines.sync.Mutex
|
|
23
|
+
import kotlinx.coroutines.sync.withLock
|
|
22
24
|
import kotlinx.coroutines.withTimeoutOrNull
|
|
23
25
|
|
|
24
26
|
class MentraBluetoothSdk private constructor(
|
|
@@ -73,6 +75,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
73
75
|
private var pendingWifiStatus: PendingWifiStatusRequest? = null
|
|
74
76
|
private var pendingWifiForget: PendingWifiForgetRequest? = null
|
|
75
77
|
private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
|
|
78
|
+
private val hotspotMutex = Mutex()
|
|
76
79
|
private var pendingVersionInfo: PendingVersionInfoRequest? = null
|
|
77
80
|
private val wifiSessionCapabilities = WifiSessionCapabilities()
|
|
78
81
|
@Volatile private var configuredOtaVersionUrl: String? = null
|
|
@@ -100,9 +103,24 @@ class MentraBluetoothSdk private constructor(
|
|
|
100
103
|
private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
|
|
101
104
|
private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
|
|
102
105
|
private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
|
|
106
|
+
// Mirrors AsgConstants: one active + one coalesced pending FOV update, plus BLE margin.
|
|
107
|
+
// Keep in sync with ios/Source/MentraBluetoothSDK.swift and the ASG readiness budget.
|
|
108
|
+
private const val CAMERA_FOV_READY_TIMEOUT_MS = 20_000L
|
|
109
|
+
private const val CAMERA_FOV_DELIVERY_MARGIN_MS = 5_000L
|
|
110
|
+
private const val CAMERA_FOV_REQUEST_TIMEOUT_MS =
|
|
111
|
+
2 * CAMERA_FOV_READY_TIMEOUT_MS + CAMERA_FOV_DELIVERY_MARGIN_MS
|
|
103
112
|
// A photo response is terminal only after capture, encoding, transport, and upload.
|
|
104
113
|
// Max-quality BLE fallback can legitimately exceed the generic command deadline.
|
|
105
114
|
private const val PHOTO_REQUEST_TIMEOUT_MS = 30_000L
|
|
115
|
+
// Mirrors AsgConstants.PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS and the iOS facade.
|
|
116
|
+
// Reserve capture, preview ACK/retries, full delivery, then terminal-event transit.
|
|
117
|
+
private const val PHOTO_CAPTURE_TIMEOUT_MS = 45_000L
|
|
118
|
+
private const val PHOTO_THUMBNAIL_TIMEOUT_SECONDS = 30L
|
|
119
|
+
private const val PHOTO_DELIVERY_TIMEOUT_MS = 30_000L
|
|
120
|
+
private const val PHOTO_RESPONSE_MARGIN_MS = 5_000L
|
|
121
|
+
private const val PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS =
|
|
122
|
+
PHOTO_CAPTURE_TIMEOUT_MS + PHOTO_THUMBNAIL_TIMEOUT_SECONDS * 1000L +
|
|
123
|
+
PHOTO_DELIVERY_TIMEOUT_MS + PHOTO_RESPONSE_MARGIN_MS
|
|
106
124
|
private const val WIFI_SCAN_TIMEOUT_MS = 20_000L
|
|
107
125
|
private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
|
|
108
126
|
private const val STREAM_START_TIMEOUT_MS = 30_000L
|
|
@@ -597,7 +615,12 @@ class MentraBluetoothSdk private constructor(
|
|
|
597
615
|
pendingSettingsRequests[requestId] = pending
|
|
598
616
|
try {
|
|
599
617
|
send(requestId)
|
|
600
|
-
val
|
|
618
|
+
val timeoutMs = if (setting == "camera_fov" || setting == "camera_fov_override") {
|
|
619
|
+
CAMERA_FOV_REQUEST_TIMEOUT_MS
|
|
620
|
+
} else {
|
|
621
|
+
DEFAULT_REQUEST_TIMEOUT_MS
|
|
622
|
+
}
|
|
623
|
+
val ack = pending.await(timeoutMs)
|
|
601
624
|
updateStore(ack)
|
|
602
625
|
return ack
|
|
603
626
|
} finally {
|
|
@@ -664,7 +687,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
664
687
|
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_iso_cap", it)
|
|
665
688
|
}
|
|
666
689
|
settings.compress?.let {
|
|
667
|
-
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it)
|
|
690
|
+
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it.value)
|
|
668
691
|
}
|
|
669
692
|
settings.sound?.let {
|
|
670
693
|
DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_sound", it)
|
|
@@ -1010,23 +1033,23 @@ class MentraBluetoothSdk private constructor(
|
|
|
1010
1033
|
}
|
|
1011
1034
|
|
|
1012
1035
|
suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
)
|
|
1020
|
-
}
|
|
1021
|
-
pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
|
|
1022
|
-
}
|
|
1023
|
-
try {
|
|
1024
|
-
deviceManager.setHotspotState(enabled)
|
|
1025
|
-
return pending.await()
|
|
1026
|
-
} finally {
|
|
1036
|
+
// SoftAP teardown disables from the transport and again from the host
|
|
1037
|
+
// barrier. Throwing request_in_flight on the second disable recorded a
|
|
1038
|
+
// cleanup error that refused the next join. Queue instead: same-state
|
|
1039
|
+
// and opposite-state both wait their turn, then send.
|
|
1040
|
+
return hotspotMutex.withLock {
|
|
1041
|
+
val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
|
|
1027
1042
|
synchronized(oneShotLock) {
|
|
1028
|
-
|
|
1029
|
-
|
|
1043
|
+
pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
|
|
1044
|
+
}
|
|
1045
|
+
try {
|
|
1046
|
+
deviceManager.setHotspotState(enabled)
|
|
1047
|
+
pending.await()
|
|
1048
|
+
} finally {
|
|
1049
|
+
synchronized(oneShotLock) {
|
|
1050
|
+
if (pendingHotspotStatus?.pending === pending) {
|
|
1051
|
+
pendingHotspotStatus = null
|
|
1052
|
+
}
|
|
1030
1053
|
}
|
|
1031
1054
|
}
|
|
1032
1055
|
}
|
|
@@ -1052,7 +1075,9 @@ class MentraBluetoothSdk private constructor(
|
|
|
1052
1075
|
pendingPhotoRequests[routedRequest.requestId] = pending
|
|
1053
1076
|
try {
|
|
1054
1077
|
deviceManager.requestPhoto(routedRequest)
|
|
1055
|
-
return pending.await(
|
|
1078
|
+
return pending.await(
|
|
1079
|
+
if (routedRequest.presendThumbnail) PHOTO_THUMBNAIL_REQUEST_TIMEOUT_MS else PHOTO_REQUEST_TIMEOUT_MS,
|
|
1080
|
+
)
|
|
1056
1081
|
} finally {
|
|
1057
1082
|
pendingPhotoRequests.remove(routedRequest.requestId, pending)
|
|
1058
1083
|
}
|
|
@@ -53,13 +53,15 @@ enum class ButtonPhotoSize(val value: String) {
|
|
|
53
53
|
|
|
54
54
|
enum class PhotoCompression(val value: String) {
|
|
55
55
|
NONE("none"),
|
|
56
|
+
LOW("low"),
|
|
56
57
|
MEDIUM("medium"),
|
|
57
|
-
|
|
58
|
+
HIGH("high");
|
|
58
59
|
|
|
59
60
|
companion object {
|
|
60
61
|
@JvmStatic
|
|
61
|
-
fun fromValue(value:
|
|
62
|
-
values().firstOrNull { it.value == value }
|
|
62
|
+
fun fromValue(value: Any?): PhotoCompression =
|
|
63
|
+
values().firstOrNull { it.value == value }
|
|
64
|
+
?: throw IllegalArgumentException("Invalid photo compression $value. Expected none, low, medium, or high.")
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -73,10 +75,29 @@ data class PhotoCaptureDefaults(
|
|
|
73
75
|
val ispAnalogGain: String? = null,
|
|
74
76
|
val aeExposureDivisor: Int? = null,
|
|
75
77
|
val isoCap: Int? = null,
|
|
76
|
-
val compress:
|
|
78
|
+
val compress: PhotoCompression? = null,
|
|
77
79
|
val sound: Boolean? = null,
|
|
78
80
|
val resetCaptureTuning: Boolean = false,
|
|
79
|
-
)
|
|
81
|
+
) {
|
|
82
|
+
companion object {
|
|
83
|
+
@JvmStatic
|
|
84
|
+
fun fromMap(values: Map<String, Any?>): PhotoCaptureDefaults =
|
|
85
|
+
PhotoCaptureDefaults(
|
|
86
|
+
size = (values["size"] as? String)?.let { PhotoSize.fromValue(it) },
|
|
87
|
+
mfnr = values["mfnr"] as? Boolean,
|
|
88
|
+
zsl = values["zsl"] as? Boolean,
|
|
89
|
+
noiseReduction = values["noiseReduction"] as? Boolean,
|
|
90
|
+
edgeEnhancement = values["edgeEnhancement"] as? Boolean,
|
|
91
|
+
ispDigitalGain = (values["ispDigitalGain"] as? Number)?.toInt(),
|
|
92
|
+
ispAnalogGain = values["ispAnalogGain"] as? String,
|
|
93
|
+
aeExposureDivisor = (values["aeExposureDivisor"] as? Number)?.toInt(),
|
|
94
|
+
isoCap = (values["isoCap"] as? Number)?.toInt(),
|
|
95
|
+
compress = if (values.containsKey("compress")) PhotoCompression.fromValue(values["compress"]) else null,
|
|
96
|
+
sound = values["sound"] as? Boolean,
|
|
97
|
+
resetCaptureTuning = values["resetCaptureTuning"] as? Boolean == true,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
80
101
|
|
|
81
102
|
data class VideoRecordingDefaults(
|
|
82
103
|
val width: Int,
|
|
@@ -184,7 +205,7 @@ data class PhotoRequest @JvmOverloads constructor(
|
|
|
184
205
|
val size: PhotoSize,
|
|
185
206
|
val webhookUrl: String,
|
|
186
207
|
val authToken: String? = null,
|
|
187
|
-
val compress: PhotoCompression = PhotoCompression.
|
|
208
|
+
val compress: PhotoCompression = PhotoCompression.NONE,
|
|
188
209
|
val save: Boolean = false,
|
|
189
210
|
val sound: Boolean = true,
|
|
190
211
|
/** Sensor exposure time for this capture only (ns), or null for auto exposure */
|
|
@@ -203,6 +224,7 @@ data class PhotoRequest @JvmOverloads constructor(
|
|
|
203
224
|
val mode: PhotoMode = PhotoMode.PHOTO,
|
|
204
225
|
/** `direct` disables BLE fallback; `ble` skips direct upload; `auto` tries both. */
|
|
205
226
|
val transferMethod: String = "auto",
|
|
227
|
+
val presendThumbnail: Boolean = false,
|
|
206
228
|
) {
|
|
207
229
|
companion object {
|
|
208
230
|
private fun transferMethodFromValue(value: Any?): String {
|
|
@@ -219,7 +241,7 @@ data class PhotoRequest @JvmOverloads constructor(
|
|
|
219
241
|
|
|
220
242
|
/** Mirrors iOS `BluetoothSdkModule` defaults for keys omitted from the JS bridge. */
|
|
221
243
|
@JvmStatic
|
|
222
|
-
fun fromMap(values: Map<String, Any
|
|
244
|
+
fun fromMap(values: Map<String, Any?>): PhotoRequest {
|
|
223
245
|
val rawExp = values["exposureTimeNs"] ?: values["exposure_time_ns"]
|
|
224
246
|
val exposureTimeNs: Double? =
|
|
225
247
|
when (rawExp) {
|
|
@@ -249,11 +271,12 @@ data class PhotoRequest @JvmOverloads constructor(
|
|
|
249
271
|
size = PhotoSize.fromValue(stringValue(values, "size") ?: "medium"),
|
|
250
272
|
webhookUrl = stringValue(values, "webhookUrl", "webhook_url").orEmpty(),
|
|
251
273
|
authToken = stringValue(values, "authToken", "auth_token")?.takeIf { it.isNotBlank() },
|
|
252
|
-
compress = PhotoCompression.fromValue(
|
|
274
|
+
compress = if (values.containsKey("compress")) PhotoCompression.fromValue(values["compress"]) else PhotoCompression.NONE,
|
|
253
275
|
save = boolValue(values, "save", "saveToGallery") ?: false,
|
|
254
276
|
sound = boolValue(values, "sound") ?: true,
|
|
255
277
|
mode = PhotoMode.fromValue(stringValue(values, "mode")),
|
|
256
278
|
transferMethod = transferMethodFromValue(values["transferMethod"]),
|
|
279
|
+
presendThumbnail = boolValue(values, "presend_thumbnail") ?: false,
|
|
257
280
|
exposureTimeNs = exposureTimeNs,
|
|
258
281
|
iso = iso,
|
|
259
282
|
aeExposureDivisor = aeDivisor,
|
|
@@ -464,6 +487,8 @@ data class PhotoResponseEvent(
|
|
|
464
487
|
data class PhotoStatusEvent(
|
|
465
488
|
val values: Map<String, Any>,
|
|
466
489
|
) {
|
|
490
|
+
val thumbnailUrl: String? get() = stringValue(values, "thumbnailUrl")
|
|
491
|
+
val fileSizeBytes: Long? get() = longValue(values, "fileSizeBytes")
|
|
467
492
|
val requestId: String get() = stringValue(values, "requestId").orEmpty()
|
|
468
493
|
val status: String get() = stringValue(values, "status").orEmpty()
|
|
469
494
|
val timestamp: Long get() = longValue(values, "timestamp") ?: System.currentTimeMillis()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
package com.mentra.bluetoothsdk
|
|
2
2
|
|
|
3
3
|
internal fun numberValue(
|
|
4
|
-
values: Map<String, Any
|
|
4
|
+
values: Map<String, Any?>,
|
|
5
5
|
vararg keys: String,
|
|
6
6
|
): Int? =
|
|
7
7
|
keys.firstNotNullOfOrNull { key ->
|
|
@@ -9,7 +9,7 @@ internal fun numberValue(
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
internal fun doubleValue(
|
|
12
|
-
values: Map<String, Any
|
|
12
|
+
values: Map<String, Any?>,
|
|
13
13
|
vararg keys: String,
|
|
14
14
|
): Double? =
|
|
15
15
|
keys.firstNotNullOfOrNull { key ->
|
|
@@ -17,7 +17,7 @@ internal fun doubleValue(
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
internal fun stringValue(
|
|
20
|
-
values: Map<String, Any
|
|
20
|
+
values: Map<String, Any?>,
|
|
21
21
|
vararg keys: String,
|
|
22
22
|
): String? =
|
|
23
23
|
keys.firstNotNullOfOrNull { key ->
|
|
@@ -25,7 +25,7 @@ internal fun stringValue(
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
internal fun boolValue(
|
|
28
|
-
values: Map<String, Any
|
|
28
|
+
values: Map<String, Any?>,
|
|
29
29
|
vararg keys: String,
|
|
30
30
|
): Boolean? =
|
|
31
31
|
keys.firstNotNullOfOrNull { key ->
|
|
@@ -33,7 +33,7 @@ internal fun boolValue(
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
internal fun longValue(
|
|
36
|
-
values: Map<String, Any
|
|
36
|
+
values: Map<String, Any?>,
|
|
37
37
|
vararg keys: String,
|
|
38
38
|
): Long? =
|
|
39
39
|
keys.firstNotNullOfOrNull { key ->
|
|
@@ -41,7 +41,7 @@ internal fun longValue(
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
internal fun hasAnyKey(
|
|
44
|
-
values: Map<String, Any
|
|
44
|
+
values: Map<String, Any?>,
|
|
45
45
|
vararg keys: String,
|
|
46
46
|
): Boolean = keys.any(values::containsKey)
|
|
47
47
|
|
|
@@ -53,7 +53,7 @@ internal fun Map<*, *>.stringKeyedMap(): Map<String, Any> =
|
|
|
53
53
|
}.toMap()
|
|
54
54
|
|
|
55
55
|
internal fun optionalNumberValue(
|
|
56
|
-
values: Map<String, Any
|
|
56
|
+
values: Map<String, Any?>,
|
|
57
57
|
vararg keys: String,
|
|
58
58
|
): Int? =
|
|
59
59
|
if (hasAnyKey(values, *keys)) {
|
|
@@ -63,7 +63,7 @@ internal fun optionalNumberValue(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
internal fun optionalLongValue(
|
|
66
|
-
values: Map<String, Any
|
|
66
|
+
values: Map<String, Any?>,
|
|
67
67
|
vararg keys: String,
|
|
68
68
|
): Long? =
|
|
69
69
|
if (hasAnyKey(values, *keys)) {
|
|
@@ -73,7 +73,7 @@ internal fun optionalLongValue(
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
internal fun optionalStringValue(
|
|
76
|
-
values: Map<String, Any
|
|
76
|
+
values: Map<String, Any?>,
|
|
77
77
|
vararg keys: String,
|
|
78
78
|
): String? =
|
|
79
79
|
if (hasAnyKey(values, *keys)) {
|
|
@@ -83,7 +83,7 @@ internal fun optionalStringValue(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
internal fun optionalBoolValue(
|
|
86
|
-
values: Map<String, Any
|
|
86
|
+
values: Map<String, Any?>,
|
|
87
87
|
vararg keys: String,
|
|
88
88
|
): Boolean? =
|
|
89
89
|
if (hasAnyKey(values, *keys)) {
|
|
@@ -93,12 +93,12 @@ internal fun optionalBoolValue(
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
internal fun stringListValue(
|
|
96
|
-
values: Map<String, Any
|
|
96
|
+
values: Map<String, Any?>,
|
|
97
97
|
key: String,
|
|
98
98
|
): List<String> = (values[key] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
|
|
99
99
|
|
|
100
100
|
internal fun optionalStringListValue(
|
|
101
|
-
values: Map<String, Any
|
|
101
|
+
values: Map<String, Any?>,
|
|
102
102
|
key: String,
|
|
103
103
|
): List<String>? =
|
|
104
104
|
if (values.containsKey(key)) {
|
|
@@ -108,13 +108,13 @@ internal fun optionalStringListValue(
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
internal fun mapListValue(
|
|
111
|
-
values: Map<String, Any
|
|
111
|
+
values: Map<String, Any?>,
|
|
112
112
|
key: String,
|
|
113
113
|
): List<Map<String, Any>> =
|
|
114
114
|
(values[key] as? List<*>)?.mapNotNull(::stringMapValue) ?: emptyList()
|
|
115
115
|
|
|
116
116
|
internal fun optionalMapListValue(
|
|
117
|
-
values: Map<String, Any
|
|
117
|
+
values: Map<String, Any?>,
|
|
118
118
|
key: String,
|
|
119
119
|
): List<Map<String, Any>>? =
|
|
120
120
|
if (values.containsKey(key)) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.mentra.bluetoothsdk.sgcs
|
|
2
2
|
|
|
3
|
+
import com.mentra.bluetoothsdk.PhotoCompression
|
|
4
|
+
|
|
3
5
|
// Mentra
|
|
4
6
|
// old augmentos imports:
|
|
5
7
|
import android.Manifest
|
|
@@ -729,6 +731,7 @@ class MentraLive : SGCManager() {
|
|
|
729
731
|
var requestId: String,
|
|
730
732
|
var webhookUrl: String?
|
|
731
733
|
) {
|
|
734
|
+
var isThumbnail: Boolean = false
|
|
732
735
|
var authToken: String? = null
|
|
733
736
|
var session: FileTransferSession? = null
|
|
734
737
|
var phoneStartTime: Long = System.currentTimeMillis() // When phone received the request
|
|
@@ -5203,6 +5206,20 @@ class MentraLive : SGCManager() {
|
|
|
5203
5206
|
val bleImgId = json.optString("bleImgId", "")
|
|
5204
5207
|
val requestId = json.optString("requestId", "")
|
|
5205
5208
|
val compressionDurationMs = json.optLong("compressionDurationMs", 0)
|
|
5209
|
+
if (json.optBoolean("thumbnail", false) && bleImgId.isNotEmpty() && requestId.isNotEmpty()) {
|
|
5210
|
+
val parent = blePhotoTransfers.values.firstOrNull {
|
|
5211
|
+
!it.isThumbnail && it.requestId == requestId &&
|
|
5212
|
+
bleImgId == "T" + it.bleImgId.drop(1)
|
|
5213
|
+
}
|
|
5214
|
+
if (parent == null) {
|
|
5215
|
+
Log.w(TAG, "Ignoring thumbnail for an unknown photo request")
|
|
5216
|
+
return
|
|
5217
|
+
}
|
|
5218
|
+
// Duplicate ready messages must not erase packets already received.
|
|
5219
|
+
blePhotoTransfers.getOrPut(bleImgId) {
|
|
5220
|
+
BlePhotoTransfer(bleImgId, requestId, null).apply { isThumbnail = true }
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5206
5223
|
|
|
5207
5224
|
Bridge.log(
|
|
5208
5225
|
"LIVE: 📸 BLE photo ready notification: bleImgId=" +
|
|
@@ -6699,13 +6716,10 @@ class MentraLive : SGCManager() {
|
|
|
6699
6716
|
json.put("size", size)
|
|
6700
6717
|
}
|
|
6701
6718
|
json.put("mode", mode)
|
|
6702
|
-
|
|
6703
|
-
json.put("compress", compress)
|
|
6704
|
-
} else {
|
|
6705
|
-
json.put("compress", "none")
|
|
6706
|
-
}
|
|
6719
|
+
json.put("compress", compress)
|
|
6707
6720
|
json.put("save", save)
|
|
6708
6721
|
json.put("sound", sound)
|
|
6722
|
+
if (request.presendThumbnail) json.put("presend_thumbnail", true)
|
|
6709
6723
|
if (exposureTimeNs != null && exposureTimeNs > 0L) {
|
|
6710
6724
|
Bridge.log(
|
|
6711
6725
|
"LIVE: Using manual exposure time for photo request " +
|
|
@@ -7918,7 +7932,7 @@ class MentraLive : SGCManager() {
|
|
|
7918
7932
|
command.put("isoCap", isoCap)
|
|
7919
7933
|
}
|
|
7920
7934
|
if (!compress.isNullOrEmpty()) {
|
|
7921
|
-
command.put("compress", compress)
|
|
7935
|
+
command.put("compress", PhotoCompression.fromValue(compress).value)
|
|
7922
7936
|
}
|
|
7923
7937
|
if (sound != null) {
|
|
7924
7938
|
command.put("sound", sound)
|
|
@@ -10408,6 +10422,17 @@ class MentraLive : SGCManager() {
|
|
|
10408
10422
|
|
|
10409
10423
|
/** Process and upload a BLE photo transfer */
|
|
10410
10424
|
private fun processAndUploadBlePhoto(transfer: BlePhotoTransfer, imageData: ByteArray) {
|
|
10425
|
+
if (transfer.isThumbnail) {
|
|
10426
|
+
Bridge.sendPhotoStatus(mapOf(
|
|
10427
|
+
"type" to "photo_status",
|
|
10428
|
+
"requestId" to transfer.requestId,
|
|
10429
|
+
"status" to "thumbnail_received",
|
|
10430
|
+
"timestamp" to System.currentTimeMillis(),
|
|
10431
|
+
"thumbnailUrl" to ("data:image/jpeg;base64," + android.util.Base64.encodeToString(imageData, android.util.Base64.NO_WRAP)),
|
|
10432
|
+
"fileSizeBytes" to imageData.size,
|
|
10433
|
+
))
|
|
10434
|
+
return
|
|
10435
|
+
}
|
|
10411
10436
|
Bridge.log("LIVE: Processing BLE photo for upload. RequestId: " + transfer.requestId)
|
|
10412
10437
|
val uploadStartTime = System.currentTimeMillis()
|
|
10413
10438
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.mentra.bluetoothsdk.camera
|
|
2
2
|
|
|
3
|
+
import com.mentra.bluetoothsdk.PhotoCaptureDefaults
|
|
3
4
|
import com.mentra.bluetoothsdk.PhotoCompression
|
|
4
5
|
import com.mentra.bluetoothsdk.PhotoMode
|
|
5
6
|
import com.mentra.bluetoothsdk.PhotoRequest
|
|
@@ -9,6 +10,37 @@ import org.assertj.core.api.Assertions.assertThatThrownBy
|
|
|
9
10
|
import org.junit.Test
|
|
10
11
|
|
|
11
12
|
class PhotoRequestTest {
|
|
13
|
+
@Test
|
|
14
|
+
fun `compression has four exact wire values and defaults to none`() {
|
|
15
|
+
assertThat(PhotoCompression.values().map { it.value }).containsExactly("none", "low", "medium", "high")
|
|
16
|
+
for (compression in PhotoCompression.values()) {
|
|
17
|
+
assertThat(PhotoRequest.fromMap(mapOf("compress" to compression.value)).compress).isEqualTo(compression)
|
|
18
|
+
}
|
|
19
|
+
assertThat(PhotoRequest(size = PhotoSize.MEDIUM, webhookUrl = "https://example.com/upload", sound = true).compress).isEqualTo(PhotoCompression.NONE)
|
|
20
|
+
assertThat(PhotoRequest.fromMap(emptyMap()).compress).isEqualTo(PhotoCompression.NONE)
|
|
21
|
+
for (invalid in listOf("heavy", "", "HIGH", 1, false, null)) {
|
|
22
|
+
assertThatThrownBy { PhotoRequest.fromMap(mapOf("compress" to invalid)) }
|
|
23
|
+
.isInstanceOf(IllegalArgumentException::class.java)
|
|
24
|
+
assertThatThrownBy { PhotoCaptureDefaults.fromMap(mapOf("compress" to invalid)) }
|
|
25
|
+
.isInstanceOf(IllegalArgumentException::class.java)
|
|
26
|
+
}
|
|
27
|
+
assertThat(PhotoCaptureDefaults.fromMap(emptyMap()).compress).isNull()
|
|
28
|
+
for (compression in PhotoCompression.values()) {
|
|
29
|
+
assertThat(PhotoCaptureDefaults.fromMap(mapOf("compress" to compression.value)).compress).isEqualTo(compression)
|
|
30
|
+
}
|
|
31
|
+
// Nullable non-compression fields still follow the existing omission convention.
|
|
32
|
+
assertThat(PhotoRequest.fromMap(mapOf("authToken" to null)).compress).isEqualTo(PhotoCompression.NONE)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun `thumbnail is opt in and survives request routing copies`() {
|
|
37
|
+
val fields = mapOf("size" to "medium", "webhookUrl" to "https://example.com/upload")
|
|
38
|
+
assertThat(PhotoRequest.fromMap(fields).presendThumbnail).isFalse()
|
|
39
|
+
assertThat(PhotoRequest.fromMap(fields + ("presend_thumbnail" to false)).presendThumbnail).isFalse()
|
|
40
|
+
val request = PhotoRequest.fromMap(fields + ("presend_thumbnail" to true))
|
|
41
|
+
assertThat(request.copy(requestId = "routed").presendThumbnail).isTrue()
|
|
42
|
+
assertThat(request.transferMethod).isEqualTo("auto")
|
|
43
|
+
}
|
|
12
44
|
@Test
|
|
13
45
|
fun `constructor generates requestId when omitted`() {
|
|
14
46
|
val request =
|