@mentra/bluetooth-sdk 3.2.0-dev.180 → 3.2.0-dev.187
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 +29 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcher.kt +69 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +66 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/NativeNotifications.kt +42 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +38 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenFileService.kt +101 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenNotificationProtocol.kt +113 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +378 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NotificationIds.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcherTest.kt +161 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +87 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/EvenFileServiceTest.kt +153 -0
- package/build/BluetoothSdk.types.d.ts +57 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +6 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +28 -49
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/scanSession.d.ts +21 -0
- package/build/_private/scanSession.d.ts.map +1 -0
- package/build/_private/scanSession.js +75 -0
- package/build/_private/scanSession.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/react/useBluetoothScan.d.ts +2 -1
- package/build/react/useBluetoothScan.d.ts.map +1 -1
- package/build/react/useBluetoothScan.js +13 -0
- package/build/react/useBluetoothScan.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -1
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +1 -0
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +30 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +53 -2
- package/ios/Source/NativeNotifications.swift +52 -0
- package/ios/Source/connection/ScanSession.swift +11 -0
- package/ios/Source/internal/BluetoothAvailability.swift +7 -0
- package/ios/Source/internal/ConnectedDeviceMatcher.swift +45 -0
- package/ios/Source/sgcs/G2.swift +131 -21
- package/ios/Source/sgcs/SGCManager.swift +22 -0
- package/ios/Tests/ConnectedDeviceMatcherTests.swift +27 -0
- package/ios/Tests/NativeNotificationTests.swift +20 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +62 -0
- package/src/_private/BluetoothSdkModule.ts +47 -57
- package/src/_private/scanSession.ts +88 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +7 -0
- package/src/react/useBluetoothScan.ts +12 -1
- package/src/react/useMentraBluetooth.ts +2 -0
package/README.md
CHANGED
|
@@ -125,6 +125,7 @@ const devices = await BluetoothSdk.scan(DeviceModels.MentraLive, {
|
|
|
125
125
|
onResults: (nextDevices) => {
|
|
126
126
|
console.log('Nearby glasses:', nextDevices)
|
|
127
127
|
},
|
|
128
|
+
onDiagnostic: (hint) => showScanHint(hint.message),
|
|
128
129
|
})
|
|
129
130
|
|
|
130
131
|
const device = await chooseDevice(devices)
|
|
@@ -137,6 +138,12 @@ const versionInfo = await BluetoothSdk.requestVersionInfo()
|
|
|
137
138
|
console.log(versionInfo.buildNumber)
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
`onDiagnostic` optionally reports a matching phone connection after an empty scan
|
|
142
|
+
on Android and iOS. It is advisory, does not identify another app, and leaves
|
|
143
|
+
normal empty completion unchanged. React hooks expose it as `scan.diagnostic`
|
|
144
|
+
on `useMentraBluetooth()`, or `diagnostic` on `useBluetoothScan()`. Clear the hint
|
|
145
|
+
when retrying or connecting.
|
|
146
|
+
|
|
140
147
|
In multi-device environments, present an explicit picker instead of
|
|
141
148
|
auto-connecting to the first nearby device.
|
|
142
149
|
|
|
@@ -288,6 +295,28 @@ await BluetoothSdk.clearDefaultDevice()
|
|
|
288
295
|
await saveDeviceToYourAppStorage(null)
|
|
289
296
|
```
|
|
290
297
|
|
|
298
|
+
## Native notification controls
|
|
299
|
+
|
|
300
|
+
G2 exposes firmware-owned notification history and popups. `getNativeNotificationStatus()` reports support, source (`phone` on Android or `ancs` on iOS), authorization, the desired controls, and submission/failure state. `submitted` means the controls were queued for the glasses, not that firmware acknowledged them. Unsupported drivers reject configuration instead of silently succeeding.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
await BluetoothSdk.configureNativeNotifications({
|
|
304
|
+
enabled: true,
|
|
305
|
+
autoDisplay: true, // false keeps history without automatic popups
|
|
306
|
+
durationSeconds: 5, // 1–30
|
|
307
|
+
doNotDisturb: false,
|
|
308
|
+
blockedApps: [], // Android package names; iOS requires an empty list
|
|
309
|
+
})
|
|
310
|
+
const status = await BluetoothSdk.getNativeNotificationStatus()
|
|
311
|
+
const subscription = BluetoothSdk.addListener('native_notification_status', handleStatus)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The native SDKs expose the same configuration/status methods and `NativeNotificationConfig` type. Engine hosts should use the shared `engine.phoneNotifications` presentation policy and settings rather than competing with it through direct SDK configuration.
|
|
315
|
+
|
|
316
|
+
Android content uploads use the existing phone listener and a bounded G2 file-transfer queue. `native_notification_delivery` reports delivered, failed, cancelled, or dropped notifications by id without their content. Delivered means all three file phases acknowledged success. Disable, settings changes, and disconnect invalidate pending work. An interrupted or timed-out upload requires reconnect before another upload because the verified ACK format has no transaction id.
|
|
317
|
+
|
|
318
|
+
iOS G2 receives notifications directly through ANCS. Authorization is reported from the connected accessory and controls are reapplied after reconnect or authorization changes. No iOS content upload or full title/body relay is exposed. iOS keeps the firmware's existing app filter and rejects a nonempty `blockedApps` list. Phone dismissals are not yet synchronized to G2 history; the verified protocol does not establish a removal command.
|
|
319
|
+
|
|
291
320
|
## Common Commands
|
|
292
321
|
|
|
293
322
|
```ts
|
|
@@ -367,6 +367,8 @@ class BluetoothSdkModule : Module() {
|
|
|
367
367
|
"audio_disconnected",
|
|
368
368
|
"save_setting",
|
|
369
369
|
"phone_notification",
|
|
370
|
+
"native_notification_status",
|
|
371
|
+
"native_notification_delivery",
|
|
370
372
|
"phone_notification_dismissed",
|
|
371
373
|
"ws_text",
|
|
372
374
|
"ws_bin",
|
|
@@ -541,6 +543,12 @@ class BluetoothSdkModule : Module() {
|
|
|
541
543
|
sdk?.startScan(DeviceModel.fromDeviceType(model))
|
|
542
544
|
}
|
|
543
545
|
|
|
546
|
+
SdkAsyncFunction("getScanDiagnostic") { model: String ->
|
|
547
|
+
sdk?.connectedDeviceScanDiagnostic(DeviceModel.fromDeviceType(model))?.let {
|
|
548
|
+
mapOf("code" to it.code, "message" to it.message)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
544
552
|
SdkAsyncFunction("stopScan") { -> sdk?.stopScan() }
|
|
545
553
|
|
|
546
554
|
SdkAsyncFunction("cancelConnectionAttempt") { -> sdk?.cancelConnectionAttempt() }
|
|
@@ -567,6 +575,19 @@ class BluetoothSdkModule : Module() {
|
|
|
567
575
|
sdk?.sendIncidentId(incidentId, apiBaseUrl)
|
|
568
576
|
}
|
|
569
577
|
|
|
578
|
+
// MARK: - Native Notification Centre
|
|
579
|
+
|
|
580
|
+
// Public controls use the SDK facade; phone payload delivery uses DeviceManager.
|
|
581
|
+
SdkAsyncFunction("configureNativeNotifications") { config: Map<String, Any> ->
|
|
582
|
+
requireSdk().configureNativeNotifications(NativeNotificationConfig.fromMap(config))
|
|
583
|
+
}
|
|
584
|
+
SdkAsyncFunction("getNativeNotificationStatus") { ->
|
|
585
|
+
requireSdk().getNativeNotificationStatus().toMap()
|
|
586
|
+
}
|
|
587
|
+
AsyncFunction("sendPhoneNotification") { notification: Map<String, Any> ->
|
|
588
|
+
deviceManager?.sendPhoneNotification(notification)
|
|
589
|
+
}
|
|
590
|
+
|
|
570
591
|
// MARK: - WiFi Commands
|
|
571
592
|
|
|
572
593
|
SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure matcher deciding whether a GATT-connected peripheral (as enumerated by
|
|
5
|
+
* `BluetoothManager.getConnectedDevices`) is plausibly the glasses a scan for a
|
|
6
|
+
* given [DeviceModel] was looking for. This is a name/address heuristic, not
|
|
7
|
+
* proof of device identity or connection ownership.
|
|
8
|
+
*
|
|
9
|
+
* Kept free of Android framework types so the logic is unit-testable on the
|
|
10
|
+
* JVM (see `ConnectedDeviceMatcherTest`).
|
|
11
|
+
*/
|
|
12
|
+
internal object ConnectedDeviceMatcher {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when the candidate peripheral either matches the saved default
|
|
16
|
+
* device for the scanned [model] (by address ignoring case when both
|
|
17
|
+
* addresses exist, otherwise by a nonblank exact name) or carries a
|
|
18
|
+
* known advertised-name prefix for that model.
|
|
19
|
+
*
|
|
20
|
+
* [defaultDevice] only counts when its model equals the scanned [model];
|
|
21
|
+
* a saved Mentra Live cannot explain an empty Nex scan. [DeviceModel.SIMULATED]
|
|
22
|
+
* never matches anything.
|
|
23
|
+
*/
|
|
24
|
+
fun matches(
|
|
25
|
+
model: DeviceModel,
|
|
26
|
+
defaultDevice: Device?,
|
|
27
|
+
candidateName: String?,
|
|
28
|
+
candidateAddress: String?,
|
|
29
|
+
): Boolean {
|
|
30
|
+
if (model == DeviceModel.SIMULATED) return false
|
|
31
|
+
val matchesDefault = if (defaultDevice?.model == model) {
|
|
32
|
+
val savedAddress = defaultDevice.address?.takeIf { it.isNotBlank() }
|
|
33
|
+
val address = candidateAddress?.takeIf { it.isNotBlank() }
|
|
34
|
+
if (savedAddress != null && address != null) {
|
|
35
|
+
address.equals(savedAddress, ignoreCase = true)
|
|
36
|
+
} else {
|
|
37
|
+
!candidateName.isNullOrBlank() && candidateName == defaultDevice.name
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
false
|
|
41
|
+
}
|
|
42
|
+
return matchesDefault || (candidateName != null && matchesModelNamePrefix(model, candidateName))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-model advertised/bonded-name filters, mirroring the scan filters the
|
|
47
|
+
* SGCs apply: MentraLive.kt (`Xy_A`, `XyBLE_`, `MENTRA_LIVE_BLE`,
|
|
48
|
+
* `MENTRA_LIVE_BT`, lowercase `mentra_live`) and MentraNex.kt (`Nex1-`,
|
|
49
|
+
* `MENTRA_DISPLAY_`). Models without a Mentra-branded advertised name
|
|
50
|
+
* (G1, G2, Nimo, Ar99, ...) have no prefix entry here and are detected
|
|
51
|
+
* only through the saved-default match above.
|
|
52
|
+
*/
|
|
53
|
+
private fun matchesModelNamePrefix(
|
|
54
|
+
model: DeviceModel,
|
|
55
|
+
name: String,
|
|
56
|
+
): Boolean =
|
|
57
|
+
when (model) {
|
|
58
|
+
DeviceModel.MENTRA_LIVE ->
|
|
59
|
+
name == "Xy_A" ||
|
|
60
|
+
name.startsWith("XyBLE_") ||
|
|
61
|
+
name.startsWith("MENTRA_LIVE_BLE") ||
|
|
62
|
+
name.startsWith("MENTRA_LIVE_BT") ||
|
|
63
|
+
name.lowercase().startsWith("mentra_live")
|
|
64
|
+
DeviceModel.MENTRA_NEX ->
|
|
65
|
+
name.startsWith("Nex1-") ||
|
|
66
|
+
name.startsWith("MENTRA_DISPLAY_")
|
|
67
|
+
else -> false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1789,6 +1789,14 @@ class DeviceManager {
|
|
|
1789
1789
|
sgc?.sendIncidentId(incidentId, apiBaseUrl)
|
|
1790
1790
|
}
|
|
1791
1791
|
|
|
1792
|
+
/** Push a notification into the glasses' own notification centre; rejects unsupported or disconnected devices. */
|
|
1793
|
+
fun sendPhoneNotification(notification: Map<String, Any>) {
|
|
1794
|
+
// Package only - never the notification text.
|
|
1795
|
+
Bridge.log("MAN: sendPhoneNotification from ${notification["packageName"]}")
|
|
1796
|
+
val driver = sgc ?: throw IllegalStateException("Glasses are not connected")
|
|
1797
|
+
driver.sendPhoneNotification(notification)
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1792
1800
|
fun sendWifiCredentials(ssid: String, password: String) {
|
|
1793
1801
|
Bridge.log("MAN: Sending wifi credentials: $ssid")
|
|
1794
1802
|
sgc?.sendWifiCredentials(ssid, password)
|
|
@@ -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.187"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.187"
|
|
8
|
+
const val SOURCE_COMMIT: String = "d8b34892f11593baa3c075b94380253b06eb874d"
|
|
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.187.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "cf21f4ab6347acb12eefa38d2f0a3b433bc0e05428c527bcda57a8693d7a72f9"
|
|
11
11
|
}
|
|
@@ -3,6 +3,7 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.app.Application
|
|
5
5
|
import android.bluetooth.BluetoothManager
|
|
6
|
+
import android.bluetooth.BluetoothProfile
|
|
6
7
|
import android.content.Context
|
|
7
8
|
import android.os.Bundle
|
|
8
9
|
import android.os.Handler
|
|
@@ -347,6 +348,16 @@ class MentraBluetoothSdk private constructor(
|
|
|
347
348
|
timeoutMs = timeoutMs,
|
|
348
349
|
)
|
|
349
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Scans for glasses of [model], reporting through [callback].
|
|
353
|
+
*
|
|
354
|
+
* A completed or cancelled scan ends with [ScanCallback.onComplete].
|
|
355
|
+
* [ScanCallback.onError] remains reserved for failure to start scanning.
|
|
356
|
+
* Callbacks implementing [ScanDiagnosticCallback] may receive a best-effort
|
|
357
|
+
* hint before an empty completed scan. Android reports phone-wide connection
|
|
358
|
+
* state, so this hint cannot identify an owning app or prove why scanning
|
|
359
|
+
* found no devices. React Native's separate scan implementation is unaffected.
|
|
360
|
+
*/
|
|
350
361
|
@JvmOverloads
|
|
351
362
|
fun scan(
|
|
352
363
|
model: DeviceModel,
|
|
@@ -378,7 +389,9 @@ class MentraBluetoothSdk private constructor(
|
|
|
378
389
|
mainHandler.removeCallbacks(timeoutRunnable)
|
|
379
390
|
session.markStopped()
|
|
380
391
|
stopScan(reason)
|
|
381
|
-
callback.
|
|
392
|
+
callback.completeScan(reason, latestResults.toList()) {
|
|
393
|
+
connectedDeviceScanDiagnostic(model)
|
|
394
|
+
}
|
|
382
395
|
}
|
|
383
396
|
|
|
384
397
|
timeoutRunnable = Runnable { finish(ScanStopReason.COMPLETED) }
|
|
@@ -493,6 +506,15 @@ class MentraBluetoothSdk private constructor(
|
|
|
493
506
|
deviceManager.displayEvent(request.toMap())
|
|
494
507
|
}
|
|
495
508
|
|
|
509
|
+
/** Configure native notification presentation on a supported connected driver. */
|
|
510
|
+
fun configureNativeNotifications(config: NativeNotificationConfig) {
|
|
511
|
+
val driver = deviceManager.sgc ?: throw IllegalStateException("Glasses are not connected")
|
|
512
|
+
driver.configureNativeNotifications(config)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
fun getNativeNotificationStatus(): NativeNotificationStatus =
|
|
516
|
+
deviceManager.sgc?.getNativeNotificationStatus() ?: NativeNotificationStatus()
|
|
517
|
+
|
|
496
518
|
fun clearDisplay() {
|
|
497
519
|
deviceManager.clearDisplay()
|
|
498
520
|
}
|
|
@@ -1494,6 +1516,49 @@ class MentraBluetoothSdk private constructor(
|
|
|
1494
1516
|
)
|
|
1495
1517
|
}
|
|
1496
1518
|
|
|
1519
|
+
/**
|
|
1520
|
+
* A model-compatible device already connected to this phone can explain an
|
|
1521
|
+
* empty scan, but Android does not expose app ownership or exclusive use.
|
|
1522
|
+
* Skip known SDK connections/attempts and unavailable Bluetooth permissions.
|
|
1523
|
+
*/
|
|
1524
|
+
internal fun connectedDeviceScanDiagnostic(model: DeviceModel): ScanDiagnostic? {
|
|
1525
|
+
if (model == DeviceModel.SIMULATED) return null
|
|
1526
|
+
val glassesStatus = getRawGlassesStatus()
|
|
1527
|
+
if (glassesStatus.connected ||
|
|
1528
|
+
glassesStatus.connectionState == GlassesConnectionState.CONNECTED ||
|
|
1529
|
+
glassesStatus.connectionState == GlassesConnectionState.CONNECTING ||
|
|
1530
|
+
glassesStatus.connectionState == GlassesConnectionState.BONDING
|
|
1531
|
+
) {
|
|
1532
|
+
return null
|
|
1533
|
+
}
|
|
1534
|
+
return try {
|
|
1535
|
+
val bluetoothManager =
|
|
1536
|
+
appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return null
|
|
1537
|
+
val adapter = bluetoothManager.adapter ?: return null
|
|
1538
|
+
if (!adapter.isEnabled) return null
|
|
1539
|
+
val defaultDevice = currentDefaultDevice()
|
|
1540
|
+
val device = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
|
|
1541
|
+
.firstOrNull { device ->
|
|
1542
|
+
ConnectedDeviceMatcher.matches(
|
|
1543
|
+
model = model,
|
|
1544
|
+
defaultDevice = defaultDevice,
|
|
1545
|
+
candidateName = device.name,
|
|
1546
|
+
candidateAddress = device.address,
|
|
1547
|
+
)
|
|
1548
|
+
} ?: return null
|
|
1549
|
+
val name = device.name?.takeIf { it.isNotBlank() } ?: device.address
|
|
1550
|
+
ScanDiagnostic(
|
|
1551
|
+
code = "device_connected_on_phone",
|
|
1552
|
+
message =
|
|
1553
|
+
"Scan found no glasses, but a matching device \"$name\" is already connected to this phone. " +
|
|
1554
|
+
"If another app is using it, disconnect it there and scan again.",
|
|
1555
|
+
)
|
|
1556
|
+
} catch (error: SecurityException) {
|
|
1557
|
+
// BLUETOOTH_CONNECT can be absent or revoked while scanning.
|
|
1558
|
+
null
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1497
1562
|
private fun requireBluetoothReady(operation: String) {
|
|
1498
1563
|
val bluetoothManager = appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
|
1499
1564
|
val adapter =
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
/** Desired notification-centre controls. Completion means submitted, not firmware-confirmed. */
|
|
4
|
+
data class NativeNotificationConfig(
|
|
5
|
+
val enabled: Boolean = false,
|
|
6
|
+
val autoDisplay: Boolean = true,
|
|
7
|
+
val durationSeconds: Int = 5,
|
|
8
|
+
val doNotDisturb: Boolean = false,
|
|
9
|
+
val blockedApps: List<String> = emptyList(),
|
|
10
|
+
) {
|
|
11
|
+
init { require(durationSeconds in 1..30) { "durationSeconds must be between 1 and 30" } }
|
|
12
|
+
|
|
13
|
+
fun toMap(): Map<String, Any> = mapOf(
|
|
14
|
+
"enabled" to enabled, "autoDisplay" to autoDisplay,
|
|
15
|
+
"durationSeconds" to durationSeconds, "doNotDisturb" to doNotDisturb, "blockedApps" to blockedApps,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
companion object {
|
|
19
|
+
fun fromMap(value: Map<String, Any>) = NativeNotificationConfig(
|
|
20
|
+
enabled = value["enabled"] as? Boolean ?: false,
|
|
21
|
+
autoDisplay = value["autoDisplay"] as? Boolean ?: true,
|
|
22
|
+
durationSeconds = (value["durationSeconds"] as? Number)?.toInt() ?: 5,
|
|
23
|
+
doNotDisturb = value["doNotDisturb"] as? Boolean ?: false,
|
|
24
|
+
blockedApps = (value["blockedApps"] as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Availability is independent from notification content access and firmware acknowledgement. */
|
|
30
|
+
data class NativeNotificationStatus(
|
|
31
|
+
val supported: Boolean = false,
|
|
32
|
+
val source: String = "unsupported",
|
|
33
|
+
val authorization: String = "unknown",
|
|
34
|
+
val state: String = "unavailable",
|
|
35
|
+
val config: NativeNotificationConfig = NativeNotificationConfig(),
|
|
36
|
+
val error: String = "",
|
|
37
|
+
) {
|
|
38
|
+
fun toMap(): Map<String, Any> = mapOf(
|
|
39
|
+
"supported" to supported, "source" to source, "authorization" to authorization,
|
|
40
|
+
"state" to state, "config" to config.toMap(), "error" to error,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -53,13 +53,50 @@ enum class ScanStopReason {
|
|
|
53
53
|
ERROR,
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Callbacks for `MentraBluetoothSdk.scan`.
|
|
58
|
+
*
|
|
59
|
+
* Completed and cancelled scans report [onComplete]. A failure to start reports
|
|
60
|
+
* [onError] and is thrown to the caller. Optional empty-scan hints are delivered
|
|
61
|
+
* separately through [ScanDiagnosticCallback], never as errors.
|
|
62
|
+
*/
|
|
56
63
|
interface ScanCallback {
|
|
57
64
|
fun onResults(devices: List<Device>) {}
|
|
58
65
|
fun onComplete(devices: List<Device>) {}
|
|
59
66
|
fun onError(error: BluetoothError) {}
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
|
|
69
|
+
/** A non-fatal scan hint; it does not establish the cause of an empty result. */
|
|
70
|
+
data class ScanDiagnostic(val code: String, val message: String)
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Optional scan diagnostics, delivered before [onComplete] for an empty completed
|
|
74
|
+
* scan. Kept separate so existing compiled [ScanCallback] implementations do not
|
|
75
|
+
* need a new method. Android's `device_connected_on_phone` hint identifies a
|
|
76
|
+
* model-compatible GATT connection on this phone, not which app owns it.
|
|
77
|
+
*/
|
|
78
|
+
interface ScanDiagnosticCallback : ScanCallback {
|
|
79
|
+
fun onDiagnostic(diagnostic: ScanDiagnostic)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
abstract class MentraBluetoothScanCallback : ScanDiagnosticCallback {
|
|
83
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Complete once, with an optional advisory that never replaces completion. */
|
|
87
|
+
internal fun ScanCallback.completeScan(
|
|
88
|
+
reason: ScanStopReason,
|
|
89
|
+
devices: List<Device>,
|
|
90
|
+
diagnostic: () -> ScanDiagnostic?,
|
|
91
|
+
) {
|
|
92
|
+
try {
|
|
93
|
+
if (this is ScanDiagnosticCallback && reason == ScanStopReason.COMPLETED && devices.isEmpty()) {
|
|
94
|
+
diagnostic()?.let { onDiagnostic(it) }
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
onComplete(devices)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
63
100
|
|
|
64
101
|
class ScanSession internal constructor(
|
|
65
102
|
private val stopAction: () -> Unit,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
5
|
+
import kotlinx.coroutines.sync.Mutex
|
|
6
|
+
import kotlinx.coroutines.sync.withLock
|
|
7
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
8
|
+
|
|
9
|
+
/** A single in-flight Even file upload. The wire ACK has no transaction id. */
|
|
10
|
+
internal class EvenFileService(
|
|
11
|
+
private val timeoutMs: Long = 15_000,
|
|
12
|
+
private val send: suspend (Byte, ByteArray) -> Unit,
|
|
13
|
+
) {
|
|
14
|
+
private val mutex = Mutex()
|
|
15
|
+
private data class Pending(val generation: Long, val cid: Int, val result: CompletableDeferred<Int>)
|
|
16
|
+
@Volatile private var generation = 0L
|
|
17
|
+
@Volatile private var pending: Pending? = null
|
|
18
|
+
@Volatile var needsReconnect = false
|
|
19
|
+
private set
|
|
20
|
+
|
|
21
|
+
/** Only a new physical connection can clear an ambiguous in-flight transfer. */
|
|
22
|
+
@Synchronized fun resetConnection() {
|
|
23
|
+
generation++
|
|
24
|
+
pending?.result?.cancel()
|
|
25
|
+
pending = null
|
|
26
|
+
needsReconnect = false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fun acceptFrame(frame: ByteArray) {
|
|
30
|
+
decodeAckFrame(frame)?.let(::acceptAck)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reject old-connection callbacks before calling this method. */
|
|
34
|
+
@Synchronized fun acceptAck(payload: ByteArray) {
|
|
35
|
+
if (payload.size != 2) return
|
|
36
|
+
val current = pending ?: return
|
|
37
|
+
if (current.generation == generation && current.cid == (payload[0].toInt() and 0xff)) {
|
|
38
|
+
current.result.complete(payload[1].toInt() and 0xff)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private suspend fun phase(epoch: Long, cid: Int, write: suspend () -> Unit): Int {
|
|
43
|
+
val ack = synchronized(this) {
|
|
44
|
+
check(epoch == generation) { "connection_changed" }
|
|
45
|
+
CompletableDeferred<Int>().also { pending = Pending(epoch, cid, it) }
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
write()
|
|
49
|
+
return withTimeoutOrNull(timeoutMs) { ack.await() } ?: throw IOException("ack_timeout")
|
|
50
|
+
} finally {
|
|
51
|
+
synchronized(this) { if (pending?.result === ack) pending = null }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
suspend fun transfer(fileType: Int, filename: String, bytes: ByteArray): Int = mutex.withLock {
|
|
56
|
+
check(!needsReconnect) { "needs_reconnect" }
|
|
57
|
+
val epoch = generation
|
|
58
|
+
try {
|
|
59
|
+
val start = phase(epoch, FileService.CID_SEND_START) {
|
|
60
|
+
send(0xC4.toByte(), FileService.sendStart(fileType, bytes.size, crc32(bytes), filename))
|
|
61
|
+
}
|
|
62
|
+
if (start != 0) return@withLock start
|
|
63
|
+
val data = phase(epoch, FileService.CID_SEND_DATA) {
|
|
64
|
+
send(0xC4.toByte(), FileService.sendData())
|
|
65
|
+
send(0xC5.toByte(), bytes)
|
|
66
|
+
}
|
|
67
|
+
if (data != 0) return@withLock data
|
|
68
|
+
phase(epoch, FileService.CID_SEND_RESULT_CHECK) {
|
|
69
|
+
send(0xC4.toByte(), FileService.resultCheck())
|
|
70
|
+
}
|
|
71
|
+
} catch (error: Exception) {
|
|
72
|
+
// No abort ACK or transaction id exists in the verified protocol. Reusing
|
|
73
|
+
// this channel after a timeout/cancel could accept the old transfer's ACK.
|
|
74
|
+
synchronized(this) { if (epoch == generation) needsReconnect = true }
|
|
75
|
+
throw error
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
companion object {
|
|
80
|
+
/** Verified ACK shape: one frame carrying cid/status plus payload CRC16. */
|
|
81
|
+
fun decodeAckFrame(frame: ByteArray): ByteArray? {
|
|
82
|
+
if (frame.size != 12 || frame[0] != 0xAA.toByte() || frame[3] != 4.toByte() ||
|
|
83
|
+
frame[4] != 1.toByte() || frame[5] != 1.toByte() ||
|
|
84
|
+
(frame[6] != 0xC4.toByte() && frame[6] != 0xC5.toByte()) ||
|
|
85
|
+
((frame[7].toInt() shr 1) and 0x0F) != 0) return null
|
|
86
|
+
val payload = frame.copyOfRange(8, 10)
|
|
87
|
+
val crc = (frame[10].toInt() and 0xff) or ((frame[11].toInt() and 0xff) shl 8)
|
|
88
|
+
return payload.takeIf { g2Crc16(it) == crc }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** CRC-32/Castagnoli, MSB-first, zero seed and no final xor (Even wire format). */
|
|
92
|
+
fun crc32(bytes: ByteArray): Int {
|
|
93
|
+
var crc = 0
|
|
94
|
+
for (byte in bytes) {
|
|
95
|
+
crc = crc xor ((byte.toInt() and 0xff) shl 24)
|
|
96
|
+
repeat(8) { crc = if (crc < 0) (crc shl 1) xor 0x1EDC6F41 else crc shl 1 }
|
|
97
|
+
}
|
|
98
|
+
return crc
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream
|
|
4
|
+
|
|
5
|
+
// ---------- Even File Service (service 0xC4 cmd / 0xC5 data) ----------
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The glasses' generic file-push channel, and how notification content gets across: the body is a
|
|
9
|
+
* JSON document pushed as a file. Unlike every other G2 service this is **not** protobuf —
|
|
10
|
+
* SEND_START is a fixed 93-byte struct and the data phase writes raw bytes.
|
|
11
|
+
*
|
|
12
|
+
* Each phase is acked with a 2-byte `[cid][status]`:
|
|
13
|
+
* START(fileType, length, crc32, filename) → ack → DATA → raw bytes on 0xC5 → ack
|
|
14
|
+
* → RESULT_CHECK → ack
|
|
15
|
+
*/
|
|
16
|
+
internal object FileService {
|
|
17
|
+
// eEvenFileSendServiceCID — first byte of every 0xC4 payload
|
|
18
|
+
const val CID_SEND_START = 0
|
|
19
|
+
const val CID_SEND_DATA = 1
|
|
20
|
+
const val CID_SEND_RESULT_CHECK = 2
|
|
21
|
+
|
|
22
|
+
const val TYPE_ANDROID_MSG_JSON_NOTIFICATION = 1
|
|
23
|
+
|
|
24
|
+
// Notification bodies and the whitelist share this path: the firmware has one filename
|
|
25
|
+
// constant (`BleG2GlassesFilePath.notifyWhitelist`) and discriminates on `fileType`.
|
|
26
|
+
const val PATH_NOTIFY = "user/notify_whitelist.json"
|
|
27
|
+
|
|
28
|
+
const val FILENAME_FIELD_LEN = 80
|
|
29
|
+
const val SEND_START_LEN = 93 // 1 + 4 + 4 + 4 + 80
|
|
30
|
+
|
|
31
|
+
// eEvenFileServiceRsp
|
|
32
|
+
fun statusName(status: Int): String =
|
|
33
|
+
when (status) {
|
|
34
|
+
0 -> "SUCCESS"
|
|
35
|
+
1 -> "START_ERR"
|
|
36
|
+
2 -> "DATA_CRC_ERR"
|
|
37
|
+
3 -> "FLASH_WRITE_ERR"
|
|
38
|
+
4 -> "TIMEOUT"
|
|
39
|
+
5 -> "NO_RESOURCES"
|
|
40
|
+
6 -> "RESULT_CHECK_FAIL"
|
|
41
|
+
7 -> "FAIL"
|
|
42
|
+
8 -> "CANCEL"
|
|
43
|
+
else -> "status_$status"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private fun ByteArrayOutputStream.writeU32LE(value: Int) {
|
|
47
|
+
write(value and 0xFF)
|
|
48
|
+
write((value ushr 8) and 0xFF)
|
|
49
|
+
write((value ushr 16) and 0xFF)
|
|
50
|
+
write((value ushr 24) and 0xFF)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 93-byte fixed struct: cid | fileType u32 | fileLength u32 | fileCrc32 u32 | filename[80]. */
|
|
54
|
+
fun sendStart(fileType: Int, fileLength: Int, fileCrc32: Int, filename: String): ByteArray {
|
|
55
|
+
val out = ByteArrayOutputStream()
|
|
56
|
+
out.write(CID_SEND_START)
|
|
57
|
+
out.writeU32LE(fileType)
|
|
58
|
+
out.writeU32LE(fileLength)
|
|
59
|
+
out.writeU32LE(fileCrc32)
|
|
60
|
+
|
|
61
|
+
val nameBytes = filename.toByteArray(Charsets.US_ASCII)
|
|
62
|
+
require(nameBytes.size < FILENAME_FIELD_LEN) { "filename too long: $filename" }
|
|
63
|
+
out.write(nameBytes)
|
|
64
|
+
repeat(FILENAME_FIELD_LEN - nameBytes.size) { out.write(0) } // NUL-padded to 80
|
|
65
|
+
|
|
66
|
+
return out.toByteArray().also { check(it.size == SEND_START_LEN) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun sendData(): ByteArray = byteArrayOf(CID_SEND_DATA.toByte())
|
|
70
|
+
|
|
71
|
+
fun resultCheck(): ByteArray = byteArrayOf(CID_SEND_RESULT_CHECK.toByte())
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- Notification payload JSON ----------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The JSON document the glasses expect on the file service — these nine fields, exactly. Schema
|
|
78
|
+
* confirmed against a BLE capture of the Even app; see `notes/g2-notification-service.md`.
|
|
79
|
+
*
|
|
80
|
+
* `time_s` is UTC epoch seconds while `date` is **device-local** wall time; formatting `date` in
|
|
81
|
+
* UTC would skew every displayed timestamp by the device's offset.
|
|
82
|
+
*/
|
|
83
|
+
internal object NotificationJson {
|
|
84
|
+
fun androidNotification(
|
|
85
|
+
msgId: Int,
|
|
86
|
+
action: Int,
|
|
87
|
+
appIdentifier: String,
|
|
88
|
+
title: String,
|
|
89
|
+
subtitle: String,
|
|
90
|
+
message: String,
|
|
91
|
+
postTimeMs: Long,
|
|
92
|
+
displayName: String
|
|
93
|
+
): ByteArray {
|
|
94
|
+
val dateFmt = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss", java.util.Locale.US)
|
|
95
|
+
val body =
|
|
96
|
+
org.json.JSONObject()
|
|
97
|
+
.put("msg_id", msgId)
|
|
98
|
+
.put("action", action)
|
|
99
|
+
.put("app_identifier", appIdentifier)
|
|
100
|
+
.put("title", title)
|
|
101
|
+
.put("subtitle", subtitle)
|
|
102
|
+
.put("message", message)
|
|
103
|
+
.put("time_s", postTimeMs / 1000)
|
|
104
|
+
.put("date", dateFmt.format(java.util.Date(postTimeMs)))
|
|
105
|
+
.put("display_name", displayName)
|
|
106
|
+
|
|
107
|
+
return org.json.JSONObject()
|
|
108
|
+
.put("android_notification", body)
|
|
109
|
+
.toString()
|
|
110
|
+
.toByteArray(Charsets.UTF_8)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|