@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.200
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 +19 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +15 -1
- package/ios/Source/DeviceManager.swift +7 -3
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +32 -6
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
- package/ios/Source/sgcs/MentraLive.swift +9 -2
- package/ios/Source/status/DeviceStatus.swift +2 -3
- package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
- package/package.json +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
package/README.md
CHANGED
|
@@ -115,6 +115,24 @@ Android apps should request the permissions required by the features they use:
|
|
|
115
115
|
|
|
116
116
|
Some Android 12+ devices require Location permission and Location services before BLE scan callbacks are delivered.
|
|
117
117
|
|
|
118
|
+
### Android Foreground Service Types
|
|
119
|
+
|
|
120
|
+
The SDK service reads its allowed types from the merged Android manifest.
|
|
121
|
+
The default manifest retains MentraOS's connected-device, microphone, location,
|
|
122
|
+
media-playback and data-sync capabilities. A Bluetooth-only host can override
|
|
123
|
+
`com.mentra.bluetoothsdk.services.ForegroundService` to use `connectedDevice`
|
|
124
|
+
with `tools:replace="android:foregroundServiceType"`, and remove unused typed
|
|
125
|
+
FGS permissions from its final manifest. Startup then uses `connectedDevice`
|
|
126
|
+
instead of `dataSync`; the SDK's `CHANGE_WIFI_STATE` permission satisfies its
|
|
127
|
+
startup prerequisite even before a Bluetooth runtime permission is granted.
|
|
128
|
+
|
|
129
|
+
Only restrict types when the corresponding background features are unused.
|
|
130
|
+
Receiving the glasses' BLE audio is distinct from selecting Android's phone or
|
|
131
|
+
Bluetooth headset microphone. Hosts using Android microphone capture, location
|
|
132
|
+
tracking or background media playback must retain the corresponding types and
|
|
133
|
+
permissions, including those required by other native modules. At least
|
|
134
|
+
`connectedDevice` or `dataSync` must remain for SDK service startup.
|
|
135
|
+
|
|
118
136
|
iOS apps should include usage descriptions:
|
|
119
137
|
|
|
120
138
|
```json
|
|
@@ -422,7 +440,7 @@ await sdk.setDashboardContent("")
|
|
|
422
440
|
Settings commands that return `SettingsAckSuccessEvent` reject when the ASG reports an error ack. The SDK updates its local settings store only after that ASG ack resolves successfully, so observed SDK state reflects the acknowledged glasses state rather than a queued request. Raw `settings_ack` listener events still use `SettingsAckEvent` because they can include both success and failure statuses. `rgbLedControl(...)` resolves from a successful ASG `rgb_led_control_response` and rejects when the ASG reports `state: "error"`; raw `settings_ack` and `rgb_led_control_response` events remain available through listeners.
|
|
423
441
|
|
|
424
442
|
WiFi, hotspot, and version-info commands resolve from the ASG response path, not local dispatch:
|
|
425
|
-
`requestWifiScan()` resolves from the ASG `wifi_scan_result` completion response with the updated scan list, including `[]` when no networks are found. Intermediate `wifi_scan_result` events can arrive with `scanComplete: false` while the glasses stream discovered networks; the final event uses `scanComplete: true`. If older glasses stream non-empty scan results but never send the completion event, the request resolves with the accumulated scan list when the request times out. `sendWifiCredentials()` resolves when the requested SSID is connected, `forgetWifiNetwork()` resolves when that SSID is no longer connected, `setHotspotState()` resolves when the requested hotspot state is reported
|
|
443
|
+
`requestWifiScan()` resolves from the ASG `wifi_scan_result` completion response with the updated scan list, including `[]` when no networks are found. Intermediate `wifi_scan_result` events can arrive with `scanComplete: false` while the glasses stream discovered networks; the final event uses `scanComplete: true`. If older glasses stream non-empty scan results but never send the completion event, the request resolves with the accumulated scan list when the request times out. `sendWifiCredentials()` resolves when the requested SSID is connected, `forgetWifiNetwork()` resolves when that SSID is no longer connected, and `setHotspotState()` resolves when the requested hotspot state is reported. `requestVersionInfo()` waits for all chunks declared by the correlated response's `chunkCount`, `chunkIndex`, and `final` metadata. Legacy single-message responses complete immediately; legacy chunked responses complete on `version_info_3` after `version_info_1`. Missing final chunks time out rather than returning partial data after a quiet period.
|
|
426
444
|
|
|
427
445
|
The SDK automatically sends the phone wall clock once shortly after a glasses connection becomes ready. It waits for the initial command burst to drain before timestamping the command so startup queue delay does not become clock skew. The once-per-connection guard resets after disconnect, so every successful reconnect synchronizes again. The SDK does not periodically verify or correct clock skew during a long-lived connection; apps that require periodic reconciliation can compare `requestVersionInfo().systemTimeMs` with the phone clock.
|
|
428
446
|
|
|
@@ -476,11 +476,24 @@ public class Bridge private constructor() {
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
@JvmStatic
|
|
479
|
-
|
|
479
|
+
@JvmOverloads
|
|
480
|
+
fun sendVersionInfo(values: Map<String, Any>, responseChunk: String = "version_info") {
|
|
480
481
|
fun stringField(vararg keys: String): String =
|
|
481
482
|
keys.firstNotNullOfOrNull { key -> values[key] as? String } ?: ""
|
|
482
483
|
val body = HashMap<String, Any>()
|
|
483
484
|
body["type"] = "version_info"
|
|
485
|
+
body[VersionInfoResponseAccumulator.RESPONSE_CHUNK_KEY] = responseChunk
|
|
486
|
+
mapOf(
|
|
487
|
+
"chunkIndex" to VersionInfoResponseAccumulator.RESPONSE_INDEX_KEY,
|
|
488
|
+
"chunkCount" to VersionInfoResponseAccumulator.RESPONSE_COUNT_KEY,
|
|
489
|
+
"final" to VersionInfoResponseAccumulator.RESPONSE_FINAL_KEY,
|
|
490
|
+
"sid" to VersionInfoResponseAccumulator.RESPONSE_SID_KEY,
|
|
491
|
+
).forEach { (wireKey, internalKey) -> values[wireKey]?.let { body[internalKey] = it } }
|
|
492
|
+
(values["requestId"] as? String ?: values["request_id"] as? String)
|
|
493
|
+
?.takeIf { it.isNotEmpty() }
|
|
494
|
+
?.let {
|
|
495
|
+
body[VersionInfoResponseAccumulator.RESPONSE_REQUEST_ID_KEY] = it
|
|
496
|
+
}
|
|
484
497
|
body["androidVersion"] = stringField("androidVersion", "android_version")
|
|
485
498
|
body["firmwareVersion"] = stringField("firmwareVersion", "firmware_version")
|
|
486
499
|
body["besFirmwareVersion"] = stringField("besFirmwareVersion", "bes_fw_version")
|
|
@@ -2016,9 +2016,14 @@ class DeviceManager {
|
|
|
2016
2016
|
* Request version info from glasses. Glasses will respond with version_info message containing
|
|
2017
2017
|
* build number, firmware version, etc.
|
|
2018
2018
|
*/
|
|
2019
|
-
fun requestVersionInfo() {
|
|
2019
|
+
fun requestVersionInfo(requestId: String? = null) {
|
|
2020
2020
|
Bridge.log("MAN: 📱 Requesting version info from glasses")
|
|
2021
|
-
sgc
|
|
2021
|
+
val controller = sgc
|
|
2022
|
+
if (controller is MentraLive) {
|
|
2023
|
+
controller.requestVersionInfo(requestId)
|
|
2024
|
+
} else {
|
|
2025
|
+
controller?.requestVersionInfo()
|
|
2026
|
+
}
|
|
2022
2027
|
}
|
|
2023
2028
|
|
|
2024
2029
|
/** Send shutdown command to glasses. This will initiate a graceful shutdown of the device. */
|
|
@@ -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.200"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.200"
|
|
8
|
+
const val SOURCE_COMMIT: String = "1cdd911f596eba7b2b2f0e9ba5655c7ff16b5362"
|
|
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.200.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "f0b5cca2fa4d1d43acf5933fb4de629819d36a145a162c763f4faa27a7c8ff79"
|
|
11
11
|
}
|
|
@@ -71,7 +71,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
71
71
|
private var pendingWifiScan: PendingWifiScan? = null
|
|
72
72
|
private var pendingWifiStatus: PendingWifiStatusRequest? = null
|
|
73
73
|
private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
|
|
74
|
-
private var pendingVersionInfo:
|
|
74
|
+
private var pendingVersionInfo: PendingVersionInfoRequest? = null
|
|
75
75
|
@Volatile private var configuredOtaVersionUrl: String? = null
|
|
76
76
|
|
|
77
77
|
init {
|
|
@@ -178,6 +178,12 @@ class MentraBluetoothSdk private constructor(
|
|
|
178
178
|
var uploadSucceeded: Boolean = false,
|
|
179
179
|
)
|
|
180
180
|
|
|
181
|
+
private class PendingVersionInfoRequest(
|
|
182
|
+
val pending: PendingResponse<VersionInfoResult>,
|
|
183
|
+
val accumulator: VersionInfoResponseAccumulator,
|
|
184
|
+
) {
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
private data class PendingWifiScan(
|
|
182
188
|
val pending: PendingResponse<List<WifiScanResult>>,
|
|
183
189
|
val scanId: String,
|
|
@@ -1195,7 +1201,13 @@ class MentraBluetoothSdk private constructor(
|
|
|
1195
1201
|
}
|
|
1196
1202
|
|
|
1197
1203
|
suspend fun requestVersionInfo(): VersionInfoResult {
|
|
1204
|
+
val requestId = UUID.randomUUID().toString()
|
|
1198
1205
|
val pending = PendingResponse<VersionInfoResult>("version info request")
|
|
1206
|
+
val request =
|
|
1207
|
+
PendingVersionInfoRequest(
|
|
1208
|
+
pending = pending,
|
|
1209
|
+
accumulator = VersionInfoResponseAccumulator(requestId),
|
|
1210
|
+
)
|
|
1199
1211
|
synchronized(oneShotLock) {
|
|
1200
1212
|
if (pendingVersionInfo != null) {
|
|
1201
1213
|
throw BluetoothSdkException(
|
|
@@ -1203,14 +1215,14 @@ class MentraBluetoothSdk private constructor(
|
|
|
1203
1215
|
"A version info request is already waiting for a glasses response.",
|
|
1204
1216
|
)
|
|
1205
1217
|
}
|
|
1206
|
-
pendingVersionInfo =
|
|
1218
|
+
pendingVersionInfo = request
|
|
1207
1219
|
}
|
|
1208
1220
|
try {
|
|
1209
|
-
deviceManager.requestVersionInfo()
|
|
1221
|
+
deviceManager.requestVersionInfo(requestId)
|
|
1210
1222
|
return pending.await()
|
|
1211
1223
|
} finally {
|
|
1212
1224
|
synchronized(oneShotLock) {
|
|
1213
|
-
if (pendingVersionInfo ===
|
|
1225
|
+
if (pendingVersionInfo === request) {
|
|
1214
1226
|
pendingVersionInfo = null
|
|
1215
1227
|
}
|
|
1216
1228
|
}
|
|
@@ -1607,6 +1619,22 @@ class MentraBluetoothSdk private constructor(
|
|
|
1607
1619
|
}
|
|
1608
1620
|
}
|
|
1609
1621
|
|
|
1622
|
+
private fun handleVersionInfoForRequest(data: Map<String, Any>) {
|
|
1623
|
+
synchronized(oneShotLock) {
|
|
1624
|
+
val request = pendingVersionInfo ?: return
|
|
1625
|
+
when (val outcome = request.accumulator.accept(data)) {
|
|
1626
|
+
VersionInfoAccumulatorOutcome.Ignored -> Unit
|
|
1627
|
+
VersionInfoAccumulatorOutcome.Waiting -> Unit
|
|
1628
|
+
is VersionInfoAccumulatorOutcome.Complete -> {
|
|
1629
|
+
if (pendingVersionInfo === request) {
|
|
1630
|
+
pendingVersionInfo = null
|
|
1631
|
+
request.pending.resolve(outcome.result)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1610
1638
|
private fun dispatchBridgeEvent(eventName: String, data: Map<String, Any>) {
|
|
1611
1639
|
when (eventName) {
|
|
1612
1640
|
"log" -> dispatchToListeners { it.onLog(data["message"] as? String ?: data.toString()) }
|
|
@@ -1773,9 +1801,7 @@ class MentraBluetoothSdk private constructor(
|
|
|
1773
1801
|
}
|
|
1774
1802
|
"version_info" -> {
|
|
1775
1803
|
val event = VersionInfoResult.fromMap(data)
|
|
1776
|
-
|
|
1777
|
-
pendingVersionInfo?.resolve(event)
|
|
1778
|
-
}
|
|
1804
|
+
handleVersionInfoForRequest(data)
|
|
1779
1805
|
dispatchToListeners { it.onVersionInfo(event) }
|
|
1780
1806
|
}
|
|
1781
1807
|
"mic_pcm" -> {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
internal sealed interface VersionInfoAccumulatorOutcome {
|
|
4
|
+
data object Ignored : VersionInfoAccumulatorOutcome
|
|
5
|
+
data object Waiting : VersionInfoAccumulatorOutcome
|
|
6
|
+
data class Complete(val result: VersionInfoResult) : VersionInfoAccumulatorOutcome
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary. */
|
|
10
|
+
internal class VersionInfoResponseAccumulator(private val expectedRequestId: String) {
|
|
11
|
+
private val values = mutableMapOf<String, Any>()
|
|
12
|
+
private val chunks = mutableMapOf<Int, Map<String, Any>>()
|
|
13
|
+
private var count: Int? = null
|
|
14
|
+
private var sid: String? = null
|
|
15
|
+
private var legacyStarted = false
|
|
16
|
+
private var completed = false
|
|
17
|
+
|
|
18
|
+
fun accept(event: Map<String, Any>): VersionInfoAccumulatorOutcome {
|
|
19
|
+
if (completed) return VersionInfoAccumulatorOutcome.Ignored
|
|
20
|
+
val hasModernMetadata = event.keys.any { it in MODERN_KEYS }
|
|
21
|
+
if (hasModernMetadata) {
|
|
22
|
+
if (event[RESPONSE_REQUEST_ID_KEY] != expectedRequestId) return VersionInfoAccumulatorOutcome.Ignored
|
|
23
|
+
val index = integer(event[RESPONSE_INDEX_KEY]) ?: return VersionInfoAccumulatorOutcome.Ignored
|
|
24
|
+
val total = integer(event[RESPONSE_COUNT_KEY]) ?: return VersionInfoAccumulatorOutcome.Ignored
|
|
25
|
+
val final = event[RESPONSE_FINAL_KEY] as? Boolean ?: return VersionInfoAccumulatorOutcome.Ignored
|
|
26
|
+
val process = (event[RESPONSE_SID_KEY] as? String)?.takeIf { it.isNotBlank() }
|
|
27
|
+
?: return VersionInfoAccumulatorOutcome.Ignored
|
|
28
|
+
if (total !in 1..16 || index !in 1..total || final != (index == total)) return VersionInfoAccumulatorOutcome.Ignored
|
|
29
|
+
if (count != null && (count != total || sid != process)) return VersionInfoAccumulatorOutcome.Ignored
|
|
30
|
+
if (count == null) {
|
|
31
|
+
values.clear()
|
|
32
|
+
count = total
|
|
33
|
+
sid = process
|
|
34
|
+
}
|
|
35
|
+
chunks[index] = event
|
|
36
|
+
if (chunks.size != total) return VersionInfoAccumulatorOutcome.Waiting
|
|
37
|
+
(1..total).forEach { merge(chunks.getValue(it)) }
|
|
38
|
+
return finish()
|
|
39
|
+
}
|
|
40
|
+
// Once a response is correlated, unsolicited legacy traffic cannot replace it.
|
|
41
|
+
if (count != null) return VersionInfoAccumulatorOutcome.Ignored
|
|
42
|
+
when (event[RESPONSE_CHUNK_KEY] as? String ?: "version_info") {
|
|
43
|
+
"version_info" -> { values.clear(); merge(event); return finish() }
|
|
44
|
+
"version_info_1" -> { values.clear(); legacyStarted = true; merge(event) }
|
|
45
|
+
"version_info_2" -> {
|
|
46
|
+
if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
|
|
47
|
+
merge(event)
|
|
48
|
+
}
|
|
49
|
+
"version_info_3" -> {
|
|
50
|
+
if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
|
|
51
|
+
merge(event)
|
|
52
|
+
return finish()
|
|
53
|
+
}
|
|
54
|
+
else -> return VersionInfoAccumulatorOutcome.Ignored
|
|
55
|
+
}
|
|
56
|
+
// Silence is never evidence of completion. The request's normal deadline handles loss.
|
|
57
|
+
return VersionInfoAccumulatorOutcome.Waiting
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private fun finish(): VersionInfoAccumulatorOutcome.Complete {
|
|
61
|
+
completed = true
|
|
62
|
+
return VersionInfoAccumulatorOutcome.Complete(VersionInfoResult.fromMap(values))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private fun merge(event: Map<String, Any>) {
|
|
66
|
+
event.forEach { (key, value) ->
|
|
67
|
+
if (!key.startsWith("_response") && key != "type" && (value !is String || value.isNotEmpty())) {
|
|
68
|
+
values[key] = value
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fun integer(value: Any?): Int? {
|
|
74
|
+
val number = value as? Number ?: return null
|
|
75
|
+
val int = number.toInt()
|
|
76
|
+
return int.takeIf { number.toDouble() == it.toDouble() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
companion object {
|
|
80
|
+
internal const val RESPONSE_CHUNK_KEY = "_responseChunk"
|
|
81
|
+
internal const val RESPONSE_REQUEST_ID_KEY = "_responseRequestId"
|
|
82
|
+
internal const val RESPONSE_INDEX_KEY = "_responseChunkIndex"
|
|
83
|
+
internal const val RESPONSE_COUNT_KEY = "_responseChunkCount"
|
|
84
|
+
internal const val RESPONSE_FINAL_KEY = "_responseFinal"
|
|
85
|
+
internal const val RESPONSE_SID_KEY = "_responseSid"
|
|
86
|
+
private val MODERN_KEYS = setOf(RESPONSE_REQUEST_ID_KEY, RESPONSE_INDEX_KEY, RESPONSE_COUNT_KEY, RESPONSE_FINAL_KEY)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -3,6 +3,7 @@ package com.mentra.bluetoothsdk.services
|
|
|
3
3
|
import android.app.NotificationChannel
|
|
4
4
|
import android.app.NotificationManager
|
|
5
5
|
import android.app.Service
|
|
6
|
+
import android.content.ComponentName
|
|
6
7
|
import android.content.Intent
|
|
7
8
|
import android.content.pm.PackageManager
|
|
8
9
|
import android.content.pm.ServiceInfo
|
|
@@ -21,14 +22,31 @@ class ForegroundService : Service() {
|
|
|
21
22
|
const val ACTION_REFRESH_TYPES =
|
|
22
23
|
"com.mentra.bluetoothsdk.services.action.REFRESH_FOREGROUND_SERVICE_TYPES"
|
|
23
24
|
|
|
24
|
-
internal
|
|
25
|
-
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
25
|
+
internal const val DEFAULT_SERVICE_TYPES =
|
|
26
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or
|
|
27
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
|
|
28
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION or
|
|
29
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
|
|
30
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
31
|
+
|
|
32
|
+
internal fun bootstrapServiceType(declaredTypes: Int = DEFAULT_SERVICE_TYPES): Int {
|
|
33
|
+
// Both types can start without a while-in-use permission. The SDK declares
|
|
34
|
+
// CHANGE_WIFI_STATE, which satisfies connectedDevice's prerequisite.
|
|
35
|
+
if (declaredTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC != 0) {
|
|
36
|
+
return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
37
|
+
}
|
|
38
|
+
require(declaredTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE != 0) {
|
|
39
|
+
"The SDK foreground service must declare connectedDevice or dataSync"
|
|
40
|
+
}
|
|
41
|
+
return ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
|
42
|
+
}
|
|
26
43
|
|
|
27
44
|
internal fun preferredServiceType(
|
|
28
45
|
hasConnectedDeviceAccess: Boolean,
|
|
29
46
|
hasMicrophoneAccess: Boolean,
|
|
30
47
|
hasLocationAccess: Boolean,
|
|
31
48
|
includeMediaPlayback: Boolean = true,
|
|
49
|
+
declaredTypes: Int = DEFAULT_SERVICE_TYPES,
|
|
32
50
|
): Int {
|
|
33
51
|
var serviceType = 0
|
|
34
52
|
|
|
@@ -47,21 +65,31 @@ class ForegroundService : Service() {
|
|
|
47
65
|
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
|
|
68
|
+
serviceType = serviceType and declaredTypes
|
|
69
|
+
return if (serviceType == 0) bootstrapServiceType(declaredTypes) else serviceType
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
private var locationTypeRequested = false
|
|
55
74
|
|
|
75
|
+
private val declaredServiceTypes: Int by lazy {
|
|
76
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
77
|
+
packageManager.getServiceInfo(ComponentName(this, ForegroundService::class.java), 0)
|
|
78
|
+
.foregroundServiceType
|
|
79
|
+
} else {
|
|
80
|
+
DEFAULT_SERVICE_TYPES
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
override fun onCreate() {
|
|
57
85
|
super.onCreate()
|
|
58
86
|
Bridge.log("ForegroundService: onCreate() called")
|
|
59
87
|
BleTraceLogger.logLifecycle(this, "ForegroundService", "service_create")
|
|
60
|
-
// Enter the foreground immediately
|
|
61
|
-
//
|
|
88
|
+
// Enter the foreground immediately using a type allowed by the host manifest.
|
|
89
|
+
// onStartCommand() replaces this bootstrap type with the
|
|
62
90
|
// eligible long-running types, deliberately omitting dataSync so Android 15's
|
|
63
91
|
// six-hour dataSync timer no longer applies.
|
|
64
|
-
startForegroundWithType(bootstrapServiceType())
|
|
92
|
+
startForegroundWithType(bootstrapServiceType(declaredServiceTypes))
|
|
65
93
|
}
|
|
66
94
|
|
|
67
95
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
@@ -114,12 +142,13 @@ class ForegroundService : Service() {
|
|
|
114
142
|
return 0 // No service types before Android Q
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
// MentraOS uses background audio prompts; Bluetooth-only hosts can omit
|
|
146
|
+
// this type from their merged manifest.
|
|
147
|
+
val includeMediaPlayback =
|
|
148
|
+
declaredServiceTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK != 0
|
|
149
|
+
if (includeMediaPlayback) {
|
|
150
|
+
Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
|
|
151
|
+
}
|
|
123
152
|
|
|
124
153
|
// Check Bluetooth permissions
|
|
125
154
|
val hasBluetoothPermission =
|
|
@@ -168,13 +197,14 @@ class ForegroundService : Service() {
|
|
|
168
197
|
|
|
169
198
|
// Check microphone permission
|
|
170
199
|
val hasMicPermission =
|
|
171
|
-
|
|
172
|
-
|
|
200
|
+
declaredServiceTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE != 0 &&
|
|
201
|
+
ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) ==
|
|
202
|
+
PackageManager.PERMISSION_GRANTED
|
|
173
203
|
|
|
174
204
|
if (hasMicPermission) {
|
|
175
205
|
Bridge.log("ForegroundService: Added microphone (has RECORD_AUDIO permission)")
|
|
176
206
|
} else {
|
|
177
|
-
Bridge.log("ForegroundService:
|
|
207
|
+
Bridge.log("ForegroundService: Microphone type disabled or permission not granted")
|
|
178
208
|
}
|
|
179
209
|
|
|
180
210
|
val hasLocationPermission =
|
|
@@ -189,7 +219,9 @@ class ForegroundService : Service() {
|
|
|
189
219
|
val locationManager = getSystemService(LocationManager::class.java)
|
|
190
220
|
val isLocationEnabled = locationManager?.isLocationEnabled == true
|
|
191
221
|
|
|
192
|
-
val hasLocationAccess =
|
|
222
|
+
val hasLocationAccess =
|
|
223
|
+
declaredServiceTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0 &&
|
|
224
|
+
locationTypeRequested && hasLocationPermission && isLocationEnabled
|
|
193
225
|
if (hasLocationAccess) {
|
|
194
226
|
Bridge.log("ForegroundService: Added location (has foreground location permission)")
|
|
195
227
|
} else {
|
|
@@ -203,6 +235,8 @@ class ForegroundService : Service() {
|
|
|
203
235
|
hasConnectedDeviceAccess = hasConnectedDeviceAccess,
|
|
204
236
|
hasMicrophoneAccess = hasMicPermission,
|
|
205
237
|
hasLocationAccess = hasLocationAccess,
|
|
238
|
+
includeMediaPlayback = includeMediaPlayback,
|
|
239
|
+
declaredTypes = declaredServiceTypes,
|
|
206
240
|
)
|
|
207
241
|
}
|
|
208
242
|
|