munim-bluetooth 0.3.27 → 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 (115) 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/HybridMunimBluetooth.kt +2006 -209
  7. package/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt +561 -53
  8. package/app.plugin.js +155 -0
  9. package/ios/HybridMunimBluetooth.swift +2123 -298
  10. package/ios/MunimBluetoothEventEmitter.swift +68 -8
  11. package/lib/commonjs/index.js +272 -11
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/module/index.js +243 -11
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/typescript/src/index.d.ts +310 -7
  16. package/lib/typescript/src/index.d.ts.map +1 -1
  17. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts +219 -5
  18. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts.map +1 -1
  19. package/nitro.json +9 -3
  20. package/nitrogen/generated/android/c++/JAdvertisingDataTypes.hpp +96 -96
  21. package/nitrogen/generated/android/c++/JAdvertisingOptions.hpp +8 -8
  22. package/nitrogen/generated/android/c++/JBackgroundSessionOptions.hpp +8 -8
  23. package/nitrogen/generated/android/c++/JBluetoothCapabilities.hpp +105 -0
  24. package/nitrogen/generated/android/c++/JBluetoothPhy.hpp +61 -0
  25. package/nitrogen/generated/android/c++/JBluetoothPhyOption.hpp +61 -0
  26. package/nitrogen/generated/android/c++/JBondState.hpp +64 -0
  27. package/nitrogen/generated/android/c++/JDescriptorValue.hpp +69 -0
  28. package/nitrogen/generated/android/c++/JExtendedAdvertisingOptions.hpp +131 -0
  29. package/nitrogen/generated/android/c++/JGATTCharacteristic.hpp +35 -11
  30. package/nitrogen/generated/android/c++/JGATTDescriptor.hpp +85 -0
  31. package/nitrogen/generated/android/c++/JGATTService.hpp +33 -9
  32. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.cpp +422 -12
  33. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.hpp +29 -0
  34. package/nitrogen/generated/android/c++/JL2CAPChannel.hpp +66 -0
  35. package/nitrogen/generated/android/c++/JMultipeerDiscoveryInfoEntry.hpp +61 -0
  36. package/nitrogen/generated/android/c++/JMultipeerEncryptionPreference.hpp +61 -0
  37. package/nitrogen/generated/android/c++/JMultipeerPeer.hpp +93 -0
  38. package/nitrogen/generated/android/c++/JMultipeerPeerState.hpp +61 -0
  39. package/nitrogen/generated/android/c++/JMultipeerSessionOptions.hpp +105 -0
  40. package/nitrogen/generated/android/c++/JPhyStatus.hpp +62 -0
  41. package/nitrogen/generated/android/c++/JScanOptions.hpp +8 -8
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingDataTypes.kt +47 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingOptions.kt +19 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BackgroundSessionOptions.kt +27 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothCapabilities.kt +111 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhy.kt +24 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhyOption.kt +24 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BondState.kt +25 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/CharacteristicValue.kt +17 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/DescriptorValue.kt +66 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ExtendedAdvertisingOptions.kt +111 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTCharacteristic.kt +25 -3
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTDescriptor.kt +61 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTService.kt +23 -3
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/HybridMunimBluetoothSpec.kt +138 -22
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/L2CAPChannel.kt +61 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerDiscoveryInfoEntry.kt +56 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerEncryptionPreference.kt +24 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeer.kt +66 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeerState.kt +24 -0
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerSessionOptions.kt +81 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/PhyStatus.kt +56 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ScanOptions.kt +17 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ServiceDataEntry.kt +15 -0
  65. package/nitrogen/generated/ios/MunimBluetooth+autolinking.rb +2 -0
  66. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.cpp +61 -5
  67. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.hpp +494 -49
  68. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Umbrella.hpp +42 -0
  69. package/nitrogen/generated/ios/c++/HybridMunimBluetoothSpecSwift.hpp +254 -0
  70. package/nitrogen/generated/ios/swift/BluetoothCapabilities.swift +89 -0
  71. package/nitrogen/generated/ios/swift/BluetoothPhy.swift +44 -0
  72. package/nitrogen/generated/ios/swift/BluetoothPhyOption.swift +44 -0
  73. package/nitrogen/generated/ios/swift/BondState.swift +48 -0
  74. package/nitrogen/generated/ios/swift/DescriptorValue.swift +44 -0
  75. package/nitrogen/generated/ios/swift/ExtendedAdvertisingOptions.swift +243 -0
  76. package/nitrogen/generated/ios/swift/Func_void_BluetoothCapabilities.swift +46 -0
  77. package/nitrogen/generated/ios/swift/Func_void_BondState.swift +46 -0
  78. package/nitrogen/generated/ios/swift/Func_void_DescriptorValue.swift +46 -0
  79. package/nitrogen/generated/ios/swift/Func_void_L2CAPChannel.swift +46 -0
  80. package/nitrogen/generated/ios/swift/Func_void_PhyStatus.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__vector_MultipeerPeer_.swift +46 -0
  83. package/nitrogen/generated/ios/swift/GATTCharacteristic.swift +25 -1
  84. package/nitrogen/generated/ios/swift/GATTDescriptor.swift +71 -0
  85. package/nitrogen/generated/ios/swift/GATTService.swift +25 -1
  86. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec.swift +29 -0
  87. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec_cxx.swift +556 -23
  88. package/nitrogen/generated/ios/swift/L2CAPChannel.swift +52 -0
  89. package/nitrogen/generated/ios/swift/MultipeerDiscoveryInfoEntry.swift +34 -0
  90. package/nitrogen/generated/ios/swift/MultipeerEncryptionPreference.swift +44 -0
  91. package/nitrogen/generated/ios/swift/MultipeerPeer.swift +63 -0
  92. package/nitrogen/generated/ios/swift/MultipeerPeerState.swift +44 -0
  93. package/nitrogen/generated/ios/swift/MultipeerSessionOptions.swift +136 -0
  94. package/nitrogen/generated/ios/swift/PhyStatus.swift +34 -0
  95. package/nitrogen/generated/shared/c++/BluetoothCapabilities.hpp +131 -0
  96. package/nitrogen/generated/shared/c++/BluetoothPhy.hpp +80 -0
  97. package/nitrogen/generated/shared/c++/BluetoothPhyOption.hpp +80 -0
  98. package/nitrogen/generated/shared/c++/BondState.hpp +84 -0
  99. package/nitrogen/generated/shared/c++/DescriptorValue.hpp +95 -0
  100. package/nitrogen/generated/shared/c++/ExtendedAdvertisingOptions.hpp +138 -0
  101. package/nitrogen/generated/shared/c++/GATTCharacteristic.hpp +9 -3
  102. package/nitrogen/generated/shared/c++/GATTDescriptor.hpp +93 -0
  103. package/nitrogen/generated/shared/c++/GATTService.hpp +7 -2
  104. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.cpp +29 -0
  105. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.hpp +61 -2
  106. package/nitrogen/generated/shared/c++/L2CAPChannel.hpp +92 -0
  107. package/nitrogen/generated/shared/c++/MultipeerDiscoveryInfoEntry.hpp +87 -0
  108. package/nitrogen/generated/shared/c++/MultipeerEncryptionPreference.hpp +80 -0
  109. package/nitrogen/generated/shared/c++/MultipeerPeer.hpp +102 -0
  110. package/nitrogen/generated/shared/c++/MultipeerPeerState.hpp +80 -0
  111. package/nitrogen/generated/shared/c++/MultipeerSessionOptions.hpp +114 -0
  112. package/nitrogen/generated/shared/c++/PhyStatus.hpp +88 -0
  113. package/package.json +22 -11
  114. package/src/index.ts +416 -31
  115. 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,13 +27,16 @@ 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
