munim-bluetooth 0.3.26 → 0.4.0

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 (116) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +476 -74
  3. package/android/gradle.properties +2 -2
  4. package/android/src/main/AndroidManifest.xml +3 -1
  5. package/android/src/main/cpp/cpp-adapter.cpp +4 -1
  6. package/android/src/main/java/com/munimbluetooth/BluetoothPermissionUtils.kt +40 -0
  7. package/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt +2116 -217
  8. package/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt +591 -56
  9. package/app.plugin.js +155 -0
  10. package/ios/HybridMunimBluetooth.swift +2123 -298
  11. package/ios/MunimBluetoothEventEmitter.swift +68 -8
  12. package/lib/commonjs/index.js +272 -11
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/module/index.js +243 -11
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/index.d.ts +310 -7
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts +219 -5
  19. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts.map +1 -1
  20. package/nitro.json +9 -3
  21. package/nitrogen/generated/android/c++/JAdvertisingDataTypes.hpp +96 -96
  22. package/nitrogen/generated/android/c++/JAdvertisingOptions.hpp +8 -8
  23. package/nitrogen/generated/android/c++/JBackgroundSessionOptions.hpp +8 -8
  24. package/nitrogen/generated/android/c++/JBluetoothCapabilities.hpp +105 -0
  25. package/nitrogen/generated/android/c++/JBluetoothPhy.hpp +61 -0
  26. package/nitrogen/generated/android/c++/JBluetoothPhyOption.hpp +61 -0
  27. package/nitrogen/generated/android/c++/JBondState.hpp +64 -0
  28. package/nitrogen/generated/android/c++/JDescriptorValue.hpp +69 -0
  29. package/nitrogen/generated/android/c++/JExtendedAdvertisingOptions.hpp +131 -0
  30. package/nitrogen/generated/android/c++/JGATTCharacteristic.hpp +35 -11
  31. package/nitrogen/generated/android/c++/JGATTDescriptor.hpp +85 -0
  32. package/nitrogen/generated/android/c++/JGATTService.hpp +33 -9
  33. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.cpp +422 -12
  34. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.hpp +29 -0
  35. package/nitrogen/generated/android/c++/JL2CAPChannel.hpp +66 -0
  36. package/nitrogen/generated/android/c++/JMultipeerDiscoveryInfoEntry.hpp +61 -0
  37. package/nitrogen/generated/android/c++/JMultipeerEncryptionPreference.hpp +61 -0
  38. package/nitrogen/generated/android/c++/JMultipeerPeer.hpp +93 -0
  39. package/nitrogen/generated/android/c++/JMultipeerPeerState.hpp +61 -0
  40. package/nitrogen/generated/android/c++/JMultipeerSessionOptions.hpp +105 -0
  41. package/nitrogen/generated/android/c++/JPhyStatus.hpp +62 -0
  42. package/nitrogen/generated/android/c++/JScanOptions.hpp +8 -8
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingDataTypes.kt +47 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingOptions.kt +19 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BackgroundSessionOptions.kt +27 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothCapabilities.kt +111 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhy.kt +24 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhyOption.kt +24 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BondState.kt +25 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/CharacteristicValue.kt +17 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/DescriptorValue.kt +66 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ExtendedAdvertisingOptions.kt +111 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTCharacteristic.kt +25 -3
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTDescriptor.kt +61 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTService.kt +23 -3
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/HybridMunimBluetoothSpec.kt +138 -22
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/L2CAPChannel.kt +61 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerDiscoveryInfoEntry.kt +56 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerEncryptionPreference.kt +24 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeer.kt +66 -0
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeerState.kt +24 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerSessionOptions.kt +81 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/PhyStatus.kt +56 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ScanOptions.kt +17 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ServiceDataEntry.kt +15 -0
  66. package/nitrogen/generated/ios/MunimBluetooth+autolinking.rb +2 -0
  67. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.cpp +61 -5
  68. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.hpp +494 -49
  69. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Umbrella.hpp +42 -0
  70. package/nitrogen/generated/ios/c++/HybridMunimBluetoothSpecSwift.hpp +254 -0
  71. package/nitrogen/generated/ios/swift/BluetoothCapabilities.swift +89 -0
  72. package/nitrogen/generated/ios/swift/BluetoothPhy.swift +44 -0
  73. package/nitrogen/generated/ios/swift/BluetoothPhyOption.swift +44 -0
  74. package/nitrogen/generated/ios/swift/BondState.swift +48 -0
  75. package/nitrogen/generated/ios/swift/DescriptorValue.swift +44 -0
  76. package/nitrogen/generated/ios/swift/ExtendedAdvertisingOptions.swift +243 -0
  77. package/nitrogen/generated/ios/swift/Func_void_BluetoothCapabilities.swift +46 -0
  78. package/nitrogen/generated/ios/swift/Func_void_BondState.swift +46 -0
  79. package/nitrogen/generated/ios/swift/Func_void_DescriptorValue.swift +46 -0
  80. package/nitrogen/generated/ios/swift/Func_void_L2CAPChannel.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_PhyStatus.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  83. package/nitrogen/generated/ios/swift/Func_void_std__vector_MultipeerPeer_.swift +46 -0
  84. package/nitrogen/generated/ios/swift/GATTCharacteristic.swift +25 -1
  85. package/nitrogen/generated/ios/swift/GATTDescriptor.swift +71 -0
  86. package/nitrogen/generated/ios/swift/GATTService.swift +25 -1
  87. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec.swift +29 -0
  88. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec_cxx.swift +556 -23
  89. package/nitrogen/generated/ios/swift/L2CAPChannel.swift +52 -0
  90. package/nitrogen/generated/ios/swift/MultipeerDiscoveryInfoEntry.swift +34 -0
  91. package/nitrogen/generated/ios/swift/MultipeerEncryptionPreference.swift +44 -0
  92. package/nitrogen/generated/ios/swift/MultipeerPeer.swift +63 -0
  93. package/nitrogen/generated/ios/swift/MultipeerPeerState.swift +44 -0
  94. package/nitrogen/generated/ios/swift/MultipeerSessionOptions.swift +136 -0
  95. package/nitrogen/generated/ios/swift/PhyStatus.swift +34 -0
  96. package/nitrogen/generated/shared/c++/BluetoothCapabilities.hpp +131 -0
  97. package/nitrogen/generated/shared/c++/BluetoothPhy.hpp +80 -0
  98. package/nitrogen/generated/shared/c++/BluetoothPhyOption.hpp +80 -0
  99. package/nitrogen/generated/shared/c++/BondState.hpp +84 -0
  100. package/nitrogen/generated/shared/c++/DescriptorValue.hpp +95 -0
  101. package/nitrogen/generated/shared/c++/ExtendedAdvertisingOptions.hpp +138 -0
  102. package/nitrogen/generated/shared/c++/GATTCharacteristic.hpp +9 -3
  103. package/nitrogen/generated/shared/c++/GATTDescriptor.hpp +93 -0
  104. package/nitrogen/generated/shared/c++/GATTService.hpp +7 -2
  105. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.cpp +29 -0
  106. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.hpp +61 -2
  107. package/nitrogen/generated/shared/c++/L2CAPChannel.hpp +92 -0
  108. package/nitrogen/generated/shared/c++/MultipeerDiscoveryInfoEntry.hpp +87 -0
  109. package/nitrogen/generated/shared/c++/MultipeerEncryptionPreference.hpp +80 -0
  110. package/nitrogen/generated/shared/c++/MultipeerPeer.hpp +102 -0
  111. package/nitrogen/generated/shared/c++/MultipeerPeerState.hpp +80 -0
  112. package/nitrogen/generated/shared/c++/MultipeerSessionOptions.hpp +114 -0
  113. package/nitrogen/generated/shared/c++/PhyStatus.hpp +88 -0
  114. package/package.json +22 -11
  115. package/src/index.ts +416 -31
  116. package/src/specs/munim-bluetooth.nitro.ts +298 -14
@@ -11,9 +11,15 @@ import android.bluetooth.BluetoothGattServerCallback
11
11
  import android.bluetooth.BluetoothGattService
12
12
  import android.bluetooth.BluetoothManager
13
13
  import android.bluetooth.BluetoothProfile
14
+ import android.bluetooth.BluetoothServerSocket
15
+ import android.bluetooth.BluetoothSocket
16
+ import android.bluetooth.BluetoothStatusCodes
14
17
  import android.bluetooth.le.AdvertiseCallback
15
18
  import android.bluetooth.le.AdvertiseData
16
19
  import android.bluetooth.le.AdvertiseSettings
20
+ import android.bluetooth.le.AdvertisingSet
21
+ import android.bluetooth.le.AdvertisingSetCallback
22
+ import android.bluetooth.le.AdvertisingSetParameters
17
23
  import android.bluetooth.le.BluetoothLeAdvertiser
18
24
  import android.bluetooth.le.BluetoothLeScanner
19
25
  import android.bluetooth.le.ScanCallback
@@ -21,24 +27,41 @@ import android.bluetooth.le.ScanFilter
21
27
  import android.bluetooth.le.ScanRecord
22
28
  import android.bluetooth.le.ScanResult
23
29
  import android.bluetooth.le.ScanSettings
30
+ import android.content.BroadcastReceiver
24
31
  import android.content.Context
25
32
  import android.content.Intent
33
+ import android.content.IntentFilter
34
+ import android.content.pm.PackageManager
26
35
  import android.os.Build
27
36
  import android.os.ParcelUuid
28
37
  import android.util.Log
29
38
  import com.facebook.react.bridge.Arguments
39
+ import com.facebook.react.bridge.UiThreadUtil
30
40
  import com.facebook.react.bridge.WritableArray
31
41
  import com.facebook.react.bridge.WritableMap
32
42
  import com.facebook.react.modules.core.DeviceEventManagerModule
43
+ import com.facebook.react.modules.core.PermissionAwareActivity
44
+ import com.facebook.react.modules.core.PermissionListener
33
45
  import com.margelo.nitro.NitroModules
34
46
  import com.margelo.nitro.core.Promise
35
47
  import com.margelo.nitro.munimbluetooth.AdvertisingDataTypes
36
48
  import com.margelo.nitro.munimbluetooth.AdvertisingOptions
37
49
  import com.margelo.nitro.munimbluetooth.BackgroundSessionOptions
50
+ import com.margelo.nitro.munimbluetooth.BluetoothCapabilities
51
+ import com.margelo.nitro.munimbluetooth.BluetoothPhy
52
+ import com.margelo.nitro.munimbluetooth.BluetoothPhyOption
53
+ import com.margelo.nitro.munimbluetooth.BondState
38
54
  import com.margelo.nitro.munimbluetooth.CharacteristicValue
55
+ import com.margelo.nitro.munimbluetooth.DescriptorValue
56
+ import com.margelo.nitro.munimbluetooth.ExtendedAdvertisingOptions
39
57
  import com.margelo.nitro.munimbluetooth.GATTCharacteristic
58
+ import com.margelo.nitro.munimbluetooth.GATTDescriptor
40
59
  import com.margelo.nitro.munimbluetooth.GATTService
41
60
  import com.margelo.nitro.munimbluetooth.HybridMunimBluetoothSpec
61
+ import com.margelo.nitro.munimbluetooth.L2CAPChannel
62
+ import com.margelo.nitro.munimbluetooth.MultipeerPeer
63
+ import com.margelo.nitro.munimbluetooth.MultipeerSessionOptions
64
+ import com.margelo.nitro.munimbluetooth.PhyStatus
42
65
  import com.margelo.nitro.munimbluetooth.ScanMode
43
66
  import com.margelo.nitro.munimbluetooth.ScanOptions
44
67
  import com.margelo.nitro.munimbluetooth.ServiceDataEntry
@@ -49,6 +72,9 @@ import kotlinx.coroutines.Job
49
72
  import kotlinx.coroutines.SupervisorJob
50
73
  import kotlinx.coroutines.delay
51
74
  import kotlinx.coroutines.launch
75
+ import org.json.JSONArray
76
+ import org.json.JSONObject
77
+ import java.io.IOException
52
78
  import java.util.UUID
53
79
 
54
80
  class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
@@ -56,6 +82,8 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
56
82
 
57
83
  private var advertiser: BluetoothLeAdvertiser? = null
58
84
  private var advertiseCallback: AdvertiseCallback? = null
85
+ private val extendedAdvertisingSets = mutableMapOf<String, AdvertisingSet>()
86
+ private val extendedAdvertisingCallbacks = mutableMapOf<String, AdvertisingSetCallback>()
59
87
  private var gattServer: BluetoothGattServer? = null
60
88
  private var gattServerReady = false
61
89
  private var advertiseJob: Job? = null
@@ -64,6 +92,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
64
92
  private var currentLocalName: String? = null
65
93
  private var currentManufacturerData: String? = null
66
94
  private var previousAdapterName: String? = null
95
+ private var configuredServices: Array<GATTService> = emptyArray()
67
96
  private var bluetoothManager: BluetoothManager? = null
68
97
  private var bluetoothAdapter: BluetoothAdapter? = null
69
98
 
@@ -76,10 +105,30 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
76
105
  private val pendingServiceDiscoveries = mutableMapOf<String, Promise<Array<GATTService>>>()
77
106
  private val pendingReads = mutableMapOf<String, Promise<CharacteristicValue>>()
78
107
  private val pendingWrites = mutableMapOf<String, Promise<Unit>>()
108
+ private val pendingDescriptorReads = mutableMapOf<String, Promise<DescriptorValue>>()
109
+ private val pendingDescriptorWrites = mutableMapOf<String, Promise<Unit>>()
110
+ private val pendingMtuRequests = mutableMapOf<String, Promise<Double>>()
111
+ private val pendingPhyReads = mutableMapOf<String, Promise<PhyStatus>>()
79
112
  private val pendingRssiReads = mutableMapOf<String, Promise<Double>>()
113
+ private val pendingConnectionTimeouts = mutableMapOf<String, Job>()
114
+ private val pendingConnectionAttempts = mutableMapOf<String, Int>()
115
+ private val pendingOperationTimeouts = mutableMapOf<String, Job>()
116
+ private val pendingConnectionGatts = mutableMapOf<String, BluetoothGatt>()
80
117
  private val lastCharacteristicValues = mutableMapOf<String, CharacteristicValue>()
81
118
  private val lastRssiValues = mutableMapOf<String, Double>()
119
+ private val subscribedDevices = mutableMapOf<UUID, MutableSet<BluetoothDevice>>()
120
+ private var classicScanReceiver: BroadcastReceiver? = null
121
+ private val classicDevices = mutableMapOf<String, BluetoothDevice>()
122
+ private val classicSockets = mutableMapOf<String, BluetoothSocket>()
123
+ private val classicReadJobs = mutableMapOf<String, Job>()
124
+ private val classicServerSockets = mutableMapOf<String, BluetoothServerSocket>()
125
+ private val classicServerJobs = mutableMapOf<String, Job>()
126
+ private val l2capServerSockets = mutableMapOf<Int, BluetoothServerSocket>()
127
+ private val l2capAcceptJobs = mutableMapOf<Int, Job>()
128
+ private val l2capSockets = mutableMapOf<String, BluetoothSocket>()
129
+ private val l2capReadJobs = mutableMapOf<String, Job>()
82
130
  private val eventEmitter = NitroEventEmitter(TAG)
131
+ private var nextPermissionRequestCode = BLUETOOTH_PERMISSION_REQUEST_CODE
83
132
 
84
133
  private fun getBluetoothManager(): BluetoothManager? {
85
134
  val context = NitroModules.applicationContext ?: return null
@@ -93,7 +142,35 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
93
142
  }
94
143
  }
95
144
 
