@mentra/bluetooth-sdk 0.1.19 → 0.1.21-beta.0

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 (83) hide show
  1. package/README.md +6 -2
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +239 -52
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +47 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +6 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +381 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +16 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +293 -174
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  19. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  20. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  21. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +31 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  29. package/build/BluetoothSdk.types.d.ts +71 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/_internal.d.ts +2 -0
  33. package/build/_internal.d.ts.map +1 -1
  34. package/build/_internal.js +2 -0
  35. package/build/_internal.js.map +1 -1
  36. package/build/_private/BluetoothSdkModule.d.ts +27 -2
  37. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  38. package/build/_private/BluetoothSdkModule.js +20 -0
  39. package/build/_private/BluetoothSdkModule.js.map +1 -1
  40. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  41. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  42. package/build/_private/MentraLocalNetworkModule.js +3 -0
  43. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  44. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  45. package/build/_private/cameraRequestPayload.js +1 -0
  46. package/build/_private/cameraRequestPayload.js.map +1 -1
  47. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  48. package/build/_private/photoRequestPayload.js +2 -0
  49. package/build/_private/photoRequestPayload.js.map +1 -1
  50. package/build/index.d.ts +1 -1
  51. package/build/index.d.ts.map +1 -1
  52. package/build/index.js +9 -0
  53. package/build/index.js.map +1 -1
  54. package/expo-module.config.json +1 -0
  55. package/ios/BluetoothSdkModule.swift +72 -0
  56. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  57. package/ios/Source/Bridge.swift +72 -18
  58. package/ios/Source/DeviceManager.swift +52 -3
  59. package/ios/Source/DeviceStore.swift +6 -1
  60. package/ios/Source/MentraBluetoothSDK.swift +298 -21
  61. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  62. package/ios/Source/camera/CameraModels.swift +22 -3
  63. package/ios/Source/sgcs/G1.swift +2 -2
  64. package/ios/Source/sgcs/G2.swift +19 -7
  65. package/ios/Source/sgcs/Mach1.swift +1 -1
  66. package/ios/Source/sgcs/MentraLive.swift +456 -52
  67. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  68. package/ios/Source/sgcs/MentraNex.swift +1 -1
  69. package/ios/Source/sgcs/Nimo.swift +1 -1
  70. package/ios/Source/sgcs/SGCManager.swift +5 -2
  71. package/ios/Source/sgcs/Simulated.swift +1 -1
  72. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  73. package/ios/Source/streaming/StreamModels.swift +9 -0
  74. package/ios/Source/tts/TTSTools.swift +1 -1
  75. package/ios/Source/utils/G1Text.swift +30 -1
  76. package/package.json +1 -2
  77. package/src/BluetoothSdk.types.ts +75 -0
  78. package/src/_internal.ts +2 -0
  79. package/src/_private/BluetoothSdkModule.ts +68 -13
  80. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  81. package/src/_private/cameraRequestPayload.ts +1 -0
  82. package/src/_private/photoRequestPayload.ts +3 -3
  83. package/src/index.ts +10 -0
@@ -334,7 +334,7 @@ class MentraNex : SGCManager() {
334
334
  }
335
335
 
336
336
  // Network Management
337
- override fun requestWifiScan () { Bridge.log("Nex: requestWifiScan operation not supported") }
337
+ override fun requestWifiScan (scanId: String?) { Bridge.log("Nex: requestWifiScan operation not supported") }
338
338
  override fun sendWifiCredentials(ssid: String, password: String) { Bridge.log("Nex: sendWifiCredentials operation not supported") }
339
339
  override fun forgetWifiNetwork(ssid: String) { }
340
340
  override fun sendHotspotState(enabled: Boolean) { Bridge.log("Nex: sendHotspotState operation not supported") }
