@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7

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 (114) hide show
  1. package/README.md +38 -2
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1174 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  27. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  33. package/build/BluetoothSdk.types.d.ts +61 -2
  34. package/build/BluetoothSdk.types.d.ts.map +1 -1
  35. package/build/BluetoothSdk.types.js.map +1 -1
  36. package/build/_internal.d.ts +2 -0
  37. package/build/_internal.d.ts.map +1 -1
  38. package/build/_internal.js +2 -0
  39. package/build/_internal.js.map +1 -1
  40. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  41. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  42. package/build/_private/BluetoothSdkModule.js +1 -1
  43. package/build/_private/BluetoothSdkModule.js.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  45. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  47. package/build/_private/sdkOtaManifest.d.ts +3 -12
  48. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  49. package/build/_private/sdkOtaManifest.js +5 -17
  50. package/build/_private/sdkOtaManifest.js.map +1 -1
  51. package/build/generated/releaseMetadata.d.ts +12 -0
  52. package/build/generated/releaseMetadata.d.ts.map +1 -0
  53. package/build/generated/releaseMetadata.js +10 -0
  54. package/build/generated/releaseMetadata.js.map +1 -0
  55. package/build/index.d.ts +1 -1
  56. package/build/index.d.ts.map +1 -1
  57. package/build/index.js +10 -2
  58. package/build/index.js.map +1 -1
  59. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  60. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  61. package/build/ota-server/MentraOtaServer.types.js +2 -0
  62. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  63. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  64. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.js +3 -0
  66. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  68. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  70. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  71. package/build/ota-server/index.d.ts +3 -0
  72. package/build/ota-server/index.d.ts.map +1 -0
  73. package/build/ota-server/index.js +3 -0
  74. package/build/ota-server/index.js.map +1 -0
  75. package/expo-module.config.json +3 -2
  76. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  77. package/ios/BluetoothSdkModule.swift +16 -2
  78. package/ios/LocalIPv4.swift +150 -0
  79. package/ios/LocalOtaServer.swift +309 -0
  80. package/ios/MentraBluetoothSDK.podspec +5 -1
  81. package/ios/MentraOtaServerModule.swift +147 -0
  82. package/ios/MentraPhotoReceiverModule.swift +1 -81
  83. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  84. package/ios/Source/Bridge.swift +47 -3
  85. package/ios/Source/DeviceManager.swift +135 -10
  86. package/ios/Source/DeviceStore.swift +2 -0
  87. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  88. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  89. package/ios/Source/OtaManifest.swift +5 -7
  90. package/ios/Source/audio/AudioModels.swift +17 -0
  91. package/ios/Source/camera/CameraModels.swift +1 -0
  92. package/ios/Source/events/BluetoothEvents.swift +27 -0
  93. package/ios/Source/sgcs/MentraLive.swift +439 -30
  94. package/ios/Source/sgcs/SGCManager.swift +11 -0
  95. package/ios/Source/status/DeviceStatus.swift +23 -0
  96. package/ios/Source/types/DeviceModels.swift +23 -2
  97. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  98. package/package.json +5 -1
  99. package/plugin/build/withIos.d.ts +3 -1
  100. package/plugin/build/withIos.js +22 -10
  101. package/scripts/verify-release-package.mjs +34 -0
  102. package/scripts/write-release-metadata.mjs +154 -0
  103. package/scripts/write-release-metadata.test.mjs +29 -0
  104. package/src/BluetoothSdk.types.ts +72 -3
  105. package/src/_internal.ts +2 -0
  106. package/src/_private/BluetoothSdkModule.ts +5 -5
  107. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  108. package/src/_private/sdkOtaManifest.ts +5 -17
  109. package/src/generated/releaseMetadata.ts +20 -0
  110. package/src/index.ts +19 -2
  111. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  112. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  113. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  114. package/src/ota-server/index.ts +2 -0
