@bearound/react-native-sdk
v3.8.2
Published
bearound
Downloads
396
Readme
🐻 Bearound React Native SDK
Official SDK to integrate Bearound's secure BLE beacon detection into React Native apps (Android and iOS).
Aligned with Bearound native SDKs 3.8.0 (exact pins live in android/build.gradle and BearoundReactSdk.podspec, kept in lockstep by scripts/check-native-versions.mjs).
✅ Compatible with New Architecture (TurboModules) and also compatible with classic architecture.
[!TIP] ⚡ Set it up with an AI agent. Don't wire the iOS/Android background integration by hand — hand one prompt to your AI coding agent (Claude Code, Cursor, Copilot) and let it pilot the whole install, pausing only for the few human-only steps. → Set up with an AI agent
Table of Contents
- Requirements
- Installation
- Expo
- Set up with an AI agent
- Permission Configuration
- Wi-Fi observations
- Presence heartbeat
- Advertising identifier (IDFA / AAID)
- Scan modes (Android)
- iOS Background Integration (required)
- Quick Start
- API
- Best Practices
- Troubleshooting
- Migrating from 2.x
- License
Requirements
- React Native ≥ 0.73
- Expo SDK 53+ (with the config plugin) — dev client or EAS Build, not Expo Go
- Android: minSdk 24+ (the library builds with
minSdkVersion 24); Android 12+ requires theBLUETOOTH_SCANruntime permission ("Nearby devices") - iOS: iOS 13+ (recommended 15+), Bluetooth and Location enabled
Important: The SDK does not work on iOS simulator for BLE (use physical device).
Installation
In your React Native project:
# with yarn
yarn add @bearound/react-native-sdk
# or with npm
npm i @bearound/react-native-sdkiOS
In the ios folder:
cd ios
pod installThe Podspec declares a CocoaPods dependency on the native
BearoundSDKpod (exact version pin — seeBearoundReactSdk.podspec), resolved automatically bypod install. If yourPodfileusesuse_frameworks!, prefer static:use_frameworks! :linkage => :static
Android
No additional Gradle configuration is needed beyond permissions. The native Android SDK is resolved as a module dependency.
Expo
The SDK ships an Expo config plugin, so an Expo app gets the whole native setup — background modes, BGTask identifiers, usage strings, entitlements and the AppDelegate wiring — from one line in app.json.
Two things to know before you start:
- Expo Go cannot run this SDK. It bundles native code, and Expo Go only ships the modules Expo compiled into it. Use a development build (
npx expo run:ios/run:android, or EAS Build). - Under CNG, hand edits to
ios/andandroid/do not survive.expo prebuildregenerates those folders from your config, so anything you paste intoAppDelegate.swiftby hand is gone on the next prebuild — silently, and only background/terminated detection breaks. That is exactly what the plugin exists to prevent.
Config plugin
npx expo install @bearound/react-native-sdk{
"expo": {
"plugins": [
["@bearound/react-native-sdk", {
"usageDescriptions": {
"NSLocationAlwaysAndWhenInUseUsageDescription": "Allow \"Always\" so we can show you nearby offers even when the app is closed."
}
}]
]
}
}npx expo prebuild --clean # or just build: prebuild runs as part of itWhat it applies, on every prebuild:
| Where | What |
|---|---|
| Info.plist | the five UIBackgroundModes (fetch, location, processing, bluetooth-central, remote-notification) and both BGTaskSchedulerPermittedIdentifiers (io.bearound.sdk.sync, io.bearound.sdk.processing) |
| Info.plist | the four Bluetooth/Location NS…UsageDescription strings — only if your config does not already declare them, so ios.infoPlist and the usageDescriptions prop always win |
| <app>.entitlements | aps-environment (silent-push wake) and com.apple.developer.networking.wifi-info (Access WiFi Information) |
| AppDelegate.swift | the SDK delegate, registerBackgroundTasks(), APNs registration, background fetch, the silent-push handler and the background-URLSession handoff |
| AndroidManifest.xml | nothing by default — the native SDK declares its own permissions and the manifest merger injects them. ACCESS_BACKGROUND_LOCATION only when you opt in with backgroundWifi |
Expo's
AppDelegateis not the bare React Native one. It subclassesExpoAppDelegate, which already implements the push, fetch and background-session callbacks and forwards them to the Expo modules in your app. So every method the plugin injects is anoverridethat callssuper—expo-notifications,expo-background-taskand friends keep receiving everything they did before. The plugin also never touches theUNUserNotificationCenterdelegate:expo-notificationsowns it, and reassigning it would steal your app's push routing. To show notifications while the app is in the foreground, useNotifications.setNotificationHandler()in JS.
The injected code sits between // @generated begin bearound / // @generated end bearound markers and is rewritten in place, so re-running prebuild never duplicates it. Verified against the expo-template-bare-minimum AppDelegate of SDK 53, 54, 55, 56 and 57.
The JS side is the same as anywhere else — configure() on mount, then ensurePermissions(), then startScanning(): see Quick Start. If your app pulls in Firebase or anything else that requires static frameworks, set useFrameworks: "static" via expo-build-properties; the SDK works either way.
If your app is already configured
The plugin is additive, and it never overwrites a decision your app already made:
Info.plist— background modes and BGTask ids are merged as a union (your other modes stay); theNS…UsageDescriptionstrings are written only when absent, soios.infoPlistand theusageDescriptionsprop win.<app>.entitlements— anaps-environmentyou already set is kept as is.AndroidManifest.xml— nothing is removed;ACCESS_BACKGROUND_LOCATIONis added only withbackgroundWifi.AppDelegate.swift— if your app (or another config plugin) already implements one of these callbacks, the plugin skips that one rather than emitting a second declaration, which Swift would reject asinvalid redeclaration. It prints a warning naming what it skipped: that method is then yours to keep correct, so make sure it carries the matching Bearound call from §1.- Listing the plugin twice is harmless — it runs once per prebuild.
So an app that had been wired by hand and later adds the plugin still builds; you just have to reconcile whatever the warning names.
Parity with the reference apps
The plugin was diffed against the two apps that are known to work in background — the native iOS example and this repo's own React Native example. Every SDK touch point that drives background and terminated-state operation is present, identically:
BeAroundSDK.shared.delegate · registerBackgroundTasks() · registerForRemoteNotifications() · launchOptions[.location] / [.bluetoothCentrals] · performBackgroundFetch · setPushToken · performBackgroundBLERefreshAndSync (silent push) · handleBackgroundURLSessionEvents — plus the same UIBackgroundModes and BGTaskSchedulerPermittedIdentifiers, and a signed aps-environment. The plugin adds one thing the examples do not have: it only claims the background URLSession whose identifier is the SDK's, so another library's transfers still reach super.
What the plugin deliberately leaves to your app — all of it about notifications, none of it about detection:
| Reference apps do | Plugin does not | Why it does not affect detection |
|---|---|---|
| UNUserNotificationCenter.current().delegate = self | — | expo-notifications owns that delegate; stealing it breaks your app's push routing. Use Notifications.setNotificationHandler(). |
| requestAuthorization([.alert, .sound, .badge]) | — | Ask through expo-notifications (requestPermissionsAsync()), so one library owns the prompt. |
| willPresent → .banner | — | Foreground presentation only. setNotificationHandler covers it. |
| Post a local "App reactivated" notification | — | That notification belongs to the example app, not the SDK. |
Waking on beacon entry, BGTasks, the silent-push wake and the background upload handoff are all driven by CoreLocation, BGTaskScheduler, APNs and URLSession — none of them go through UNUserNotificationCenter. The SDK never posts a notification; it only reads the authorization status for telemetry (so with no notification permission, that one telemetry field reports denied).
This still costs you something concrete: your field test goes blind. The 3-state test in §6 uses a local notification as the proof that the app woke up in background. On Expo, install
expo-notifications, request permission and set a handler — otherwise background detection may be working perfectly and you will have no way to see it.
One more difference to know: NSUserTrackingUsageDescription is in the reference apps but is written only if you pass trackingUsageDescription. Without it there is no ATT prompt, so no advertising identifier — silently.
Plugin options
All optional.
| Prop | Default | What it does |
|---|---|---|
| usageDescriptions | generic copy | Overrides the NS…UsageDescription strings your users read in the iOS dialogs. Apple reviews this wording against what your app really does — write your own. |
| trackingUsageDescription | (unset) | Adds NSUserTrackingUsageDescription. Without it iOS never shows the App Tracking Transparency prompt, so the SDK reports no advertising identifier — silently, nothing errors. |
| backgroundWifi | false | Declares ACCESS_BACKGROUND_LOCATION on Android. Only needed to keep Wi-Fi observations coming while the app is in the background — beacon detection never needs it, and it costs a Play policy review. |
| wifiInfo | true | Adds the iOS Access WiFi Information entitlement. |
| apsEnvironment | "development" | The aps-environment entitlement. EAS Build sets production for release builds; pass false to manage it yourself. |
| appDelegate | true | false skips the AppDelegate wiring — background and terminated-state detection stop working. Only for apps that wire it themselves. |
["@bearound/react-native-sdk", { "backgroundWifi": true, "trackingUsageDescription": "We use your advertising identifier to measure store visits." }]What still needs you
The plugin writes project files. It cannot touch your Apple/Google accounts or a physical device:
- Push credentials. The entitlement is only half of it — APNs needs a push key on your App ID.
eas credentials(or Xcode signing) is what creates it. Without itdidFailToRegisterForRemoteNotificationsWithErrorfires, no token ever reaches the backend, and the terminated-state wake stays dead. - On device: grant Always location and turn on Background App Refresh.
- Google Play: the
connectedDeviceforeground-service declaration + demo video, if you use foreground-service scan mode.
Verify the prebuild before you trust it:
npx expo prebuild --clean
plutil -p ios/*/Info.plist | grep -E "UIBackgroundModes|BGTaskScheduler" -A 8
grep -c "@generated begin bearound" ios/*/AppDelegate.swift # expect 2Already ejected — ios/ and android/ committed and no longer prebuilt? Then the plugin does not run: wire it by hand from iOS Background Integration.
Set up with an AI agent
Instead of wiring the intricate iOS/Android background setup by hand, hand it to an AI coding agent (Claude Code, Cursor, Copilot, …). This README is written to be agent-readable — the agent reads it and does the whole integration. There's one ready-made prompt to give it:
Open AI-AGENT-SETUP.md and click the copy icon on its code block — GitHub shows one on every code block, and it drops the prompt on your clipboard. Then paste it into your agent with your app's repo open. Web-capable agents can fetch its raw URL directly.
The agent will pause for these human-only steps — they need your Apple/Google accounts and a physical device, so no SDK or agent can do them:
- Xcode → Push Notifications capability on your app target, signed with your push-enabled App ID / provisioning profile. Set
aps-environmenttodevelopmentfor Debug andproductionfor Release — see §3. - On device: grant Always location and turn on Background App Refresh.
- Google Play: the
connectedDeviceforeground-service declaration + demonstration video required at review — see Scan modes.
On Expo, the prompt takes the config-plugin route instead of editing native files: the entitlements and Info.plist keys above are written for you, and what stays human-only is the APNs push key (eas credentials), the on-device grants and the Play declaration.
Prefer to wire it by hand? Everything the prompt references is spelled out in the sections below.
Permission Configuration
Android – Manifest
You normally don't need to add any permissions — the native SDK declares them all and the Android manifest merger injects them into your app automatically. The SDK uses the connectedDevice foreground-service model (Bluetooth), not location.
⚠️ If you redeclare
BLUETOOTH_SCAN, keep theneverForLocationflag (and addxmlns:toolsto your<manifest>tag). If any declaration omits it, the flag is dropped from the merged manifest and Google treats the app as deriving location.
For reference, the SDK declares:
<!-- Bluetooth -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<!-- Location: legacy only (BLE scan on API <= 30); not requested on API 31+ -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
<!-- Foreground service: connectedDevice (BLE) on Android 14+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />Runtime (Android 12+): request
BLUETOOTH_SCANandPOST_NOTIFICATIONSat runtime. BecauseBLUETOOTH_SCANis declared withneverForLocation, no location permission is required for scanning on API 31+. This package exposes a helperensurePermissionsto facilitate this.
iOS – Info.plist and Background Modes
In Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>location</string>
<string>processing</string>
<string>bluetooth-central</string>
<string>remote-notification</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to detect nearby locations and provide relevant features.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to detect nearby locations and provide relevant features.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses your location to detect nearby locations and provide relevant features.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Allow "Always" so this app can detect nearby locations in the background.</string>These four NS…UsageDescription strings appear in your users' iOS permission dialogs — keep them generic and benefit-oriented (avoid internal jargon like "beacon") and tailor them to what your app actually does; Apple reviews the rationale, so it must match your real use.
Important for terminated app detection:
- Waking the app on beacon detection is done by CoreLocation region monitoring (the
locationbackground mode + "Always" permission) —fetchdoes not wake the app for beacons; it only grants periodic background windows the SDK uses to sync queued dataremote-notificationenables the silent-push wake vector — the only mechanism that resurrects a fully terminated app (see iOS Background Integration)- User must grant "Always" location permission
- User must enable "Background App Refresh" in Settings > General > Background App Refresh
Wi-Fi observations
Alongside each beacon sighting the SDK reports the access points visible at that moment. An access point seen repeatedly next to a known beacon gets a position of its own, and from then on it can place a device even where no beacon reaches.
No network name is used as identity. What travels is apId — a one-way hash of the
access point's hardware address, canonicalised so the same router yields the same identifier
on both platforms.
Collection is on by default and costs you nothing extra. No separate dialog, no Play policy review, no demonstration video:
| Platform | What it reports | What you must do |
|---|---|---|
| Android | The connected access point and its neighbours, with RSSI | Nothing — requestPermissions() already asks for NEARBY_WIFI_DEVICES on 13+, in the same "Nearby devices" group as Bluetooth, so usually no second dialog appears |
| iOS | Only the connected access point, without RSSI (there is no public API for neighbours) | Add the Access WiFi Information capability — one checkbox in Xcode (Signing & Capabilities → + Capability), or automatic with the Expo config plugin |
Nothing degrades if you skip the iOS capability: the field is simply omitted and every other feature behaves the same.
⚠️ In the background, Wi-Fi needs one permission more — and its absence is invisible
The table above is about what unlocks collection. It says nothing about for how long — and that is the part that surprises people.
- Android: from Android 10 on, a backgrounded app without
ACCESS_BACKGROUND_LOCATIONgets an empty scan list and the placeholder BSSID02:00:00:00:00:00. No error, no exception: the SDK discards the placeholder andwifis[]simply arrives empty. Measured in production — 25 access points dropped to zero the instant the app was backgrounded, with every permission it had asked for granted.- iOS: with
.whenInUsethe system stops revealing the access point in the background and returnsnil..alwaysis what the SDK asks for — but do not count on it keeping collection alive. In a field run on iPhone 17 Pro Max / iOS 27 withlocation: authorized_alwaysgranted, over 300 payloads sent while the app sat in the background arrived withwifis: nullandlocation: null, while foreground payloads carried both. The one background payload that did carry them was the cold relaunch — the app resurrected by iOS reported an access point and a fix 88 ms into its life, then went quiet again. So treat iOS background Wi-Fi as opportunistic, not guaranteed, and verify on your target OS version.A fleet spends nearly all its time in the background, so "foreground only" means almost never in practice — while a hand test with the app open passes perfectly.
Beacon detection is not affected either way: on Android 12+ the scan runs on
BLUETOOTH_SCAN(neverForLocation), with no location permission at all.That is why
requestPermissions()deliberately does not ask for background location: unlike Wi-Fi collection itself, it is a dangerous permission with a Google Play policy review and a demonstration video attached. If your app contributes to the access-point map, ask for it explicitly:import { requestBackgroundLocation } from '@bearound/react-native-sdk'; // only AFTER foreground location has been granted (Android 11+ refuses both // in a single dialog). On iOS this resolves to the "Always" authorization. const ok = await requestBackgroundLocation();On Expo, declare the Android permission with the plugin prop
backgroundWifi: true— otherwise the request resolves toNEVER_ASK_AGAINbecause the manifest never declared it.Check the outcome in the payload:
device.permissions.backgroundLocation.
Presence heartbeat
Until 3.8.0 a device that saw no beacon and no other SDK device stayed silent, and the backend could not tell "there was no coverage here" apart from "the app was not running". Those two look identical in the data and mean opposite things.
Now a scan that finds nothing still reports, under the presence_heartbeat sync trigger,
carrying what the device can observe: its own location and the Wi-Fi around it.
BeAround.configure({
businessToken: 'your-business-token',
presenceHeartbeatIntervalMs: 5 * 60 * 1000, // default
});| | |
|---|---|
| Default | 5 minutes (300000) |
| Accepted range | 1 minute – 1 hour (clamped natively, with a log warning) |
| Turn it off | 0 |
Two things it does not do. It never throttles scanning — only the upload, so detection latency is untouched. And it sends nothing when there is neither a location fix nor an access point to report: an app that grants no permissions keeps sending exactly what it sent before.
Advertising identifier (IDFA / AAID)
The SDK can report the advertising identifier — what makes audiences built from beacon visits usable in ad platforms.
iOS — you must ask. Add to your Info.plist:
<key>NSUserTrackingUsageDescription</key>
<string>We use this identifier to measure visits and show you more relevant offers.</string>and call it in the foreground, at a point in your onboarding where the user has just been told why:
import { requestTrackingAuthorization } from '@bearound/react-native-sdk';
const status = await requestTrackingAuthorization();
// 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unavailable'Without the key iOS shows no dialog at all and the status stays notDetermined forever.
Answering is a one-time event per install, so it is safe to call on every launch. To read the
state without prompting, use getTrackingAuthorizationStatus().
Android — nothing to ask. There is no prompt: the user's choice lives in system settings and the platform enforces it (opting out zeroes the id and the SDK reports none). To receive an id at all, your app needs Play Services on the classpath:
implementation 'com.google.android.gms:play-services-ads-identifier:18.2.0'Store obligations follow the feature, not the SDK: prompting for tracking obliges you to declare Tracking in your App Store privacy label; the
AD_IDpermission obliges you to tick "Device or other IDs" in the Play Data Safety form. Apps for children must stripAD_ID(Play Families policy).
Scan modes (Android)
On iOS scanning is always system-managed (region monitoring +
BGTaskScheduler). These modes are Android-only.
The SDK ships two background-scan strategies — you pick per app. Both already exist in the native SDK; you just choose which one to turn on.
At a glance — what you gain
| | 🪶 Opportunistic (default) | 🛡️ Foreground service | |---|---|---| | Best for | casual presence, battery-first apps | real-time footfall, mission-critical presence | | You gain | zero setup · no Play video · lowest battery | reliable detection that survives app-kill & aggressive OEMs | | You accept | unpredictable latency · misses in deep background | persistent notification + Play demo video |
Mode 1 — Opportunistic (no foreground service) · default
What you gain: no FOREGROUND_SERVICE_CONNECTED_DEVICE permission, no Play demonstration video, lowest battery.
import { startScanning } from '@bearound/react-native-sdk';
await startScanning(); // PendingIntent/AlarmManager — no foreground serviceThe OS delivers beacons via a PendingIntent scan re-armed by AlarmManager — it keeps working with the app killed, but the system decides when (throttled).
To fully drop the Play video, also remove the FGS permission the native SDK injects via manifest merge:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"
tools:node="remove" />Mode 2 — Foreground service (connectedDevice)
What you gain: continuous, low-latency detection that survives app-kill and aggressive OEMs (Xiaomi/Huawei/Samsung) — the reliable path for footfall/presence.
import { startScanning, enableForegroundScanning } from '@bearound/react-native-sdk';
await startScanning();
// By default the notification shows the host app's own name (localized by the
// device) + a generic, localized subtitle ("Atualizando conteúdo" / "Updating
// content") — nothing about Bluetooth or reading data.
await enableForegroundScanning();
// Want a custom title/subtitle instead? Pass them explicitly:
// await enableForegroundScanning({ notificationTitle: 'My App', notificationText: 'Bluetooth active' });⚠️ Google Play: the
connectedDeviceforeground service requires a Play Console declaration + demonstration video. In the video/declaration, frame the feature as reading data from external Bluetooth devices — never as location or proximity (stays consistent withneverForLocation). The persistent notification itself just shows the app name, which is enough to satisfy the perceptibility requirement.
Trade-off
| | 🪶 Opportunistic | 🛡️ Foreground service |
|---|---|---|
| App in foreground | continuous | continuous |
| App in background | opportunistic, throttled | continuous |
| App killed / swiped away | relaunched by OS (PendingIntent) | process kept alive |
| Aggressive OEM (Xiaomi/Huawei) | ❌ killed | ✅ survives |
| Detection latency | unpredictable (s → min) | low (per scan precision) |
| Presence accuracy | low / medium | high |
| Battery | lower | higher |
| Persistent notification | none | yes (mandatory) |
| Extra permission | none | FOREGROUND_SERVICE_CONNECTED_DEVICE |
| Google Play video | ❌ not required | ✅ required |
Advanced: WorkManager (client-side)
The SDK doesn't bundle WorkManager. For a predictable low-frequency sweep without a foreground service, schedule your own periodic worker (minimum interval 15 min) that calls startScanning() for a short window and then stopScanning(). Trades latency for battery and avoids the Play video — presence lags by up to the chosen period.
Silent-push wake-up (Android)
A silent FCM data message can wake a killed or backgrounded app to restart the scan and sync immediately — the Android counterpart to the iOS silent-push wake vector. Forward the message to the SDK from your Firebase background handler:
// index.js — registered at module scope, before AppRegistry.registerComponent.
import messaging from '@react-native-firebase/messaging';
import { handleRemoteMessage } from '@bearound/react-native-sdk';
// Runs in a headless JS task when a data message arrives with the app in
// background or killed. Forward the payload; the SDK restarts the scan + syncs
// when it recognizes a Bearound wake and resolves `true`.
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
await handleRemoteMessage(remoteMessage.data);
});Requires @react-native-firebase/messaging and a data message (not notification-only, which the OS delivers to the tray without waking JS). iOS needs none of this — the silent push is handled by the AppDelegate wiring in iOS Background Integration; calling handleRemoteMessage there simply resolves false for any non-Bearound payload.
iOS Background Integration (required)
This section is the consumer contract for background and terminated-state operation. Without this wiring the SDK still works in foreground, but it silently degrades in background: terminated-state uploads never finalize, BGTasks never run, and the app is never woken once iOS kills it.
On Expo (CNG), do not do any of this by hand — the config plugin applies this same contract on every prebuild, adapted to Expo's
ExpoAppDelegate(every method anoverridethat callssuper). EditingAppDelegate.swiftyourself works until the nextexpo prebuildwipes it. Read this section to understand what is wired and why; let the plugin do the wiring.
The snippets below are the example app verbatim: §1 is the complete AppDelegate and §2 + the usage-description strings in Permission Configuration → iOS together form the complete Info.plist. Copy them as-is (changing only the module name, marked in §1).
Template note: §1 is the Swift
AppDelegate(the React Native ≥ 0.77 default —RCTReactNativeFactory/ReactNativeDelegate).BeAroundSDKis a pure-Swift API (not@objc), so it cannot be called from an Objective-CAppDelegate.mm. If your app still shipsAppDelegate.mm(common on RN 0.73–0.76), you must migrate the target to a SwiftAppDelegateand wire the calls there — on RN 0.73–0.76 that's a SwiftAppDelegatesubclassingRCTAppDelegate; on ≥ 0.77 it's theRCTReactNativeFactorytemplate above. A bridging header (Objective-C → Swift) does not help here.
1. AppDelegate wiring
This is the complete AppDelegate from the proven-working example (example/ios/BearoundReactSdkExample/AppDelegate.swift) — copy it as-is (changing only the module name, marked below). Every method here is load-bearing for background/terminated detection; nothing is optional or Firebase-specific. Two rules make it work:
- Touch
BeAroundSDK.sharedsynchronously indidFinishLaunchingWithOptions, before the React Native bootstrap and before youreturn. Accessing it runs the SDK init, which auto-restores the saved config and re-arms region monitoring while the BLE state-restoration window is still open; the region-enter callback then fires asynchronously. The SDK delegate is a runtime object (not persisted), so it must be re-set now — deferring it to the async JSconfigure()path is too late and races the relaunch region event. - The class conforms to
UNUserNotificationCenterDelegateand importsUserNotifications, so foreground banners, APNs registration, and the silent-push handler are wired unconditionally — not hidden behind a Firebase check. These push methods cover the user-force-quit resurrection vector — the one case CoreLocation region monitoring cannot wake:registerForRemoteNotifications()obtains the APNs token,didRegisterForRemoteNotificationsWithDeviceTokenforwards it, anddidReceiveRemoteNotificationhandles the cold-launch silent push that arrives before the JS swizzle installs. (Background/terminated wake on beacon entry is driven separately by CoreLocation region monitoring — see §2.)
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import BearoundSDK
import BearoundReactSdk
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// CRITICAL — terminated/background relaunch path:
// When iOS relaunches the app from a beacon-region event, accessing
// BeAroundSDK.shared runs its init, which auto-restores the saved config and
// RE-ARMS region monitoring synchronously. The region-enter callback then
// fires asynchronously on the run loop. The SDK delegate is a runtime object
// (not persisted), so it MUST be re-set NOW — before that callback fires —
// otherwise didEnterBeaconRegion lands on a nil delegate and the persisted
// log + local notification never happen. Doing this via the async JS
// configure() path is too late and races the relaunch region event.
BeAroundSDK.shared.delegate = RNBearoundBridge.shared
// Register background tasks BEFORE app finishes launching
BeAroundSDK.shared.registerBackgroundTasks()
// Become the notification delegate so banners show while the app is in the
// foreground (iOS suppresses them by default without willPresent → .banner).
UNUserNotificationCenter.current().delegate = self
// Request notification permissions for background alerts
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
if granted {
NSLog("[Bearound] Notification permission granted")
} else if let error = error {
NSLog("[Bearound] Notification permission error: %@", error.localizedDescription)
}
}
// Register for remote (silent) push — the ONLY vector that wakes a
// user-force-quit app on iOS. This triggers APNs registration; the raw
// token arrives in didRegisterForRemoteNotificationsWithDeviceToken below.
// Requires the app target to have the Push Notifications capability
// (the signed `aps-environment` entitlement) — no SDK can add it for you.
application.registerForRemoteNotifications()
// If iOS relaunched us due to a region/bluetooth event, surface it immediately
// (the SDK auto-restores scanning from storage; we don't reconfigure here so
// the user's saved scan precision is preserved).
if launchOptions?[.location] != nil {
NSLog("[Bearound] App launched due to LOCATION event (beacon region entry)")
postRelaunchNotification()
}
if launchOptions?[.bluetoothCentrals] != nil {
NSLog("[Bearound] App launched due to BLUETOOTH event (state restoration)")
postRelaunchNotification()
}
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "YourAppName", // 👈 replace with YOUR registered module name (AppRegistry.registerComponent / app.json "name")
in: window,
launchOptions: launchOptions
)
return true
}
// Handle background fetch - called by iOS when app needs to refresh data
func application(
_ application: UIApplication,
performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
NSLog("[Bearound] Background fetch triggered")
BeAroundSDK.shared.performBackgroundFetch { success in
completionHandler(success ? .newData : .noData)
}
}
// Raw APNs token → SDK. The backend pushes via APNs (not FCM), so we forward
// the RAW device token. The SDK also auto-captures it via swizzle, but
// forwarding explicitly is the robust path (the swizzle is intercepted when
// Firebase is present). Idempotent — setting the same token twice is a no-op.
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
NSLog("[Bearound] APNs token registered (%d bytes)", deviceToken.count)
BeAroundSDK.shared.setPushToken(token)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
// Common cause: the Push Notifications capability / aps-environment
// entitlement is missing from the app target (nothing the SDK can fix).
NSLog("[Bearound] APNs registration failed: %@", error.localizedDescription)
}
// Silent push (cold-launch race): the SDK's push swizzle only installs once
// configure() runs — in RN that's after JS boots. iOS delivers the
// launch-triggering push before that, so handle it here. After configure(),
// the SDK's swizzle consumes bearound pushes itself (no double-handling).
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard userInfo["bearound"] != nil else {
completionHandler(.noData)
return
}
NSLog("[Bearound] Silent push received (bearound) — triggering BLE refresh + sync")
BeAroundSDK.shared.performBackgroundBLERefreshAndSync(
bleScanDuration: 10,
trigger: "silent_push"
) { success in
completionHandler(success ? .newData : .noData)
}
}
// Background URLSession: iOS relaunches the app to deliver completed beacon-upload
// transfers. Forward to the SDK so it finalizes the pending upload(s), invokes their
// delegate callbacks (batch removal on success) and calls the system completion handler.
// Without this, terminated-state uploads complete in nsurlsessiond but the app is never
// re-attached to process the result.
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
NSLog("[Bearound] handleEventsForBackgroundURLSession: %@", identifier)
BeAroundSDK.shared.handleBackgroundURLSessionEvents(
identifier: identifier,
completionHandler: completionHandler
)
}
// Present banners + sound while the app is in the FOREGROUND. Without this,
// iOS silently drops notifications added while the app is active.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound, .badge])
}
private func postRelaunchNotification() {
let content = UNMutableNotificationContent()
content.title = "App reactivated"
content.body = "Bearound detected a beacon region in the background"
content.sound = .default
let request = UNNotificationRequest(
identifier: "bearound-relaunch-\(UUID().uuidString)",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
self.bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}The three APNs methods above (
registerForRemoteNotifications,didRegisterForRemoteNotificationsWithDeviceToken,didReceiveRemoteNotification) do nothing without the Push Notifications capability (§3): if the signedaps-environmententitlement is missing,didFailToRegisterForRemoteNotificationsWithErrorfires and no token ever arrives.
2. Info.plist — background modes and BGTask identifiers
Declare the full UIBackgroundModes list and the two BGTask identifiers the SDK schedules:
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>fetch</string>
<string>processing</string>
<string>location</string>
<string>remote-notification</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>io.bearound.sdk.sync</string>
<string>io.bearound.sdk.processing</string>
</array>Without the BGTaskSchedulerPermittedIdentifiers entries, registerBackgroundTasks() cannot register the SDK's BGTasks and iOS will never grant them execution time.
These background-mode keys are only half of the Info.plist. Also add the four Bluetooth/Location usage-description strings —
NSBluetoothAlwaysUsageDescription,NSBluetoothPeripheralUsageDescription,NSLocationWhenInUseUsageDescription,NSLocationAlwaysAndWhenInUseUsageDescription— from Permission Configuration → iOS. Copy only thoseNS…UsageDescriptionstrings from that section (itsUIBackgroundModesblock is the same one shown here — don't declare it twice). Without the usage strings, iOS silently denies Bluetooth/Location at runtime even with the background modes set.
3. Push Notifications capability (silent-push wake vector)
Enable the Push Notifications capability on your app target in Xcode (Signing & Capabilities → + Capability → Push Notifications). This adds the aps-environment entitlement.
developmentvsproduction— the value matters.aps-environmentselects the APNs environment:development= sandbox (debug builds installed from Xcode /devicectl),production= production APNs. TestFlight and App Store builds use the production environment, so a distribution build needsaps-environment=production— a build shipped withdevelopmentregisters its token on the wrong server and the silent-push wake silently fails in production only. The robust setup drives the value per build configuration (developmentin Debug,productionin Release) via a build-setting variable (aps-environment = $(APS_ENVIRONMENT)) or a separate Release entitlements file. The bundled example usesdevelopmentbecause it is only ever run as a development build.
Without it, the SDK's automatic APNs token capture silently gets no token, and the silent-push wake vector — the only mechanism that resurrects a fully terminated app — never works. Everything else still compiles and runs, which is exactly why this is easy to miss.
The capability by itself yields no token — something must trigger APNs registration. That trigger is the application.registerForRemoteNotifications() call wired in §1; without it, enabling the capability produces no APNs token and the silent-push wake vector stays dead.
4. Call configure() on app mount
Call configure() when your root component mounts (e.g. in a useEffect), not behind a button press. The SDK's push swizzle (automatic APNs token capture + silent-push handling) only installs once configure() runs in the process — if the user never taps the button after a relaunch, the app never registers for pushes in that process.
useEffect(() => {
BeAround.configure({ businessToken: 'your-business-token' });
}, []);5. Using Firebase Messaging / disabled swizzling?
§1 already forwards the APNs token and handles the silent push directly on your AppDelegate — that explicit wiring is the robust default, not an escape hatch. The SDK also auto-captures the APNs token and consumes bearound silent pushes via AppDelegate swizzling as a fallback, but the swizzle is intercepted whenever Firebase (or any other push library) swizzles first. Relying on the swizzle alone is exactly how an app ends up with a NULL push token in the backend and no terminated-state wake (a real production failure we have seen). So keep the §1 wiring regardless of whether you use Firebase.
Two cases:
- You own the
AppDelegate(the §1 setup): you're done. The raw APNs token is forwarded viaBeAroundSDK.shared.setPushToken(...)indidRegisterForRemoteNotificationsWithDeviceToken, and the cold-launch silent push is handled indidReceiveRemoteNotification. Nothing else to do. - Firebase (or another library) owns the push delegates — or you opted out with
BearoundAppDelegateProxyEnabled = NOinInfo.plist: the SDK's swizzle won't fire and your nativedidRegister…may never run. Forward the token from JS instead. On iOS, forward the raw APNs token (messaging().getAPNSToken()) — not the FCM token:
import { setPushToken } from '@bearound/react-native-sdk';
// e.g. from your Firebase Messaging token-refresh handler.
// On iOS forward the RAW APNs token, not the FCM token.
await setPushToken(token);setPushToken is idempotent, so forwarding the same token from both the native delegate and JS is safe.
6. Verify it works
Foreground detection working is not proof that background/terminated detection is wired — every gap above fails silently while the app is open. Run these checks before you ship.
Static config
plutil -lint ios/YourApp/Info.plistprintsOK.plutil -p ios/YourApp/Info.plistshows all five background modes (fetch,location,processing,bluetooth-central,remote-notification) and bothBGTaskSchedulerPermittedIdentifiers(io.bearound.sdk.sync,io.bearound.sdk.processing).- Your app target's
.entitlementscontainsaps-environment, andCODE_SIGN_ENTITLEMENTSpoints to it in both the Debug and Release build configurations — a Debug-only wiring builds fine but ships a Release/TestFlight build with no push entitlement.
Runtime
getAuthorizationStatus()resolves'always'(not'whenInUse') — Always location is what delivers region events in background/terminated state.- Background App Refresh is on (Settings → General → Background App Refresh, plus the per-app toggle).
- On launch, the Xcode console prints
APNs token registered (… bytes)— proof thatregisterForRemoteNotifications()→setPushTokenfired. If you instead seeAPNs registration failed, the Push Notifications capability /aps-environmententitlement is missing (§3).
End-to-end (the real test)
- Foreground: walk near a real Bearound beacon — beacons appear in the list and a banner shows (proves
willPresent). - Background: background the app, walk into range — the "App reactivated" local notification fires.
- Terminated: force-quit the app (swipe up in the app switcher), then either walk into a beacon region or have the backend send the
bearoundsilent push. Expect the "App reactivated" notification and, on next foreground,getPersistedLog()entries written while the app wasn't running, withgetPendingBatchCount()draining to0(proves the background URLSession upload finalized viahandleEventsForBackgroundURLSession).
Quick Start
Three rules the snippet below follows — all come from how the SDK actually works:
configure()runs on mount (in auseEffect), not behind a button. The SDK's push swizzle (automatic APNs token capture + silent-push handling) only installs onceconfigure()runs in the process — see §4 of iOS Background Integration.- The Android permission gate depends on the OS version. On Android 12+ the only permission that unlocks scanning is
BLUETOOTH_SCAN("Nearby devices") — location does not unlock BLE scan there (the SDK declaresneverForLocation). On Android ≤ 11 it's the opposite: location is what unlocks scanning. Do not gate onbtConnect/backgroundLocation— the SDK doesn't need them to scan, and on 12+ they can never all be granted (the location permissions are declared withmaxSdkVersion="30"). - Android background needs the foreground service. For reliable background detection, call
enableForegroundScanning()afterstartScanning()— it's what the proven-working example does. The opportunistic default is throttled by the OS and killed outright by aggressive OEMs (Xiaomi/Huawei/Samsung). It shows a persistent notification and needs a Play Console declaration + demo video — see Scan modes.
import React, { useEffect } from 'react';
import { Alert, Button, View, Platform } from 'react-native';
import * as BeAround from '@bearound/react-native-sdk';
import { ensurePermissions } from '@bearound/react-native-sdk';
export default function App() {
useEffect(() => {
// On mount — installs the push swizzle in every process, including
// background relaunches where the user never taps anything.
BeAround.configure({
businessToken: 'your-business-token',
scanPrecision: BeAround.ScanPrecision.HIGH,
maxQueuedPayloads: BeAround.MaxQueuedPayloads.MEDIUM,
});
}, []);
const start = async () => {
// askBackground: false — background location is NOT needed for scanning.
// Only pass true if your app declares ACCESS_BACKGROUND_LOCATION itself
// (the SDK doesn't); otherwise the request is auto-denied and the helper
// bounces the user to Settings.
const status = await ensurePermissions({ askBackground: false });
const ok =
Platform.OS === 'android'
? Number(Platform.Version) >= 31
? status.btScan // Android 12+: BLUETOOTH_SCAN is the only scan gate
: status.fineLocation // Android ≤ 11: location unlocks BLE scan
: true; // iOS: either eye works (Location OR Bluetooth) — don't hard-block
if (!ok) {
Alert.alert(
'Permissions',
Number(Platform.Version) >= 31
? 'Allow "Nearby devices" to detect beacons.'
: 'Allow Location to detect beacons.'
);
return;
}
await BeAround.startScanning();
if (Platform.OS === 'android') {
// Reliable background detection on Android — the opportunistic default is
// throttled by the OS and killed by aggressive OEMs. Android 13+: also
// check status.notifications so the persistent notification is visible.
await BeAround.enableForegroundScanning().catch(() => null);
}
Alert.alert('Bearound', 'SDK started successfully');
};
const stop = async () => {
await BeAround.stopScanning();
Alert.alert('Bearound', 'SDK stopped');
};
return (
<View style={{ padding: 24 }}>
<Button title="Start SDK" onPress={start} />
<Button title="Stop SDK" onPress={stop} />
</View>
);
}API
Types
export enum ScanPrecision {
HIGH = 'high', // continuous scanning, sync every 15s
MEDIUM = 'medium', // 3 cycles/min (10s scan + 10s pause), sync every 60s
LOW = 'low', // 1 cycle/min (10s scan + 50s pause), sync every 60s
}
export enum MaxQueuedPayloads {
SMALL = 50,
MEDIUM = 100, // default
LARGE = 200,
XLARGE = 500,
}
export type SdkConfig = {
businessToken: string; // required - your business token
scanPrecision?: ScanPrecision; // defaults to HIGH (aligned with the iOS native default)
maxQueuedPayloads?: MaxQueuedPayloads; // defaults to MEDIUM
// Periodic background reconciliation (best effort — the OS decides when it
// actually runs; iOS: BGAppRefreshTask, Android: WorkManager). The interval is
// only the MINIMUM requested, never a guaranteed cadence. Out-of-range values
// are clamped by the NATIVE SDKs with a highlighted log warning: interval
// floor 10 min (iOS) / 15 min (Android, WorkManager hard minimum), ceiling
// 24 h; scan window 3–15s (iOS, ~30s BGTask budget) / 3–30s (Android).
periodicReconciliationEnabled?: boolean; // default: true
periodicReconciliationIntervalMs?: number; // default: 20 * 60 * 1000 (20 min)
periodicScanDurationMs?: number; // default: 12_000 (12s)
// A scan that finds nothing still reports (its location + the Wi-Fi around it), so the
// backend can tell "no coverage here" apart from "the app wasn't running". Throttles the
// UPLOAD only — scanning is untouched. Clamped natively to 1 min–1 h; 0 disables.
// See "Presence heartbeat".
presenceHeartbeatIntervalMs?: number; // default: 5 * 60 * 1000 (5 min)
// iOS only: shows the App Tracking Transparency prompt when scanning starts, which is
// what unlocks the IDFA. Android has no such prompt and ignores this.
requestTrackingOnStart?: boolean; // default: true
};
export type UserProperties = {
internalId?: string;
email?: string;
name?: string;
customProperties?: Record<string, string>;
};
export type BeaconProximity = 'immediate' | 'near' | 'far' | 'bt' | 'unknown';
export type BeaconMetadata = {
// Firmware identifier. As of native SDK 3.0.0 this is an integer encoded as
// a string (e.g. "1"), NOT a semantic version ("2.1.0") as in 2.x.
firmwareVersion: string;
// Battery level. As of native SDK 3.0.0 this is in millivolts (e.g. 3269),
// NOT a 0-100 percentage as in 2.x.
batteryLevel: number;
movements: number;
temperature: number;
txPower?: number;
rssiFromBLE?: number;
isConnectable?: boolean;
};
// iOS-only: which detector(s) saw the beacon ("two eyes" model —
// coreLocation = Location eye; serviceUUID/name = Bluetooth eye).
export type BeaconDiscoverySource = 'serviceUUID' | 'name' | 'coreLocation';
// Android-only: aggregated RSSI statistics over a sync window.
export type RssiStats = {
count: number;
min: number;
max: number;
avg: number;
stdDev: number;
firstSeen: number;
lastSeen: number;
};
export type Beacon = {
uuid: string;
major: number;
minor: number;
rssi: number;
proximity: BeaconProximity;
accuracy: number;
timestamp: number; // milliseconds since epoch
metadata?: BeaconMetadata;
txPower?: number;
alreadySynced?: boolean; // whether this beacon was already synced to the ingest API
syncedAt?: number; // epoch ms of the last successful sync, if any
discoverySources?: BeaconDiscoverySource[]; // iOS-only
rssiRaw?: number; // Android-only: raw (unsmoothed) RSSI of the latest sample
rssiSamples?: RssiStats; // Android-only
isStale?: boolean; // Android-only: not seen within the freshness window
};
export type SyncLifecycleEvent = {
type: 'started' | 'completed';
beaconCount: number;
success?: boolean;
error?: string;
};
export type BackgroundDetectionEvent = {
beaconCount: number;
};
export type BearoundError = {
message: string;
};Functions
// Configures the SDK (call before startScanning)
configure(config: SdkConfig): Promise<void>;
// Starts and stops scanning
startScanning(): Promise<void>;
stopScanning(): Promise<void>;
isScanning(): Promise<boolean>;
// User properties
setUserProperties(properties: UserProperties): Promise<void>;
clearUserProperties(): Promise<void>;
// Push token — forwards the token to the native SDK, which associates it with
// the device and sends it on the next sync (re-sent only when it changes or
// after the native heartbeat window).
// - Android: pass the FCM token. The native SDK also auto-collects it when
// Firebase is present; this call is the explicit fallback.
// - iOS: forward the RAW APNs device token (hex), NOT the FCM token. The
// example AppDelegate (§1) already forwards it from
// didRegisterForRemoteNotificationsWithDeviceToken — that native wiring is the
// robust default. Use THIS JS call when Firebase (or another library) owns the
// push delegates so your native didRegister never runs, or when
// BearoundAppDelegateProxyEnabled = NO. See "Using Firebase Messaging /
// disabled swizzling?" above.
setPushToken(token: string): Promise<void>;
// Silent-push wake-up (Android). Forward an FCM data-message payload
// (remoteMessage.data) from your Firebase setBackgroundMessageHandler to restart
// the scan + sync; resolves true when the SDK recognizes a Bearound wake. On iOS
// the AppDelegate handles the silent push and this resolves false for
// non-Bearound payloads. See "Silent-push wake-up (Android)" under Scan modes.
handleRemoteMessage(data: { [key: string]: string }): Promise<boolean>;
// Error telemetry opt-out (default: enabled). See "SDK error telemetry" below.
setErrorReportingEnabled(enabled: boolean): void;
// Event listeners
addBeaconsListener(listener: (beacons: Beacon[]) => void): EmitterSubscription;
addSyncLifecycleListener(listener: (event: SyncLifecycleEvent) => void): EmitterSubscription;
addBackgroundDetectionListener(listener: (event: BackgroundDetectionEvent) => void): EmitterSubscription;
addScanningListener(listener: (isScanning: boolean) => void): EmitterSubscription;
addErrorListener(listener: (error: BearoundError) => void): EmitterSubscription;
addBeaconRegionListener(listener: (event: BeaconRegionEvent) => void): EmitterSubscription;
addActiveScanListener(listener: (event: ActiveScanEvent) => void): EmitterSubscription;
addBluetoothZoneListener(listener: (event: BluetoothZoneEvent) => void): EmitterSubscription; // iOS-only event
addBluetoothScanModeListener(listener: (event: BluetoothScanModeEvent) => void): EmitterSubscription; // iOS-only event
addBluetoothStateListener(listener: (state: BluetoothState) => void): EmitterSubscription; // both platforms
// Diagnostics / state getters
getSdkVersion(): Promise<string>; // native SDK version (real value on both platforms)
getCurrentScanPrecision(): Promise<string>; // 'high' | 'medium' | 'low', or '' if not configured
getBleDiagnosticInfo(): Promise<string>; // iOS-only; Android returns ''
getPendingBatchCount(): Promise<number>; // failed sync batches queued for retry (real value on both platforms)
isConfigured(): Promise<boolean>; // whether configure() has run
isLocationAvailable(): Promise<boolean>; // whether device location services are enabled
getAuthorizationStatus(): Promise<AuthorizationStatus>; // iOS: 'always' | 'whenInUse' | ...; Android: its own permission-status string
getBluetoothState(): Promise<BluetoothState>; // current Bluetooth adapter state (both platforms)
// Location authorization (iOS-only; no-op on Android — use requestForegroundPermissions there)
requestLocationAuthorization(level?: 'always' | 'whenInUse'): Promise<void>;
// Persisted detection log (both platforms; Android needs native SDK 3.6.2+)
// Entries are written natively on every event — including while the app is
// backgrounded or terminated — so JS can show what happened while it wasn't running.
getPersistedLog(): Promise<PersistedLogEntry[]>;
clearPersistedLog(): Promise<void>;
// Foreground-service scanning (Android-only; no-op on iOS)
enableForegroundScanning(config?: ForegroundScanConfig): Promise<void>;
disableForegroundScanning(): Promise<void>;
isForegroundScanningEnabled(): Promise<boolean>; // iOS always resolves false
setForegroundNotificationContent(content: NotificationContent): Promise<void>;
// Permission helper (Android + iOS)
ensurePermissions(opts?: { askBackground?: boolean }): Promise<{
fineLocation: boolean;
btScan: boolean;
btConnect: boolean;
notifications: boolean;
backgroundLocation: boolean;
}>;
// Check current permission status
checkPermissions(): Promise<{
fineLocation: boolean;
btScan: boolean;
btConnect: boolean;
notifications: boolean;
backgroundLocation: boolean;
}>;
// Request only foreground permissions (Android)
requestForegroundPermissions(): Promise<{
fineLocation: boolean;
btScan: boolean;
btConnect: boolean;
notifications: boolean;
backgroundLocation: boolean;
}>;
// Request background location permission (Android)
requestBackgroundLocation(): Promise<boolean>;
PermissionResultsemantics on iOS: the iOS bridge checks a single thing — location authorization (authorizedAlwaysorauthorizedWhenInUse).fineLocation,btScan,btConnectandbackgroundLocationall mirror that one location boolean, andnotificationsis hardcodedtrue(never checked). In particular:
btScan/btConnectdo not reflect the Bluetooth permission on iOS — usegetBluetoothState()instead ('unauthorized'means Bluetooth permission was denied).backgroundLocation: truedoes not mean "Always" was granted — it istruewith only When-In-Use. UsegetAuthorizationStatus()to distinguish'always'from'whenInUse'(terminated-app wake-up requires Always).- On iOS,
ensurePermissions/requestForegroundPermissionstrigger the system location prompt (requesting Always) only while the status isnotDetermined; once denied they resolvefalsewithout prompting.On Android each field reflects the real status of its permission (
ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION,BLUETOOTH_SCAN,BLUETOOTH_CONNECT,POST_NOTIFICATIONS,ACCESS_BACKGROUND_LOCATION), but note the SDK manifest only declares the location permissions up to API 30 and does not declareACCESS_BACKGROUND_LOCATION— so on Android 12+fineLocationandbackgroundLocationstayfalseunless your app declares them itself. Gate scanning as shown in Quick Start, not on "all fields true".
getBluetoothState()on iOS — side effect: the first call lazily creates theCBCentralManager, which triggers the system Bluetooth permission prompt if not yet determined. It is also what armsaddBluetoothStateListeneron iOS — the listener only starts emitting after the firstgetBluetoothState()call in the process (on Android it emits on adapter changes without any prior call).
SDK error telemetry
The SDK ships lightweight, self-contained crash telemetry so we can spot and fix
SDK-side regressions in the field. It's installed automatically by configure()
and covers three layers:
- Native (Android/iOS): the embedded native SDKs capture their own crashes via their built-in error reporters.
- React Native / JS: this package additionally captures uncaught JS exceptions and unhandled promise rejections that originate in the SDK's own JS layer.
Golden rules — it never gets in your way:
- Only the SDK's own errors are reported. An error is sent only when its
first application stack frame (skipping the RN runtime) is inside
@bearound/react-native-sdk— i.e. the error originated in the SDK. Errors from your app code are ignored — including errors thrown inside your own callbacks that merely pass through the SDK — and the telemetry module never reports its own failures. - It never throws and never hijacks your handlers. The global error handler is
chained: the SDK stores your previous
ErrorUtilshandler and always delegates back to it, so your own crash reporter (Sentry, Crashlytics, etc.) keeps working unchanged. - Fire-and-forget and self-limiting. Reports are posted best-effort to
https://ingest.bearound.io/sdk-errorswith a 5 s timeout, rate-limited to 20/hour and de-duplicated for 5 minutes. Nothing blocks your app.
Each report includes the error (type, message, stack, context), a device snapshot
(OS/version, permission state), and the SDK version/platform. If a businessToken
is set, it's sent as the Authorization header.
Opting out:
import { setErrorReportingEnabled } from '@bearound/react-native-sdk';
// Disable JS-layer SDK error reporting (default: enabled).
setErrorReportingEnabled(false);Opting out disables the JS-layer reporting exposed by this package. Native crash telemetry follows the embedded native SDKs' own behavior.
Events
Available listeners:
addBeaconsListener— fires with the detected beacons on every scan window.addSyncLifecycleListener— fires when a sync to the ingest API starts/completes.addBackgroundDetectionListener— fires when beacons are detected while the app is in background.- `addScanningL
