munim-bluetooth 0.3.26 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +476 -74
  3. package/android/gradle.properties +2 -2
  4. package/android/src/main/AndroidManifest.xml +3 -1
  5. package/android/src/main/cpp/cpp-adapter.cpp +4 -1
  6. package/android/src/main/java/com/munimbluetooth/BluetoothPermissionUtils.kt +40 -0
  7. package/android/src/main/java/com/munimbluetooth/HybridMunimBluetooth.kt +2116 -217
  8. package/android/src/main/java/com/munimbluetooth/MunimBluetoothBackgroundService.kt +591 -56
  9. package/app.plugin.js +155 -0
  10. package/ios/HybridMunimBluetooth.swift +2123 -298
  11. package/ios/MunimBluetoothEventEmitter.swift +68 -8
  12. package/lib/commonjs/index.js +272 -11
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/module/index.js +243 -11
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/index.d.ts +310 -7
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts +219 -5
  19. package/lib/typescript/src/specs/munim-bluetooth.nitro.d.ts.map +1 -1
  20. package/nitro.json +9 -3
  21. package/nitrogen/generated/android/c++/JAdvertisingDataTypes.hpp +96 -96
  22. package/nitrogen/generated/android/c++/JAdvertisingOptions.hpp +8 -8
  23. package/nitrogen/generated/android/c++/JBackgroundSessionOptions.hpp +8 -8
  24. package/nitrogen/generated/android/c++/JBluetoothCapabilities.hpp +105 -0
  25. package/nitrogen/generated/android/c++/JBluetoothPhy.hpp +61 -0
  26. package/nitrogen/generated/android/c++/JBluetoothPhyOption.hpp +61 -0
  27. package/nitrogen/generated/android/c++/JBondState.hpp +64 -0
  28. package/nitrogen/generated/android/c++/JDescriptorValue.hpp +69 -0
  29. package/nitrogen/generated/android/c++/JExtendedAdvertisingOptions.hpp +131 -0
  30. package/nitrogen/generated/android/c++/JGATTCharacteristic.hpp +35 -11
  31. package/nitrogen/generated/android/c++/JGATTDescriptor.hpp +85 -0
  32. package/nitrogen/generated/android/c++/JGATTService.hpp +33 -9
  33. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.cpp +422 -12
  34. package/nitrogen/generated/android/c++/JHybridMunimBluetoothSpec.hpp +29 -0
  35. package/nitrogen/generated/android/c++/JL2CAPChannel.hpp +66 -0
  36. package/nitrogen/generated/android/c++/JMultipeerDiscoveryInfoEntry.hpp +61 -0
  37. package/nitrogen/generated/android/c++/JMultipeerEncryptionPreference.hpp +61 -0
  38. package/nitrogen/generated/android/c++/JMultipeerPeer.hpp +93 -0
  39. package/nitrogen/generated/android/c++/JMultipeerPeerState.hpp +61 -0
  40. package/nitrogen/generated/android/c++/JMultipeerSessionOptions.hpp +105 -0
  41. package/nitrogen/generated/android/c++/JPhyStatus.hpp +62 -0
  42. package/nitrogen/generated/android/c++/JScanOptions.hpp +8 -8
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingDataTypes.kt +47 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/AdvertisingOptions.kt +19 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BackgroundSessionOptions.kt +27 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothCapabilities.kt +111 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhy.kt +24 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BluetoothPhyOption.kt +24 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/BondState.kt +25 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/CharacteristicValue.kt +17 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/DescriptorValue.kt +66 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ExtendedAdvertisingOptions.kt +111 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTCharacteristic.kt +25 -3
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTDescriptor.kt +61 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/GATTService.kt +23 -3
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/HybridMunimBluetoothSpec.kt +138 -22
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/L2CAPChannel.kt +61 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerDiscoveryInfoEntry.kt +56 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerEncryptionPreference.kt +24 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeer.kt +66 -0
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerPeerState.kt +24 -0
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/MultipeerSessionOptions.kt +81 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/PhyStatus.kt +56 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ScanOptions.kt +17 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/munimbluetooth/ServiceDataEntry.kt +15 -0
  66. package/nitrogen/generated/ios/MunimBluetooth+autolinking.rb +2 -0
  67. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.cpp +61 -5
  68. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Bridge.hpp +494 -49
  69. package/nitrogen/generated/ios/MunimBluetooth-Swift-Cxx-Umbrella.hpp +42 -0
  70. package/nitrogen/generated/ios/c++/HybridMunimBluetoothSpecSwift.hpp +254 -0
  71. package/nitrogen/generated/ios/swift/BluetoothCapabilities.swift +89 -0
  72. package/nitrogen/generated/ios/swift/BluetoothPhy.swift +44 -0
  73. package/nitrogen/generated/ios/swift/BluetoothPhyOption.swift +44 -0
  74. package/nitrogen/generated/ios/swift/BondState.swift +48 -0
  75. package/nitrogen/generated/ios/swift/DescriptorValue.swift +44 -0
  76. package/nitrogen/generated/ios/swift/ExtendedAdvertisingOptions.swift +243 -0
  77. package/nitrogen/generated/ios/swift/Func_void_BluetoothCapabilities.swift +46 -0
  78. package/nitrogen/generated/ios/swift/Func_void_BondState.swift +46 -0
  79. package/nitrogen/generated/ios/swift/Func_void_DescriptorValue.swift +46 -0
  80. package/nitrogen/generated/ios/swift/Func_void_L2CAPChannel.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_PhyStatus.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  83. package/nitrogen/generated/ios/swift/Func_void_std__vector_MultipeerPeer_.swift +46 -0
  84. package/nitrogen/generated/ios/swift/GATTCharacteristic.swift +25 -1
  85. package/nitrogen/generated/ios/swift/GATTDescriptor.swift +71 -0
  86. package/nitrogen/generated/ios/swift/GATTService.swift +25 -1
  87. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec.swift +29 -0
  88. package/nitrogen/generated/ios/swift/HybridMunimBluetoothSpec_cxx.swift +556 -23
  89. package/nitrogen/generated/ios/swift/L2CAPChannel.swift +52 -0
  90. package/nitrogen/generated/ios/swift/MultipeerDiscoveryInfoEntry.swift +34 -0
  91. package/nitrogen/generated/ios/swift/MultipeerEncryptionPreference.swift +44 -0
  92. package/nitrogen/generated/ios/swift/MultipeerPeer.swift +63 -0
  93. package/nitrogen/generated/ios/swift/MultipeerPeerState.swift +44 -0
  94. package/nitrogen/generated/ios/swift/MultipeerSessionOptions.swift +136 -0
  95. package/nitrogen/generated/ios/swift/PhyStatus.swift +34 -0
  96. package/nitrogen/generated/shared/c++/BluetoothCapabilities.hpp +131 -0
  97. package/nitrogen/generated/shared/c++/BluetoothPhy.hpp +80 -0
  98. package/nitrogen/generated/shared/c++/BluetoothPhyOption.hpp +80 -0
  99. package/nitrogen/generated/shared/c++/BondState.hpp +84 -0
  100. package/nitrogen/generated/shared/c++/DescriptorValue.hpp +95 -0
  101. package/nitrogen/generated/shared/c++/ExtendedAdvertisingOptions.hpp +138 -0
  102. package/nitrogen/generated/shared/c++/GATTCharacteristic.hpp +9 -3
  103. package/nitrogen/generated/shared/c++/GATTDescriptor.hpp +93 -0
  104. package/nitrogen/generated/shared/c++/GATTService.hpp +7 -2
  105. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.cpp +29 -0
  106. package/nitrogen/generated/shared/c++/HybridMunimBluetoothSpec.hpp +61 -2
  107. package/nitrogen/generated/shared/c++/L2CAPChannel.hpp +92 -0
  108. package/nitrogen/generated/shared/c++/MultipeerDiscoveryInfoEntry.hpp +87 -0
  109. package/nitrogen/generated/shared/c++/MultipeerEncryptionPreference.hpp +80 -0
  110. package/nitrogen/generated/shared/c++/MultipeerPeer.hpp +102 -0
  111. package/nitrogen/generated/shared/c++/MultipeerPeerState.hpp +80 -0
  112. package/nitrogen/generated/shared/c++/MultipeerSessionOptions.hpp +114 -0
  113. package/nitrogen/generated/shared/c++/PhyStatus.hpp +88 -0
  114. package/package.json +22 -11
  115. package/src/index.ts +416 -31
  116. package/src/specs/munim-bluetooth.nitro.ts +298 -14
