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
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ override fun onNewIntent(intent: Intent) {
```typescript
import LinkForty from '@linkforty/mobile-sdk-react-native';

// 1. Initialize the SDK (call once at app startup, and await it before step 2)
// 1. Initialize the SDK (call once at app startup; steps 2 and 3 may follow without awaiting it)
await LinkForty.init({
baseUrl: 'https://api.linkforty.com', // hosted service; or your own server's URL if you self-host
appToken: 'at_a1b2c3d4...', // Required on the hosted service
Expand All @@ -170,7 +170,7 @@ LinkForty.onDeferredDeepLink((data) => {
});
```

Await `init()` before calling `onDeepLink()`: `onDeepLink()` throws if `init()` was never called, and one registered while `init()` is still running recognizes links on the `baseUrl` host only, not your workspace's other link hosts.
`onDeepLink()` and `onDeferredDeepLink()` may be called as soon as `init()` has been called, without awaiting it, so an app can register its link handler on the first frame while the install report is still in flight. `onDeepLink()` throws only if `init()` was never called. A link handled before the SDK has learned your workspace's link hosts waits for them, up to 3 seconds (not on later launches, which know them from the start).

## API Reference

Expand All @@ -180,6 +180,8 @@ Initialize the SDK. Must be called before any other method. Calling it a second

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.

`init()` always settles. Every request the SDK makes is abandoned after `requestTimeoutMs` (default 10 seconds), since React Native sets no network timeout of its own on Android, and the device's user agent lookup is abandoned after 3 seconds in favor of a generated one. A network failure, a timeout or unavailable storage (the AsyncStorage native module not linked) is logged, never thrown: with unavailable storage the SDK behaves as on a first launch and keeps working for the session. The only error `init()` rejects with is one thrown by your own `onDeferredDeepLink` callback.

**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
Expand All @@ -189,6 +191,7 @@ await LinkForty.init({
apiKey: 'your-api-key', // Optional (only needed for createLink)
debug: true, // Optional (default: false)
attributionWindow: 7, // Optional, in days (default: 7)
requestTimeoutMs: 10000, // Optional, in ms (default: 10000)
});
```

Expand All @@ -199,6 +202,7 @@ await LinkForty.init({
| `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 | 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) |
| `requestTimeoutMs` | `number` | No | How long any request to the server may take before it is abandoned, in milliseconds (default: 10000). An abandoned install report is retried on the next launch. |
| `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 Down Expand Up @@ -234,27 +238,30 @@ 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. 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.
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 second argument says which of the three happened. 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';
import type { DeepLinkData, DeferredDeepLinkResult } from '@linkforty/mobile-sdk-react-native';

LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => {
LinkForty.onDeferredDeepLink((data: DeepLinkData | null, result: DeferredDeepLinkResult) => {
if (data) {
// Attributed install — user clicked a link before installing
console.log('Came from:', data.utmParameters?.source);
const { route, id } = data.customParameters || {};
if (route) navigation.navigate(route, { id });
} else if (result.status === 'failed') {
// The report didn't reach the server; it is sent again on the next launch
console.warn('Install report failed:', result.error);
} else {
// Organic install or attribution failed
console.log('Organic install');
}
});
```

| Callback Parameter | Type | Description |
|--------------------|------|-------------|
| `data` | `DeepLinkData \| null` | Attributed link data, or `null` for organic installs |
| `data` | `DeepLinkData \| null` | Attributed link data, or `null` for an organic install or a failed report |
| `result` | `DeferredDeepLinkResult` | `{ status: 'attributed' \| 'organic' \| 'failed', error? }`. With `failed`, `error` is the failure (an `Error` with `status` for an HTTP error response, `code: 'timeout'` for an abandoned request) and the next launch reports again. A callback that takes only `data` keeps working. |

