@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.
Files changed (31) hide show
  1. package/README.md +19 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
  12. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  18. package/build/generated/releaseMetadata.js +5 -5
  19. package/build/generated/releaseMetadata.js.map +1 -1
  20. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  21. package/ios/Source/Bridge.swift +15 -1
  22. package/ios/Source/DeviceManager.swift +7 -3
  23. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  24. package/ios/Source/MentraBluetoothSDK.swift +32 -6
  25. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  26. package/ios/Source/sgcs/MentraLive.swift +9 -2
  27. package/ios/Source/status/DeviceStatus.swift +2 -3
  28. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  29. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  30. package/package.json +1 -1
  31. 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, and `requestVersionInfo()` resolves from the ASG `version_info` response instead of local store changes.
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
- fun sendVersionInfo(values: Map<String, Any>) {
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?.requestVersionInfo()
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.195"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.195"
8
- const val SOURCE_COMMIT: String = "321202ca96ec1852be19a7230ebcc3593fe87e54"
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.195.json"
10
- const val OTA_MANIFEST_SHA256: String = "70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4"
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: PendingResponse<VersionInfoResult>? = null
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 = pending
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 === pending) {
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
- synchronized(oneShotLock) {
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 fun bootstrapServiceType(): Int =
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
- return if (serviceType == 0) bootstrapServiceType() else serviceType
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 with a type that has no runtime
61
- // prerequisites. onStartCommand() replaces this bootstrap type with the
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
- // Audio prompts can be initiated by glasses while the host Activity is
118
- // backgrounded. mediaPlayback has no runtime prerequisite, so keep it
119
- // active on the existing Mentra service for the entire connected
120
- // session rather than trying to launch a second service after a wake
121
- // phrase arrives.
122
- Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
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
- ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) ==
172
- PackageManager.PERMISSION_GRANTED
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: No microphone permission")
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 = locationTypeRequested && hasLocationPermission && isLocationEnabled
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