145
+ private fun hasRequiredBluetoothPermissions(): Boolean {
146
+ val context = NitroModules.applicationContext ?: return false
147
+ return BluetoothPermissionUtils.hasRequiredPermissions(context)
148
+ }
149
+
150
+ private fun ensureBluetoothPermissions(operationName: String): Boolean {
151
+ val context = NitroModules.applicationContext
152
+ if (context == null) {
153
+ Log.w(TAG, "Unable to $operationName: React context unavailable")
154
+ return false
155
+ }
156
+
157
+ val missingPermissions = BluetoothPermissionUtils.missingPermissions(context)
158
+ if (missingPermissions.isNotEmpty()) {
159
+ Log.w(
160
+ TAG,
161
+ "Unable to $operationName: missing Bluetooth permissions (${missingPermissions.joinToString()})"
162
+ )
163
+ return false
164
+ }
165
+
166
+ return true
167
+ }
168
+
96
169
  override fun startAdvertising(options: AdvertisingOptions) {
170
+ if (!ensureBluetoothPermissions("start advertising")) {
171
+ return
172
+ }
173
+
97
174
  ensureBluetoothManager()
98
175
  val adapter = bluetoothAdapter
99
176
  if (adapter == null || !adapter.isEnabled) {
@@ -115,7 +192,12 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
115
192
  )
116
193
 
117
194
  if (!currentLocalName.isNullOrBlank() && previousAdapterName == null) {
118
- previousAdapterName = adapter.name
195
+ previousAdapterName = try {
196
+ adapter.name
197
+ } catch (error: SecurityException) {
198
+ Log.w(TAG, "Unable to read Bluetooth adapter name", error)
199
+ null
200
+ }
119
201
  }
120
202
  if (!currentLocalName.isNullOrBlank()) {
121
203
  try {
@@ -126,7 +208,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
126
208
  }
127
209
 
128
210
  if (!gattServerReady) {
129
- setServicesFromOptions(options.serviceUUIDs)
211
+ if (configuredServices.isNotEmpty()) {
212
+ setServices(configuredServices)
213
+ } else {
214
+ setServicesFromOptions(options.serviceUUIDs)
215
+ }
130
216
  }
131
217
  restartAdvertising(delayMs = 300L)
132
218
  }
@@ -151,8 +237,21 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
151
237
  advertiseCallback?.let { callback ->
152
238
  advertiser?.stopAdvertising(callback)
153
239
  }
240
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
241
+ val activeAdvertiser = advertiser ?: bluetoothAdapter?.bluetoothLeAdvertiser
242
+ extendedAdvertisingCallbacks.values.forEach { callback ->
243
+ activeAdvertiser?.stopAdvertisingSet(callback)
244
+ }
245
+ }
246
+ extendedAdvertisingCallbacks.clear()
247
+ extendedAdvertisingSets.clear()
154
248
  advertiseCallback = null
155
249
  advertiser = null
250
+ gattServer?.clearServices()
251
+ gattServer?.close()
252
+ gattServer = null
253
+ gattServerReady = false
254
+ subscribedDevices.clear()
156
255
  currentAdvertisingData = null
157
256
  currentServiceUUIDs = emptyArray()
158
257
  currentLocalName = null
@@ -161,6 +260,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
161
260
  }
162
261
 
163
262
  override fun setServices(services: Array<GATTService>) {
263
+ if (!ensureBluetoothPermissions("set GATT services")) {
264
+ return
265
+ }
266
+
267
+ configuredServices = services
164
268
  ensureBluetoothManager()
165
269
  gattServerReady = false
166
270
 
@@ -171,6 +275,8 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
171
275
  gattServer = manager.openGattServer(context, buildGattServerCallback())
172
276
  gattServer?.clearServices()
173
277
 
278
+ val nativeServices = linkedMapOf<String, BluetoothGattService>()
279
+
174
280
  for (serviceData in services) {
175
281
  val service = BluetoothGattService(
176
282
  UUID.fromString(serviceData.uuid),
@@ -185,27 +291,152 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
185
291
  BluetoothGattCharacteristic.PERMISSION_WRITE
186
292
  )
187
293
  characteristicData.value?.let { value ->
188
- characteristic.value = hexStringToByteArray(value) ?: value.toByteArray()
294
+ setCharacteristicValue(characteristic, hexStringToByteArray(value) ?: value.toByteArray())
295
+ }
296
+ characteristicData.descriptors?.forEach { descriptorData ->
297
+ val descriptor = BluetoothGattDescriptor(
298
+ UUID.fromString(descriptorData.uuid),
299
+ descriptorPermissionsFromArray(descriptorData.permissions)
300
+ )
301
+ descriptorData.value?.let { value ->
302
+ setDescriptorValue(descriptor, hexStringToByteArray(value) ?: value.toByteArray())
303
+ }
304
+ characteristic.addDescriptor(descriptor)
305
+ }
306
+
307
+ val hasClientConfigDescriptor = characteristic.descriptors.any {
308
+ it.uuid == CLIENT_CHARACTERISTIC_CONFIG_UUID
309
+ }
310
+ if (supportsNotifyOrIndicate(characteristic) && !hasClientConfigDescriptor) {
311
+ characteristic.addDescriptor(
312
+ BluetoothGattDescriptor(
313
+ CLIENT_CHARACTERISTIC_CONFIG_UUID,
314
+ BluetoothGattDescriptor.PERMISSION_READ or
315
+ BluetoothGattDescriptor.PERMISSION_WRITE
316
+ )
317
+ )
189
318
  }
190
319
  service.addCharacteristic(characteristic)
191
320
  }
192
321
 
322
+ nativeServices[serviceData.uuid.lowercase()] = service
323
+ }
324
+
325
+ for (serviceData in services) {
326
+ val service = nativeServices[serviceData.uuid.lowercase()] ?: continue
327
+ serviceData.includedServices?.forEach { includedServiceUuid ->
328
+ nativeServices[includedServiceUuid.lowercase()]?.let { includedService ->
329
+ service.addService(includedService)
330
+ }
331
+ }
332
+ }
333
+
334
+ nativeServices.values.forEach { service ->
193
335
  gattServer?.addService(service)
194
336
  }
195
337
 
196
338
  gattServerReady = true
197
339
  }
198
340
 
341
+ override fun updateCharacteristicValue(
342
+ serviceUUID: String,
343
+ characteristicUUID: String,
344
+ value: String,
345
+ notify: Boolean?
346
+ ): Promise<Unit> {
347
+ val payload = hexStringToByteArray(value)
348
+ ?: return Promise.rejected(IllegalArgumentException("Value must be a hex string"))
349
+ val characteristic = findLocalCharacteristic(serviceUUID, characteristicUUID)
350
+ ?: return Promise.rejected(IllegalArgumentException("Local characteristic $characteristicUUID was not found"))
351
+
352
+ setCharacteristicValue(characteristic, payload)
353
+ if (notify == true) {
354
+ notifySubscribedDevices(characteristic)
355
+ }
356
+ return Promise.resolved(Unit)
357
+ }
358
+
199
359
  override fun isBluetoothEnabled(): Promise<Boolean> {
360
+ if (!hasRequiredBluetoothPermissions()) {
361
+ return Promise.resolved(false)
362
+ }
363
+
200
364
  ensureBluetoothManager()
201
365
  return Promise.resolved(bluetoothAdapter?.isEnabled == true)
202
366
  }
203
367
 
204
368
  override fun requestBluetoothPermission(): Promise<Boolean> {
205
- return Promise.resolved(true)
369
+ val context = NitroModules.applicationContext ?: run {
370
+ Log.w(TAG, "Unable to request Bluetooth permissions: React context unavailable")
371
+ return Promise.resolved(false)
372
+ }
373
+
374
+ val missingPermissions = BluetoothPermissionUtils.missingPermissions(context)
375
+ if (missingPermissions.isEmpty()) {
376
+ return Promise.resolved(true)
377
+ }
378
+
379
+ val activity = context.currentActivity as? PermissionAwareActivity
380
+ if (activity == null) {
381
+ Log.w(TAG, "Unable to request Bluetooth permissions: current activity unavailable")
382
+ return Promise.resolved(false)
383
+ }
384
+
385
+ val requestCode = nextPermissionRequestCode++
386
+ val promise = Promise<Boolean>()
387
+
388
+ try {
389
+ activity.requestPermissions(
390
+ missingPermissions,
391
+ requestCode,
392
+ PermissionListener { callbackRequestCode, _, grantResults ->
393
+ if (callbackRequestCode != requestCode) {
394
+ return@PermissionListener false
395
+ }
396
+
397
+ val isGranted =
398
+ grantResults.isNotEmpty() &&
399
+ grantResults.all { it == PackageManager.PERMISSION_GRANTED }
400
+ promise.resolve(isGranted)
401
+ true
402
+ }
403
+ )
404
+ } catch (error: IllegalStateException) {
405
+ Log.w(TAG, "Unable to request Bluetooth permissions", error)
406
+ promise.resolve(false)
407
+ }
408
+
409
+ return promise
410
+ }
411
+
412
+ override fun getCapabilities(): Promise<BluetoothCapabilities> {
413
+ ensureBluetoothManager()
414
+ val adapter = bluetoothAdapter
415
+ return Promise.resolved(
416
+ BluetoothCapabilities(
417
+ platform = "android",
418
+ supportsBleCentral = true,
419
+ supportsBlePeripheral = adapter?.bluetoothLeAdvertiser != null,
420
+ supportsDescriptors = true,
421
+ supportsIncludedServices = true,
422
+ supportsMtu = true,
423
+ supportsPhy = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O,
424
+ supportsBonding = true,
425
+ supportsExtendedAdvertising = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
426
+ adapter?.isLeExtendedAdvertisingSupported == true,
427
+ supportsL2cap = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
428
+ supportsClassicBluetooth = adapter != null,
429
+ supportsBackgroundBle = true,
430
+ supportsMultipeerConnectivity = false
431
+ )
432
+ )
206
433
  }
207
434
 
208
435
  override fun startScan(options: ScanOptions?) {
436
+ if (!ensureBluetoothPermissions("start scanning")) {
437
+ return
438
+ }
439
+
209
440
  ensureBluetoothManager()
210
441
  val adapter = bluetoothAdapter
211
442
  if (adapter == null || !adapter.isEnabled) {
@@ -247,20 +478,27 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
247
478
  override fun onScanResult(callbackType: Int, result: ScanResult) {
248
479
  val device = result.device
249
480
  discoveredDevices[device.address] = device
250
- eventEmitter.emit("deviceFound", buildScanPayload(result))
481
+ emitDeviceFound(buildScanPayload(result))
251
482
  }
252
483
 
253
484
  override fun onBatchScanResults(results: MutableList<ScanResult>) {
254
485
  results.forEach { result ->
255
486
  val device = result.device
256
487
  discoveredDevices[device.address] = device
257
- eventEmitter.emit("deviceFound", buildScanPayload(result))
488
+ emitDeviceFound(buildScanPayload(result))
258
489
  }
259
490
  }
260
491
 
261
492
  override fun onScanFailed(errorCode: Int) {
262
493
  Log.e(TAG, "Scan failed: $errorCode")
263
494
  isScanning = false
495
+ eventEmitter.emit(
496
+ "scanFailed",
497
+ mapOf(
498
+ "errorCode" to errorCode,
499
+ "message" to scanFailureMessage(errorCode)
500
+ )
501
+ )
264
502
  }
265
503
  }
266
504
 
@@ -278,6 +516,10 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
278
516
  }
279
517
 
280
518
  override fun connect(deviceId: String): Promise<Unit> {
519
+ if (!ensureBluetoothPermissions("connect to BLE device")) {
520
+ return Promise.rejected(IllegalStateException("Bluetooth permissions not granted"))
521
+ }
522
+
281
523
  ensureBluetoothManager()
282
524
  connectedDevices[deviceId]?.let { existingGatt ->
283
525
  if (existingGatt.services != null) {
@@ -285,10 +527,10 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
285
527
  }
286
528
  }
287
529
 
288
- val context = NitroModules.applicationContext
289
- ?: return Promise.rejected(IllegalStateException("React context unavailable"))
290
530
  val adapter = bluetoothAdapter
291
531
  ?: return Promise.rejected(IllegalStateException("Bluetooth adapter unavailable"))
532
+ NitroModules.applicationContext
533
+ ?: return Promise.rejected(IllegalStateException("React context unavailable"))
292
534
 
293
535
  val device = discoveredDevices[deviceId] ?: run {
294
536
  try {
@@ -300,20 +542,22 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
300
542
 
301
543
  val promise = Promise<Unit>()
302
544
  pendingConnections[deviceId] = promise
303
-
304
- val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
305
- device.connectGatt(context, false, createGattCallback(deviceId), BluetoothDevice.TRANSPORT_LE)
306
- } else {
307
- device.connectGatt(context, false, createGattCallback(deviceId))
308
- }
309
- connectedDevices[deviceId] = gatt
545
+ pendingConnectionAttempts[deviceId] = 0
546
+ scheduleConnectionTimeout(deviceId)
547
+ startGattConnection(deviceId, device)
310
548
  return promise
311
549
  }
312
550
 
313
551
  override fun disconnect(deviceId: String) {
314
- pendingConnections.remove(deviceId)
315
- pendingServiceDiscoveries.remove(deviceId)
316
- pendingRssiReads.remove(deviceId)
552
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
553
+ pendingConnectionAttempts.remove(deviceId)
554
+ pendingConnectionGatts.remove(deviceId)?.let { gatt ->
555
+ gatt.disconnect()
556
+ gatt.close()
557
+ }
558
+ pendingConnections.remove(deviceId)?.reject(
559
+ IllegalStateException("Disconnected from $deviceId")
560
+ )
317
561
 
318
562
  val gatt = connectedDevices.remove(deviceId)
319
563
  gatt?.disconnect()
@@ -333,7 +577,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
333
577
 
334
578
  val promise = Promise<Array<GATTService>>()
335
579
  pendingServiceDiscoveries[deviceId] = promise
580
+ schedulePendingOperationTimeout("services|$deviceId", "Service discovery for $deviceId") {
581
+ pendingServiceDiscoveries.remove(deviceId)
582
+ }
336
583
  if (!gatt.discoverServices()) {
584
+ cancelPendingOperationTimeout("services|$deviceId")
337
585
  pendingServiceDiscoveries.remove(deviceId)
338
586
  return Promise.rejected(IllegalStateException("Failed to start service discovery for $deviceId"))
339
587
  }
@@ -355,14 +603,43 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
355
603
  val promise = Promise<CharacteristicValue>()
356
604
  val key = characteristicKey(deviceId, serviceUUID, characteristicUUID)
357
605
  pendingReads[key] = promise
606
+ schedulePendingOperationTimeout("read|$key", "Characteristic read $key") {
607
+ pendingReads.remove(key)
608
+ }
358
609
 
359
610
  if (!gatt.readCharacteristic(characteristic)) {
611
+ cancelPendingOperationTimeout("read|$key")
360
612
  pendingReads.remove(key)
361
613
  return Promise.rejected(IllegalStateException("Failed to start characteristic read"))
362
614
  }
363
615
  return promise
364
616
  }
365
617
 
618
+ override fun readDescriptor(
619
+ deviceId: String,
620
+ serviceUUID: String,
621
+ characteristicUUID: String,
622
+ descriptorUUID: String
623
+ ): Promise<DescriptorValue> {
624
+ val gatt = connectedDevices[deviceId]
625
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
626
+ val descriptor = findDescriptor(gatt, serviceUUID, characteristicUUID, descriptorUUID)
627
+ ?: return Promise.rejected(IllegalStateException("Descriptor not found: $descriptorUUID"))
628
+
629
+ val promise = Promise<DescriptorValue>()
630
+ val key = descriptorKey(deviceId, serviceUUID, characteristicUUID, descriptorUUID)
631
+ pendingDescriptorReads[key] = promise
632
+ schedulePendingOperationTimeout("descriptorRead|$key", "Descriptor read $key") {
633
+ pendingDescriptorReads.remove(key)
634
+ }
635
+ if (!gatt.readDescriptor(descriptor)) {
636
+ cancelPendingOperationTimeout("descriptorRead|$key")
637
+ pendingDescriptorReads.remove(key)
638
+ return Promise.rejected(IllegalStateException("Failed to start descriptor read"))
639
+ }
640
+ return promise
641
+ }
642
+
366
643
  override fun writeCharacteristic(
367
644
  deviceId: String,
368
645
  serviceUUID: String,
@@ -379,20 +656,56 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
379
656
  val data = hexStringToByteArray(value)
380
657
  ?: return Promise.rejected(IllegalArgumentException("Invalid hex string for characteristic write"))
381
658
 
382
- characteristic.value = data
383
- characteristic.writeType = when (writeType) {
659
+ val resolvedWriteType = when (writeType) {
384
660
  WriteType.WRITEWITHOUTRESPONSE -> BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
385
661
  else -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
386
662
  }
387
663
 
388
664
  val promise = Promise<Unit>()
389
665
  val key = characteristicKey(deviceId, serviceUUID, characteristicUUID)
390
- pendingWrites[key] = promise
666
+ if (resolvedWriteType != BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) {
667
+ pendingWrites[key] = promise
668
+ schedulePendingOperationTimeout("write|$key", "Characteristic write $key") {
669
+ pendingWrites.remove(key)
670
+ }
671
+ }
391
672
 
392
- if (!gatt.writeCharacteristic(characteristic)) {
673
+ if (!writeGattCharacteristic(gatt, characteristic, data, resolvedWriteType)) {
674
+ cancelPendingOperationTimeout("write|$key")
393
675
  pendingWrites.remove(key)
394
676
  return Promise.rejected(IllegalStateException("Failed to start characteristic write"))
395
677
  }
678
+ if (resolvedWriteType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) {
679
+ promise.resolve(Unit)
680
+ }
681
+ return promise
682
+ }
683
+
684
+ override fun writeDescriptor(
685
+ deviceId: String,
686
+ serviceUUID: String,
687
+ characteristicUUID: String,
688
+ descriptorUUID: String,
689
+ value: String
690
+ ): Promise<Unit> {
691
+ val gatt = connectedDevices[deviceId]
692
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
693
+ val descriptor = findDescriptor(gatt, serviceUUID, characteristicUUID, descriptorUUID)
694
+ ?: return Promise.rejected(IllegalStateException("Descriptor not found: $descriptorUUID"))
695
+ val data = hexStringToByteArray(value)
696
+ ?: return Promise.rejected(IllegalArgumentException("Invalid hex string for descriptor write"))
697
+
698
+ val promise = Promise<Unit>()
699
+ val key = descriptorKey(deviceId, serviceUUID, characteristicUUID, descriptorUUID)
700
+ pendingDescriptorWrites[key] = promise
701
+ schedulePendingOperationTimeout("descriptorWrite|$key", "Descriptor write $key") {
702
+ pendingDescriptorWrites.remove(key)
703
+ }
704
+ if (!writeGattDescriptor(gatt, descriptor, data)) {
705
+ cancelPendingOperationTimeout("descriptorWrite|$key")
706
+ pendingDescriptorWrites.remove(key)
707
+ return Promise.rejected(IllegalStateException("Failed to start descriptor write"))
708
+ }
396
709
  return promise
397
710
  }
398
711
 
@@ -406,8 +719,14 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
406
719
  gatt.setCharacteristicNotification(characteristic, true)
407
720
 
408
721
  characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)?.let { descriptor ->
409
- descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
410
- gatt.writeDescriptor(descriptor)
722
+ val subscriptionValue = when {
723
+ characteristic.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 ->
724
+ BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
725
+ characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0 ->
726
+ BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
727
+ else -> return
728
+ }
729
+ writeGattDescriptor(gatt, descriptor, subscriptionValue)
411
730
  }
412
731
  }
413
732
 
@@ -421,8 +740,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
421
740
  gatt.setCharacteristicNotification(characteristic, false)
422
741
 
423
742
  characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)?.let { descriptor ->
424
- descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
425
- gatt.writeDescriptor(descriptor)
743
+ writeGattDescriptor(gatt, descriptor, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
426
744
  }
427
745
  }
