# Layers React Native SDK

`@layers/react-native` is the Layers analytics SDK for React Native apps. It provides event tracking, screen tracking, user identification, App Tracking Transparency (ATT), SKAdNetwork (SKAN), deep link handling, clipboard-based deferred deep links, consent management, and automatic lifecycle and connectivity handling.

Use this package for **bare React Native** projects. For **Expo managed workflow** projects, use [`@layers/expo`](https://www.npmjs.com/package/@layers/expo) instead.

## Requirements

- React Native 0.70.0+
- React 18.0+
- iOS 14.0+ / Android API 21+

## Installation

```bash
npm install @layers/react-native
# or
yarn add @layers/react-native
```

### Recommended Peer Dependencies

```bash
npm install @react-native-async-storage/async-storage @react-native-community/netinfo
```

These are optional but strongly recommended:

- **@react-native-async-storage/async-storage** (>=1.21.0) -- Enables persistent event storage across app restarts. Without it, events are stored in memory only.
- **@react-native-community/netinfo** (>=11.0.0) -- Enables automatic flush when the device reconnects to the network.
- **@react-native-clipboard/clipboard** (>=1.13.0) -- Enables clipboard attribution for iOS deferred deep links.

#### AsyncStorage version compatibility

| `@react-native-async-storage/async-storage` | Supported by `@layers/react-native` |
| ------------------------------------------- | ----------------------------------- |
| 1.21.0 -- 1.x                               | All versions                        |
| 2.x                                         | All versions                        |
| 3.x                                         | 3.2.10 and newer                    |

async-storage 3.x has been npm's `latest` since its release, so `npm install
@react-native-async-storage/async-storage` installs it by default. Two separate
defects have affected that combination; both are fixed in `@layers/react-native`
3.2.10.

**Batch read (fixed in 3.2.8).** async-storage 3.0.0 replaced its batch read
(`multiGet`) with a new one (`getMany`). SDK versions before 3.2.8 called the old
name unconditionally, so on async-storage 3.x the SDK saved state it could never
read back: every launch came up with a fresh `anonymous_id` and `device_id`,
re-sent the first-open event, and reported `session_number` as 1 forever. Writes
kept succeeding throughout, which is why the app looked healthy.

**Identity split on offline-first installs (3.2.8 and 3.2.9, fixed in 3.2.10).**
async-storage 3.0.0 also dropped the serial executor v1/v2 ran every storage
operation through, so two writes to the same key issued in the same tick can
commit in either order. The SDK wrote its identity record twice while starting
up, and when the wrong one committed last, an install whose first launch had no
network could come back under a second `anonymous_id` and `device_id` with a
duplicate first-open event -- measured on roughly one in four such installs.
3.2.10 writes the record once, with the identity its events actually carry, so
no commit order can produce a different one.

Both defects are fixed as of 3.2.10, so async-storage 3.x needs no version pin
on this release. Earlier `@layers/react-native` releases still do.

Expo managed projects are unaffected by either: `npx expo install` picks the
async-storage version pinned to your Expo SDK, which is 2.x.

### iOS Setup

```bash
cd ios && pod install
```

If your app calls `requestTrackingPermission()` or
`requestTrackingAuthorization()`, add a non-empty
`NSUserTrackingUsageDescription` to the app's `Info.plist`:

```xml
<key>NSUserTrackingUsageDescription</key>
<string>Your app-specific explanation for requesting tracking permission.</string>
```

Without this key, the SDK rejects the request with
`ATT_USAGE_DESCRIPTION_MISSING` and does not invoke Apple's ATT prompt.

## Quick Start

```typescript
import { LayersReactNative } from '@layers/react-native';

// Create and initialize
const layers = new LayersReactNative({
  appId: 'your-app-id',
  environment: 'production'
});
await layers.init();

// Track events
layers.track('button_tapped', { button_name: 'signup' });

// Track screen views
layers.screen('Home');

// Identify users
layers.setAppUserId('user_123');
```

## Configuration

### LayersRNConfig

```typescript
interface LayersRNConfig {
  appId: string;
  environment: 'development' | 'staging' | 'production';
  appUserId?: string;
  enableDebug?: boolean; // default: false
  baseUrl?: string; // default: "https://in.layers.com"
  flushIntervalMs?: number; // default: 30000
  flushThreshold?: number; // default: 10
  maxQueueSize?: number; // default: 1000
  autoTrackAppOpen?: boolean; // default: true
  autoTrackDeepLinks?: boolean; // default: true
  autoTrackExceptions?: boolean; // default: true
  appState?: AppStateLike; // default: the SDK's own require('react-native').AppState
}
```

| Option                | Type           | Default                   | Description                                      |
| --------------------- | -------------- | ------------------------- | ------------------------------------------------ |
| `appId`               | `string`       | _required_                | Your Layers application identifier.              |
| `environment`         | `Environment`  | _required_                | `'development'`, `'staging'`, or `'production'`. |
| `appUserId`           | `string`       | `undefined`               | Optional user ID to set at construction time.    |
| `enableDebug`         | `boolean`      | `false`                   | Enable verbose console logging.                  |
| `baseUrl`             | `string`       | `"https://in.layers.com"` | Custom ingest API endpoint.                      |
| `flushIntervalMs`     | `number`       | `30000`                   | Automatic flush interval in milliseconds.        |
| `flushThreshold`      | `number`       | `10`                      | Queue size that triggers an automatic flush.     |
| `maxQueueSize`        | `number`       | `1000`                    | Maximum events in the queue before dropping.     |
| `autoTrackAppOpen`    | `boolean`      | `true`                    | Automatically track `app_open` on init.          |
| `autoTrackDeepLinks`  | `boolean`      | `true`                    | Automatically track `deep_link_opened` events.   |
| `autoTrackExceptions` | `boolean`      | `true`                    | Track uncaught JS errors as `$exception`.        |
| `appState`            | `AppStateLike` | SDK's own `AppState`      | Your app's `AppState`. See below.                |

### One copy of react-native

Your bundle must contain exactly one `react-native`. React Native hands native
events to a single copy of the module — the one whose `RCTDeviceEventEmitter`
was registered as a callable module, which is the copy your app's entry file
imported. Code running in any other copy still reads `Platform`, calls native
modules and gets a real subscription object back from `addEventListener`; it
just never receives an event.

If the SDK ends up in a second copy, `$app_background`, `$app_foreground`, the
background crash-safety snapshot and deep-link events all go silently dead
while the app looks healthy. The SDK detects this and reports it through your
`on('error')` handler and `console.warn`.

Duplicates come from the bundler, so that is where to fix them. In a monorepo,
make Metro resolve `react-native` from one place regardless of which file
imports it:

```js
// metro.config.js
config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (moduleName === 'react-native' || moduleName.startsWith('react-native/')) {
    return context.resolveRequest(
      {
        ...context,
        nodeModulesPaths: [path.resolve(workspaceRoot, 'node_modules')],
        disableHierarchicalLookup: true
      },
      moduleName,
      platform
    );
  }
  return context.resolveRequest(context, moduleName, platform);
};
```

If you cannot change the bundler, hand the SDK your app's module directly:

```typescript
import { AppState } from 'react-native';