26
34
  import android.content.pm.PackageManager
27
35
  import android.os.Build
28
36
  import android.os.ParcelUuid
29
37
  import android.util.Log
30
38
  import com.facebook.react.bridge.Arguments
39
+ import com.facebook.react.bridge.UiThreadUtil
31
40
  import com.facebook.react.bridge.WritableArray
32
41
  import com.facebook.react.bridge.WritableMap
33
42
  import com.facebook.react.modules.core.DeviceEventManagerModule
@@ -38,10 +47,21 @@ import com.margelo.nitro.core.Promise
38
47
  import com.margelo.nitro.munimbluetooth.AdvertisingDataTypes
39
48
  import com.margelo.nitro.munimbluetooth.AdvertisingOptions
40
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
41
54
  import com.margelo.nitro.munimbluetooth.CharacteristicValue
55
+ import com.margelo.nitro.munimbluetooth.DescriptorValue
56
+ import com.margelo.nitro.munimbluetooth.ExtendedAdvertisingOptions
42
57
  import com.margelo.nitro.munimbluetooth.GATTCharacteristic
58
+ import com.margelo.nitro.munimbluetooth.GATTDescriptor
43
59
  import com.margelo.nitro.munimbluetooth.GATTService
44
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
45
65
  import com.margelo.nitro.munimbluetooth.ScanMode
46
66
  import com.margelo.nitro.munimbluetooth.ScanOptions
47
67
  import com.margelo.nitro.munimbluetooth.ServiceDataEntry
@@ -52,6 +72,9 @@ import kotlinx.coroutines.Job
52
72
  import kotlinx.coroutines.SupervisorJob
53
73
  import kotlinx.coroutines.delay
54
74
  import kotlinx.coroutines.launch
75
+ import org.json.JSONArray
76
+ import org.json.JSONObject
77
+ import java.io.IOException
55
78
  import java.util.UUID
56
79
 
57
80
  class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
@@ -59,6 +82,8 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
59
82
 
60
83
  private var advertiser: BluetoothLeAdvertiser? = null
61
84
  private var advertiseCallback: AdvertiseCallback? = null
85
+ private val extendedAdvertisingSets = mutableMapOf<String, AdvertisingSet>()
86
+ private val extendedAdvertisingCallbacks = mutableMapOf<String, AdvertisingSetCallback>()
62
87
  private var gattServer: BluetoothGattServer? = null
63
88
  private var gattServerReady = false
64
89
  private var advertiseJob: Job? = null
@@ -67,6 +92,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
67
92
  private var currentLocalName: String? = null
68
93
  private var currentManufacturerData: String? = null
69
94
  private var previousAdapterName: String? = null
95
+ private var configuredServices: Array<GATTService> = emptyArray()
70
96
  private var bluetoothManager: BluetoothManager? = null
71
97
  private var bluetoothAdapter: BluetoothAdapter? = null
72
98
 
@@ -79,9 +105,28 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
79
105
  private val pendingServiceDiscoveries = mutableMapOf<String, Promise<Array<GATTService>>>()