428
746
 
@@ -440,148 +758,809 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
440
758
 
441
759
  val promise = Promise<Double>()
442
760
  pendingRssiReads[deviceId] = promise
761
+ schedulePendingOperationTimeout("rssi|$deviceId", "RSSI read for $deviceId") {
762
+ pendingRssiReads.remove(deviceId)
763
+ }
443
764
  if (!gatt.readRemoteRssi()) {
765
+ cancelPendingOperationTimeout("rssi|$deviceId")
444
766
  pendingRssiReads.remove(deviceId)
445
767
  return Promise.rejected(IllegalStateException("Failed to start RSSI read"))
446
768
  }
447
769
  return promise
448
770
  }
449
771
 
450
- override fun startBackgroundSession(options: BackgroundSessionOptions) {
451
- val context = NitroModules.applicationContext ?: run {
452
- Log.w(TAG, "Unable to start background BLE session: application context unavailable")
453
- return
454
- }
772
+ override fun requestMTU(deviceId: String, mtu: Double): Promise<Double> {
773
+ val gatt = connectedDevices[deviceId]
774
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
455
775
 
456
- val intent = Intent(context, MunimBluetoothBackgroundService::class.java).apply {
457
- action = MunimBluetoothBackgroundService.ACTION_START
458
- putExtra(
459
- MunimBluetoothBackgroundService.EXTRA_SERVICE_UUIDS,
460
- options.serviceUUIDs
461
- )
462
- putExtra(
463
- MunimBluetoothBackgroundService.EXTRA_LOCAL_NAME,
464
- options.localName
465
- )
466
- putExtra(
467
- MunimBluetoothBackgroundService.EXTRA_ALLOW_DUPLICATES,
468
- options.allowDuplicates ?: false
469
- )
470
- putExtra(
471
- MunimBluetoothBackgroundService.EXTRA_SCAN_MODE,
472
- options.scanMode?.name ?: ScanMode.LOWPOWER.name
473
- )
474
- putExtra(
475
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_CHANNEL_ID,
476
- options.androidNotificationChannelId
477
- ?: MunimBluetoothBackgroundService.DEFAULT_CHANNEL_ID
478
- )
479
- putExtra(
480
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_CHANNEL_NAME,
481
- options.androidNotificationChannelName
482
- ?: MunimBluetoothBackgroundService.DEFAULT_CHANNEL_NAME
483
- )
484
- putExtra(
485
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_TITLE,
486
- options.androidNotificationTitle
487
- ?: MunimBluetoothBackgroundService.DEFAULT_NOTIFICATION_TITLE
488
- )
489
- putExtra(
490
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_TEXT,
491
- options.androidNotificationText
492
- ?: MunimBluetoothBackgroundService.DEFAULT_NOTIFICATION_TEXT
493
- )
776
+ val requestedMtu = mtu.toInt().coerceIn(23, 517)
777
+ val promise = Promise<Double>()
778
+ pendingMtuRequests[deviceId] = promise
779
+ schedulePendingOperationTimeout("mtu|$deviceId", "MTU request for $deviceId") {
780
+ pendingMtuRequests.remove(deviceId)
494
781
  }
495
-
496
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
497
- context.startForegroundService(intent)
498
- } else {
499
- context.startService(intent)
782
+ if (!gatt.requestMtu(requestedMtu)) {
783
+ cancelPendingOperationTimeout("mtu|$deviceId")
784
+ pendingMtuRequests.remove(deviceId)
785
+ return Promise.rejected(IllegalStateException("Failed to start MTU request"))
500
786
  }
787
+ return promise
501
788
  }
502
789
 
503
- override fun stopBackgroundSession() {
504
- val context = NitroModules.applicationContext ?: return
505
- val intent = Intent(context, MunimBluetoothBackgroundService::class.java).apply {
506
- action = MunimBluetoothBackgroundService.ACTION_STOP
790
+ override fun setPreferredPhy(
791
+ deviceId: String,
792
+ txPhy: BluetoothPhy,
793
+ rxPhy: BluetoothPhy,
794
+ phyOption: BluetoothPhyOption?
795
+ ): Promise<Unit> {
796
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
797
+ return unsupportedPromise("BLE PHY selection requires Android 8.0 or newer")
507
798
  }
508
- context.startService(intent)
509
- }
510
799
 
511
- override fun addListener(eventName: String) {
512
- // Nitro uses JS-side listener registration. No native bookkeeping required here.
513
- }
800
+ val gatt = connectedDevices[deviceId]
801
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
514
802
 
515
- override fun removeListeners(count: Double) {
516
- // Nitro uses JS-side listener registration. No native bookkeeping required here.
803
+ gatt.setPreferredPhy(
804
+ phyToMask(txPhy),
805
+ phyToMask(rxPhy),
806
+ phyOptionToConstant(phyOption ?: BluetoothPhyOption.NONE)
807
+ )
808
+ return Promise.resolved(Unit)
517
809
  }
518
810
 
519
- private fun restartAdvertising(delayMs: Long) {
520
- ensureBluetoothManager()
521
- val adapter = bluetoothAdapter
522
- if (adapter == null || !adapter.isEnabled) {
523
- Log.e(TAG, "Bluetooth is not enabled or not available")
524
- return
811
+ override fun readPhy(deviceId: String): Promise<PhyStatus> {
812
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
813
+ return unsupportedPromise("BLE PHY reads require Android 8.0 or newer")
525
814
  }
526
815
 
527
- advertiseJob?.cancel()
528
- advertiseCallback?.let { callback ->
529
- advertiser?.stopAdvertising(callback)
816
+ val gatt = connectedDevices[deviceId]
817
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
818
+
819
+ val promise = Promise<PhyStatus>()
820
+ pendingPhyReads[deviceId] = promise
821
+ schedulePendingOperationTimeout("phy|$deviceId", "PHY read for $deviceId") {
822
+ pendingPhyReads.remove(deviceId)
530
823
  }
824
+ gatt.readPhy()
825
+ return promise
826
+ }
531
827
 
532
- advertiseJob = bluetoothScope.launch {
533
- if (delayMs > 0) {
534
- delay(delayMs)
535
- }
828
+ override fun getBondState(deviceId: String): Promise<BondState> {
829
+ val device = resolveBluetoothDevice(deviceId)
830
+ ?: return Promise.rejected(IllegalArgumentException("Device not found: $deviceId"))
831
+ return Promise.resolved(bondStateFor(device))
832
+ }
536
833
 
537
- advertiser = adapter.bluetoothLeAdvertiser
538
- val activeAdvertiser = advertiser
539
- if (activeAdvertiser == null) {
540
- Log.e(TAG, "Bluetooth LE advertiser is not available")
541
- return@launch
542
- }
834
+ override fun createBond(deviceId: String): Promise<BondState> {
835
+ val device = resolveBluetoothDevice(deviceId)
836
+ ?: return Promise.rejected(IllegalArgumentException("Device not found: $deviceId"))
543
837
 
544
- val dataBuilder = AdvertiseData.Builder()
545
- currentAdvertisingData?.let { processAdvertisingData(it, dataBuilder) }
546
- currentServiceUUIDs.forEach { uuid ->
547
- dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid))
548
- }
838
+ if (device.bondState == BluetoothDevice.BOND_BONDED) {
839
+ return Promise.resolved(BondState.BONDED)
840
+ }
549
841
 
550
- val settings = AdvertiseSettings.Builder()
551
- .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
552
- .setConnectable(true)
553
- .setTimeout(0)
554
- .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
555
- .build()
842
+ return if (device.createBond()) {
843
+ Promise.resolved(bondStateFor(device))
844
+ } else {
845
+ Promise.rejected(IllegalStateException("Failed to start bond creation for $deviceId"))
846
+ }
847
+ }
556
848
 
557
- advertiseCallback = object : AdvertiseCallback() {
558
- override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
559
- Log.i(TAG, "Advertising started successfully")
560
- }
849
+ override fun removeBond(deviceId: String): Promise<BondState> {
850
+ val device = resolveBluetoothDevice(deviceId)
851
+ ?: return Promise.rejected(IllegalArgumentException("Device not found: $deviceId"))
561
852
 
562
- override fun onStartFailure(errorCode: Int) {
563
- Log.e(TAG, "Advertising failed: $errorCode")
564
- }
853
+ return try {
854
+ val method = device.javaClass.getMethod("removeBond")
855
+ val removed = method.invoke(device) as? Boolean ?: false
856
+ if (removed) {
857
+ Promise.resolved(bondStateFor(device))
858
+ } else {
859
+ Promise.rejected(IllegalStateException("Failed to remove bond for $deviceId"))
565
860
  }
566
-
567
- activeAdvertiser.startAdvertising(settings, dataBuilder.build(), advertiseCallback)
861
+ } catch (error: ReflectiveOperationException) {
862
+ Promise.rejected(UnsupportedOperationException("Removing bonds is unavailable on this Android build", error))
568
863
  }
569
864
  }
570
865
 
571
- private fun buildGattServerCallback(): BluetoothGattServerCallback {
572
- return object : BluetoothGattServerCallback() {
573
- override fun onCharacteristicReadRequest(
574
- device: BluetoothDevice,
575
- requestId: Int,
576
- offset: Int,
577
- characteristic: BluetoothGattCharacteristic
578
- ) {
579
- gattServer?.sendResponse(
580
- device,
581
- requestId,
582
- BluetoothGatt.GATT_SUCCESS,
866
+ override fun startExtendedAdvertising(options: ExtendedAdvertisingOptions): Promise<String> {
867
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
868
+ return unsupportedPromise("BLE extended advertising requires Android 8.0 or newer")
869
+ }
870
+ if (!ensureBluetoothPermissions("start extended advertising")) {
871
+ return Promise.rejected(IllegalStateException("Bluetooth permissions not granted"))
872
+ }
873
+
874
+ ensureBluetoothManager()
875
+ val adapter = bluetoothAdapter
876
+ ?: return Promise.rejected(IllegalStateException("Bluetooth adapter unavailable"))
877
+ if (!adapter.isLeExtendedAdvertisingSupported) {
878
+ return unsupportedPromise("BLE extended advertising is not supported by this device")
879
+ }
880
+ val advertiser = adapter.bluetoothLeAdvertiser
881
+ ?: return Promise.rejected(IllegalStateException("Bluetooth LE advertiser is unavailable"))
882
+
883
+ val id = UUID.randomUUID().toString()
884
+ val promise = Promise<String>()
885
+ val dataBuilder = AdvertiseData.Builder()
886
+ options.serviceUUIDs?.forEach { uuid ->
887
+ dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid))
888
+ }
889
+ normalizeAdvertisingData(
890
+ options.advertisingData,
891
+ options.localName,
892
+ options.manufacturerData
893
+ ).let { data ->
894
+ processAdvertisingData(data, dataBuilder, includeServiceUuids = true)
895
+ }
896
+
897
+ val scanResponseBuilder = AdvertiseData.Builder()
898
+ options.localName?.let { scanResponseBuilder.setIncludeDeviceName(true) }
899
+
900
+ val parameters = AdvertisingSetParameters.Builder()
901
+ .setLegacyMode(options.legacyMode ?: false)
902
+ .setConnectable(options.connectable ?: true)
903
+ .setScannable(options.scannable ?: false)
904
+ .setAnonymous(options.anonymous ?: false)
905
+ .setIncludeTxPower(options.includeTxPower ?: false)
906
+ .setPrimaryPhy(phyToAdvertisingPhy(options.primaryPhy ?: BluetoothPhy.LE1M))
907
+ .setSecondaryPhy(phyToAdvertisingPhy(options.secondaryPhy ?: BluetoothPhy.LE1M))
908
+ .setInterval(options.interval?.toInt() ?: AdvertisingSetParameters.INTERVAL_MEDIUM)
909
+ .setTxPowerLevel(options.txPowerLevel?.toInt() ?: AdvertisingSetParameters.TX_POWER_HIGH)
910
+ .build()
911
+
912
+ val callback = object : AdvertisingSetCallback() {
913
+ override fun onAdvertisingSetStarted(
914
+ advertisingSet: AdvertisingSet?,
915
+ txPower: Int,
916
+ status: Int
917
+ ) {
918
+ if (status == AdvertisingSetCallback.ADVERTISE_SUCCESS && advertisingSet != null) {
919
+ extendedAdvertisingSets[id] = advertisingSet
920
+ promise.resolve(id)
921
+ eventEmitter.emit("advertisingStarted", mapOf("advertisingId" to id))
922
+ } else {
923
+ extendedAdvertisingCallbacks.remove(id)
924
+ promise.reject(IllegalStateException("Extended advertising failed (status=$status)"))
925
+ eventEmitter.emit(
926
+ "advertisingStartFailed",
927
+ mapOf(
928
+ "advertisingId" to id,
929
+ "errorCode" to status,
930
+ "message" to advertiseFailureMessage(status)
931
+ )
932
+ )
933
+ }
934
+ }
935
+ }
936
+
937
+ extendedAdvertisingCallbacks[id] = callback
938
+ advertiser.startAdvertisingSet(
939
+ parameters,
940
+ dataBuilder.build(),
941
+ scanResponseBuilder.build(),
942
+ null,
943
+ null,
944
+ callback
945
+ )
946
+ return promise
947
+ }
948
+
949
+ override fun stopExtendedAdvertising(advertisingId: String) {
950
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
951
+
952
+ ensureBluetoothManager()
953
+ extendedAdvertisingSets.remove(advertisingId)
954
+ val callback = extendedAdvertisingCallbacks.remove(advertisingId) ?: return
955
+ bluetoothAdapter?.bluetoothLeAdvertiser?.stopAdvertisingSet(callback)
956
+ }
957
+
958
+ override fun publishL2CAPChannel(encryptionRequired: Boolean?): Promise<L2CAPChannel> {
959
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
960
+ return unsupportedPromise("BLE L2CAP channel streams require Android 10 or newer")
961
+ }
962
+ if (!hasRequiredBluetoothPermissions()) {
963
+ return Promise.rejected(SecurityException("Missing Bluetooth permissions"))
964
+ }
965
+
966
+ ensureBluetoothManager()
967
+ val adapter = bluetoothAdapter
968
+ ?: return Promise.rejected(IllegalStateException("Bluetooth adapter is unavailable"))
969
+ val promise = Promise<L2CAPChannel>()
970
+
971
+ bluetoothScope.launch(Dispatchers.IO) {
972
+ try {
973
+ val serverSocket = if (encryptionRequired == true) {
974
+ adapter.listenUsingL2capChannel()
975
+ } else {
976
+ adapter.listenUsingInsecureL2capChannel()
977
+ }
978
+ val psm = serverSocket.psm
979
+ l2capServerSockets[psm] = serverSocket
980
+ l2capAcceptJobs[psm]?.cancel()
981
+ l2capAcceptJobs[psm] = bluetoothScope.launch(Dispatchers.IO) {
982
+ acceptL2CAPConnections(psm, serverSocket)
983
+ }
984
+
985
+ val channel = L2CAPChannel("server:$psm", psm.toDouble(), null)
986
+ eventEmitter.emit(
987
+ "l2capChannelPublished",
988
+ mapOf("channelId" to channel.id, "psm" to channel.psm)
989
+ )
990
+ promise.resolve(channel)
991
+ } catch (error: SecurityException) {
992
+ promise.reject(error)
993
+ } catch (error: IOException) {
994
+ promise.reject(error)
995
+ }
996
+ }
997
+
998
+ return promise
999
+ }
1000
+
1001
+ override fun unpublishL2CAPChannel(psm: Double) {
1002
+ val psmValue = psm.toInt()
1003
+ l2capAcceptJobs.remove(psmValue)?.cancel()
1004
+ try {
1005
+ l2capServerSockets.remove(psmValue)?.close()
1006
+ } catch (error: IOException) {
1007
+ Log.w(TAG, "Unable to close L2CAP server socket for PSM $psmValue", error)
1008
+ }
1009
+ }
1010
+
1011
+ override fun openL2CAPChannel(deviceId: String, psm: Double): Promise<L2CAPChannel> {
1012
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
1013
+ return unsupportedPromise("BLE L2CAP channel streams require Android 10 or newer")
1014
+ }
1015
+ if (!hasRequiredBluetoothPermissions()) {
1016
+ return Promise.rejected(SecurityException("Missing Bluetooth permissions"))
1017
+ }
1018
+
1019
+ val device = resolveBluetoothDevice(deviceId)
1020
+ ?: return Promise.rejected(IllegalArgumentException("Device $deviceId was not found"))
1021
+ val promise = Promise<L2CAPChannel>()
1022
+
1023
+ bluetoothScope.launch(Dispatchers.IO) {
1024
+ try {
1025
+ val socket = device.createL2capChannel(psm.toInt())
1026
+ socket.connect()
1027
+ val channel = registerL2CAPSocket(socket, psm.toInt(), device.address)
1028
+ promise.resolve(channel)
1029
+ } catch (error: SecurityException) {
1030
+ promise.reject(error)
1031
+ } catch (error: IOException) {
1032
+ promise.reject(error)
1033
+ }
1034
+ }
1035
+
1036
+ return promise
1037
+ }
1038
+
1039
+ override fun closeL2CAPChannel(channelId: String) {
1040
+ closeL2CAPChannelInternal(channelId, true)
1041
+ }
1042
+
1043
+ override fun sendL2CAPData(channelId: String, value: String): Promise<Unit> {
1044
+ val socket = l2capSockets[channelId]
1045
+ ?: return Promise.rejected(IllegalArgumentException("L2CAP channel $channelId is not open"))
1046
+ val payload = hexStringToByteArray(value)
1047
+ ?: return Promise.rejected(IllegalArgumentException("Value must be a hex string"))
1048
+ val promise = Promise<Unit>()
1049
+
1050
+ bluetoothScope.launch(Dispatchers.IO) {
1051
+ try {
1052
+ socket.outputStream.write(payload)
1053
+ socket.outputStream.flush()
1054
+ promise.resolve(Unit)
1055
+ } catch (error: IOException) {
1056
+ promise.reject(error)
1057
+ closeL2CAPChannelInternal(channelId, true)
1058
+ }
1059
+ }
1060
+
1061
+ return promise
1062
+ }
1063
+
1064
+ override fun startClassicScan() {
1065
+ if (!ensureBluetoothPermissions("start Classic Bluetooth discovery")) {
1066
+ return
1067
+ }
1068
+
1069
+ ensureBluetoothManager()
1070
+ val adapter = bluetoothAdapter
1071
+ val context = NitroModules.applicationContext ?: return
1072
+ if (adapter == null || !adapter.isEnabled) {
1073
+ eventEmitter.emit(
1074
+ "classicScanFailed",
1075
+ mapOf("message" to "Bluetooth is not enabled or unavailable")
1076
+ )
1077
+ return
1078
+ }
1079
+
1080
+ if (classicScanReceiver == null) {
1081
+ classicScanReceiver = object : BroadcastReceiver() {
1082
+ override fun onReceive(context: Context, intent: Intent) {
1083
+ when (intent.action) {
1084
+ BluetoothDevice.ACTION_FOUND -> {
1085
+ val device = getBluetoothDeviceExtra(intent) ?: return
1086
+ classicDevices[device.address] = device
1087
+ eventEmitter.emit(
1088
+ "classicDeviceFound",
1089
+ mapOf(
1090
+ "id" to device.address,
1091
+ "name" to device.name,
1092
+ "bondState" to bondStateFor(device).name.lowercase()
1093
+ )
1094
+ )
1095
+ }
1096
+
1097
+ BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
1098
+ eventEmitter.emit("classicScanFinished", emptyMap())
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+ val filter = IntentFilter().apply {
1104
+ addAction(BluetoothDevice.ACTION_FOUND)
1105
+ addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
1106
+ }
1107
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1108
+ context.registerReceiver(classicScanReceiver, filter, Context.RECEIVER_EXPORTED)
1109
+ } else {
1110
+ @Suppress("DEPRECATION")
1111
+ context.registerReceiver(classicScanReceiver, filter)
1112
+ }
1113
+ }
1114
+
1115
+ try {
1116
+ if (adapter.isDiscovering) {
1117
+ adapter.cancelDiscovery()
1118
+ }
1119
+ if (!adapter.startDiscovery()) {
1120
+ eventEmitter.emit(
1121
+ "classicScanFailed",
1122
+ mapOf("message" to "Classic Bluetooth discovery failed to start")
1123
+ )
1124
+ }
1125
+ } catch (error: SecurityException) {
1126
+ eventEmitter.emit("classicScanFailed", mapOf("message" to (error.message ?: "Missing Bluetooth permissions")))
1127
+ }
1128
+ }
1129
+
1130
+ override fun stopClassicScan() {
1131
+ ensureBluetoothManager()
1132
+ try {
1133
+ bluetoothAdapter?.cancelDiscovery()
1134
+ } catch (error: SecurityException) {
1135
+ Log.w(TAG, "Unable to cancel Classic Bluetooth discovery", error)
1136
+ }
1137
+
1138
+ val context = NitroModules.applicationContext ?: return
1139
+ classicScanReceiver?.let { receiver ->
1140
+ try {
1141
+ context.unregisterReceiver(receiver)
1142
+ } catch (error: IllegalArgumentException) {
1143
+ Log.w(TAG, "Classic Bluetooth discovery receiver was not registered", error)
1144
+ }
1145
+ }
1146
+ classicScanReceiver = null
1147
+ }
1148
+
1149
+ override fun connectClassic(deviceId: String, serviceUUID: String?): Promise<Unit> {
1150
+ if (!hasRequiredBluetoothPermissions()) {
1151
+ return Promise.rejected(SecurityException("Missing Bluetooth permissions"))
1152
+ }
1153
+ val device = resolveClassicDevice(deviceId)
1154
+ ?: return Promise.rejected(IllegalArgumentException("Classic Bluetooth device $deviceId was not found"))
1155
+ val uuid = try {
1156
+ UUID.fromString(serviceUUID ?: SERIAL_PORT_PROFILE_UUID.toString())
1157
+ } catch (error: IllegalArgumentException) {
1158
+ return Promise.rejected(error)
1159
+ }
1160
+ val promise = Promise<Unit>()
1161
+
1162
+ bluetoothScope.launch(Dispatchers.IO) {
1163
+ try {
1164
+ bluetoothAdapter?.cancelDiscovery()
1165
+ closeClassicSocket(deviceId, false)
1166
+ val socket = device.createRfcommSocketToServiceRecord(uuid)
1167
+ socket.connect()
1168
+ classicSockets[deviceId] = socket
1169
+ startClassicReadLoop(deviceId, socket)
1170
+ eventEmitter.emit("classicConnected", mapOf("deviceId" to deviceId))
1171
+ promise.resolve(Unit)
1172
+ } catch (error: SecurityException) {
1173
+ promise.reject(error)
1174
+ } catch (error: IOException) {
1175
+ closeClassicSocket(deviceId, false)
1176
+ promise.reject(error)
1177
+ }
1178
+ }
1179
+
1180
+ return promise
1181
+ }
1182
+
1183
+ override fun startClassicServer(serviceUUID: String?, serviceName: String?): Promise<Unit> {
1184
+ if (!hasRequiredBluetoothPermissions()) {
1185
+ return Promise.rejected(SecurityException("Missing Bluetooth permissions"))
1186
+ }
1187
+
1188
+ ensureBluetoothManager()
1189
+ val adapter = bluetoothAdapter
1190
+ ?: return Promise.rejected(IllegalStateException("Bluetooth adapter unavailable"))
1191
+ val uuid = try {
1192
+ UUID.fromString(serviceUUID ?: SERIAL_PORT_PROFILE_UUID.toString())
1193
+ } catch (error: IllegalArgumentException) {
1194
+ return Promise.rejected(error)
1195
+ }
1196
+ val key = uuid.toString().lowercase()
1197
+ val promise = Promise<Unit>()
1198
+
1199
+ bluetoothScope.launch(Dispatchers.IO) {
1200
+ try {
1201
+ closeClassicServerInternal(key, false)
1202
+ val serverSocket = adapter.listenUsingRfcommWithServiceRecord(
1203
+ serviceName ?: DEFAULT_CLASSIC_SERVICE_NAME,
1204
+ uuid
1205
+ )
1206
+ classicServerSockets[key] = serverSocket
1207
+ classicServerJobs[key] = bluetoothScope.launch(Dispatchers.IO) {
1208
+ acceptClassicConnections(key, serverSocket)
1209
+ }
1210
+ eventEmitter.emit(
1211
+ "classicServerStarted",
1212
+ mapOf("serviceUUID" to uuid.toString(), "serviceName" to (serviceName ?: DEFAULT_CLASSIC_SERVICE_NAME))
1213
+ )
1214
+ promise.resolve(Unit)
1215
+ } catch (error: SecurityException) {
1216
+ promise.reject(error)
1217
+ } catch (error: IOException) {
1218
+ promise.reject(error)
1219
+ }
1220
+ }
1221
+
1222
+ return promise
1223
+ }
1224
+
1225
+ override fun stopClassicServer(serviceUUID: String?) {
1226
+ val uuid = try {
1227
+ UUID.fromString(serviceUUID ?: SERIAL_PORT_PROFILE_UUID.toString())
1228
+ } catch (_: IllegalArgumentException) {
1229
+ return
1230
+ }
1231
+ closeClassicServerInternal(uuid.toString().lowercase(), true)
1232
+ }
1233
+
1234
+ override fun disconnectClassic(deviceId: String) {
1235
+ closeClassicSocket(deviceId, true)
1236
+ }
1237
+
1238
+ override fun writeClassic(deviceId: String, value: String): Promise<Unit> {
1239
+ val socket = classicSockets[deviceId]
1240
+ ?: return Promise.rejected(IllegalArgumentException("Classic Bluetooth device $deviceId is not connected"))
1241
+ val payload = hexStringToByteArray(value)
1242
+ ?: return Promise.rejected(IllegalArgumentException("Value must be a hex string"))
1243
+ val promise = Promise<Unit>()
1244
+
1245
+ bluetoothScope.launch(Dispatchers.IO) {
1246
+ try {
1247
+ socket.outputStream.write(payload)
1248
+ socket.outputStream.flush()
1249
+ promise.resolve(Unit)
1250
+ } catch (error: IOException) {
1251
+ promise.reject(error)
1252
+ closeClassicSocket(deviceId, true)
1253
+ }
1254
+ }
1255
+
1256
+ return promise
1257
+ }
1258
+
1259
+ override fun startBackgroundSession(options: BackgroundSessionOptions) {
1260
+ val context = NitroModules.applicationContext ?: run {
1261
+ Log.w(TAG, "Unable to start background BLE session: application context unavailable")
1262
+ return
1263
+ }
1264
+
1265
+ if (!ensureBluetoothPermissions("start background BLE session")) {
1266
+ return
1267
+ }
1268
+
1269
+ val intent = Intent(context, MunimBluetoothBackgroundService::class.java).apply {
1270
+ action = MunimBluetoothBackgroundService.ACTION_START
1271
+ putExtra(
1272
+ MunimBluetoothBackgroundService.EXTRA_SERVICE_UUIDS,
1273
+ options.serviceUUIDs
1274
+ )
1275
+ putExtra(
1276
+ MunimBluetoothBackgroundService.EXTRA_LOCAL_NAME,
1277
+ options.localName
1278
+ )
1279
+ putExtra(
1280
+ MunimBluetoothBackgroundService.EXTRA_ALLOW_DUPLICATES,
1281
+ options.allowDuplicates ?: false
1282
+ )
1283
+ putExtra(
1284
+ MunimBluetoothBackgroundService.EXTRA_SCAN_MODE,
1285
+ options.scanMode?.name ?: ScanMode.LOWPOWER.name
1286
+ )
1287
+ serializeConfiguredServices()?.let { servicesJson ->
1288
+ putExtra(
1289
+ MunimBluetoothBackgroundService.EXTRA_GATT_SERVICES_JSON,
1290
+ servicesJson
1291
+ )
1292
+ }
1293
+ putExtra(
1294
+ MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_CHANNEL_ID,
1295
+ options.androidNotificationChannelId
1296
+ ?: MunimBluetoothBackgroundService.DEFAULT_CHANNEL_ID
1297
+ )
1298
+ putExtra(
1299
+ MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_CHANNEL_NAME,
1300
+ options.androidNotificationChannelName
1301
+ ?: MunimBluetoothBackgroundService.DEFAULT_CHANNEL_NAME
1302
+ )
1303
+ putExtra(
1304
+ MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_TITLE,
1305
+ options.androidNotificationTitle
1306
+ ?: MunimBluetoothBackgroundService.DEFAULT_NOTIFICATION_TITLE
1307
+ )
1308
+ putExtra(
1309
+ MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_TEXT,
1310
+ options.androidNotificationText
1311
+ ?: MunimBluetoothBackgroundService.DEFAULT_NOTIFICATION_TEXT
1312
+ )
1313
+ }
1314
+
1315
+ try {
1316
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1317
+ context.startForegroundService(intent)
1318
+ } else {
1319
+ context.startService(intent)
1320
+ }
1321
+ eventEmitter.emit(
1322
+ "backgroundSessionStarted",
1323
+ mapOf(
1324
+ "platform" to "android",
1325
+ "serviceUUIDs" to options.serviceUUIDs.toList(),
1326
+ "localName" to options.localName
1327
+ )
1328
+ )
1329
+ } catch (error: RuntimeException) {
1330
+ Log.w(TAG, "Unable to start background BLE session", error)
1331
+ eventEmitter.emit(
1332
+ "backgroundSessionStartFailed",
1333
+ mapOf(
1334
+ "platform" to "android",
1335
+ "error" to (error.message ?: "Unable to start background BLE session")
1336
+ )
1337
+ )
1338
+ }
1339
+ }
1340
+
1341
+ override fun stopBackgroundSession() {
1342
+ val context = NitroModules.applicationContext ?: return
1343
+ val intent = Intent(context, MunimBluetoothBackgroundService::class.java).apply {
1344
+ action = MunimBluetoothBackgroundService.ACTION_STOP
1345
+ }
1346
+ context.startService(intent)
1347
+ eventEmitter.emit("backgroundSessionStopped", mapOf("platform" to "android"))
1348
+ }
1349
+
1350
+ override fun startMultipeerSession(options: MultipeerSessionOptions) {
1351
+ eventEmitter.emit(
1352
+ "multipeerStartFailed",
1353
+ mapOf(
1354
+ "platform" to "android",
1355
+ "error" to MULTIPEER_UNSUPPORTED_MESSAGE
1356
+ )
1357
+ )
1358
+ throw UnsupportedOperationException(MULTIPEER_UNSUPPORTED_MESSAGE)
1359
+ }
1360
+
1361
+ override fun stopMultipeerSession() {
1362
+ eventEmitter.emit("multipeerStopped", mapOf("platform" to "android"))
1363
+ }
1364
+
1365
+ override fun inviteMultipeerPeer(peerId: String) {
1366
+ throw UnsupportedOperationException(MULTIPEER_UNSUPPORTED_MESSAGE)
1367
+ }
1368
+
1369
+ override fun getMultipeerPeers(): Promise<Array<MultipeerPeer>> {
1370
+ return Promise.resolved(emptyArray<MultipeerPeer>())
1371
+ }
1372
+
1373
+ override fun sendMultipeerMessage(
1374
+ value: String,
1375
+ peerIds: Array<String>?,
1376
+ reliable: Boolean?
1377
+ ): Promise<Unit> {
1378
+ return unsupportedPromise(MULTIPEER_UNSUPPORTED_MESSAGE)
1379
+ }
1380
+
1381
+ override fun addListener(eventName: String) {
1382
+ // Nitro uses JS-side listener registration. No native bookkeeping required here.
1383
+ }
1384
+
1385
+ override fun removeListeners(count: Double) {
1386
+ // Nitro uses JS-side listener registration. No native bookkeeping required here.
1387
+ }
1388
+
1389
+ private fun restartAdvertising(delayMs: Long) {
1390
+ if (!ensureBluetoothPermissions("restart advertising")) {
1391
+ return
1392
+ }
1393
+
1394
+ ensureBluetoothManager()
1395
+ val adapter = bluetoothAdapter
1396
+ if (adapter == null || !adapter.isEnabled) {
1397
+ Log.e(TAG, "Bluetooth is not enabled or not available")
1398
+ return
1399
+ }
1400
+
1401
+ advertiseJob?.cancel()
1402
+ advertiseCallback?.let { callback ->
1403
+ advertiser?.stopAdvertising(callback)
1404
+ }
1405
+
1406
+ advertiseJob = bluetoothScope.launch {
1407
+ if (delayMs > 0) {
1408
+ delay(delayMs)
1409
+ }
1410
+
1411
+ advertiser = adapter.bluetoothLeAdvertiser
1412
+ val activeAdvertiser = advertiser
1413
+ if (activeAdvertiser == null) {
1414
+ Log.e(TAG, "Bluetooth LE advertiser is not available")
1415
+ return@launch
1416
+ }
1417
+
1418
+ val dataBuilder = AdvertiseData.Builder()
1419
+ currentServiceUUIDs.forEach { uuid ->
1420
+ dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid))
1421
+ }
1422
+
1423
+ val scanResponseBuilder = AdvertiseData.Builder()
1424
+ currentAdvertisingData?.let {
1425
+ processAdvertisingData(
1426
+ data = it,
1427
+ dataBuilder = scanResponseBuilder,
1428
+ includeServiceUuids = false
1429
+ )
1430
+ }
1431
+
1432
+ val settings = AdvertiseSettings.Builder()
1433
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
1434
+ .setConnectable(true)
1435
+ .setTimeout(0)
1436
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
1437
+ .build()
1438
+
1439
+ advertiseCallback = object : AdvertiseCallback() {
1440
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
1441
+ Log.i(TAG, "Advertising started successfully")
1442
+ eventEmitter.emit("advertisingStarted", emptyMap())
1443
+ }
1444
+
1445
+ override fun onStartFailure(errorCode: Int) {
1446
+ Log.e(TAG, "Advertising failed: $errorCode")
1447
+ eventEmitter.emit(
1448
+ "advertisingStartFailed",
1449
+ mapOf(
1450
+ "errorCode" to errorCode,
1451
+ "message" to advertiseFailureMessage(errorCode)
1452
+ )
1453
+ )
1454
+ }
1455
+ }
1456
+
1457
+ activeAdvertiser.startAdvertising(
1458
+ settings,
1459
+ dataBuilder.build(),
1460
+ scanResponseBuilder.build(),
1461
+ advertiseCallback
1462
+ )
1463
+ }
1464
+ }
1465
+
1466
+ private fun serializeConfiguredServices(): String? {
1467
+ if (configuredServices.isEmpty()) {
1468
+ return null
1469
+ }
1470
+
1471
+ val services = JSONArray()
1472
+ configuredServices.forEach { service ->
1473
+ val serviceJson = JSONObject()
1474
+ .put("uuid", service.uuid)
1475
+ .put("characteristics", JSONArray().also { characteristics ->
1476
+ service.characteristics.forEach { characteristic ->
1477
+ val characteristicJson = JSONObject()
1478
+ .put("uuid", characteristic.uuid)
1479
+ .put("properties", stringArrayJson(characteristic.properties))
1480
+ characteristic.value?.let { characteristicJson.put("value", it) }
1481
+ characteristic.descriptors?.let { descriptors ->
1482
+ characteristicJson.put(
1483
+ "descriptors",
1484
+ JSONArray().also { descriptorArray ->
1485
+ descriptors.forEach { descriptor ->
1486
+ val descriptorJson = JSONObject()
1487
+ .put("uuid", descriptor.uuid)
1488
+ descriptor.value?.let { descriptorJson.put("value", it) }
1489
+ descriptor.permissions?.let { permissions ->
1490
+ descriptorJson.put(
1491
+ "permissions",
1492
+ stringArrayJson(permissions)
1493
+ )
1494
+ }
1495
+ descriptorArray.put(descriptorJson)
1496
+ }
1497
+ }
1498
+ )
1499
+ }
1500
+ characteristics.put(characteristicJson)
1501
+ }
1502
+ })
1503
+ service.includedServices?.let {
1504
+ serviceJson.put("includedServices", stringArrayJson(it))
1505
+ }
1506
+ services.put(serviceJson)
1507
+ }
1508
+
1509
+ return services.toString()
1510
+ }
1511
+
1512
+ private fun stringArrayJson(values: Array<String>): JSONArray {
1513
+ return JSONArray().also { array ->
1514
+ values.forEach(array::put)
1515
+ }
1516
+ }
1517
+
1518
+ private fun buildGattServerCallback(): BluetoothGattServerCallback {
1519
+ return object : BluetoothGattServerCallback() {
1520
+ override fun onCharacteristicReadRequest(
1521
+ device: BluetoothDevice,
1522
+ requestId: Int,
1523
+ offset: Int,
1524
+ characteristic: BluetoothGattCharacteristic
1525
+ ) {
1526
+ if ((characteristic.properties and BluetoothGattCharacteristic.PROPERTY_READ) == 0) {
1527
+ gattServer?.sendResponse(
1528
+ device,
1529
+ requestId,
1530
+ BluetoothGatt.GATT_READ_NOT_PERMITTED,
1531
+ offset,
1532
+ null
1533
+ )
1534
+ return
1535
+ }
1536
+
1537
+ val value = getCharacteristicValue(characteristic) ?: byteArrayOf()
1538
+ if (offset > value.size) {
1539
+ gattServer?.sendResponse(
1540
+ device,
1541
+ requestId,
1542
+ BluetoothGatt.GATT_INVALID_OFFSET,
1543
+ offset,
1544
+ null
1545
+ )
1546
+ return
1547
+ }
1548
+
1549
+ gattServer?.sendResponse(
1550
+ device,
1551
+ requestId,
1552
+ BluetoothGatt.GATT_SUCCESS,
583
1553
  offset,
584
- characteristic.value
1554
+ value.copyOfRange(offset, value.size)
1555
+ )
1556
+ eventEmitter.emit(
1557
+ "peripheralReadRequest",
1558
+ mapOf(
1559
+ "centralId" to device.address,
1560
+ "serviceUUID" to characteristic.service.uuid.toString(),
1561
+ "characteristicUUID" to characteristic.uuid.toString(),
1562
+ "value" to value.toHexString()
1563
+ )
585
1564
  )
586
1565
  }
587
1566
 
@@ -594,7 +1573,173 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
594
1573
  offset: Int,
595
1574
  value: ByteArray?
596
1575
  ) {
597
- characteristic.value = value ?: byteArrayOf()
1576
+ val canWrite = (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0 ||
1577
+ (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0
1578
+ if (!canWrite) {
1579
+ if (responseNeeded) {
1580
+ gattServer?.sendResponse(
1581
+ device,
1582
+ requestId,
1583
+ BluetoothGatt.GATT_WRITE_NOT_PERMITTED,
1584
+ offset,
1585
+ null
1586
+ )
1587
+ }
1588
+ return
1589
+ }
1590
+
1591
+ val incomingValue = value ?: byteArrayOf()
1592
+ val currentValue = getCharacteristicValue(characteristic) ?: byteArrayOf()
1593
+ if (offset > currentValue.size) {
1594
+ if (responseNeeded) {
1595
+ gattServer?.sendResponse(
1596
+ device,
1597
+ requestId,
1598
+ BluetoothGatt.GATT_INVALID_OFFSET,
1599
+ offset,
1600
+ null
1601
+ )
1602
+ }
1603
+ return
1604
+ }
1605
+
1606
+ val nextValue = if (offset == 0) {
1607
+ incomingValue
1608
+ } else {
1609
+ val replaceEnd = minOf(offset + incomingValue.size, currentValue.size)
1610
+ currentValue.copyOfRange(0, offset) +
1611
+ incomingValue +
1612
+ currentValue.copyOfRange(replaceEnd, currentValue.size)
1613
+ }
1614
+ setCharacteristicValue(characteristic, nextValue)
1615
+ if (responseNeeded) {
1616
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
1617
+ }
1618
+ notifySubscribedDevices(characteristic)
1619
+ eventEmitter.emit(
1620
+ "peripheralWriteRequest",
1621
+ mapOf(
1622
+ "centralId" to device.address,
1623
+ "serviceUUID" to characteristic.service.uuid.toString(),
1624
+ "characteristicUUID" to characteristic.uuid.toString(),
1625
+ "value" to nextValue.toHexString()
1626
+ )
1627
+ )
1628
+ }
1629
+
1630
+ override fun onDescriptorReadRequest(
1631
+ device: BluetoothDevice,
1632
+ requestId: Int,
1633
+ offset: Int,
1634
+ descriptor: BluetoothGattDescriptor
1635
+ ) {
1636
+ if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) {
1637
+ if ((descriptor.permissions and BluetoothGattDescriptor.PERMISSION_READ) == 0) {
1638
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, offset, null)
1639
+ return
1640
+ }
1641
+
1642
+ val value = getDescriptorValue(descriptor) ?: byteArrayOf()
1643
+ if (offset > value.size) {
1644
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, offset, null)
1645
+ return
1646
+ }
1647
+
1648
+ gattServer?.sendResponse(
1649
+ device,
1650
+ requestId,
1651
+ BluetoothGatt.GATT_SUCCESS,
1652
+ offset,
1653
+ value.copyOfRange(offset, value.size)
1654
+ )
1655
+ return
1656
+ }
1657
+
1658
+ val characteristic = descriptor.characteristic
1659
+ val subscribers = subscribedDevices[characteristic.uuid]
1660
+ val value = if (subscribers?.contains(device) == true) {
1661
+ if ((characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
1662
+ BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
1663
+ } else {
1664
+ BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
1665
+ }
1666
+ } else {
1667
+ BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
1668
+ }
1669
+
1670
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
1671
+ }
1672
+
1673
+ override fun onDescriptorWriteRequest(
1674
+ device: BluetoothDevice,
1675
+ requestId: Int,
1676
+ descriptor: BluetoothGattDescriptor,
1677
+ preparedWrite: Boolean,
1678
+ responseNeeded: Boolean,
1679
+ offset: Int,
1680
+ value: ByteArray?
1681
+ ) {
1682
+ if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) {
1683
+ if ((descriptor.permissions and BluetoothGattDescriptor.PERMISSION_WRITE) == 0) {
1684
+ if (responseNeeded) {
1685
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_WRITE_NOT_PERMITTED, offset, null)
1686
+ }
1687
+ return
1688
+ }
1689
+
1690
+ val incomingValue = value ?: byteArrayOf()
1691
+ val currentValue = getDescriptorValue(descriptor) ?: byteArrayOf()
1692
+ if (offset > currentValue.size) {
1693
+ if (responseNeeded) {
1694
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, offset, null)
1695
+ }
1696
+ return
1697
+ }
1698
+
1699
+ val nextValue = if (offset == 0) {
1700
+ incomingValue
1701
+ } else {
1702
+ val replaceEnd = minOf(offset + incomingValue.size, currentValue.size)
1703
+ currentValue.copyOfRange(0, offset) +
1704
+ incomingValue +
1705
+ currentValue.copyOfRange(replaceEnd, currentValue.size)
1706
+ }
1707
+ setDescriptorValue(descriptor, nextValue)
1708
+ if (responseNeeded) {
1709
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
1710
+ }
1711
+ return
1712
+ }
1713
+
1714
+ val characteristic = descriptor.characteristic
1715
+ val requestedValue = value ?: BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
1716
+ val enabled = requestedValue.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) ||
1717
+ requestedValue.contentEquals(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE)
1718
+
1719
+ if (enabled) {
1720
+ subscribedDevices.getOrPut(characteristic.uuid) { mutableSetOf() }.add(device)
1721
+ setDescriptorValue(descriptor, requestedValue)
1722
+ eventEmitter.emit(
1723
+ "peripheralSubscribed",
1724
+ mapOf(
1725
+ "centralId" to device.address,
1726
+ "serviceUUID" to characteristic.service.uuid.toString(),
1727
+ "characteristicUUID" to characteristic.uuid.toString()
1728
+ )
1729
+ )
1730
+ } else {
1731
+ subscribedDevices[characteristic.uuid]?.remove(device)
1732
+ setDescriptorValue(descriptor, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
1733
+ eventEmitter.emit(
1734
+ "peripheralUnsubscribed",
1735
+ mapOf(
1736
+ "centralId" to device.address,
1737
+ "serviceUUID" to characteristic.service.uuid.toString(),
1738
+ "characteristicUUID" to characteristic.uuid.toString()
1739
+ )
1740
+ )
1741
+ }
1742
+
598
1743
  if (responseNeeded) {
599
1744
  gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
600
1745
  }
@@ -606,25 +1751,35 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
606
1751
  return object : BluetoothGattCallback() {
607
1752
  override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
608
1753
  if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
1754
+ if (retryPendingConnection(deviceId, gatt, status)) {
1755
+ return
1756
+ }
1757
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
1758
+ pendingConnectionAttempts.remove(deviceId)
609
1759
  pendingConnections.remove(deviceId)?.reject(
610
1760
  IllegalStateException("Failed to connect to $deviceId (status=$status)")
611
1761
  )
612
- connectedDevices.remove(deviceId)?.close()
1762
+ (pendingConnectionGatts.remove(deviceId) ?: connectedDevices.remove(deviceId))?.close()
613
1763
  return
614
1764
  }
615
1765
 
616
1766
  when (newState) {
617
1767
  BluetoothProfile.STATE_CONNECTED -> {
1768
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
1769
+ pendingConnectionAttempts.remove(deviceId)
1770
+ pendingConnectionGatts.remove(deviceId)
618
1771
  connectedDevices[deviceId] = gatt
619
1772
  pendingConnections.remove(deviceId)?.resolve(Unit)
620
1773
  eventEmitter.emit("deviceConnected", mapOf("deviceId" to deviceId))
621
1774
  }
622
1775
 
623
1776
  BluetoothProfile.STATE_DISCONNECTED -> {
1777
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
1778
+ pendingConnectionAttempts.remove(deviceId)
624
1779
  pendingConnections.remove(deviceId)?.reject(
625
1780
  IllegalStateException("Disconnected from $deviceId")
626
1781
  )
627
- connectedDevices.remove(deviceId)?.close()
1782
+ (pendingConnectionGatts.remove(deviceId) ?: connectedDevices.remove(deviceId))?.close()
628
1783
  rejectPendingOperationsForDevice(
629
1784
  deviceId,
630
1785
  IllegalStateException("Disconnected from $deviceId")
@@ -635,23 +1790,13 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
635
1790
  }
636
1791
 
637
1792
  override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
1793
+ cancelPendingOperationTimeout("services|$deviceId")
638
1794
  if (status == BluetoothGatt.GATT_SUCCESS) {
639
1795
  val services = buildGattServices(gatt)
640
1796
  pendingServiceDiscoveries.remove(deviceId)?.resolve(services)
641
1797
  eventEmitter.emit(
642
1798
  "servicesDiscovered",
643
- mapOf("deviceId" to deviceId, "services" to services.map { service ->
644
- mapOf(
645
- "uuid" to service.uuid,
646
- "characteristics" to service.characteristics.map { characteristic ->
647
- mapOf(
648
- "uuid" to characteristic.uuid,
649
- "properties" to characteristic.properties.toList(),
650
- "value" to characteristic.value
651
- )
652
- }
653
- )
654
- })
1799
+ mapOf("deviceId" to deviceId, "services" to services.map { servicePayload(it) })
655
1800
  )
656
1801
  } else {
657
1802
  pendingServiceDiscoveries.remove(deviceId)?.reject(
@@ -665,29 +1810,16 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
665
1810
  characteristic: BluetoothGattCharacteristic,
666
1811
  status: Int
667
1812
  ) {
668
- val key = characteristicKey(
669
- deviceId,
670
- characteristic.service.uuid.toString(),
671
- characteristic.uuid.toString()
672
- )
673
- if (status == BluetoothGatt.GATT_SUCCESS) {
674
- val value = buildCharacteristicValue(characteristic)
675
- lastCharacteristicValues[key] = value
676
- pendingReads.remove(key)?.resolve(value)
677
- eventEmitter.emit(
678
- "characteristicValueChanged",
679
- mapOf(
680
- "deviceId" to deviceId,
681
- "serviceUUID" to value.serviceUUID,
682
- "characteristicUUID" to value.characteristicUUID,
683
- "value" to value.value
684
- )
685
- )
686
- } else {
687
- pendingReads.remove(key)?.reject(
688
- IllegalStateException("Failed to read characteristic $key (status=$status)")
689
- )
690
- }
1813
+ handleCharacteristicRead(deviceId, characteristic, getCharacteristicValue(characteristic), status)
1814
+ }
1815
+
1816
+ override fun onCharacteristicRead(
1817
+ gatt: BluetoothGatt,
1818
+ characteristic: BluetoothGattCharacteristic,
1819
+ value: ByteArray,
1820
+ status: Int
1821
+ ) {
1822
+ handleCharacteristicRead(deviceId, characteristic, value, status)
691
1823
  }
692
1824
 
693
1825
  override fun onCharacteristicWrite(
@@ -700,6 +1832,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
700
1832
  characteristic.service.uuid.toString(),
701
1833
  characteristic.uuid.toString()
702
1834
  )
1835
+ cancelPendingOperationTimeout("write|$key")
703
1836
  if (status == BluetoothGatt.GATT_SUCCESS) {
704
1837
  pendingWrites.remove(key)?.resolve(Unit)
705
1838
  } else {
@@ -713,21 +1846,87 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
713
1846
  gatt: BluetoothGatt,
714
1847
  characteristic: BluetoothGattCharacteristic
715
1848
  ) {
716
- val value = buildCharacteristicValue(characteristic)
717
- val key = characteristicKey(deviceId, value.serviceUUID, value.characteristicUUID)
718
- lastCharacteristicValues[key] = value
719
- eventEmitter.emit(
720
- "characteristicValueChanged",
721
- mapOf(
722
- "deviceId" to deviceId,
723
- "serviceUUID" to value.serviceUUID,
724
- "characteristicUUID" to value.characteristicUUID,
725
- "value" to value.value
726
- )
1849
+ handleCharacteristicChanged(deviceId, characteristic, getCharacteristicValue(characteristic))
1850
+ }
1851
+
1852
+ override fun onCharacteristicChanged(
1853
+ gatt: BluetoothGatt,
1854
+ characteristic: BluetoothGattCharacteristic,
1855
+ value: ByteArray
1856
+ ) {
1857
+ handleCharacteristicChanged(deviceId, characteristic, value)
1858
+ }
1859
+
1860
+ override fun onDescriptorRead(
1861
+ gatt: BluetoothGatt,
1862
+ descriptor: BluetoothGattDescriptor,
1863
+ status: Int
1864
+ ) {
1865
+ handleDescriptorRead(deviceId, descriptor, getDescriptorValue(descriptor), status)
1866
+ }
1867
+
1868
+ override fun onDescriptorRead(
1869
+ gatt: BluetoothGatt,
1870
+ descriptor: BluetoothGattDescriptor,
1871
+ status: Int,
1872
+ value: ByteArray
1873
+ ) {
1874
+ handleDescriptorRead(deviceId, descriptor, value, status)
1875
+ }
1876
+
1877
+ override fun onDescriptorWrite(
1878
+ gatt: BluetoothGatt,
1879
+ descriptor: BluetoothGattDescriptor,
1880
+ status: Int
1881
+ ) {
1882
+ val characteristic = descriptor.characteristic
1883
+ val key = descriptorKey(
1884
+ deviceId,
1885
+ characteristic.service.uuid.toString(),
1886
+ characteristic.uuid.toString(),
1887
+ descriptor.uuid.toString()
727
1888
  )
1889
+ cancelPendingOperationTimeout("descriptorWrite|$key")
1890
+ if (status == BluetoothGatt.GATT_SUCCESS) {
1891
+ pendingDescriptorWrites.remove(key)?.resolve(Unit)
1892
+ } else {
1893
+ pendingDescriptorWrites.remove(key)?.reject(
1894
+ IllegalStateException("Failed to write descriptor $key (status=$status)")
1895
+ )
1896
+ }
1897
+ }
1898
+
1899
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
1900
+ cancelPendingOperationTimeout("mtu|$deviceId")
1901
+ if (status == BluetoothGatt.GATT_SUCCESS) {
1902
+ pendingMtuRequests.remove(deviceId)?.resolve(mtu.toDouble())
1903
+ } else {
1904
+ pendingMtuRequests.remove(deviceId)?.reject(
1905
+ IllegalStateException("Failed to request MTU for $deviceId (status=$status)")
1906
+ )
1907
+ }
1908
+ }
1909
+
1910
+ override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
1911
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
1912
+
1913
+ cancelPendingOperationTimeout("phy|$deviceId")
1914
+ if (status == BluetoothGatt.GATT_SUCCESS) {
1915
+ pendingPhyReads.remove(deviceId)?.resolve(
1916
+ PhyStatus(
1917
+ txPhy = constantToPhy(txPhy),
1918
+ rxPhy = constantToPhy(rxPhy)
1919
+ )
1920
+ )
1921
+ } else {
1922
+ pendingPhyReads.remove(deviceId)?.reject(
1923
+ IllegalStateException("Failed to read PHY for $deviceId (status=$status)")
1924
+ )
1925
+ }
728
1926
  }
729
1927
 
730
1928
  override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
1929
+ cancelPendingOperationTimeout("rssi|$deviceId")
731
1930
  if (status == BluetoothGatt.GATT_SUCCESS) {
732
1931
  val rssiValue = rssi.toDouble()
733
1932
  lastRssiValues[deviceId] = rssiValue
@@ -745,15 +1944,213 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
745
1944
  }
746
1945
  }
747
1946
 
1947
+ private fun handleCharacteristicRead(
1948
+ deviceId: String,
1949
+ characteristic: BluetoothGattCharacteristic,
1950
+ valueBytes: ByteArray?,
1951
+ status: Int
1952
+ ) {
1953
+ val key = characteristicKey(
1954
+ deviceId,
1955
+ characteristic.service.uuid.toString(),
1956
+ characteristic.uuid.toString()
1957
+ )
1958
+ cancelPendingOperationTimeout("read|$key")
1959
+ if (status == BluetoothGatt.GATT_SUCCESS) {
1960
+ val value = buildCharacteristicValue(characteristic, valueBytes)
1961
+ lastCharacteristicValues[key] = value
1962
+ pendingReads.remove(key)?.resolve(value)
1963
+ emitCharacteristicValueChanged(deviceId, value)
1964
+ } else {
1965
+ pendingReads.remove(key)?.reject(
1966
+ IllegalStateException("Failed to read characteristic $key (status=$status)")
1967
+ )
1968
+ }
1969
+ }
1970
+
1971
+ private fun handleCharacteristicChanged(
1972
+ deviceId: String,
1973
+ characteristic: BluetoothGattCharacteristic,
1974
+ valueBytes: ByteArray?
1975
+ ) {
1976
+ val value = buildCharacteristicValue(characteristic, valueBytes)
1977
+ val key = characteristicKey(deviceId, value.serviceUUID, value.characteristicUUID)
1978
+ lastCharacteristicValues[key] = value
1979
+ emitCharacteristicValueChanged(deviceId, value)
1980
+ }
1981
+
1982
+ private fun handleDescriptorRead(
1983
+ deviceId: String,
1984
+ descriptor: BluetoothGattDescriptor,
1985
+ valueBytes: ByteArray?,
1986
+ status: Int
1987
+ ) {
1988
+ val characteristic = descriptor.characteristic
1989
+ val key = descriptorKey(
1990
+ deviceId,
1991
+ characteristic.service.uuid.toString(),
1992
+ characteristic.uuid.toString(),
1993
+ descriptor.uuid.toString()
1994
+ )
1995
+ cancelPendingOperationTimeout("descriptorRead|$key")
1996
+ if (status == BluetoothGatt.GATT_SUCCESS) {
1997
+ pendingDescriptorReads.remove(key)?.resolve(buildDescriptorValue(descriptor, valueBytes))
1998
+ } else {
1999
+ pendingDescriptorReads.remove(key)?.reject(
2000
+ IllegalStateException("Failed to read descriptor $key (status=$status)")
2001
+ )
2002
+ }
2003
+ }
2004
+
2005
+ private fun emitCharacteristicValueChanged(deviceId: String, value: CharacteristicValue) {
2006
+ eventEmitter.emit(
2007
+ "characteristicValueChanged",
2008
+ mapOf(
2009
+ "deviceId" to deviceId,
2010
+ "serviceUUID" to value.serviceUUID,
2011
+ "characteristicUUID" to value.characteristicUUID,
2012
+ "value" to value.value
2013
+ )
2014
+ )
2015
+ }
2016
+
748
2017
  private fun rejectPendingOperationsForDevice(deviceId: String, error: Throwable) {
2018
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
2019
+ pendingConnectionAttempts.remove(deviceId)
2020
+ cancelPendingOperationTimeoutsForDevice(deviceId)
749
2021
  pendingReads.keys
750
2022
  .filter { it.startsWith("$deviceId|") }
751
2023
  .forEach { key -> pendingReads.remove(key)?.reject(error) }
752
2024
  pendingWrites.keys
753
2025
  .filter { it.startsWith("$deviceId|") }
754
2026
  .forEach { key -> pendingWrites.remove(key)?.reject(error) }
2027
+ pendingDescriptorReads.keys
2028
+ .filter { it.startsWith("$deviceId|") }
2029
+ .forEach { key -> pendingDescriptorReads.remove(key)?.reject(error) }
2030
+ pendingDescriptorWrites.keys
2031
+ .filter { it.startsWith("$deviceId|") }
2032
+ .forEach { key -> pendingDescriptorWrites.remove(key)?.reject(error) }
755
2033
  pendingServiceDiscoveries.remove(deviceId)?.reject(error)
756
2034
  pendingRssiReads.remove(deviceId)?.reject(error)
2035
+ pendingMtuRequests.remove(deviceId)?.reject(error)
2036
+ pendingPhyReads.remove(deviceId)?.reject(error)
2037
+ }
2038
+
2039
+ private fun scheduleConnectionTimeout(deviceId: String) {
2040
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
2041
+ pendingConnectionTimeouts[deviceId] = bluetoothScope.launch {
2042
+ delay(CONNECTION_TIMEOUT_MS)
2043
+ pendingConnectionTimeouts.remove(deviceId)
2044
+ pendingConnectionAttempts.remove(deviceId)
2045
+ val promise = pendingConnections.remove(deviceId) ?: return@launch
2046
+ (pendingConnectionGatts.remove(deviceId) ?: connectedDevices.remove(deviceId))?.let { gatt ->
2047
+ gatt.disconnect()
2048
+ gatt.close()
2049
+ }
2050
+ promise.reject(IllegalStateException("Connection timed out for $deviceId"))
2051
+ }
2052
+ }
2053
+
2054
+ private fun startGattConnection(deviceId: String, device: BluetoothDevice) {
2055
+ val context = NitroModules.applicationContext
2056
+ if (context == null) {
2057
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
2058
+ pendingConnectionAttempts.remove(deviceId)
2059
+ pendingConnections.remove(deviceId)?.reject(IllegalStateException("React context unavailable"))
2060
+ return
2061
+ }
2062
+
2063
+ val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
2064
+ device.connectGatt(context, false, createGattCallback(deviceId), BluetoothDevice.TRANSPORT_LE)
2065
+ } else {
2066
+ device.connectGatt(context, false, createGattCallback(deviceId))
2067
+ }
2068
+ pendingConnectionGatts[deviceId] = gatt
2069
+ }
2070
+
2071
+ private fun retryPendingConnection(deviceId: String, failedGatt: BluetoothGatt, status: Int): Boolean {
2072
+ val promise = pendingConnections[deviceId] ?: return false
2073
+ val attempt = pendingConnectionAttempts[deviceId] ?: 0
2074
+ if (attempt >= MAX_CONNECTION_RETRIES) {
2075
+ return false
2076
+ }
2077
+
2078
+ pendingConnectionAttempts[deviceId] = attempt + 1
2079
+ pendingConnectionGatts.remove(deviceId)
2080
+ failedGatt.close()
2081
+
2082
+ Log.w(
2083
+ TAG,
2084
+ "BLE connection attempt ${attempt + 1} for $deviceId failed with status=$status; retrying"
2085
+ )
2086
+
2087
+ bluetoothScope.launch {
2088
+ delay(CONNECTION_RETRY_DELAY_MS * (attempt + 1))
2089
+ if (pendingConnections[deviceId] === promise) {
2090
+ startGattConnection(deviceId, failedGatt.device)
2091
+ }
2092
+ }
2093
+ return true
2094
+ }
2095
+
2096
+ private fun <T> schedulePendingOperationTimeout(
2097
+ timeoutKey: String,
2098
+ description: String,
2099
+ removePending: () -> Promise<T>?
2100
+ ) {
2101
+ pendingOperationTimeouts.remove(timeoutKey)?.cancel()
2102
+ pendingOperationTimeouts[timeoutKey] = bluetoothScope.launch {
2103
+ delay(OPERATION_TIMEOUT_MS)
2104
+ pendingOperationTimeouts.remove(timeoutKey)
2105
+ removePending()?.reject(IllegalStateException("$description timed out"))
2106
+ }
2107
+ }
2108
+
2109
+ private fun cancelPendingOperationTimeout(timeoutKey: String) {
2110
+ pendingOperationTimeouts.remove(timeoutKey)?.cancel()
2111
+ }
2112
+
2113
+ private fun cancelPendingOperationTimeoutsForDevice(deviceId: String) {
2114
+ pendingOperationTimeouts.keys
2115
+ .filter { key ->
2116
+ key == "services|$deviceId" ||
2117
+ key == "rssi|$deviceId" ||
2118
+ key == "mtu|$deviceId" ||
2119
+ key == "phy|$deviceId" ||
2120
+ key.contains("|$deviceId|")
2121
+ }
2122
+ .forEach { key -> pendingOperationTimeouts.remove(key)?.cancel() }
2123
+ }
2124
+
2125
+ private fun notifySubscribedDevices(characteristic: BluetoothGattCharacteristic) {
2126
+ if (!supportsNotifyOrIndicate(characteristic)) return
2127
+
2128
+ val subscribers = subscribedDevices[characteristic.uuid]?.toList().orEmpty()
2129
+ if (subscribers.isEmpty()) return
2130
+
2131
+ subscribers.forEach { device ->
2132
+ val confirm = (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0
2133
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
2134
+ gattServer?.notifyCharacteristicChanged(
2135
+ device,
2136
+ characteristic,
2137
+ confirm,
2138
+ getCharacteristicValue(characteristic) ?: byteArrayOf()
2139
+ )
2140
+ } else {
2141
+ @Suppress("DEPRECATION")
2142
+ gattServer?.notifyCharacteristicChanged(
2143
+ device,
2144
+ characteristic,
2145
+ confirm
2146
+ )
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ private fun supportsNotifyOrIndicate(characteristic: BluetoothGattCharacteristic): Boolean {
2152
+ return (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
2153
+ (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0
757
2154
  }
758
2155
 
759
2156
  private fun buildGattServices(gatt: BluetoothGatt): Array<GATTService> {
@@ -764,21 +2161,66 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
764
2161
  GATTCharacteristic(
765
2162
  uuid = characteristic.uuid.toString(),
766
2163
  properties = propertiesToArray(characteristic.properties),
767
- value = characteristic.value?.toHexString()
2164
+ value = getCharacteristicValue(characteristic)?.toHexString(),
2165
+ descriptors = characteristic.descriptors.map { descriptor ->
2166
+ GATTDescriptor(
2167
+ uuid = descriptor.uuid.toString(),
2168
+ value = getDescriptorValue(descriptor)?.toHexString(),
2169
+ permissions = permissionsToArray(descriptor.permissions)
2170
+ )
2171
+ }.toTypedArray()
768
2172
  )
769
- }.toTypedArray()
2173
+ }.toTypedArray(),
2174
+ includedServices = service.includedServices.map { it.uuid.toString() }.toTypedArray()
770
2175
  )
771
2176
  }.toTypedArray()
772
2177
  }
773
2178
 
774
- private fun buildCharacteristicValue(characteristic: BluetoothGattCharacteristic): CharacteristicValue {
2179
+ private fun servicePayload(service: GATTService): Map<String, Any?> {
2180
+ return mapOf(
2181
+ "uuid" to service.uuid,
2182
+ "characteristics" to service.characteristics.map { characteristic ->
2183
+ mapOf(
2184
+ "uuid" to characteristic.uuid,
2185
+ "properties" to characteristic.properties.toList(),
2186
+ "value" to characteristic.value,
2187
+ "descriptors" to characteristic.descriptors?.map { descriptor ->
2188
+ mapOf(
2189
+ "uuid" to descriptor.uuid,
2190
+ "value" to descriptor.value,
2191
+ "permissions" to descriptor.permissions?.toList()
2192
+ )
2193
+ }
2194
+ )
2195
+ },
2196
+ "includedServices" to service.includedServices?.toList()
2197
+ )
2198
+ }
2199
+
2200
+ private fun buildCharacteristicValue(
2201
+ characteristic: BluetoothGattCharacteristic,
2202
+ valueBytes: ByteArray? = getCharacteristicValue(characteristic)
2203
+ ): CharacteristicValue {
775
2204
  return CharacteristicValue(
776
- value = characteristic.value?.toHexString() ?: "",
2205
+ value = valueBytes?.toHexString() ?: "",
777
2206
  serviceUUID = characteristic.service.uuid.toString(),
778
2207
  characteristicUUID = characteristic.uuid.toString()
779
2208
  )
780
2209
  }
781
2210
 
2211
+ private fun buildDescriptorValue(
2212
+ descriptor: BluetoothGattDescriptor,
2213
+ valueBytes: ByteArray? = getDescriptorValue(descriptor)
2214
+ ): DescriptorValue {
2215
+ val characteristic = descriptor.characteristic
2216
+ return DescriptorValue(
2217
+ value = valueBytes?.toHexString() ?: "",
2218
+ serviceUUID = characteristic.service.uuid.toString(),
2219
+ characteristicUUID = characteristic.uuid.toString(),
2220
+ descriptorUUID = descriptor.uuid.toString()
2221
+ )
2222
+ }
2223
+
782
2224
  private fun findCharacteristic(
783
2225
  gatt: BluetoothGatt,
784
2226
  serviceUUID: String,
@@ -791,6 +2233,68 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
791
2233
  }
792
2234
  }
793
2235
 
2236
+ private fun findDescriptor(
2237
+ gatt: BluetoothGatt,
2238
+ serviceUUID: String,
2239
+ characteristicUUID: String,
2240
+ descriptorUUID: String
2241
+ ): BluetoothGattDescriptor? {
2242
+ val characteristic = findCharacteristic(gatt, serviceUUID, characteristicUUID) ?: return null
2243
+ return characteristic.descriptors.firstOrNull {
2244
+ it.uuid.toString().equals(descriptorUUID, ignoreCase = true)
2245
+ }
2246
+ }
2247
+
2248
+ @Suppress("DEPRECATION")
2249
+ private fun getCharacteristicValue(characteristic: BluetoothGattCharacteristic): ByteArray? {
2250
+ return characteristic.value
2251
+ }
2252
+
2253
+ @Suppress("DEPRECATION")
2254
+ private fun setCharacteristicValue(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
2255
+ characteristic.value = value
2256
+ }
2257
+
2258
+ @Suppress("DEPRECATION")
2259
+ private fun getDescriptorValue(descriptor: BluetoothGattDescriptor): ByteArray? {
2260
+ return descriptor.value
2261
+ }
2262
+
2263
+ @Suppress("DEPRECATION")
2264
+ private fun setDescriptorValue(descriptor: BluetoothGattDescriptor, value: ByteArray) {
2265
+ descriptor.value = value
2266
+ }
2267
+
2268
+ @Suppress("DEPRECATION")
2269
+ private fun writeGattCharacteristic(
2270
+ gatt: BluetoothGatt,
2271
+ characteristic: BluetoothGattCharacteristic,
2272
+ value: ByteArray,
2273
+ writeType: Int
2274
+ ): Boolean {
2275
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
2276
+ gatt.writeCharacteristic(characteristic, value, writeType) == BluetoothStatusCodes.SUCCESS
2277
+ } else {
2278
+ characteristic.value = value
2279
+ characteristic.writeType = writeType
2280
+ gatt.writeCharacteristic(characteristic)
2281
+ }
2282
+ }
2283
+
2284
+ @Suppress("DEPRECATION")
2285
+ private fun writeGattDescriptor(
2286
+ gatt: BluetoothGatt,
2287
+ descriptor: BluetoothGattDescriptor,
2288
+ value: ByteArray
2289
+ ): Boolean {
2290
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
2291
+ gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS
2292
+ } else {
2293
+ descriptor.value = value
2294
+ gatt.writeDescriptor(descriptor)
2295
+ }
2296
+ }
2297
+
794
2298
  private fun characteristicKey(
795
2299
  deviceId: String,
796
2300
  serviceUUID: String,
@@ -799,6 +2303,38 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
799
2303
  return "$deviceId|${serviceUUID.lowercase()}|${characteristicUUID.lowercase()}"
800
2304
  }
