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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ React Native SDK for [LinkForty](https://github.com/LinkForty/core) — the open
- **Direct Deep Linking** - Handle Universal Links (iOS) and App Links (Android) with automatic server-side resolution
- **Deferred Deep Linking** - Route new users to specific content after install
- **Link Creation** - Create short links programmatically from your app
- **Probabilistic Attribution** - Match installs to clicks via device fingerprinting
- **Install Attribution** - Match installs to clicks exactly from the Google Play install referrer on Android, and otherwise via device fingerprinting
- **Event Tracking** - Track in-app events with attribution
- **Privacy-Focused** - No persistent device IDs required
- **TypeScript Support** - Full type definitions included
Expand Down Expand Up @@ -396,7 +396,7 @@ interface DeepLinkData {
// Deferred deep links only (onDeferredDeepLink / getInstallData):
confidenceScore?: number; // 0-100
matchedFactors?: string[]; // e.g. ['ip', 'user_agent', 'timezone']
attributionMethod?: 'fingerprint' | 'none' | string;
attributionMethod?: 'install_referrer' | 'fingerprint' | 'none' | string;
competingLinks?: number;
}
```
Expand Down Expand Up @@ -469,7 +469,7 @@ When a user taps a LinkForty URL and the app is **not** installed:
1. The LinkForty server records a click with the user's device fingerprint
2. The user is redirected to the App Store / Play Store
3. After installing and opening the app, the SDK reports the install with the device's fingerprint
4. The server matches the fingerprint to the original click (probabilistic attribution)
4. The server matches the fingerprint to the original click (probabilistic attribution). On Android, the SDK also sends the Google Play install referrer, read through `react-native-device-info` (waiting at most 2 seconds): a LinkForty link puts its click id in the referrer of the Play listing it sends the visitor to, so a server on `@linkforty/core` 1.41.1 or later matches the install to that exact click instead (`attributionMethod: 'install_referrer'`, `competingLinks: 0`)
5. Your `onDeferredDeepLink` callback fires with the matched link data
6. Your app navigates to the content the user originally clicked on

Expand Down
42 changes: 42 additions & 0 deletions __tests__/InstallReferrer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest';

vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }));
vi.mock('react-native-device-info', () => ({ default: { getInstallReferrer: vi.fn() } }));

import { readPlayInstallReferrer } from '../src/InstallReferrer';

const REFERRER = 'lf_click=11111111-2222-4333-8444-555555555555&utm_source=poster';
const fast = { os: 'android', pollIntervalMs: 1 };

describe('readPlayInstallReferrer', () => {
it('reads nothing on iOS', async () => {
const get = vi.fn(async () => REFERRER);
expect(await readPlayInstallReferrer({ os: 'ios', getInstallReferrer: get })).toBeNull();
expect(get).not.toHaveBeenCalled();
});

it('returns the referrer Play recorded', async () => {
expect(await readPlayInstallReferrer({ ...fast, getInstallReferrer: async () => REFERRER })).toBe(REFERRER);
});

it('waits while the answer is not stored yet', async () => {
const get = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('unknown')
.mockRejectedValueOnce(new Error('not ready'))
.mockResolvedValueOnce(REFERRER);
expect(await readPlayInstallReferrer({ ...fast, getInstallReferrer: get })).toBe(REFERRER);
expect(get).toHaveBeenCalledTimes(3);
});

it('returns null for an empty referrer', async () => {
expect(await readPlayInstallReferrer({ ...fast, getInstallReferrer: async () => '' })).toBeNull();
});

it('gives up at the timeout', async () => {
const started = Date.now();
const result = await readPlayInstallReferrer({ ...fast, timeoutMs: 50, getInstallReferrer: async () => 'unknown' });
expect(result).toBeNull();
expect(Date.now() - started).toBeLessThan(500);
});
});
6 changes: 3 additions & 3 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ npm install @linkforty/mobile-sdk-react-native

Peer dependencies are included automatically:
- `@react-native-async-storage/async-storage` — persists install ID and attribution data
- `react-native-device-info` — collects device fingerprint for attribution
- `react-native-device-info` — collects device fingerprint for attribution, and the Google Play install referrer on Android

For iOS, run pod install after:

Expand Down Expand Up @@ -64,8 +64,8 @@ interface DeepLinkData {
confidenceScore?: number;
/** Deferred deep links only: fingerprint factors that matched the click */
matchedFactors?: string[];
/** Deferred deep links only: 'fingerprint' when matched to a click (newer values may appear) */
attributionMethod?: 'fingerprint' | 'none' | string;
/** Deferred deep links only: 'install_referrer' when the Play install referrer named the click, 'fingerprint' when matched by device signals (newer values may appear) */
attributionMethod?: 'install_referrer' | 'fingerprint' | 'none' | string;
/**
* Deferred deep links only: how many OTHER links had a click from the
* install's network that also reached the confidence threshold. 0 = no other
Expand Down
54 changes: 54 additions & 0 deletions src/InstallReferrer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Platform } from 'react-native';
import DeviceInfo from 'react-native-device-info';

/** How long the install report waits for Play. */
export const INSTALL_REFERRER_TIMEOUT_MS = 2000;

/** How often the stored referrer is checked while Play answers. */
const POLL_INTERVAL_MS = 200;

/** What react-native-device-info returns until Play has answered, or without Play. */
const NOT_READY = 'unknown';

interface ReadOptions {
os?: string;
getInstallReferrer?: () => Promise<string>;
timeoutMs?: number;
pollIntervalMs?: number;
}

/**
* The referrer Google Play recorded for this install, on Android. A LinkForty
* link puts its click id in the referrer of every Play listing it sends a
* visitor to, so the server can match the install to that click exactly.
*
* react-native-device-info asks Play when the app starts and stores the
* answer; until then it returns 'unknown'. This waits for the answer, up to the
* timeout. Resolves null on iOS, without Play (another store, a sideload),
* when Play doesn't answer in time, and on any error: the install is then
* matched by fingerprint. Never rejects.
*/
export async function readPlayInstallReferrer(options: ReadOptions = {}): Promise<string | null> {
const {
os = Platform.OS,
getInstallReferrer = () => DeviceInfo.getInstallReferrer(),
timeoutMs = INSTALL_REFERRER_TIMEOUT_MS,
pollIntervalMs = POLL_INTERVAL_MS,
} = options;
if (os !== 'android') return null;

const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const value = await getInstallReferrer();
if (typeof value === 'string' && value !== NOT_READY) {
return value.trim() !== '' ? value : null;
}
} catch {
// Unavailable: keep trying until the deadline, then give up.
}
const remaining = deadline - Date.now();
if (remaining <= 0) return null;
await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
}
}
12 changes: 10 additions & 2 deletions src/LinkFortySDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { NavigationTracker } from './NavigationTracker';
import { LinkDomains } from './LinkDomains';
import { DeferredDeepLink, deferredDeepLinkFromInstall } from './DeferredDeepLink';
import { SDK_NAME, SDK_VERSION } from './version';
import { readPlayInstallReferrer } from './InstallReferrer';
import type {
LinkFortyConfig,
InstallAttributionResponse,
Expand Down Expand Up @@ -456,11 +457,16 @@ export class LinkFortySDK {
}

try {
// Collect device fingerprint
const fingerprint = await FingerprintCollector.collect();
// Collect device fingerprint, and on Android the Play install referrer,
// which names the click exactly
const [fingerprint, installReferrer] = await Promise.all([
FingerprintCollector.collect(),
readPlayInstallReferrer(),
]);

if (this.config.debug) {
console.log('[LinkForty] Reporting install with fingerprint:', fingerprint);
console.log('[LinkForty] Play install referrer:', installReferrer);
}

// Parse screen resolution (e.g., "1080x1920" -> [1080, 1920])
Expand Down Expand Up @@ -494,6 +500,8 @@ export class LinkFortySDK {
// entirely when not configured — server treats absence as
// "scope organic installs to no workspace" (current behavior).
appToken: this.config.appToken,
// Google Play install referrer (Android); omitted when there is none
installReferrer: installReferrer ?? undefined,
}),
}
);
Expand Down
7 changes: 4 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,12 @@ export interface DeepLinkData {
}

/**
* How an install was attributed: `'fingerprint'` when it was matched to a
* click, `'none'` for an organic install. Other values may be added by newer
* How an install was attributed: `'install_referrer'` when the Google Play
* install referrer named the click, `'fingerprint'` when device signals matched
* one, `'none'` for an organic install. Other values may be added by newer
* servers.
*/
export type AttributionMethod = 'fingerprint' | 'none' | (string & {});
export type AttributionMethod = 'install_referrer' | 'fingerprint' | 'none' | (string & {});

/**
* Response from install attribution endpoint
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// AUTO-GENERATED at build from package.json by scripts/gen-version.mjs — do not edit by hand.
export const SDK_NAME = 'react-native';
export const SDK_VERSION = '1.4.0';
export const SDK_VERSION = '1.8.0';
Loading