80
106
  private val pendingReads = mutableMapOf<String, Promise<CharacteristicValue>>()
81
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>>()
82
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>()
83
117
  private val lastCharacteristicValues = mutableMapOf<String, CharacteristicValue>()
84
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>()
85
130
  private val eventEmitter = NitroEventEmitter(TAG)
86
131
  private var nextPermissionRequestCode = BLUETOOTH_PERMISSION_REQUEST_CODE
87
132
 
@@ -163,7 +208,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
163
208
  }
164
209
 
165
210
  if (!gattServerReady) {
166
- setServicesFromOptions(options.serviceUUIDs)
211
+ if (configuredServices.isNotEmpty()) {
212
+ setServices(configuredServices)
213
+ } else {
214
+ setServicesFromOptions(options.serviceUUIDs)
215
+ }
167
216
  }
168
217
  restartAdvertising(delayMs = 300L)
169
218
  }
@@ -188,8 +237,21 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
188
237
  advertiseCallback?.let { callback ->
189
238
  advertiser?.stopAdvertising(callback)
190
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()
191
248
  advertiseCallback = null
192
249
  advertiser = null
250
+ gattServer?.clearServices()
251
+ gattServer?.close()
252
+ gattServer = null
253
+ gattServerReady = false
254
+ subscribedDevices.clear()
193
255
  currentAdvertisingData = null
194
256
  currentServiceUUIDs = emptyArray()
195
257
  currentLocalName = null
@@ -202,6 +264,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
202
264
  return
203
265
  }
204
266
 
267
+ configuredServices = services
205
268
  ensureBluetoothManager()
206
269
  gattServerReady = false
207
270
 
@@ -212,6 +275,8 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
212
275
  gattServer = manager.openGattServer(context, buildGattServerCallback())
213
276
  gattServer?.clearServices()
214
277
 
278
+ val nativeServices = linkedMapOf<String, BluetoothGattService>()
279
+
215
280
  for (serviceData in services) {
216
281
  val service = BluetoothGattService(
217
282
  UUID.fromString(serviceData.uuid),
@@ -226,17 +291,71 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
226
291
  BluetoothGattCharacteristic.PERMISSION_WRITE
227
292
  )
228
293
  characteristicData.value?.let { value ->
229
- 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
+ )
230
318
  }
231
319
  service.addCharacteristic(characteristic)
232
320
  }
233
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 ->
234
335
  gattServer?.addService(service)
235
336
  }
236
337
 
237
338
  gattServerReady = true
238
339
  }
239
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
+
240
359
  override fun isBluetoothEnabled(): Promise<Boolean> {
241
360
  if (!hasRequiredBluetoothPermissions()) {
242
361
  return Promise.resolved(false)
@@ -290,6 +409,29 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
290
409
  return promise
291
410
  }
292
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
+ )
433
+ }
434
+
293
435
  override fun startScan(options: ScanOptions?) {
294
436
  if (!ensureBluetoothPermissions("start scanning")) {
295
437
  return
@@ -336,20 +478,27 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
336
478
  override fun onScanResult(callbackType: Int, result: ScanResult) {
337
479
  val device = result.device
338
480
  discoveredDevices[device.address] = device
339
- eventEmitter.emit("deviceFound", buildScanPayload(result))
481
+ emitDeviceFound(buildScanPayload(result))
340
482
  }
341
483
 
342
484
  override fun onBatchScanResults(results: MutableList<ScanResult>) {
343
485
  results.forEach { result ->
344
486
  val device = result.device
345
487
  discoveredDevices[device.address] = device
346
- eventEmitter.emit("deviceFound", buildScanPayload(result))
488
+ emitDeviceFound(buildScanPayload(result))
347
489
  }
348
490
  }
349
491
 
350
492
  override fun onScanFailed(errorCode: Int) {
351
493
  Log.e(TAG, "Scan failed: $errorCode")
352
494
  isScanning = false
495
+ eventEmitter.emit(
496
+ "scanFailed",
497
+ mapOf(
498
+ "errorCode" to errorCode,
499
+ "message" to scanFailureMessage(errorCode)
500
+ )
501
+ )
353
502
  }
354
503
  }
355
504
 
@@ -378,10 +527,10 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
378
527
  }
379
528
  }
380
529
 
381
- val context = NitroModules.applicationContext
382
- ?: return Promise.rejected(IllegalStateException("React context unavailable"))
383
530
  val adapter = bluetoothAdapter
384
531
  ?: return Promise.rejected(IllegalStateException("Bluetooth adapter unavailable"))
532
+ NitroModules.applicationContext
533
+ ?: return Promise.rejected(IllegalStateException("React context unavailable"))
385
534
 
386
535
  val device = discoveredDevices[deviceId] ?: run {
387
536
  try {
@@ -393,20 +542,22 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
393
542
 
394
543
  val promise = Promise<Unit>()
395
544
  pendingConnections[deviceId] = promise
396
-
397
- val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
398
- device.connectGatt(context, false, createGattCallback(deviceId), BluetoothDevice.TRANSPORT_LE)
399
- } else {
400
- device.connectGatt(context, false, createGattCallback(deviceId))
401
- }
402
- connectedDevices[deviceId] = gatt
545
+ pendingConnectionAttempts[deviceId] = 0
546
+ scheduleConnectionTimeout(deviceId)
547
+ startGattConnection(deviceId, device)
403
548
  return promise
404
549
  }
