diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2e812..8f4165d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,11 @@ ## 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) @@ -40,7 +40,7 @@ * 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) diff --git a/README.md b/README.md index 36d5594..a696330 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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({ @@ -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 | @@ -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`. @@ -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'; @@ -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. @@ -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()`). - 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: @@ -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(); @@ -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(); @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/__tests__/DeepLinkHandler.test.ts b/__tests__/DeepLinkHandler.test.ts new file mode 100644 index 0000000..8d57f6a --- /dev/null +++ b/__tests__/DeepLinkHandler.test.ts @@ -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) => 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(); + }); +}); diff --git a/__tests__/LinkFortySDK.test.ts b/__tests__/LinkFortySDK.test.ts new file mode 100644 index 0000000..55a1cc3 --- /dev/null +++ b/__tests__/LinkFortySDK.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const linking = vi.hoisted(() => ({ + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + removeAllListeners: vi.fn(), + getInitialURL: vi.fn(() => Promise.resolve(null as string | null)), +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios', select: (o: Record) => o.ios }, + Dimensions: { get: () => ({ width: 390, height: 844 }) }, + NativeModules: { SettingsManager: { settings: { AppleLocale: 'en_US' } } }, + Linking: linking, +})); + +vi.mock('react-native-device-info', () => ({ + default: { + getUserAgent: vi.fn(async () => 'Mozilla/5.0 (iPhone)'), + getModel: vi.fn(async () => 'iPhone'), + getSystemVersion: vi.fn(async () => '18.0'), + getVersion: vi.fn(async () => '1.0.0'), + getInstallReferrer: vi.fn(async () => 'unknown'), + }, +})); + +import * as storage from '@react-native-async-storage/async-storage'; +import { LinkFortySDK } from '../src/LinkFortySDK'; + +const store = (storage as unknown as { __store: Record }).__store; + +const INSTALL_ID = '11111111-2222-4333-8444-555555555555'; + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +const organicInstall = { + installId: INSTALL_ID, + attributed: false, + confidenceScore: 0, + matchedFactors: [], + deepLinkData: {}, +}; + +type FetchCall = [string, RequestInit | undefined]; + +function calls(fetchMock: ReturnType, path: string): FetchCall[] { + return (fetchMock.mock.calls as FetchCall[]).filter(([url]) => url.endsWith(path)); +} + +function networkDown(): Promise { + return Promise.reject(new TypeError('Network request failed')); +} + +const config = { baseUrl: 'https://go.example.com' }; + +let fetchMock: ReturnType; + +beforeEach(() => { + for (const key of Object.keys(store)) delete store[key]; + linking.addEventListener.mockClear(); + linking.removeAllListeners.mockClear(); + fetchMock = vi.fn(async () => jsonResponse(200, organicInstall)); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('LinkFortySDK install report', () => { + it('reports the install on first launch and tracks events with its id', async () => { + const sdk = new LinkFortySDK(); + await sdk.init(config); + await sdk.trackEvent('signup'); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + const [[, init]] = calls(fetchMock, '/api/sdk/v1/event'); + expect(JSON.parse(String(init?.body)).installId).toBe(INSTALL_ID); + }); + + it('does not report the install again once it was reported', async () => { + await new LinkFortySDK().init(config); + const relaunch = new LinkFortySDK(); + await relaunch.init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + expect(await relaunch.getInstallId()).toBe(INSTALL_ID); + }); + + it('retries a failed install report on the next launch', async () => { + fetchMock.mockImplementation(networkDown); + const offline = new LinkFortySDK(); + await offline.init(config); + expect(await offline.getInstallId()).toBeNull(); + + fetchMock.mockImplementation(async () => jsonResponse(200, organicInstall)); + const relaunch = new LinkFortySDK(); + await relaunch.init(config); + await relaunch.trackEvent('signup'); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(2); + expect(await relaunch.getInstallId()).toBe(INSTALL_ID); + const [[, init]] = calls(fetchMock, '/api/sdk/v1/event'); + expect(JSON.parse(String(init?.body)).installId).toBe(INSTALL_ID); + }); + + it('retries after a server error response', async () => { + fetchMock.mockImplementation(async () => jsonResponse(503, { message: 'unavailable' })); + await new LinkFortySDK().init(config); + + fetchMock.mockImplementation(async () => jsonResponse(200, organicInstall)); + const relaunch = new LinkFortySDK(); + await relaunch.init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(2); + expect(await relaunch.getInstallId()).toBe(INSTALL_ID); + }); + + it('delivers the deferred deep link on the launch whose report succeeds', async () => { + fetchMock.mockImplementation(networkDown); + const offline = new LinkFortySDK(); + const first = vi.fn(); + offline.onDeferredDeepLink(first); + await offline.init(config); + expect(first).toHaveBeenCalledWith(null); + + fetchMock.mockImplementation(async () => + jsonResponse(200, { + ...organicInstall, + attributed: true, + confidenceScore: 90, + matchedFactors: ['ip'], + deepLinkData: { shortCode: 'abc', linkId: 'link-1' }, + }), + ); + const relaunch = new LinkFortySDK(); + const second = vi.fn(); + relaunch.onDeferredDeepLink(second); + await relaunch.init(config); + + expect(second).toHaveBeenCalledWith(expect.objectContaining({ shortCode: 'abc', linkId: 'link-1' })); + }); + + it('does not report again after a 2xx response without an install id', async () => { + fetchMock.mockImplementation(async () => jsonResponse(200, { ...organicInstall, installId: undefined })); + await new LinkFortySDK().init(config); + await new LinkFortySDK().init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + }); +}); + +describe('LinkFortySDK recovery of a device whose earlier report failed', () => { + // What versions up to 1.9.0 left behind when the first-launch report failed: + // the first-launch flag set, but no install ID + function stuckDevice() { + store['@linkforty:first_launch'] = 'true'; + } + + it('reports the install once, stores the install ID and tracks events', async () => { + stuckDevice(); + const sdk = new LinkFortySDK(); + await sdk.init(config); + await sdk.trackEvent('signup'); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + expect(store['@linkforty:install_id']).toBe(INSTALL_ID); + const [[, init]] = calls(fetchMock, '/api/sdk/v1/event'); + expect(JSON.parse(String(init?.body)).installId).toBe(INSTALL_ID); + + await new LinkFortySDK().init(config); + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + }); + + it('keeps retrying while the recovery report fails', async () => { + stuckDevice(); + fetchMock.mockImplementation(networkDown); + await new LinkFortySDK().init(config); + + fetchMock.mockImplementation(async () => jsonResponse(200, organicInstall)); + const relaunch = new LinkFortySDK(); + await relaunch.init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(2); + expect(await relaunch.getInstallId()).toBe(INSTALL_ID); + }); + + it('recovers at most once after a 2xx response without an install ID', async () => { + stuckDevice(); + fetchMock.mockImplementation(async () => jsonResponse(200, { ...organicInstall, installId: undefined })); + await new LinkFortySDK().init(config); + await new LinkFortySDK().init(config); + await new LinkFortySDK().init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + }); + + it('does not call the deferred deep link callback on a recovery report', async () => { + const attributed = { + ...organicInstall, + attributed: true, + confidenceScore: 90, + matchedFactors: ['ip'], + deepLinkData: { shortCode: 'abc', linkId: 'link-1' }, + }; + + for (const respond of [ + async () => jsonResponse(200, attributed), + async () => jsonResponse(200, organicInstall), + networkDown, + ]) { + for (const key of Object.keys(store)) delete store[key]; + stuckDevice(); + fetchMock.mockImplementation(respond); + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + await sdk.init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install').length).toBeGreaterThan(0); + expect(callback).not.toHaveBeenCalled(); + fetchMock.mockClear(); + } + }); + + it('only restores the install ID: no install data, no credited link', async () => { + stuckDevice(); + fetchMock.mockImplementation(async () => + jsonResponse(200, { + ...organicInstall, + attributed: true, + confidenceScore: 90, + matchedFactors: ['ip'], + deepLinkData: { shortCode: 'abc', linkId: 'link-1' }, + }), + ); + const sdk = new LinkFortySDK(); + await sdk.init(config); + await sdk.trackEvent('signup'); + + expect(await sdk.getInstallId()).toBe(INSTALL_ID); + expect(await sdk.getInstallData()).toBeNull(); + expect(store['@linkforty:install_data']).toBeUndefined(); + expect(sdk.getActiveAttribution()).toBeNull(); + const [[, init]] = calls(fetchMock, '/api/sdk/v1/event'); + expect(JSON.parse(String(init?.body)).attributedLinkId).toBeUndefined(); + }); + + it('keeps the link credited by an earlier link tap', async () => { + stuckDevice(); + const tapped = { linkId: 'tapped-link', openedAt: '2026-10-01T00:00:00.000Z' }; + store['@linkforty:attribution'] = JSON.stringify(tapped); + fetchMock.mockImplementation(async () => + jsonResponse(200, { ...organicInstall, attributed: true, deepLinkData: { shortCode: 'abc', linkId: 'link-1' } }), + ); + const sdk = new LinkFortySDK(); + await sdk.init(config); + + expect(sdk.getActiveAttribution()).toEqual(tapped); + }); + + it('never reports again on a device that has an install ID', async () => { + store['@linkforty:first_launch'] = 'true'; + store['@linkforty:install_id'] = INSTALL_ID; + const sdk = new LinkFortySDK(); + await sdk.init(config); + await new LinkFortySDK().init(config); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(0); + expect(await sdk.getInstallId()).toBe(INSTALL_ID); + }); +}); + +function logged(): string { + const spies = [console.log, console.warn, console.error] as unknown as ReturnType[]; + return spies + .flatMap((spy) => spy.mock.calls) + .map((args) => args.map((a: unknown) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')) + .join('\n'); +} + +describe('LinkFortySDK logging', () => { + const API_KEY = 'lf_live_secret_key_123'; + const LINK = 'https://go.example.com/abc123?utm_source=email&email=person%40example.com'; + + async function openLink(debug: boolean) { + linking.getInitialURL.mockResolvedValueOnce(LINK); + fetchMock.mockImplementation(async (url: string) => + url.includes('/api/sdk/v1/resolve/') + ? jsonResponse(200, { shortCode: 'abc123', linkId: 'link-1', customParameters: { route: 'promo' } }) + : jsonResponse(200, organicInstall), + ); + const sdk = new LinkFortySDK(); + await sdk.init({ ...config, apiKey: API_KEY, debug }); + return new Promise((resolve) => sdk.onDeepLink((_url, data) => resolve(data))); + } + + it('logs nothing on a successful launch and deep link without debug', async () => { + const data = await openLink(false); + + expect(data).toEqual(expect.objectContaining({ linkId: 'link-1' })); + expect(logged()).toBe(''); + }); + + it('logs nothing on a cold start without a link and without debug', async () => { + const sdk = new LinkFortySDK(); + await sdk.init(config); + sdk.onDeepLink(() => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(logged()).toBe(''); + }); + + it('logs the link with debug, but never the API key', async () => { + await openLink(true); + + const output = logged(); + expect(output).toContain('go.example.com/abc123'); + expect(output).toContain('[redacted]'); + expect(output).not.toContain(API_KEY); + }); + + it('surfaces a failed install report without debug', async () => { + fetchMock.mockImplementation(networkDown); + await new LinkFortySDK().init({ ...config, apiKey: API_KEY }); + + expect(console.error).toHaveBeenCalledTimes(1); + expect(logged()).toContain('Failed to report install'); + expect(logged()).not.toContain(API_KEY); + }); +}); + +describe('LinkFortySDK.clearData', () => { + const appConfig = { ...config, appToken: 'at_00000000000000000000000000000000' }; + + it('removes the saved link hosts and reports a new install on the next launch', async () => { + fetchMock.mockImplementation(async (url: string) => + url.endsWith('/api/sdk/v1/link-domains') + ? jsonResponse(200, { domains: ['links.example.org'] }) + : jsonResponse(200, organicInstall), + ); + const sdk = new LinkFortySDK(); + await sdk.init(appConfig); + await vi.waitFor(() => expect(store['@linkforty:link_domains']).toBeDefined()); + + // The refresh clearData() starts finds no hosts this time + fetchMock.mockImplementation(async (url: string) => + url.endsWith('/api/sdk/v1/link-domains') + ? jsonResponse(404, { message: 'not found' }) + : jsonResponse(200, organicInstall), + ); + await sdk.clearData(); + + expect(store['@linkforty:link_domains']).toBeUndefined(); + expect(store['@linkforty:install_id']).toBeUndefined(); + expect(store['@linkforty:first_launch']).toBeUndefined(); + expect(await sdk.getInstallId()).toBeNull(); + + await new LinkFortySDK().init(appConfig); + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(2); + }); + + it('can be called before init()', async () => { + store['@linkforty:link_domains'] = '["links.example.org"]'; + await new LinkFortySDK().clearData(); + expect(store['@linkforty:link_domains']).toBeUndefined(); + }); +}); diff --git a/__tests__/NavigationTracker.test.ts b/__tests__/NavigationTracker.test.ts index f66f7f9..fecebb4 100644 --- a/__tests__/NavigationTracker.test.ts +++ b/__tests__/NavigationTracker.test.ts @@ -64,6 +64,18 @@ describe('sanitizeScreenParams', () => { expect(sanitizeScreenParams({ nested: { a: 1 } }, ['nested'])).toBeUndefined(); expect(sanitizeScreenParams(undefined, ['x'])).toBeUndefined(); }); + + it('reads params typed as React Navigation types them, and ignores non-objects', () => { + // React Navigation 7 types route params as Readonly + const params: Readonly = Object.freeze({ productId: 'abc' }); + expect(sanitizeScreenParams(params, ['productId'])).toEqual({ productId: 'abc' }); + + const loose = (value: unknown) => value as Readonly; + expect(sanitizeScreenParams(loose(['a', 'b']), ['0'])).toBeUndefined(); + expect(sanitizeScreenParams(loose('productId'), ['productId'])).toBeUndefined(); + expect(sanitizeScreenParams(loose(42), ['productId'])).toBeUndefined(); + expect(sanitizeScreenParams(null, ['productId'])).toBeUndefined(); + }); }); describe('NavigationTracker', () => { diff --git a/__tests__/__mocks__/async-storage.ts b/__tests__/__mocks__/async-storage.ts index 59c873d..6c440e8 100644 --- a/__tests__/__mocks__/async-storage.ts +++ b/__tests__/__mocks__/async-storage.ts @@ -2,6 +2,9 @@ import { vi } from 'vitest'; const store: Record = {}; +// Only the methods AsyncStorage 2.x and 3.x share (3.x dropped multiRemove and +// the other multi* methods), so a test fails if the SDK uses one of them. + vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: vi.fn((key: string) => Promise.resolve(store[key] ?? null)), @@ -13,10 +16,6 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ delete store[key]; return Promise.resolve(); }), - multiRemove: vi.fn((keys: string[]) => { - for (const key of keys) delete store[key]; - return Promise.resolve(); - }), clear: vi.fn(() => { for (const key of Object.keys(store)) delete store[key]; return Promise.resolve(); diff --git a/llms.txt b/llms.txt index dfcbf0c..150346d 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # @linkforty/mobile-sdk-react-native -> React Native SDK for LinkForty — an open-source mobile attribution and deep link management platform. This SDK handles deep linking, deferred deep linking (install attribution), in-app event tracking, and link creation. Works with LinkForty Cloud (linkforty.com) or self-hosted Core instances. +> React Native SDK for LinkForty — an open-source mobile attribution and deep link management platform. This SDK handles deep linking, deferred deep linking (install attribution), in-app event tracking, and link creation. Works with the hosted service (linkforty.com) or self-hosted Core instances. ## Installation @@ -8,14 +8,14 @@ npm install @linkforty/mobile-sdk-react-native ``` -Also install the SDK's two native dependencies in the app itself, at the SDK's major versions. React Native autolinking only links native modules listed in the app's own package.json, and AsyncStorage 3.x registers a different native module name than the 2.x the SDK uses: +Also install the SDK's two native dependencies in the app itself. They are peer dependencies, and React Native autolinking only links native modules listed in the app's own package.json: ```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-async-storage/async-storage` — persists install ID and attribution data -- `react-native-device-info` — collects device fingerprint for attribution, and the Google Play install referrer on Android +- `@react-native-async-storage/async-storage` (^2.2.0 or ^3.0.0) — persists install ID and attribution data. With 3.x the SDK uses its default (legacy) storage, which keeps 2.x data. +- `react-native-device-info` (^15.0.0) — collects device fingerprint for attribution, and the Google Play install referrer on Android Requires React Native >= 0.65 (AsyncStorage 2.x's floor), React >= 17. @@ -41,7 +41,10 @@ interface LinkFortyConfig { * Safe to ship in your app bundle (workspace identifier only, not auth). */ appToken?: string; - /** Enable debug logging (default: false) */ + /** + * Log what the SDK does, including link URLs and link data (default: false). + * The API key is never logged. Without debug, only failures are logged. + */ debug?: boolean; /** Attribution window in days (default: 7) */ attributionWindow?: number; @@ -146,7 +149,7 @@ await LinkFortySDK.init({ ### onDeepLink(callback: DeepLinkCallback): void -Register a callback for direct deep links — fires when user taps a LinkForty link and the app is already installed. The SDK resolves the link server-side to get enriched data. +Register a callback for direct deep links — fires when user taps a LinkForty link and the app is already installed. The SDK resolves the link server-side to get enriched data. Calling it again replaces the callback (only the newest is called, once per link). ```typescript LinkFortySDK.onDeepLink((url, data) => { @@ -161,7 +164,7 @@ LinkFortySDK.onDeepLink((url, data) => { ### onDeferredDeepLink(callback: DeferredDeepLinkCallback): void -Register a callback for deferred deep links — fires on first app launch if the install was attributed to a link click. This enables routing users to specific content after they install. +Register a callback for deferred deep links — fires once, on the launch that reports the install (the first launch, or a later one if that report failed): with the link's data if the install was attributed to a link click, or null for an organic install. A launch whose report fails also calls it with null. This enables routing users to specific content after they install. ```typescript LinkFortySDK.onDeferredDeepLink((data) => { @@ -177,7 +180,7 @@ LinkFortySDK.onDeferredDeepLink((data) => { ### trackEvent(name: string, properties?: Record\): Promise\ -Track in-app events for attribution and conversion analysis. Needs the install ID from the first-launch install report; without it the call logs a warning and sends nothing. There is no offline queue: a failed send is logged and dropped, not retried. +Track in-app events for attribution and conversion analysis. Needs the install ID from the install report (sent on first launch, and again on each later launch until the server accepts it); without it the call logs a warning and sends nothing. Devices whose first-launch report failed on SDK 1.9.0 or earlier (no install ID) report the install once after upgrading; it appears as an install dated at the upgrade, usually organic, and only restores the install ID: it does not call onDeferredDeepLink, save link data for getInstallData(), or credit a link for later events. There is no offline queue: a failed send is logged and dropped, not retried. ```typescript await LinkFortySDK.trackEvent('purchase', { @@ -228,11 +231,11 @@ Retrieve cached attribution data from the initial deferred deep link. ### getInstallId(): Promise\ -Get the unique install ID assigned on first launch. +Get the unique install ID assigned when the install is reported (first launch, or a later launch if that report failed); null until then. ### clearData(): Promise\ -Clear all cached SDK data (install ID, attribution, first-launch flag). The next launch reports a new install, so use it for testing, not on user logout. +Clear all cached SDK data (install ID, install link data, install-reported flag, attribution context, saved link hosts). The next launch reports a new install, so use it for testing, not on user logout. ## iOS Platform Setup diff --git a/package-lock.json b/package-lock.json index b32fee1..f79a285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,8 @@ "name": "@linkforty/mobile-sdk-react-native", "version": "1.9.1", "license": "MIT", - "dependencies": { - "@react-native-async-storage/async-storage": "^2.2.0", - "react-native-device-info": "^15.0.0" - }, "devDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^10.3.5", @@ -20,6 +17,7 @@ "@types/react": "^19.1.0", "@vitest/coverage-v8": "^3.0.0", "conventional-changelog-conventionalcommits": "^7.0.2", + "react-native-device-info": "^15.0.1", "semantic-release": "^25.0.3", "typescript": "^5.9.0", "vitest": "^3.0.0" @@ -29,8 +27,16 @@ "npm": ">=10.0.0" }, "peerDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0", + "@react-navigation/native": ">=6.0.0", "react": ">=17.0.0", - "react-native": ">=0.64.0" + "react-native": ">=0.65.0", + "react-native-device-info": "^15.0.0" + }, + "peerDependenciesMeta": { + "@react-navigation/native": { + "optional": true + } } }, "node_modules/@actions/core": { @@ -1640,6 +1646,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "dev": true, "license": "MIT", "dependencies": { "merge-options": "^3.0.4" @@ -5554,6 +5561,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6333,6 +6341,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-plain-obj": "^2.1.0" @@ -9718,6 +9727,7 @@ "version": "15.0.1", "resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-15.0.1.tgz", "integrity": "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A==", + "dev": true, "license": "MIT", "peerDependencies": { "react-native": "*" diff --git a/package.json b/package.json index 0969f7d..22b3199 100644 --- a/package.json +++ b/package.json @@ -44,20 +44,19 @@ "npm": ">=10.0.0" }, "peerDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0", "@react-navigation/native": ">=6.0.0", "react": ">=17.0.0", - "react-native": ">=0.64.0" + "react-native": ">=0.65.0", + "react-native-device-info": "^15.0.0" }, "peerDependenciesMeta": { "@react-navigation/native": { "optional": true } }, - "dependencies": { - "@react-native-async-storage/async-storage": "^2.2.0", - "react-native-device-info": "^15.0.0" - }, "devDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^10.3.5", @@ -65,6 +64,7 @@ "@types/react": "^19.1.0", "@vitest/coverage-v8": "^3.0.0", "conventional-changelog-conventionalcommits": "^7.0.2", + "react-native-device-info": "^15.0.1", "semantic-release": "^25.0.3", "typescript": "^5.9.0", "vitest": "^3.0.0" diff --git a/src/AttributionContext.ts b/src/AttributionContext.ts index 34c4213..9cad91b 100644 --- a/src/AttributionContext.ts +++ b/src/AttributionContext.ts @@ -18,8 +18,6 @@ * still attributes to the last link (subject to the server-side conversion window). * The session is intentionally in-memory: a new JS context (cold start) is a new * session. - * - * See `.brandon/sit-237-auto-nav-tracking/plan.md` §0.2 / Phase D for the model. */ import AsyncStorage from '@react-native-async-storage/async-storage'; diff --git a/src/DeepLinkHandler.ts b/src/DeepLinkHandler.ts index c2b40c4..a223959 100644 --- a/src/DeepLinkHandler.ts +++ b/src/DeepLinkHandler.ts @@ -6,6 +6,9 @@ * resolves them via the server API to retrieve the full deep link data * (custom parameters, UTM params, etc.) that would normally be appended * during the redirect. + * + * Logs only with `debug` on: URLs and link data never reach the console + * otherwise. */ import { Linking } from 'react-native'; @@ -19,6 +22,21 @@ export class DeepLinkHandler { private baseUrl: string | null = null; private resolveFn: ResolveFunction | null = null; private linkDomains: LinkDomains | null = null; + private readonly debug: boolean; + /** The `Linking` 'url' subscription, added once and removed by cleanup() */ + private subscription: { remove: () => void } | null = null; + + /** + * @param options.debug - Log each link handled, its resolution and fallbacks + * (default: false, logs nothing) + */ + constructor(options: { debug?: boolean } = {}) { + this.debug = options.debug ?? false; + } + + private log(...args: unknown[]): void { + if (this.debug) console.log('[LinkForty]', ...args); + } /** * Parse a URL string manually. @@ -57,8 +75,7 @@ export class DeepLinkHandler { } return { pathname, searchParams }; - } catch (error) { - console.error('[LinkForty] Failed to parse URL string:', error); + } catch { return null; } } @@ -73,7 +90,8 @@ export class DeepLinkHandler { } /** - * Initialize deep link listener + * Initialize deep link listener. Calling it again replaces the callback + * (the newest one receives links) without adding a second listener. * @param baseUrl - LinkForty instance base URL for detecting LinkForty URLs * @param callback - Callback invoked with parsed/resolved deep link data * @param resolveFn - Optional function to resolve LinkForty URLs via the server API @@ -90,27 +108,31 @@ export class DeepLinkHandler { this.resolveFn = resolveFn || null; this.linkDomains = linkDomains || null; - console.log('[LinkForty] DeepLinkHandler.initialize: baseUrl=', baseUrl, 'hasResolveFn=', !!resolveFn); + this.log('DeepLinkHandler.initialize: baseUrl=', baseUrl, 'hasResolveFn=', !!resolveFn); - // Listen for deep links when app is already open - Linking.addEventListener('url', this.handleDeepLink); + // Listen for deep links when app is already open (one listener per handler) + if (!this.subscription) { + this.subscription = Linking.addEventListener('url', this.handleDeepLink); + } // Check if app was opened via deep link Linking.getInitialURL().then((url) => { - console.log('[LinkForty] getInitialURL result:', url); + this.log('getInitialURL result:', url); if (url) { this.handleDeepLink({ url }); } else { - console.warn('[LinkForty] getInitialURL returned null — app may not have been opened via deep link, or Android consumed the Intent'); + this.log('getInitialURL returned null — app may not have been opened via deep link, or Android consumed the Intent'); } }); } /** - * Clean up listeners + * Remove this handler's listener. Other 'url' listeners (the app's own) + * are left in place. */ cleanup(): void { - Linking.removeAllListeners('url'); + this.subscription?.remove(); + this.subscription = null; this.callback = null; } @@ -120,38 +142,40 @@ export class DeepLinkHandler { * Falls back to local URL parsing on failure. */ private handleDeepLink = async ({ url }: { url: string }): Promise => { - console.log('[LinkForty] handleDeepLink called with url:', url); + this.log('handleDeepLink called with url:', url); if (!this.callback || !url) { - console.warn('[LinkForty] handleDeepLink early return: callback=', !!this.callback, 'url=', url); + this.log('handleDeepLink early return: callback=', !!this.callback, 'url=', url); return; } // Parse a LinkForty URL locally first, for the fallback const isLinkFortyUrl = await this.isLinkFortyUrl(url); const localData = isLinkFortyUrl ? this.parseLinkURL(url) : null; - console.log('[LinkForty] localData parsed:', JSON.stringify(localData)); + this.log('localData parsed:', JSON.stringify(localData)); // If this is a LinkForty URL and we have a resolver, try the server if (localData && this.resolveFn) { try { - console.log('[LinkForty] Resolving URL via server...'); + this.log('Resolving URL via server...'); const resolvedData = await this.resolveURL(url); - console.log('[LinkForty] Resolve result:', JSON.stringify(resolvedData)); + this.log('Resolve result:', JSON.stringify(resolvedData)); if (resolvedData) { this.callback(url, mergeUrlParameters(resolvedData, localData?.customParameters)); return; } - console.warn('[LinkForty] Resolve returned null, falling back to local parse'); + this.log('Resolve returned null, falling back to local parse'); } catch (error) { - console.warn('[LinkForty] Failed to resolve URL from server, falling back to local parse:', error); + this.log('Failed to resolve URL from server, falling back to local parse:', error); } } else { - console.warn('[LinkForty] Skipping server resolve: localData=', !!localData, 'resolveFn=', !!this.resolveFn, 'isLinkFortyUrl=', isLinkFortyUrl); + this.log('Skipping server resolve: localData=', !!localData, 'resolveFn=', !!this.resolveFn, 'isLinkFortyUrl=', isLinkFortyUrl); } // Fallback to locally-parsed data (no customParameters from server) - console.warn('[LinkForty] Using local fallback — customParameters will be empty for App Link URLs'); + if (localData) { + this.log('Using local fallback — customParameters will be empty for App Link URLs'); + } this.callback(url, localData); }; @@ -212,7 +236,7 @@ export class DeepLinkHandler { resolvePath += `?${DeepLinkHandler.buildQueryString(fpParams)}`; } catch (error) { // If fingerprint collection fails, still resolve without it - console.warn('[LinkForty] Fingerprint collection failed, resolving without fingerprint:', error); + this.log('Fingerprint collection failed, resolving without fingerprint:', error); } return this.resolveFn(resolvePath); @@ -264,7 +288,7 @@ export class DeepLinkHandler { customParameters: Object.keys(customParameters).length > 0 ? customParameters : undefined, }; } catch (error) { - console.error('Failed to parse deep link URL:', error); + this.log('Failed to parse deep link URL:', error); return null; } } diff --git a/src/LinkDomains.ts b/src/LinkDomains.ts index 3fc6532..8f68f4b 100644 --- a/src/LinkDomains.ts +++ b/src/LinkDomains.ts @@ -96,6 +96,20 @@ export class LinkDomains { } } + /** + * Forgets the saved hosts (see `LinkFortySDK.clearData`) and learns them + * again, as on a first launch. + */ + async reset(): Promise { + await this.update(null); + this.start(); + } + + /** Removes the saved hosts without an instance (before `init()`). */ + static async clearSaved(): Promise { + await AsyncStorage.removeItem(STORAGE_KEY); + } + private async load(): Promise { try { const saved = await AsyncStorage.getItem(STORAGE_KEY); diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index fef29f8..0c17e98 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -24,7 +24,19 @@ import type { const STORAGE_KEYS = { INSTALL_ID: '@linkforty:install_id', INSTALL_DATA: '@linkforty:install_data', - FIRST_LAUNCH: '@linkforty:first_launch', + /** + * Set once the server has accepted the install report. Versions up to + * 1.9.0 set it before sending the report, so on its own it doesn't prove + * the report was accepted; it is still written so a downgraded app doesn't + * report again. + */ + INSTALL_REPORTED: '@linkforty:first_launch', + /** + * Set when the server accepts an install report (a 2xx response), even one + * without an install ID, so such a response can't trigger a report on every + * launch. + */ + INSTALL_REPORT_ACCEPTED: '@linkforty:install_report_accepted', }; export class LinkFortySDK { @@ -54,7 +66,7 @@ export class LinkFortySDK { this.config = config; if (config.debug) { - console.log('[LinkForty] Initializing SDK with config:', config); + console.log('[LinkForty] Initializing SDK with config:', redactConfig(config)); } // Recreate the attribution context so it honors the debug flag, then restore @@ -63,15 +75,15 @@ export class LinkFortySDK { this.attribution = new AttributionContext({ debug: config.debug }); await this.attribution.load(); - // Check if this is first launch - const isFirstLaunch = await this.isFirstLaunch(); - - if (isFirstLaunch) { - // Report install and get deferred deep link - await this.reportInstall(); - } else { + if (await this.isInstallReported()) { // Load cached install data await this.loadInstallData(); + } else { + // First launch, or an earlier report failed: report the install and get + // the deferred deep link. With the first-launch flag already set, this + // is a device recovering from a report that failed on 1.9.0 or earlier. + const recovery = (await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORTED)) !== null; + await this.reportInstall({ recovery }); } // Learn the workspace's link hosts in the background @@ -84,7 +96,7 @@ export class LinkFortySDK { this.linkDomains.start(); // Initialize deep link handler for direct deep links - this.deepLinkHandler = new DeepLinkHandler(); + this.deepLinkHandler = new DeepLinkHandler({ debug: config.debug }); // Start auto screen-view tracking if requested. Screen views flow through // trackEvent, so they inherit the last-click attribution stamp. Guarded so a @@ -188,7 +200,7 @@ export class LinkFortySDK { } if (!this.deepLinkHandler) { - this.deepLinkHandler = new DeepLinkHandler(); + this.deepLinkHandler = new DeepLinkHandler({ debug: this.config.debug }); } // Create a resolver that wraps the private apiRequest method @@ -247,7 +259,7 @@ export class LinkFortySDK { eventData: properties || {}, timestamp: new Date().toISOString(), ...this.attribution.getStamp(), - // SDK identity for health/version diagnostics (SIT-235) + // SDK identity, so the server can tell which SDK and version sent it sdkName: SDK_NAME, sdkVersion: SDK_VERSION, }; @@ -410,17 +422,32 @@ export class LinkFortySDK { } /** - * Clear all cached data (for testing) + * Clear all cached data (for testing): the install ID and link data, the + * install-reported flag, the attribution context and the saved link hosts. + * The next launch reports a new install. */ async clearData(): Promise { - await AsyncStorage.multiRemove([ - STORAGE_KEYS.INSTALL_ID, - STORAGE_KEYS.INSTALL_DATA, - STORAGE_KEYS.FIRST_LAUNCH, - ]); + // removeItem (not multiRemove) works with AsyncStorage 2.x and 3.x alike + await Promise.all( + [ + STORAGE_KEYS.INSTALL_ID, + STORAGE_KEYS.INSTALL_DATA, + STORAGE_KEYS.INSTALL_REPORTED, + STORAGE_KEYS.INSTALL_REPORT_ACCEPTED, + ].map( + (key) => AsyncStorage.removeItem(key), + ), + ); await this.attribution.clear(); + // Forget the saved link hosts, then learn them again + if (this.linkDomains) { + await this.linkDomains.reset(); + } else { + await LinkDomains.clearSaved(); + } + if (this.navigationTracker) { this.navigationTracker.stop(); this.navigationTracker = null; @@ -435,23 +462,37 @@ export class LinkFortySDK { } /** - * Check if this is the first app launch + * Whether the server has accepted this install's report (on this or an + * earlier launch): an install ID is stored, or a report got a 2xx response. + * + * Until then every launch reports the install, so a failed report is + * retried. This also recovers devices where a version up to 1.9.0 set the + * first-launch flag but its report failed (flag set, no install ID): they + * report once after upgrading. A device with an install ID never reports + * again. */ - private async isFirstLaunch(): Promise { - const firstLaunchFlag = await AsyncStorage.getItem(STORAGE_KEYS.FIRST_LAUNCH); - - if (!firstLaunchFlag) { - await AsyncStorage.setItem(STORAGE_KEYS.FIRST_LAUNCH, 'true'); - return true; - } - - return false; + private async isInstallReported(): Promise { + const [installId, accepted] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID), + AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED), + ]); + return installId !== null || accepted !== null; } /** - * Report app installation to LinkForty backend + * Report app installation to LinkForty backend. Marks the install reported + * only once the server accepts it; on failure the next launch reports again. + * + * A recovery report (a device whose report failed on 1.9.0 or earlier) only + * restores the install ID: it doesn't call the deferred deep link callback + * (the user has been using the app, so routing them like a fresh install + * would be wrong), save link data or set the link credited for events. */ - private async reportInstall(): Promise { + private async reportInstall({ recovery = false }: { recovery?: boolean } = {}): Promise { + const deliver = (data: DeepLinkData | null) => { + if (!recovery) this.deferredDeepLink.deliver(data); + }; + if (!this.config) { return; } @@ -492,13 +533,13 @@ export class LinkFortySDK { platformVersion: fingerprint.osVersion, deviceId: undefined, // Optional: Can add IDFA/GAID if available attributionWindowHours, - // SDK identity for health/version diagnostics (SIT-235) + // SDK identity, so the server can tell which SDK and version sent it sdkName: SDK_NAME, sdkVersion: SDK_VERSION, - // Public workspace token; lets Cloud scope organic installs + // Public workspace token; lets the server scope organic installs // (those with no click match) to the right workspace. Omitted - // entirely when not configured — server treats absence as - // "scope organic installs to no workspace" (current behavior). + // entirely when not configured; the server then scopes organic + // installs to no workspace. appToken: this.config.appToken, // Google Play install referrer (Android); omitted when there is none installReferrer: installReferrer ?? undefined, @@ -516,6 +557,21 @@ export class LinkFortySDK { await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_ID, response.installId); } + // The server accepted the report: don't report this install again + await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED, 'true'); + await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_REPORTED, 'true'); + + // A recovery report only restores the install ID. Its match is a + // fingerprint match made now, not the click that led to the install, so + // it is neither saved as install data nor credited for later events (a + // link tap that opens the app sets that through the deep link path). + if (recovery) { + if (this.config.debug) { + console.log('[LinkForty] Install ID restored; link data from this report is not used'); + } + return; + } + // If attributed, store deep link data const deepLinkData = deferredDeepLinkFromInstall(response); if (deepLinkData) { @@ -531,7 +587,7 @@ export class LinkFortySDK { await this.attribution.recordDeepLinkOpen(deepLinkData.linkId); } - this.deferredDeepLink.deliver(deepLinkData); + deliver(deepLinkData); if (this.config.debug) { console.log('[LinkForty] Install attributed with confidence:', response.confidenceScore); @@ -544,13 +600,13 @@ export class LinkFortySDK { } // null indicates an organic install - this.deferredDeepLink.deliver(null); + deliver(null); } } catch (error) { - console.error('[LinkForty] Failed to report install:', error); + console.error('[LinkForty] Failed to report install (retried on the next launch):', error); // null on error - this.deferredDeepLink.deliver(null); + deliver(null); } } @@ -573,7 +629,7 @@ export class LinkFortySDK { const headers: Record = { 'Content-Type': 'application/json', - // SDK identity header (SIT-235) — name/version on every SDK request. + // SDK identity header: name/version on every SDK request. 'X-LinkForty-SDK': `${SDK_NAME}/${SDK_VERSION}`, }; @@ -606,5 +662,15 @@ export class LinkFortySDK { } } +/** The config as logged with `debug`: the API key, a secret, is redacted. */ +function redactConfig(config: LinkFortyConfig): Record { + const { apiKey, navigationRef, ...rest } = config; + return { + ...rest, + ...(apiKey !== undefined && { apiKey: '[redacted]' }), + ...(navigationRef !== undefined && { navigationRef: '[set]' }), + }; +} + // Export singleton instance export default new LinkFortySDK(); diff --git a/src/NavigationTracker.ts b/src/NavigationTracker.ts index 15d00c6..1c686ed 100644 --- a/src/NavigationTracker.ts +++ b/src/NavigationTracker.ts @@ -12,8 +12,6 @@ * that doesn't use react-navigation) results in a no-op, never a crash. The SDK * has no compile-time dependency on `@react-navigation/native` — the ref is * typed structurally (see NavigationContainerRefLike). - * - * See `.brandon/sit-237-auto-nav-tracking/plan.md` Phase A. */ import type { NavigationContainerRefLike, NavigationRouteLike } from './types'; @@ -46,19 +44,20 @@ const MAX_PARAM_STRING_LENGTH = 256; * keys: only the allow-listed keys are considered, and only primitive values * (string / number / boolean) survive — strings are capped and nested * objects/arrays/functions are dropped. Returns undefined when nothing survives - * (including when the allow-list is empty/omitted). + * (including when the allow-list is empty/omitted). Accepts any value, since + * route params are typed loosely by React Navigation and set by the app. */ export function sanitizeScreenParams( - params: Record | undefined, + params: Readonly | null | undefined, allowList: string[] | undefined, ): Record | undefined { - if (!params || typeof params !== 'object' || !allowList || allowList.length === 0) { + if (!params || typeof params !== 'object' || Array.isArray(params) || !allowList || allowList.length === 0) { return undefined; } const allowed = new Set(allowList); const out: Record = {}; - for (const [key, value] of Object.entries(params)) { + for (const [key, value] of Object.entries(params as Record)) { if (!allowed.has(key)) { continue; } diff --git a/src/types.ts b/src/types.ts index e201bb2..bc6e938 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,17 +8,25 @@ export interface LinkFortyConfig { /** Base URL of your LinkForty instance (e.g., 'https://go.yourdomain.com') */ baseUrl: string; - /** Optional API key for Cloud authentication */ + /** + * Optional API key, only needed for `createLink()`. It is a secret: don't + * ship one in your app unless you create links from it. Never logged. + */ apiKey?: string; /** - * Public workspace token (LinkForty Cloud only). Recommended — required - * for organic installs (App Store discovery, social mentions, etc.) to - * be attributed to your workspace. Find it in the dashboard under - * Workspace Settings → App Token. Safe to ship in your app bundle. + * Public workspace token, used by the hosted service. Recommended there — + * required for organic installs (App Store discovery, social mentions, etc.) + * to be attributed to your workspace. Find it in the dashboard under + * Settings → App Configuration. Safe to ship in your app bundle. * Format: `at_<32 hex chars>`. */ appToken?: string; - /** Enable debug logging */ + /** + * Log what the SDK does (URLs, resolved link data, requests) to the console. + * Off by default: without it the SDK logs only failures (a failed install + * report or event, init() called twice, an event dropped for lack of an + * install ID), never link or device data. + */ debug?: boolean; /** Custom attribution window in days (default: 7) */ attributionWindow?: number; @@ -71,8 +79,12 @@ export interface AutoTrackNavigationOptions { export interface NavigationRouteLike { /** The active route/screen name (used as the screen-view name) */ name: string; - /** Route params, if any (sanitized before being attached to the event) */ - params?: Record; + /** + * Route params, if any (sanitized before being attached to the event). + * Typed as `object` to match React Navigation, whose routes carry + * `Readonly` params; the SDK reads them defensively. + */ + params?: Readonly; } /**