801
2305
 
2306
+ private fun descriptorKey(
2307
+ deviceId: String,
2308
+ serviceUUID: String,
2309
+ characteristicUUID: String,
2310
+ descriptorUUID: String
2311
+ ): String {
2312
+ return "${characteristicKey(deviceId, serviceUUID, characteristicUUID)}|${descriptorUUID.lowercase()}"
2313
+ }
2314
+
2315
+ private fun resolveBluetoothDevice(deviceId: String): BluetoothDevice? {
2316
+ ensureBluetoothManager()
2317
+ return discoveredDevices[deviceId]
2318
+ ?: connectedDevices[deviceId]?.device
2319
+ ?: try {
2320
+ bluetoothAdapter?.getRemoteDevice(deviceId)
2321
+ } catch (_: IllegalArgumentException) {
2322
+ null
2323
+ }
2324
+ }
2325
+
2326
+ private fun findLocalCharacteristic(
2327
+ serviceUUID: String,
2328
+ characteristicUUID: String
2329
+ ): BluetoothGattCharacteristic? {
2330
+ return try {
2331
+ val service = gattServer?.getService(UUID.fromString(serviceUUID))
2332
+ service?.getCharacteristic(UUID.fromString(characteristicUUID))
2333
+ } catch (_: IllegalArgumentException) {
2334
+ null
2335
+ }
2336
+ }
2337
+
802
2338
  private fun buildScanPayload(result: ScanResult): Map<String, Any?> {
803
2339
  val record = result.scanRecord
804
2340
  val manufacturerData = extractManufacturerData(record)
@@ -810,14 +2346,10 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
810
2346
  record?.deviceName?.let { advertisingData["completeLocalName"] = it }
811
2347
  txPower?.let { advertisingData["txPowerLevel"] = it }
812
2348
  manufacturerData?.let { advertisingData["manufacturerData"] = it }
813
- serviceUUIDs?.let { advertisingData["serviceUUIDs"] = it }
2349
+ serviceUUIDs?.let { addServiceUuidBuckets(it, advertisingData) }
814
2350
  serviceData?.takeIf { it.isNotEmpty() }?.let { entries ->
815
- advertisingData["serviceData"] = entries.map { mapOf("uuid" to it.uuid, "data" to it.data) }
816
- }
817
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
818
- advertisingData["isConnectable"] = result.isConnectable
2351
+ addServiceDataBuckets(entries, advertisingData)
819
2352
  }