405
550
 
406
551
  override fun disconnect(deviceId: String) {
407
- pendingConnections.remove(deviceId)
408
- pendingServiceDiscoveries.remove(deviceId)
409
- 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
+ )
410
561
 
411
562
  val gatt = connectedDevices.remove(deviceId)
412
563
  gatt?.disconnect()
@@ -426,7 +577,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
426
577
 
427
578
  val promise = Promise<Array<GATTService>>()
428
579
  pendingServiceDiscoveries[deviceId] = promise
580
+ schedulePendingOperationTimeout("services|$deviceId", "Service discovery for $deviceId") {
581
+ pendingServiceDiscoveries.remove(deviceId)
582
+ }
429
583
  if (!gatt.discoverServices()) {
584
+ cancelPendingOperationTimeout("services|$deviceId")
430
585
  pendingServiceDiscoveries.remove(deviceId)
431
586
  return Promise.rejected(IllegalStateException("Failed to start service discovery for $deviceId"))
432
587
  }
@@ -448,14 +603,43 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
448
603
  val promise = Promise<CharacteristicValue>()
449
604
  val key = characteristicKey(deviceId, serviceUUID, characteristicUUID)
450
605
  pendingReads[key] = promise
606
+ schedulePendingOperationTimeout("read|$key", "Characteristic read $key") {
607
+ pendingReads.remove(key)
608
+ }
451
609
 
452
610
  if (!gatt.readCharacteristic(characteristic)) {
611
+ cancelPendingOperationTimeout("read|$key")
453
612
  pendingReads.remove(key)
454
613
  return Promise.rejected(IllegalStateException("Failed to start characteristic read"))
455
614
  }
456
615
  return promise
457
616
  }
458
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
+
459
643
  override fun writeCharacteristic(
460
644
  deviceId: String,
461
645
  serviceUUID: String,
@@ -472,20 +656,56 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
472
656
  val data = hexStringToByteArray(value)
473
657
  ?: return Promise.rejected(IllegalArgumentException("Invalid hex string for characteristic write"))
474
658
 
475
- characteristic.value = data
476
- characteristic.writeType = when (writeType) {
659
+ val resolvedWriteType = when (writeType) {
477
660
  WriteType.WRITEWITHOUTRESPONSE -> BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
478
661
  else -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
479
662
  }
480
663
 
481
664
  val promise = Promise<Unit>()
482
665
  val key = characteristicKey(deviceId, serviceUUID, characteristicUUID)
483
- 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
+ }
484
672
 
485
- if (!gatt.writeCharacteristic(characteristic)) {
673
+ if (!writeGattCharacteristic(gatt, characteristic, data, resolvedWriteType)) {
674
+ cancelPendingOperationTimeout("write|$key")
486
675
  pendingWrites.remove(key)
487
676
  return Promise.rejected(IllegalStateException("Failed to start characteristic write"))
488
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
+ }
489
709
  return promise
490
710
  }
491
711
 
@@ -499,8 +719,14 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
499
719
  gatt.setCharacteristicNotification(characteristic, true)
500
720
 
501
721
  characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)?.let { descriptor ->
502
- descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
503
- 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)
504
730
  }
505
731
  }
506
732
 
@@ -514,8 +740,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
514
740
  gatt.setCharacteristicNotification(characteristic, false)
515
741
 
516
742
  characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)?.let { descriptor ->
517
- descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
518
- gatt.writeDescriptor(descriptor)
743
+ writeGattDescriptor(gatt, descriptor, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
519
744
  }
520
745
  }
521
746
 
@@ -533,156 +758,809 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
533
758
 
534
759
  val promise = Promise<Double>()
535
760
  pendingRssiReads[deviceId] = promise
761
+ schedulePendingOperationTimeout("rssi|$deviceId", "RSSI read for $deviceId") {
762
+ pendingRssiReads.remove(deviceId)
763
+ }
536
764
  if (!gatt.readRemoteRssi()) {
765
+ cancelPendingOperationTimeout("rssi|$deviceId")
537
766
  pendingRssiReads.remove(deviceId)
538
767
  return Promise.rejected(IllegalStateException("Failed to start RSSI read"))
539
768
  }
540
769
  return promise
541
770
  }
542
771
 
543
- override fun startBackgroundSession(options: BackgroundSessionOptions) {
544
- val context = NitroModules.applicationContext ?: run {
545
- Log.w(TAG, "Unable to start background BLE session: application context unavailable")
546
- return
772
+ override fun requestMTU(deviceId: String, mtu: Double): Promise<Double> {
773
+ val gatt = connectedDevices[deviceId]
774
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
775
+
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)
547
781
  }
782
+ if (!gatt.requestMtu(requestedMtu)) {
783
+ cancelPendingOperationTimeout("mtu|$deviceId")
784
+ pendingMtuRequests.remove(deviceId)
785
+ return Promise.rejected(IllegalStateException("Failed to start MTU request"))
786
+ }
787
+ return promise
788
+ }
548
789
 
549
- if (!ensureBluetoothPermissions("start background BLE session")) {
550
- return
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")
551
798
  }
552
799
 
