@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -0,0 +1,156 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /** Private FBP1 protocol for one pinned diagnostic build, never a general memory reader. */
4
+ internal object NimoFramebufferProtocol {
5
+ const val EXPECTED_VERSION = "FW-VERSION-v0.1.1.1-20260827164351-537cf1-dirty-Debug"
6
+ const val ARTIFACT_SHA256 = "4de441f887e85b1f8194881695b96c16e8018fbdc19d4edc9f5b5d4210e5aa83"
7
+ const val WIDTH = 540
8
+ const val HEIGHT = 280
9
+ const val PAGE_BYTES = 240
10
+ const val PAGE_COUNT = 315
11
+ const val BANK_BYTES = PAGE_BYTES * PAGE_COUNT
12
+ private val MAGIC = "FBP1".toByteArray(Charsets.US_ASCII)
13
+ private val BUILD_TAG = "537cf1p1".toByteArray(Charsets.US_ASCII)
14
+
15
+ data class Request(val nonce: Long, val op: Int, val bank: Int, val page: Int)
16
+ data class State(val draw: Int, val prepared: Int, val dirty: Int, val pending: Int)
17
+ data class Reply(val nonce: Long, val op: Int, val bank: Int, val page: Int,
18
+ val before: State, val after: State, val data: ByteArray)
19
+
20
+ fun hex(bytes: ByteArray): String {
21
+ val digits = "0123456789abcdef"
22
+ val chars = CharArray(bytes.size * 2)
23
+ bytes.forEachIndexed { index, byte ->
24
+ val value = byte.toInt() and 255
25
+ chars[index * 2] = digits[value ushr 4]
26
+ chars[index * 2 + 1] = digits[value and 15]
27
+ }
28
+ return chars.concatToString()
29
+ }
30
+
31
+ fun queryFrame(key: Int): ByteArray {
32
+ require(key == 0x0B || key == 0x19) { "Only version and readiness queries are permitted" }
33
+ return frame(key, byteArrayOf())
34
+ }
35
+
36
+ fun requestFrame(request: Request): ByteArray {
37
+ validate(request)
38
+ val nonce = ByteArray(4) { (request.nonce shr (8 * it)).toByte() }
39
+ return frame(0x15, MAGIC + nonce + byteArrayOf(request.op.toByte(), request.bank.toByte(),
40
+ request.page.toByte(), (request.page shr 8).toByte()))
41
+ }
42
+
43
+ fun version(packet: ByteArray): String {
44
+ val payload = basicPayload(packet, 0x0B)
45
+ require(payload.isNotEmpty() && payload[0] == 0.toByte()) { "Version query rejected" }
46
+ val expected = EXPECTED_VERSION.toByteArray(Charsets.US_ASCII)
47
+ require(payload.copyOfRange(1, payload.size).contentEquals(expected)) { "Unexpected firmware identity" }
48
+ return EXPECTED_VERSION
49
+ }
50
+
51
+ /** Preserve status and all three readiness fields, including a not-ready result. */
52
+ fun readiness(packet: ByteArray): List<Int> {
53
+ val payload = basicPayload(packet, 0x19)
54
+ require(payload.size == 4) { "Readiness response must contain status, ready, TWS, and peer GATT" }
55
+ return payload.map { it.toInt() and 255 }
56
+ }
57
+
58
+ fun basicPayload(packet: ByteArray, key: Int): ByteArray {
59
+ require(key == 0x0B || key == 0x19) { "Unexpected basic query" }
60
+ return payload(packet, key, 128)
61
+ }
62
+
63
+ fun reply(packet: ByteArray, expected: Request): Reply {
64
+ validate(expected)
65
+ val body = payload(packet, 0x15, 23 + PAGE_BYTES)
66
+ require(body.size >= 23) { "Short FBP1 response" }
67
+ require(body.copyOfRange(1, 5).contentEquals(MAGIC)) { "FBP1 response magic mismatch" }
68
+ require(word(body, 21) == body.size - 23) { "FBP1 data length mismatch" }
69
+ val status = unsigned(body, 0)
70
+ require(status in listOf(0, 6, 7)) { "Unknown FBP1 response status $status" }
71
+ if (status != 0) {
72
+ require(body.size == 23) { "Rejected FBP1 response contains data" }
73
+ throw IllegalArgumentException("FBP1 request rejected with status $status")
74
+ }
75
+ val nonce = (0..3).fold(0L) { value, i -> value or (unsigned(body, 5 + i).toLong() shl (8 * i)) }
76
+ val actual = Request(nonce, unsigned(body, 9), unsigned(body, 10), word(body, 11))
77
+ require(actual == expected) { "FBP1 response correlation mismatch" }
78
+ val before = state(body, 13)
79
+ val after = state(body, 17)
80
+ require(before == after) { "Framebuffer metadata changed during page copy" }
81
+ val data = body.copyOfRange(23, body.size)
82
+ if (actual.op == 0) require(data.contentEquals(BUILD_TAG)) { "Unexpected FBP1 build tag" }
83
+ else require(data.size == PAGE_BYTES) { "Framebuffer page must contain $PAGE_BYTES bytes" }
84
+ return Reply(nonce, actual.op, actual.bank, actual.page, before, after, data)
85
+ }
86
+
87
+ /** Sequential, single-pass assembler. Any rejected input permanently invalidates the read. */
88
+ class BankRead(val nonce: Long, val bank: Int) {
89
+ var pageCount = 0
90
+ private set
91
+ var metadata: State? = null
92
+ private set
93
+ private var failed = false
94
+ private val data = ByteArray(BANK_BYTES)
95
+
96
+ init { validate(Request(nonce, 1, bank, 0)) }
97
+
98
+ fun add(reply: Reply) {
99
+ require(!failed) { "Bank read was invalidated" }
100
+ try {
101
+ require(pageCount < PAGE_COUNT && reply.page == pageCount) { "Duplicate or out-of-order framebuffer page" }
102
+ require(reply.nonce == nonce && reply.bank == bank && reply.op == 1) { "Bank read correlation mismatch" }
103
+ require(reply.data.size == PAGE_BYTES) { "Invalid framebuffer page length" }
104
+ require(reply.before == reply.after) { "Metadata changed during page copy" }
105
+ require(metadata == null || metadata == reply.before) { "Metadata changed between pages" }
106
+ metadata = reply.before
107
+ reply.data.copyInto(data, pageCount * PAGE_BYTES)
108
+ pageCount += 1
109
+ } catch (error: IllegalArgumentException) {
110
+ failed = true
111
+ throw error
112
+ }
113
+ }
114
+
115
+ fun bytes(): ByteArray {
116
+ require(!failed && pageCount == PAGE_COUNT) { "Framebuffer bank is incomplete or invalidated" }
117
+ return data.copyOf()
118
+ }
119
+ }
120
+
121
+ fun verifyPair(first: BankRead, second: BankRead) {
122
+ require(first.nonce != second.nonce) { "Static verification requires independent nonces" }
123
+ require(first.bank == second.bank && first.metadata == second.metadata) { "Static pair metadata mismatch" }
124
+ require(first.bytes().contentEquals(second.bytes())) { "Framebuffer pixels changed between passes" }
125
+ }
126
+
127
+ private fun validate(request: Request) {
128
+ require(request.nonce in 0..0xFFFFFFFFL && request.op in 0..1 && request.bank in 0..1 &&
129
+ request.page in 0 until PAGE_COUNT && (request.op != 0 || request.page == 0)) { "Invalid FBP1 request" }
130
+ }
131
+
132
+ private fun state(body: ByteArray, offset: Int): State {
133
+ val state = State(unsigned(body, offset), unsigned(body, offset + 1), unsigned(body, offset + 2), unsigned(body, offset + 3))
134
+ require(state.draw in 0..1 && state.prepared in 0..1) { "Invalid framebuffer selectors" }
135
+ return state
136
+ }
137
+
138
+ private fun payload(packet: ByteArray, key: Int, maximum: Int): ByteArray {
139
+ require(packet.size in 12..(12 + maximum)) { "Invalid frame size" }
140
+ require(unsigned(packet, 0) == 0xBF && packet[1] == 0.toByte()) { "Expected BF00 frame" }
141
+ require(word(packet, 6) == 0) { "Fragmented framebuffer responses are not supported" }
142
+ require(word(packet, 2) == packet.size - 8 && word(packet, 10) == packet.size - 12) { "Frame length mismatch" }
143
+ require(unsigned(packet, 8) == 2 && unsigned(packet, 9) == key) { "Frame command mismatch" }
144
+ require(word(packet, 4) == NimoCanvasCodec.crc16(packet.copyOfRange(8, packet.size))) { "Frame CRC mismatch" }
145
+ return packet.copyOfRange(12, packet.size)
146
+ }
147
+
148
+ private fun frame(key: Int, payload: ByteArray): ByteArray {
149
+ val body = byteArrayOf(2, key.toByte(), payload.size.toByte(), (payload.size shr 8).toByte()) + payload
150
+ val crc = NimoCanvasCodec.crc16(body)
151
+ return byteArrayOf(0xBF.toByte(), 0, body.size.toByte(), (body.size shr 8).toByte(),
152
+ crc.toByte(), (crc shr 8).toByte(), 0, 0) + body
153
+ }
154
+ private fun unsigned(bytes: ByteArray, index: Int) = bytes[index].toInt() and 255
155
+ private fun word(bytes: ByteArray, index: Int) = unsigned(bytes, index) or (unsigned(bytes, index + 1) shl 8)
156
+ }
@@ -46,7 +46,12 @@ internal class BluetoothSdkAnalytics(
46
46
  // The tracker is touched from store listeners and SDK entry points, hence
47
47
  // @Synchronized on the methods that read or write it.
48
48
  private val tracker = BluetoothSdkAnalyticsTracker(DeviceTypes.SIMULATED)
49
+ private val usage = BluetoothSdkUsageTracker()
49
50
  private var startedCaptured = false
51
+ private var lastGlasses: AnalyticsGlassesSnapshot? = null
52
+ // Set by an SDK disconnect/forget/cancel call and consumed by the next
53
+ // connected -> disconnected transition, so that transition can say why.
54
+ private var pendingDisconnectReason: String? = null
50
55
  // Resolved once, lazily, on the transport executor: PackageManager lookups
51
56
  // must not run on the caller (often main or the Bluetooth status) thread.
52
57
  private val hostProperties: Map<String, Any> by lazy { BluetoothSdkAnalyticsHost.resolve(appContext).toMap() }
@@ -56,9 +61,15 @@ internal class BluetoothSdkAnalytics(
56
61
  timeZone = TimeZone.getTimeZone("UTC")
57
62
  }
58
63
 
64
+ /** False when the host disabled analytics; callers skip building usage facts entirely. */
65
+ val isEnabled: Boolean
66
+ get() = config.isReady
67
+
59
68
  @Synchronized
60
69
  fun initializeGlassesStatus(status: GlassesStatus) {
61
- tracker.initialize(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
70
+ val snapshot = status.toAnalyticsSnapshot()
71
+ lastGlasses = snapshot
72
+ tracker.initialize(snapshot, BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
62
73
  }
63
74
 
64
75
  @Synchronized
@@ -71,12 +82,54 @@ internal class BluetoothSdkAnalytics(
71
82
  }
72
83
 
73
84
  @Synchronized
74
- fun observeGlassesStatus(status: GlassesStatus) {
75
- val events = tracker.observe(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
85
+ fun observeGlassesStatus(status: GlassesStatus, phoneBluetoothOff: () -> Boolean) {
86
+ val snapshot = status.toAnalyticsSnapshot()
87
+ lastGlasses = snapshot
88
+ val now = System.currentTimeMillis()
89
+ val events =
90
+ tracker.observe(snapshot, BluetoothSdkAnalyticsTracker.reportingDay(now), now) {
91
+ pendingDisconnectReason
92
+ ?: if (phoneBluetoothOff()) {
93
+ BluetoothSdkAnalyticsTracker.DISCONNECT_BLUETOOTH_OFF
94
+ } else {
95
+ BluetoothSdkAnalyticsTracker.DISCONNECT_LINK_LOST
96
+ }
97
+ }
98
+ if (!snapshot.connected || events.any { it.name == "bluetooth_sdk_glasses_connected" }) {
99
+ pendingDisconnectReason = null
100
+ }
76
101
  if (!config.isReady) return
77
102
  for (event in events) capture(event.name, event.properties)
78
103
  }
79
104
 
105
+ @Synchronized
106
+ fun noteDisconnectRequested(reason: String) {
107
+ if (lastGlasses?.connected == true) pendingDisconnectReason = reason
108
+ }
109
+
110
+ @Synchronized
111
+ fun recordUsage(build: BluetoothSdkUsageTracker.() -> AnalyticsEvent?) {
112
+ if (!config.isReady) return
113
+ val event = usage.build() ?: return
114
+ capture(event.name, glassesContext() + event.properties)
115
+ }
116
+
117
+ /** Usage-tracker state changes that emit nothing themselves (e.g. a stop request). */
118
+ @Synchronized
119
+ fun updateUsage(update: BluetoothSdkUsageTracker.() -> Unit) {
120
+ if (!config.isReady) return
121
+ usage.update()
122
+ }
123
+
124
+ private fun glassesContext(): Map<String, Any> {
125
+ val glasses = lastGlasses?.takeIf { it.connected } ?: return emptyMap()
126
+ return buildMap {
127
+ glasses.model.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
128
+ glasses.serialNumber.validManufacturingSerial()?.let { put("glasses_device_id", it) }
129
+ glasses.buildNumber.takeIf { it.isNotBlank() }?.let { put("glasses_build_number", it) }
130
+ }
131
+ }
132
+
80
133
  /**
81
134
  * Delivery is owned by the process-wide transport, so nothing is torn down
82
135
  * here: a retry in flight when this instance closes still completes, and a
@@ -101,7 +154,7 @@ internal class BluetoothSdkAnalytics(
101
154
  "api_key" to DEFAULT_POSTHOG_API_KEY,
102
155
  "uuid" to uuid,
103
156
  "event" to eventName,
104
- "distinct_id" to distinctId(),
157
+ "distinct_id" to BluetoothSdkInstallId.get(appContext),
105
158
  "timestamp" to isoFormat.format(Date(capturedAt)),
106
159
  "properties" to baseProperties(activeConfig) + hostProperties + eventProperties,
107
160
  )
@@ -156,16 +209,12 @@ internal class BluetoothSdkAnalytics(
156
209
  put("app_package", appContext.packageName)
157
210
  put("os_platform", "android")
158
211
  put("os_version", Build.VERSION.SDK_INT)
212
+ Build.MANUFACTURER?.takeIf { it.isNotBlank() }?.let { put("phone_manufacturer", it) }
213
+ Build.MODEL?.takeIf { it.isNotBlank() }?.let { put("phone_model", it) }
214
+ put("locale", Locale.getDefault().toLanguageTag())
215
+ put("timezone", TimeZone.getDefault().id)
159
216
  }
160
217
 
161
- private fun distinctId(): String {
162
- val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
163
- prefs.getString(PREFS_DISTINCT_ID, null)?.let { return it }
164
- val generated = "mentra-bt-sdk-${UUID.randomUUID()}"
165
- prefs.edit().putString(PREFS_DISTINCT_ID, generated).apply()
166
- return generated
167
- }
168
-
169
218
  private fun captureUrl(): String {
170
219
  val normalized = DEFAULT_POSTHOG_HOST.trim().trimEnd('/')
171
220
  return "$normalized/i/v0/e/"
@@ -173,8 +222,6 @@ internal class BluetoothSdkAnalytics(
173
222
 
174
223
  companion object {
175
224
  internal const val META_ANALYTICS_DISABLED = "com.mentra.bluetoothsdk.analytics.disabled"
176
- private const val PREFS_NAME = "mentra_bluetooth_sdk_analytics"
177
- private const val PREFS_DISTINCT_ID = "distinct_id"
178
225
  private const val DEFAULT_POSTHOG_API_KEY = "phc_FCweXVAxVgU7wZK4Fk3okOx4RmyNqVHJf62YpZSfJt5"
179
226
  private const val DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
180
227
  }
@@ -212,4 +259,6 @@ private fun GlassesStatus.toAnalyticsSnapshot(): AnalyticsGlassesSnapshot =
212
259
  androidVersion = androidVersion,
213
260
  appVersion = appVersion,
214
261
  buildNumber = buildNumber,
262
+ batteryLevel = batteryLevel,
263
+ charging = charging,
215
264
  )
@@ -14,6 +14,9 @@ internal data class AnalyticsGlassesSnapshot(
14
14
  val androidVersion: String = "",
15
15
  val appVersion: String = "",
16
16
  val buildNumber: String = "",
17
+ /** -1 while the glasses have not reported a level. */
18
+ val batteryLevel: Int = -1,
19
+ val charging: Boolean = false,
17
20
  )
18
21
 
19
22
  internal data class AnalyticsEvent(
@@ -36,12 +39,21 @@ internal data class AnalyticsEvent(
36
39
  * is visible in both weeks. Reporting days follow `America/Los_Angeles`, the
37
40
  * calendar the WAU weeks are cut on; a UTC day would miss the Sunday-evening to
38
41
  * Monday-morning Pacific boundary, which is one UTC day.
42
+ * - `bluetooth_sdk_glasses_disconnected` when a counted connection ends, with its
43
+ * length, why it ended, and the battery levels seen during it. The reason is
44
+ * supplied by the caller: an SDK disconnect/forget/cancel call, the phone's
45
+ * Bluetooth turning off, or otherwise a lost link.
39
46
  */
40
47
  internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String) {
41
48
  private var lastConnected = false
42
49
  private var connectedPendingModel = false
43
50
  private var identifiedSerial: String? = null
44
51
  private var identifiedReportingDay: Long? = null
52
+ private var connectedAtMs: Long? = null
53
+ private var batteryAtConnect = -1
54
+ private var lastBattery = -1
55
+ private var lastCharging: Boolean? = null
56
+ private var lastModel = ""
45
57
 
46
58
  fun initialize(snapshot: AnalyticsGlassesSnapshot, reportingDay: Long) {
47
59
  lastConnected = snapshot.connected
@@ -54,7 +66,12 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
54
66
  identifiedReportingDay = identifiedSerial?.let { reportingDay }
55
67
  }
56
68
 
57
- fun observe(snapshot: AnalyticsGlassesSnapshot, reportingDay: Long): List<AnalyticsEvent> {
69
+ fun observe(
70
+ snapshot: AnalyticsGlassesSnapshot,
71
+ reportingDay: Long,
72
+ nowMs: Long = 0L,
73
+ disconnectReason: () -> String = { DISCONNECT_UNKNOWN },
74
+ ): List<AnalyticsEvent> {
58
75
  val events = mutableListOf<AnalyticsEvent>()
59
76
  val wasConnected = lastConnected
60
77
  lastConnected = snapshot.connected
@@ -64,14 +81,30 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
64
81
  connectedPendingModel = false
65
82
  events += connectedEvent(snapshot, modelUnresolved = true)
66
83
  }
84
+ connectedAtMs?.let { startedAt ->
85
+ events += disconnectedEvent(nowMs - startedAt, disconnectReason())
86
+ }
87
+ connectedAtMs = null
67
88
  identifiedSerial = null
68
89
  identifiedReportingDay = null
69
90
  return events
70
91
  }
71
92
 
93
+ if (snapshot.model.isNotBlank()) lastModel = snapshot.model
94
+ if (snapshot.batteryLevel >= 0) {
95
+ if (batteryAtConnect < 0) batteryAtConnect = snapshot.batteryLevel
96
+ lastBattery = snapshot.batteryLevel
97
+ lastCharging = snapshot.charging
98
+ }
99
+
72
100
  if (!wasConnected) {
73
101
  identifiedSerial = null
74
102
  identifiedReportingDay = null
103
+ connectedAtMs = nowMs
104
+ lastModel = snapshot.model
105
+ batteryAtConnect = snapshot.batteryLevel
106
+ lastBattery = snapshot.batteryLevel
107
+ lastCharging = snapshot.charging.takeIf { snapshot.batteryLevel >= 0 }
75
108
  if (snapshot.model.isBlank()) {
76
109
  connectedPendingModel = true
77
110
  } else {
@@ -106,6 +139,21 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
106
139
  },
107
140
  )
108
141
 
142
+ private fun disconnectedEvent(durationMs: Long, reason: String) =
143
+ AnalyticsEvent(
144
+ "bluetooth_sdk_glasses_disconnected",
145
+ buildMap {
146
+ put("event_kind", "glasses_disconnected")
147
+ put("session_duration_ms", durationMs.coerceAtLeast(0))
148
+ put("disconnect_reason", reason)
149
+ lastModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
150
+ put("glasses_is_simulated", lastModel == simulatedModel)
151
+ batteryAtConnect.takeIf { it >= 0 }?.let { put("battery_level_at_connect", it) }
152
+ lastBattery.takeIf { it >= 0 }?.let { put("battery_level_at_disconnect", it) }
153
+ lastCharging?.let { put("charging", it) }
154
+ },
155
+ )
156
+
109
157
  private fun identifiedEvent(snapshot: AnalyticsGlassesSnapshot, serial: String, kind: String) =
110
158
  AnalyticsEvent(
111
159
  "bluetooth_sdk_glasses_identified",
@@ -126,6 +174,12 @@ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String)
126
174
  )
127
175
 
128
176
  companion object {
177
+ const val DISCONNECT_USER = "user_disconnect"
178
+ const val DISCONNECT_FORGET = "forget"
179
+ const val DISCONNECT_CANCEL = "cancel"
180
+ const val DISCONNECT_BLUETOOTH_OFF = "bluetooth_off"
181
+ const val DISCONNECT_LINK_LOST = "link_lost"
182
+ const val DISCONNECT_UNKNOWN = "unknown"
129
183
  const val MILLIS_PER_DAY = 86_400_000L
130
184
  val REPORTING_ZONE: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
131
185
 
@@ -3,5 +3,8 @@ package com.mentra.bluetoothsdk
3
3
  /** Defaults for the public Bluetooth SDK surface. */
4
4
  object BluetoothSdkDefaults {
5
5
  const val VOICE_ACTIVITY_DETECTION_ENABLED = false
6
- const val LOUDNESS_GATE_ENABLED = false
6
+ const val LOUDNESS_GATE_ENABLED = true
7
+
8
+ /** Off until someone turns it on in Super Mode. Re-pushed on connect. */
9
+ const val AUTO_POWER_OFF_ENABLED = false
7
10
  }
@@ -0,0 +1,23 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.content.Context
4
+ import java.util.UUID
5
+
6
+ /**
7
+ * One random id per app install, shared by usage analytics (as the PostHog
8
+ * `distinct_id`) and the startup update check, so both describe the same install.
9
+ * It is created on first use whether or not analytics is enabled.
10
+ */
11
+ internal object BluetoothSdkInstallId {
12
+ private const val PREFS_NAME = "mentra_bluetooth_sdk_analytics"
13
+ private const val PREFS_DISTINCT_ID = "distinct_id"
14
+
15
+ @Synchronized
16
+ fun get(context: Context): String {
17
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
18
+ prefs.getString(PREFS_DISTINCT_ID, null)?.let { return it }
19
+ val generated = "mentra-bt-sdk-${UUID.randomUUID()}"
20
+ prefs.edit().putString(PREFS_DISTINCT_ID, generated).apply()
21
+ return generated
22
+ }
23
+ }
@@ -334,6 +334,7 @@ class BluetoothSdkModule : Module() {
334
334
  "glasses_status",
335
335
  "bluetooth_status",
336
336
  "log",
337
+ "sdk_update_notice",
337
338
  "device_discovered",
338
339
  "default_device_changed",
339
340
  // Individual event handlers
@@ -404,7 +405,6 @@ class BluetoothSdkModule : Module() {
404
405
  "send_command_to_ble",
405
406
  "receive_command_from_ble",
406
407
  "miniapp_selected",
407
- "captions_tester_incident",
408
408
  "extraction_progress",
409
409
  )
410
410
 
@@ -550,6 +550,8 @@ class BluetoothSdkModule : Module() {
550
550
 
551
551
  SdkAsyncFunction("forget") { -> sdk?.forget() }
552
552
 
553
+ SdkCoroutineFunction("unpair") { -> deviceManager?.unpair() }
554
+
553
555
  AsyncFunction("connectDefaultController") { deviceManager?.connectDefaultController() }
554
556
 
555
557
  AsyncFunction("disconnectController") { deviceManager?.disconnectController() }
@@ -616,7 +618,7 @@ class BluetoothSdkModule : Module() {
616
618
  // MARK: - Incident Reporting
617
619
 
618
620
  SdkAsyncFunction("sendIncidentId") { incidentId: String, apiBaseUrl: String? ->
619
- sdk?.sendIncidentId(incidentId, apiBaseUrl)
621
+ requireSdk().sendIncidentId(incidentId, apiBaseUrl)
620
622
  }
621
623
 
622
624
  // MARK: - Native Notification Centre
@@ -676,6 +678,10 @@ class BluetoothSdkModule : Module() {
676
678
  sdk?.setLoudnessGateEnabled(enabled)
677
679
  }
678
680
 
681
+ SdkAsyncFunction("setAutoPowerOffEnabled") { enabled: Boolean ->
682
+ sdk?.setAutoPowerOffEnabled(enabled)
683
+ }
684
+
679
685
  @Suppress("DEPRECATION")
680
686
  SdkCoroutineFunction("setPhotoCaptureDefaults") { params: Map<String, Any?> ->
681
687
  requireSdk().setPhotoCaptureDefaults(PhotoCaptureDefaults.fromMap(params)).values
@@ -0,0 +1,184 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import org.json.JSONObject
6
+ import java.net.HttpURLConnection
7
+ import java.net.URL
8
+ import java.util.concurrent.Executors
9
+ import java.util.concurrent.RejectedExecutionException
10
+ import java.util.concurrent.ScheduledExecutorService
11
+ import java.util.concurrent.ScheduledFuture
12
+ import java.util.concurrent.TimeUnit
13
+
14
+ internal data class BluetoothSdkUpdateCheckResult(
15
+ val status: String,
16
+ val channel: String,
17
+ val latestVersion: String?,
18
+ val minimumSupportedVersion: String?,
19
+ ) {
20
+ companion object {
21
+ fun parse(body: String): BluetoothSdkUpdateCheckResult? =
22
+ try {
23
+ val json = JSONObject(body)
24
+ BluetoothSdkUpdateCheckResult(
25
+ status = json.optString("status"),
26
+ channel = json.optString("channel"),
27
+ latestVersion = json.optNonBlankString("latestVersion"),
28
+ minimumSupportedVersion = json.optNonBlankString("minimumSupportedVersion"),
29
+ )
30
+ } catch (_: Exception) {
31
+ null
32
+ }
33
+
34
+ private fun JSONObject.optNonBlankString(key: String): String? =
35
+ if (isNull(key)) null else optString(key).trim().takeIf { it.isNotEmpty() }
36
+ }
37
+ }
38
+
39
+ /** The developer-facing console banner for an out-of-date SDK. Never shown in app UI. */
40
+ internal object BluetoothSdkUpdateNotice {
41
+ const val STATUS_UPDATE_AVAILABLE = "update_available"
42
+ const val STATUS_UNSUPPORTED = "unsupported"
43
+ private const val RULE = "=================================================================="
44
+
45
+ /** Null when the installed SDK is current or the server could not tell. */
46
+ fun banner(installedVersion: String, result: BluetoothSdkUpdateCheckResult, surface: String): String? {
47
+ val latest = result.latestVersion
48
+ val heading =
49
+ when (result.status) {
50
+ STATUS_UNSUPPORTED -> "MENTRA BLUETOOTH SDK $installedVersion IS NO LONGER SUPPORTED"
51
+ STATUS_UPDATE_AVAILABLE -> if (latest != null) "MENTRA BLUETOOTH SDK UPDATE AVAILABLE" else return null
52
+ else -> return null
53
+ }
54
+ return buildString {
55
+ appendLine(RULE)
56
+ appendLine(" $heading")
57
+ appendLine(" Installed: $installedVersion Latest: ${latest ?: "unknown"}")
58
+ appendLine(" Update: ${updateLine(surface, latest)}")
59
+ if (result.status == STATUS_UNSUPPORTED) {
60
+ appendLine(" This version has known issues that are fixed in newer releases.")
61
+ }
62
+ appendLine(" Running an old SDK is the most common cause of glasses issues.")
63
+ append(RULE)
64
+ }
65
+ }
66
+
67
+ private fun updateLine(surface: String, version: String?): String =
68
+ when (surface) {
69
+ "react_native" -> "bun add @mentra/bluetooth-sdk@${version ?: "latest"}"
70
+ "ios" -> "the MentraBluetoothSDK Swift package at ${version ?: "its latest release"}"
71
+ else -> "com.mentraglass:bluetooth-sdk:${version ?: "<latest release>"}"
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Checks Mentra Core for a newer SDK on every SDK start and, when this SDK is
77
+ * behind, prints a console banner now and every [REPRINT_INTERVAL_MINUTES] while
78
+ * the SDK instance lives. The check is one request with a short timeout and no
79
+ * retry; any failure is silent and changes no SDK behavior. It runs regardless
80
+ * of the analytics setting.
81
+ */
82
+ internal class BluetoothSdkUpdateCheck(
83
+ context: Context,
84
+ private val surface: String,
85
+ private val sdkVersion: String = BuildConfig.SDK_VERSION,
86
+ private val transport: (String) -> String? = ::post,
87
+ private val onNotice: (banner: String, result: BluetoothSdkUpdateCheckResult) -> Unit,
88
+ ) {
89
+ private val appContext = context.applicationContext
90
+ private val lock = Any()
91
+ private var stopped = false
92
+ private var reprint: ScheduledFuture<*>? = null
93
+
94
+ fun start() {
95
+ if (!isCheckable(sdkVersion)) return
96
+ try {
97
+ executor.execute { runCatching { check() } }
98
+ } catch (_: RejectedExecutionException) {
99
+ }
100
+ }
101
+
102
+ fun stop() {
103
+ synchronized(lock) {
104
+ stopped = true
105
+ reprint?.cancel(false)
106
+ reprint = null
107
+ }
108
+ }
109
+
110
+ private fun check() {
111
+ val response = transport(requestBody().toString()) ?: return
112
+ val result = BluetoothSdkUpdateCheckResult.parse(response) ?: return
113
+ val banner = BluetoothSdkUpdateNotice.banner(sdkVersion, result, surface) ?: return
114
+ synchronized(lock) {
115
+ if (stopped) return
116
+ onNotice(banner, result)
117
+ reprint =
118
+ executor.scheduleAtFixedRate(
119
+ { synchronized(lock) { if (!stopped) onNotice(banner, result) } },
120
+ REPRINT_INTERVAL_MINUTES,
121
+ REPRINT_INTERVAL_MINUTES,
122
+ TimeUnit.MINUTES,
123
+ )
124
+ }
125
+ }
126
+
127
+ internal fun requestBody(): JSONObject {
128
+ val host = BluetoothSdkAnalyticsHost.resolve(appContext)
129
+ return JSONObject().apply {
130
+ put("install_id", BluetoothSdkInstallId.get(appContext))
131
+ put("app_identifier", appContext.packageName)
132
+ host.appVersion?.let { put("app_version", it) }
133
+ host.appBuild?.let { put("app_build", it) }
134
+ put("app_build_type", host.buildType)
135
+ put("app_install_source", host.installSource)
136
+ host.environment?.let { put("app_environment", it) }
137
+ put("sdk_version", sdkVersion)
138
+ put("sdk_platform", "android")
139
+ put("sdk_surface", surface)
140
+ put("os_platform", "android")
141
+ put("os_version", Build.VERSION.SDK_INT.toString())
142
+ }
143
+ }
144
+
145
+ companion object {
146
+ const val UPDATE_CHECK_URL = "https://core.mentraglass.com/api/client/bluetooth-sdk/update-check"
147
+ const val REPRINT_INTERVAL_MINUTES = 15L
148
+ private const val TIMEOUT_MS = 4_000
149
+
150
+ private val executor: ScheduledExecutorService =
151
+ Executors.newSingleThreadScheduledExecutor { runnable ->
152
+ Thread(runnable, "MentraBluetoothSdkUpdateCheck").apply { isDaemon = true }
153
+ }
154
+
155
+ fun isCheckable(version: String): Boolean = version.isNotBlank() && version != "unknown"
156
+
157
+ private fun post(body: String): String? {
158
+ val connection = URL(UPDATE_CHECK_URL).openConnection() as HttpURLConnection
159
+ return try {
160
+ connection.requestMethod = "POST"
161
+ connection.connectTimeout = TIMEOUT_MS
162
+ connection.readTimeout = TIMEOUT_MS
163
+ connection.doOutput = true
164
+ connection.setRequestProperty("Content-Type", "application/json")
165
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
166
+ if (connection.responseCode != 200) {
167
+ connection.errorStream?.close()
168
+ null
169
+ } else {
170
+ connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
171
+ }
172
+ } catch (_: Exception) {
173
+ null
174
+ } finally {
175
+ connection.disconnect()
176
+ }
177
+ }
178
+
179
+ /** Test hook: waits until every task submitted so far has run. */
180
+ internal fun awaitIdle() {
181
+ executor.submit {}.get()
182
+ }
183
+ }
184
+ }