820
- advertisingData["rssi"] = result.rssi
821
2353
 
822
2354
  return mapOf(
823
2355
  "id" to result.device.address,
@@ -833,6 +2365,65 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
833
2365
  )
834
2366
  }
835
2367
 
2368
+ private fun addServiceUuidBuckets(
2369
+ uuidStrings: List<String>,
2370
+ target: MutableMap<String, Any?>
2371
+ ) {
2372
+ addUuidBuckets(
2373
+ uuidStrings,
2374
+ key16 = "completeServiceUUIDs16",
2375
+ key32 = "completeServiceUUIDs32",
2376
+ key128 = "completeServiceUUIDs128",
2377
+ target = target
2378
+ )
2379
+ }
2380
+
2381
+ private fun addUuidBuckets(
2382
+ uuidStrings: List<String>,
2383
+ key16: String,
2384
+ key32: String,
2385
+ key128: String,
2386
+ target: MutableMap<String, Any?>
2387
+ ) {
2388
+ val uuid16 = uuidStrings.filter { uuidBitWidth(it) == 16 }
2389
+ val uuid32 = uuidStrings.filter { uuidBitWidth(it) == 32 }
2390
+ val uuid128 = uuidStrings.filter { uuidBitWidth(it) == 128 }
2391
+
2392
+ if (uuid16.isNotEmpty()) target[key16] = uuid16
2393
+ if (uuid32.isNotEmpty()) target[key32] = uuid32
2394
+ if (uuid128.isNotEmpty()) target[key128] = uuid128
2395
+ }
2396
+
2397
+ private fun addServiceDataBuckets(
2398
+ entries: List<ServiceDataEntry>,
2399
+ target: MutableMap<String, Any?>
2400
+ ) {
2401
+ val serviceData16 = entries.filter { uuidBitWidth(it.uuid) == 16 }
2402
+ .map { mapOf("uuid" to it.uuid, "data" to it.data) }
2403
+ val serviceData32 = entries.filter { uuidBitWidth(it.uuid) == 32 }
2404
+ .map { mapOf("uuid" to it.uuid, "data" to it.data) }
2405
+ val serviceData128 = entries.filter { uuidBitWidth(it.uuid) == 128 }
2406
+ .map { mapOf("uuid" to it.uuid, "data" to it.data) }
2407
+
2408
+ if (serviceData16.isNotEmpty()) target["serviceData16"] = serviceData16
2409
+ if (serviceData32.isNotEmpty()) target["serviceData32"] = serviceData32
2410
+ if (serviceData128.isNotEmpty()) target["serviceData128"] = serviceData128
2411
+ }
2412
+
2413
+ private fun uuidBitWidth(uuidString: String): Int {
2414
+ return when (uuidString.replace("-", "").length) {
2415
+ 4 -> 16
2416
+ 8 -> 32
2417
+ else -> 128
2418
+ }
2419
+ }
2420
+
2421
+ private fun emitDeviceFound(payload: Map<String, Any?>) {
2422
+ eventEmitter.emit("deviceFound", payload)
2423
+ eventEmitter.emit("onDeviceFound", payload)
2424
+ eventEmitter.emit("scanResult", payload)
2425
+ }
2426
+
836
2427
  private fun extractManufacturerData(record: ScanRecord?): String? {
837
2428
  val data = record?.manufacturerSpecificData ?: return null
838
2429
  if (data.size() == 0) return null
@@ -849,14 +2440,17 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
849
2440
 
850
2441
  private fun processAdvertisingData(
851
2442
  data: AdvertisingDataTypes,
852
- dataBuilder: AdvertiseData.Builder
2443
+ dataBuilder: AdvertiseData.Builder,
2444
+ includeServiceUuids: Boolean = true
853
2445
  ) {
854
- addServiceUUIDs(data.incompleteServiceUUIDs16, dataBuilder)
855
- addServiceUUIDs(data.completeServiceUUIDs16, dataBuilder)
856
- addServiceUUIDs(data.incompleteServiceUUIDs32, dataBuilder)
857
- addServiceUUIDs(data.completeServiceUUIDs32, dataBuilder)
858
- addServiceUUIDs(data.incompleteServiceUUIDs128, dataBuilder)
859
- addServiceUUIDs(data.completeServiceUUIDs128, dataBuilder)
2446
+ if (includeServiceUuids) {
2447
+ addServiceUUIDs(data.incompleteServiceUUIDs16, dataBuilder)
2448
+ addServiceUUIDs(data.completeServiceUUIDs16, dataBuilder)
2449
+ addServiceUUIDs(data.incompleteServiceUUIDs32, dataBuilder)
2450
+ addServiceUUIDs(data.completeServiceUUIDs32, dataBuilder)
2451
+ addServiceUUIDs(data.incompleteServiceUUIDs128, dataBuilder)
2452
+ addServiceUUIDs(data.completeServiceUUIDs128, dataBuilder)
2453
+ }
860
2454
 
861
2455
  if (data.shortenedLocalName != null || data.completeLocalName != null) {
862
2456
  dataBuilder.setIncludeDeviceName(true)
@@ -865,9 +2459,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
865
2459
  dataBuilder.setIncludeTxPowerLevel(true)
866
2460
  }
867
2461
 
868
- addServiceUUIDs(data.serviceSolicitationUUIDs16, dataBuilder)
869
- addServiceUUIDs(data.serviceSolicitationUUIDs32, dataBuilder)
870
- addServiceUUIDs(data.serviceSolicitationUUIDs128, dataBuilder)
2462
+ if (includeServiceUuids) {
2463
+ addServiceUUIDs(data.serviceSolicitationUUIDs16, dataBuilder)
2464
+ addServiceUUIDs(data.serviceSolicitationUUIDs32, dataBuilder)
2465
+ addServiceUUIDs(data.serviceSolicitationUUIDs128, dataBuilder)
2466
+ }
871
2467
  addServiceData(data.serviceData16, dataBuilder)
872
2468
  addServiceData(data.serviceData32, dataBuilder)
873
2469
  addServiceData(data.serviceData128, dataBuilder)
@@ -953,6 +2549,273 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
953
2549
  return result.toTypedArray()
954
2550
  }
955
2551
 
2552
+ private fun descriptorPermissionsFromArray(permissions: Array<String>?): Int {
2553
+ if (permissions.isNullOrEmpty()) {
2554
+ return BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
2555
+ }
2556
+
2557
+ var result = 0
2558
+ permissions.forEach { permission ->
2559
+ when (permission) {
2560
+ "read" -> result = result or BluetoothGattDescriptor.PERMISSION_READ
2561
+ "write" -> result = result or BluetoothGattDescriptor.PERMISSION_WRITE
2562
+ "readEncrypted" -> result = result or BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED
2563
+ "writeEncrypted" -> result = result or BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED
2564
+ "readEncryptedMitm" -> {
2565
+ result = result or BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED_MITM
2566
+ }
2567
+ "writeEncryptedMitm" -> {
2568
+ result = result or BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED_MITM
2569
+ }
2570
+ }
2571
+ }
2572
+ return result
2573
+ }
2574
+
2575
+ private fun permissionsToArray(permissions: Int): Array<String> {
2576
+ val result = mutableListOf<String>()
2577
+ if (permissions and BluetoothGattDescriptor.PERMISSION_READ != 0) result += "read"
2578
+ if (permissions and BluetoothGattDescriptor.PERMISSION_WRITE != 0) result += "write"
2579
+ if (permissions and BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED != 0) result += "readEncrypted"
2580
+ if (permissions and BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED != 0) result += "writeEncrypted"
2581
+ if (permissions and BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED_MITM != 0) {
2582
+ result += "readEncryptedMitm"
2583
+ }
2584
+ if (permissions and BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED_MITM != 0) {
2585
+ result += "writeEncryptedMitm"
2586
+ }
2587
+ return result.toTypedArray()
2588
+ }
2589
+
2590
+ private fun bondStateFor(device: BluetoothDevice): BondState {
2591
+ return when (device.bondState) {
2592
+ BluetoothDevice.BOND_BONDING -> BondState.BONDING
2593
+ BluetoothDevice.BOND_BONDED -> BondState.BONDED
2594
+ else -> BondState.NONE
2595
+ }
2596
+ }
2597
+
2598
+ private fun phyToMask(phy: BluetoothPhy): Int {
2599
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 0
2600
+ return when (phy) {
2601
+ BluetoothPhy.LE2M -> BluetoothDevice.PHY_LE_2M_MASK
2602
+ BluetoothPhy.LECODED -> BluetoothDevice.PHY_LE_CODED_MASK
2603
+ BluetoothPhy.LE1M -> BluetoothDevice.PHY_LE_1M_MASK
2604
+ }
2605
+ }
2606
+
2607
+ private fun phyToAdvertisingPhy(phy: BluetoothPhy): Int {
2608
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 1
2609
+ return when (phy) {
2610
+ BluetoothPhy.LE2M -> BluetoothDevice.PHY_LE_2M
2611
+ BluetoothPhy.LECODED -> BluetoothDevice.PHY_LE_CODED
2612
+ BluetoothPhy.LE1M -> BluetoothDevice.PHY_LE_1M
2613
+ }
2614
+ }
2615
+
2616
+ private fun constantToPhy(phy: Int): BluetoothPhy {
2617
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
2618
+ return when (phy) {
2619
+ BluetoothDevice.PHY_LE_2M -> BluetoothPhy.LE2M
2620
+ BluetoothDevice.PHY_LE_CODED -> BluetoothPhy.LECODED
2621
+ else -> BluetoothPhy.LE1M
2622
+ }
2623
+ }
2624
+ return BluetoothPhy.LE1M
2625
+ }
2626
+
2627
+ private fun phyOptionToConstant(option: BluetoothPhyOption): Int {
2628
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 0
2629
+ return when (option) {
2630
+ BluetoothPhyOption.S2 -> BluetoothDevice.PHY_OPTION_S2
2631
+ BluetoothPhyOption.S8 -> BluetoothDevice.PHY_OPTION_S8
2632
+ BluetoothPhyOption.NONE -> BluetoothDevice.PHY_OPTION_NO_PREFERRED
2633
+ }
2634
+ }
2635
+
2636
+ private fun acceptL2CAPConnections(psm: Int, serverSocket: BluetoothServerSocket) {
2637
+ while (l2capServerSockets[psm] === serverSocket) {
2638
+ try {
2639
+ val socket = serverSocket.accept()
2640
+ val deviceId = try {
2641
+ socket.remoteDevice?.address
2642
+ } catch (_: SecurityException) {
2643
+ null
2644
+ }
2645
+ registerL2CAPSocket(socket, psm, deviceId)
2646
+ } catch (error: IOException) {
2647
+ if (l2capServerSockets[psm] === serverSocket) {
2648
+ Log.w(TAG, "L2CAP accept failed for PSM $psm", error)
2649
+ }
2650
+ break
2651
+ }
2652
+ }
2653
+ }
2654
+
2655
+ private fun registerL2CAPSocket(socket: BluetoothSocket, psm: Int, deviceId: String?): L2CAPChannel {
2656
+ val channelId = UUID.randomUUID().toString()
2657
+ l2capSockets[channelId] = socket
2658
+ val channel = L2CAPChannel(channelId, psm.toDouble(), deviceId)
2659
+ eventEmitter.emit(
2660
+ "l2capChannelOpened",
2661
+ mapOf("channelId" to channelId, "psm" to channel.psm, "deviceId" to deviceId)
2662
+ )
2663
+ startL2CAPReadLoop(channelId, socket, channel)
2664
+ return channel
2665
+ }
2666
+
2667
+ private fun startL2CAPReadLoop(channelId: String, socket: BluetoothSocket, channel: L2CAPChannel) {
2668
+ l2capReadJobs.remove(channelId)?.cancel()
2669
+ l2capReadJobs[channelId] = bluetoothScope.launch(Dispatchers.IO) {
2670
+ val buffer = ByteArray(DEFAULT_STREAM_BUFFER_SIZE)
2671
+ try {
2672
+ while (true) {
2673
+ val count = socket.inputStream.read(buffer)
2674
+ if (count < 0) break
2675
+ if (count > 0) {
2676
+ eventEmitter.emit(
2677
+ "l2capDataReceived",
2678
+ mapOf(
2679
+ "channelId" to channelId,
2680
+ "psm" to channel.psm,
2681
+ "deviceId" to channel.deviceId,
2682
+ "value" to buffer.copyOf(count).toHexString()
2683
+ )
2684
+ )
2685
+ }
2686
+ }
2687
+ } catch (error: IOException) {
2688
+ if (l2capSockets[channelId] === socket) {
2689
+ Log.w(TAG, "L2CAP channel $channelId closed", error)
2690
+ }
2691
+ } finally {
2692
+ if (l2capSockets[channelId] === socket) {
2693
+ closeL2CAPChannelInternal(channelId, true)
2694
+ }
2695
+ }
2696
+ }
2697
+ }
2698
+
2699
+ private fun closeL2CAPChannelInternal(channelId: String, emitEvent: Boolean) {
2700
+ l2capReadJobs.remove(channelId)?.cancel()
2701
+ val socket = l2capSockets.remove(channelId)
2702
+ try {
2703
+ socket?.close()
2704
+ } catch (error: IOException) {
2705
+ Log.w(TAG, "Unable to close L2CAP channel $channelId", error)
2706
+ }
2707
+ if (emitEvent) {
2708
+ eventEmitter.emit("l2capChannelClosed", mapOf("channelId" to channelId))
2709
+ }
2710
+ }
2711
+
2712
+ private fun startClassicReadLoop(deviceId: String, socket: BluetoothSocket) {
2713
+ classicReadJobs.remove(deviceId)?.cancel()
2714
+ classicReadJobs[deviceId] = bluetoothScope.launch(Dispatchers.IO) {
2715
+ val buffer = ByteArray(DEFAULT_STREAM_BUFFER_SIZE)
2716
+ try {
2717
+ while (true) {
2718
+ val count = socket.inputStream.read(buffer)
2719
+ if (count < 0) break
2720
+ if (count > 0) {
2721
+ eventEmitter.emit(
2722
+ "classicDataReceived",
2723
+ mapOf("deviceId" to deviceId, "value" to buffer.copyOf(count).toHexString())
2724
+ )
2725
+ }
2726
+ }
2727
+ } catch (error: IOException) {
2728
+ if (classicSockets[deviceId] === socket) {
2729
+ Log.w(TAG, "Classic Bluetooth socket for $deviceId closed", error)
2730
+ }
2731
+ } finally {
2732
+ if (classicSockets[deviceId] === socket) {
2733
+ closeClassicSocket(deviceId, true)
2734
+ }
2735
+ }
2736
+ }
2737
+ }
2738
+
2739
+ private fun acceptClassicConnections(key: String, serverSocket: BluetoothServerSocket) {
2740
+ while (classicServerSockets[key] === serverSocket) {
2741
+ try {
2742
+ val socket = serverSocket.accept()
2743
+ val device = socket.remoteDevice
2744
+ val deviceId = device.address
2745
+ classicDevices[deviceId] = device
2746
+ closeClassicSocket(deviceId, false)
2747
+ classicSockets[deviceId] = socket
2748
+ startClassicReadLoop(deviceId, socket)
2749
+ eventEmitter.emit("classicConnectionReceived", mapOf("deviceId" to deviceId))
2750
+ eventEmitter.emit("classicConnected", mapOf("deviceId" to deviceId))
2751
+ } catch (error: IOException) {
2752
+ if (classicServerSockets[key] === serverSocket) {
2753
+ Log.w(TAG, "Classic Bluetooth RFCOMM accept failed", error)
2754
+ }
2755
+ break
2756
+ } catch (error: SecurityException) {
2757
+ Log.w(TAG, "Classic Bluetooth RFCOMM accept failed due to permissions", error)
2758
+ break
2759
+ }
2760
+ }
2761
+ }
2762
+
2763
+ private fun closeClassicSocket(deviceId: String, emitEvent: Boolean) {
2764
+ classicReadJobs.remove(deviceId)?.cancel()
2765
+ try {
2766
+ classicSockets.remove(deviceId)?.close()
2767
+ } catch (error: IOException) {
2768
+ Log.w(TAG, "Unable to close Classic Bluetooth socket for $deviceId", error)
2769
+ }
2770
+ if (emitEvent) {
2771
+ eventEmitter.emit("classicDisconnected", mapOf("deviceId" to deviceId))
2772
+ }
2773
+ }
2774
+
2775
+ private fun closeClassicServerInternal(key: String, emitEvent: Boolean) {
2776
+ classicServerJobs.remove(key)?.cancel()
2777
+ try {
2778
+ classicServerSockets.remove(key)?.close()
2779
+ } catch (error: IOException) {
2780
+ Log.w(TAG, "Unable to close Classic Bluetooth server socket for $key", error)
2781
+ }
2782
+ if (emitEvent) {
2783
+ eventEmitter.emit("classicServerStopped", mapOf("serviceUUID" to key))
2784
+ }
2785
+ }
2786
+
2787
+ private fun resolveClassicDevice(deviceId: String): BluetoothDevice? {
2788
+ ensureBluetoothManager()
2789
+ classicDevices[deviceId]?.let { return it }
2790
+ discoveredDevices[deviceId]?.let { return it }
2791
+
2792
+ val adapter = bluetoothAdapter ?: return null
2793
+ try {
2794
+ adapter.bondedDevices?.firstOrNull { it.address == deviceId }?.let { return it }
2795
+ } catch (error: SecurityException) {
2796
+ Log.w(TAG, "Unable to inspect bonded Classic Bluetooth devices", error)
2797
+ }
2798
+
2799
+ return try {
2800
+ adapter.getRemoteDevice(deviceId)
2801
+ } catch (_: IllegalArgumentException) {
2802
+ null
2803
+ }
2804
+ }
2805
+
2806
+ private fun getBluetoothDeviceExtra(intent: Intent): BluetoothDevice? {
2807
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
2808
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
2809
+ } else {
2810
+ @Suppress("DEPRECATION")
2811
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
2812
+ }
2813
+ }
2814
+
2815
+ private fun <T> unsupportedPromise(message: String): Promise<T> {
2816
+ return Promise.rejected(UnsupportedOperationException(message))
2817
+ }
2818
+
956
2819
  private fun addServiceUUIDs(uuids: Array<String>?, dataBuilder: AdvertiseData.Builder) {
957
2820
  uuids?.forEach { uuid ->
958
2821
  dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid))