@@ -1427,7 +1427,7 @@ class Nimo : SGCManager() {
1427
1427
 
1428
1428
  // ---------- SGCManager: Network (no WiFi) ----------
1429
1429
 
1430
- override fun requestWifiScan() {}
1430
+ override fun requestWifiScan(scanId: String?) {}
1431
1431
  override fun sendWifiCredentials(ssid: String, password: String) {}
1432
1432
  override fun forgetWifiNetwork(ssid: String) {}
1433
1433
  override fun sendHotspotState(enabled: Boolean) {}
@@ -46,6 +46,8 @@ abstract class SGCManager {
46
46
 
47
47
  // Audio Control
48
48
  abstract fun setMicEnabled(enabled: Boolean)
49
+ open val isMicSuspendedForAudio: Boolean
50
+ get() = false
49
51
  abstract fun sortMicRanking(list: MutableList<String>): MutableList<String>
50
52
 
51
53
  // Camera & Media
@@ -296,12 +298,12 @@ abstract class SGCManager {
296
298
  abstract fun dbg2()
297
299
 
298
300
  // Network Management
299
- abstract fun requestWifiScan()
301
+ abstract fun requestWifiScan(scanId: String?)
300
302
  abstract fun sendWifiCredentials(ssid: String, password: String)
301
303
  abstract fun forgetWifiNetwork(ssid: String)
302
304
  abstract fun sendHotspotState(enabled: Boolean)
303
305
 
304
- /** Set glasses system clock (Mentra Live only; no-op on other devices). */
306
+ /** Set glasses system clock (Mentra Live and G2; no-op on other devices). */
305
307
  open fun sendSetSystemTime(timestampMs: Long) {
306
308
  Bridge.log("SGC: sendSetSystemTime not supported on $type")
307
309
  }
@@ -188,7 +188,7 @@ class Simulated : SGCManager() {
188
188
  override fun dbg2() {}
189
189
 
190
190
  // Network Management
191
- override fun requestWifiScan() {
191
+ override fun requestWifiScan(scanId: String?) {
192
192
  Bridge.log("requestWifiScan")
193
193
  }
194
194
 
@@ -66,15 +66,31 @@ sealed interface WifiStatus {
66
66
  }
67
67
  }
68
68
 
69
- data class WifiStatusEvent(
69
+ data class WifiStatusEvent @JvmOverloads constructor(
70
70
  val status: WifiStatus,
71
+ /**
72
+ * Glasses-reported provisioning failure reason when THIS event is the verdict of a
73
+ * failed connect attempt; null for routine link-state updates. An attempt property,
74
+ * not a link property — which is why it lives on the event and not on [WifiStatus]:
75
+ * "connect_timeout" arrives with a disconnected status (never associated), while
76
+ * "connected_to_other_network" arrives with a *connected* status — the attempt
77
+ * failed and Android's auto-join left the glasses on (or returned them to) a
78
+ * different SSID than requested, so the link is genuinely up while the request
79
+ * genuinely failed. Sent by ASG client builds that include the WiFi error
80
+ * surfacing (v40+); older glasses never set it.
81
+ */
82
+ val error: String? = null,
71
83
  ) {
72
- internal constructor(values: Map<String, Any>) : this(WifiStatus.fromMap(values) ?: WifiStatus.Disconnected)
84
+ internal constructor(values: Map<String, Any>) : this(
85
+ WifiStatus.fromMap(values) ?: WifiStatus.Disconnected,
86
+ stringValue(values, "error")?.takeIf { it.isNotEmpty() },
87
+ )
73
88
  internal constructor(connected: Boolean, ssid: String?, localIp: String?) : this(
74
89
  WifiStatus.fromStoreFields(connected, ssid, localIp) ?: WifiStatus.Disconnected
75
90
  )
76
91
 
77
- val values: Map<String, Any> get() = status.toEventMap()
92
+ val values: Map<String, Any>
93
+ get() = status.toEventMap() + (error?.let { mapOf("error" to it) } ?: emptyMap())
78
94
  }
79
95
 
80
96
  sealed interface HotspotStatus {
@@ -436,12 +436,25 @@ sealed interface StreamStatus {
436
436
  data class StreamStatusEvent(
437
437
  val status: StreamStatus,
438
438
  ) {
439
- constructor(values: Map<String, Any>) : this(StreamStatus.fromMap(values))
439
+ // True when the glasses will retry the failed publisher themselves
440
+ // (emitting side lands in PR #3488); absent on older firmware and on
441
+ // events not parsed from a glasses status map. Carried here instead of
442
+ // on StreamStatus.Error so the public Error shape stays unchanged.
443
+ var willRetry: Boolean? = null
444
+ private set
445
+
446
+ constructor(values: Map<String, Any>) : this(StreamStatus.fromMap(values)) {
447
+ willRetry = boolValue(values, "willRetry")
448
+ }
440
449
 
441
450
  val state: StreamState get() = status.state
442
451
  val streamId: String? get() = status.streamId
443
452
  val resolvedConfig: StreamResolvedConfig? get() = status.resolvedConfig
444
- val values: Map<String, Any> get() = status.toEventMap()
453
+ val values: Map<String, Any>
454
+ get() = buildMap {
455
+ putAll(status.toEventMap())
456
+ willRetry?.let { put("willRetry", it) }
457
+ }
445
458
  }
446
459
 
447
460
  data class KeepAliveAckEvent(
@@ -9,6 +9,9 @@ import com.k2fsa.sherpa.onnx.OfflineTtsModelConfig
9
9
  import com.k2fsa.sherpa.onnx.OfflineTtsSupertonicModelConfig
10
10
  import com.mentra.bluetoothsdk.Bridge
11
11
  import java.io.File
12
+ import java.util.concurrent.Executors
13
+ import java.util.concurrent.ScheduledFuture
14
+ import java.util.concurrent.TimeUnit
12
15
 
13
16
  /**
14
17
  * Utilities for offline Sherpa-ONNX Supertonic 3 TTS model management.
@@ -31,6 +34,21 @@ object TTSTools {
31
34
  private const val FILE_UNICODE_INDEXER = "unicode_indexer.bin"
32
35
  private const val FILE_VOICE_STYLE = "voice.bin"
33
36
 
37
+ /**
38
+ * Keep the native model warm briefly after use to reduce repeated TTS latency while
39
+ * still reclaiming its memory when speech has been idle for a while.
40
+ */
41
+ private const val MODEL_IDLE_TIMEOUT_MINUTES = 3L
42
+ private val modelLock = Any()
43
+ private val teardownExecutor =
44
+ Executors.newSingleThreadScheduledExecutor { runnable ->
45
+ Thread(runnable, "MentraTtsIdleTeardown").apply { isDaemon = true }
46
+ }
47
+ private var cachedTts: OfflineTts? = null
48
+ private var cachedModelPath: String? = null
49
+ private var teardownFuture: ScheduledFuture<*>? = null
50
+ private var teardownGeneration = 0L
51
+
34
52
  private val REQUIRED_FILES = listOf(
35
53
  FILE_DURATION_PREDICTOR,
36
54
  FILE_TEXT_ENCODER,
@@ -42,6 +60,11 @@ object TTSTools {
42
60
  )
43
61
 
44
62
  fun setTtsModelDetails(context: Context, path: String, languageCode: String) {
63
+ synchronized(modelLock) {
64
+ if (cachedModelPath != null && cachedModelPath != path) {
65
+ releaseCachedModelLocked("model path changed")
66
+ }
67
+ }
45
68
  val prefs = getPrefs(context)
46
69
  prefs.edit().apply {
47
70
  putString(KEY_TTS_MODEL_PATH, path)
@@ -118,59 +141,104 @@ object TTSTools {
118
141
  return false
119
142
  }
120
143
 
121
- val modelDir = File(modelPath)
122
144
  val outputFile = File(outputPath)
123
145
  outputFile.parentFile?.mkdirs()
124
146
 
125
- var tts: OfflineTts? = null
126
- return try {
127
- val supertonic = OfflineTtsSupertonicModelConfig(
128
- durationPredictor = File(modelDir, FILE_DURATION_PREDICTOR).absolutePath,
129
- textEncoder = File(modelDir, FILE_TEXT_ENCODER).absolutePath,
130
- vectorEstimator = File(modelDir, FILE_VECTOR_ESTIMATOR).absolutePath,
131
- vocoder = File(modelDir, FILE_VOCODER).absolutePath,
132
- ttsJson = File(modelDir, FILE_TTS_JSON).absolutePath,
133
- unicodeIndexer = File(modelDir, FILE_UNICODE_INDEXER).absolutePath,
134
- voiceStyle = File(modelDir, FILE_VOICE_STYLE).absolutePath,
135
- )
136
-
137
- val modelConfig = OfflineTtsModelConfig(
138
- supertonic = supertonic,
139
- numThreads = 2,
140
- provider = "cpu",
141
- )
142
-
143
- val config = OfflineTtsConfig(
144
- model = modelConfig,
145
- maxNumSentences = 1,
146
- silenceScale = 0.2f,
147
- )
148
-
149
- tts = OfflineTts(null, config)
150
-
151
- // sid out of range falls back to F1 (sid 0); clamp 0..9.
152
- val sid = speakerId.coerceIn(0, tts.numSpeakers() - 1)
153
- val clampedSpeed = speed.coerceIn(0.5f, 2.0f)
154
- val lang = languageTagToSupertonicLang(languageTag)
155
- val genConfig = GenerationConfig(
156
- sid = sid,
157
- speed = clampedSpeed,
158
- extra = mapOf("lang" to lang),
159
- )
160
- val audio = tts.generateWithConfig(text = text, config = genConfig)
161
- val saved = audio.save(outputFile.absolutePath)
162
- Bridge.log("TTS generated ${outputFile.absolutePath}: saved=$saved sid=$sid lang=$lang")
163
- saved
164
- } catch (e: Exception) {
165
- Bridge.log("TTS_ERROR: ${e.javaClass.simpleName}: ${e.message}")
166
- e.printStackTrace()
167
- false
168
- } finally {
147
+ return synchronized(modelLock) {
169
148
  try {
170
- tts?.release()
149
+ val tts = getOrCreateModelLocked(modelPath)
150
+
151
+ // sid out of range falls back to F1 (sid 0); clamp 0..9.
152
+ val sid = speakerId.coerceIn(0, tts.numSpeakers() - 1)
153
+ val clampedSpeed = speed.coerceIn(0.5f, 2.0f)
154
+ val lang = languageTagToSupertonicLang(languageTag)
155
+ val genConfig = GenerationConfig(
156
+ sid = sid,
157
+ speed = clampedSpeed,
158
+ numSteps = 5,
159
+ extra = mapOf("lang" to lang),
160
+ )
161
+ val audio = tts.generateWithConfig(text = text, config = genConfig)
162
+ val saved = audio.save(outputFile.absolutePath)
163
+ Bridge.log("TTS generated ${outputFile.absolutePath}: saved=$saved sid=$sid lang=$lang")
164
+ saved
171
165
  } catch (e: Exception) {
172
- Bridge.log("TTS release failed: ${e.message}")
166
+ Bridge.log("TTS_ERROR: ${e.javaClass.simpleName}: ${e.message}")
167
+ e.printStackTrace()
168
+ false
169
+ } finally {
170
+ scheduleIdleTeardownLocked()
171
+ }
172
+ }
173
+ }
174
+
175
+ private fun getOrCreateModelLocked(modelPath: String): OfflineTts {
176
+ cachedTts?.let { cached ->
177
+ if (cachedModelPath == modelPath) {
178
+ Bridge.log("TTS reusing cached model")
179
+ return cached
173
180
  }
181
+ releaseCachedModelLocked("model path changed")
182
+ }
183
+
184
+ val modelDir = File(modelPath)
185
+ val supertonic = OfflineTtsSupertonicModelConfig(
186
+ durationPredictor = File(modelDir, FILE_DURATION_PREDICTOR).absolutePath,
187
+ textEncoder = File(modelDir, FILE_TEXT_ENCODER).absolutePath,
188
+ vectorEstimator = File(modelDir, FILE_VECTOR_ESTIMATOR).absolutePath,
189
+ vocoder = File(modelDir, FILE_VOCODER).absolutePath,
190
+ ttsJson = File(modelDir, FILE_TTS_JSON).absolutePath,
191
+ unicodeIndexer = File(modelDir, FILE_UNICODE_INDEXER).absolutePath,
192
+ voiceStyle = File(modelDir, FILE_VOICE_STYLE).absolutePath,
193
+ )
194
+ val modelConfig = OfflineTtsModelConfig(
195
+ supertonic = supertonic,
196
+ numThreads = 8,
197
+ provider = "cpu",
198
+ )
199
+ val config = OfflineTtsConfig(
200
+ model = modelConfig,
201
+ maxNumSentences = 1,
202
+ silenceScale = 0.2f,
203
+ )
204
+ return OfflineTts(null, config).also {
205
+ cachedTts = it
206
+ cachedModelPath = modelPath
207
+ Bridge.log("TTS loaded model; keeping it warm for $MODEL_IDLE_TIMEOUT_MINUTES minutes after use")
208
+ }
209
+ }
210
+
211
+ private fun scheduleIdleTeardownLocked() {
212
+ if (cachedTts == null) return
213
+ teardownFuture?.cancel(false)
214
+ val generation = ++teardownGeneration
215
+ teardownFuture =
216
+ teardownExecutor.schedule(
217
+ {
218
+ synchronized(modelLock) {
219
+ if (generation == teardownGeneration) {
220
+ releaseCachedModelLocked("idle for $MODEL_IDLE_TIMEOUT_MINUTES minutes")
221
+ }
222
+ }
223
+ },
224
+ MODEL_IDLE_TIMEOUT_MINUTES,
225
+ TimeUnit.MINUTES,
226
+ )
227
+ }
228
+
229
+ private fun releaseCachedModelLocked(reason: String) {
230
+ teardownFuture?.cancel(false)
231
+ teardownFuture = null
232
+ teardownGeneration++
233
+ val tts = cachedTts
234
+ cachedTts = null
235
+ cachedModelPath = null
236
+ if (tts == null) return
237
+ try {
238
+ tts.release()
239
+ Bridge.log("TTS released cached model: $reason")
240
+ } catch (e: Exception) {
241
+ Bridge.log("TTS release failed: ${e.message}")
174
242
  }
175
243
  }
176
244
 
@@ -2,7 +2,6 @@ package com.mentra.bluetoothsdk
2
2
 
3
3
  import com.mentra.bluetoothsdk.utils.ControllerTypes
4
4
  import com.mentra.bluetoothsdk.utils.DeviceTypes
5
- import expo.modules.kotlin.exception.CodedException
6
5
 
7
6
  data class MentraBluetoothSdkConfig @JvmOverloads constructor(
8
7
  val deliverCallbacksOnMainThread: Boolean = true,
@@ -10,10 +9,10 @@ data class MentraBluetoothSdkConfig @JvmOverloads constructor(
10
9
  )
11
10
 
12
11
  class BluetoothSdkException(
13
- code: String,
12
+ val code: String,
14
13
  message: String,
15
14
  cause: Throwable? = null,
16
- ) : CodedException(code, message, cause)
15
+ ) : IllegalStateException(message, cause)
17
16
 
18
17
  enum class DeviceModel(val deviceType: String) {
19
18
  G1(DeviceTypes.G1),
@@ -37,10 +37,10 @@ import org.json.JSONObject;
37
37
  */
38
38
  public class BlePhotoUploadService {
39
39
  private static final String TAG = "BlePhotoUploadService";
40
- // 100: the source already went through a lossy AVIF pass on the glasses, so
41
- // this re-encode must not compound the loss. Phone CPU and upload bandwidth
42
- // are cheap relative to what was paid to get the bytes over BLE.
43
- private static final int JPEG_QUALITY = 100;
40
+ // AVIF sources already went through a lossy pass on the glasses; re-encode at
41
+ // high-but-not-max quality to avoid inflating upload size. JPEG fast-path
42
+ // payloads from text mode are uploaded as-is.
43
+ private static final int AVIF_TO_JPEG_QUALITY = 90;
44
44
 
45
45
  public interface UploadCallback {
46
46
  void onSuccess(String requestId, String responseBody);
@@ -84,6 +84,18 @@ public class BlePhotoUploadService {
84
84
  */
85
85
  @VisibleForTesting
86
86
  static byte[] convertToJpegPreservingExif(byte[] imageData) throws Exception {
87
+ long conversionStartMs = System.currentTimeMillis();
88
+ if (isJpeg(imageData)) {
89
+ Log.d(
90
+ TAG,
91
+ "BLE relay pass-through: input already JPEG ("
92
+ + imageData.length
93
+ + " bytes), skipping decode/re-encode in "
94
+ + (System.currentTimeMillis() - conversionStartMs)
95
+ + "ms");
96
+ return imageData;
97
+ }
98
+
87
99
  File inputFile = File.createTempFile("ble_photo_in_", guessExtension(imageData));
88
100
  File outputFile = File.createTempFile("ble_photo_out_", ".jpg");
89
101
  try {
@@ -95,19 +107,39 @@ public class BlePhotoUploadService {
95
107
 
96
108
  String imuJson = readImuJsonFromBleImage(imageData, inputFile.getAbsolutePath());
97
109
 
110
+ long decodeStartMs = System.currentTimeMillis();
98
111
  Bitmap bitmap = decodeImage(imageData);
112
+ long decodeDurationMs = System.currentTimeMillis() - decodeStartMs;
99
113
  if (bitmap == null) {
100
114
  throw new Exception("Failed to decode image data");
101
115
  }
102
116
 
103
- Log.d(TAG, "Decoded image to bitmap: " + bitmap.getWidth() + "x" + bitmap.getHeight());
104
-
117
+ Log.d(
118
+ TAG,
119
+ "AVIF decode complete: "
120
+ + bitmap.getWidth()
121
+ + "x"
122
+ + bitmap.getHeight()
123
+ + " in "
124
+ + decodeDurationMs
125
+ + "ms");
126
+
127
+ long encodeStartMs = System.currentTimeMillis();
105
128
  try (FileOutputStream fos = new FileOutputStream(outputFile)) {
106
- bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos);
129
+ bitmap.compress(Bitmap.CompressFormat.JPEG, AVIF_TO_JPEG_QUALITY, fos);
107
130
  } finally {
108
131
  bitmap.recycle();
109
132
  }
133
+ long encodeDurationMs = System.currentTimeMillis() - encodeStartMs;
134
+ Log.d(
135
+ TAG,
136
+ "AVIF->JPEG encode complete: quality="
137
+ + AVIF_TO_JPEG_QUALITY
138
+ + " in "
139
+ + encodeDurationMs
140
+ + "ms");
110
141
 
142
+ long metadataStartMs = System.currentTimeMillis();
111
143
  if (imuJson != null && !imuJson.isEmpty()) {
112
144
  logImuData(imuJson);
113
145
  // IMU EXIF is enrichment, not the payload: a write failure must not drop the
@@ -131,8 +163,27 @@ public class BlePhotoUploadService {
131
163
  + "). If rawHasExifMarker=true, EXIF may be present but unreadable via"
132
164
  + " ExifInterface on this container.");
133
165
  }
166
+ Log.d(
167
+ TAG,
168
+ "AVIF metadata handling complete in "
169
+ + (System.currentTimeMillis() - metadataStartMs)
170
+ + "ms");
134
171
 
135
- return java.nio.file.Files.readAllBytes(outputFile.toPath());
172
+ byte[] jpegData = java.nio.file.Files.readAllBytes(outputFile.toPath());
173
+ Log.d(
174
+ TAG,
175
+ "Phone image conversion complete: AVIF "
176
+ + imageData.length
177
+ + " bytes -> JPEG "
178
+ + jpegData.length
179
+ + " bytes in "
180
+ + (System.currentTimeMillis() - conversionStartMs)
181
+ + "ms (decode="
182
+ + decodeDurationMs
183
+ + "ms, encode="
184
+ + encodeDurationMs
185
+ + "ms)");
186
+ return jpegData;
136
187
  } finally {
137
188
  if (!inputFile.delete()) {
138
189
  inputFile.deleteOnExit();
@@ -254,6 +305,12 @@ public class BlePhotoUploadService {
254
305
  exif.saveAttributes();
255
306
  }
256
307
 
308
+ private static boolean isJpeg(byte[] imageData) {
309
+ return imageData.length >= 2
310
+ && (imageData[0] & 0xFF) == 0xFF
311
+ && (imageData[1] & 0xFF) == 0xD8;
312
+ }
313
+
257
314
  private static String guessExtension(byte[] imageData) {
258
315
  if (imageData.length > 12
259
316
  && imageData[4] == 'f'
@@ -30,7 +30,9 @@ public class K900ProtocolUtils {
30
30
  public static final byte CMD_TYPE_BINARY_MSG = BleWireProtocol.CMD_TYPE_BINARY_MSG;
31
31
 
32
32
  // File transfer constants
33
- public static final int FILE_PACK_SIZE = 400; // Max data size per packet
33
+ // Negotiated file protocol ceiling. Legacy/GATT transfers remain smaller; 800-byte frames are
34
+ // accepted only after file_payload_v2 negotiation and an open CoC channel.
35
+ public static final int FILE_PACK_SIZE = 800;
34
36
  public static final int LENGTH_FILE_START = 2;
35
37
  public static final int LENGTH_FILE_TYPE = 1;
36
38
  public static final int LENGTH_FILE_PACKSIZE = 2;
@@ -274,9 +276,6 @@ public class K900ProtocolUtils {
274
276
  return false;
275
277
  }
276
278
 
277
- Log.d("K900ProtocolUtils", "isK900ProtocolFormat: " + data[0] + " " + data[1]);
278
- Log.d("K900ProtocolUtils", "CMD_START_CODE: " + CMD_START_CODE[0] + " " + CMD_START_CODE[1]);
279
-
280
279
  return data[0] == CMD_START_CODE[0] &&
281
280
  data[1] == CMD_START_CODE[1];
282
281
  }
@@ -792,7 +791,9 @@ public class K900ProtocolUtils {
792
791
  Log.e("K900ProtocolUtils", "File packet checksum failed. Expected: " +
793
792
  String.format("%02X", info.verifyCode) + ", Calculated: " +
794
793
  String.format("%02X", calculatedVerify));
795
- } else {
794
+ } else if (info.packIndex == 0
795
+ || info.packIndex % 32 == 0
796
+ || info.packIndex == (info.fileSize + FILE_PACK_SIZE - 1) / FILE_PACK_SIZE - 1) {
796
797
  Log.d("K900ProtocolUtils", "File packet extracted successfully: index=" + info.packIndex +
797
798
  ", size=" + info.packSize + ", fileName=" + info.fileName);
798
799
  }