munim-bluetooth 0.3.27 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +201 -21
  3. package/README.md +480 -75
  4. package/android/gradle.properties +2 -2
  5. package/android/src/main/AndroidManifest.xml +3 -1
  6. package/android/src/main/cpp/cpp-adapter.cpp +4 -1
  7. package/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt +2006 -209
  8. package/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt +561 -53
  9. package/app.plugin.js +155 -0
  10. package/ios/HybridMunimBluetooth.swift +2123 -298
  11. package/ios/MunimBluetoothEventEmitter.swift +68 -8
  12. package/lib/commonjs/index.js +272 -11
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/module/index.js +243 -11
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/index.d.ts +310 -7
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts +219 -5
  19. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts.map +1 -1
  20. package/nitro.json +9 -3
  21. package/nitrogen/generated/android/c++/JAdvertisingDataTypes.hpp +96 -96
  22. package/nitrogen/generated/android/c++/JAdvertisingOptions.hpp +8 -8
  23. package/nitrogen/generated/android/c++/JBackgroundSessionOptions.hpp +8 -8
  24. package/nitrogen/generated/android/c++/JBluetoothCapabilities.hpp +105 -0
  25. package/nitrogen/generated/android/c++/JBluetoothPhy.hpp +61 -0
  26. package/nitrogen/generated/android/c++/JBluetoothPhyOption.hpp +61 -0
  27. package/nitrogen/generated/android/c++/JBondState.hpp +64 -0
  28. package/nitrogen/generated/android/c++/JDescriptorValue.hpp +69 -0
  29. package/nitrogen/generated/android/c++/JExtendedAdvertisingOptions.hpp +131 -0
  30. package/nitrogen/generated/android/c++/JGATTCharacteristic.hpp +35 -11
  31. package/nitrogen/generated/android/c++/JGATTDescriptor.hpp +85 -0
  32. package/nitrogen/generated/android/c++/JGATTService.hpp +33 -9
  33. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.cpp +422 -12
  34. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.hpp +29 -0
  35. package/nitrogen/generated/android/c++/JL2CAPChannel.hpp +66 -0
  36. package/nitrogen/generated/android/c++/JMultipeerDiscoveryInfoEntry.hpp +61 -0
  37. package/nitrogen/generated/android/c++/JMultipeerEncryptionPreference.hpp +61 -0
  38. package/nitrogen/generated/android/c++/JMultipeerPeer.hpp +93 -0
  39. package/nitrogen/generated/android/c++/JMultipeerPeerState.hpp +61 -0
  40. package/nitrogen/generated/android/c++/JMultipeerSessionOptions.hpp +105 -0
  41. package/nitrogen/generated/android/c++/JPhyStatus.hpp +62 -0
  42. package/nitrogen/generated/android/c++/JScanOptions.hpp +8 -8
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingDataTypes.kt +47 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingOptions.kt +19 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BackgroundSessionOptions.kt +27 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothCapabilities.kt +111 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhy.kt +24 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhyOption.kt +24 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BondState.kt +25 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/CharacteristicValue.kt +17 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/DescriptorValue.kt +66 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ExtendedAdvertisingOptions.kt +111 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTCharacteristic.kt +25 -3
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTDescriptor.kt +61 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTService.kt +23 -3
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/HybridMunimBluetoothSpec.kt +138 -22
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/L2CAPChannel.kt +61 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerDiscoveryInfoEntry.kt +56 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerEncryptionPreference.kt +24 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeer.kt +66 -0
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeerState.kt +24 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerSessionOptions.kt +81 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/PhyStatus.kt +56 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ScanOptions.kt +17 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ServiceDataEntry.kt +15 -0
  66. package/nitrogen/generated/ios/MunimBluetooth+autolinking.rb +2 -0
  67. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.cpp +61 -5
  68. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.hpp +494 -49
  69. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Umbrella.hpp +42 -0
  70. package/nitrogen/generated/ios/c++/HybridMunimBluetoothSpecSwift.hpp +254 -0
  71. package/nitrogen/generated/ios/swift/BluetoothCapabilities.swift +89 -0
  72. package/nitrogen/generated/ios/swift/BluetoothPhy.swift +44 -0
  73. package/nitrogen/generated/ios/swift/BluetoothPhyOption.swift +44 -0
  74. package/nitrogen/generated/ios/swift/BondState.swift +48 -0
  75. package/nitrogen/generated/ios/swift/DescriptorValue.swift +44 -0
  76. package/nitrogen/generated/ios/swift/ExtendedAdvertisingOptions.swift +243 -0
  77. package/nitrogen/generated/ios/swift/Func_void_BluetoothCapabilities.swift +46 -0
  78. package/nitrogen/generated/ios/swift/Func_void_BondState.swift +46 -0
  79. package/nitrogen/generated/ios/swift/Func_void_DescriptorValue.swift +46 -0
  80. package/nitrogen/generated/ios/swift/Func_void_L2CAPChannel.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_PhyStatus.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  83. package/nitrogen/generated/ios/swift/Func_void_std__vector_MultipeerPeer_.swift +46 -0
  84. package/nitrogen/generated/ios/swift/GATTCharacteristic.swift +25 -1
  85. package/nitrogen/generated/ios/swift/GATTDescriptor.swift +71 -0
  86. package/nitrogen/generated/ios/swift/GATTService.swift +25 -1
  87. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec.swift +29 -0
  88. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec_cxx.swift +556 -23
  89. package/nitrogen/generated/ios/swift/L2CAPChannel.swift +52 -0
  90. package/nitrogen/generated/ios/swift/MultipeerDiscoveryInfoEntry.swift +34 -0
  91. package/nitrogen/generated/ios/swift/MultipeerEncryptionPreference.swift +44 -0
  92. package/nitrogen/generated/ios/swift/MultipeerPeer.swift +63 -0
  93. package/nitrogen/generated/ios/swift/MultipeerPeerState.swift +44 -0
  94. package/nitrogen/generated/ios/swift/MultipeerSessionOptions.swift +136 -0
  95. package/nitrogen/generated/ios/swift/PhyStatus.swift +34 -0
  96. package/nitrogen/generated/shared/c++/BluetoothCapabilities.hpp +131 -0
  97. package/nitrogen/generated/shared/c++/BluetoothPhy.hpp +80 -0
  98. package/nitrogen/generated/shared/c++/BluetoothPhyOption.hpp +80 -0
  99. package/nitrogen/generated/shared/c++/BondState.hpp +84 -0
  100. package/nitrogen/generated/shared/c++/DescriptorValue.hpp +95 -0
  101. package/nitrogen/generated/shared/c++/ExtendedAdvertisingOptions.hpp +138 -0
  102. package/nitrogen/generated/shared/c++/GATTCharacteristic.hpp +9 -3
  103. package/nitrogen/generated/shared/c++/GATTDescriptor.hpp +93 -0
  104. package/nitrogen/generated/shared/c++/GATTService.hpp +7 -2
  105. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.cpp +29 -0
  106. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.hpp +61 -2
  107. package/nitrogen/generated/shared/c++/L2CAPChannel.hpp +92 -0
  108. package/nitrogen/generated/shared/c++/MultipeerDiscoveryInfoEntry.hpp +87 -0
  109. package/nitrogen/generated/shared/c++/MultipeerEncryptionPreference.hpp +80 -0
  110. package/nitrogen/generated/shared/c++/MultipeerPeer.hpp +102 -0
  111. package/nitrogen/generated/shared/c++/MultipeerPeerState.hpp +80 -0
  112. package/nitrogen/generated/shared/c++/MultipeerSessionOptions.hpp +114 -0
  113. package/nitrogen/generated/shared/c++/PhyStatus.hpp +88 -0
  114. package/package.json +23 -12
  115. package/src/index.ts +416 -31
  116. package/src/specs/munim-bluetooth.nitro.ts +298 -14
