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.
- package/README.md +514 -0
- package/android/build.gradle +23 -0
- package/android/src/main/AndroidManifest.xml +57 -0
- package/android/src/main/java/expo/modules/beacon/BeaconEventReceiver.kt +41 -0
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +300 -0
- package/android/src/main/java/expo/modules/beacon/BootReceiver.kt +18 -0
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +329 -0
- package/build/ExpoBeacon.types.d.ts +53 -0
- package/build/ExpoBeacon.types.d.ts.map +1 -0
- package/build/ExpoBeacon.types.js +2 -0
- package/build/ExpoBeacon.types.js.map +1 -0
- package/build/ExpoBeaconModule.d.ts +46 -0
- package/build/ExpoBeaconModule.d.ts.map +1 -0
- package/build/ExpoBeaconModule.js +3 -0
- package/build/ExpoBeaconModule.js.map +1 -0
- package/build/ExpoBeaconModule.web.d.ts +16 -0
- package/build/ExpoBeaconModule.web.d.ts.map +1 -0
- package/build/ExpoBeaconModule.web.js +18 -0
- package/build/ExpoBeaconModule.web.js.map +1 -0
- package/build/ExpoBeaconView.d.ts +2 -0
- package/build/ExpoBeaconView.d.ts.map +1 -0
- package/build/ExpoBeaconView.js +2 -0
- package/build/ExpoBeaconView.js.map +1 -0
- package/build/ExpoBeaconView.web.d.ts +2 -0
- package/build/ExpoBeaconView.web.d.ts.map +1 -0
- package/build/ExpoBeaconView.web.js +2 -0
- package/build/ExpoBeaconView.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoBeacon.podspec +32 -0
- package/ios/ExpoBeaconModule.swift +432 -0
- package/ios/ExpoBeaconView.swift +5 -0
- package/package.json +67 -0
- package/src/ExpoBeacon.types.ts +57 -0
- package/src/ExpoBeaconModule.ts +64 -0
- package/src/ExpoBeaconModule.web.ts +31 -0
- package/src/ExpoBeaconView.tsx +2 -0
- package/src/ExpoBeaconView.web.tsx +2 -0
- 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
|
+
}
|