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,432 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import CoreLocation
|
|
3
|
+
import UserNotifications
|
|
4
|
+
|
|
5
|
+
private let PAIRED_BEACONS_KEY = "expo.beacon.paired"
|
|
6
|
+
private let IS_MONITORING_KEY = "expo.beacon.is_monitoring"
|
|
7
|
+
private let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
|
|
8
|
+
|
|
9
|
+
public class ExpoBeaconModule: Module, CLLocationManagerDelegate {
|
|
10
|
+
|
|
11
|
+
private lazy var locationManager: CLLocationManager = {
|
|
12
|
+
let manager = CLLocationManager()
|
|
13
|
+
manager.delegate = self
|
|
14
|
+
manager.allowsBackgroundLocationUpdates = true
|
|
15
|
+
manager.pausesLocationUpdatesAutomatically = false
|
|
16
|
+
return manager
|
|
17
|
+
}()
|
|
18
|
+
|
|
19
|
+
// One-shot scan state
|
|
20
|
+
private var scanPromise: Promise?
|
|
21
|
+
private var scannedBeacons: [CLBeacon] = []
|
|
22
|
+
private var scanConstraint: CLBeaconIdentityConstraint?
|
|
23
|
+
private var scanRegion: CLBeaconRegion?
|
|
24
|
+
|
|
25
|
+
// Monitored regions
|
|
26
|
+
private var monitoredRegions: [CLBeaconRegion] = []
|
|
27
|
+
|
|
28
|
+
// Always-on ranging for distance events + distance-based enter/exit (identifier → constraint)
|
|
29
|
+
private var distanceRangingConstraints: [String: CLBeaconIdentityConstraint] = [:]
|
|
30
|
+
// Identifiers currently in "entered" state (used for distance-driven enter/exit)
|
|
31
|
+
private var enteredRegions: Set<String> = []
|
|
32
|
+
|
|
33
|
+
// Continuous scan state
|
|
34
|
+
private var continuousScanActive = false
|
|
35
|
+
// Constraints started exclusively for continuous scan (not shared with distance ranging)
|
|
36
|
+
private var continuousScanOnlyConstraints: [CLBeaconIdentityConstraint] = []
|
|
37
|
+
|
|
38
|
+
// Permission callback
|
|
39
|
+
private var permissionCompletion: ((Bool) -> Void)?
|
|
40
|
+
|
|
41
|
+
public func definition() -> ModuleDefinition {
|
|
42
|
+
Name("ExpoBeacon")
|
|
43
|
+
|
|
44
|
+
Events("onBeaconEnter", "onBeaconExit", "onBeaconRanging", "onBeaconDistance", "onBeaconFound")
|
|
45
|
+
|
|
46
|
+
// MARK: - Scan
|
|
47
|
+
|
|
48
|
+
AsyncFunction("scanForBeaconsAsync") { (scanDurationMs: Int, promise: Promise) in
|
|
49
|
+
guard self.scanPromise == nil else {
|
|
50
|
+
promise.reject("SCAN_IN_PROGRESS", "A scan is already in progress")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
self.scanPromise = promise
|
|
54
|
+
self.scannedBeacons = []
|
|
55
|
+
|
|
56
|
+
self.requestLocationPermission { granted in
|
|
57
|
+
guard granted else {
|
|
58
|
+
promise.reject("PERMISSION_DENIED", "Location permission required for beacon scanning")
|
|
59
|
+
self.scanPromise = nil
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// iOS ranging requires a specific UUID; use a placeholder for generic scan
|
|
64
|
+
let placeholderUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
|
65
|
+
let region = CLBeaconRegion(uuid: placeholderUUID, identifier: "scan_wildcard")
|
|
66
|
+
self.scanRegion = region
|
|
67
|
+
self.scanConstraint = CLBeaconIdentityConstraint(uuid: placeholderUUID)
|
|
68
|
+
self.locationManager.startRangingBeacons(satisfying: self.scanConstraint!)
|
|
69
|
+
|
|
70
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(scanDurationMs)) {
|
|
71
|
+
self.stopScanAndResolve()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - Pair
|
|
77
|
+
|
|
78
|
+
Function("pairBeacon") { (identifier: String, uuid: String, major: Int, minor: Int) in
|
|
79
|
+
var beacons = self.loadPairedBeaconsRaw()
|
|
80
|
+
beacons.removeAll { ($0["identifier"] as? String) == identifier }
|
|
81
|
+
beacons.append([
|
|
82
|
+
"identifier": identifier,
|
|
83
|
+
"uuid": uuid,
|
|
84
|
+
"major": major,
|
|
85
|
+
"minor": minor
|
|
86
|
+
])
|
|
87
|
+
UserDefaults.standard.set(beacons, forKey: PAIRED_BEACONS_KEY)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
Function("unpairBeacon") { (identifier: String) in
|
|
91
|
+
var beacons = self.loadPairedBeaconsRaw()
|
|
92
|
+
beacons.removeAll { ($0["identifier"] as? String) == identifier }
|
|
93
|
+
UserDefaults.standard.set(beacons, forKey: PAIRED_BEACONS_KEY)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Function("getPairedBeacons") { () -> [[String: Any]] in
|
|
97
|
+
return self.loadPairedBeaconsRaw()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// MARK: - Monitoring
|
|
101
|
+
|
|
102
|
+
AsyncFunction("startMonitoring") { (maxDistance: Double?, promise: Promise) in
|
|
103
|
+
if let dist = maxDistance {
|
|
104
|
+
UserDefaults.standard.set(dist, forKey: MAX_DISTANCE_KEY)
|
|
105
|
+
} else {
|
|
106
|
+
UserDefaults.standard.removeObject(forKey: MAX_DISTANCE_KEY)
|
|
107
|
+
}
|
|
108
|
+
UserDefaults.standard.set(true, forKey: IS_MONITORING_KEY)
|
|
109
|
+
self.requestLocationPermission { granted in
|
|
110
|
+
guard granted else {
|
|
111
|
+
promise.reject("PERMISSION_DENIED", "Always location permission required for background monitoring")
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
self.requestNotificationPermission()
|
|
115
|
+
self.startRegionMonitoring()
|
|
116
|
+
promise.resolve(nil)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
AsyncFunction("stopMonitoring") { (promise: Promise) in
|
|
121
|
+
UserDefaults.standard.set(false, forKey: IS_MONITORING_KEY)
|
|
122
|
+
UserDefaults.standard.removeObject(forKey: MAX_DISTANCE_KEY)
|
|
123
|
+
self.stopRegionMonitoring()
|
|
124
|
+
promise.resolve(nil)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
AsyncFunction("requestPermissionsAsync") { (promise: Promise) in
|
|
128
|
+
self.requestLocationPermission { granted in
|
|
129
|
+
promise.resolve(granted)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// MARK: - Continuous Scan
|
|
134
|
+
|
|
135
|
+
Function("startContinuousScan") { () -> Void in
|
|
136
|
+
guard !self.continuousScanActive else { return }
|
|
137
|
+
self.continuousScanActive = true
|
|
138
|
+
self.startContinuousScanRanging()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
Function("stopContinuousScan") { () -> Void in
|
|
142
|
+
self.continuousScanActive = false
|
|
143
|
+
for constraint in self.continuousScanOnlyConstraints {
|
|
144
|
+
self.locationManager.stopRangingBeacons(satisfying: constraint)
|
|
145
|
+
}
|
|
146
|
+
self.continuousScanOnlyConstraints.removeAll()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MARK: - Private Helpers
|
|
151
|
+
|
|
152
|
+
private func requestLocationPermission(completion: @escaping (Bool) -> Void) {
|
|
153
|
+
let status = locationManager.authorizationStatus
|
|
154
|
+
switch status {
|
|
155
|
+
case .authorizedAlways:
|
|
156
|
+
completion(true)
|
|
157
|
+
case .notDetermined:
|
|
158
|
+
self.permissionCompletion = completion
|
|
159
|
+
locationManager.requestAlwaysAuthorization()
|
|
160
|
+
default:
|
|
161
|
+
// WhenInUse allows foreground ranging but not background region monitoring
|
|
162
|
+
completion(status == .authorizedWhenInUse)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func requestNotificationPermission() {
|
|
167
|
+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func startRegionMonitoring() {
|
|
171
|
+
stopRegionMonitoring()
|
|
172
|
+
|
|
173
|
+
let beacons = loadPairedBeaconsRaw()
|
|
174
|
+
for b in beacons {
|
|
175
|
+
guard
|
|
176
|
+
let identifier = b["identifier"] as? String,
|
|
177
|
+
let uuidString = b["uuid"] as? String,
|
|
178
|
+
let uuid = UUID(uuidString: uuidString),
|
|
179
|
+
let major = b["major"] as? Int,
|
|
180
|
+
let minor = b["minor"] as? Int
|
|
181
|
+
else { continue }
|
|
182
|
+
|
|
183
|
+
let region = CLBeaconRegion(
|
|
184
|
+
uuid: uuid,
|
|
185
|
+
major: CLBeaconMajorValue(major),
|
|
186
|
+
minor: CLBeaconMinorValue(minor),
|
|
187
|
+
identifier: identifier
|
|
188
|
+
)
|
|
189
|
+
region.notifyOnEntry = true
|
|
190
|
+
region.notifyOnExit = true
|
|
191
|
+
region.notifyEntryStateOnDisplay = true
|
|
192
|
+
|
|
193
|
+
monitoredRegions.append(region)
|
|
194
|
+
locationManager.startMonitoring(for: region)
|
|
195
|
+
|
|
196
|
+
// Always-on ranging for distance events + distance-driven enter/exit
|
|
197
|
+
let constraint = CLBeaconIdentityConstraint(
|
|
198
|
+
uuid: uuid,
|
|
199
|
+
major: CLBeaconMajorValue(major),
|
|
200
|
+
minor: CLBeaconMinorValue(minor)
|
|
201
|
+
)
|
|
202
|
+
distanceRangingConstraints[identifier] = constraint
|
|
203
|
+
locationManager.startRangingBeacons(satisfying: constraint)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private func stopRegionMonitoring() {
|
|
208
|
+
for region in monitoredRegions {
|
|
209
|
+
locationManager.stopMonitoring(for: region)
|
|
210
|
+
}
|
|
211
|
+
monitoredRegions.removeAll()
|
|
212
|
+
|
|
213
|
+
for constraint in distanceRangingConstraints.values {
|
|
214
|
+
locationManager.stopRangingBeacons(satisfying: constraint)
|
|
215
|
+
}
|
|
216
|
+
distanceRangingConstraints.removeAll()
|
|
217
|
+
enteredRegions.removeAll()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Start ranging for paired beacons not already covered by distance ranging
|
|
221
|
+
private func startContinuousScanRanging() {
|
|
222
|
+
let beacons = loadPairedBeaconsRaw()
|
|
223
|
+
for b in beacons {
|
|
224
|
+
guard
|
|
225
|
+
let identifier = b["identifier"] as? String,
|
|
226
|
+
let uuidString = b["uuid"] as? String,
|
|
227
|
+
let uuid = UUID(uuidString: uuidString),
|
|
228
|
+
let major = b["major"] as? Int,
|
|
229
|
+
let minor = b["minor"] as? Int
|
|
230
|
+
else { continue }
|
|
231
|
+
|
|
232
|
+
// Reuse the existing distance-ranging stream if monitoring is active
|
|
233
|
+
if distanceRangingConstraints[identifier] != nil { continue }
|
|
234
|
+
|
|
235
|
+
let constraint = CLBeaconIdentityConstraint(
|
|
236
|
+
uuid: uuid,
|
|
237
|
+
major: CLBeaconMajorValue(major),
|
|
238
|
+
minor: CLBeaconMinorValue(minor)
|
|
239
|
+
)
|
|
240
|
+
continuousScanOnlyConstraints.append(constraint)
|
|
241
|
+
locationManager.startRangingBeacons(satisfying: constraint)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private func stopScanAndResolve() {
|
|
246
|
+
if let constraint = scanConstraint {
|
|
247
|
+
locationManager.stopRangingBeacons(satisfying: constraint)
|
|
248
|
+
scanConstraint = nil
|
|
249
|
+
scanRegion = nil
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
var seen = Set<String>()
|
|
253
|
+
let results: [[String: Any]] = scannedBeacons.compactMap { beacon in
|
|
254
|
+
let key = "\(beacon.uuid):\(beacon.major):\(beacon.minor)"
|
|
255
|
+
guard !seen.contains(key) else { return nil }
|
|
256
|
+
seen.insert(key)
|
|
257
|
+
return [
|
|
258
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
259
|
+
"major": beacon.major.intValue,
|
|
260
|
+
"minor": beacon.minor.intValue,
|
|
261
|
+
"rssi": beacon.rssi,
|
|
262
|
+
"distance": beacon.accuracy,
|
|
263
|
+
"txPower": 0
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
scanPromise?.resolve(results)
|
|
268
|
+
scanPromise = nil
|
|
269
|
+
scannedBeacons = []
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func loadPairedBeaconsRaw() -> [[String: Any]] {
|
|
273
|
+
return UserDefaults.standard.array(forKey: PAIRED_BEACONS_KEY) as? [[String: Any]] ?? []
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private func postBeaconNotification(identifier: String, eventType: String) {
|
|
277
|
+
let content = UNMutableNotificationContent()
|
|
278
|
+
content.title = eventType == "enter" ? "Beacon Entered" : "Beacon Exited"
|
|
279
|
+
content.body = "\(identifier) region \(eventType)ed"
|
|
280
|
+
content.sound = .default
|
|
281
|
+
|
|
282
|
+
let request = UNNotificationRequest(
|
|
283
|
+
identifier: "beacon_\(eventType)_\(identifier)_\(Date().timeIntervalSince1970)",
|
|
284
|
+
content: content,
|
|
285
|
+
trigger: nil // deliver immediately
|
|
286
|
+
)
|
|
287
|
+
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private func constraintMatches(_ a: CLBeaconIdentityConstraint, _ b: CLBeaconIdentityConstraint) -> Bool {
|
|
291
|
+
return a.uuid == b.uuid && a.major == b.major && a.minor == b.minor
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// MARK: - CLLocationManagerDelegate
|
|
295
|
+
|
|
296
|
+
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
297
|
+
let granted = (status == .authorizedAlways || status == .authorizedWhenInUse)
|
|
298
|
+
permissionCompletion?(granted)
|
|
299
|
+
permissionCompletion = nil
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
public func locationManager(_ manager: CLLocationManager, didRangeBeacons beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) {
|
|
303
|
+
// 1. One-shot scan mode
|
|
304
|
+
if let sc = scanConstraint, constraintMatches(sc, constraint) {
|
|
305
|
+
scannedBeacons.append(contentsOf: beacons)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 2. Distance-ranging for monitored beacons
|
|
310
|
+
if let (identifier, _) = distanceRangingConstraints.first(where: { constraintMatches($0.value, constraint) }) {
|
|
311
|
+
guard let beacon = beacons.first(where: { $0.accuracy >= 0 }) else {
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Emit distance event every ranging cycle (~1 s)
|
|
316
|
+
let distParams: [String: Any] = [
|
|
317
|
+
"identifier": identifier,
|
|
318
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
319
|
+
"major": beacon.major.intValue,
|
|
320
|
+
"minor": beacon.minor.intValue,
|
|
321
|
+
"distance": beacon.accuracy
|
|
322
|
+
]
|
|
323
|
+
sendEvent("onBeaconDistance", distParams)
|
|
324
|
+
print("[ExpoBeacon] DIST: \(identifier) → \(String(format: "%.2f", beacon.accuracy))m")
|
|
325
|
+
|
|
326
|
+
// Distance-driven enter/exit synthesis
|
|
327
|
+
if let maxDist = UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) as? Double {
|
|
328
|
+
if !enteredRegions.contains(identifier) && beacon.accuracy <= maxDist {
|
|
329
|
+
enteredRegions.insert(identifier)
|
|
330
|
+
let params: [String: Any] = [
|
|
331
|
+
"identifier": identifier,
|
|
332
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
333
|
+
"major": beacon.major.intValue,
|
|
334
|
+
"minor": beacon.minor.intValue,
|
|
335
|
+
"event": "enter",
|
|
336
|
+
"distance": beacon.accuracy
|
|
337
|
+
]
|
|
338
|
+
sendEvent("onBeaconEnter", params)
|
|
339
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
340
|
+
} else if enteredRegions.contains(identifier) && beacon.accuracy > maxDist {
|
|
341
|
+
enteredRegions.remove(identifier)
|
|
342
|
+
let params: [String: Any] = [
|
|
343
|
+
"identifier": identifier,
|
|
344
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
345
|
+
"major": beacon.major.intValue,
|
|
346
|
+
"minor": beacon.minor.intValue,
|
|
347
|
+
"event": "exit",
|
|
348
|
+
"distance": beacon.accuracy
|
|
349
|
+
]
|
|
350
|
+
sendEvent("onBeaconExit", params)
|
|
351
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Also emit onBeaconFound if continuous scan is active for this beacon
|
|
356
|
+
if continuousScanActive {
|
|
357
|
+
let foundParams: [String: Any] = [
|
|
358
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
359
|
+
"major": beacon.major.intValue,
|
|
360
|
+
"minor": beacon.minor.intValue,
|
|
361
|
+
"rssi": beacon.rssi,
|
|
362
|
+
"distance": beacon.accuracy,
|
|
363
|
+
"txPower": 0
|
|
364
|
+
]
|
|
365
|
+
sendEvent("onBeaconFound", foundParams)
|
|
366
|
+
}
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 3. Continuous-scan-only constraints (monitoring not active)
|
|
371
|
+
if continuousScanActive,
|
|
372
|
+
continuousScanOnlyConstraints.contains(where: { constraintMatches($0, constraint) }) {
|
|
373
|
+
for beacon in beacons where beacon.accuracy >= 0 {
|
|
374
|
+
let params: [String: Any] = [
|
|
375
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
376
|
+
"major": beacon.major.intValue,
|
|
377
|
+
"minor": beacon.minor.intValue,
|
|
378
|
+
"rssi": beacon.rssi,
|
|
379
|
+
"distance": beacon.accuracy,
|
|
380
|
+
"txPower": 0
|
|
381
|
+
]
|
|
382
|
+
sendEvent("onBeaconFound", params)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
|
388
|
+
guard let beaconRegion = region as? CLBeaconRegion else { return }
|
|
389
|
+
let identifier = beaconRegion.identifier
|
|
390
|
+
|
|
391
|
+
// If maxDistance is set, distance ranging handles enter/exit — skip region-based emit
|
|
392
|
+
guard UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) == nil else { return }
|
|
393
|
+
|
|
394
|
+
let params: [String: Any] = [
|
|
395
|
+
"identifier": identifier,
|
|
396
|
+
"uuid": beaconRegion.uuid.uuidString.uppercased(),
|
|
397
|
+
"major": beaconRegion.major?.intValue ?? 0,
|
|
398
|
+
"minor": beaconRegion.minor?.intValue ?? 0,
|
|
399
|
+
"event": "enter",
|
|
400
|
+
"distance": -1
|
|
401
|
+
]
|
|
402
|
+
sendEvent("onBeaconEnter", params)
|
|
403
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
|
|
407
|
+
guard let beaconRegion = region as? CLBeaconRegion else { return }
|
|
408
|
+
let identifier = beaconRegion.identifier
|
|
409
|
+
|
|
410
|
+
// If maxDistance is set, distance ranging handles exit; just clean up tracked state
|
|
411
|
+
if UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) != nil {
|
|
412
|
+
enteredRegions.remove(identifier)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let params: [String: Any] = [
|
|
417
|
+
"identifier": identifier,
|
|
418
|
+
"uuid": beaconRegion.uuid.uuidString.uppercased(),
|
|
419
|
+
"major": beaconRegion.major?.intValue ?? 0,
|
|
420
|
+
"minor": beaconRegion.minor?.intValue ?? 0,
|
|
421
|
+
"event": "exit",
|
|
422
|
+
"distance": -1
|
|
423
|
+
]
|
|
424
|
+
sendEvent("onBeaconExit", params)
|
|
425
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
public func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
|
|
429
|
+
print("[ExpoBeacon] Monitoring failed for region \(region?.identifier ?? "unknown"): \(error.localizedDescription)")
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-beacon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Expo module for scanning, pairing, and monitoring iBeacons on Android and iOS",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./build/index.js",
|
|
10
|
+
"require": "./build/index.js",
|
|
11
|
+
"types": "./build/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"build/",
|
|
16
|
+
"ios/",
|
|
17
|
+
"android/src/",
|
|
18
|
+
"android/build.gradle",
|
|
19
|
+
"expo-module.config.json",
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "expo-module build",
|
|
24
|
+
"clean": "expo-module clean",
|
|
25
|
+
"lint": "expo-module lint",
|
|
26
|
+
"test": "expo-module test",
|
|
27
|
+
"prepare": "expo-module prepare",
|
|
28
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
29
|
+
"expo-module": "expo-module",
|
|
30
|
+
"open:ios": "xed example/ios",
|
|
31
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"react-native",
|
|
35
|
+
"expo",
|
|
36
|
+
"ibeacon",
|
|
37
|
+
"beacon",
|
|
38
|
+
"ble",
|
|
39
|
+
"bluetooth",
|
|
40
|
+
"ranging",
|
|
41
|
+
"monitoring",
|
|
42
|
+
"expo-beacon",
|
|
43
|
+
"ExpoBeacon"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/martinmikesCCS/expo-beacon.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/martinmikesCCS/expo-beacon/issues"
|
|
51
|
+
},
|
|
52
|
+
"author": "mikes <martin.mikes@ccs.cz> (https://github.com/martinmikesCCS)",
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"homepage": "https://github.com/martinmikesCCS/expo-beacon#readme",
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/react": "~19.1.1",
|
|
57
|
+
"expo": "~55.0.8",
|
|
58
|
+
"expo-module-scripts": "^55.0.2",
|
|
59
|
+
"react": "19.2.0",
|
|
60
|
+
"react-native": "0.83.2"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"expo": ">=51.0.0",
|
|
64
|
+
"react": "*",
|
|
65
|
+
"react-native": "*"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Raw beacon discovered during a scan. */
|
|
2
|
+
export type BeaconScanResult = {
|
|
3
|
+
uuid: string; // iBeacon proximity UUID (uppercase, formatted)
|
|
4
|
+
major: number; // iBeacon major value (0–65535)
|
|
5
|
+
minor: number; // iBeacon minor value (0–65535)
|
|
6
|
+
rssi: number; // Signal strength in dBm (negative number)
|
|
7
|
+
distance: number; // Estimated distance in meters
|
|
8
|
+
txPower: number; // Calibrated TX power
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** A beacon that has been paired/registered for monitoring. */
|
|
12
|
+
export type PairedBeacon = {
|
|
13
|
+
identifier: string; // User-defined label (e.g. "lobby-door")
|
|
14
|
+
uuid: string;
|
|
15
|
+
major: number;
|
|
16
|
+
minor: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Payload for enter/exit region events. */
|
|
20
|
+
export type BeaconRegionEvent = {
|
|
21
|
+
identifier: string; // Matches PairedBeacon.identifier
|
|
22
|
+
uuid: string;
|
|
23
|
+
major: number;
|
|
24
|
+
minor: number;
|
|
25
|
+
event: "enter" | "exit";
|
|
26
|
+
/** Measured distance in metres at the time of the event (–1 if unavailable). */
|
|
27
|
+
distance: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Payload for ranging events (beacon proximity update). */
|
|
31
|
+
export type BeaconRangingEvent = {
|
|
32
|
+
identifier: string;
|
|
33
|
+
uuid: string;
|
|
34
|
+
major: number;
|
|
35
|
+
minor: number;
|
|
36
|
+
rssi: number;
|
|
37
|
+
distance: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Payload for periodic distance update events during monitoring. */
|
|
41
|
+
export type BeaconDistanceEvent = {
|
|
42
|
+
identifier: string;
|
|
43
|
+
uuid: string;
|
|
44
|
+
major: number;
|
|
45
|
+
minor: number;
|
|
46
|
+
distance: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Module event map. */
|
|
50
|
+
export type ExpoBeaconModuleEvents = {
|
|
51
|
+
onBeaconEnter: (params: BeaconRegionEvent) => void;
|
|
52
|
+
onBeaconExit: (params: BeaconRegionEvent) => void;
|
|
53
|
+
onBeaconRanging: (params: BeaconRangingEvent) => void;
|
|
54
|
+
onBeaconDistance: (params: BeaconDistanceEvent) => void;
|
|
55
|
+
/** Fired continuously during a live scan as each beacon is detected. */
|
|
56
|
+
onBeaconFound: (params: BeaconScanResult) => void;
|
|
57
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NativeModule, requireNativeModule } from "expo";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ExpoBeaconModuleEvents,
|
|
5
|
+
BeaconScanResult,
|
|
6
|
+
PairedBeacon,
|
|
7
|
+
} from "./ExpoBeacon.types";
|
|
8
|
+
|
|
9
|
+
declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
|
|
10
|
+
/**
|
|
11
|
+
* Start a one-shot BLE scan. Resolves with discovered beacons after scanDuration ms.
|
|
12
|
+
* @param scanDuration Duration in ms (default 5000)
|
|
13
|
+
*/
|
|
14
|
+
scanForBeaconsAsync(scanDuration?: number): Promise<BeaconScanResult[]>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register a beacon for persistent region monitoring.
|
|
18
|
+
*/
|
|
19
|
+
pairBeacon(
|
|
20
|
+
identifier: string,
|
|
21
|
+
uuid: string,
|
|
22
|
+
major: number,
|
|
23
|
+
minor: number,
|
|
24
|
+
): void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Remove a previously paired beacon.
|
|
28
|
+
*/
|
|
29
|
+
unpairBeacon(identifier: string): void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return all currently paired beacons.
|
|
33
|
+
*/
|
|
34
|
+
getPairedBeacons(): PairedBeacon[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start background region monitoring for all paired beacons.
|
|
38
|
+
* On Android starts a foreground service.
|
|
39
|
+
* On iOS starts CLLocationManager region monitoring.
|
|
40
|
+
* @param maxDistance Optional distance threshold in metres. Enter events are only
|
|
41
|
+
* emitted when the beacon is measured to be within this distance.
|
|
42
|
+
* Exit events are always emitted when the beacon region is lost.
|
|
43
|
+
*/
|
|
44
|
+
startMonitoring(maxDistance?: number): Promise<void>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stop background region monitoring.
|
|
48
|
+
*/
|
|
49
|
+
stopMonitoring(): Promise<void>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start a continuous BLE scan. Fires `onBeaconFound` events as beacons are detected.
|
|
53
|
+
* Call stopContinuousScan() to end the scan.
|
|
54
|
+
*/
|
|
55
|
+
startContinuousScan(): void;
|
|
56
|
+
|
|
57
|
+
/** Stop the continuous scan started by startContinuousScan(). */
|
|
58
|
+
stopContinuousScan(): void;
|
|
59
|
+
|
|
60
|
+
/** Request Bluetooth + Location permissions. Returns true if granted. */
|
|
61
|
+
requestPermissionsAsync(): Promise<boolean>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default requireNativeModule<ExpoBeaconModule>("ExpoBeacon");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExpoBeaconModuleEvents,
|
|
3
|
+
BeaconScanResult,
|
|
4
|
+
PairedBeacon,
|
|
5
|
+
} from "./ExpoBeacon.types";
|
|
6
|
+
|
|
7
|
+
const notSupported = (): never => {
|
|
8
|
+
throw new Error("expo-beacon is not supported on web.");
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const stub = {
|
|
12
|
+
scanForBeaconsAsync: (_scanDuration?: number): Promise<BeaconScanResult[]> =>
|
|
13
|
+
notSupported(),
|
|
14
|
+
pairBeacon: (
|
|
15
|
+
_identifier: string,
|
|
16
|
+
_uuid: string,
|
|
17
|
+
_major: number,
|
|
18
|
+
_minor: number,
|
|
19
|
+
): void => notSupported(),
|
|
20
|
+
unpairBeacon: (_identifier: string): void => notSupported(),
|
|
21
|
+
getPairedBeacons: (): PairedBeacon[] => notSupported(),
|
|
22
|
+
startMonitoring: (): Promise<void> => notSupported(),
|
|
23
|
+
stopMonitoring: (): Promise<void> => notSupported(),
|
|
24
|
+
requestPermissionsAsync: (): Promise<boolean> => notSupported(),
|
|
25
|
+
addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({
|
|
26
|
+
remove: () => {},
|
|
27
|
+
}),
|
|
28
|
+
removeAllListeners: (_eventName: keyof ExpoBeaconModuleEvents) => {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default stub;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Native module (default export)
|
|
2
|
+
export { default } from "./ExpoBeaconModule";
|
|
3
|
+
|
|
4
|
+
// All public types
|
|
5
|
+
export type {
|
|
6
|
+
BeaconScanResult,
|
|
7
|
+
PairedBeacon,
|
|
8
|
+
BeaconRegionEvent,
|
|
9
|
+
BeaconRangingEvent,
|
|
10
|
+
ExpoBeaconModuleEvents,
|
|
11
|
+
} from "./ExpoBeacon.types";
|