@@ -5,6 +5,13 @@ import android.app.NotificationChannel
5
5
  import android.app.NotificationManager
6
6
  import android.app.Service
7
7
  import android.bluetooth.BluetoothAdapter
8
+ import android.bluetooth.BluetoothDevice
9
+ import android.bluetooth.BluetoothGatt
10
+ import android.bluetooth.BluetoothGattCharacteristic
11
+ import android.bluetooth.BluetoothGattDescriptor
12
+ import android.bluetooth.BluetoothGattServer
13
+ import android.bluetooth.BluetoothGattServerCallback
14
+ import android.bluetooth.BluetoothGattService
8
15
  import android.bluetooth.BluetoothManager
9
16
  import android.bluetooth.le.AdvertiseCallback
10
17
  import android.bluetooth.le.AdvertiseData
@@ -22,18 +29,37 @@ import android.os.IBinder
22
29
  import android.os.ParcelUuid
23
30
  import android.util.Log
24
31
  import com.margelo.nitro.munimbluetooth.ScanMode
32
+ import org.json.JSONArray
25
33
  import java.util.Locale
34
+ import java.util.UUID
26
35
 
27
36
  class MunimBluetoothBackgroundService : Service() {
37
+ private data class SessionConfig(
38
+ val serviceUUIDs: Array<String>,
39
+ val localName: String?,
40
+ val allowDuplicates: Boolean,
41
+ val scanMode: ScanMode,
42
+ val gattServicesJson: String?,
43
+ val restoreGattOnStart: Boolean,
44
+ val notificationChannelId: String,
45
+ val notificationChannelName: String,
46
+ val notificationTitle: String,
47
+ val notificationText: String
48
+ )
49
+
28
50
  private var bluetoothManager: BluetoothManager? = null
29
51
  private var bluetoothAdapter: BluetoothAdapter? = null
30
52
  private var advertiser: BluetoothLeAdvertiser? = null
31
53
  private var advertiseCallback: AdvertiseCallback? = null
32
54
  private var scanner: BluetoothLeScanner? = null
33
55
  private var scanCallback: ScanCallback? = null
56
+ private var gattServer: BluetoothGattServer? = null
34
57
  private var previousAdapterName: String? = null
35
58
 
36
59
  private val discoveredDeviceIds = linkedSetOf<String>()
60
+ private val characteristicValues = mutableMapOf<String, ByteArray>()
61
+ private val descriptorValues = mutableMapOf<String, ByteArray>()
62
+ private val subscribedDevices = mutableMapOf<UUID, MutableSet<BluetoothDevice>>()
37
63
  private var notificationChannelId = DEFAULT_CHANNEL_ID
38
64
  private var notificationChannelName = DEFAULT_CHANNEL_NAME
39
65
  private var notificationTitle = DEFAULT_NOTIFICATION_TITLE
@@ -42,51 +68,34 @@ class MunimBluetoothBackgroundService : Service() {
42
68
  override fun onBind(intent: Intent?): IBinder? = null
43
69
 
44
70
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
45
- when (intent?.action) {
46
- ACTION_STOP -> {
47
- stopSelf()
48
- return START_NOT_STICKY
49
- }
50
-
51
- ACTION_START -> {
52
- notificationChannelId =
53
- intent.getStringExtra(EXTRA_NOTIFICATION_CHANNEL_ID)
54
- ?: DEFAULT_CHANNEL_ID
55
- notificationChannelName =
56
- intent.getStringExtra(EXTRA_NOTIFICATION_CHANNEL_NAME)
57
- ?: DEFAULT_CHANNEL_NAME
58
- notificationTitle =
59
- intent.getStringExtra(EXTRA_NOTIFICATION_TITLE)
60
- ?: DEFAULT_NOTIFICATION_TITLE
61
- notificationText =
62
- intent.getStringExtra(EXTRA_NOTIFICATION_TEXT)
63
- ?: DEFAULT_NOTIFICATION_TEXT
64
-
65
- startForeground(
66
- NOTIFICATION_ID,
67
- buildNotification(neighborCount = discoveredDeviceIds.size)
68
- )
71
+ if (intent?.action == ACTION_STOP) {
72
+ clearPersistedSession()
73
+ stopSelf()
74
+ return START_NOT_STICKY
75
+ }
69
76
 
70
- val serviceUUIDs =
71
- intent.getStringArrayExtra(EXTRA_SERVICE_UUIDS) ?: emptyArray()
72
- val localName = intent.getStringExtra(EXTRA_LOCAL_NAME)
73
- val allowDuplicates =
74
- intent.getBooleanExtra(EXTRA_ALLOW_DUPLICATES, false)
75
- val scanMode = parseScanMode(
76
- intent.getStringExtra(EXTRA_SCAN_MODE) ?: ScanMode.LOWPOWER.name
77
- )
77
+ val config = when (intent?.action) {
78
+ ACTION_START -> sessionConfigFromIntent(intent).also(::persistSession)
79
+ else -> readPersistedSession()
80
+ }
78
81
 
79
- startBleSession(
80
- serviceUUIDs = serviceUUIDs,
81
- localName = localName,
82
- allowDuplicates = allowDuplicates,
83
- scanMode = scanMode
84
- )
85
- return START_STICKY
86
- }
82
+ if (config == null) {
83
+ Log.w(TAG, "No persisted background BLE session to restart")
84
+ return START_NOT_STICKY
87
85
  }
88
86
 
89
- return START_NOT_STICKY
87
+ notificationChannelId = config.notificationChannelId
88
+ notificationChannelName = config.notificationChannelName
89
+ notificationTitle = config.notificationTitle
90
+ notificationText = config.notificationText
91
+
92
+ startForeground(
93
+ NOTIFICATION_ID,
94
+ buildNotification(neighborCount = discoveredDeviceIds.size)
95
+ )
96
+
97
+ startBleSession(config)
98
+ return START_STICKY
90
99
  }
91
100
 
92
101
  override fun onDestroy() {
@@ -94,12 +103,9 @@ class MunimBluetoothBackgroundService : Service() {
94
103
  super.onDestroy()
95
104
  }
96
105
 
97
- private fun startBleSession(
98
- serviceUUIDs: Array<String>,
99
- localName: String?,
100
- allowDuplicates: Boolean,
101
- scanMode: ScanMode
102
- ) {
106
+ private fun startBleSession(config: SessionConfig) {
107
+ stopBleSession()
108
+
103
109
  if (!BluetoothPermissionUtils.hasRequiredPermissions(applicationContext)) {
104
110
  Log.w(TAG, "Unable to start background BLE session: missing runtime permissions")
105
111
  stopSelf()
@@ -124,7 +130,7 @@ class MunimBluetoothBackgroundService : Service() {
124
130
  return
125
131
  }
126
132
 
127
- if (!localName.isNullOrBlank() && previousAdapterName == null) {
133
+ if (!config.localName.isNullOrBlank() && previousAdapterName == null) {
128
134
  previousAdapterName = try {
129
135
  adapter.name
130
136
  } catch (error: SecurityException) {
@@ -133,14 +139,17 @@ class MunimBluetoothBackgroundService : Service() {
133
139
  }
134
140
 
135
141
  try {
136
- adapter.name = localName
142
+ adapter.name = config.localName
137
143
  } catch (error: SecurityException) {
138
144
  Log.w(TAG, "Unable to set adapter name for background advertising", error)
139
145
  }
140
146
  }
141
147
 
142
- startAdvertising(adapter, serviceUUIDs, !localName.isNullOrBlank())
143
- startScan(adapter, serviceUUIDs, allowDuplicates, scanMode)
148
+ if (config.restoreGattOnStart) {
149
+ startGattServer(config.gattServicesJson)
150
+ }
151
+ startAdvertising(adapter, config.serviceUUIDs, !config.localName.isNullOrBlank())
152
+ startScan(adapter, config.serviceUUIDs, config.allowDuplicates, config.scanMode)
144
153
  }
145
154
 
146
155
  private fun stopBleSession() {
@@ -154,6 +163,12 @@ class MunimBluetoothBackgroundService : Service() {
154
163
  scanCallback = null
155
164
  scanner = null
156
165
 
166
+ gattServer?.close()
167
+ gattServer = null
168
+ characteristicValues.clear()
169
+ descriptorValues.clear()
170
+ subscribedDevices.clear()
171
+
157
172
  advertiseCallback?.let { callback ->
158
173
  try {
159
174
  advertiser?.stopAdvertising(callback)
@@ -189,12 +204,14 @@ class MunimBluetoothBackgroundService : Service() {
189
204
  advertiser = activeAdvertiser
190
205
 
191
206
  val data = AdvertiseData.Builder()
192
- .setIncludeDeviceName(includeDeviceName)
193
207
 
194
208
  serviceUUIDs.forEach { uuid ->
195
209
  runCatching { ParcelUuid.fromString(uuid) }.getOrNull()?.let(data::addServiceUuid)
196
210
  }
197
211
 
212
+ val scanResponse = AdvertiseData.Builder()
213
+ .setIncludeDeviceName(includeDeviceName)
214
+
198
215
  val settings = AdvertiseSettings.Builder()
199
216
  .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
200
217
  .setConnectable(true)
@@ -213,12 +230,277 @@ class MunimBluetoothBackgroundService : Service() {
213
230
  }
214
231
 
215
232
  try {
216
- activeAdvertiser.startAdvertising(settings, data.build(), advertiseCallback)
233
+ activeAdvertiser.startAdvertising(
234
+ settings,
235
+ data.build(),
236
+ scanResponse.build(),
237
+ advertiseCallback
238
+ )
217
239
  } catch (error: SecurityException) {
218
240
  Log.w(TAG, "Unable to start background advertising", error)
219
241
  }
220
242
  }
221
243
 
244
+ private fun startGattServer(gattServicesJson: String?) {
245
+ if (gattServicesJson.isNullOrBlank()) {
246
+ return
247
+ }
248
+
249
+ val manager = bluetoothManager ?: return
250
+ gattServer = try {
251
+ manager.openGattServer(applicationContext, buildGattServerCallback())
252
+ } catch (error: SecurityException) {
253
+ Log.w(TAG, "Unable to open background GATT server", error)
254
+ null
255
+ }
256
+
257
+ val server = gattServer ?: return
258
+ runCatching {
259
+ val services = JSONArray(gattServicesJson)
260
+ val nativeServices = linkedMapOf<String, BluetoothGattService>()
261
+ for (index in 0 until services.length()) {
262
+ val serviceJson = services.getJSONObject(index)
263
+ val service = BluetoothGattService(
264
+ UUID.fromString(serviceJson.getString("uuid")),
265
+ BluetoothGattService.SERVICE_TYPE_PRIMARY
266
+ )
267
+ val characteristics = serviceJson.optJSONArray("characteristics") ?: JSONArray()
268
+ for (characteristicIndex in 0 until characteristics.length()) {
269
+ val characteristicJson = characteristics.getJSONObject(characteristicIndex)
270
+ val characteristic = BluetoothGattCharacteristic(
271
+ UUID.fromString(characteristicJson.getString("uuid")),
272
+ propertiesFromJson(characteristicJson.optJSONArray("properties")),
273
+ BluetoothGattCharacteristic.PERMISSION_READ or
274
+ BluetoothGattCharacteristic.PERMISSION_WRITE
275
+ )
276
+ val characteristicInitialValue = optionalString(characteristicJson, "value")
277
+ ?.let { value ->
278
+ hexStringToByteArray(value) ?: value.toByteArray()
279
+ }
280
+ val descriptorInitialValues =
281
+ mutableListOf<Pair<BluetoothGattDescriptor, ByteArray>>()
282
+ characteristicInitialValue?.let { value ->
283
+ @Suppress("DEPRECATION")
284
+ characteristic.value = value
285
+ }
286
+
287
+ val descriptors = characteristicJson.optJSONArray("descriptors") ?: JSONArray()
288
+ for (descriptorIndex in 0 until descriptors.length()) {
289
+ val descriptorJson = descriptors.getJSONObject(descriptorIndex)
290
+ val descriptor = BluetoothGattDescriptor(
291
+ UUID.fromString(descriptorJson.getString("uuid")),
292
+ descriptorPermissionsFromJson(descriptorJson.optJSONArray("permissions"))
293
+ )
294
+ optionalString(descriptorJson, "value")?.let { value ->
295
+ val bytes = hexStringToByteArray(value) ?: value.toByteArray()
296
+ @Suppress("DEPRECATION")
297
+ descriptor.value = bytes
298
+ descriptorInitialValues.add(descriptor to bytes)
299
+ }
300
+ characteristic.addDescriptor(descriptor)
301
+ }
302
+
303
+ val hasClientConfigDescriptor = characteristic.descriptors.any {
304
+ it.uuid == CLIENT_CHARACTERISTIC_CONFIG_UUID
305
+ }
306
+ if (supportsNotifyOrIndicate(characteristic) && !hasClientConfigDescriptor) {
307
+ characteristic.addDescriptor(
308
+ BluetoothGattDescriptor(
309
+ CLIENT_CHARACTERISTIC_CONFIG_UUID,
310
+ BluetoothGattDescriptor.PERMISSION_READ or
311
+ BluetoothGattDescriptor.PERMISSION_WRITE
312
+ )
313
+ )
314
+ }
315
+
316
+ service.addCharacteristic(characteristic)
317
+ characteristicInitialValue?.let { value ->
318
+ setCharacteristicValue(characteristic, value)
319
+ }
320
+ descriptorInitialValues.forEach { (descriptor, value) ->
321
+ setDescriptorValue(descriptor, value)
322
+ }
323
+ }
324
+ nativeServices[serviceJson.getString("uuid").lowercase(Locale.US)] = service
325
+ }
326
+
327
+ for (index in 0 until services.length()) {
328
+ val serviceJson = services.getJSONObject(index)
329
+ val service = nativeServices[
330
+ serviceJson.getString("uuid").lowercase(Locale.US)
331
+ ] ?: continue
332
+ val includedServices = serviceJson.optJSONArray("includedServices") ?: continue
333
+ for (includedIndex in 0 until includedServices.length()) {
334
+ nativeServices[
335
+ includedServices.optString(includedIndex).lowercase(Locale.US)
336
+ ]?.let(service::addService)
337
+ }
338
+ }
339
+
340
+ nativeServices.values.forEach { service ->
341
+ server.addService(service)
342
+ }
343
+ }.onFailure { error ->
344
+ Log.w(TAG, "Unable to restore background GATT services", error)
345
+ }
346
+ }
347
+
348
+ private fun buildGattServerCallback(): BluetoothGattServerCallback {
349
+ return object : BluetoothGattServerCallback() {
350
+ override fun onCharacteristicReadRequest(
351
+ device: BluetoothDevice,
352
+ requestId: Int,
353
+ offset: Int,
354
+ characteristic: BluetoothGattCharacteristic
355
+ ) {
356
+ val value = characteristicValues[characteristicKey(characteristic)] ?: ByteArray(0)
357
+ if (offset > value.size) {
358
+ gattServer?.sendResponse(
359
+ device,
360
+ requestId,
361
+ BluetoothGatt.GATT_INVALID_OFFSET,
362
+ offset,
363
+ null
364
+ )
365
+ return
366
+ }
367
+
368
+ gattServer?.sendResponse(
369
+ device,
370
+ requestId,
371
+ BluetoothGatt.GATT_SUCCESS,
372
+ offset,
373
+ value.copyOfRange(offset, value.size)
374
+ )
375
+ }
376
+
377
+ override fun onCharacteristicWriteRequest(
378
+ device: BluetoothDevice,
379
+ requestId: Int,
380
+ characteristic: BluetoothGattCharacteristic,
381
+ preparedWrite: Boolean,
382
+ responseNeeded: Boolean,
383
+ offset: Int,
384
+ value: ByteArray
385
+ ) {
386
+ val canWrite = characteristic.properties and
387
+ (BluetoothGattCharacteristic.PROPERTY_WRITE or
388
+ BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0
389
+ if (!canWrite) {
390
+ if (responseNeeded) {
391
+ gattServer?.sendResponse(
392
+ device,
393
+ requestId,
394
+ BluetoothGatt.GATT_WRITE_NOT_PERMITTED,
395
+ offset,
396
+ null
397
+ )
398
+ }
399
+ return
400
+ }
401
+
402
+ val key = characteristicKey(characteristic)
403
+ val previous = characteristicValues[key] ?: ByteArray(0)
404
+ if (offset > previous.size) {
405
+ if (responseNeeded) {
406
+ gattServer?.sendResponse(
407
+ device,
408
+ requestId,
409
+ BluetoothGatt.GATT_INVALID_OFFSET,
410
+ offset,
411
+ null
412
+ )
413
+ }
414
+ return
415
+ }
416
+
417
+ val updated = if (offset == 0) {
418
+ value
419
+ } else {
420
+ val merged = previous.copyOf(maxOf(previous.size, offset + value.size))
421
+ System.arraycopy(value, 0, merged, offset, value.size)
422
+ merged
423
+ }
424
+ setCharacteristicValue(characteristic, updated)
425
+
426
+ if (responseNeeded) {
427
+ gattServer?.sendResponse(
428
+ device,
429
+ requestId,
430
+ BluetoothGatt.GATT_SUCCESS,
431
+ offset,
432
+ updated
433
+ )
434
+ }
435
+
436
+ notifySubscribers(characteristic, updated)
437
+ }
438
+
439
+ override fun onDescriptorReadRequest(
440
+ device: BluetoothDevice,
441
+ requestId: Int,
442
+ offset: Int,
443
+ descriptor: BluetoothGattDescriptor
444
+ ) {
445
+ val value = descriptorValues[descriptorKey(descriptor)] ?: ByteArray(0)
446
+ if (offset > value.size) {
447
+ gattServer?.sendResponse(
448
+ device,
449
+ requestId,
450
+ BluetoothGatt.GATT_INVALID_OFFSET,
451
+ offset,
452
+ null
453
+ )
454
+ return
455
+ }
456
+
457
+ gattServer?.sendResponse(
458
+ device,
459
+ requestId,
460
+ BluetoothGatt.GATT_SUCCESS,
461
+ offset,
462
+ value.copyOfRange(offset, value.size)
463
+ )
464
+ }
465
+
466
+ override fun onDescriptorWriteRequest(
467
+ device: BluetoothDevice,
468
+ requestId: Int,
469
+ descriptor: BluetoothGattDescriptor,
470
+ preparedWrite: Boolean,
471
+ responseNeeded: Boolean,
472
+ offset: Int,
473
+ value: ByteArray
474
+ ) {
475
+ setDescriptorValue(descriptor, value)
476
+
477
+ if (descriptor.uuid == CLIENT_CHARACTERISTIC_CONFIG_UUID) {
478
+ val characteristic = descriptor.characteristic
479
+ val subscribers = subscribedDevices.getOrPut(characteristic.uuid) {
480
+ mutableSetOf()
481
+ }
482
+ val enabled = value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) ||
483
+ value.contentEquals(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE)
484
+ if (enabled) {
485
+ subscribers.add(device)
486
+ } else {
487
+ subscribers.remove(device)
488
+ }
489
+ }
490
+
491
+ if (responseNeeded) {
492
+ gattServer?.sendResponse(
493
+ device,
494
+ requestId,
495
+ BluetoothGatt.GATT_SUCCESS,
496
+ offset,
497
+ value
498
+ )
499
+ }
500
+ }
501
+ }
502
+ }
503
+
222
504
  private fun startScan(
223
505
  adapter: BluetoothAdapter,
224
506
  serviceUUIDs: Array<String>,
@@ -292,6 +574,7 @@ class MunimBluetoothBackgroundService : Service() {
292
574
  )
293
575
  }
294
576
 
577
+ @Suppress("DEPRECATION")
295
578
  private fun buildNotification(neighborCount: Int): Notification {
296
579
  ensureNotificationChannel()
297
580
 
@@ -347,6 +630,218 @@ class MunimBluetoothBackgroundService : Service() {
347
630
  return runCatching { ScanMode.valueOf(rawValue) }.getOrElse { ScanMode.LOWPOWER }
348
631
  }
349
632
 
633
+ private fun sessionConfigFromIntent(intent: Intent): SessionConfig {
634
+ return SessionConfig(
635
+ serviceUUIDs = intent.getStringArrayExtra(EXTRA_SERVICE_UUIDS) ?: emptyArray(),
636
+ localName = intent.getStringExtra(EXTRA_LOCAL_NAME),
637
+ allowDuplicates = intent.getBooleanExtra(EXTRA_ALLOW_DUPLICATES, false),
638
+ scanMode = parseScanMode(
639
+ intent.getStringExtra(EXTRA_SCAN_MODE) ?: ScanMode.LOWPOWER.name
640
+ ),
641
+ gattServicesJson = intent.getStringExtra(EXTRA_GATT_SERVICES_JSON),
642
+ restoreGattOnStart = false,
643
+ notificationChannelId = intent.getStringExtra(EXTRA_NOTIFICATION_CHANNEL_ID)
644
+ ?: DEFAULT_CHANNEL_ID,
645
+ notificationChannelName = intent.getStringExtra(EXTRA_NOTIFICATION_CHANNEL_NAME)
646
+ ?: DEFAULT_CHANNEL_NAME,
647
+ notificationTitle = intent.getStringExtra(EXTRA_NOTIFICATION_TITLE)
648
+ ?: DEFAULT_NOTIFICATION_TITLE,
649
+ notificationText = intent.getStringExtra(EXTRA_NOTIFICATION_TEXT)
650
+ ?: DEFAULT_NOTIFICATION_TEXT
651
+ )
652
+ }
653
+
654
+ private fun persistSession(config: SessionConfig) {
655
+ getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
656
+ .edit()
657
+ .putString(PREF_SERVICE_UUIDS, config.serviceUUIDs.joinToString("\n"))
658
+ .putString(PREF_LOCAL_NAME, config.localName)
659
+ .putBoolean(PREF_ALLOW_DUPLICATES, config.allowDuplicates)
660
+ .putString(PREF_SCAN_MODE, config.scanMode.name)
661
+ .putString(PREF_GATT_SERVICES_JSON, config.gattServicesJson)
662
+ .putString(PREF_NOTIFICATION_CHANNEL_ID, config.notificationChannelId)
663
+ .putString(PREF_NOTIFICATION_CHANNEL_NAME, config.notificationChannelName)
664
+ .putString(PREF_NOTIFICATION_TITLE, config.notificationTitle)
665
+ .putString(PREF_NOTIFICATION_TEXT, config.notificationText)
666
+ .apply()
667
+ }
668
+
669
+ private fun readPersistedSession(): SessionConfig? {
670
+ val preferences = getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
671
+ if (!preferences.contains(PREF_SERVICE_UUIDS)) {
672
+ return null
673
+ }
674
+
675
+ return SessionConfig(
676
+ serviceUUIDs = preferences.getString(PREF_SERVICE_UUIDS, "")
677
+ ?.split('\n')
678
+ ?.filter { it.isNotBlank() }
679
+ ?.toTypedArray()
680
+ ?: emptyArray(),
681
+ localName = preferences.getString(PREF_LOCAL_NAME, null),
682
+ allowDuplicates = preferences.getBoolean(PREF_ALLOW_DUPLICATES, false),
683
+ scanMode = parseScanMode(
684
+ preferences.getString(PREF_SCAN_MODE, ScanMode.LOWPOWER.name)
685
+ ?: ScanMode.LOWPOWER.name
686
+ ),
687
+ gattServicesJson = preferences.getString(PREF_GATT_SERVICES_JSON, null),
688
+ restoreGattOnStart = true,
689
+ notificationChannelId = preferences.getString(
690
+ PREF_NOTIFICATION_CHANNEL_ID,
691
+ DEFAULT_CHANNEL_ID
692
+ ) ?: DEFAULT_CHANNEL_ID,
693
+ notificationChannelName = preferences.getString(
694
+ PREF_NOTIFICATION_CHANNEL_NAME,
695
+ DEFAULT_CHANNEL_NAME
696
+ ) ?: DEFAULT_CHANNEL_NAME,
697
+ notificationTitle = preferences.getString(
698
+ PREF_NOTIFICATION_TITLE,
699
+ DEFAULT_NOTIFICATION_TITLE
700
+ ) ?: DEFAULT_NOTIFICATION_TITLE,
701
+ notificationText = preferences.getString(
702
+ PREF_NOTIFICATION_TEXT,
703
+ DEFAULT_NOTIFICATION_TEXT
704
+ ) ?: DEFAULT_NOTIFICATION_TEXT
705
+ )
706
+ }
707
+
708
+ private fun clearPersistedSession() {
709
+ getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
710
+ .edit()
711
+ .clear()
712
+ .apply()
713
+ }
714
+
715
+ private fun propertiesFromJson(properties: JSONArray?): Int {
716
+ var result = 0
717
+ forEachString(properties) { property ->
718
+ when (property) {
719
+ "read" -> result = result or BluetoothGattCharacteristic.PROPERTY_READ
720
+ "write" -> result = result or BluetoothGattCharacteristic.PROPERTY_WRITE
721
+ "notify" -> result = result or BluetoothGattCharacteristic.PROPERTY_NOTIFY
722
+ "indicate" -> result = result or BluetoothGattCharacteristic.PROPERTY_INDICATE
723
+ "writeWithoutResponse" -> {
724
+ result = result or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
725
+ }
726
+ }
727
+ }
728
+ return result
729
+ }
730
+
731
+ private fun descriptorPermissionsFromJson(permissions: JSONArray?): Int {
732
+ if (permissions == null || permissions.length() == 0) {
733
+ return BluetoothGattDescriptor.PERMISSION_READ or
734
+ BluetoothGattDescriptor.PERMISSION_WRITE
735
+ }
736
+
737
+ var result = 0
738
+ forEachString(permissions) { permission ->
739
+ when (permission) {
740
+ "read" -> result = result or BluetoothGattDescriptor.PERMISSION_READ
741
+ "write" -> result = result or BluetoothGattDescriptor.PERMISSION_WRITE
742
+ "readEncrypted" -> result = result or
743
+ BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED
744
+ "writeEncrypted" -> result = result or
745
+ BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED
746
+ "readEncryptedMitm" -> result = result or
747
+ BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED_MITM
748
+ "writeEncryptedMitm" -> result = result or
749
+ BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED_MITM
750
+ }
751
+ }
752
+ return result
753
+ }
754
+
755
+ private fun forEachString(array: JSONArray?, block: (String) -> Unit) {
756
+ if (array == null) return
757
+ for (index in 0 until array.length()) {
758
+ block(array.optString(index))
759
+ }
760
+ }
761
+
762
+ private fun optionalString(json: org.json.JSONObject, key: String): String? {
763
+ return if (json.has(key) && !json.isNull(key)) json.getString(key) else null
764
+ }
765
+
766
+ private fun supportsNotifyOrIndicate(characteristic: BluetoothGattCharacteristic): Boolean {
767
+ return characteristic.properties and
768
+ (BluetoothGattCharacteristic.PROPERTY_NOTIFY or
769
+ BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0
770
+ }
771
+
772
+ private fun setCharacteristicValue(
773
+ characteristic: BluetoothGattCharacteristic,
774
+ value: ByteArray
775
+ ) {
776
+ characteristicValues[characteristicKey(characteristic)] = value
777
+ @Suppress("DEPRECATION")
778
+ characteristic.value = value
779
+ }
780
+
781
+ private fun setDescriptorValue(descriptor: BluetoothGattDescriptor, value: ByteArray) {
782
+ descriptorValues[descriptorKey(descriptor)] = value
783
+ @Suppress("DEPRECATION")
784
+ descriptor.value = value
785
+ }
786
+
787
+ private fun notifySubscribers(
788
+ characteristic: BluetoothGattCharacteristic,
789
+ value: ByteArray
790
+ ) {
791
+ val subscribers = subscribedDevices[characteristic.uuid]?.toList().orEmpty()
792
+ subscribers.forEach { device ->
793
+ try {
794
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
795
+ gattServer?.notifyCharacteristicChanged(
796
+ device,
797
+ characteristic,
798
+ characteristic.properties and
799
+ BluetoothGattCharacteristic.PROPERTY_INDICATE != 0,
800
+ value
801
+ )
802
+ } else {
803
+ @Suppress("DEPRECATION")
804
+ characteristic.value = value
805
+ @Suppress("DEPRECATION")
806
+ gattServer?.notifyCharacteristicChanged(
807
+ device,
808
+ characteristic,
809
+ characteristic.properties and
810
+ BluetoothGattCharacteristic.PROPERTY_INDICATE != 0
811
+ )
812
+ }
813
+ } catch (error: SecurityException) {
814
+ Log.w(TAG, "Unable to notify background GATT subscriber", error)
815
+ }
816
+ }
817
+ }
818
+
819
+ private fun characteristicKey(characteristic: BluetoothGattCharacteristic): String {
820
+ return "${characteristic.service?.uuid}|${characteristic.uuid}".lowercase(Locale.US)
821
+ }
822
+
823
+ private fun descriptorKey(descriptor: BluetoothGattDescriptor): String {
824
+ val characteristic = descriptor.characteristic
825
+ ?: return "unknown|${descriptor.uuid}".lowercase(Locale.US)
826
+ return "${characteristicKey(characteristic)}|${descriptor.uuid}"
827
+ .lowercase(Locale.US)
828
+ }
829
+
830
+ private fun hexStringToByteArray(hex: String): ByteArray? {
831
+ val cleanHex = hex.removePrefix("0x")
832
+ if (cleanHex.isEmpty() || cleanHex.length % 2 != 0) {
833
+ return null
834
+ }
835
+
836
+ return try {
837
+ ByteArray(cleanHex.length / 2) { index ->
838
+ cleanHex.substring(index * 2, index * 2 + 2).toInt(16).toByte()
839
+ }
840
+ } catch (_: NumberFormatException) {
841
+ null
842
+ }
843
+ }
844
+
350
845
  companion object {
351
846
  const val ACTION_START = "com.munimbluetooth.action.START_BACKGROUND_SESSION"
352
847
  const val ACTION_STOP = "com.munimbluetooth.action.STOP_BACKGROUND_SESSION"
@@ -355,6 +850,7 @@ class MunimBluetoothBackgroundService : Service() {
355
850
  const val EXTRA_LOCAL_NAME = "localName"
356
851
  const val EXTRA_ALLOW_DUPLICATES = "allowDuplicates"
357
852
  const val EXTRA_SCAN_MODE = "scanMode"
853
+ const val EXTRA_GATT_SERVICES_JSON = "gattServicesJson"
358
854
  const val EXTRA_NOTIFICATION_CHANNEL_ID = "notificationChannelId"
359
855
  const val EXTRA_NOTIFICATION_CHANNEL_NAME = "notificationChannelName"
360
856
  const val EXTRA_NOTIFICATION_TITLE = "notificationTitle"
@@ -367,5 +863,17 @@ class MunimBluetoothBackgroundService : Service() {
367
863
 
368
864
  private const val NOTIFICATION_ID = 48231
369
865
  private const val TAG = "MunimBluetoothBgSvc"
866
+ private const val PREFERENCES_NAME = "munim-bluetooth-background"
867
+ private const val PREF_SERVICE_UUIDS = "serviceUUIDs"
868
+ private const val PREF_LOCAL_NAME = "localName"
869
+ private const val PREF_ALLOW_DUPLICATES = "allowDuplicates"
870
+ private const val PREF_SCAN_MODE = "scanMode"
871
+ private const val PREF_GATT_SERVICES_JSON = "gattServicesJson"
872
+ private const val PREF_NOTIFICATION_CHANNEL_ID = "notificationChannelId"
873
+ private const val PREF_NOTIFICATION_CHANNEL_NAME = "notificationChannelName"
874
+ private const val PREF_NOTIFICATION_TITLE = "notificationTitle"
875
+ private const val PREF_NOTIFICATION_TEXT = "notificationText"
876
+ private val CLIENT_CHARACTERISTIC_CONFIG_UUID =
877
+ UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
370
878
  }
371
879
  }