553
- val intent = Intent(context, MunimBluetoothBackgroundService::class.java).apply {
554
- action = MunimBluetoothBackgroundService.ACTION_START
555
- putExtra(
556
- MunimBluetoothBackgroundService.EXTRA_SERVICE_UUIDS,
557
- options.serviceUUIDs
558
- )
559
- putExtra(
560
- MunimBluetoothBackgroundService.EXTRA_LOCAL_NAME,
561
- options.localName
562
- )
563
- putExtra(
564
- MunimBluetoothBackgroundService.EXTRA_ALLOW_DUPLICATES,
565
- options.allowDuplicates ?: false
566
- )
567
- putExtra(
568
- MunimBluetoothBackgroundService.EXTRA_SCAN_MODE,
569
- options.scanMode?.name ?: ScanMode.LOWPOWER.name
570
- )
571
- putExtra(
572
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_CHANNEL_ID,
573
- options.androidNotificationChannelId
574
- ?: MunimBluetoothBackgroundService.DEFAULT_CHANNEL_ID
575
- )
576
- putExtra(
577
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_CHANNEL_NAME,
578
- options.androidNotificationChannelName
579
- ?: MunimBluetoothBackgroundService.DEFAULT_CHANNEL_NAME
580
- )
581
- putExtra(
582
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_TITLE,
583
- options.androidNotificationTitle
584
- ?: MunimBluetoothBackgroundService.DEFAULT_NOTIFICATION_TITLE
585
- )
586
- putExtra(
587
- MunimBluetoothBackgroundService.EXTRA_NOTIFICATION_TEXT,
588
- options.androidNotificationText
589
- ?: MunimBluetoothBackgroundService.DEFAULT_NOTIFICATION_TEXT
590
- )
800
+ val gatt = connectedDevices[deviceId]
801
+ ?: return Promise.rejected(IllegalStateException("Device not connected: $deviceId"))
802
+
803
+ gatt.setPreferredPhy(
804
+ phyToMask(txPhy),
805
+ phyToMask(rxPhy),
806
+ phyOptionToConstant(phyOption ?: BluetoothPhyOption.NONE)
807
+ )
808
+ return Promise.resolved(Unit)
809
+ }
810
+
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")
591
814
  }
592
815
 
593
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
594
- context.startForegroundService(intent)
595
- } else {
596
- context.startService(intent)
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)
597
823
  }
824
+ gatt.readPhy()
825
+ return promise
598
826
  }
599
827
 
600
- override fun stopBackgroundSession() {
601
- val context = NitroModules.applicationContext ?: return
602
- val intent = Intent(context, MunimBluetoothBackgroundService::class.java).apply {
603
- action = MunimBluetoothBackgroundService.ACTION_STOP
604
- }
605
- context.startService(intent)
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))
606
832
  }
607
833
 
608
- override fun addListener(eventName: String) {
609
- // Nitro uses JS-side listener registration. No native bookkeeping required here.
834
+ override fun createBond(deviceId: String): Promise<BondState> {
835
+ val device = resolveBluetoothDevice(deviceId)
836
+ ?: return Promise.rejected(IllegalArgumentException("Device not found: $deviceId"))
837
+
838
+ if (device.bondState == BluetoothDevice.BOND_BONDED) {
839
+ return Promise.resolved(BondState.BONDED)
840
+ }
841
+
842
+ return if (device.createBond()) {
843
+ Promise.resolved(bondStateFor(device))
844
+ } else {
845
+ Promise.rejected(IllegalStateException("Failed to start bond creation for $deviceId"))
846
+ }
610
847
  }
611
848
 
612
- override fun removeListeners(count: Double) {
613
- // Nitro uses JS-side listener registration. No native bookkeeping required here.
849
+ override fun removeBond(deviceId: String): Promise<BondState> {
850
+ val device = resolveBluetoothDevice(deviceId)
851
+ ?: return Promise.rejected(IllegalArgumentException("Device not found: $deviceId"))
852
+
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"))
860
+ }
861
+ } catch (error: ReflectiveOperationException) {
862
+ Promise.rejected(UnsupportedOperationException("Removing bonds is unavailable on this Android build", error))
863
+ }
614
864
  }
615
865
 
616
- private fun restartAdvertising(delayMs: Long) {
617
- if (!ensureBluetoothPermissions("restart advertising")) {
618
- return
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"))
619
872
  }
620
873
 
621
874
  ensureBluetoothManager()
622
875
  val adapter = bluetoothAdapter
623
- if (adapter == null || !adapter.isEnabled) {
624
- Log.e(TAG, "Bluetooth is not enabled or not available")
625
- return
876
+ ?: return Promise.rejected(IllegalStateException("Bluetooth adapter unavailable"))
877
+ if (!adapter.isLeExtendedAdvertisingSupported) {
878
+ return unsupportedPromise("BLE extended advertising is not supported by this device")
626
879
  }
880
+ val advertiser = adapter.bluetoothLeAdvertiser
881
+ ?: return Promise.rejected(IllegalStateException("Bluetooth LE advertiser is unavailable"))
627
882
 
628
- advertiseJob?.cancel()
629
- advertiseCallback?.let { callback ->
630
- advertiser?.stopAdvertising(callback)
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))
631
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()
632
911
 
633
- advertiseJob = bluetoothScope.launch {
634
- if (delayMs > 0) {
635
- delay(delayMs)
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
+ }
636
934
  }
935
+ }
637
936
 
638
- advertiser = adapter.bluetoothLeAdvertiser
639
- val activeAdvertiser = advertiser
640
- if (activeAdvertiser == null) {
641
- Log.e(TAG, "Bluetooth LE advertiser is not available")
642
- return@launch
643
- }
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
+ }
644
948
 
