@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.203
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 +64 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +318 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -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 +16 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
- 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/WifiRequestResolutionTest.kt +271 -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/ClassicAudioConnectionTrackerTest.kt +131 -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/BluetoothSdk.types.d.ts +62 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +3 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +78 -1
- package/ios/Source/DeviceManager.swift +13 -6
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +397 -99
- package/ios/Source/WifiRequestResolution.swift +251 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
- package/ios/Source/sgcs/MentraLive.swift +179 -37
- package/ios/Source/sgcs/SGCManager.swift +13 -1
- package/ios/Source/status/DeviceStatus.swift +30 -3
- package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
- package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +76 -1
- package/src/_private/BluetoothSdkModule.ts +4 -1
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +11 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
internal fun wifiSsidIsValid(ssid: String): Boolean = ssid.trim().isNotEmpty()
|
|
4
|
+
|
|
5
|
+
/** Validate raw fields before JSON convenience getters can coerce numbers or erase presence. */
|
|
6
|
+
internal fun wifiResponseEnvelopeIsValid(values: Map<String, Any>, allowLegacy: Boolean): Boolean {
|
|
7
|
+
if (values.containsKey("connected") && values["connected"] !is Boolean) return false
|
|
8
|
+
if (values.containsKey("dispatched") && values["dispatched"] !is Boolean) return false
|
|
9
|
+
val hasTuple = listOf("protocol_version", "requestId", "sid").any(values::containsKey)
|
|
10
|
+
if (!hasTuple) return allowLegacy && !values.containsKey("outcome")
|
|
11
|
+
return (values["protocol_version"] as? Number)?.toDouble() == 1.0 &&
|
|
12
|
+
(values["requestId"] as? String)?.isNotBlank() == true &&
|
|
13
|
+
(values["sid"] as? String)?.isNotBlank() == true &&
|
|
14
|
+
!values.containsKey("dispatched")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
internal const val WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE = "capability_negotiation_timeout"
|
|
18
|
+
|
|
19
|
+
internal sealed class WifiProtocolCapability {
|
|
20
|
+
data object Unknown : WifiProtocolCapability()
|
|
21
|
+
|
|
22
|
+
data class Supported(val version: Int) : WifiProtocolCapability()
|
|
23
|
+
|
|
24
|
+
data object Unsupported : WifiProtocolCapability()
|
|
25
|
+
|
|
26
|
+
data object Legacy : WifiProtocolCapability()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
internal enum class WifiRequestMode {
|
|
30
|
+
DISCOVERING,
|
|
31
|
+
MODERN,
|
|
32
|
+
LEGACY,
|
|
33
|
+
UNSUPPORTED,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
internal data class WifiSessionRequestSnapshot(
|
|
37
|
+
val mode: WifiRequestMode,
|
|
38
|
+
val sessionId: String,
|
|
39
|
+
val epoch: Long,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
internal class WifiSessionCapabilities {
|
|
43
|
+
var sessionId: String = ""
|
|
44
|
+
private set
|
|
45
|
+
var epoch: Long = 0
|
|
46
|
+
private set
|
|
47
|
+
var forgetResult: WifiProtocolCapability = WifiProtocolCapability.Unknown
|
|
48
|
+
private set
|
|
49
|
+
var savedNetworks: WifiProtocolCapability = WifiProtocolCapability.Unknown
|
|
50
|
+
private set
|
|
51
|
+
|
|
52
|
+
fun reset(sessionId: String = "") {
|
|
53
|
+
epoch += 1
|
|
54
|
+
this.sessionId = sessionId
|
|
55
|
+
forgetResult = WifiProtocolCapability.Unknown
|
|
56
|
+
savedNetworks = WifiProtocolCapability.Unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun applyVersionInfo1(values: Map<String, Any>) {
|
|
60
|
+
(values["sid"] as? String)?.takeIf { it.isNotEmpty() }?.let { sessionId = it }
|
|
61
|
+
forgetResult = capability(values["wifiForgetResultVersion"])
|
|
62
|
+
savedNetworks = capability(values["savedWifiNetworksVersion"])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun forgetMode(): WifiRequestMode = requestMode(forgetResult)
|
|
66
|
+
|
|
67
|
+
fun savedNetworksMode(): WifiRequestMode = requestMode(savedNetworks)
|
|
68
|
+
|
|
69
|
+
fun savedNetworksRequestSnapshot(): WifiSessionRequestSnapshot =
|
|
70
|
+
WifiSessionRequestSnapshot(savedNetworksMode(), sessionId, epoch)
|
|
71
|
+
|
|
72
|
+
private fun capability(raw: Any?): WifiProtocolCapability {
|
|
73
|
+
if (raw == null) return WifiProtocolCapability.Legacy
|
|
74
|
+
return if (raw is Number && raw.toDouble() == 1.0 && sessionId.isNotEmpty()) {
|
|
75
|
+
WifiProtocolCapability.Supported(1)
|
|
76
|
+
} else WifiProtocolCapability.Unsupported
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun requestMode(capability: WifiProtocolCapability): WifiRequestMode =
|
|
80
|
+
when (capability) {
|
|
81
|
+
WifiProtocolCapability.Unknown -> WifiRequestMode.DISCOVERING
|
|
82
|
+
is WifiProtocolCapability.Supported -> WifiRequestMode.MODERN
|
|
83
|
+
WifiProtocolCapability.Legacy -> WifiRequestMode.LEGACY
|
|
84
|
+
WifiProtocolCapability.Unsupported -> WifiRequestMode.UNSUPPORTED
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
enum class WifiForgetOutcome(val wireValue: String) {
|
|
89
|
+
CONFIRMED("confirmed"),
|
|
90
|
+
DISPATCHED("dispatched"),
|
|
91
|
+
NOT_FOUND("not_found"),
|
|
92
|
+
UNSUPPORTED("unsupported"),
|
|
93
|
+
FAILED("failed"),
|
|
94
|
+
LEGACY_UNVERIFIED("legacy_unverified");
|
|
95
|
+
|
|
96
|
+
companion object {
|
|
97
|
+
internal fun fromWire(value: String): WifiForgetOutcome? =
|
|
98
|
+
entries.find { it.wireValue == value }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
internal fun normalizeWifiForgetResultEvent(
|
|
103
|
+
requestId: String,
|
|
104
|
+
sid: String,
|
|
105
|
+
ssid: String,
|
|
106
|
+
protocolVersion: Int,
|
|
107
|
+
outcome: String,
|
|
108
|
+
legacyDispatched: Boolean?,
|
|
109
|
+
connected: Boolean?,
|
|
110
|
+
currentSsid: String,
|
|
111
|
+
localIp: String,
|
|
112
|
+
error: String?,
|
|
113
|
+
): Map<String, Any>? {
|
|
114
|
+
if (!wifiSsidIsValid(ssid)) return null
|
|
115
|
+
val modernOutcome = WifiForgetOutcome.fromWire(outcome)
|
|
116
|
+
if (requestId.isNotEmpty() && sid.isNotEmpty() && protocolVersion == 1 &&
|
|
117
|
+
modernOutcome != null && modernOutcome != WifiForgetOutcome.LEGACY_UNVERIFIED
|
|
118
|
+
) {
|
|
119
|
+
return buildMap {
|
|
120
|
+
put("mode", "modern")
|
|
121
|
+
put("requestId", requestId)
|
|
122
|
+
put("sid", sid)
|
|
123
|
+
put("ssid", ssid)
|
|
124
|
+
put("protocolVersion", protocolVersion)
|
|
125
|
+
put("outcome", modernOutcome.wireValue)
|
|
126
|
+
connected?.let { put("connected", it) }
|
|
127
|
+
if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
|
|
128
|
+
if (localIp.isNotEmpty()) put("localIp", localIp)
|
|
129
|
+
error?.let { put("error", it) }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (requestId.isEmpty() && sid.isEmpty() && protocolVersion == 0 && outcome.isEmpty() && legacyDispatched != null) {
|
|
133
|
+
return buildMap {
|
|
134
|
+
put("mode", "legacy")
|
|
135
|
+
put("ssid", ssid)
|
|
136
|
+
put("dispatched", legacyDispatched)
|
|
137
|
+
connected?.let { put("connected", it) }
|
|
138
|
+
if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
|
|
139
|
+
if (localIp.isNotEmpty()) put("localIp", localIp)
|
|
140
|
+
error?.let { put("error", it) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
data class WifiForgetResult(
|
|
147
|
+
val ssid: String,
|
|
148
|
+
val outcome: WifiForgetOutcome,
|
|
149
|
+
val connected: Boolean?,
|
|
150
|
+
val currentSsid: String?,
|
|
151
|
+
val localIp: String?,
|
|
152
|
+
val error: String? = null,
|
|
153
|
+
) {
|
|
154
|
+
internal fun toMap(): Map<String, Any> =
|
|
155
|
+
buildMap {
|
|
156
|
+
put("ssid", ssid)
|
|
157
|
+
put("outcome", outcome.wireValue)
|
|
158
|
+
connected?.let { put("connected", it) }
|
|
159
|
+
currentSsid?.let { put("currentSsid", it) }
|
|
160
|
+
localIp?.let { put("localIp", it) }
|
|
161
|
+
error?.let { put("error", it) }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
enum class SavedWifiNetworksOutcome(val wireValue: String) {
|
|
166
|
+
CONFIRMED("confirmed"),
|
|
167
|
+
UNSUPPORTED("unsupported"),
|
|
168
|
+
FAILED("failed");
|
|
169
|
+
|
|
170
|
+
companion object {
|
|
171
|
+
internal fun fromWire(value: String): SavedWifiNetworksOutcome? =
|
|
172
|
+
entries.find { it.wireValue == value }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
data class SavedWifiNetworksResult(
|
|
177
|
+
val outcome: SavedWifiNetworksOutcome,
|
|
178
|
+
val networks: List<String>,
|
|
179
|
+
val error: String? = null,
|
|
180
|
+
) {
|
|
181
|
+
internal fun toMap(): Map<String, Any> =
|
|
182
|
+
buildMap {
|
|
183
|
+
put("outcome", outcome.wireValue)
|
|
184
|
+
put("networks", networks)
|
|
185
|
+
error?.let { put("error", it) }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
internal fun parseWifiForgetResult(
|
|
190
|
+
expectedRequestId: String,
|
|
191
|
+
expectedSid: String,
|
|
192
|
+
expectedSsid: String,
|
|
193
|
+
capabilityVersion: Int,
|
|
194
|
+
data: Map<String, Any>,
|
|
195
|
+
): WifiForgetResult? {
|
|
196
|
+
if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
|
|
197
|
+
if (data["requestId"] as? String != expectedRequestId) return null
|
|
198
|
+
if (data["sid"] as? String != expectedSid) return null
|
|
199
|
+
if (data["ssid"] as? String != expectedSsid) return null
|
|
200
|
+
if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
|
|
201
|
+
val outcome = WifiForgetOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
|
|
202
|
+
if (outcome == WifiForgetOutcome.LEGACY_UNVERIFIED) return null
|
|
203
|
+
return WifiForgetResult(
|
|
204
|
+
ssid = expectedSsid,
|
|
205
|
+
outcome = outcome,
|
|
206
|
+
connected = data["connected"] as? Boolean,
|
|
207
|
+
currentSsid = data["currentSsid"] as? String,
|
|
208
|
+
localIp = data["localIp"] as? String,
|
|
209
|
+
error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
internal fun parseSavedWifiNetworks(
|
|
214
|
+
expectedRequestId: String,
|
|
215
|
+
expectedSid: String,
|
|
216
|
+
capabilityVersion: Int,
|
|
217
|
+
data: Map<String, Any>,
|
|
218
|
+
): SavedWifiNetworksResult? {
|
|
219
|
+
if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
|
|
220
|
+
if (data["requestId"] as? String != expectedRequestId) return null
|
|
221
|
+
if (data["sid"] as? String != expectedSid) return null
|
|
222
|
+
if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
|
|
223
|
+
val outcome =
|
|
224
|
+
SavedWifiNetworksOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
|
|
225
|
+
val rawNetworks = data["networks"] as? List<*> ?: return null
|
|
226
|
+
if (rawNetworks.any { it !is String }) return null
|
|
227
|
+
if (outcome != SavedWifiNetworksOutcome.CONFIRMED && rawNetworks.isNotEmpty()) return null
|
|
228
|
+
val networks = rawNetworks.map { it as String }.filter { it.trim().isNotEmpty() }.distinct()
|
|
229
|
+
return SavedWifiNetworksResult(
|
|
230
|
+
outcome = outcome,
|
|
231
|
+
networks = networks,
|
|
232
|
+
error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
internal fun legacyWifiForgetResult(
|
|
237
|
+
ssid: String,
|
|
238
|
+
): WifiForgetResult {
|
|
239
|
+
return WifiForgetResult(
|
|
240
|
+
ssid = ssid,
|
|
241
|
+
outcome = WifiForgetOutcome.LEGACY_UNVERIFIED,
|
|
242
|
+
connected = null,
|
|
243
|
+
currentSsid = null,
|
|
244
|
+
localIp = null,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
internal enum class ClassicAudioProfile {
|
|
4
|
+
A2DP,
|
|
5
|
+
HEADSET,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** A confirmed connection is sufficient; declaring disconnection requires both profile reads. */
|
|
9
|
+
internal fun readyClassicAudioSnapshot(states: Map<ClassicAudioProfile, Boolean>): Set<ClassicAudioProfile>? {
|
|
10
|
+
val connected = states.filterValues { it }.keys
|
|
11
|
+
return if (connected.isNotEmpty() || states.size == ClassicAudioProfile.entries.size) connected else null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tracks the Android Classic audio profiles for one dual-mode glasses device.
|
|
16
|
+
*
|
|
17
|
+
* Android reports A2DP and HFP independently. Keeping both states prevents an A2DP disconnect from
|
|
18
|
+
* publishing a false negative while HFP is still connected. Snapshot tickets reject obsolete
|
|
19
|
+
* queries, including a previous session with the same MAC address. Broadcast payloads are not truth.
|
|
20
|
+
*/
|
|
21
|
+
internal class ClassicAudioConnectionTracker(
|
|
22
|
+
private val onConnectedChanged: (Boolean) -> Unit,
|
|
23
|
+
) {
|
|
24
|
+
private var targetAddress: String? = null
|
|
25
|
+
private var revision = 0L
|
|
26
|
+
private val connectedProfiles = mutableSetOf<ClassicAudioProfile>()
|
|
27
|
+
|
|
28
|
+
@get:Synchronized
|
|
29
|
+
val connected: Boolean
|
|
30
|
+
get() = connectedProfiles.isNotEmpty()
|
|
31
|
+
|
|
32
|
+
@Synchronized
|
|
33
|
+
fun setTarget(address: String) {
|
|
34
|
+
val normalizedAddress = address.normalizedBluetoothAddress()
|
|
35
|
+
revision++
|
|
36
|
+
if (targetAddress == normalizedAddress) return
|
|
37
|
+
|
|
38
|
+
targetAddress = normalizedAddress
|
|
39
|
+
clearConnectedProfiles()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Synchronized
|
|
43
|
+
fun beginSnapshot(address: String): Long? {
|
|
44
|
+
if (targetAddress != address.normalizedBluetoothAddress()) return null
|
|
45
|
+
return ++revision
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Both profile queries must belong to the newest request for this session. */
|
|
49
|
+
@Synchronized
|
|
50
|
+
fun applySnapshot(
|
|
51
|
+
ticket: Long,
|
|
52
|
+
address: String,
|
|
53
|
+
profiles: Set<ClassicAudioProfile>,
|
|
54
|
+
onAccepted: () -> Unit = {},
|
|
55
|
+
): Boolean {
|
|
56
|
+
if (ticket != revision || targetAddress != address.normalizedBluetoothAddress()) return false
|
|
57
|
+
val wasConnected = this.connected
|
|
58
|
+
connectedProfiles.clear()
|
|
59
|
+
connectedProfiles.addAll(profiles)
|
|
60
|
+
publishIfChanged(wasConnected)
|
|
61
|
+
// Audio routing notifications must not race a target invalidation after validation.
|
|
62
|
+
onAccepted()
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Synchronized
|
|
67
|
+
fun clear(address: String): Boolean {
|
|
68
|
+
if (targetAddress != address.normalizedBluetoothAddress()) return false
|
|
69
|
+
revision++
|
|
70
|
+
clearConnectedProfiles()
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Synchronized
|
|
75
|
+
fun invalidate(address: String): Boolean {
|
|
76
|
+
if (targetAddress != address.normalizedBluetoothAddress()) return false
|
|
77
|
+
|
|
78
|
+
targetAddress = null
|
|
79
|
+
revision++
|
|
80
|
+
clearConnectedProfiles()
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Synchronized
|
|
85
|
+
fun reset() {
|
|
86
|
+
targetAddress = null
|
|
87
|
+
revision++
|
|
88
|
+
clearConnectedProfiles()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun clearConnectedProfiles() {
|
|
92
|
+
val wasConnected = connected
|
|
93
|
+
connectedProfiles.clear()
|
|
94
|
+
publishIfChanged(wasConnected)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private fun publishIfChanged(wasConnected: Boolean) {
|
|
98
|
+
if (wasConnected != connected) {
|
|
99
|
+
onConnectedChanged(connected)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private fun String.normalizedBluetoothAddress(): String = trim().uppercase()
|