From 456acda314ae218d93c45b5e9b0f552b5f2bdde6 Mon Sep 17 00:00:00 2001 From: xantus smith <281845000+smith-909@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:57:24 -0700 Subject: [PATCH 1/9] docs: correct setup, requirements and API reference in README and llms.txt - Require React Native 0.65+, the floor set by AsyncStorage 2.x - Install AsyncStorage 2.x and device-info 15.x in the app itself, since autolinking only links an app's own dependencies and AsyncStorage 3.x registers a different native module name - Add the AppDelegate / SceneDelegate step that forwards links to RCTLinkingManager, and launchMode="singleTask" on Android - Explain that the server serves the AASA and assetlinks.json files - Use https://api.linkforty.com for the hosted service, mark appToken as required there, apiKey as only needed for createLink() - Await init() before onDeepLink() - Document trackRevenue, setExternalUserId, externalUserId, deduplicated, the lack of an offline event queue, and the data the SDK sends - Note what differs on self-hosted Core (createLink templateId, link hosts) --- README.md | 197 ++++++++++++++++++++++++++++++++++++++---------------- llms.txt | 85 ++++++++++++++++------- 2 files changed, 198 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 6c86c4f..36d5594 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ React Native SDK for [LinkForty](https://github.com/LinkForty/core) — the open - **Link Creation** - Create short links programmatically from your app - **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 +- **Privacy-Focused** - No advertising IDs (IDFA/GAID) and no App Tracking Transparency prompt (see [Data collected](#data-collected)) - **TypeScript Support** - Full type definitions included -- **Works with Core & Cloud** - Compatible with self-hosted and Cloud instances +- **Works with self-hosted Core and the hosted service** - Same SDK, different `baseUrl` ## Installation @@ -24,16 +24,20 @@ npm install @linkforty/mobile-sdk-react-native ### Requirements -- React Native >= 0.64.0 +- React Native >= 0.65.0 (the floor set by `@react-native-async-storage/async-storage` 2.x, which the SDK depends on) - React >= 17.0.0 - Node.js >= 20.0.0 -### Peer Dependencies +### Native dependencies + +The SDK depends on two native modules. Install them in your app as well, at the major versions the SDK uses: ```bash -npm install @react-native-async-storage/async-storage react-native-device-info +npm install @react-native-async-storage/async-storage@^2.2.0 react-native-device-info@^15.0.0 ``` +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. + 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: ```bash @@ -52,33 +56,62 @@ cd ios && pod install - Open your project in Xcode - Select your app target > Signing & Capabilities - Add "Associated Domains" capability - - Add domain: `applinks:go.yourdomain.com` (replace with your LinkForty domain) + - Add domain: `applinks:go.yourdomain.com` (replace with your link host; add one entry per host your links use) -3. Host an Apple App Site Association (AASA) file at: - ``` - https://go.yourdomain.com/.well-known/apple-app-site-association +3. **Forward incoming links to React Native in your `AppDelegate`.** Without this, iOS opens your app from a Universal Link but JavaScript never receives the URL, so `onDeepLink` never fires. Add both methods: the first handles URL-scheme links, the second Universal Links. + + In `AppDelegate.swift`: + + ```swift + import React // for RCTLinkingManager + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return RCTLinkingManager.application(app, open: url, options: options) + } + + func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + } ``` - ```json + Or in `AppDelegate.mm`: + + ```objc + #import + + - (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + options:(NSDictionary *)options { - "applinks": { - "apps": [], - "details": [ - { - "appID": "TEAM_ID.com.yourapp.bundle", - "paths": ["*"] - } - ] - } + return [RCTLinkingManager application:application openURL:url options:options]; + } + + - (BOOL)application:(UIApplication *)application + continueUserActivity:(nonnull NSUserActivity *)userActivity + restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler + { + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; } ``` + If your app uses a `SceneDelegate` (it declares `UIApplicationSceneManifest` in `Info.plist`), iOS delivers links to the scene instead, so forward them from the `SceneDelegate`. See React Native's [Linking guide](https://reactnative.dev/docs/linking). + +4. The Apple App Site Association file is served by your LinkForty server at `https://go.yourdomain.com/.well-known/apple-app-site-association`; you don't host it yourself. On self-hosted [LinkForty Core](https://github.com/LinkForty/core), set the `IOS_TEAM_ID` and `IOS_BUNDLE_ID` environment variables; on the hosted service, enter your Team ID and bundle ID in the dashboard's app configuration. Check it with `curl https://go.yourdomain.com/.well-known/apple-app-site-association`. + ### Android Setup -1. Add an App Links intent filter in `android/app/src/main/AndroidManifest.xml`: +1. Add an App Links intent filter to your main activity in `android/app/src/main/AndroidManifest.xml`, with `android:launchMode="singleTask"` so a link opens the running app instead of a second copy of the activity: ```xml - + @@ -89,21 +122,7 @@ cd ios && pod install ``` -2. Host a Digital Asset Links file at: - ``` - https://go.yourdomain.com/.well-known/assetlinks.json - ``` - - ```json - [{ - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { - "namespace": "android_app", - "package_name": "com.yourapp", - "sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"] - } - }] - ``` +2. The Digital Asset Links file is served by your LinkForty server at `https://go.yourdomain.com/.well-known/assetlinks.json`; you don't host it yourself. On self-hosted LinkForty Core, set the `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` (comma-separated) environment variables; on the hosted service, enter your package name and signing-certificate SHA-256 fingerprints in the dashboard's app configuration. 3. Ensure `MainActivity` preserves the Intent for React Native. In `MainActivity.kt`: @@ -121,11 +140,10 @@ override fun onNewIntent(intent: Intent) { ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; -// 1. Initialize the SDK (call once at app startup) +// 1. Initialize the SDK (call once at app startup, and await it before step 2) await LinkForty.init({ - baseUrl: 'https://go.yourdomain.com', - apiKey: 'your-api-key', // Required for createLink(), optional otherwise - appToken: 'at_a1b2c3d4...', // Recommended for Cloud — enables organic-install attribution + 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 debug: __DEV__, }); @@ -149,29 +167,35 @@ 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. + ## API Reference ### `init(config)` -Initialize the SDK. Must be called before any other method. +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. ```typescript await LinkForty.init({ - baseUrl: 'https://go.yourdomain.com', // Required - apiKey: 'your-api-key', // Optional (required for createLink) - appToken: 'at_a1b2c3d4...', // Optional (recommended for Cloud — enables organic-install attribution) - debug: true, // Optional (default: false) - attributionWindow: 7, // Optional, in days (default: 7) + baseUrl: 'https://api.linkforty.com', // Required + appToken: 'at_a1b2c3d4...', // Required on the hosted service + apiKey: 'your-api-key', // Optional (only needed for createLink) + debug: true, // Optional (default: false) + attributionWindow: 7, // Optional, in days (default: 7) }); ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `baseUrl` | `string` | Yes | Base URL of your LinkForty instance | -| `apiKey` | `string` | No | API key for authenticated endpoints (Cloud) | -| `appToken` | `string` | No | Public workspace token. **Recommended for Cloud** — 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. Format: `at_<32 hex chars>`. Safe to ship in your app bundle. | -| `debug` | `boolean` | No | Enable debug logging | +| `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`) | | `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 | --- @@ -181,9 +205,11 @@ Listen for direct deep links. Called when the app is opened via a Universal 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`. -A LinkForty link is an `http(s)` URL on your `baseUrl` host or, with `appToken` set, on one of your workspace's link hosts: its `toapp.me` label and verified custom domains. The SDK learns those hosts from the server at launch and saves them for the next one. With `appToken` set, a lookup answers only with your own workspace's links. +A LinkForty link is an `http(s)` URL on your `baseUrl` host or, with `appToken` set, on one of your workspace's link hosts. The SDK learns those hosts from the server at launch (`GET /api/sdk/v1/link-domains`) and saves them for the next one. On the hosted service they are your workspace's `toapp.me` subdomain and verified custom domains, and with `appToken` set a lookup answers only with your own workspace's links. A server without that endpoint (such as self-hosted LinkForty Core) leaves the SDK looking up links on the `baseUrl` host only, so point `baseUrl` at the host your links use. ```typescript +import type { DeepLinkData } from '@linkforty/mobile-sdk-react-native'; + LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => { console.log('Link URL:', url); @@ -197,7 +223,7 @@ LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => { | Callback Parameter | Type | Description | |--------------------|------|-------------| | `url` | `string` | The full URL that opened the app | -| `data` | `DeepLinkData \| null` | Parsed link data, or `null` if parsing failed | +| `data` | `DeepLinkData \| null` | Resolved link data, or `null` when the URL is not a LinkForty link or can't be parsed | --- @@ -206,6 +232,8 @@ LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => { 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. ```typescript +import type { DeepLinkData } from '@linkforty/mobile-sdk-react-native'; + LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => { if (data) { // Attributed install — user clicked a link before installing @@ -241,7 +269,7 @@ LinkForty.onDeferredDeepLink((data) => { Create a short link programmatically. Requires `apiKey` to be set in `init()`. -When `templateId` is omitted, the server auto-selects your organization's default template. +When `templateId` is omitted, the SDK calls `POST /api/sdk/v1/links`, where the server auto-selects your organization's default template. That endpoint is on the hosted service; self-hosted LinkForty Core doesn't have it, so pass a `templateId` (and `templateSlug`) there, which uses `POST /api/links`. ```typescript const result = await LinkForty.createLink({ @@ -266,6 +294,7 @@ console.log(result.linkId); // uuid | `utmParameters` | `object` | No | UTM parameters (`source`, `medium`, `campaign`, `term`, `content`) | | `templateId` | `string` | No | Template UUID (auto-selected if omitted) | | `templateSlug` | `string` | No | Template slug (only needed with `templateId`) | +| `externalUserId` | `string` | No | Your user ID for this link; overrides the value set with `setExternalUserId()` | **Returns:** `CreateLinkResult` @@ -274,12 +303,26 @@ console.log(result.linkId); // uuid | `url` | `string` | Full shareable URL | | `shortCode` | `string` | The generated short code | | `linkId` | `string` | Link UUID | +| `deduplicated` | `boolean \| undefined` | `true` when the server returned an existing link for this user instead of creating one | + +--- + +### `setExternalUserId(id)` / `getExternalUserId()` + +Set your own user ID, attached to every `createLink()` call unless the call passes its own `externalUserId`. Pass `null` to clear it. It is kept in memory only, so set it again after each launch (for example after sign-in). + +```typescript +LinkForty.setExternalUserId('user_123'); +LinkForty.getExternalUserId(); // 'user_123' +``` --- ### `trackEvent(name, properties?)` -Track an in-app event for attribution analytics. Requires a successful install report (automatic on first launch). +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. + +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. ```typescript await LinkForty.trackEvent('purchase', { @@ -298,6 +341,22 @@ await LinkForty.trackEvent('purchase', { --- +### `trackRevenue(amount, currency, properties?)` + +Track a revenue event. Sends an event named `revenue` with `revenue` and `currency` added to its properties, the convention every LinkForty SDK uses. Throws if `amount` is negative. + +```typescript +await LinkForty.trackRevenue(29.99, 'USD', { productId: 'premium_plan', orderId: 'order-456' }); +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `amount` | `number` | Yes | Non-negative revenue amount | +| `currency` | `string` | Yes | ISO 4217 currency code (e.g. `'USD'`) | +| `properties` | `Record` | No | Additional event properties | + +--- + ### Automatic Screen Tracking (`autoTrackNavigation`) Emit a `screen_view` event on every navigation — no manual per-screen calls — so your dashboard can show an **attributed screen-flow funnel** per campaign (which link → which screens → which events). This is opt-in and uses [React Navigation](https://reactnavigation.org/). @@ -319,7 +378,8 @@ 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. -- Rapid transitions are debounced and same-screen re-renders are deduped. +- 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: ```typescript @@ -371,6 +431,17 @@ Clear all cached SDK data (install ID, attribution data, first-launch flag). The await LinkForty.clearData(); ``` +## Data collected + +To match an install or a direct open to a link click, the SDK sends: + +- **On the install report (first launch):** user agent, timezone, device language, screen width and height, platform (`ios`/`android`), OS version, and on Android the Google Play install referrer. The server also sees the request's IP address. +- **On a direct deep link open:** the link's path, with timezone, language, screen size, platform and OS version. +- **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. + ## Types ### `DeepLinkData` @@ -437,6 +508,7 @@ interface CreateLinkOptions { term?: string; content?: string; }; + externalUserId?: string; } ``` @@ -447,6 +519,7 @@ interface CreateLinkResult { url: string; shortCode: string; linkId: string; + deduplicated?: boolean; } ``` @@ -480,7 +553,8 @@ When a user taps a LinkForty URL and the app is **not** installed: 1. Verify AASA file is accessible at `https://yourdomain.com/.well-known/apple-app-site-association` 2. Check that Team ID and Bundle ID are correct in the AASA file 3. Confirm "Associated Domains" capability is added in Xcode with `applinks:yourdomain.com` -4. Test on a real device (Universal Links don't work in the simulator) +4. If the app opens from the link but `onDeepLink` never fires, the `AppDelegate` (or `SceneDelegate`) methods that forward links to `RCTLinkingManager` are missing (see [iOS Setup](#ios-setup), step 3) +5. Test on a real device (Universal Links don't work in the simulator) ### Deep links not working on Android @@ -499,7 +573,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) +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 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) @@ -507,12 +581,16 @@ This usually means another SDK or library is consuming the Intent data before Re ### TypeScript errors -Ensure peer dependencies are installed: +React Native ships its own type definitions, so `@types/react-native` is not needed (it is a deprecated stub). Make sure `@types/react` is installed: ```bash -npm install --save-dev @types/react @types/react-native +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). + ## Contributing Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. @@ -528,6 +606,7 @@ MIT License - see [LICENSE](LICENSE) for details. | Expo | [`@linkforty/mobile-sdk-expo`](https://github.com/LinkForty/mobile-sdk-expo) | | iOS (Swift) | [LinkFortySDK](https://github.com/LinkForty/mobile-sdk-ios) | | Android (Kotlin) | [LinkFortySDK](https://github.com/LinkForty/mobile-sdk-android) | +| Flutter | [`linkforty_flutter`](https://github.com/LinkForty/mobile-sdk-flutter) | ## Support diff --git a/llms.txt b/llms.txt index 831822f..dfcbf0c 100644 --- a/llms.txt +++ b/llms.txt @@ -8,10 +8,17 @@ npm install @linkforty/mobile-sdk-react-native ``` -Peer dependencies are included automatically: +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: + +```bash +npm install @react-native-async-storage/async-storage@^2.2.0 react-native-device-info@^15.0.0 +``` + - `@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 +Requires React Native >= 0.65 (AsyncStorage 2.x's floor), React >= 17. + For iOS, run pod install after: ```bash @@ -22,22 +29,26 @@ cd ios && pod install ```typescript interface LinkFortyConfig { - /** Base URL of your LinkForty instance (e.g., 'https://go.yourdomain.com') */ + /** 'https://api.linkforty.com' on the hosted service; your server's URL if self-hosting Core */ baseUrl: string; /** API key — only needed if calling createLink() */ 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. Format: `at_<32 hex chars>`. Safe to - * ship in your app bundle (workspace identifier only, not auth). + * Public workspace token. Required on the hosted service: it scopes install + * matching and link lookups to your workspace, attributes organic installs + * 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 (workspace identifier only, not auth). */ appToken?: string; /** Enable debug logging (default: false) */ debug?: boolean; /** Attribution window in days (default: 7) */ attributionWindow?: number; + /** Emit screen_view events from React Navigation (opt-in); requires navigationRef */ + autoTrackNavigation?: boolean | { captureParams?: string[]; debounceMs?: number }; + /** React Navigation container ref (createNavigationContainerRef()) */ + navigationRef?: NavigationContainerRefLike; } interface DeepLinkData { @@ -94,6 +105,8 @@ interface CreateLinkOptions { term?: string; content?: string; }; + /** Your user ID for this link; overrides setExternalUserId() */ + externalUserId?: string; } interface CreateLinkResult { @@ -101,6 +114,8 @@ interface CreateLinkResult { url: string; shortCode: string; linkId: string; + /** True when an existing link for this user was returned instead of a new one */ + deduplicated?: boolean; } type DeepLinkCallback = (url: string, deepLinkData: DeepLinkData | null) => void; @@ -117,13 +132,13 @@ import LinkFortySDK from '@linkforty/mobile-sdk-react-native'; ### init(config: LinkFortyConfig): Promise\ -Initialize the SDK. Must be called before any other method. Call this early in your app lifecycle (App.tsx or index.js). +Initialize the SDK. Must be called before any other method. Call this early in your app lifecycle (App.tsx or index.js), and await it before calling onDeepLink(): onDeepLink() throws if init() was never called, and one registered while init() is still running only recognizes links on the baseUrl host. Calling init() twice logs a warning and does nothing. ```typescript await LinkFortySDK.init({ - baseUrl: 'https://go.yourdomain.com', - apiKey: 'lf_live_abc123', // optional — only for createLink() - appToken: 'at_a1b2c3d4...', // recommended for Cloud — enables organic-install attribution + baseUrl: 'https://api.linkforty.com', // hosted service; your own server's URL if self-hosting + appToken: 'at_a1b2c3d4...', // required on the hosted service + apiKey: 'lf_live_abc123', // optional — only for createLink(); a secret debug: __DEV__, attributionWindow: 7, // days }); @@ -162,7 +177,7 @@ LinkFortySDK.onDeferredDeepLink((data) => { ### trackEvent(name: string, properties?: Record\): Promise\ -Track in-app events for attribution and conversion analysis. +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. ```typescript await LinkFortySDK.trackEvent('purchase', { @@ -173,9 +188,21 @@ await LinkFortySDK.trackEvent('purchase', { }); ``` +### trackRevenue(amount: number, currency: string, properties?: Record\): Promise\ + +Track a revenue event: sends an event named `revenue` with `revenue` and `currency` added to its properties. Throws if `amount` is negative. + +```typescript +await LinkFortySDK.trackRevenue(59.99, 'USD', { orderId: 'order_123' }); +``` + +### setExternalUserId(id: string | null): void + +Set your user ID, attached to every createLink() call unless the call passes its own `externalUserId`. Kept in memory only; set it again after each launch. + ### createLink(options: CreateLinkOptions): Promise\ -Create a shareable LinkForty link from within the app. Requires `apiKey` in the init config. When `templateId` is omitted, the server auto-selects the organization's default template. +Create a shareable LinkForty link from within the app. Requires `apiKey` in the init config. When `templateId` is omitted, the SDK calls `POST /api/sdk/v1/links` and the server auto-selects the organization's default template; that endpoint is on the hosted service, not self-hosted Core, where you pass `templateId` (and `templateSlug`) instead. ```typescript const result = await LinkFortySDK.createLink({ @@ -205,16 +232,24 @@ Get the unique install ID assigned on first launch. ### clearData(): Promise\ -Clear all cached SDK data (install ID, attribution, first-launch flag). Use on user logout. +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. ## iOS Platform Setup 1. Open your project in Xcode. 2. Select your target → Signing & Capabilities → + Capability → Associated Domains. 3. Add: `applinks:go.yourdomain.com` -4. In `AppDelegate.mm`, ensure Universal Links are forwarded (usually automatic with React Native's Linking module): +4. Forward incoming links to React Native in `AppDelegate`. This is required: without it iOS opens the app from a Universal Link but JavaScript never receives the URL, so onDeepLink never fires. In `AppDelegate.mm` (Swift: the equivalent `RCTLinkingManager.application(...)` calls): ```objc +#import + +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + options:(NSDictionary *)options { + return [RCTLinkingManager application:application openURL:url options:options]; +} + - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray> *))restorationHandler { @@ -224,7 +259,9 @@ Clear all cached SDK data (install ID, attribution, first-launch flag). Use on u } ``` -5. LinkForty automatically serves `/.well-known/apple-app-site-association` from your domain. +Apps with a `SceneDelegate` forward links from the scene delegate instead. + +5. LinkForty automatically serves `/.well-known/apple-app-site-association` from your domain (self-hosted Core: set `IOS_TEAM_ID` and `IOS_BUNDLE_ID`). ## Android Platform Setup @@ -255,7 +292,7 @@ public void onNewIntent(Intent intent) { } ``` -3. LinkForty automatically serves `/.well-known/assetlinks.json` from your domain. +3. LinkForty automatically serves `/.well-known/assetlinks.json` from your domain (self-hosted Core: set `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS`). ## Complete Integration Example @@ -275,7 +312,7 @@ export default function App() { useEffect(() => { async function initLinkForty() { await LinkFortySDK.init({ - baseUrl: 'https://go.yourdomain.com', + baseUrl: 'https://api.linkforty.com', apiKey: 'lf_live_abc123', appToken: 'at_a1b2c3d4...', debug: __DEV__, @@ -352,16 +389,14 @@ npx uri-scheme open "https://go.yourdomain.com/abc123" --ios npx uri-scheme open "https://go.yourdomain.com/abc123" --android ``` -## Self-Hosted vs Cloud - -The only change is the `baseUrl`: +## Hosted service vs self-hosted ```typescript -// Cloud (managed SaaS) -await LinkFortySDK.init({ baseUrl: 'https://go.linkforty.com' }); +// Hosted service: appToken is required +await LinkFortySDK.init({ baseUrl: 'https://api.linkforty.com', appToken: 'at_a1b2c3d4...' }); -// Self-hosted (@linkforty/core) +// Self-hosted (@linkforty/core): your server's URL await LinkFortySDK.init({ baseUrl: 'https://links.yourdomain.com' }); ``` -All SDK methods work identically with both. +Differences on self-hosted Core: createLink() needs a `templateId` (Core has no `POST /api/sdk/v1/links`), and the SDK looks up links on the `baseUrl` host only (Core has no link-domains endpoint). From c6c64be95de0b97f45409aab2cc44b037b539936 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 16:57:22 -0700 Subject: [PATCH 2/9] fix(navigation): accept React Navigation 7 container refs as navigationRef NavigationRouteLike.params was Record, which React Navigation 7's Readonly route params don't assign to, so createNavigationContainerRef() (without a param list) failed to type-check as navigationRef (TS2322, also with strict off), including the documented setup and examples/AutoNavTracking.tsx. Params are now typed Readonly, and sanitizeScreenParams accepts any value and ignores non-objects and arrays at runtime. Also documents apiKey, appToken and debug without internal references, and drops references to private planning notes from comments. --- __tests__/NavigationTracker.test.ts | 12 ++++++++++++ src/AttributionContext.ts | 2 -- src/NavigationTracker.ts | 11 +++++------ src/types.ts | 28 ++++++++++++++++++++-------- 4 files changed, 37 insertions(+), 16 deletions(-) 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/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/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; } /** From 6e33552f26f487e119e05092233e2c5e05e8d710 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 16:57:22 -0700 Subject: [PATCH 3/9] fix(install): retry a failed install report on the next launch The install-reported flag was saved before the install report was sent, so a report that failed (no connection, server error, app killed during it) was never sent again: the install ID stayed null and every trackEvent() was dropped. The flag is now saved only once the server accepts the report, so each later launch reports again until one succeeds. The storage key is unchanged, so installs already reported are not reported again. Logging: - DeepLinkHandler logged every URL and resolved link data on every link and cold start regardless of debug; it now logs only with debug. - init() with debug logged the whole config including the API key; the key is now redacted (and the navigation ref is not dumped). - Without debug, only failures are logged: a failed install report or event, init() called twice, an event dropped for lack of an install ID. clearData() now also removes the saved link hosts and fetches them again, and uses removeItem rather than multiRemove. --- __tests__/LinkFortySDK.test.ts | 256 +++++++++++++++++++++++++++ __tests__/__mocks__/async-storage.ts | 7 +- src/DeepLinkHandler.ts | 49 +++-- src/LinkDomains.ts | 14 ++ src/LinkFortySDK.ts | 92 ++++++---- 5 files changed, 363 insertions(+), 55 deletions(-) create mode 100644 __tests__/LinkFortySDK.test.ts diff --git a/__tests__/LinkFortySDK.test.ts b/__tests__/LinkFortySDK.test.ts new file mode 100644 index 0000000..3b307df --- /dev/null +++ b/__tests__/LinkFortySDK.test.ts @@ -0,0 +1,256 @@ +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); + }); +}); + +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__/__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/src/DeepLinkHandler.ts b/src/DeepLinkHandler.ts index c2b40c4..d196dc0 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,19 @@ export class DeepLinkHandler { private baseUrl: string | null = null; private resolveFn: ResolveFunction | null = null; private linkDomains: LinkDomains | null = null; + private readonly debug: boolean; + + /** + * @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 +73,7 @@ export class DeepLinkHandler { } return { pathname, searchParams }; - } catch (error) { - console.error('[LinkForty] Failed to parse URL string:', error); + } catch { return null; } } @@ -90,18 +105,18 @@ 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); // 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'); } }); } @@ -120,38 +135,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 +229,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 +281,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..3f405ee 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -24,7 +24,13 @@ 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. Until then every + * launch reports the install again, so a report that fails (no connection, + * server error) is retried on the next launch. The key keeps its original + * name so installs reported by earlier versions are not reported again. + */ + INSTALL_REPORTED: '@linkforty:first_launch', }; export class LinkFortySDK { @@ -54,7 +60,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 +69,13 @@ 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 + await this.reportInstall(); } // Learn the workspace's link hosts in the background @@ -84,7 +88,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 +192,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 +251,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 +414,27 @@ 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].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,21 +449,16 @@ 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) */ - 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 { + return (await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORTED)) !== 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. */ private async reportInstall(): Promise { if (!this.config) { @@ -492,13 +501,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 +525,9 @@ 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_REPORTED, 'true'); + // If attributed, store deep link data const deepLinkData = deferredDeepLinkFromInstall(response); if (deepLinkData) { @@ -547,7 +559,7 @@ export class LinkFortySDK { this.deferredDeepLink.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); @@ -573,7 +585,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 +618,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(); From f81cbe02d3dfc7f90cdb464fd11664f0f70a2d75 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 16:57:22 -0700 Subject: [PATCH 4/9] fix(deps): make AsyncStorage and device-info peer dependencies, allow AsyncStorage 3 React Native autolinking only links native modules listed in the app's own package.json, so apps must install both anyway. As regular dependencies they also let a package manager install a second, nested copy: an app on AsyncStorage 3 got a nested 2.x for the SDK, whose JS looks up the RNCAsyncStorage native module while 3.x registers RNAsyncStorage, so the SDK's storage was null at runtime. As peers the SDK shares the app's copy. AsyncStorage ^2.2.0 || ^3.0.0: the SDK only uses getItem, setItem and removeItem, which 3.x's default (legacy) storage keeps, reading the data 2.x wrote. react-native-device-info stays ^15.0.0. The react-native peer floor rises from 0.64 to 0.65, the floor AsyncStorage 2.x declares. --- package-lock.json | 20 +++++++++++++++----- package.json | 10 +++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69df7fc..aefd131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,8 @@ "name": "@linkforty/mobile-sdk-react-native", "version": "1.9.0", "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 399b8ac..6f7f667 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" From 68a1b4140b99309e5812378a96a936a508620efb Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 16:57:22 -0700 Subject: [PATCH 5/9] docs: document install retry, logging and peer dependencies README and llms.txt: the install report is retried on later launches, what is logged with and without debug, the native modules as peer dependencies (AsyncStorage 2.x or 3.x), what clearData() removes, and that untyped React Navigation 7 refs type-check. Refers to the hosted service generically and removes ticket IDs and a private repository link from past CHANGELOG entries. --- CHANGELOG.md | 6 +++--- README.md | 32 ++++++++++++++++++-------------- llms.txt | 23 +++++++++++++---------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed2208..1db7c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +24,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) @@ -36,7 +36,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..9639e4b 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,7 @@ 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. ```typescript await LinkForty.init({ @@ -192,7 +195,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 | @@ -229,7 +232,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. 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 +323,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 +381,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 +417,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 +429,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 +444,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 +577,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 +593,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 +617,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/llms.txt b/llms.txt index dfcbf0c..018c191 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; @@ -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. 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 From f64f6bc38af98b60da0855f5235d9bc0b7a0ef39 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 17:00:20 -0700 Subject: [PATCH 6/9] fix(deeplink): add the Linking listener once and remove only its own Each onDeepLink() call added another 'url' listener, so after a second call every link reached the callback twice. The handler now keeps the subscription from Linking.addEventListener and adds it only once; calling onDeepLink() again still replaces the callback, as before. DeepLinkHandler.cleanup() called Linking.removeAllListeners('url'), which also removed the app's own 'url' listeners. It now removes only its own subscription. --- README.md | 2 +- __tests__/DeepLinkHandler.test.ts | 114 ++++++++++++++++++++++++++++++ llms.txt | 2 +- src/DeepLinkHandler.ts | 17 +++-- 4 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 __tests__/DeepLinkHandler.test.ts diff --git a/README.md b/README.md index 9639e4b..61b65a6 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,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`. 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/llms.txt b/llms.txt index 018c191..c7696b8 100644 --- a/llms.txt +++ b/llms.txt @@ -149,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) => { diff --git a/src/DeepLinkHandler.ts b/src/DeepLinkHandler.ts index d196dc0..a223959 100644 --- a/src/DeepLinkHandler.ts +++ b/src/DeepLinkHandler.ts @@ -23,6 +23,8 @@ export class DeepLinkHandler { 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 @@ -88,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 @@ -107,8 +110,10 @@ export class DeepLinkHandler { 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) => { @@ -122,10 +127,12 @@ export class DeepLinkHandler { } /** - * 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; } From 957737d0d3d44723fed8242d4cfeb35a7a5bec84 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 22:15:48 -0700 Subject: [PATCH 7/9] fix(install): report once from devices whose first install report failed on 1.9.0 or earlier Versions up to 1.9.0 set the first-launch flag before sending the install report, so a failed report left the device with the flag set, no install ID, and every event dropped. Such a device now reports its install once after upgrading. It appears as an install dated at the upgrade, usually organic. An install now counts as reported when an install ID is stored or a new marker (@linkforty:install_report_accepted) is set. The marker is set on any 2xx response, so a response without an install ID can't trigger a report on every launch. Failed reports are still retried, and devices that already have an install ID never report again. The first-launch flag is still written, so a downgraded app doesn't report again. --- README.md | 2 ++ __tests__/LinkFortySDK.test.ts | 57 ++++++++++++++++++++++++++++++++++ llms.txt | 2 +- src/LinkFortySDK.ts | 36 ++++++++++++++++----- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 61b65a6..bc36e92 100644 --- a/README.md +++ b/README.md @@ -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. +**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. + ```typescript await LinkForty.init({ baseUrl: 'https://api.linkforty.com', // Required diff --git a/__tests__/LinkFortySDK.test.ts b/__tests__/LinkFortySDK.test.ts index 3b307df..4100661 100644 --- a/__tests__/LinkFortySDK.test.ts +++ b/__tests__/LinkFortySDK.test.ts @@ -159,6 +159,63 @@ describe('LinkFortySDK install report', () => { }); }); +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('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 diff --git a/llms.txt b/llms.txt index c7696b8..fa7f08b 100644 --- a/llms.txt +++ b/llms.txt @@ -180,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 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. 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. There is no offline queue: a failed send is logged and dropped, not retried. ```typescript await LinkFortySDK.trackEvent('purchase', { diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 3f405ee..35d6257 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -25,12 +25,18 @@ const STORAGE_KEYS = { INSTALL_ID: '@linkforty:install_id', INSTALL_DATA: '@linkforty:install_data', /** - * Set once the server has accepted the install report. Until then every - * launch reports the install again, so a report that fails (no connection, - * server error) is retried on the next launch. The key keeps its original - * name so installs reported by earlier versions are not reported again. + * 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 { @@ -421,7 +427,12 @@ export class LinkFortySDK { async clearData(): Promise { // 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].map( + [ + STORAGE_KEYS.INSTALL_ID, + STORAGE_KEYS.INSTALL_DATA, + STORAGE_KEYS.INSTALL_REPORTED, + STORAGE_KEYS.INSTALL_REPORT_ACCEPTED, + ].map( (key) => AsyncStorage.removeItem(key), ), ); @@ -450,10 +461,20 @@ export class LinkFortySDK { /** * Whether the server has accepted this install's report (on this or an - * earlier launch) + * 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 isInstallReported(): Promise { - return (await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORTED)) !== null; + const [installId, accepted] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID), + AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED), + ]); + return installId !== null || accepted !== null; } /** @@ -526,6 +547,7 @@ export class LinkFortySDK { } // 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'); // If attributed, store deep link data From a822c073b9549e27dca75f26256128e4f7b4255b Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 22:17:49 -0700 Subject: [PATCH 8/9] fix(install): don't call the deferred deep link callback on a recovery report A device recovering from a first-launch report that failed on 1.9.0 or earlier belongs to a user who has been using the app, so routing them like a fresh install would be wrong. The recovery report no longer calls onDeferredDeepLink, whether the install is attributed, organic or the report fails. First-launch reports, and their retries, still call it. --- README.md | 4 ++-- __tests__/LinkFortySDK.test.ts | 28 ++++++++++++++++++++++++++++ llms.txt | 2 +- src/LinkFortySDK.ts | 22 ++++++++++++++++------ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bc36e92..f6c94c5 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ 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. -**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. +**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 does not call `onDeferredDeepLink`: the user has been using the app, so routing them like a fresh install would be wrong. ```typescript await LinkForty.init({ @@ -234,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. 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. 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'; diff --git a/__tests__/LinkFortySDK.test.ts b/__tests__/LinkFortySDK.test.ts index 4100661..35634c4 100644 --- a/__tests__/LinkFortySDK.test.ts +++ b/__tests__/LinkFortySDK.test.ts @@ -204,6 +204,34 @@ describe('LinkFortySDK recovery of a device whose earlier report failed', () => 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('never reports again on a device that has an install ID', async () => { store['@linkforty:first_launch'] = 'true'; store['@linkforty:install_id'] = INSTALL_ID; diff --git a/llms.txt b/llms.txt index fa7f08b..3ad8360 100644 --- a/llms.txt +++ b/llms.txt @@ -180,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 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. 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 does not call onDeferredDeepLink. There is no offline queue: a failed send is logged and dropped, not retried. ```typescript await LinkFortySDK.trackEvent('purchase', { diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 35d6257..4b5a566 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -80,8 +80,10 @@ export class LinkFortySDK { await this.loadInstallData(); } else { // First launch, or an earlier report failed: report the install and get - // the deferred deep link - await this.reportInstall(); + // 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 @@ -480,8 +482,16 @@ export class LinkFortySDK { /** * 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) does + * not call the deferred deep link callback: the user has been using the app, + * so routing them like a fresh install would be wrong. */ - 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; } @@ -565,7 +575,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); @@ -578,13 +588,13 @@ export class LinkFortySDK { } // null indicates an organic install - this.deferredDeepLink.deliver(null); + deliver(null); } } catch (error) { console.error('[LinkForty] Failed to report install (retried on the next launch):', error); // null on error - this.deferredDeepLink.deliver(null); + deliver(null); } } From 8bdb0280a04ab558f8f0d39aefbdb7366b030fd7 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 27 Oct 2026 22:19:22 -0700 Subject: [PATCH 9/9] fix(install): a recovery report only restores the install ID When a device upgraded from 1.9.0 or earlier reports the install it failed to report, any link the server matches comes from a fingerprint match made at upgrade time, not from the click that led to the install. The recovery report therefore no longer saves that link as install data (getInstallData() stays null) and no longer makes it the link credited for later events, whether the install is attributed or organic. It stores only the install ID and the reported markers. A link tap that opens the app still sets the credited link through the deep link path. --- README.md | 2 +- __tests__/LinkFortySDK.test.ts | 36 ++++++++++++++++++++++++++++++++++ llms.txt | 2 +- src/LinkFortySDK.ts | 18 ++++++++++++++--- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f6c94c5..a696330 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ 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. -**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 does not call `onDeferredDeepLink`: the user has been using the app, so routing them like a fresh install would be wrong. +**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({ diff --git a/__tests__/LinkFortySDK.test.ts b/__tests__/LinkFortySDK.test.ts index 35634c4..55a1cc3 100644 --- a/__tests__/LinkFortySDK.test.ts +++ b/__tests__/LinkFortySDK.test.ts @@ -232,6 +232,42 @@ describe('LinkFortySDK recovery of a device whose earlier report failed', () => } }); + 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; diff --git a/llms.txt b/llms.txt index 3ad8360..150346d 100644 --- a/llms.txt +++ b/llms.txt @@ -180,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 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 does not call onDeferredDeepLink. 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', { diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 4b5a566..0c17e98 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -483,9 +483,10 @@ export class LinkFortySDK { * 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) does - * not call the deferred deep link callback: the user has been using the app, - * so routing them like a fresh install would be wrong. + * 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({ recovery = false }: { recovery?: boolean } = {}): Promise { const deliver = (data: DeepLinkData | null) => { @@ -560,6 +561,17 @@ export class LinkFortySDK { 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) {