The deferred link also carries how certain the match is: `confidenceScore`, `matchedFactors`, `attributionMethod` and `competingLinks` (see [`DeepLinkData`](#deeplinkdata)). Before acting on a sensitive link, check `competingLinks`:

Expand Down Expand Up @@ -327,7 +334,7 @@ LinkForty.getExternalUserId(); // 'user_123'

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.
Events are sent once, straight away. An event tracked while `init()` is still running is held (up to 100) and sent, with the time it was tracked, once `init()` has the install ID; if that launch's install report fails, the held events are dropped with one warning. There is no offline queue: an event that fails to send (no connection, server error) is logged and dropped, not retried.

```typescript
await LinkForty.trackEvent('purchase', {
Expand Down Expand Up @@ -491,6 +498,7 @@ interface LinkFortyConfig {
appToken?: string;
debug?: boolean;
attributionWindow?: number;
requestTimeoutMs?: number; // default 10000
// true = screen names only; pass options to opt into specific params
autoTrackNavigation?: boolean | { captureParams?: string[]; debounceMs?: number };
navigationRef?: NavigationContainerRefLike; // required when autoTrackNavigation is enabled
Expand Down
93 changes: 93 additions & 0 deletions __tests__/DeepLinkHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ describe('DeepLinkHandler listener', () => {
expect(second).toHaveBeenCalledWith(OTHER_URL, null);
});

it('delivers the launch URL once, however often it is initialized', async () => {
const LAUNCH_URL = 'myapp://from-launch';
linking.getInitialURL.mockClear();
linking.getInitialURL.mockResolvedValue(LAUNCH_URL);
try {
const handler = new DeepLinkHandler();
const first = vi.fn();
const second = vi.fn();
handler.initialize(BASE_URL, first);
await flush();
handler.initialize(BASE_URL, second);
await flush();

expect(linking.getInitialURL).toHaveBeenCalledTimes(1);
expect(first).toHaveBeenCalledWith(LAUNCH_URL, null);
expect(second).not.toHaveBeenCalled();
} finally {
linking.getInitialURL.mockResolvedValue(null);
linking.getInitialURL.mockClear();
}
});

it('cleanup() removes only its own listener, not the app\'s', async () => {
const appListener = vi.fn();
linking.addEventListener('url', appListener);
Expand Down Expand Up @@ -111,4 +133,75 @@ describe('LinkFortySDK.onDeepLink', () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('registered right after init() without awaiting it, keeps one listener and learns the link hosts', async () => {
const LINK_HOST_URL = 'https://acme.toapp.me/abc123';
const resolved = { shortCode: 'abc123', linkId: 'link-1', customParameters: { route: 'promo' } };
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/sdk/v1/link-domains')) {
return { ok: true, status: 200, json: async () => ({ domains: ['acme.toapp.me'] }) };
}
if (url.includes('/api/sdk/v1/resolve/')) {
return { ok: true, status: 200, json: async () => resolved };
}
return { ok: true, status: 200, json: async () => ({ installId: 'i-1', attributed: false }) };
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});

const sdk = new LinkFortySDK();
const callback = vi.fn();
const init = sdk.init({ baseUrl: BASE_URL, appToken: 'at_00000000000000000000000000000000' });
sdk.onDeepLink(callback);
await init;
await flush();

expect(linking.count()).toBe(1);

linking.emit(LINK_HOST_URL);
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
expect(callback).toHaveBeenCalledWith(LINK_HOST_URL, expect.objectContaining(resolved));

// Registering again after init() still replaces the callback, with one listener
const replacement = vi.fn();
sdk.onDeepLink(replacement);
await flush();
expect(linking.count()).toBe(1);
linking.emit(OTHER_URL);
await flush();
expect(replacement).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);

vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('a second init() while the first runs keeps one listener and one delivery per link', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ installId: 'i-1', attributed: false }) })));
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

// What React 18 StrictMode does to an effect in development: run it twice
const sdk = new LinkFortySDK();
const first = vi.fn();
const second = vi.fn();
const init1 = sdk.init({ baseUrl: BASE_URL });
sdk.onDeepLink(first);
const init2 = sdk.init({ baseUrl: BASE_URL });
sdk.onDeepLink(second);
expect(init2).toBe(init1);
await init2;
await flush();

expect(linking.count()).toBe(1);
expect(console.warn).toHaveBeenCalledWith('LinkForty SDK already initialized');

linking.emit(OTHER_URL);
await flush();
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);

vi.unstubAllGlobals();
vi.restoreAllMocks();
});
});
24 changes: 18 additions & 6 deletions __tests__/DeferredDeepLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,46 @@ import { DeferredDeepLink, deferredDeepLinkFromInstall } from '../src/DeferredDe
import type { DeepLinkData, InstallAttributionResponse } from '../src/types';

const data = { shortCode: 'abc123', linkId: 'link-1' } as DeepLinkData;
const attributed = { status: 'attributed' } as const;
const organic = { status: 'organic' } as const;

describe('DeferredDeepLink', () => {
it('calls a callback registered before the install is reported', () => {
const deferred = new DeferredDeepLink();
const callback = vi.fn();
deferred.register(callback);
deferred.deliver(data);
deferred.deliver(data, attributed);

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(data);
expect(callback).toHaveBeenCalledWith(data, attributed);
});

it('calls a callback registered after the install is reported', () => {
const deferred = new DeferredDeepLink();
deferred.deliver(data);
deferred.deliver(data, attributed);
const callback = vi.fn();
deferred.register(callback);

expect(callback).toHaveBeenCalledWith(data);
expect(callback).toHaveBeenCalledWith(data, attributed);
});

it('delivers null for an organic install registered after init', () => {
const deferred = new DeferredDeepLink();
deferred.deliver(null);
deferred.deliver(null, organic);
const callback = vi.fn();
deferred.register(callback);

expect(callback).toHaveBeenCalledWith(null);
expect(callback).toHaveBeenCalledWith(null, organic);
});

it('tells a failed report apart from an organic install', () => {
const deferred = new DeferredDeepLink();
const callback = vi.fn();
deferred.register(callback);
const error = new Error('HTTP 503');
deferred.deliver(null, { status: 'failed', error });

expect(callback).toHaveBeenCalledWith(null, { status: 'failed', error });
});

it('calls nothing on a launch that does not report the install', () => {
Expand Down
57 changes: 57 additions & 0 deletions __tests__/FingerprintCollector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, afterEach } from 'vitest';

const deviceInfo = vi.hoisted(() => ({
getUserAgent: vi.fn(async () => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15'),
getModel: vi.fn(async () => 'iPhone'),
getSystemVersion: vi.fn(async () => '18.0'),
getVersion: vi.fn(async () => '2.3.1'),
getApplicationName: vi.fn(() => 'Acme'),
}));

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

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

import { FingerprintCollector, USER_AGENT_TIMEOUT_MS } from '../src/FingerprintCollector';

afterEach(() => {
vi.useRealTimers();
deviceInfo.getUserAgent.mockReset();
deviceInfo.getUserAgent.mockImplementation(
async () => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15',
);
});

describe('FingerprintCollector user agent', () => {
it("uses the device's web user agent when the lookup answers", async () => {
const fingerprint = await FingerprintCollector.collect();

expect(fingerprint.userAgent).toContain('AppleWebKit');
expect(fingerprint).toMatchObject({ osVersion: '18.0', appVersion: '2.3.1', deviceModel: 'iPhone' });
});

it('generates one in the native SDK form when the lookup never settles', async () => {
vi.useFakeTimers();
deviceInfo.getUserAgent.mockImplementation(() => new Promise<string>(() => {}));

const pending = FingerprintCollector.collect();
await vi.advanceTimersByTimeAsync(USER_AGENT_TIMEOUT_MS);
const fingerprint = await pending;

expect(fingerprint.userAgent).toBe('Acme/2.3.1 iOS/18.0');
});

it('generates one when the lookup rejects', async () => {
deviceInfo.getUserAgent.mockImplementation(async () => {
throw new Error('getUserAgentError');
});

const fingerprint = await FingerprintCollector.collect();

expect(fingerprint.userAgent).toBe('Acme/2.3.1 iOS/18.0');
});
});
Loading
Loading