645
- val dataBuilder = AdvertiseData.Builder()
646
- currentAdvertisingData?.let { processAdvertisingData(it, dataBuilder) }
647
- currentServiceUUIDs.forEach { uuid ->
648
- dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid))
649
- }
949
+ override fun stopExtendedAdvertising(advertisingId: String) {
950
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
650
951
 
651
- val settings = AdvertiseSettings.Builder()
652
- .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
653
- .setConnectable(true)
654
- .setTimeout(0)
655
- .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
656
- .build()
952
+ ensureBluetoothManager()
953
+ extendedAdvertisingSets.remove(advertisingId)
954
+ val callback = extendedAdvertisingCallbacks.remove(advertisingId) ?: return
955
+ bluetoothAdapter?.bluetoothLeAdvertiser?.stopAdvertisingSet(callback)
956
+ }
657
957
 
658
- advertiseCallback = object : AdvertiseCallback() {
659
- override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
660
- Log.i(TAG, "Advertising started successfully")
661
- }
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
+ }
662
965
 
663
- override fun onStartFailure(errorCode: Int) {
664
- Log.e(TAG, "Advertising failed: $errorCode")
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()
665
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)
666
995
  }
996
+ }
997
+
998
+ return promise
999
+ }
667
1000
 
668
- activeAdvertiser.startAdvertising(settings, dataBuilder.build(), advertiseCallback)
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)
669
1008
  }
670
1009
  }
671
1010
 
672
- private fun buildGattServerCallback(): BluetoothGattServerCallback {
673
- return object : BluetoothGattServerCallback() {
674
- override fun onCharacteristicReadRequest(
675
- device: BluetoothDevice,
676
- requestId: Int,
677
- offset: Int,
678
- characteristic: BluetoothGattCharacteristic
679
- ) {
680
- gattServer?.sendResponse(
681
- device,
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,
682
1551
  requestId,
683
1552
  BluetoothGatt.GATT_SUCCESS,
684
1553
  offset,
685
- 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
+ )
686
1564
  )
687
1565
  }
688
1566
 
@@ -695,7 +1573,173 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
695
1573
  offset: Int,
696
1574
  value: ByteArray?
697
1575
  ) {
698
- 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
+
699
1743
  if (responseNeeded) {
700
1744
  gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
701
1745
  }
@@ -707,25 +1751,35 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
707
1751
  return object : BluetoothGattCallback() {
708
1752
  override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
709
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)
710
1759
  pendingConnections.remove(deviceId)?.reject(
711
1760
  IllegalStateException("Failed to connect to $deviceId (status=$status)")
712
1761
  )
713
- connectedDevices.remove(deviceId)?.close()
1762
+ (pendingConnectionGatts.remove(deviceId) ?: connectedDevices.remove(deviceId))?.close()
714
1763
  return
715
1764
  }
716
1765
 