@@ -970,6 +2833,29 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
970
2833
  }
971
2834
  }
972
2835
 
2836
+ private fun scanFailureMessage(errorCode: Int): String {
2837
+ return when (errorCode) {
2838
+ ScanCallback.SCAN_FAILED_ALREADY_STARTED -> "Scan already started"
2839
+ ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "Application registration failed"
2840
+ ScanCallback.SCAN_FAILED_INTERNAL_ERROR -> "Internal scan error"
2841
+ ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED -> "BLE scan feature unsupported"
2842
+ ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> "Out of hardware scan resources"
2843
+ ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> "Scanning too frequently"
2844
+ else -> "Scan failed"
2845
+ }
2846
+ }
2847
+
2848
+ private fun advertiseFailureMessage(errorCode: Int): String {
2849
+ return when (errorCode) {
2850
+ AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "Advertising data too large"
2851
+ AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
2852
+ AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "Advertising already started"
2853
+ AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal advertising error"
2854
+ AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "BLE advertising feature unsupported"
2855
+ else -> "Advertising failed"
2856
+ }
2857
+ }
2858
+
973
2859
  private fun hexStringToByteArray(hexString: String?): ByteArray? {
974
2860
  if (hexString == null) return null
975
2861
 
@@ -1027,6 +2913,17 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
1027
2913
 
1028
2914
  companion object {
1029
2915
  private const val TAG = "HybridMunimBluetooth"
2916
+ private const val BLUETOOTH_PERMISSION_REQUEST_CODE = 9137
2917
+ private const val CONNECTION_TIMEOUT_MS = 15_000L
2918
+ private const val CONNECTION_RETRY_DELAY_MS = 350L
2919
+ private const val MAX_CONNECTION_RETRIES = 2
2920
+ private const val OPERATION_TIMEOUT_MS = 15_000L
2921
+ private const val DEFAULT_STREAM_BUFFER_SIZE = 4096
2922
+ private const val DEFAULT_CLASSIC_SERVICE_NAME = "MunimBluetooth"
2923
+ private const val MULTIPEER_UNSUPPORTED_MESSAGE =
2924
+ "Apple Multipeer Connectivity is only available on Apple platforms"
2925
+ private val SERIAL_PORT_PROFILE_UUID =
2926
+ UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
1030
2927
  private val CLIENT_CHARACTERISTIC_CONFIG_UUID =
1031
2928
  UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
1032
2929
  }
@@ -1039,17 +2936,19 @@ private class NitroEventEmitter(private val tag: String) {
1039
2936
  Log.w(tag, "Unable to emit $eventName: React context unavailable")
1040
2937
  return
1041
2938
  }
1042
-
1043
- val writable = Arguments.createMap()
1044
- payload.forEach { (key, value) ->
1045
- writeValue(writable, key, value)
2939
+
2940
+ UiThreadUtil.runOnUiThread {
2941
+ val writable = Arguments.createMap()
2942
+ payload.forEach { (key, value) ->
2943
+ writeValue(writable, key, value)
2944
+ }
2945
+
2946
+ context
2947
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
2948
+ .emit(eventName, writable)
1046
2949
  }
1047
-
1048
- context
1049
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1050
- .emit(eventName, writable)
1051
2950
  }
1052
-
2951
+
1053
2952
  private fun writeValue(map: WritableMap, key: String, value: Any?) {
1054
2953
  when (value) {
1055
2954
  null -> map.putNull(key)
@@ -1064,7 +2963,7 @@ private class NitroEventEmitter(private val tag: String) {
1064
2963
  else -> map.putString(key, value.toString())
1065
2964
  }
1066
2965
  }
1067
-
2966
+
1068
2967
  private fun convertMap(map: Map<*, *>): WritableMap {
1069
2968
  val writable = Arguments.createMap()
1070
2969
  map.forEach { (key, value) ->
@@ -1074,7 +2973,7 @@ private class NitroEventEmitter(private val tag: String) {
1074
2973
  }
1075
2974
  return writable
1076
2975
  }
1077
-
2976
+
1078
2977
  private fun convertArray(list: List<*>): WritableArray {
1079
2978
  val writable = Arguments.createArray()
1080
2979
  list.forEach { value ->