@@ -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,34 +103,53 @@ 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
+
109
+ if (!BluetoothPermissionUtils.hasRequiredPermissions(applicationContext)) {
110
+ Log.w(TAG, "Unable to start background BLE session: missing runtime permissions")
111
+ stopSelf()
112
+ return
113
+ }
114
+
103
115
  bluetoothManager =
104
116
  applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
105
117
  bluetoothAdapter = bluetoothManager?.adapter
106
118
 
107
119
  val adapter = bluetoothAdapter
108
- if (adapter == null || !adapter.isEnabled) {
120
+ val isEnabled = try {
121
+ adapter?.isEnabled == true
122
+ } catch (error: SecurityException) {
123
+ Log.w(TAG, "Unable to inspect Bluetooth adapter state for background session", error)
124
+ false
125
+ }
126
+
127
+ if (adapter == null || !isEnabled) {
109
128
  Log.w(TAG, "Unable to start background BLE session: Bluetooth unavailable")
110
129
  stopSelf()
111
130
  return
112
131
  }
113
132
 
114
- if (!localName.isNullOrBlank() && previousAdapterName == null) {
115
- previousAdapterName = adapter.name
133
+ if (!config.localName.isNullOrBlank() && previousAdapterName == null) {
134
+ previousAdapterName = try {
135
+ adapter.name
136
+ } catch (error: SecurityException) {
137
+ Log.w(TAG, "Unable to read adapter name for background advertising", error)
138
+ null
139
+ }
140
+
116
141
  try {
117
- adapter.name = localName
142
+ adapter.name = config.localName
118
143
  } catch (error: SecurityException) {
119
144
  Log.w(TAG, "Unable to set adapter name for background advertising", error)
120
145
  }
121
146
  }
122
147
 
123
- startAdvertising(adapter, serviceUUIDs, !localName.isNullOrBlank())
124
- 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)
125
153
  }