717
1766
  when (newState) {
718
1767
  BluetoothProfile.STATE_CONNECTED -> {
1768
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
1769
+ pendingConnectionAttempts.remove(deviceId)
1770
+ pendingConnectionGatts.remove(deviceId)
719
1771
  connectedDevices[deviceId] = gatt
720
1772
  pendingConnections.remove(deviceId)?.resolve(Unit)
721
1773
  eventEmitter.emit("deviceConnected", mapOf("deviceId" to deviceId))
722
1774
  }
723
1775
 
724
1776
  BluetoothProfile.STATE_DISCONNECTED -> {
1777
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
1778
+ pendingConnectionAttempts.remove(deviceId)
725
1779
  pendingConnections.remove(deviceId)?.reject(
726
1780
  IllegalStateException("Disconnected from $deviceId")
727
1781
  )
728
- connectedDevices.remove(deviceId)?.close()
1782
+ (pendingConnectionGatts.remove(deviceId) ?: connectedDevices.remove(deviceId))?.close()
729
1783
  rejectPendingOperationsForDevice(
730
1784
  deviceId,
731
1785
  IllegalStateException("Disconnected from $deviceId")
@@ -736,23 +1790,13 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
736
1790
  }
737
1791
 
738
1792
  override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
1793
+ cancelPendingOperationTimeout("services|$deviceId")
739
1794
  if (status == BluetoothGatt.GATT_SUCCESS) {
740
1795
  val services = buildGattServices(gatt)
741
1796
  pendingServiceDiscoveries.remove(deviceId)?.resolve(services)
742
1797
  eventEmitter.emit(
743
1798
  "servicesDiscovered",
744
- mapOf("deviceId" to deviceId, "services" to services.map { service ->
745
- mapOf(
746
- "uuid" to service.uuid,
747
- "characteristics" to service.characteristics.map { characteristic ->
748
- mapOf(
749
- "uuid" to characteristic.uuid,
750
- "properties" to characteristic.properties.toList(),
751
- "value" to characteristic.value
752
- )
753
- }
754
- )
755
- })
1799
+ mapOf("deviceId" to deviceId, "services" to services.map { servicePayload(it) })
756
1800
  )
757
1801
  } else {
758
1802
  pendingServiceDiscoveries.remove(deviceId)?.reject(
@@ -766,29 +1810,16 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
766
1810
  characteristic: BluetoothGattCharacteristic,
767
1811
  status: Int
768
1812
  ) {
769
- val key = characteristicKey(
770
- deviceId,
771
- characteristic.service.uuid.toString(),
772
- characteristic.uuid.toString()
773
- )
774
- if (status == BluetoothGatt.GATT_SUCCESS) {
775
- val value = buildCharacteristicValue(characteristic)
776
- lastCharacteristicValues[key] = value
777
- pendingReads.remove(key)?.resolve(value)
778
- eventEmitter.emit(
779
- "characteristicValueChanged",
780
- mapOf(
781
- "deviceId" to deviceId,
782
- "serviceUUID" to value.serviceUUID,
783
- "characteristicUUID" to value.characteristicUUID,
784
- "value" to value.value
785
- )
786
- )
787
- } else {
788
- pendingReads.remove(key)?.reject(
789
- IllegalStateException("Failed to read characteristic $key (status=$status)")
790
- )
791
- }
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)
792
1823
  }
793
1824
 
794
1825
  override fun onCharacteristicWrite(
@@ -801,6 +1832,7 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
801
1832
  characteristic.service.uuid.toString(),
802
1833
  characteristic.uuid.toString()
803
1834
  )
1835
+ cancelPendingOperationTimeout("write|$key")
804
1836
  if (status == BluetoothGatt.GATT_SUCCESS) {
805
1837
  pendingWrites.remove(key)?.resolve(Unit)
806
1838
  } else {
@@ -814,21 +1846,87 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
814
1846
  gatt: BluetoothGatt,
815
1847
  characteristic: BluetoothGattCharacteristic
816
1848
  ) {
817
- val value = buildCharacteristicValue(characteristic)
818
- val key = characteristicKey(deviceId, value.serviceUUID, value.characteristicUUID)
819
- lastCharacteristicValues[key] = value
820
- eventEmitter.emit(
821
- "characteristicValueChanged",
822
- mapOf(
823
- "deviceId" to deviceId,
824
- "serviceUUID" to value.serviceUUID,
825
- "characteristicUUID" to value.characteristicUUID,
826
- "value" to value.value
827
- )
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()
828
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
+ }
829
1926
  }
830
1927
 
831
1928
  override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
1929
+ cancelPendingOperationTimeout("rssi|$deviceId")
832
1930
  if (status == BluetoothGatt.GATT_SUCCESS) {
833
1931
  val rssiValue = rssi.toDouble()
834
1932
  lastRssiValues[deviceId] = rssiValue
@@ -846,15 +1944,213 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
846
1944
  }
847
1945
  }
848
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
+
849
2017
  private fun rejectPendingOperationsForDevice(deviceId: String, error: Throwable) {
2018
+ pendingConnectionTimeouts.remove(deviceId)?.cancel()
2019
+ pendingConnectionAttempts.remove(deviceId)
2020
+ cancelPendingOperationTimeoutsForDevice(deviceId)
850
2021
  pendingReads.keys
851
2022
  .filter { it.startsWith("$deviceId|") }
852
2023
  .forEach { key -> pendingReads.remove(key)?.reject(error) }
853
2024
  pendingWrites.keys
854
2025
  .filter { it.startsWith("$deviceId|") }
855
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) }
856
2033
  pendingServiceDiscoveries.remove(deviceId)?.reject(error)
857
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
858
2154
  }
859
2155
 
860
2156
  private fun buildGattServices(gatt: BluetoothGatt): Array<GATTService> {
@@ -865,21 +2161,66 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
865
2161
  GATTCharacteristic(
866
2162
  uuid = characteristic.uuid.toString(),
867
2163
  properties = propertiesToArray(characteristic.properties),
868
- 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()
869
2172
  )
870
- }.toTypedArray()
2173
+ }.toTypedArray(),
2174
+ includedServices = service.includedServices.map { it.uuid.toString() }.toTypedArray()
871
2175
  )
872
2176
  }.toTypedArray()
873
2177
  }
874
2178
 
875
- 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 {
876
2204
  return CharacteristicValue(
877
- value = characteristic.value?.toHexString() ?: "",
2205
+ value = valueBytes?.toHexString() ?: "",
878
2206
  serviceUUID = characteristic.service.uuid.toString(),
879
2207
  characteristicUUID = characteristic.uuid.toString()
880
2208
  )
881
2209
  }
882
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
+
883
2224
  private fun findCharacteristic(
884
2225
  gatt: BluetoothGatt,
885
2226
  serviceUUID: String,
@@ -892,6 +2233,68 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
892
2233
  }
893
2234
  }
894
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
+
895
2298
  private fun characteristicKey(
896
2299
  deviceId: String,
897
2300
  serviceUUID: String,
@@ -900,6 +2303,38 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
900
2303
  return "$deviceId|${serviceUUID.lowercase()}|${characteristicUUID.lowercase()}"
901
2304
  }
902
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
+
903
2338
  private fun buildScanPayload(result: ScanResult): Map<String, Any?> {
904
2339
  val record = result.scanRecord
905
2340
  val manufacturerData = extractManufacturerData(record)
@@ -911,14 +2346,10 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
911
2346
  record?.deviceName?.let { advertisingData["completeLocalName"] = it }
912
2347
  txPower?.let { advertisingData["txPowerLevel"] = it }
913
2348
  manufacturerData?.let { advertisingData["manufacturerData"] = it }
914
- serviceUUIDs?.let { advertisingData["serviceUUIDs"] = it }
2349
+ serviceUUIDs?.let { addServiceUuidBuckets(it, advertisingData) }
915
2350
  serviceData?.takeIf { it.isNotEmpty() }?.let { entries ->
916
- advertisingData["serviceData"] = entries.map { mapOf("uuid" to it.uuid, "data" to it.data) }
917
- }
918
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
919
- advertisingData["isConnectable"] = result.isConnectable
2351
+ addServiceDataBuckets(entries, advertisingData)
920
2352
  }