const layers = new LayersReactNative({ appId, environment, appState: AppState });
```

## Core API

### Constructor & Initialization

```typescript
const layers = new LayersReactNative(config: LayersRNConfig);
await layers.init();
```

The constructor creates the SDK instance with an in-memory queue. Calling `init()` upgrades to AsyncStorage-backed persistence, collects device info, fetches remote config, reads clipboard attribution (iOS), fires `app_open`, and sets up auto-tracking.

You can call `track()` and `screen()` before `init()` completes -- events are queued in memory.

### Event Tracking

```typescript
track(eventName: string, properties?: EventProperties): void
```

```typescript
layers.track('purchase_completed', {
  product_id: 'sku_123',
  price: 9.99,
  currency: 'USD'
});
```

### Screen Tracking

```typescript
screen(screenName: string, properties?: EventProperties): void
```

```typescript
layers.screen('ProductDetail', { product_id: 'sku_123' });
```

### User Identity

```typescript
// Set the app user ID (set-user-once: ignored if already set)
setAppUserId(appUserId: string): void

// Clear the current user ID (allows setting a new one)
clearAppUserId(): void

// Get the current user ID
getAppUserId(): string | undefined

// Set user properties
async setUserProperties(properties: UserProperties): Promise<void>
```

```typescript
// After login
layers.setAppUserId('user_123');
await layers.setUserProperties({
  email: 'user@example.com',
  plan: 'premium'
});

// On logout
layers.clearAppUserId();
```

> **Set-user-once semantics**: Once `setAppUserId()` is called, subsequent calls are ignored until `clearAppUserId()` is called. This prevents accidental user ID changes.

### Consent Management

```typescript
async setConsent(consent: ConsentState): Promise<void>
getConsentState(): ConsentState
```

```typescript
interface ConsentState {
  analytics?: boolean;
  advertising?: boolean;
}
```

```typescript
// User accepts all tracking
await layers.setConsent({ analytics: true, advertising: true });

// User denies advertising
await layers.setConsent({ analytics: true, advertising: false });