126
154
 
127
155
  private fun stopBleSession() {
@@ -135,6 +163,12 @@ class MunimBluetoothBackgroundService : Service() {
135
163
  scanCallback = null
136
164
  scanner = null
137
165
 
166
+ gattServer?.close()
167
+ gattServer = null
168
+ characteristicValues.clear()
169
+ descriptorValues.clear()
170
+ subscribedDevices.clear()
171
+
138
172
  advertiseCallback?.let { callback ->
139
173
  try {
140
174
  advertiser?.stopAdvertising(callback)
@@ -170,12 +204,14 @@ class MunimBluetoothBackgroundService : Service() {
170
204
  advertiser = activeAdvertiser
171
205
 
172
206
  val data = AdvertiseData.Builder()
173
- .setIncludeDeviceName(includeDeviceName)
174
207
 
175
208
  serviceUUIDs.forEach { uuid ->
176
209
  runCatching { ParcelUuid.fromString(uuid) }.getOrNull()?.let(data::addServiceUuid)
177
210
  }
178
211
 
212
+ val scanResponse = AdvertiseData.Builder()
213
+ .setIncludeDeviceName(includeDeviceName)
214
+
179
215
  val settings = AdvertiseSettings.Builder()
180
216
  .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
181
217
  .setConnectable(true)
@@ -193,7 +229,276 @@ class MunimBluetoothBackgroundService : Service() {
193
229
  }
194
230
  }
195
231
 
196
- activeAdvertiser.startAdvertising(settings, data.build(), advertiseCallback)
232
+ try {
233
+ activeAdvertiser.startAdvertising(
234
+ settings,
235
+ data.build(),
236
+ scanResponse.build(),
237
+ advertiseCallback
238
+ )
239
+ } catch (error: SecurityException) {
240
+ Log.w(TAG, "Unable to start background advertising", error)
241
+ }
242
+ }
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
+ }
197
502
  }
198
503
 
199
504
  private fun startScan(
@@ -242,7 +547,11 @@ class MunimBluetoothBackgroundService : Service() {
242
547
  }
243
548
  }
244
549
 
245
- activeScanner.startScan(filters, settingsBuilder.build(), scanCallback)
550
+ try {
551
+ activeScanner.startScan(filters, settingsBuilder.build(), scanCallback)
552
+ } catch (error: SecurityException) {
553
+ Log.w(TAG, "Unable to start background scan", error)
554
+ }
246
555
  }
247
556
 
248
557
  private fun handleScanResult(result: ScanResult, allowDuplicates: Boolean) {
@@ -265,6 +574,7 @@ class MunimBluetoothBackgroundService : Service() {
265
574
  )
266
575
  }
267
576
 
577
+ @Suppress("DEPRECATION")
268
578
  private fun buildNotification(neighborCount: Int): Notification {
269
579
  ensureNotificationChannel()
270
580
 
@@ -320,6 +630,218 @@ class MunimBluetoothBackgroundService : Service() {
320
630
  return runCatching { ScanMode.valueOf(rawValue) }.getOrElse { ScanMode.LOWPOWER }
321
631
  }
322
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
+
323
845
  companion object {
324
846
  const val ACTION_START = "com.munimbluetooth.action.START_BACKGROUND_SESSION"
325
847
  const val ACTION_STOP = "com.munimbluetooth.action.STOP_BACKGROUND_SESSION"
@@ -328,6 +850,7 @@ class MunimBluetoothBackgroundService : Service() {
328
850
  const val EXTRA_LOCAL_NAME = "localName"
329
851
  const val EXTRA_ALLOW_DUPLICATES = "allowDuplicates"
330
852
  const val EXTRA_SCAN_MODE = "scanMode"
853
+ const val EXTRA_GATT_SERVICES_JSON = "gattServicesJson"
331
854
  const val EXTRA_NOTIFICATION_CHANNEL_ID = "notificationChannelId"
332
855
  const val EXTRA_NOTIFICATION_CHANNEL_NAME = "notificationChannelName"
333
856
  const val EXTRA_NOTIFICATION_TITLE = "notificationTitle"
@@ -340,5 +863,17 @@ class MunimBluetoothBackgroundService : Service() {
340
863
 
341
864
  private const val NOTIFICATION_ID = 48231
342
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")
343
878
  }
344
879
  }