diff --git a/README.md b/README.md index 3b39985..6c86c4f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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; } ``` @@ -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 diff --git a/__tests__/InstallReferrer.test.ts b/__tests__/InstallReferrer.test.ts new file mode 100644 index 0000000..7c08719 --- /dev/null +++ b/__tests__/InstallReferrer.test.ts @@ -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>() + .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); + }); +}); diff --git a/llms.txt b/llms.txt index 8fb74a5..831822f 100644 --- a/llms.txt +++ b/llms.txt @@ -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: @@ -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 diff --git a/src/InstallReferrer.ts b/src/InstallReferrer.ts new file mode 100644 index 0000000..eceb468 --- /dev/null +++ b/src/InstallReferrer.ts @@ -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; + 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 { + 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))); + } +} diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index c21cb2e..fef29f8 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -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, @@ -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]) @@ -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, }), } ); diff --git a/src/types.ts b/src/types.ts index 0a0520c..e201bb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/src/version.ts b/src/version.ts index ba297da..6cd2113 100644 --- a/src/version.ts +++ b/src/version.ts @@ -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';