921
- advertisingData["rssi"] = result.rssi
922
2353
 
923
2354
  return mapOf(
924
2355
  "id" to result.device.address,
@@ -934,6 +2365,65 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
934
2365
  )
935
2366
  }
936
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
+
937
2427
  private fun extractManufacturerData(record: ScanRecord?): String? {
938
2428
  val data = record?.manufacturerSpecificData ?: return null
939
2429
  if (data.size() == 0) return null
@@ -950,14 +2440,17 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
950
2440
 
951
2441
  private fun processAdvertisingData(
952
2442
  data: AdvertisingDataTypes,
953
- dataBuilder: AdvertiseData.Builder
2443
+ dataBuilder: AdvertiseData.Builder,
2444
+ includeServiceUuids: Boolean = true
954
2445
  ) {
955
- addServiceUUIDs(data.incompleteServiceUUIDs16, dataBuilder)
956
- addServiceUUIDs(data.completeServiceUUIDs16, dataBuilder)
957
- addServiceUUIDs(data.incompleteServiceUUIDs32, dataBuilder)
958
- addServiceUUIDs(data.completeServiceUUIDs32, dataBuilder)
959
- addServiceUUIDs(data.incompleteServiceUUIDs128, dataBuilder)
960
- 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
+ }
961
2454
 
962
2455
  if (data.shortenedLocalName != null || data.completeLocalName != null) {
963
2456
  dataBuilder.setIncludeDeviceName(true)
@@ -966,9 +2459,11 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
966
2459
  dataBuilder.setIncludeTxPowerLevel(true)
967
2460
  }
968
2461
 
969
- addServiceUUIDs(data.serviceSolicitationUUIDs16, dataBuilder)
970
- addServiceUUIDs(data.serviceSolicitationUUIDs32, dataBuilder)
971
- addServiceUUIDs(data.serviceSolicitationUUIDs128, dataBuilder)
2462
+ if (includeServiceUuids) {
2463
+ addServiceUUIDs(data.serviceSolicitationUUIDs16, dataBuilder)
2464
+ addServiceUUIDs(data.serviceSolicitationUUIDs32, dataBuilder)
2465
+ addServiceUUIDs(data.serviceSolicitationUUIDs128, dataBuilder)
2466
+ }
972
2467
  addServiceData(data.serviceData16, dataBuilder)
973
2468
  addServiceData(data.serviceData32, dataBuilder)
974
2469
  addServiceData(data.serviceData128, dataBuilder)
@@ -1054,6 +2549,273 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
1054
2549
  return result.toTypedArray()
1055
2550
  }
1056
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
+
1057
2819
  private fun addServiceUUIDs(uuids: Array<String>?, dataBuilder: AdvertiseData.Builder) {
1058
2820
  uuids?.forEach { uuid ->
1059
2821
  dataBuilder.addServiceUuid(ParcelUuid.fromString(uuid))
@@ -1071,6 +2833,29 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
1071
2833
  }
1072
2834
  }
1073
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
+
1074
2859
  private fun hexStringToByteArray(hexString: String?): ByteArray? {
1075
2860
  if (hexString == null) return null
1076
2861
 
@@ -1129,6 +2914,16 @@ class HybridMunimBluetooth : HybridMunimBluetoothSpec() {
1129
2914
  companion object {
1130
2915
  private const val TAG = "HybridMunimBluetooth"
1131
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")
1132
2927
  private val CLIENT_CHARACTERISTIC_CONFIG_UUID =
1133
2928
  UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
1134
2929
  }
@@ -1141,17 +2936,19 @@ private class NitroEventEmitter(private val tag: String) {
1141
2936
  Log.w(tag, "Unable to emit $eventName: React context unavailable")
1142
2937
  return
1143
2938
  }
1144
-
1145
- val writable = Arguments.createMap()
1146
- payload.forEach { (key, value) ->
1147
- 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)
1148
2949
  }
1149
-
1150
- context
1151
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1152
- .emit(eventName, writable)
1153
2950
  }
1154
-
2951
+
1155
2952
  private fun writeValue(map: WritableMap, key: String, value: Any?) {
1156
2953
  when (value) {
1157
2954
  null -> map.putNull(key)
@@ -1166,7 +2963,7 @@ private class NitroEventEmitter(private val tag: String) {
1166
2963
  else -> map.putString(key, value.toString())
1167
2964
  }
1168
2965
  }
1169
-
2966
+
1170
2967
  private fun convertMap(map: Map<*, *>): WritableMap {
1171
2968
  val writable = Arguments.createMap()
1172
2969
  map.forEach { (key, value) ->
@@ -1176,7 +2973,7 @@ private class NitroEventEmitter(private val tag: String) {
1176
2973
  }
1177
2974
  return writable
1178
2975
  }
1179
-
2976
+
1180
2977
  private fun convertArray(list: List<*>): WritableArray {
1181
2978
  val writable = Arguments.createArray()
1182
2979
  list.forEach { value ->