expo-beacon 0.1.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 (42) hide show
  1. package/README.md +514 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/AndroidManifest.xml +57 -0
  4. package/android/src/main/java/expo/modules/beacon/BeaconEventReceiver.kt +41 -0
  5. package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +300 -0
  6. package/android/src/main/java/expo/modules/beacon/BootReceiver.kt +18 -0
  7. package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +329 -0
  8. package/build/ExpoBeacon.types.d.ts +53 -0
  9. package/build/ExpoBeacon.types.d.ts.map +1 -0
  10. package/build/ExpoBeacon.types.js +2 -0
  11. package/build/ExpoBeacon.types.js.map +1 -0
  12. package/build/ExpoBeaconModule.d.ts +46 -0
  13. package/build/ExpoBeaconModule.d.ts.map +1 -0
  14. package/build/ExpoBeaconModule.js +3 -0
  15. package/build/ExpoBeaconModule.js.map +1 -0
  16. package/build/ExpoBeaconModule.web.d.ts +16 -0
  17. package/build/ExpoBeaconModule.web.d.ts.map +1 -0
  18. package/build/ExpoBeaconModule.web.js +18 -0
  19. package/build/ExpoBeaconModule.web.js.map +1 -0
  20. package/build/ExpoBeaconView.d.ts +2 -0
  21. package/build/ExpoBeaconView.d.ts.map +1 -0
  22. package/build/ExpoBeaconView.js +2 -0
  23. package/build/ExpoBeaconView.js.map +1 -0
  24. package/build/ExpoBeaconView.web.d.ts +2 -0
  25. package/build/ExpoBeaconView.web.d.ts.map +1 -0
  26. package/build/ExpoBeaconView.web.js +2 -0
  27. package/build/ExpoBeaconView.web.js.map +1 -0
  28. package/build/index.d.ts +3 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +3 -0
  31. package/build/index.js.map +1 -0
  32. package/expo-module.config.json +9 -0
  33. package/ios/ExpoBeacon.podspec +32 -0
  34. package/ios/ExpoBeaconModule.swift +432 -0
  35. package/ios/ExpoBeaconView.swift +5 -0
  36. package/package.json +67 -0
  37. package/src/ExpoBeacon.types.ts +57 -0
  38. package/src/ExpoBeaconModule.ts +64 -0
  39. package/src/ExpoBeaconModule.web.ts +31 -0
  40. package/src/ExpoBeaconView.tsx +2 -0
  41. package/src/ExpoBeaconView.web.tsx +2 -0
  42. package/src/index.ts +11 -0