// Read current consent
const consent = layers.getConsentState();
```

### Flush & Shutdown

```typescript
// Flush queued events to the server
async flush(): Promise<void>

// Shut down the SDK (removes listeners, stops timers)
shutdown(): void
```

### Session & Device

```typescript
// Get the current session ID
getSessionId(): string

// Override device context fields
setDeviceInfo(deviceInfo: DeviceContext): void
```

### Error Handling

```typescript
// Register an error listener
on(event: 'error', listener: (error: Error) => void): this

// Remove an error listener
off(event: 'error', listener: (error: Error) => void): this
```

```typescript
layers.on('error', (error) => {
  console.error('Layers error:', error.message);
  // Forward to your crash reporting service
});
```

## App Tracking Transparency (ATT) -- iOS

### Integrated ATT (Recommended)

The `requestTrackingPermission()` method on the SDK instance handles the ATT prompt and records its result:

```typescript
const status = await layers.requestTrackingPermission();
// Returns: 'authorized' | 'denied' | 'restricted' | 'not_determined'
```

This method:

1. Shows the ATT dialog (or uses `expo-tracking-transparency` if available)
2. Records the ATT status while preserving the existing device context
3. Collects IDFA only when ATT is authorized

ATT controls IDFA availability only. It does not change Layers consent. Call
`layers.setConsent(...)` separately when your app's own consent flow changes
Layers collection or delivery policy.

Attribution continues when IDFA is unavailable using the other signals the SDK
has collected, including its install/device context and available click or deep
link identifiers.

### Standalone ATT Functions

For more granular control, use the exported functions:

```typescript
import {
  getATTStatus,
  getAdvertisingId,
  getVendorId,
  isATTAvailable,
  requestTrackingAuthorization
} from '@layers/react-native';

const status = await getATTStatus();
const isAvailable = await isATTAvailable();
const idfa = await getAdvertisingId(); // null if not authorized
const idfv = await getVendorId(); // does not require ATT; null if unavailable
```

### ATTStatus

```typescript
type ATTStatus = 'not_determined' | 'restricted' | 'denied' | 'authorized';
```

> **Important**: Complete the [`Info.plist` setup](#ios-setup) before requesting
> ATT authorization.

## SKAdNetwork (SKAN) -- iOS

SKAN is auto-configured from the server's remote config. The SDK creates a `SKANManager` instance and automatically forwards every `track()` call through the SKAN rule engine.

### Required: Info.plist setup

Bare React Native projects must add these keys to `ios/<App>/Info.plist` themselves. Without `NSAdvertisingAttributionReportEndpoint`, Apple never delivers SKAdNetwork postbacks to Layers and the rule engine's conversion values never reach attribution. (Using `@layers/expo`? The config plugin adds both automatically — skip this.)

```xml
<!-- Tells Apple to copy SKAdNetwork postbacks to Layers. REQUIRED. -->
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://layers.click</string>

<!-- One <dict> per ad network you run campaigns on. (Meta shown as an example.) -->
<key>SKAdNetworkItems</key>
<array>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>v9wttpbfk9.skadnetwork</string>
  </dict>
</array>
```

> Apple appends `/.well-known/skadnetwork/report` to the endpoint and allows only
> **one** `NSAdvertisingAttributionReportEndpoint` per app.

### Accessing the Auto-Configured Manager

```typescript
const skanManager = layers.getSkanManager();
if (skanManager) {
  const metrics = skanManager.getMetrics();
  console.log('SKAN value:', metrics.currentValue);
  console.log('SKAN preset:', metrics.currentPreset);
}
```

### Manual SKAN Configuration

If you need to configure SKAN manually instead of relying on remote config:

```typescript
import { SKANManager } from '@layers/react-native';

const skan = new SKANManager((data) => {
  console.log(`Conversion value updated: ${data.previousValue} -> ${data.newValue}`);
});

// Use a built-in preset
skan.setPreset('subscriptions'); // or 'engagement' or 'iap'
await skan.initialize();

// Or define custom rules
skan.setCustomRules([
  {
    eventName: 'purchase_success',
    conditions: { revenue: { '>=': 10 } },
    conversionValue: 63,
    coarseValue: 'high',
    priority: 10
  },
  {
    eventName: 'trial_start',
    conversionValue: 20,
    priority: 5
  }
]);
await skan.initialize();

