expo-beacon 0.6.3 → 0.6.6
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/android/src/main/AndroidManifest.xml +5 -1
- package/android/src/main/java/expo/modules/beacon/BeaconConstants.kt +32 -4
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +188 -94
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +71 -0
- package/build/ExpoBeaconModule.d.ts +12 -0
- package/build/ExpoBeaconModule.d.ts.map +1 -1
- package/build/ExpoBeaconModule.js.map +1 -1
- package/build/ExpoBeaconModule.web.d.ts +2 -0
- package/build/ExpoBeaconModule.web.d.ts.map +1 -1
- package/build/ExpoBeaconModule.web.js +2 -0
- package/build/ExpoBeaconModule.web.js.map +1 -1
- package/ios/ExpoBeaconModule.swift +75 -36
- package/package.json +1 -1
- package/src/ExpoBeaconModule.ts +14 -0
- package/src/ExpoBeaconModule.web.ts +2 -0
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
<!-- Receive boot broadcast to restart monitoring after device reboot -->
|
|
23
23
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
24
24
|
|
|
25
|
+
<!-- Battery optimization exemption for reliable background BLE scanning -->
|
|
26
|
+
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
|
27
|
+
|
|
25
28
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
|
26
29
|
|
|
27
30
|
<application>
|
|
@@ -30,9 +33,10 @@
|
|
|
30
33
|
android:name="org.altbeacon.beacon.service.BeaconService"
|
|
31
34
|
android:enabled="true"
|
|
32
35
|
android:exported="false"
|
|
36
|
+
android:foregroundServiceType="connectedDevice"
|
|
33
37
|
android:isolatedProcess="false"
|
|
34
38
|
android:label="iBeacon Service"
|
|
35
|
-
tools:replace="android:label" />
|
|
39
|
+
tools:replace="android:label,android:foregroundServiceType" />
|
|
36
40
|
|
|
37
41
|
<service
|
|
38
42
|
android:name="org.altbeacon.beacon.BeaconIntentProcessor"
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
package expo.modules.beacon
|
|
2
2
|
|
|
3
3
|
// Shared constants used across ExpoBeaconModule and BeaconForegroundService.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// NOTE: EXIT_MISS_THRESHOLD and scan timing deviate from iOS intentionally —
|
|
5
|
+
// Android requires a non-zero between-scan gap to prevent OS-level BLE
|
|
6
|
+
// throttling, and a higher miss threshold to tolerate brief scan pauses.
|
|
7
|
+
// HYSTERESIS_COUNT should stay in sync with ExpoBeaconModule.swift (iOS).
|
|
6
8
|
|
|
7
9
|
internal const val PREFS_NAME = "expo.beacon.paired"
|
|
8
10
|
internal const val PREFS_KEY = "paired_beacons"
|
|
@@ -13,12 +15,38 @@ internal const val MONITORING_OPTIONS_PREFS = "expo.beacon.monitoring_options"
|
|
|
13
15
|
internal const val EVENT_LOGGING_PREFS = "expo.beacon.event_logging"
|
|
14
16
|
internal const val EVENT_LOGGING_ENABLED_KEY = "enabled"
|
|
15
17
|
|
|
16
|
-
/**
|
|
17
|
-
internal const val
|
|
18
|
+
/** Foreground-service scan window for background monitoring responsiveness. */
|
|
19
|
+
internal const val MONITORING_SCAN_PERIOD_MS = 1100L
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gap between scan windows while the foreground service is active.
|
|
23
|
+
* A non-zero gap prevents Android from throttling continuous BLE scans
|
|
24
|
+
* after a few minutes — the BLE stack needs brief idle periods to avoid
|
|
25
|
+
* OS-level scan suppression on many devices (Samsung, Pixel, etc.).
|
|
26
|
+
*/
|
|
27
|
+
internal const val MONITORING_BETWEEN_SCAN_PERIOD_MS = 1000L
|
|
28
|
+
|
|
29
|
+
/** Ignore monitor-based exits if ranging saw the beacon within this window. */
|
|
30
|
+
internal const val RECENT_RANGING_SIGHTING_GRACE_MS = 25000L
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Number of consecutive ranging misses before emitting a distance-based exit event.
|
|
34
|
+
* With a ~2.1 s scan cycle (1100 ms scan + 1000 ms gap), 10 misses ≈ 21 s of
|
|
35
|
+
* silence before declaring exit — tolerant of brief BLE gaps while still
|
|
36
|
+
* responsive to actual departures.
|
|
37
|
+
*/
|
|
38
|
+
internal const val EXIT_MISS_THRESHOLD = 10
|
|
18
39
|
|
|
19
40
|
/** Number of consecutive readings required to confirm a distance-based enter or exit transition. */
|
|
20
41
|
internal const val HYSTERESIS_COUNT = 3
|
|
21
42
|
|
|
43
|
+
/**
|
|
44
|
+
* AltBeacon region exit period — how long after the last sighting before
|
|
45
|
+
* MonitorNotifier.didExitRegion fires. Set generously to avoid premature
|
|
46
|
+
* monitor-level exits that bypass ranging hysteresis.
|
|
47
|
+
*/
|
|
48
|
+
internal const val REGION_EXIT_PERIOD_MS = 60000L
|
|
49
|
+
|
|
22
50
|
/** Shared log tag for the expo-beacon module. */
|
|
23
51
|
internal const val TAG = "ExpoBeacon"
|
|
24
52
|
|
|
@@ -10,6 +10,7 @@ import android.os.Handler
|
|
|
10
10
|
import android.os.IBinder
|
|
11
11
|
import android.os.Looper
|
|
12
12
|
import android.os.RemoteException
|
|
13
|
+
import android.os.SystemClock
|
|
13
14
|
import android.util.Log
|
|
14
15
|
import java.util.concurrent.atomic.AtomicInteger
|
|
15
16
|
import androidx.core.app.NotificationCompat
|
|
@@ -18,7 +19,7 @@ import org.altbeacon.beacon.*
|
|
|
18
19
|
import org.json.JSONArray
|
|
19
20
|
|
|
20
21
|
private const val CHANNEL_ID = "expo_beacon_channel"
|
|
21
|
-
|
|
22
|
+
internal const val FOREGROUND_NOTIF_ID = 1001
|
|
22
23
|
/**
|
|
23
24
|
* Base ID for per-beacon enter/exit notifications; incremented per unique region.
|
|
24
25
|
* With FOREGROUND_NOTIF_ID at 1001, this allows up to 999 unique regions
|
|
@@ -31,11 +32,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
31
32
|
private lateinit var beaconManager: BeaconManager
|
|
32
33
|
private val monitoredRegions = mutableListOf<Region>()
|
|
33
34
|
|
|
35
|
+
/** Tracks whether onBeaconServiceConnect has fired for the current bind. */
|
|
36
|
+
@Volatile private var serviceConnected = false
|
|
37
|
+
|
|
34
38
|
// Distance filtering
|
|
35
39
|
@Volatile private var maxDistance: Double? = null
|
|
36
40
|
@Volatile private var exitDistance: Double? = null
|
|
37
|
-
private val
|
|
41
|
+
private val monitoredRegionIds = java.util.concurrent.CopyOnWriteArraySet<String>()
|
|
38
42
|
private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
|
|
43
|
+
private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
|
39
44
|
|
|
40
45
|
// Hysteresis counters (synchronized on distanceLock)
|
|
41
46
|
private val distanceLock = Any()
|
|
@@ -57,42 +62,31 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
57
62
|
private val beaconTimeouts = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
|
58
63
|
private var eventLogger: BeaconEventLogger? = null
|
|
59
64
|
|
|
60
|
-
companion object {
|
|
61
|
-
private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
|
|
62
|
-
|
|
63
|
-
fun start(context: Context) {
|
|
64
|
-
context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
|
|
65
|
-
.edit().putBoolean("active", true).apply()
|
|
66
|
-
val intent = Intent(context, BeaconForegroundService::class.java)
|
|
67
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
68
|
-
context.startForegroundService(intent)
|
|
69
|
-
} else {
|
|
70
|
-
context.startService(intent)
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
fun stop(context: Context) {
|
|
75
|
-
context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
|
|
76
|
-
.edit().putBoolean("active", false).apply()
|
|
77
|
-
context.stopService(Intent(context, BeaconForegroundService::class.java))
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
fun isMonitoringActive(context: Context): Boolean {
|
|
81
|
-
return context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
|
|
82
|
-
.getBoolean("active", false)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
65
|
override fun onCreate() {
|
|
87
66
|
super.onCreate()
|
|
88
67
|
createNotificationChannel()
|
|
89
68
|
beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
|
|
90
69
|
BeaconParsers.ensureRegistered(manager)
|
|
91
70
|
try { manager.setEnableScheduledScanJobs(false) } catch (e: IllegalStateException) { Log.w(TAG, "setEnableScheduledScanJobs failed", e) }
|
|
92
|
-
manager.setBackgroundBetweenScanPeriod(
|
|
93
|
-
manager.setBackgroundScanPeriod(
|
|
94
|
-
manager.setForegroundScanPeriod(
|
|
95
|
-
manager.setForegroundBetweenScanPeriod(
|
|
71
|
+
manager.setBackgroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
|
|
72
|
+
manager.setBackgroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
|
|
73
|
+
manager.setForegroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
|
|
74
|
+
manager.setForegroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
|
|
75
|
+
}
|
|
76
|
+
// Increase AltBeacon's region exit period so didExitRegion doesn't fire
|
|
77
|
+
// prematurely during brief BLE scan gaps.
|
|
78
|
+
BeaconManager.setRegionExitPeriod(REGION_EXIT_PERIOD_MS)
|
|
79
|
+
// Let AltBeacon's internal BeaconService run as a foreground service so
|
|
80
|
+
// Samsung/OEM battery enforcement doesn't kill BLE scanning after ~4 min.
|
|
81
|
+
// Reset first to clear any stale state from a previous session.
|
|
82
|
+
try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
|
|
83
|
+
try {
|
|
84
|
+
beaconManager.enableForegroundServiceScanning(
|
|
85
|
+
buildForegroundNotification(), FOREGROUND_NOTIF_ID
|
|
86
|
+
)
|
|
87
|
+
} catch (e: IllegalStateException) {
|
|
88
|
+
// Already bound by another consumer (e.g. a scan in progress) — non-fatal.
|
|
89
|
+
Log.w(TAG, "enableForegroundServiceScanning skipped (already bound)", e)
|
|
96
90
|
}
|
|
97
91
|
}
|
|
98
92
|
|
|
@@ -106,11 +100,18 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
106
100
|
} else {
|
|
107
101
|
startForeground(FOREGROUND_NOTIF_ID, buildForegroundNotification())
|
|
108
102
|
}
|
|
109
|
-
|
|
103
|
+
if (serviceConnected) {
|
|
104
|
+
// Already bound from a prior onStartCommand — reload regions directly
|
|
105
|
+
// so that re-starting monitoring from JS always takes effect.
|
|
106
|
+
loadAndMonitorRegions()
|
|
107
|
+
} else {
|
|
108
|
+
beaconManager.bind(this)
|
|
109
|
+
}
|
|
110
110
|
return START_STICKY
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
override fun onBeaconServiceConnect() {
|
|
114
|
+
serviceConnected = true
|
|
114
115
|
// Read max distance and exit distance from options prefs
|
|
115
116
|
val optPrefs = getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
|
|
116
117
|
maxDistance = optPrefs.getString("max_distance", null)?.toDoubleOrNull()
|
|
@@ -156,6 +157,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
156
157
|
try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
157
158
|
}
|
|
158
159
|
distanceLogRegions.clear()
|
|
160
|
+
monitoredRegionIds.clear()
|
|
161
|
+
enteredRegions.clear()
|
|
162
|
+
lastSeenAtMs.clear()
|
|
163
|
+
timeoutHandler.removeCallbacksAndMessages(null)
|
|
164
|
+
timeoutRunnables.clear()
|
|
165
|
+
synchronized(distanceLock) {
|
|
166
|
+
enterCounters.clear()
|
|
167
|
+
exitCounters.clear()
|
|
168
|
+
missCounters.clear()
|
|
169
|
+
}
|
|
159
170
|
monitoredRegions.forEach {
|
|
160
171
|
try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
161
172
|
}
|
|
@@ -171,6 +182,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
171
182
|
Identifier.fromInt(b.getInt("minor"))
|
|
172
183
|
)
|
|
173
184
|
monitoredRegions.add(region)
|
|
185
|
+
monitoredRegionIds.add(region.uniqueId)
|
|
174
186
|
try {
|
|
175
187
|
beaconManager.startMonitoringBeaconsInRegion(region)
|
|
176
188
|
} catch (e: RemoteException) {
|
|
@@ -200,6 +212,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
200
212
|
null
|
|
201
213
|
)
|
|
202
214
|
monitoredRegions.add(region)
|
|
215
|
+
monitoredRegionIds.add(region.uniqueId)
|
|
203
216
|
try {
|
|
204
217
|
beaconManager.startMonitoringBeaconsInRegion(region)
|
|
205
218
|
} catch (ex: RemoteException) {
|
|
@@ -224,51 +237,42 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
224
237
|
|
|
225
238
|
// Distance logging only — emits distance broadcasts. Enter/exit logic lives in rangeNotifier.
|
|
226
239
|
private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
|
|
240
|
+
if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
|
|
227
241
|
val closest = beacons.filter { it.distance >= 0 }.minByOrNull { it.distance }
|
|
228
242
|
if (closest != null) {
|
|
243
|
+
lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
|
|
229
244
|
sendBeaconBroadcast(region, "distance", closest.distance)
|
|
230
245
|
}
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
private val monitorNotifier = object : MonitorNotifier {
|
|
234
249
|
override fun didEnterRegion(region: Region) {
|
|
235
|
-
|
|
236
|
-
if (maxDist != null) {
|
|
237
|
-
// Mark region for distance confirmation — ranging is already active via distance logging
|
|
238
|
-
rangingRegions.add(region)
|
|
239
|
-
} else {
|
|
240
|
-
enteredRegions.add(region.uniqueId)
|
|
241
|
-
sendBeaconBroadcast(region, "enter", -1.0)
|
|
242
|
-
showEnterExitNotification(region, "enter")
|
|
243
|
-
scheduleTimeoutIfConfigured(region)
|
|
244
|
-
}
|
|
250
|
+
// Enter is synthesized from ranging so distance and enter/exit stay in sync.
|
|
245
251
|
}
|
|
246
252
|
|
|
247
253
|
override fun didExitRegion(region: Region) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// Distance ranging normally handles exit. But if the beacon was
|
|
252
|
-
// in "entered" state when OS fires didExitRegion, we must emit
|
|
253
|
-
// the exit event — ranging will no longer receive readings.
|
|
254
|
-
val wasEntered = enteredRegions.remove(region.uniqueId)
|
|
255
|
-
synchronized(distanceLock) {
|
|
256
|
-
enterCounters.remove(region.uniqueId)
|
|
257
|
-
exitCounters.remove(region.uniqueId)
|
|
258
|
-
missCounters.remove(region.uniqueId)
|
|
259
|
-
}
|
|
260
|
-
if (wasEntered) {
|
|
261
|
-
cancelTimeout(region.uniqueId)
|
|
262
|
-
sendBeaconBroadcast(region, "exit", -1.0)
|
|
263
|
-
showEnterExitNotification(region, "exit")
|
|
264
|
-
}
|
|
254
|
+
if (!monitoredRegionIds.contains(region.uniqueId)) return
|
|
255
|
+
if (wasSeenRecently(region.uniqueId)) {
|
|
256
|
+
Log.d(TAG, "Ignoring stale didExitRegion for ${region.uniqueId}; beacon was seen by ranging recently")
|
|
265
257
|
return
|
|
266
258
|
}
|
|
267
259
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
260
|
+
lastSeenAtMs.remove(region.uniqueId)
|
|
261
|
+
|
|
262
|
+
// Ranging-based hysteresis handles exit in the normal case. If the OS
|
|
263
|
+
// fires didExitRegion after ranging has already stopped, emit exit as a
|
|
264
|
+
// safety net only if the region was previously in the entered state.
|
|
265
|
+
val wasEntered = enteredRegions.remove(region.uniqueId)
|
|
266
|
+
synchronized(distanceLock) {
|
|
267
|
+
enterCounters.remove(region.uniqueId)
|
|
268
|
+
exitCounters.remove(region.uniqueId)
|
|
269
|
+
missCounters.remove(region.uniqueId)
|
|
270
|
+
}
|
|
271
|
+
if (wasEntered) {
|
|
272
|
+
cancelTimeout(region.uniqueId)
|
|
273
|
+
sendBeaconBroadcast(region, "exit", -1.0)
|
|
274
|
+
showEnterExitNotification(region, "exit")
|
|
275
|
+
}
|
|
272
276
|
}
|
|
273
277
|
|
|
274
278
|
override fun didDetermineStateForRegion(state: Int, region: Region) {
|
|
@@ -277,11 +281,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
// Single source of truth for distance-based enter/exit with hysteresis.
|
|
280
|
-
// Processes regions
|
|
281
|
-
//
|
|
284
|
+
// Processes only actual monitoring regions and handles exit via miss counting
|
|
285
|
+
// when beacons disappear.
|
|
282
286
|
private val rangeNotifier = RangeNotifier { beacons, region ->
|
|
283
|
-
val maxDist = maxDistance
|
|
284
|
-
if (!
|
|
287
|
+
val maxDist = maxDistance
|
|
288
|
+
if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
|
|
285
289
|
|
|
286
290
|
val beacon = beacons
|
|
287
291
|
.filter { it.distance >= 0 }
|
|
@@ -290,13 +294,13 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
290
294
|
synchronized(distanceLock) {
|
|
291
295
|
if (beacon != null) {
|
|
292
296
|
// Got a valid reading — reset miss counter
|
|
297
|
+
lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
|
|
293
298
|
missCounters[region.uniqueId] = 0
|
|
294
299
|
|
|
295
300
|
val action = evaluateDistanceHysteresis(region.uniqueId, beacon.distance, maxDist)
|
|
296
301
|
when (action) {
|
|
297
302
|
HysteresisAction.ENTER -> {
|
|
298
303
|
enteredRegions.add(region.uniqueId)
|
|
299
|
-
rangingRegions.remove(region)
|
|
300
304
|
sendBeaconBroadcast(region, "enter", beacon.distance)
|
|
301
305
|
showEnterExitNotification(region, "enter")
|
|
302
306
|
scheduleTimeoutIfConfigured(region)
|
|
@@ -304,14 +308,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
304
308
|
HysteresisAction.EXIT -> {
|
|
305
309
|
cancelTimeout(region.uniqueId)
|
|
306
310
|
enteredRegions.remove(region.uniqueId)
|
|
307
|
-
rangingRegions.add(region)
|
|
308
311
|
sendBeaconBroadcast(region, "exit", beacon.distance)
|
|
309
312
|
showEnterExitNotification(region, "exit")
|
|
310
313
|
}
|
|
311
314
|
HysteresisAction.NONE -> {}
|
|
312
315
|
}
|
|
313
316
|
} else {
|
|
314
|
-
// No valid beacon reading —
|
|
317
|
+
// No valid beacon reading — break distance hysteresis streaks and
|
|
318
|
+
// track consecutive misses for disappearance-based exit detection.
|
|
319
|
+
enterCounters[region.uniqueId] = 0
|
|
320
|
+
exitCounters[region.uniqueId] = 0
|
|
315
321
|
val count = (missCounters[region.uniqueId] ?: 0) + 1
|
|
316
322
|
missCounters[region.uniqueId] = count
|
|
317
323
|
|
|
@@ -349,8 +355,23 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
349
355
|
private fun evaluateDistanceHysteresis(
|
|
350
356
|
regionId: String,
|
|
351
357
|
distance: Double,
|
|
352
|
-
maxDist: Double
|
|
358
|
+
maxDist: Double?
|
|
353
359
|
): HysteresisAction {
|
|
360
|
+
if (maxDist == null) {
|
|
361
|
+
exitCounters[regionId] = 0
|
|
362
|
+
if (enteredRegions.contains(regionId)) {
|
|
363
|
+
enterCounters[regionId] = 0
|
|
364
|
+
return HysteresisAction.NONE
|
|
365
|
+
}
|
|
366
|
+
val count = (enterCounters[regionId] ?: 0) + 1
|
|
367
|
+
enterCounters[regionId] = count
|
|
368
|
+
if (count >= HYSTERESIS_COUNT) {
|
|
369
|
+
enterCounters[regionId] = 0
|
|
370
|
+
return HysteresisAction.ENTER
|
|
371
|
+
}
|
|
372
|
+
return HysteresisAction.NONE
|
|
373
|
+
}
|
|
374
|
+
|
|
354
375
|
val exitDist = effectiveExitDistance(maxDist)
|
|
355
376
|
if (distance <= maxDist) {
|
|
356
377
|
// Inside enter threshold
|
|
@@ -378,6 +399,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
378
399
|
return HysteresisAction.NONE
|
|
379
400
|
}
|
|
380
401
|
|
|
402
|
+
private fun wasSeenRecently(regionId: String): Boolean {
|
|
403
|
+
val lastSeen = lastSeenAtMs[regionId] ?: return false
|
|
404
|
+
return SystemClock.elapsedRealtime() - lastSeen <= RECENT_RANGING_SIGHTING_GRACE_MS
|
|
405
|
+
}
|
|
406
|
+
|
|
381
407
|
// MARK: - Timeout timer helpers
|
|
382
408
|
|
|
383
409
|
private fun scheduleTimeoutIfConfigured(region: Region) {
|
|
@@ -546,26 +572,94 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
546
572
|
}
|
|
547
573
|
|
|
548
574
|
private fun buildForegroundNotification(): Notification {
|
|
549
|
-
|
|
550
|
-
|
|
575
|
+
return Companion.buildForegroundNotification(this)
|
|
576
|
+
}
|
|
551
577
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() }
|
|
555
|
-
?: "Monitoring for iBeacons in the background"
|
|
556
|
-
val iconName = fgConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
|
|
557
|
-
val iconResId = iconName?.let { name ->
|
|
558
|
-
try { resources.getIdentifier(name, "drawable", packageName).takeIf { it != 0 } }
|
|
559
|
-
catch (_: Exception) { null }
|
|
560
|
-
} ?: android.R.drawable.ic_dialog_info
|
|
578
|
+
companion object {
|
|
579
|
+
private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
|
|
561
580
|
|
|
562
|
-
|
|
563
|
-
.
|
|
564
|
-
|
|
565
|
-
.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
581
|
+
fun start(context: Context) {
|
|
582
|
+
context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
|
|
583
|
+
.edit().putBoolean("active", true).apply()
|
|
584
|
+
val intent = Intent(context, BeaconForegroundService::class.java)
|
|
585
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
586
|
+
context.startForegroundService(intent)
|
|
587
|
+
} else {
|
|
588
|
+
context.startService(intent)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fun stop(context: Context) {
|
|
593
|
+
context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
|
|
594
|
+
.edit().putBoolean("active", false).apply()
|
|
595
|
+
context.stopService(Intent(context, BeaconForegroundService::class.java))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
fun isMonitoringActive(context: Context): Boolean {
|
|
599
|
+
return context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
|
|
600
|
+
.getBoolean("active", false)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Ensure the notification channel exists. Must be called before building
|
|
605
|
+
* a notification from a non-service context (e.g. ExpoBeaconModule).
|
|
606
|
+
*/
|
|
607
|
+
fun ensureNotificationChannel(context: Context) {
|
|
608
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
609
|
+
val json = context.getSharedPreferences(NOTIFICATION_CONFIG_PREFS, Context.MODE_PRIVATE)
|
|
610
|
+
.getString("config", null)
|
|
611
|
+
val config = try { org.json.JSONObject(json ?: "") } catch (_: Exception) { org.json.JSONObject() }
|
|
612
|
+
val channelConfig = config.optJSONObject("channel")
|
|
613
|
+
|
|
614
|
+
val channelName = channelConfig?.optString("name")?.takeIf { it.isNotEmpty() }
|
|
615
|
+
?: "Beacon Monitoring"
|
|
616
|
+
val channelDesc = channelConfig?.optString("description")?.takeIf { it.isNotEmpty() }
|
|
617
|
+
?: "Used for background iBeacon region monitoring"
|
|
618
|
+
val importance = when (channelConfig?.optString("importance")) {
|
|
619
|
+
"high" -> NotificationManager.IMPORTANCE_HIGH
|
|
620
|
+
"default" -> NotificationManager.IMPORTANCE_DEFAULT
|
|
621
|
+
else -> NotificationManager.IMPORTANCE_LOW
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
val notifMgr = context.getSystemService(NotificationManager::class.java)
|
|
625
|
+
if (notifMgr?.getNotificationChannel(CHANNEL_ID) == null) {
|
|
626
|
+
val channel = NotificationChannel(CHANNEL_ID, channelName, importance).apply {
|
|
627
|
+
description = channelDesc
|
|
628
|
+
}
|
|
629
|
+
notifMgr?.createNotificationChannel(channel)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Build the foreground notification from any Context (service or module).
|
|
636
|
+
* Shared so that ExpoBeaconModule can pass the same notification to
|
|
637
|
+
* enableForegroundServiceScanning() before the service starts.
|
|
638
|
+
*/
|
|
639
|
+
fun buildForegroundNotification(context: Context): Notification {
|
|
640
|
+
val json = context.getSharedPreferences(NOTIFICATION_CONFIG_PREFS, Context.MODE_PRIVATE)
|
|
641
|
+
.getString("config", null)
|
|
642
|
+
val config = try { org.json.JSONObject(json ?: "") } catch (_: Exception) { org.json.JSONObject() }
|
|
643
|
+
val fgConfig = config.optJSONObject("foregroundService")
|
|
644
|
+
|
|
645
|
+
val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() }
|
|
646
|
+
?: "Beacon Monitoring Active"
|
|
647
|
+
val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() }
|
|
648
|
+
?: "Monitoring for iBeacons in the background"
|
|
649
|
+
val iconName = fgConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
|
|
650
|
+
val iconResId = iconName?.let { name ->
|
|
651
|
+
try { context.resources.getIdentifier(name, "drawable", context.packageName).takeIf { it != 0 } }
|
|
652
|
+
catch (_: Exception) { null }
|
|
653
|
+
} ?: android.R.drawable.ic_dialog_info
|
|
654
|
+
|
|
655
|
+
return NotificationCompat.Builder(context, CHANNEL_ID)
|
|
656
|
+
.setSmallIcon(iconResId)
|
|
657
|
+
.setContentTitle(title)
|
|
658
|
+
.setContentText(text)
|
|
659
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
660
|
+
.setOngoing(true)
|
|
661
|
+
.build()
|
|
662
|
+
}
|
|
569
663
|
}
|
|
570
664
|
|
|
571
665
|
private fun readNotificationConfig(): org.json.JSONObject {
|
|
@@ -575,17 +669,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
575
669
|
}
|
|
576
670
|
|
|
577
671
|
override fun onDestroy() {
|
|
672
|
+
serviceConnected = false
|
|
578
673
|
timeoutHandler.removeCallbacksAndMessages(null)
|
|
579
674
|
timeoutRunnables.clear()
|
|
580
675
|
beaconTimeouts.clear()
|
|
676
|
+
lastSeenAtMs.clear()
|
|
677
|
+
monitoredRegionIds.clear()
|
|
581
678
|
releaseEventLogger()
|
|
582
679
|
beaconManager.removeMonitorNotifier(monitorNotifier)
|
|
583
680
|
beaconManager.removeRangeNotifier(rangeNotifier)
|
|
584
681
|
beaconManager.removeRangeNotifier(distanceLoggingRangeNotifier)
|
|
585
|
-
rangingRegions.forEach {
|
|
586
|
-
try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
587
|
-
}
|
|
588
|
-
rangingRegions.clear()
|
|
589
682
|
distanceLogRegions.forEach {
|
|
590
683
|
try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
591
684
|
}
|
|
@@ -598,6 +691,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
598
691
|
monitoredRegions.forEach {
|
|
599
692
|
try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
600
693
|
}
|
|
694
|
+
try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
|
|
601
695
|
beaconManager.unbind(this)
|
|
602
696
|
super.onDestroy()
|
|
603
697
|
}
|
|
@@ -7,7 +7,9 @@ import android.content.IntentFilter
|
|
|
7
7
|
import android.content.ServiceConnection
|
|
8
8
|
import android.content.SharedPreferences
|
|
9
9
|
import android.content.pm.PackageManager
|
|
10
|
+
import android.net.Uri
|
|
10
11
|
import android.os.Build
|
|
12
|
+
import android.os.PowerManager
|
|
11
13
|
import android.os.RemoteException
|
|
12
14
|
import androidx.core.content.ContextCompat
|
|
13
15
|
import expo.modules.interfaces.permissions.PermissionsStatus
|
|
@@ -294,6 +296,22 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
294
296
|
}
|
|
295
297
|
}
|
|
296
298
|
}
|
|
299
|
+
if (maxDistance != null && (!maxDistance.isFinite() || maxDistance <= 0.0)) {
|
|
300
|
+
promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0", null)
|
|
301
|
+
return@AsyncFunction
|
|
302
|
+
}
|
|
303
|
+
if (exitDistance != null && (!exitDistance.isFinite() || exitDistance <= 0.0)) {
|
|
304
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0", null)
|
|
305
|
+
return@AsyncFunction
|
|
306
|
+
}
|
|
307
|
+
if (exitDistance != null && maxDistance == null) {
|
|
308
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set", null)
|
|
309
|
+
return@AsyncFunction
|
|
310
|
+
}
|
|
311
|
+
if (maxDistance != null && exitDistance != null && exitDistance < maxDistance) {
|
|
312
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance", null)
|
|
313
|
+
return@AsyncFunction
|
|
314
|
+
}
|
|
297
315
|
ctx.getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
|
|
298
316
|
.edit().apply {
|
|
299
317
|
if (maxDistance != null) putString("max_distance", maxDistance.toString())
|
|
@@ -310,6 +328,21 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
310
328
|
return@AsyncFunction
|
|
311
329
|
}
|
|
312
330
|
|
|
331
|
+
// Enable AltBeacon foreground service scanning so the internal BeaconService
|
|
332
|
+
// runs as a foreground service, preventing Samsung/OEM BLE throttling.
|
|
333
|
+
// Reset first to clear stale state from a previous session / hot-reload.
|
|
334
|
+
if (!isBoundForScan) {
|
|
335
|
+
try {
|
|
336
|
+
BeaconForegroundService.ensureNotificationChannel(ctx)
|
|
337
|
+
try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
|
|
338
|
+
beaconManager.enableForegroundServiceScanning(
|
|
339
|
+
BeaconForegroundService.buildForegroundNotification(ctx), FOREGROUND_NOTIF_ID
|
|
340
|
+
)
|
|
341
|
+
} catch (_: IllegalStateException) {
|
|
342
|
+
// Already bound — service onCreate() will enable it instead.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
313
346
|
registerEventReceiver()
|
|
314
347
|
BeaconForegroundService.start(ctx)
|
|
315
348
|
promise.resolve(null)
|
|
@@ -327,6 +360,7 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
327
360
|
return@AsyncFunction
|
|
328
361
|
}
|
|
329
362
|
BeaconForegroundService.stop(ctx)
|
|
363
|
+
try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
|
|
330
364
|
unregisterEventReceiver()
|
|
331
365
|
promise.resolve(null)
|
|
332
366
|
}
|
|
@@ -415,6 +449,43 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
415
449
|
BeaconEventLogger.deleteLogDatabase(ctx)
|
|
416
450
|
}
|
|
417
451
|
|
|
452
|
+
// MARK: - Battery Optimization
|
|
453
|
+
|
|
454
|
+
Function("isBatteryOptimizationExempt") {
|
|
455
|
+
val ctx = appContext.reactContext ?: return@Function false
|
|
456
|
+
val pm = ctx.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
|
457
|
+
?: return@Function false
|
|
458
|
+
pm.isIgnoringBatteryOptimizations(ctx.packageName)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
AsyncFunction("requestBatteryOptimizationExemption") { promise: Promise ->
|
|
462
|
+
val ctx = appContext.reactContext ?: run {
|
|
463
|
+
promise.reject("NO_CONTEXT", "React context is not available", null)
|
|
464
|
+
return@AsyncFunction
|
|
465
|
+
}
|
|
466
|
+
val pm = ctx.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
|
467
|
+
if (pm == null) {
|
|
468
|
+
promise.resolve(false)
|
|
469
|
+
return@AsyncFunction
|
|
470
|
+
}
|
|
471
|
+
if (pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
|
472
|
+
promise.resolve(true)
|
|
473
|
+
return@AsyncFunction
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
|
477
|
+
data = Uri.parse("package:${ctx.packageName}")
|
|
478
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
479
|
+
}
|
|
480
|
+
ctx.startActivity(intent)
|
|
481
|
+
// The system dialog is fire-and-forget; we cannot observe the result
|
|
482
|
+
// from a non-Activity context. Resolve true to indicate the dialog was shown.
|
|
483
|
+
promise.resolve(true)
|
|
484
|
+
} catch (e: Exception) {
|
|
485
|
+
promise.reject("BATTERY_OPT_ERROR", "Failed to open battery optimization settings: ${e.message}", e)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
418
489
|
OnDestroy {
|
|
419
490
|
with(this@ExpoBeaconModule) {
|
|
420
491
|
unregisterEventReceiver()
|
|
@@ -77,6 +77,18 @@ declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
|
|
|
77
77
|
cancelScan(): void;
|
|
78
78
|
/** Request Bluetooth + Location permissions. Returns true if granted. */
|
|
79
79
|
requestPermissionsAsync(): Promise<boolean>;
|
|
80
|
+
/**
|
|
81
|
+
* Check whether the app is exempt from Android battery optimizations.
|
|
82
|
+
* Always returns true on iOS and web (no equivalent concept).
|
|
83
|
+
*/
|
|
84
|
+
isBatteryOptimizationExempt(): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Request exemption from Android battery optimizations.
|
|
87
|
+
* Opens the system dialog asking the user to whitelist this app.
|
|
88
|
+
* Returns true if the dialog was shown (or already exempt), false on failure.
|
|
89
|
+
* Always resolves true on iOS and web.
|
|
90
|
+
*/
|
|
91
|
+
requestBatteryOptimizationExemption(): Promise<boolean>;
|
|
80
92
|
/** Enable SQLite event logging. All beacon events will be persisted to a local database. */
|
|
81
93
|
enableEventLogging(): void;
|
|
82
94
|
/** Disable event logging. Previously logged events are retained. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeaconModule.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EACL,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAE5B,OAAO,OAAO,gBAAiB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IACzE;;;;;;;;;;;OAWG;IACH,mBAAmB,CACjB,KAAK,CAAC,EAAE,MAAM,EAAE,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAE9B;;;;;OAKG;IACH,sBAAsB,CACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAEjC;;OAEG;IACH,UAAU,CACR,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEtC;;OAEG;IACH,gBAAgB,IAAI,YAAY,EAAE;IAElC;;OAEG;IACH,aAAa,CACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEzC;;OAEG;IACH,mBAAmB,IAAI,eAAe,EAAE;IAExC;;;OAGG;IACH,qBAAqB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAEvD;;;;;;;OAOG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAEpE;;OAEG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAE/B;;;OAGG;IACH,mBAAmB,IAAI,IAAI;IAE3B,iEAAiE;IACjE,kBAAkB,IAAI,IAAI;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI;IAElB,yEAAyE;IACzE,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC;IAE3C,4FAA4F;IAC5F,kBAAkB,IAAI,IAAI;IAE1B,oEAAoE;IACpE,mBAAmB,IAAI,IAAI;IAE3B;;;OAGG;IACH,YAAY,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,EAAE;IAE7D,kDAAkD;IAClD,cAAc,IAAI,IAAI;IAEtB,mEAAmE;IACnE,gBAAgB,IAAI,IAAI;CACzB;;AAED,wBAAmE"}
|
|
1
|
+
{"version":3,"file":"ExpoBeaconModule.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EACL,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAE5B,OAAO,OAAO,gBAAiB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IACzE;;;;;;;;;;;OAWG;IACH,mBAAmB,CACjB,KAAK,CAAC,EAAE,MAAM,EAAE,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAE9B;;;;;OAKG;IACH,sBAAsB,CACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAEjC;;OAEG;IACH,UAAU,CACR,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEtC;;OAEG;IACH,gBAAgB,IAAI,YAAY,EAAE;IAElC;;OAEG;IACH,aAAa,CACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEzC;;OAEG;IACH,mBAAmB,IAAI,eAAe,EAAE;IAExC;;;OAGG;IACH,qBAAqB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAEvD;;;;;;;OAOG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAEpE;;OAEG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAE/B;;;OAGG;IACH,mBAAmB,IAAI,IAAI;IAE3B,iEAAiE;IACjE,kBAAkB,IAAI,IAAI;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI;IAElB,yEAAyE;IACzE,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC;IAE3C;;;OAGG;IACH,2BAA2B,IAAI,OAAO;IAEtC;;;;;OAKG;IACH,mCAAmC,IAAI,OAAO,CAAC,OAAO,CAAC;IAEvD,4FAA4F;IAC5F,kBAAkB,IAAI,IAAI;IAE1B,oEAAoE;IACpE,mBAAmB,IAAI,IAAI;IAE3B;;;OAGG;IACH,YAAY,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,EAAE;IAE7D,kDAAkD;IAClD,cAAc,IAAI,IAAI;IAEtB,mEAAmE;IACnE,gBAAgB,IAAI,IAAI;CACzB;;AAED,wBAAmE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeaconModule.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"ExpoBeaconModule.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AA6JzD,eAAe,mBAAmB,CAAmB,YAAY,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\r\n\r\nimport {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n NotificationConfig,\r\n MonitoringOptions,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\ndeclare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {\r\n /**\r\n * Start a one-shot iBeacon scan. Resolves with discovered beacons after scanDuration ms.\r\n *\r\n * Pass one or more UUIDs to scan for specific beacons (uses CoreLocation on iOS).\r\n * On iOS, at least one UUID is required — Apple strips iBeacon data from BLE\r\n * advertisements, making wildcard discovery impossible. When you pass an empty\r\n * array, the module automatically uses UUIDs from paired beacons.\r\n * On Android, pass an empty array to discover all nearby iBeacons.\r\n *\r\n * @param uuids Proximity UUIDs to filter by. Empty/omitted = use paired UUIDs (iOS) or wildcard (Android).\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForBeaconsAsync(\r\n uuids?: string[],\r\n scanDuration?: number,\r\n ): Promise<BeaconScanResult[]>;\r\n\r\n /**\r\n * Start a one-shot Eddystone beacon scan using BLE.\r\n * Discovers Eddystone-UID and Eddystone-URL frames.\r\n *\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForEddystonesAsync(\r\n scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]>;\r\n\r\n /**\r\n * Register a beacon for persistent region monitoring.\r\n */\r\n pairBeacon(\r\n identifier: string,\r\n uuid: string,\r\n major: number,\r\n minor: number,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired beacon.\r\n */\r\n unpairBeacon(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired beacons.\r\n */\r\n getPairedBeacons(): PairedBeacon[];\r\n\r\n /**\r\n * Register an Eddystone-UID beacon for persistent monitoring.\r\n */\r\n pairEddystone(\r\n identifier: string,\r\n namespace: string,\r\n instance: string,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired Eddystone beacon.\r\n */\r\n unpairEddystone(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired Eddystone beacons.\r\n */\r\n getPairedEddystones(): PairedEddystone[];\r\n\r\n /**\r\n * Set persistent notification configuration. Settings are saved and applied to all\r\n * subsequent monitoring sessions until explicitly changed.\r\n */\r\n setNotificationConfig(config: NotificationConfig): void;\r\n\r\n /**\r\n * Start background region monitoring for all paired beacons.\r\n * On Android starts a foreground service.\r\n * On iOS starts CLLocationManager region monitoring.\r\n *\r\n * Accepts a plain number (backward-compatible maxDistance shorthand) or a\r\n * MonitoringOptions object with maxDistance and/or notification overrides.\r\n */\r\n startMonitoring(options?: MonitoringOptions | number): Promise<void>;\r\n\r\n /**\r\n * Stop background region monitoring.\r\n */\r\n stopMonitoring(): Promise<void>;\r\n\r\n /**\r\n * Start a continuous BLE scan. Fires `onBeaconFound` events as beacons are detected.\r\n * Call stopContinuousScan() to end the scan.\r\n */\r\n startContinuousScan(): void;\r\n\r\n /** Stop the continuous scan started by startContinuousScan(). */\r\n stopContinuousScan(): void;\r\n\r\n /**\r\n * Cancel any in-progress one-shot scan (iBeacon or Eddystone).\r\n * The pending promise will be rejected with code \"SCAN_CANCELLED\".\r\n */\r\n cancelScan(): void;\r\n\r\n /** Request Bluetooth + Location permissions. Returns true if granted. */\r\n requestPermissionsAsync(): Promise<boolean>;\r\n\r\n /**\r\n * Check whether the app is exempt from Android battery optimizations.\r\n * Always returns true on iOS and web (no equivalent concept).\r\n */\r\n isBatteryOptimizationExempt(): boolean;\r\n\r\n /**\r\n * Request exemption from Android battery optimizations.\r\n * Opens the system dialog asking the user to whitelist this app.\r\n * Returns true if the dialog was shown (or already exempt), false on failure.\r\n * Always resolves true on iOS and web.\r\n */\r\n requestBatteryOptimizationExemption(): Promise<boolean>;\r\n\r\n /** Enable SQLite event logging. All beacon events will be persisted to a local database. */\r\n enableEventLogging(): void;\r\n\r\n /** Disable event logging. Previously logged events are retained. */\r\n disableEventLogging(): void;\r\n\r\n /**\r\n * Retrieve logged beacon events from the SQLite database.\r\n * @param options Optional filters (limit, eventType, sinceTimestamp).\r\n */\r\n getEventLogs(options?: EventLogQueryOptions): EventLogEntry[];\r\n\r\n /** Delete all logged events from the database. */\r\n clearEventLogs(): void;\r\n\r\n /** Delete the entire event log database. Also disables logging. */\r\n destroyEventLogs(): void;\r\n}\r\n\r\nexport default requireNativeModule<ExpoBeaconModule>(\"ExpoBeacon\");\r\n"]}
|
|
@@ -20,6 +20,8 @@ declare const stub: {
|
|
|
20
20
|
getEventLogs: (_options?: EventLogQueryOptions) => EventLogEntry[];
|
|
21
21
|
clearEventLogs: () => void;
|
|
22
22
|
destroyEventLogs: () => void;
|
|
23
|
+
isBatteryOptimizationExempt: () => boolean;
|
|
24
|
+
requestBatteryOptimizationExemption: () => Promise<boolean>;
|
|
23
25
|
addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => {
|
|
24
26
|
remove: () => void;
|
|
25
27
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeaconModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAM5B,QAAA,MAAM,IAAI;kCAEE,MAAM,EAAE,kBACA,MAAM,KACrB,OAAO,CAAC,gBAAgB,EAAE,CAAC;6CAEZ,MAAM,KACrB,OAAO,CAAC,mBAAmB,EAAE,CAAC;8BAElB,MAAM,SACZ,MAAM,UACL,MAAM,UACN,MAAM,KACb,IAAI;gCACqB,MAAM,KAAG,IAAI;4BACnB,YAAY,EAAE;iCAErB,MAAM,cACP,MAAM,aACP,MAAM,KAChB,IAAI;mCACwB,MAAM,KAAG,IAAI;+BACnB,eAAe,EAAE;2BACrB,OAAO,CAAC,IAAI,CAAC;0BACd,OAAO,CAAC,IAAI,CAAC;+BACR,IAAI;8BACL,IAAI;sBACZ,IAAI;qCACa,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAAI;mCAClC,OAAO,CAAC,OAAO,CAAC;8BACrB,IAAI;+BACH,IAAI;8BACH,oBAAoB,KAAG,aAAa,EAAE;0BAC5C,IAAI;4BACF,IAAI;
|
|
1
|
+
{"version":3,"file":"ExpoBeaconModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAM5B,QAAA,MAAM,IAAI;kCAEE,MAAM,EAAE,kBACA,MAAM,KACrB,OAAO,CAAC,gBAAgB,EAAE,CAAC;6CAEZ,MAAM,KACrB,OAAO,CAAC,mBAAmB,EAAE,CAAC;8BAElB,MAAM,SACZ,MAAM,UACL,MAAM,UACN,MAAM,KACb,IAAI;gCACqB,MAAM,KAAG,IAAI;4BACnB,YAAY,EAAE;iCAErB,MAAM,cACP,MAAM,aACP,MAAM,KAChB,IAAI;mCACwB,MAAM,KAAG,IAAI;+BACnB,eAAe,EAAE;2BACrB,OAAO,CAAC,IAAI,CAAC;0BACd,OAAO,CAAC,IAAI,CAAC;+BACR,IAAI;8BACL,IAAI;sBACZ,IAAI;qCACa,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAAI;mCAClC,OAAO,CAAC,OAAO,CAAC;8BACrB,IAAI;+BACH,IAAI;8BACH,oBAAoB,KAAG,aAAa,EAAE;0BAC5C,IAAI;4BACF,IAAI;uCACO,OAAO;+CACC,OAAO,CAAC,OAAO,CAAC;8BAC/B,MAAM,sBAAsB,aAAa,GAAG;;;qCAGrC,MAAM,sBAAsB;CAC9D,CAAC;AAEF,eAAe,IAAI,CAAC"}
|
|
@@ -22,6 +22,8 @@ const stub = {
|
|
|
22
22
|
getEventLogs: (_options) => notSupported(),
|
|
23
23
|
clearEventLogs: () => notSupported(),
|
|
24
24
|
destroyEventLogs: () => notSupported(),
|
|
25
|
+
isBatteryOptimizationExempt: () => true,
|
|
26
|
+
requestBatteryOptimizationExemption: () => Promise.resolve(true),
|
|
25
27
|
addListener: (_eventName, _listener) => ({
|
|
26
28
|
remove: () => { },
|
|
27
29
|
}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeaconModule.web.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAUA,MAAM,YAAY,GAAG,GAAU,EAAE;IAC/B,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;AAC1D,CAAC,CAAC;AAEF,MAAM,IAAI,GAAG;IACX,mBAAmB,EAAE,CACnB,MAAgB,EAChB,aAAsB,EACO,EAAE,CAAC,YAAY,EAAE;IAChD,sBAAsB,EAAE,CACtB,aAAsB,EACU,EAAE,CAAC,YAAY,EAAE;IACnD,UAAU,EAAE,CACV,WAAmB,EACnB,KAAa,EACb,MAAc,EACd,MAAc,EACR,EAAE,CAAC,YAAY,EAAE;IACzB,YAAY,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC3D,gBAAgB,EAAE,GAAmB,EAAE,CAAC,YAAY,EAAE;IACtD,aAAa,EAAE,CACb,WAAmB,EACnB,UAAkB,EAClB,SAAiB,EACX,EAAE,CAAC,YAAY,EAAE;IACzB,eAAe,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC9D,mBAAmB,EAAE,GAAsB,EAAE,CAAC,YAAY,EAAE;IAC5D,eAAe,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACpD,cAAc,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACnD,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,UAAU,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IACtC,qBAAqB,EAAE,CAAC,OAAgC,EAAQ,EAAE,CAAC,YAAY,EAAE;IACjF,uBAAuB,EAAE,GAAqB,EAAE,CAAC,YAAY,EAAE;IAC/D,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,YAAY,EAAE,CAAC,QAA+B,EAAmB,EAAE,CAAC,YAAY,EAAE;IAClF,cAAc,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC1C,gBAAgB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC5C,WAAW,EAAE,CAAC,UAAwC,EAAE,SAAc,EAAE,EAAE,CAAC,CAAC;QAC1E,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IACF,kBAAkB,EAAE,CAAC,UAAwC,EAAE,EAAE,GAAE,CAAC;CACrE,CAAC;AAEF,eAAe,IAAI,CAAC","sourcesContent":["import type {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\nconst notSupported = (): never => {\r\n throw new Error(\"expo-beacon is not supported on web.\");\r\n};\r\n\r\nconst stub = {\r\n scanForBeaconsAsync: (\r\n _uuids: string[],\r\n _scanDuration?: number,\r\n ): Promise<BeaconScanResult[]> => notSupported(),\r\n scanForEddystonesAsync: (\r\n _scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]> => notSupported(),\r\n pairBeacon: (\r\n _identifier: string,\r\n _uuid: string,\r\n _major: number,\r\n _minor: number,\r\n ): void => notSupported(),\r\n unpairBeacon: (_identifier: string): void => notSupported(),\r\n getPairedBeacons: (): PairedBeacon[] => notSupported(),\r\n pairEddystone: (\r\n _identifier: string,\r\n _namespace: string,\r\n _instance: string,\r\n ): void => notSupported(),\r\n unpairEddystone: (_identifier: string): void => notSupported(),\r\n getPairedEddystones: (): PairedEddystone[] => notSupported(),\r\n startMonitoring: (): Promise<void> => notSupported(),\r\n stopMonitoring: (): Promise<void> => notSupported(),\r\n startContinuousScan: (): void => notSupported(),\r\n stopContinuousScan: (): void => notSupported(),\r\n cancelScan: (): void => notSupported(),\r\n setNotificationConfig: (_config: Record<string, unknown>): void => notSupported(),\r\n requestPermissionsAsync: (): Promise<boolean> => notSupported(),\r\n enableEventLogging: (): void => notSupported(),\r\n disableEventLogging: (): void => notSupported(),\r\n getEventLogs: (_options?: EventLogQueryOptions): EventLogEntry[] => notSupported(),\r\n clearEventLogs: (): void => notSupported(),\r\n destroyEventLogs: (): void => notSupported(),\r\n addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({\r\n remove: () => {},\r\n }),\r\n removeAllListeners: (_eventName: keyof ExpoBeaconModuleEvents) => {},\r\n};\r\n\r\nexport default stub;\r\n"]}
|
|
1
|
+
{"version":3,"file":"ExpoBeaconModule.web.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAUA,MAAM,YAAY,GAAG,GAAU,EAAE;IAC/B,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;AAC1D,CAAC,CAAC;AAEF,MAAM,IAAI,GAAG;IACX,mBAAmB,EAAE,CACnB,MAAgB,EAChB,aAAsB,EACO,EAAE,CAAC,YAAY,EAAE;IAChD,sBAAsB,EAAE,CACtB,aAAsB,EACU,EAAE,CAAC,YAAY,EAAE;IACnD,UAAU,EAAE,CACV,WAAmB,EACnB,KAAa,EACb,MAAc,EACd,MAAc,EACR,EAAE,CAAC,YAAY,EAAE;IACzB,YAAY,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC3D,gBAAgB,EAAE,GAAmB,EAAE,CAAC,YAAY,EAAE;IACtD,aAAa,EAAE,CACb,WAAmB,EACnB,UAAkB,EAClB,SAAiB,EACX,EAAE,CAAC,YAAY,EAAE;IACzB,eAAe,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC9D,mBAAmB,EAAE,GAAsB,EAAE,CAAC,YAAY,EAAE;IAC5D,eAAe,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACpD,cAAc,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACnD,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,UAAU,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IACtC,qBAAqB,EAAE,CAAC,OAAgC,EAAQ,EAAE,CAAC,YAAY,EAAE;IACjF,uBAAuB,EAAE,GAAqB,EAAE,CAAC,YAAY,EAAE;IAC/D,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,YAAY,EAAE,CAAC,QAA+B,EAAmB,EAAE,CAAC,YAAY,EAAE;IAClF,cAAc,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC1C,gBAAgB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC5C,2BAA2B,EAAE,GAAY,EAAE,CAAC,IAAI;IAChD,mCAAmC,EAAE,GAAqB,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC;IAClF,WAAW,EAAE,CAAC,UAAwC,EAAE,SAAc,EAAE,EAAE,CAAC,CAAC;QAC1E,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IACF,kBAAkB,EAAE,CAAC,UAAwC,EAAE,EAAE,GAAE,CAAC;CACrE,CAAC;AAEF,eAAe,IAAI,CAAC","sourcesContent":["import type {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\nconst notSupported = (): never => {\r\n throw new Error(\"expo-beacon is not supported on web.\");\r\n};\r\n\r\nconst stub = {\r\n scanForBeaconsAsync: (\r\n _uuids: string[],\r\n _scanDuration?: number,\r\n ): Promise<BeaconScanResult[]> => notSupported(),\r\n scanForEddystonesAsync: (\r\n _scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]> => notSupported(),\r\n pairBeacon: (\r\n _identifier: string,\r\n _uuid: string,\r\n _major: number,\r\n _minor: number,\r\n ): void => notSupported(),\r\n unpairBeacon: (_identifier: string): void => notSupported(),\r\n getPairedBeacons: (): PairedBeacon[] => notSupported(),\r\n pairEddystone: (\r\n _identifier: string,\r\n _namespace: string,\r\n _instance: string,\r\n ): void => notSupported(),\r\n unpairEddystone: (_identifier: string): void => notSupported(),\r\n getPairedEddystones: (): PairedEddystone[] => notSupported(),\r\n startMonitoring: (): Promise<void> => notSupported(),\r\n stopMonitoring: (): Promise<void> => notSupported(),\r\n startContinuousScan: (): void => notSupported(),\r\n stopContinuousScan: (): void => notSupported(),\r\n cancelScan: (): void => notSupported(),\r\n setNotificationConfig: (_config: Record<string, unknown>): void => notSupported(),\r\n requestPermissionsAsync: (): Promise<boolean> => notSupported(),\r\n enableEventLogging: (): void => notSupported(),\r\n disableEventLogging: (): void => notSupported(),\r\n getEventLogs: (_options?: EventLogQueryOptions): EventLogEntry[] => notSupported(),\r\n clearEventLogs: (): void => notSupported(),\r\n destroyEventLogs: (): void => notSupported(),\r\n isBatteryOptimizationExempt: (): boolean => true,\r\n requestBatteryOptimizationExemption: (): Promise<boolean> => Promise.resolve(true),\r\n addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({\r\n remove: () => {},\r\n }),\r\n removeAllListeners: (_eventName: keyof ExpoBeaconModuleEvents) => {},\r\n};\r\n\r\nexport default stub;\r\n"]}
|
|
@@ -13,8 +13,10 @@ private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
|
|
|
13
13
|
private let EVENT_LOGGING_ENABLED_KEY = "expo.beacon.event_logging_enabled"
|
|
14
14
|
|
|
15
15
|
/// Number of consecutive ranging misses before emitting a distance-based exit event.
|
|
16
|
-
///
|
|
17
|
-
|
|
16
|
+
/// With ~1 s CoreLocation ranging cycles, 10 misses ≈ 10 s of silence before
|
|
17
|
+
/// declaring exit — tolerant of brief ranging gaps while still responsive.
|
|
18
|
+
/// NOTE: Android uses the same value but with a different scan cycle (~2.1 s).
|
|
19
|
+
private let EXIT_MISS_THRESHOLD = 10
|
|
18
20
|
/// Number of consecutive readings required to confirm a distance-based enter or exit transition.
|
|
19
21
|
/// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
|
|
20
22
|
private let HYSTERESIS_COUNT = 3
|
|
@@ -22,7 +24,8 @@ private let HYSTERESIS_COUNT = 3
|
|
|
22
24
|
/// Eddystone monitoring timer interval in seconds.
|
|
23
25
|
private let EDDYSTONE_MONITORING_TICK_INTERVAL: TimeInterval = 2.0
|
|
24
26
|
/// Maximum age (in seconds) before a beacon is considered "not recently seen".
|
|
25
|
-
|
|
27
|
+
/// Set higher than a few tick intervals to tolerate brief BLE advertisement gaps.
|
|
28
|
+
private let EDDYSTONE_RECENTLY_SEEN_THRESHOLD: TimeInterval = 8.0
|
|
26
29
|
/// Minimum interval between consecutive distance event emissions per identifier.
|
|
27
30
|
private let DISTANCE_EVENT_THROTTLE_INTERVAL: TimeInterval = 1.0
|
|
28
31
|
|
|
@@ -313,6 +316,22 @@ public class ExpoBeaconModule: Module {
|
|
|
313
316
|
self.defaults.set(json, forKey: NOTIFICATION_CONFIG_KEY)
|
|
314
317
|
}
|
|
315
318
|
}
|
|
319
|
+
if let dist = maxDistance, (!dist.isFinite || dist <= 0) {
|
|
320
|
+
promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0")
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if let exitDist = exitDistance, (!exitDist.isFinite || exitDist <= 0) {
|
|
324
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0")
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if exitDistance != nil && maxDistance == nil {
|
|
328
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set")
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if let dist = maxDistance, let exitDist = exitDistance, exitDist < dist {
|
|
332
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance")
|
|
333
|
+
return
|
|
334
|
+
}
|
|
316
335
|
if let dist = maxDistance {
|
|
317
336
|
self.defaults.set(dist, forKey: MAX_DISTANCE_KEY)
|
|
318
337
|
} else {
|
|
@@ -435,6 +454,16 @@ public class ExpoBeaconModule: Module {
|
|
|
435
454
|
self.eventLogger = nil
|
|
436
455
|
}
|
|
437
456
|
|
|
457
|
+
// MARK: - Battery Optimization (Android-only; no-op on iOS)
|
|
458
|
+
|
|
459
|
+
Function("isBatteryOptimizationExempt") { () -> Bool in
|
|
460
|
+
return true
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
AsyncFunction("requestBatteryOptimizationExemption") { (promise: Promise) in
|
|
464
|
+
promise.resolve(true)
|
|
465
|
+
}
|
|
466
|
+
|
|
438
467
|
// MARK: - Lifecycle
|
|
439
468
|
|
|
440
469
|
OnDestroy {
|
|
@@ -744,40 +773,45 @@ public class ExpoBeaconModule: Module {
|
|
|
744
773
|
// reliably regardless of advertisement rate.
|
|
745
774
|
let maxDist = self.defaults.object(forKey: MAX_DISTANCE_KEY) as? Double
|
|
746
775
|
let exitDist = self.defaults.object(forKey: EXIT_DISTANCE_KEY) as? Double
|
|
747
|
-
let
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
"
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
"
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
776
|
+
let hasValidDistance = distance.isFinite && distance >= 0
|
|
777
|
+
if hasValidDistance || maxDist == nil {
|
|
778
|
+
let action = evaluateDistanceHysteresis(
|
|
779
|
+
identifier: identifier,
|
|
780
|
+
distance: distance,
|
|
781
|
+
maxDistance: maxDist,
|
|
782
|
+
exitDistance: exitDist,
|
|
783
|
+
entered: &eddystoneEnteredRegions,
|
|
784
|
+
enterCtrs: &eddystoneEnterCounters,
|
|
785
|
+
exitCtrs: &eddystoneExitCounters
|
|
786
|
+
)
|
|
787
|
+
switch action {
|
|
788
|
+
case .enter:
|
|
789
|
+
sendLoggedEvent("onEddystoneEnter", [
|
|
790
|
+
"identifier": identifier,
|
|
791
|
+
"namespace": ns,
|
|
792
|
+
"instance": inst,
|
|
793
|
+
"event": "enter",
|
|
794
|
+
"distance": distance
|
|
795
|
+
])
|
|
796
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
797
|
+
scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
|
|
798
|
+
case .exit:
|
|
799
|
+
cancelEddystoneTimeout(identifier: identifier)
|
|
800
|
+
sendLoggedEvent("onEddystoneExit", [
|
|
801
|
+
"identifier": identifier,
|
|
802
|
+
"namespace": ns,
|
|
803
|
+
"instance": inst,
|
|
804
|
+
"event": "exit",
|
|
805
|
+
"distance": distance
|
|
806
|
+
])
|
|
807
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
808
|
+
case .none:
|
|
809
|
+
break
|
|
810
|
+
}
|
|
779
811
|
}
|
|
780
812
|
|
|
813
|
+
guard hasValidDistance else { break }
|
|
814
|
+
|
|
781
815
|
// Throttle distance events — enter/exit above is evaluated on every
|
|
782
816
|
// callback, but distance events are rate-limited to avoid flooding JS.
|
|
783
817
|
let now = Date()
|
|
@@ -967,7 +1001,10 @@ public class ExpoBeaconModule: Module {
|
|
|
967
1001
|
continue
|
|
968
1002
|
}
|
|
969
1003
|
|
|
970
|
-
// Not seen recently —
|
|
1004
|
+
// Not seen recently — break distance hysteresis streaks and, if the
|
|
1005
|
+
// beacon was already entered, increment miss counter toward exit.
|
|
1006
|
+
eddystoneEnterCounters[identifier] = 0
|
|
1007
|
+
eddystoneExitCounters[identifier] = 0
|
|
971
1008
|
guard eddystoneEnteredRegions.contains(identifier) else { continue }
|
|
972
1009
|
|
|
973
1010
|
let count = (eddystoneMissCounters[identifier] ?? 0) + 1
|
|
@@ -1242,6 +1279,8 @@ public class ExpoBeaconModule: Module {
|
|
|
1242
1279
|
// duplicate events when both monitoring and continuous scan are active.
|
|
1243
1280
|
} else {
|
|
1244
1281
|
// No valid beacon reading — beacon may have disappeared
|
|
1282
|
+
enterCounters[identifier] = 0
|
|
1283
|
+
exitCounters[identifier] = 0
|
|
1245
1284
|
let count = (missCounters[identifier] ?? 0) + 1
|
|
1246
1285
|
missCounters[identifier] = count
|
|
1247
1286
|
|
package/package.json
CHANGED
package/src/ExpoBeaconModule.ts
CHANGED
|
@@ -122,6 +122,20 @@ declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
|
|
|
122
122
|
/** Request Bluetooth + Location permissions. Returns true if granted. */
|
|
123
123
|
requestPermissionsAsync(): Promise<boolean>;
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Check whether the app is exempt from Android battery optimizations.
|
|
127
|
+
* Always returns true on iOS and web (no equivalent concept).
|
|
128
|
+
*/
|
|
129
|
+
isBatteryOptimizationExempt(): boolean;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Request exemption from Android battery optimizations.
|
|
133
|
+
* Opens the system dialog asking the user to whitelist this app.
|
|
134
|
+
* Returns true if the dialog was shown (or already exempt), false on failure.
|
|
135
|
+
* Always resolves true on iOS and web.
|
|
136
|
+
*/
|
|
137
|
+
requestBatteryOptimizationExemption(): Promise<boolean>;
|
|
138
|
+
|
|
125
139
|
/** Enable SQLite event logging. All beacon events will be persisted to a local database. */
|
|
126
140
|
enableEventLogging(): void;
|
|
127
141
|
|
|
@@ -47,6 +47,8 @@ const stub = {
|
|
|
47
47
|
getEventLogs: (_options?: EventLogQueryOptions): EventLogEntry[] => notSupported(),
|
|
48
48
|
clearEventLogs: (): void => notSupported(),
|
|
49
49
|
destroyEventLogs: (): void => notSupported(),
|
|
50
|
+
isBatteryOptimizationExempt: (): boolean => true,
|
|
51
|
+
requestBatteryOptimizationExemption: (): Promise<boolean> => Promise.resolve(true),
|
|
50
52
|
addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({
|
|
51
53
|
remove: () => {},
|
|
52
54
|
}),
|