npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Agent setup prompt


Table of Contents


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 the BLUETOOTH_SCAN runtime 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-sdk

iOS

In the ios folder:

cd ios
pod install

The Podspec declares a CocoaPods dependency on the native BearoundSDK pod (exact version pin — see BearoundReactSdk.podspec), resolved automatically by pod install. If your Podfile uses use_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/ and android/ do not survive. expo prebuild regenerates those folders from your config, so anything you paste into AppDelegate.swift by 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 it

What 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 AppDelegate is not the bare React Native one. It subclasses ExpoAppDelegate, 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 an override that calls superexpo-notifications, expo-background-task and friends keep receiving everything they did before. The plugin also never touches the UNUserNotificationCenter delegate: expo-notifications owns it, and reassigning it would steal your app's push routing. To show notifications while the app is in the foreground, use Notifications.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); the NS…UsageDescription strings are written only when absent, so ios.infoPlist and the usageDescriptions prop win.
  • <app>.entitlements — an aps-environment you already set is kept as is.
  • AndroidManifest.xml — nothing is removed; ACCESS_BACKGROUND_LOCATION is added only with backgroundWifi.
  • 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 as invalid 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 it didFailToRegisterForRemoteNotificationsWithError fires, 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 connectedDevice foreground-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 2

Already 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:

Agent setup prompt

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-environment to development for Debug and production for Release — see §3.
  • On device: grant Always location and turn on Background App Refresh.
  • Google Play: the connectedDevice foreground-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 the neverForLocation flag (and add xmlns:tools to 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_SCAN and POST_NOTIFICATIONS at runtime. Because BLUETOOTH_SCAN is declared with neverForLocation, no location permission is required for scanning on API 31+. This package exposes a helper ensurePermissions to 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 location background mode + "Always" permission) — fetch does not wake the app for beacons; it only grants periodic background windows the SDK uses to sync queued data
  • remote-notification enables 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_LOCATION gets an empty scan list and the placeholder BSSID 02:00:00:00:00:00. No error, no exception: the SDK discards the placeholder and wifis[] 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 .whenInUse the system stops revealing the access point in the background and returns nil. .always is 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 with location: authorized_always granted, over 300 payloads sent while the app sat in the background arrived with wifis: null and location: 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 to NEVER_ASK_AGAIN because 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_ID permission obliges you to tick "Device or other IDs" in the Play Data Safety form. Apps for children must strip AD_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 strategiesyou 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 service

The 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 connectedDevice foreground 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 with neverForLocation). 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 an override that calls super). Editing AppDelegate.swift yourself works until the next expo prebuild wipes 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). BeAroundSDK is a pure-Swift API (not @objc), so it cannot be called from an Objective-C AppDelegate.mm. If your app still ships AppDelegate.mm (common on RN 0.73–0.76), you must migrate the target to a Swift AppDelegate and wire the calls there — on RN 0.73–0.76 that's a Swift AppDelegate subclassing RCTAppDelegate; on ≥ 0.77 it's the RCTReactNativeFactory template 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.shared synchronously in didFinishLaunchingWithOptions, before the React Native bootstrap and before you return. 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 JS configure() path is too late and races the relaunch region event.
  • The class conforms to UNUserNotificationCenterDelegate and imports UserNotifications, 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, didRegisterForRemoteNotificationsWithDeviceToken forwards it, and didReceiveRemoteNotification handles 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 signed aps-environment entitlement is missing, didFailToRegisterForRemoteNotificationsWithError fires 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 those NS…UsageDescription strings from that section (its UIBackgroundModes block 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.

development vs production — the value matters. aps-environment selects 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 needs aps-environment = production — a build shipped with development registers 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 (development in Debug, production in Release) via a build-setting variable (aps-environment = $(APS_ENVIRONMENT)) or a separate Release entitlements file. The bundled example uses development because 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 via BeAroundSDK.shared.setPushToken(...) in didRegisterForRemoteNotificationsWithDeviceToken, and the cold-launch silent push is handled in didReceiveRemoteNotification. Nothing else to do.
  • Firebase (or another library) owns the push delegates — or you opted out with BearoundAppDelegateProxyEnabled = NO in Info.plist: the SDK's swizzle won't fire and your native didRegister… 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.plist prints OK.
  • plutil -p ios/YourApp/Info.plist shows all five background modes (fetch, location, processing, bluetooth-central, remote-notification) and both BGTaskSchedulerPermittedIdentifiers (io.bearound.sdk.sync, io.bearound.sdk.processing).
  • Your app target's .entitlements contains aps-environment, and CODE_SIGN_ENTITLEMENTS points 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 that registerForRemoteNotifications()setPushToken fired. If you instead see APNs registration failed, the Push Notifications capability / aps-environment entitlement 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 bearound silent push. Expect the "App reactivated" notification and, on next foreground, getPersistedLog() entries written while the app wasn't running, with getPendingBatchCount() draining to 0 (proves the background URLSession upload finalized via handleEventsForBackgroundURLSession).

Quick Start

Three rules the snippet below follows — all come from how the SDK actually works:

  1. configure() runs on mount (in a useEffect), not behind a button. The SDK's push swizzle (automatic APNs token capture + silent-push handling) only installs once configure() runs in the process — see §4 of iOS Background Integration.
  2. 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 declares neverForLocation). On Android ≤ 11 it's the opposite: location is what unlocks scanning. Do not gate on btConnect/backgroundLocation — the SDK doesn't need them to scan, and on 12+ they can never all be granted (the location permissions are declared with maxSdkVersion="30").
  3. Android background needs the foreground service. For reliable background detection, call enableForegroundScanning() after startScanning() — 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>;

PermissionResult semantics on iOS: the iOS bridge checks a single thing — location authorization (authorizedAlways or authorizedWhenInUse). fineLocation, btScan, btConnect and backgroundLocation all mirror that one location boolean, and notifications is hardcoded true (never checked). In particular:

  • btScan/btConnect do not reflect the Bluetooth permission on iOS — use getBluetoothState() instead ('unauthorized' means Bluetooth permission was denied).
  • backgroundLocation: true does not mean "Always" was granted — it is true with only When-In-Use. Use getAuthorizationStatus() to distinguish 'always' from 'whenInUse' (terminated-app wake-up requires Always).
  • On iOS, ensurePermissions/requestForegroundPermissions trigger the system location prompt (requesting Always) only while the status is notDetermined; once denied they resolve false without 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 declare ACCESS_BACKGROUND_LOCATION — so on Android 12+ fineLocation and backgroundLocation stay false unless 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 the CBCentralManager, which triggers the system Bluetooth permission prompt if not yet determined. It is also what arms addBluetoothStateListener on iOS — the listener only starts emitting after the first getBluetoothState() 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 ErrorUtils handler 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-errors with 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