@@ -0,0 +1,120 @@
1
+ package com.mentra.bluetoothsdk.otaserver
2
+
3
+ import android.util.Log
4
+ import com.mentra.bluetoothsdk.net.LocalIpv4
5
+ import expo.modules.kotlin.modules.Module
6
+ import expo.modules.kotlin.modules.ModuleDefinition
7
+ import java.io.File
8
+
9
+ /**
10
+ * Expo surface for the hotspot-served OTA file server (OS-1676).
11
+ *
12
+ * JS hands over the rewritten manifest body and a map of sha256 -> local file path, and gets
13
+ * back the base URL the glasses should be pointed at via ota_start. The optional host
14
+ * argument lets the caller pass the authoritative scoped-network address (from
15
+ * MentraLocalNetwork's LinkProperties); otherwise interface scanning is used.
16
+ */
17
+ class MentraOtaServerModule : Module() {
18
+ private var otaServer: LocalOtaServer? = null
19
+
20
+ override fun definition() = ModuleDefinition {
21
+ Name("MentraOtaServer")
22
+
23
+ AsyncFunction("startOtaServer") { manifestJson: String, artifactPaths: Map<String, String>, host: String? ->
24
+ startOtaServer(manifestJson, artifactPaths, host)
25
+ }
26
+
27
+ AsyncFunction("stopOtaServer") {
28
+ stopOtaServerInternal()
29
+ }
30
+
31
+ // API parity with iOS. Android hotspot OTA obtains the authoritative address
32
+ // directly from the scoped Network and does not call this fallback.
33
+ AsyncFunction("waitForWifiAddress") { _: String, _: Int ->
34
+ LocalIpv4.bestLocalIpv4Address()
35
+ ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
36
+ }
37
+
38
+ OnDestroy {
39
+ closeOtaServerInternal()
40
+ }
41
+ }
42
+
43
+ @Synchronized
44
+ private fun startOtaServer(
45
+ manifestJson: String,
46
+ artifactPaths: Map<String, String>,
47
+ hostOverride: String?,
48
+ ): Map<String, Any> {
49
+ val artifacts = artifactPaths.mapValues { (_, path) ->
50
+ File(path.removePrefix("file://"))
51
+ }
52
+ artifacts.forEach { (key, file) ->
53
+ if (!file.isFile) {
54
+ throw IllegalArgumentException("Artifact $key not found at ${file.absolutePath}")
55
+ }
56
+ }
57
+
58
+ val server = otaServer ?: LocalOtaServer(
59
+ onLog = { message -> emitStatus(message) },
60
+ ).also {
61
+ otaServer = it
62
+ }
63
+ server.configure(manifestJson, artifacts)
64
+
65
+ val host = hostOverride?.takeIf { it.isNotBlank() }
66
+ ?: LocalIpv4.bestLocalIpv4Address()
67
+ ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
68
+
69
+ server.activePort?.let { activePort ->
70
+ emitStatus("OTA server ready at http://$host:$activePort/version.json")
71
+ return serverResult(host, activePort)
72
+ }
73
+
74
+ var lastError: Throwable? = null
75
+ for (port in OTA_PORTS) {
76
+ try {
77
+ val actualPort = server.start(host, port)
78
+ emitStatus("OTA server ready at http://$host:$actualPort/version.json")
79
+ return serverResult(host, actualPort)
80
+ } catch (error: Throwable) {
81
+ lastError = error
82
+ emitStatus("Port $port unavailable: ${error.message ?: error::class.java.simpleName}")
83
+ }
84
+ }
85
+
86
+ throw IllegalStateException(
87
+ "Could not start OTA server: ${lastError?.message ?: "all ports unavailable"}",
88
+ )
89
+ }
90
+
91
+ @Synchronized
92
+ private fun stopOtaServerInternal() {
93
+ otaServer?.stop()
94
+ emitStatus("OTA server stopped")
95
+ }
96
+
97
+ @Synchronized
98
+ private fun closeOtaServerInternal() {
99
+ otaServer?.close()
100
+ otaServer = null
101
+ }
102
+
103
+ private fun emitStatus(message: String) {
104
+ Log.d(TAG, message)
105
+ }
106
+
107
+ private fun serverResult(host: String, port: Int): Map<String, Any> {
108
+ return mapOf(
109
+ "baseUrl" to "http://$host:$port",
110
+ "manifestUrl" to "http://$host:$port/version.json",
111
+ "host" to host,
112
+ "port" to port,
113
+ )
114
+ }
115
+
116
+ private companion object {
117
+ const val TAG = "MentraOtaServer"
118
+ val OTA_PORTS = listOf(8791, 8792, 8793, 8794)
119
+ }
120
+ }
@@ -4,11 +4,10 @@ import android.content.Context
4
4
  import android.net.Uri