// Process events manually
await skan.processEvent('purchase_success', { revenue: 49.99 });
```

### SKANConversionRule

```typescript
interface SKANConversionRule {
  eventName: string;
  conditions?: Record<string, unknown>; // Operator-based: { '>': 10, '<': 100 }
  conversionValue: number; // 0-63 (SKAN 3.0) or 0-7 (SKAN 4.0)
  coarseValue?: 'low' | 'medium' | 'high'; // SKAN 4.0 only
  lockWindow?: boolean; // SKAN 4.0 only
  priority?: number;
  description?: string;
}
```

### SKANMetrics

```typescript
interface SKANMetrics {
  isSupported: boolean;
  version: string;
  currentValue: number | null;
  currentPreset: string | null;
  ruleCount: number;
  evaluationCount: number;
}
```

### Available Presets

- **`subscriptions`** -- Optimized for subscription apps (trial start, subscription start/renew)
- **`engagement`** -- Optimized for engagement-driven apps (content views, sessions, bookmarks)
- **`iap`** -- Optimized for in-app purchase revenue tracking (purchase tiers by revenue)

## Deep Links

### Auto-Tracking (Default)

When `autoTrackDeepLinks` is `true` (default), the SDK automatically tracks a `deep_link_opened` event for every incoming deep link. The event includes the full URL, scheme, host, path, and all query parameters (UTM, click IDs) as flat top-level properties.

### Manual Deep Link Handling

```typescript
import { parseDeepLink, setupDeepLinkListener } from '@layers/react-native';

const unsubscribe = setupDeepLinkListener((data) => {
  console.log('Deep link:', data.url);
  console.log('Host:', data.host);
  console.log('Path:', data.path);
  console.log('UTM Source:', data.queryParams.utm_source);
  console.log('Click ID:', data.queryParams.fbclid);
});

// Later: unsubscribe()
```

The listener handles both:

- **Initial URL** (cold start): Checks `Linking.getInitialURL()` on setup.
- **Subsequent URLs** (warm start): Listens to `Linking` `url` events.

### parseDeepLink

```typescript
function parseDeepLink(url: string): DeepLinkData | null;
```

```typescript
interface DeepLinkData {
  url: string;
  scheme: string;
  host: string;
  path: string;
  queryParams: Record<string, string>;
  timestamp: number;
}
```

## Clipboard Attribution -- iOS

On iOS, the SDK reads the clipboard on first launch (during `init()`) for a Layers click URL. If found, the click URL and click ID are included as properties on the `app_open` event. This is controlled by the server's remote config (`clipboard_attribution_enabled`).

For manual reading:

```typescript
import { readClipboardAttribution } from '@layers/react-native';

const data = await readClipboardAttribution();
if (data) {
  console.log('Click URL:', data.clickUrl);
  console.log('Click ID:', data.clickId);
}
```

```typescript
interface ClipboardAttribution {
  clickUrl: string;
  clickId: string;
}
```

Requires `@react-native-clipboard/clipboard` as a peer dependency.

## Expo Router Integration

For automatic screen tracking with Expo Router:

```typescript
import { useLayersExpoRouterTracking } from '@layers/react-native';
import { usePathname, useGlobalSearchParams } from 'expo-router';

function RootLayout() {
  useLayersExpoRouterTracking(layers, usePathname, useGlobalSearchParams);

  return <Stack />;
}
```

This hook automatically tracks a `screen` event every time the Expo Router pathname changes, including route parameters as event properties.

## Install ID

The SDK generates and persists a unique install ID via AsyncStorage:

```typescript
import { getOrSetInstallId } from '@layers/react-native';

const installId = await getOrSetInstallId();
```

This ID persists across app sessions and is included in the device context.

## Automatic Behaviors

- **app_open event**: Tracked on `init()` with clipboard attribution (iOS).
- **deep_link_opened event**: Tracked automatically for all incoming deep links.
- **Background flush**: Events are flushed when the app goes to background/inactive.
- **Foreground flush**: Events are flushed when the app becomes active.
- **Network reconnect flush**: Events are flushed when the device reconnects (requires `@react-native-community/netinfo`).
- **Periodic flush**: Events are flushed on a timer (configurable).
- **Remote config**: Server configuration is fetched during init.
- **SKAN auto-config**: SKAN preset/rules from remote config are applied automatically (iOS).
- **Device context**: Platform, OS version, device model, locale, screen size, timezone, IDFV (iOS), and install ID are collected automatically.
- **Event persistence**: Events are persisted via AsyncStorage (if available) and rehydrated on restart.

## TypeScript Types

All types are exported from the package:

```typescript
import type {
  ATTStatus,
  ClipboardAttribution,
  ConsentState,
  DeepLinkData,
  DeviceContext,
  Environment,
  ErrorListener,
  EventProperties,
  LayersRNConfig,
  SKANConversionRule,
  SKANMetrics,
  SKANPresetConfig,
  UserProperties
} from '@layers/react-native';
```
