Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,19 @@

## 1.6.0 (2026-06-10)

* feat(sdk): report SDK name + version (SIT-235 · React Native) (#6) ([a4ebc5d](https://github.com/LinkForty/mobile-sdk-react-native/commit/a4ebc5d)), closes [#6](https://github.com/LinkForty/mobile-sdk-react-native/issues/6)
* feat(sdk): report SDK name + version (#6) ([a4ebc5d](https://github.com/LinkForty/mobile-sdk-react-native/commit/a4ebc5d)), closes [#6](https://github.com/LinkForty/mobile-sdk-react-native/issues/6)

## 1.5.0 (2026-06-10)

* feat: autoTrackNavigation + last-click attribution context (SIT-237) (#5) ([c3537bf](https://github.com/LinkForty/mobile-sdk-react-native/commit/c3537bf)), closes [#5](https://github.com/LinkForty/mobile-sdk-react-native/issues/5)
* feat: autoTrackNavigation + last-click attribution context (#5) ([c3537bf](https://github.com/LinkForty/mobile-sdk-react-native/commit/c3537bf)), closes [#5](https://github.com/LinkForty/mobile-sdk-react-native/issues/5)

## 1.4.0 (2026-05-04)

* ci: drop flaky 'npm install -g npm@latest' self-upgrade step (#4) ([62cbc18](https://github.com/LinkForty/mobile-sdk-react-native/commit/62cbc18)), closes [#4](https://github.com/LinkForty/mobile-sdk-react-native/issues/4) [#2](https://github.com/LinkForty/mobile-sdk-react-native/issues/2)
* Merge pull request #2 from LinkForty/feat/app-token ([b89b060](https://github.com/LinkForty/mobile-sdk-react-native/commit/b89b060)), closes [#2](https://github.com/LinkForty/mobile-sdk-react-native/issues/2)
* update packages ([98b32fb](https://github.com/LinkForty/mobile-sdk-react-native/commit/98b32fb))
* docs: surface appToken in llms.txt and README ([46c277f](https://github.com/LinkForty/mobile-sdk-react-native/commit/46c277f))
* feat: add appToken config option for Cloud organic-install attribution ([bdb7a13](https://github.com/LinkForty/mobile-sdk-react-native/commit/bdb7a13)), closes [LinkForty/cloud#76](https://github.com/LinkForty/cloud/issues/76)
* feat: add appToken config option for organic-install attribution on the hosted service ([bdb7a13](https://github.com/LinkForty/mobile-sdk-react-native/commit/bdb7a13))

## 1.3.0 (2026-03-17)

Expand Down
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,22 @@ npm install @linkforty/mobile-sdk-react-native

### Requirements

- React Native >= 0.65.0 (the floor set by `@react-native-async-storage/async-storage` 2.x, which the SDK depends on)
- React Native >= 0.65.0 (the floor set by `@react-native-async-storage/async-storage` 2.x)
- React >= 17.0.0
- Node.js >= 20.0.0

### Native dependencies

The SDK depends on two native modules. Install them in your app as well, at the major versions the SDK uses:
The SDK uses two native modules, which are peer dependencies: install them in your app, which then has a single copy of each that the SDK shares.

```bash
npm install @react-native-async-storage/async-storage@^2.2.0 react-native-device-info@^15.0.0
npm install @react-native-async-storage/async-storage react-native-device-info
```

React Native's autolinking only links native modules listed in your app's own `package.json`, so without this step their native code is not built into the app. Keep AsyncStorage on 2.x: version 3 registers its native module under a different name, so the SDK (which uses 2.x) would not find it.
- `@react-native-async-storage/async-storage` 2.2 or later, or 3.x. With 3.x the SDK uses its default (legacy) storage, which keeps the data written with 2.x, so upgrading AsyncStorage keeps the install ID. AsyncStorage 3.x has its own native requirements (for example Android `minSdk` 24 and a recent Kotlin Gradle plugin); stay on 2.x if your app can't meet them.
- `react-native-device-info` 15.x.

React Native's autolinking only links native modules listed in your app's own `package.json`, so without this step their native code is not built into the app.

Only if you use [automatic screen tracking](#automatic-screen-tracking-autotracknavigation) (`autoTrackNavigation`), install React Navigation — it's an **optional** peer dependency and not required otherwise:

Expand Down Expand Up @@ -175,7 +178,9 @@ Await `init()` before calling `onDeepLink()`: `onDeepLink()` throws if `init()`

Initialize the SDK. Must be called before any other method. Calling it a second time logs a warning and does nothing.

On the first launch it reports the install (and on Android waits up to 2 seconds for the Google Play install referrer) before it resolves.
On the first launch it reports the install (and on Android waits up to 2 seconds for the Google Play install referrer) before it resolves. If the report fails (no connection, a server error), it is reported again on the next launch, and so on until the server accepts it; until then there is no install ID and `trackEvent()` sends nothing.

**Upgrading from 1.9.0 or earlier:** those versions never retried a failed first-launch install report, which left the device without an install ID, so its events were dropped. After the upgrade, such a device reports its install once. It appears as an install dated at the upgrade, usually organic, because the click that led to the original install is normally outside the attribution window by then. Devices that already have an install ID never report again. This recovery report only restores the install ID. It does not call `onDeferredDeepLink` (the user has been using the app, so routing them like a fresh install would be wrong). Any link it matches is not saved for `getInstallData()` and is not credited for later events: the match is made at upgrade time, not from the click that led to the install. A link the user taps to open the app is credited as usual.

```typescript
await LinkForty.init({
Expand All @@ -192,7 +197,7 @@ await LinkForty.init({
| `baseUrl` | `string` | Yes | `https://api.linkforty.com` on the hosted service; your server's URL if you self-host LinkForty Core |
| `appToken` | `string` | Hosted service: yes | Public workspace token, sent on every request. On the hosted service it scopes install matching and link lookups to your workspace, attributes organic installs (App Store discovery, social mentions, etc.) to it, and lets the SDK learn your workspace's link hosts. Find it in the dashboard under Settings → App Configuration. Format: `at_<32 hex chars>`. Safe to ship in your app bundle. |
| `apiKey` | `string` | No | API key, only needed for `createLink()`. It is a secret: don't ship one in your app unless you create links from it. |
| `debug` | `boolean` | No | Enable debug logging (default: `false`) |
| `debug` | `boolean` | No | Log what the SDK does, including link URLs and link data, to the console (default: `false`). The API key is never logged. Without `debug` the SDK logs only failures: a failed install report or event, `init()` called twice, and an event dropped for lack of an install ID. |
| `attributionWindow` | `number` | No | Attribution window in days (default: 7) |
| `autoTrackNavigation` | `boolean \| { captureParams?: string[]; debounceMs?: number }` | No | Emit `screen_view` events from React Navigation. See [Automatic Screen Tracking](#automatic-screen-tracking-autotracknavigation). Default: off |
| `navigationRef` | `NavigationContainerRefLike` | With `autoTrackNavigation` | Your React Navigation container ref |
Expand All @@ -201,7 +206,7 @@ await LinkForty.init({

### `onDeepLink(callback)`

Listen for direct deep links. Called when the app is opened via a Universal Link (iOS) or App Link (Android).
Listen for direct deep links. Called when the app is opened via a Universal Link (iOS) or App Link (Android). Calling `onDeepLink()` again replaces the callback: only the newest one is called, once per link.

When the URL is a LinkForty link, the SDK automatically resolves it via the server to retrieve the full link data including `customParameters`, UTM parameters, and metadata. If resolution fails, the SDK falls back to local URL parsing. Any other URL reaches the callback with `data` set to `null`.

Expand Down Expand Up @@ -229,7 +234,7 @@ LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => {

### `onDeferredDeepLink(callback)`

Listen for deferred deep links. Called once, on the launch that reports the install: with the link's data if the user clicked a LinkForty link before installing, or `null` for an organic install. It can be registered before or after `init()`. Later launches don't call it; use `getInstallData()` for the install's link data.
Listen for deferred deep links. Called once, on the launch that reports the install: with the link's data if the user clicked a LinkForty link before installing, or `null` for an organic install. A launch whose report fails also calls it with `null`, and the launch that later reports the install successfully calls it again with that report's result. The one-time report from a device upgraded from 1.9.0 or earlier (see [`init`](#initconfig)) does not call it. It can be registered before or after `init()`. Later launches don't call it; use `getInstallData()` for the install's link data.

```typescript
import type { DeepLinkData } from '@linkforty/mobile-sdk-react-native';
Expand Down Expand Up @@ -320,7 +325,7 @@ LinkForty.getExternalUserId(); // 'user_123'

### `trackEvent(name, properties?)`

Track an in-app event for attribution analytics. Requires a successful install report (automatic on first launch); without an install ID the call logs a warning and sends nothing.
Track an in-app event for attribution analytics. Requires a successful install report (automatic on first launch, and retried on later launches if it fails); without an install ID the call logs a warning and sends nothing.

Events are sent once, straight away. There is no offline queue: an event that fails to send (no connection, server error) is logged and dropped, not retried.

Expand Down Expand Up @@ -378,6 +383,7 @@ await LinkForty.init({
```

- `@react-navigation/native` is an **optional** peer dependency. Apps that don't use it simply omit `navigationRef` — nothing changes and there's no required dependency.
- The ref from `createNavigationContainerRef()` type-checks as `navigationRef` with React Navigation 7, with or without a param list (`createNavigationContainerRef<RootStackParamList>()`).
- Each `screen_view` carries `screen` (the route name), `previousScreen` when there is one, and `params` only for allow-listed keys.
- Rapid transitions are debounced (`debounceMs`, default 350) and same-screen re-renders are deduped.
- **Route params are OFF by default** (privacy-safe): only the screen name is captured, since params can contain PII and the SDK runs inside your app. To capture specific non-PII params, opt in with an explicit allow-list:
Expand Down Expand Up @@ -413,7 +419,7 @@ if (data) {

### `getInstallId()`

Get the unique install ID assigned by the LinkForty server on first launch.
Get the unique install ID assigned by the LinkForty server when the install is reported (on first launch, or on a later launch if that report failed). `null` until then.

```typescript
const installId = await LinkForty.getInstallId();
Expand All @@ -425,7 +431,7 @@ const installId = await LinkForty.getInstallId();

### `clearData()`

Clear all cached SDK data (install ID, attribution data, first-launch flag). The next app launch will behave as a fresh install.
Clear all cached SDK data (install ID, install link data, the install-reported flag, the attribution context and the saved link hosts, which are then fetched again). The next app launch will behave as a fresh install. Deep link listening keeps working.

```typescript
await LinkForty.clearData();
Expand All @@ -440,7 +446,7 @@ To match an install or a direct open to a link click, the SDK sends:
- **On each event:** the server-assigned install ID, the event name and the properties you pass, a timestamp, a session ID, and the link currently credited for the visit.
- **On every request:** the SDK's name and version, `appToken` if set, and `apiKey` if set.

The SDK does not read the IDFA or GAID, does not show an App Tracking Transparency prompt, and does not use SKAdNetwork. It stores the install ID, the install's link data, the last link that opened the app and your workspace's link hosts in AsyncStorage; `clearData()` removes all of them except the link hosts.
The SDK does not read the IDFA or GAID, does not show an App Tracking Transparency prompt, and does not use SKAdNetwork. It stores the install ID, the install's link data, the last link that opened the app and your workspace's link hosts in AsyncStorage; `clearData()` removes all of them.

## Types

Expand Down Expand Up @@ -573,7 +579,7 @@ This usually means another SDK or library is consuming the Intent data before Re

### Deferred deep links not attributing

1. Confirm this is a first install (or call `clearData()` first). The install is reported once, on the first launch, and a report that fails (for example with no connection) is not retried on later launches; `clearData()` and a relaunch report it again
1. Confirm this is a first install (or call `clearData()` first). The install is reported on the first launch; a report that fails (for example with no connection) is retried on each later launch until it succeeds
2. Enable `debug: true` and check logs for fingerprint data
3. Verify your LinkForty server received the install event
4. Ensure the click and install happen within the attribution window (default: 7 days)
Expand All @@ -589,7 +595,7 @@ npm install --save-dev @types/react

### `AsyncStorage is null` or a missing native module at runtime

The native modules aren't linked into the app. Install them in your app at the SDK's major versions (see [Native dependencies](#native-dependencies)), run `cd ios && pod install`, and rebuild the app (a JavaScript reload is not enough).
The native modules aren't linked into the app. Install them in your app (see [Native dependencies](#native-dependencies)), run `cd ios && pod install`, and rebuild the app (a JavaScript reload is not enough).

## Contributing

Expand All @@ -613,4 +619,4 @@ MIT License - see [LICENSE](LICENSE) for details.
- **Documentation:** [docs.linkforty.com](https://docs.linkforty.com)
- **Issues:** [GitHub Issues](https://github.com/LinkForty/mobile-sdk-react-native/issues)
- **Core Engine:** [LinkForty Core](https://github.com/LinkForty/core) — self-hosted open-source backend
- **Cloud Platform:** [LinkForty Cloud](https://linkforty.com) — hosted SaaS with dashboard, teams, and billing
- **Hosted service:** [linkforty.com](https://linkforty.com) — dashboard, teams, and billing
114 changes: 114 additions & 0 deletions __tests__/DeepLinkHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// A small stand-in for React Native's Linking event emitter
const linking = vi.hoisted(() => {
type Listener = (event: { url: string }) => void;
let listeners: Listener[] = [];
return {
addEventListener: vi.fn((_type: 'url', listener: Listener) => {
listeners.push(listener);
return { remove: () => { listeners = listeners.filter((l) => l !== listener); } };
}),
removeAllListeners: vi.fn(() => { listeners = []; }),
getInitialURL: vi.fn(() => Promise.resolve(null as string | null)),
emit(url: string) {
for (const listener of [...listeners]) listener({ url });
},
count: () => listeners.length,
reset() { listeners = []; },
};
});

vi.mock('react-native', () => ({
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios },
Dimensions: { get: () => ({ width: 390, height: 844 }) },
NativeModules: {},
Linking: linking,
}));

vi.mock('react-native-device-info', () => ({ default: {} }));

import { DeepLinkHandler } from '../src/DeepLinkHandler';
import { LinkFortySDK } from '../src/LinkFortySDK';

const BASE_URL = 'https://go.example.com';
// Not a LinkForty URL, so the callback gets it straight away with null data
const OTHER_URL = 'myapp://settings';

const flush = () => new Promise((resolve) => setTimeout(resolve, 0));

beforeEach(() => {
linking.reset();
linking.addEventListener.mockClear();
});

describe('DeepLinkHandler listener', () => {
it('adds one listener when initialized again, and the newest callback receives each link once', async () => {
const handler = new DeepLinkHandler();
const first = vi.fn();
const second = vi.fn();
handler.initialize(BASE_URL, first);
handler.initialize(BASE_URL, second);
await flush();

expect(linking.count()).toBe(1);
linking.emit(OTHER_URL);
await flush();

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledWith(OTHER_URL, null);
});

it('cleanup() removes only its own listener, not the app\'s', async () => {
const appListener = vi.fn();
linking.addEventListener('url', appListener);

const handler = new DeepLinkHandler();
const callback = vi.fn();
handler.initialize(BASE_URL, callback);
handler.cleanup();
linking.emit(OTHER_URL);
await flush();

expect(appListener).toHaveBeenCalledTimes(1);
expect(callback).not.toHaveBeenCalled();
expect(linking.count()).toBe(1);
});

it('listens again after cleanup() and a new initialize()', async () => {
const handler = new DeepLinkHandler();
handler.initialize(BASE_URL, vi.fn());
handler.cleanup();
const callback = vi.fn();
handler.initialize(BASE_URL, callback);
linking.emit(OTHER_URL);
await flush();

expect(callback).toHaveBeenCalledTimes(1);
expect(linking.count()).toBe(1);
});
});

describe('LinkFortySDK.onDeepLink', () => {
it('called twice, delivers each link once, to the newest callback', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })));
vi.spyOn(console, 'error').mockImplementation(() => {});
const sdk = new LinkFortySDK();
// Install already reported, so init() sends nothing
await (await import('@react-native-async-storage/async-storage')).default.setItem('@linkforty:first_launch', 'true');
await sdk.init({ baseUrl: BASE_URL });

const first = vi.fn();
const second = vi.fn();
sdk.onDeepLink(first);
sdk.onDeepLink(second);
linking.emit(OTHER_URL);
await flush();

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
});
Loading
Loading