5
5
  import android.util.Log
6
6
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
7
+ import com.mentra.bluetoothsdk.net.LocalIpv4
7
8
  import expo.modules.kotlin.exception.Exceptions
8
9
  import expo.modules.kotlin.modules.Module
9
10
  import expo.modules.kotlin.modules.ModuleDefinition
10
- import java.net.Inet4Address
11
- import java.net.NetworkInterface
12
11
 
13
12
  class MentraPhotoReceiverModule : Module() {
14
13
  private var photoUploadServer: LocalPhotoUploadServer? = null
@@ -45,7 +44,7 @@ class MentraPhotoReceiverModule : Module() {
45
44
  photoUploadServer = it
46
45
  }
47
46
 
48
- val host = bestLocalIpv4Address()
47
+ val host = LocalIpv4.bestLocalIpv4Address()
49
48
  ?: throw IllegalStateException("No Wi-Fi/LAN IPv4 address found for this phone.")
50
49
 
51
50
  server.activePort?.let { activePort ->
@@ -128,39 +127,6 @@ class MentraPhotoReceiverModule : Module() {
128
127
  ?: throw Exceptions.ReactContextLost()
129
128
  }
130
129
 
131
- private fun bestLocalIpv4Address(): String? {
132
- val wifiPrivateCandidates = mutableListOf<Inet4Address>()
133
- val wifiCandidates = mutableListOf<Inet4Address>()
134
- val privateCandidates = mutableListOf<Inet4Address>()
135
- val otherCandidates = mutableListOf<Inet4Address>()
136
- val interfaces = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
137
- for (networkInterface in interfaces) {
138
- if (!networkInterface.isUp || networkInterface.isLoopback) {
139
- continue
140
- }
141
- val addresses = networkInterface.inetAddresses.toList()
142
- .filterIsInstance<Inet4Address>()
143
- .filter { address ->
144
- !address.isLoopbackAddress &&
145
- !address.isLinkLocalAddress
146
- }
147
- if (isWifiInterface(networkInterface.name)) {
148
- wifiPrivateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
149
- wifiCandidates += addresses
150
- } else {
151
- privateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
152
- otherCandidates += addresses
153
- }
154
- }
155
-
156
- return (
157
- wifiPrivateCandidates.firstOrNull() ?:
158
- wifiCandidates.firstOrNull() ?:
159
- privateCandidates.firstOrNull() ?:
160
- otherCandidates.firstOrNull()
161
- )?.hostAddress
162
- }
163
-
164
130
  private fun receiverEndpoint(host: String, port: Int): ReceiverEndpoint {
165
131
  return ReceiverEndpoint(
166
132
  uploadUrl = "http://$host:$port/upload",
@@ -177,19 +143,6 @@ class MentraPhotoReceiverModule : Module() {
177
143
  )
178
144
  }
179
145
 
180
- private fun isWifiInterface(name: String): Boolean {
181
- return name.startsWith("wlan") ||
182
- name.startsWith("p2p") ||
183
- name.startsWith("ap") ||
184
- name.startsWith("swlan")
185
- }
186
-
187
- private fun isPrivateIpv4(host: String): Boolean {
188
- return host.startsWith("192.168.") ||
189
- host.startsWith("10.") ||
190
- host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"))
191
- }
192
-
193
146
  private companion object {
194
147
  const val TAG = "MentraPhotoReceiver"
195
148
  val PHOTO_PORTS = listOf(8787, 8788, 8789, 8790)
@@ -20,6 +20,35 @@ class ForegroundService : Service() {
20
20
  const val NOTIFICATION_ID = 1001
21
21
  const val ACTION_REFRESH_TYPES =
22
22
  "com.mentra.bluetoothsdk.services.action.REFRESH_FOREGROUND_SERVICE_TYPES"
23
+
24
+ internal fun bootstrapServiceType(): Int =
25
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
26
+
27
+ internal fun preferredServiceType(
28
+ hasConnectedDeviceAccess: Boolean,
29
+ hasMicrophoneAccess: Boolean,
30
+ hasLocationAccess: Boolean,
31
+ includeMediaPlayback: Boolean = true,
32
+ ): Int {
33
+ var serviceType = 0
34
+
35
+ if (includeMediaPlayback) {
36
+ serviceType =
37
+ serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
38
+ }
39
+ if (hasConnectedDeviceAccess) {
40
+ serviceType =
41
+ serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
42
+ }
43
+ if (hasMicrophoneAccess) {
44
+ serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
45
+ }
46
+ if (hasLocationAccess) {
47
+ serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
48
+ }
49
+
50
+ return if (serviceType == 0) bootstrapServiceType() else serviceType
51
+ }
23
52
  }
24
53
 
25
54
  private var locationTypeRequested = false
@@ -28,7 +57,11 @@ class ForegroundService : Service() {
28
57
  super.onCreate()
29
58
  Bridge.log("ForegroundService: onCreate() called")
30
59
  BleTraceLogger.logLifecycle(this, "ForegroundService", "service_create")
31
- startForegroundWithAutoDetectedType()
60
+ // Enter the foreground immediately with a type that has no runtime
61
+ // prerequisites. onStartCommand() replaces this bootstrap type with the
62
+ // eligible long-running types, deliberately omitting dataSync so Android 15's
63
+ // six-hour dataSync timer no longer applies.
64
+ startForegroundWithType(bootstrapServiceType())
32
65
  }
33
66
 
34
67
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -50,8 +83,10 @@ class ForegroundService : Service() {
50
83
  }
51
84
 
52
85
  private fun startForegroundWithAutoDetectedType() {
53
- val serviceType = detectServiceType()
86
+ startForegroundWithType(detectServiceType())
87
+ }
54
88
 
89
+ private fun startForegroundWithType(serviceType: Int) {
55
90
  createNotificationChannel()
56
91
 
57
92
  val notification =
@@ -65,7 +100,7 @@ class ForegroundService : Service() {
65
100
 
66
101
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
67
102
  Bridge.log(
68
- "ForegroundService: Starting with auto-detected type: ${getServiceTypeName(serviceType)}"
103
+ "ForegroundService: Starting with type: ${getServiceTypeName(serviceType)}"
69
104
  )
70
105
  startForeground(NOTIFICATION_ID, notification, serviceType)
71
106
  } else {
@@ -79,14 +114,11 @@ class ForegroundService : Service() {
79
114
  return 0 // No service types before Android Q
80
115
  }
81
116
 
82
- var serviceType = 0
83
-
84
117
  // Audio prompts can be initiated by glasses while the host Activity is
85
118
  // backgrounded. mediaPlayback has no runtime prerequisite, so keep it
86
119
  // active on the existing Mentra service for the entire connected
87
120
  // session rather than trying to launch a second service after a wake
88
121
  // phrase arrives.
89
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
90
122
  Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
91
123
 
92
124
  // Check Bluetooth permissions
@@ -106,28 +138,32 @@ class ForegroundService : Service() {
106
138
  }
107
139
  }
108
140
 
109
- // Check CHANGE_NETWORK_STATE permission - this is a "normal" permission that is
110
- // auto-granted at install time, but we check it defensively in case this changes.
111
- // This permission can satisfy the connectedDevice FGS requirement without needing
112
- // BLUETOOTH_CONNECT, which is a "dangerous" permission that users can deny.
113
- val hasNetworkStatePermission =
141
+ // These normal permissions satisfy the connectedDevice FGS prerequisite without
142
+ // waiting for a runtime Bluetooth grant. CHANGE_WIFI_STATE is declared by the SDK;
143
+ // CHANGE_NETWORK_STATE is also accepted when a host app already declares it.
144
+ val hasConnectedDeviceManifestPermission =
114
145
  ContextCompat.checkSelfPermission(
115
146
  this,
116
147
  android.Manifest.permission.CHANGE_NETWORK_STATE
117
- ) == PackageManager.PERMISSION_GRANTED
148
+ ) == PackageManager.PERMISSION_GRANTED ||
149
+ ContextCompat.checkSelfPermission(
150
+ this,
151
+ android.Manifest.permission.CHANGE_WIFI_STATE
152
+ ) == PackageManager.PERMISSION_GRANTED
118
153
 
119
- // Use connectedDevice if we have EITHER Bluetooth OR network state permission.
120
- // This avoids falling back to dataSync (which has a 6-hour timeout on Android 14+)
154
+ // Use connectedDevice if we have either Bluetooth or a qualifying normal permission.
155
+ // This avoids falling back to dataSync (which has a six-hour timeout on Android 15+)
121
156
  // when users deny Bluetooth permissions.
122
- if (hasBluetoothPermission || hasNetworkStatePermission) {
123
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
157
+ val hasConnectedDeviceAccess =
158
+ hasBluetoothPermission || hasConnectedDeviceManifestPermission
159
+ if (hasConnectedDeviceAccess) {
124
160
  if (hasBluetoothPermission) {
125
161
  Bridge.log("ForegroundService: Added connectedDevice (has Bluetooth permission)")
126
162
  } else {
127
- Bridge.log("ForegroundService: Added connectedDevice (has CHANGE_NETWORK_STATE permission)")
163
+ Bridge.log("ForegroundService: Added connectedDevice (has network control permission)")
128
164
  }
129
165
  } else {
130
- Bridge.log("ForegroundService: No Bluetooth or network state permission for connectedDevice")
166
+ Bridge.log("ForegroundService: No qualifying permission for connectedDevice")
131
167
  }
132
168
 
133
169
  // Check microphone permission
@@ -136,7 +172,6 @@ class ForegroundService : Service() {
136
172
  PackageManager.PERMISSION_GRANTED
137
173
 
138
174
  if (hasMicPermission) {
139
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
140
175
  Bridge.log("ForegroundService: Added microphone (has RECORD_AUDIO permission)")
141
176
  } else {
142
177
  Bridge.log("ForegroundService: No microphone permission")
@@ -154,8 +189,8 @@ class ForegroundService : Service() {
154
189
  val locationManager = getSystemService(LocationManager::class.java)
155
190
  val isLocationEnabled = locationManager?.isLocationEnabled == true
156
191
 
157
- if (locationTypeRequested && hasLocationPermission && isLocationEnabled) {
158
- serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
192
+ val hasLocationAccess = locationTypeRequested && hasLocationPermission && isLocationEnabled
193
+ if (hasLocationAccess) {
159
194
  Bridge.log("ForegroundService: Added location (has foreground location permission)")
160
195
  } else {
161
196
  Bridge.log(
@@ -164,15 +199,11 @@ class ForegroundService : Service() {
164
199
  )
165
200
  }
166
201
 
167
- // Only use dataSync as absolute last resort fallback if no other types were added.
168
- // WARNING: dataSync has a 6-hour timeout on Android 14+ which will crash the app.
169
- // This should rarely happen since CHANGE_NETWORK_STATE is a normal permission.
170
- if (serviceType == 0) {
171
- serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
172
- Bridge.log("ForegroundService: WARNING - Using dataSync as fallback (6-hour timeout on Android 14+)")
173
- }
174
-
175
- return serviceType
202
+ return preferredServiceType(
203
+ hasConnectedDeviceAccess = hasConnectedDeviceAccess,
204
+ hasMicrophoneAccess = hasMicPermission,
205
+ hasLocationAccess = hasLocationAccess,
206
+ )
176
207
  }
177
208
 
178
209
  private fun getNotificationText(serviceType: Int): String {