@@ -0,0 +1,300 @@
1
+ package expo.modules.beacon
2
+
3
+ import android.app.*
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.SharedPreferences
7
+ import android.content.pm.ServiceInfo
8
+ import android.os.Build
9
+ import android.os.IBinder
10
+ import android.os.RemoteException
11
+ import android.util.Log
12
+ import androidx.core.app.NotificationCompat
13
+ import androidx.core.app.NotificationManagerCompat
14
+ import org.altbeacon.beacon.*
15
+ import org.json.JSONArray
16
+
17
+ private const val PREFS_NAME = "expo.beacon.paired"
18
+ private const val PREFS_KEY = "paired_beacons"
19
+ private const val CHANNEL_ID = "expo_beacon_channel"
20
+ private const val FOREGROUND_NOTIF_ID = 1001
21
+ private const val ENTER_EXIT_NOTIF_ID = 1002
22
+
23
+ class BeaconForegroundService : Service(), BeaconConsumer {
24
+
25
+ private lateinit var beaconManager: BeaconManager
26
+ private val monitoredRegions = mutableListOf<Region>()
27
+
28
+ // Distance filtering
29
+ @Volatile private var maxDistance: Double? = null
30
+ private val rangingRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
31
+ private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
32
+
33
+ // Distance logging
34
+ private val distanceLogRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
35
+
36
+ companion object {
37
+ private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
38
+
39
+ fun start(context: Context) {
40
+ context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
41
+ .edit().putBoolean("active", true).apply()
42
+ val intent = Intent(context, BeaconForegroundService::class.java)
43
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
44
+ context.startForegroundService(intent)
45
+ } else {
46
+ context.startService(intent)
47
+ }
48
+ }
49
+
50
+ fun stop(context: Context) {
51
+ context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
52
+ .edit().putBoolean("active", false).apply()
53
+ context.stopService(Intent(context, BeaconForegroundService::class.java))
54
+ }
55
+
56
+ fun isMonitoringActive(context: Context): Boolean {
57
+ return context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
58
+ .getBoolean("active", false)
59
+ }
60
+ }
61
+
62
+ override fun onCreate() {
63
+ super.onCreate()
64
+ createNotificationChannel()
65
+ beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
66
+ // Register iBeacon parser if not already registered
67
+ if (manager.beaconParsers.none { it.layout?.contains("0215") == true }) {
68
+ manager.beaconParsers.add(
69
+ BeaconParser().setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24,d:25-25")
70
+ )
71
+ }
72
+ // Use continuous scanning (not JobScheduler) for foreground service
73
+ manager.setEnableScheduledScanJobs(false)
74
+ manager.setBackgroundBetweenScanPeriod(5000L) // 5s between scans
75
+ manager.setBackgroundScanPeriod(1100L) // 1.1s scan window
76
+ manager.setForegroundScanPeriod(1000L) // 1s scan window for distance logging
77
+ manager.setForegroundBetweenScanPeriod(0L) // no pause between scans
78
+ }
79
+ }
80
+
81
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
82
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
83
+ startForeground(
84
+ FOREGROUND_NOTIF_ID,
85
+ buildForegroundNotification(),
86
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
87
+ )
88
+ } else {
89
+ startForeground(FOREGROUND_NOTIF_ID, buildForegroundNotification())
90
+ }
91
+ beaconManager.bind(this)
92
+ return START_STICKY
93
+ }
94
+
95
+ override fun onBeaconServiceConnect() {
96
+ // Read max distance from options prefs
97
+ val optPrefs = getSharedPreferences("expo.beacon.monitoring_options", Context.MODE_PRIVATE)
98
+ maxDistance = if (optPrefs.contains("max_distance"))
99
+ optPrefs.getFloat("max_distance", Float.MAX_VALUE).toDouble()
100
+ else null
101
+
102
+ beaconManager.addMonitorNotifier(monitorNotifier)
103
+ beaconManager.addRangeNotifier(rangeNotifier)
104
+ beaconManager.addRangeNotifier(distanceLoggingRangeNotifier)
105
+ loadAndMonitorRegions()
106
+ }
107
+
108
+ private fun loadAndMonitorRegions() {
109
+ val prefs: SharedPreferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
110
+ val json = prefs.getString(PREFS_KEY, "[]") ?: "[]"
111
+ val beacons = try { JSONArray(json) } catch (_: Exception) { JSONArray() }
112
+
113
+ // Stop previous regions and distance-log ranging
114
+ distanceLogRegions.forEach {
115
+ try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
116
+ }
117
+ distanceLogRegions.clear()
118
+ monitoredRegions.forEach {
119
+ try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
120
+ }
121
+ monitoredRegions.clear()
122
+
123
+ for (i in 0 until beacons.length()) {
124
+ val b = beacons.getJSONObject(i)
125
+ val region = Region(
126
+ b.getString("identifier"),
127
+ Identifier.parse(b.getString("uuid")),
128
+ Identifier.fromInt(b.getInt("major")),
129
+ Identifier.fromInt(b.getInt("minor"))
130
+ )
131
+ monitoredRegions.add(region)
132
+ try {
133
+ beaconManager.startMonitoringBeaconsInRegion(region)
134
+ } catch (e: RemoteException) {
135
+ e.printStackTrace()
136
+ }
137
+ // Start ranging this region for distance logging
138
+ if (distanceLogRegions.add(region)) {
139
+ try {
140
+ beaconManager.startRangingBeaconsInRegion(region)
141
+ } catch (e: RemoteException) {
142
+ distanceLogRegions.remove(region)
143
+ e.printStackTrace()
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
150
+ val closest = beacons.filter { it.distance >= 0 }.minByOrNull { it.distance }
151
+ if (closest != null) {
152
+ Log.d("BeaconMonitor", "[${region.uniqueId}] distance: ${"%.2f".format(closest.distance)} m rssi=${closest.rssi} txPower=${closest.txPower}")
153
+ sendBeaconBroadcast(region, "distance", closest.distance)
154
+
155
+ val maxDist = maxDistance
156
+ if (maxDist != null) {
157
+ if (!enteredRegions.contains(region.uniqueId) && closest.distance <= maxDist) {
158
+ // Distance-based entry
159
+ Log.d("BeaconMonitor", "[${region.uniqueId}] distance ${closest.distance}m <= maxDistance ${maxDist}m — synthesizing enter")
160
+ enteredRegions.add(region.uniqueId)
161
+ rangingRegions.remove(region)
162
+ sendBeaconBroadcast(region, "enter", closest.distance)
163
+ showEnterExitNotification(region, "enter")
164
+ } else if (enteredRegions.contains(region.uniqueId) && closest.distance > maxDist) {
165
+ // Distance-based exit
166
+ Log.d("BeaconMonitor", "[${region.uniqueId}] distance ${closest.distance}m > maxDistance ${maxDist}m — synthesizing exit")
167
+ enteredRegions.remove(region.uniqueId)
168
+ rangingRegions.add(region)
169
+ sendBeaconBroadcast(region, "exit", closest.distance)
170
+ showEnterExitNotification(region, "exit")
171
+ }
172
+ }
173
+ } else {
174
+ Log.d("BeaconMonitor", "[${region.uniqueId}] no beacons in range")
175
+ }
176
+ }
177
+
178
+ private val monitorNotifier = object : MonitorNotifier {
179
+ override fun didEnterRegion(region: Region) {
180
+ val maxDist = maxDistance
181
+ if (maxDist != null) {
182
+ // Mark region for distance confirmation — ranging is already active via distance logging
183
+ rangingRegions.add(region)
184
+ } else {
185
+ enteredRegions.add(region.uniqueId)
186
+ sendBeaconBroadcast(region, "enter", -1.0)
187
+ showEnterExitNotification(region, "enter")
188
+ }
189
+ }
190
+
191
+ override fun didExitRegion(region: Region) {
192
+ // Remove from confirmation tracking (ranging stays active for distance logging)
193
+ rangingRegions.remove(region)
194
+ enteredRegions.remove(region.uniqueId)
195
+ sendBeaconBroadcast(region, "exit", -1.0)
196
+ showEnterExitNotification(region, "exit")
197
+ }
198
+
199
+ override fun didDetermineStateForRegion(state: Int, region: Region) {}
200
+ }
201
+
202
+ private val rangeNotifier = RangeNotifier { beacons, region ->
203
+ val maxDist = maxDistance ?: return@RangeNotifier
204
+ if (!rangingRegions.contains(region)) return@RangeNotifier
205
+
206
+ // Find the matching beacon (best distance reading)
207
+ val beacon = beacons
208
+ .filter { it.distance >= 0 }
209
+ .minByOrNull { it.distance } ?: return@RangeNotifier
210
+
211
+ if (beacon.distance <= maxDist && !enteredRegions.contains(region.uniqueId)) {
212
+ enteredRegions.add(region.uniqueId)
213
+ // Remove from confirmation tracking (ranging stays active for distance logging)
214
+ rangingRegions.remove(region)
215
+ sendBeaconBroadcast(region, "enter", beacon.distance)
216
+ showEnterExitNotification(region, "enter")
217
+ }
218
+ }
219
+
220
+ private fun sendBeaconBroadcast(region: Region, eventType: String, distance: Double) {
221
+ val intent = Intent(ACTION_BEACON_EVENT).apply {
222
+ putExtra("identifier", region.uniqueId)
223
+ putExtra("uuid", region.id1?.toString() ?: "")
224
+ putExtra("major", region.id2?.toInt() ?: 0)
225
+ putExtra("minor", region.id3?.toInt() ?: 0)
226
+ putExtra("event", eventType)
227
+ putExtra("distance", distance)
228
+ setPackage(packageName)
229
+ }
230
+ sendBroadcast(intent)
231
+ }
232
+
233
+ private fun showEnterExitNotification(region: Region, eventType: String) {
234
+ val title = if (eventType == "enter") "Beacon Entered" else "Beacon Exited"
235
+ val message = "${region.uniqueId} region ${eventType}ed"
236
+
237
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
238
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
239
+ .setContentTitle(title)
240
+ .setContentText(message)
241
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
242
+ .setAutoCancel(true)
243
+ .build()
244
+
245
+ try {
246
+ NotificationManagerCompat.from(this).notify(ENTER_EXIT_NOTIF_ID, notification)
247
+ } catch (_: SecurityException) {
248
+ // POST_NOTIFICATIONS not granted — silently skip notification
249
+ }
250
+ }
251
+
252
+ private fun createNotificationChannel() {
253
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
254
+ val channel = NotificationChannel(
255
+ CHANNEL_ID,
256
+ "Beacon Monitoring",
257
+ NotificationManager.IMPORTANCE_LOW
258
+ ).apply {
259
+ description = "Used for background iBeacon region monitoring"
260
+ }
261
+ getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
262
+ }
263
+ }
264
+
265
+ private fun buildForegroundNotification(): Notification {
266
+ return NotificationCompat.Builder(this, CHANNEL_ID)
267
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
268
+ .setContentTitle("Beacon Monitoring Active")
269
+ .setContentText("Monitoring for iBeacons in the background")
270
+ .setPriority(NotificationCompat.PRIORITY_LOW)
271
+ .setOngoing(true)
272
+ .build()
273
+ }
274
+
275
+ override fun onDestroy() {
276
+ beaconManager.removeMonitorNotifier(monitorNotifier)
277
+ beaconManager.removeRangeNotifier(rangeNotifier)
278
+ beaconManager.removeRangeNotifier(distanceLoggingRangeNotifier)
279
+ rangingRegions.forEach {
280
+ try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
281
+ }
282
+ rangingRegions.clear()
283
+ distanceLogRegions.forEach {
284
+ try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
285
+ }
286
+ distanceLogRegions.clear()
287
+ enteredRegions.clear()
288
+ monitoredRegions.forEach {
289
+ try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
290
+ }
291
+ beaconManager.unbind(this)
292
+ super.onDestroy()
293
+ }
294
+
295
+ override fun onBind(intent: Intent?): IBinder? = null
296
+
297
+ override fun getApplicationContext(): Context = super.getApplicationContext()
298
+ }
299
+
300
+ const val ACTION_BEACON_EVENT = "expo.modules.beacon.BEACON_EVENT"
@@ -0,0 +1,18 @@
1
+ package expo.modules.beacon
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+
7
+ /**
8
+ * Restarts beacon monitoring after device reboot if it was active before shutdown.
9
+ */
10
+ class BootReceiver : BroadcastReceiver() {
11
+ override fun onReceive(context: Context, intent: Intent) {
12
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
13
+ if (BeaconForegroundService.isMonitoringActive(context)) {
14
+ BeaconForegroundService.start(context)
15
+ }
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,329 @@
1
+ package expo.modules.beacon
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.IntentFilter
7
+ import android.content.ServiceConnection
8
+ import android.content.SharedPreferences
9
+ import android.content.pm.PackageManager
10
+ import android.os.Build
11
+ import android.os.RemoteException
12
+ import androidx.core.content.ContextCompat
13
+ import expo.modules.interfaces.permissions.PermissionsStatus
14
+ import expo.modules.kotlin.modules.Module
15
+ import expo.modules.kotlin.modules.ModuleDefinition
16
+ import expo.modules.kotlin.Promise
17
+ import org.altbeacon.beacon.*
18
+ import kotlinx.coroutines.*
19
+ import org.json.JSONArray
20
+ import org.json.JSONObject
21
+
22
+ private const val PREFS_NAME = "expo.beacon.paired"
23
+ private const val PREFS_KEY = "paired_beacons"
24
+
25
+ class ExpoBeaconModule : Module(), BeaconConsumer {
26
+
27
+ private val beaconManager: BeaconManager by lazy {
28
+ BeaconManager.getInstanceForApplication(appContext.reactContext!!).also { manager ->
29
+ // Register iBeacon layout parser
30
+ manager.beaconParsers.add(
31
+ BeaconParser().setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24,d:25-25")
32
+ )
33
+ }
34
+ }
35
+
36
+ private val prefs: SharedPreferences by lazy {
37
+ appContext.reactContext!!.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
38
+ }
39
+
40
+ // Coroutine scope tied to module lifecycle
41
+ private val moduleScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
42
+
43
+ // BroadcastReceiver bridge from BeaconForegroundService to JS events
44
+ private var eventReceiver: BeaconEventReceiver? = null
45
+
46
+ // Current one-shot scan state
47
+ private var scanPromise: Promise? = null
48
+ private var scanJob: Job? = null
49
+ private val scanResults = mutableListOf<Beacon>()
50
+ private var isBoundForScan = false
51
+
52
+ // Continuous scan state
53
+ private var continuousScanActive = false
54
+ private val continuousScanRegion = Region("continuousScanRegion", null, null, null)
55
+
56
+ override fun definition() = ModuleDefinition {
57
+ Name("ExpoBeacon")
58
+
59
+ Events("onBeaconEnter", "onBeaconExit", "onBeaconRanging", "onBeaconDistance", "onBeaconFound")
60
+
61
+ AsyncFunction("scanForBeaconsAsync") { scanDurationMs: Int, promise: Promise ->
62
+ if (scanPromise != null) {
63
+ promise.reject("SCAN_IN_PROGRESS", "A scan is already running", null)
64
+ return@AsyncFunction
65
+ }
66
+ scanResults.clear()
67
+ scanPromise = promise
68
+
69
+ beaconManager.addRangeNotifier(scanRangeNotifier)
70
+
71
+ if (!isBoundForScan) {
72
+ isBoundForScan = true
73
+ beaconManager.bind(this@ExpoBeaconModule)
74
+ } else {
75
+ startScanRanging()
76
+ }
77
+
78
+ // Resolve after duration
79
+ scanJob = moduleScope.launch {
80
+ delay(scanDurationMs.toLong())
81
+ stopScanAndResolve()
82
+ }
83
+ }
84
+
85
+ Function("startContinuousScan") {
86
+ if (!continuousScanActive) {
87
+ continuousScanActive = true
88
+ beaconManager.addRangeNotifier(continuousScanRangeNotifier)
89
+ if (!isBoundForScan) {
90
+ isBoundForScan = true
91
+ beaconManager.bind(this@ExpoBeaconModule)
92
+ } else {
93
+ startContinuousRanging()
94
+ }
95
+ }
96
+ null
97
+ }
98
+
99
+ Function("stopContinuousScan") {
100
+ if (continuousScanActive) {
101
+ continuousScanActive = false
102
+ try {
103
+ beaconManager.stopRangingBeaconsInRegion(continuousScanRegion)
104
+ } catch (_: RemoteException) {}
105
+ beaconManager.removeRangeNotifier(continuousScanRangeNotifier)
106
+ }
107
+ null
108
+ }
109
+
110
+ Function("pairBeacon") { identifier: String, uuid: String, major: Int, minor: Int ->
111
+ val beacons = loadPairedBeaconsJson()
112
+ // Remove duplicate if exists
113
+ val filtered = (0 until beacons.length())
114
+ .map { beacons.getJSONObject(it) }
115
+ .filter { it.getString("identifier") != identifier }
116
+
117
+ val arr = JSONArray()
118
+ filtered.forEach { arr.put(it) }
119
+ val newBeacon = JSONObject().apply {
120
+ put("identifier", identifier)
121
+ put("uuid", uuid)
122
+ put("major", major)
123
+ put("minor", minor)
124
+ }
125
+ arr.put(newBeacon)
126
+ prefs.edit().putString(PREFS_KEY, arr.toString()).apply()
127
+ }
128
+
129
+ Function("unpairBeacon") { identifier: String ->
130
+ val beacons = loadPairedBeaconsJson()
131
+ val filtered = (0 until beacons.length())
132
+ .map { beacons.getJSONObject(it) }
133
+ .filter { it.getString("identifier") != identifier }
134
+ val arr = JSONArray()
135
+ filtered.forEach { arr.put(it) }
136
+ prefs.edit().putString(PREFS_KEY, arr.toString()).apply()
137
+ }
138
+
139
+ Function("getPairedBeacons") {
140
+ val beacons = loadPairedBeaconsJson()
141
+ (0 until beacons.length()).map { i ->
142
+ val b = beacons.getJSONObject(i)
143
+ mapOf(
144
+ "identifier" to b.getString("identifier"),
145
+ "uuid" to b.getString("uuid"),
146
+ "major" to b.getInt("major"),
147
+ "minor" to b.getInt("minor")
148
+ )
149
+ }
150
+ }
151
+
152
+ AsyncFunction("startMonitoring") { maxDistance: Double?, promise: Promise ->
153
+ val ctx = appContext.reactContext!!
154
+ ctx.getSharedPreferences("expo.beacon.monitoring_options", Context.MODE_PRIVATE)
155
+ .edit().apply {
156
+ if (maxDistance != null) putFloat("max_distance", maxDistance.toFloat())
157
+ else remove("max_distance")
158
+ }.apply()
159
+ registerEventReceiver()
160
+ BeaconForegroundService.start(ctx)
161
+ promise.resolve(null)
162
+ }
163
+
164
+ AsyncFunction("stopMonitoring") { promise: Promise ->
165
+ BeaconForegroundService.stop(appContext.reactContext!!)
166
+ unregisterEventReceiver()
167
+ promise.resolve(null)
168
+ }
169
+
170
+ AsyncFunction("requestPermissionsAsync") { promise: Promise ->
171
+ val required = buildList {
172
+ add(Manifest.permission.ACCESS_FINE_LOCATION)
173
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
174
+ add(Manifest.permission.BLUETOOTH_SCAN)
175
+ add(Manifest.permission.BLUETOOTH_CONNECT)
176
+ }
177
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
178
+ add(Manifest.permission.POST_NOTIFICATIONS)
179
+ }
180
+ }
181
+
182
+ val permissionsManager = appContext.permissions
183
+ if (permissionsManager == null) {
184
+ val context = appContext.reactContext ?: run {
185
+ promise.resolve(false)
186
+ return@AsyncFunction
187
+ }
188
+ val allGranted = required.all {
189
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
190
+ }
191
+ promise.resolve(allGranted)
192
+ return@AsyncFunction
193
+ }
194
+
195
+ permissionsManager.askForPermissions({ results ->
196
+ val allGranted = required.all { perm ->
197
+ results[perm]?.status == PermissionsStatus.GRANTED
198
+ }
199
+ promise.resolve(allGranted)
200
+ }, *required.toTypedArray())
201
+ }
202
+
203
+ OnDestroy {
204
+ this@ExpoBeaconModule.unregisterEventReceiver()
205
+ this@ExpoBeaconModule.scanJob?.cancel()
206
+ this@ExpoBeaconModule.moduleScope.cancel()
207
+ if (this@ExpoBeaconModule.continuousScanActive) {
208
+ this@ExpoBeaconModule.continuousScanActive = false
209
+ try { this@ExpoBeaconModule.beaconManager.stopRangingBeaconsInRegion(this@ExpoBeaconModule.continuousScanRegion) } catch (_: RemoteException) {}
210
+ this@ExpoBeaconModule.beaconManager.removeRangeNotifier(this@ExpoBeaconModule.continuousScanRangeNotifier)
211
+ }
212
+ if (this@ExpoBeaconModule.isBoundForScan) {
213
+ this@ExpoBeaconModule.beaconManager.unbind(this@ExpoBeaconModule)
214
+ this@ExpoBeaconModule.isBoundForScan = false
215
+ }
216
+ }
217
+ }
218
+
219
+ // --- BeaconConsumer (for scan binding) ---
220
+
221
+ override fun onBeaconServiceConnect() {
222
+ if (scanPromise != null) startScanRanging()
223
+ if (continuousScanActive) startContinuousRanging()
224
+ }
225
+
226
+ override fun getApplicationContext(): Context {
227
+ return appContext.reactContext!!
228
+ }
229
+
230
+ private fun startScanRanging() {
231
+ try {
232
+ beaconManager.startRangingBeaconsInRegion(
233
+ Region("scanRegion", null, null, null)
234
+ )
235
+ } catch (e: RemoteException) {
236
+ scanPromise?.reject("SCAN_ERROR", e.message, e)
237
+ scanPromise = null
238
+ }
239
+ }
240
+
241
+ private val scanRangeNotifier = RangeNotifier { beacons, _ ->
242
+ scanResults.addAll(beacons)
243
+ }
244
+
245
+ private val continuousScanRangeNotifier = RangeNotifier { beacons, _ ->
246
+ beacons.forEach { beacon ->
247
+ sendEvent("onBeaconFound", mapOf(
248
+ "uuid" to beacon.id1.toString().uppercase(),
249
+ "major" to beacon.id2.toInt(),
250
+ "minor" to beacon.id3.toInt(),
251
+ "rssi" to beacon.rssi,
252
+ "distance" to beacon.distance,
253
+ "txPower" to beacon.txPower
254
+ ))
255
+ }
256
+ }
257
+
258
+ private fun startContinuousRanging() {
259
+ try {
260
+ beaconManager.startRangingBeaconsInRegion(continuousScanRegion)
261
+ } catch (e: RemoteException) {
262
+ continuousScanActive = false
263
+ beaconManager.removeRangeNotifier(continuousScanRangeNotifier)
264
+ }
265
+ }
266
+
267
+ private fun stopScanAndResolve() {
268
+ try {
269
+ beaconManager.stopRangingBeaconsInRegion(Region("scanRegion", null, null, null))
270
+ } catch (_: RemoteException) {}
271
+ beaconManager.removeRangeNotifier(scanRangeNotifier)
272
+
273
+ val results = scanResults.distinctBy { "${it.id1}:${it.id2}:${it.id3}" }.map { beacon ->
274
+ mapOf(
275
+ "uuid" to beacon.id1.toString().uppercase(),
276
+ "major" to beacon.id2.toInt(),
277
+ "minor" to beacon.id3.toInt(),
278
+ "rssi" to beacon.rssi,
279
+ "distance" to beacon.distance,
280
+ "txPower" to beacon.txPower
281
+ )
282
+ }
283
+ scanPromise?.resolve(results)
284
+ scanPromise = null
285
+ }
286
+
287
+ // --- Shared Preferences helpers ---
288
+
289
+ private fun loadPairedBeaconsJson(): JSONArray {
290
+ val json = prefs.getString(PREFS_KEY, "[]") ?: "[]"
291
+ return try { JSONArray(json) } catch (_: Exception) { JSONArray() }
292
+ }
293
+
294
+ // --- Event receiver registration ---
295
+
296
+ private fun registerEventReceiver() {
297
+ if (eventReceiver != null) return
298
+ val receiver = BeaconEventReceiver { eventName, params ->
299
+ sendEvent(eventName, params)
300
+ }
301
+ eventReceiver = receiver
302
+ val context = appContext.reactContext ?: return
303
+ val filter = IntentFilter(ACTION_BEACON_EVENT)
304
+ ContextCompat.registerReceiver(
305
+ context,
306
+ receiver,
307
+ filter,
308
+ ContextCompat.RECEIVER_NOT_EXPORTED
309
+ )
310
+ }
311
+
312
+ private fun unregisterEventReceiver() {
313
+ val receiver = eventReceiver ?: return
314
+ try {
315
+ appContext.reactContext?.unregisterReceiver(receiver)
316
+ } catch (_: IllegalArgumentException) {}
317
+ eventReceiver = null
318
+ }
319
+
320
+ // --- BeaconConsumer binding delegation ---
321
+
322
+ override fun bindService(intent: Intent, connection: ServiceConnection, mode: Int): Boolean {
323
+ return appContext.reactContext!!.bindService(intent, connection, mode)
324
+ }
325
+
326
+ override fun unbindService(connection: ServiceConnection) {
327
+ appContext.reactContext!!.unbindService(connection)
328
+ }
329
+ }