diff --git a/README.md b/README.md index 6418df2..2009087 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,7 @@ from Maven Central — so there's nothing else to add. ```ts import LinkTrail from 'linktrail-react-native'; -// At app launch. The API key is required. -await LinkTrail.configure('lt_live_…'); - +// At app launch. Register the listeners first (see "Deferred link delivery" below). // One listener handles both first-launch (deferred) AND re-engagement links: LinkTrail.onLink((link, source) => { router.navigate(link.path, link.customData); // e.g. "/products/aj1" + { voucher: "SUMMER25" } @@ -38,10 +36,37 @@ LinkTrail.onLink((link, source) => { // Observe failures: LinkTrail.onError((error) => console.warn(`[LinkTrail] ${error.code}: ${error.message}`)); + +// The API key is required. If your links use your own domain, list it (required from 0.1.0): +await LinkTrail.configure('lt_live_…', { linkDomains: ['go.acme.com'] }); ``` -The install is tracked automatically by `configure`, and incoming links (Universal Links / App -Links / custom schemes) are forwarded via React Native's `Linking` API — no extra wiring. +`configure` tracks the install automatically (held until the user's consent decision, see +[Consent gating](#consent-gating)). Incoming links reach the wrapper through React Native's +`Linking` API, which it forwards to the native SDK for you. On **iOS** that only works once your +`AppDelegate` passes URLs to `RCTLinkingManager`; see +[iOS: forward links to React Native](#ios-forward-links-to-react-native). + +### `configure` is a singleton + +Calling `configure` again with the same key and options is a no-op. A call with a different key or +different options shuts the previous native instance down (its pending work is cancelled) and +replaces it. Your JS `onLink` / `onAttribution` / `onError` subscriptions stay valid: the wrapper +re-registers its native callbacks on every `configure`. + +### Deferred link delivery + +When the install resolves a deferred link, the native SDK saves it and delivers it to `onLink` +(`source === 'deferred'`) **at least once**, even on a later launch if the app was killed first. A +saved link older than **7 days** is dropped. Delivery can repeat, so make your `onLink` handler +safe to run twice for the same link. + +In this version the wrapper does not hold a link for a JS listener that subscribes late. The +wrapper registers its native `onLink` inside `configure`, so the native SDK counts the link as +delivered once it reaches the wrapper, and the wrapper passes it only to the JS listeners that are +subscribed at that moment. So subscribe to `onLink` **before** calling `configure`, not after a +splash or login screen. If you need to route later, keep the link in your own state until the +screen is ready. ## More @@ -63,26 +88,39 @@ LinkTrail.updateConversionValue(42, 'medium'); autoTrackInstall, requireConsent, clickTokenSource, autoHandleLinks }`. Set `autoHandleLinks: false` to forward URLs yourself via `LinkTrail.handleDeepLink(url)`. +For tests and QA builds, `LinkTrail.resetForTesting()` clears the native SDK state (install flag, +cached attribution, saved deferred link, queued events and device id), so the next launch sends a +fresh install. Don't call it in production. + ### Consent gating `requireConsent` (default `true`) gates attribution/tracking behind the user's decision — -**deny-by-default** for GDPR / ePrivacy. While consent is unset or denied the SDK holds the install -and drops events, but **deep links still route** (`onLink` fires) so the user reaches their -destination. The SDK exposes **no consent getter** — your app is the source of truth: persist the -choice and replay it with `setConsent` on every launch after `configure`. +**deny-by-default** for GDPR / ePrivacy: + +| Decision | What the SDK sends | +| --- | --- | +| not made yet | nothing: the install is **held** (persisted across launches) and events are dropped | +| granted | the install with `consent: true`, counted and attributed; events are sent | +| denied | **one** install with `consent: false` and no device id, so the deep link still resolves and the user is routed, but nothing is recorded; events are dropped | + +Because the deferred link is resolved by that install, it routes **after** the user answers, +whichever answer it is. `setConsent(false)` always wins, even with `requireConsent: false`. The +SDK exposes **no consent getter** — your app is the source of truth: persist the choice and replay +it with `setConsent` on every launch after `configure`. ```ts await LinkTrail.configure('lt_live_…', { requireConsent: true }); // After your consent UI resolves (persist the choice yourself, e.g. AsyncStorage): -LinkTrail.setConsent(true); // releases the held install + flushes queued events -LinkTrail.setConsent(false); // stops sending + drops the queue +LinkTrail.setConsent(true); // sends the held install (consent: true) + flushes queued events +LinkTrail.setConsent(false); // sends one consent: false install (routes, records nothing) + drops the queue // On the next launch, replay the stored decision right after configure: LinkTrail.setConsent(storedGranted); ``` -Set `requireConsent: false` to attribute at init without a consent step. See the example app's +Set `requireConsent: false` to attribute at init without a consent step (an explicit +`setConsent(false)` still wins). See the example app's [`src/consent.ts`](example/src/consent.ts) for the persist-and-replay pattern. ### Deferred attribution & the paste button (iOS) @@ -94,7 +132,9 @@ On iOS, deferred attribution recovers a **click token** the tapped link left on system **"Allow Paste"** alert on first launch. - **`'pasteButton'`** (default) — the token is read **only** when the user taps `` (Apple's `UIPasteControl`), with **no "Allow Paste" alert**. You render - the button and set **`autoTrackInstall: false`** so the install waits for the tap. + the button and set **`autoTrackInstall: false`** so the install waits for the tap. The tap goes + through the native SDK's paste button: it sends the install with the token and, by default, also + records consent if the user hasn't decided yet (see `grantsConsent` below). - **`'none'`** — the SDK **never touches the clipboard** (no button, no alert); deferred matching falls back to probabilistic IP matching only. @@ -116,12 +156,16 @@ await LinkTrail.configure('lt_live_…', { fillColor="#111" foregroundColor="#FFFFFF" onTokenPasted={(token) => { - // The SDK already fired the install with this token; onLink routes the + // The SDK already sent the install with this token; onLink routes the // recovered destination. Use this only for UI side effects. }} /> ``` +Don't call `trackInstallWithClickToken` from `onTokenPasted`: the tap already sent the install. +An earlier `setConsent(false)` stands unless you pass `grantsConsent={true}`; after a denial the +pasted token is still used to route the user (the install goes out with `consent: false`). + **`` props** (all optional; plus any `ViewProps` such as `style`): | Prop | Type | Default | Notes | @@ -141,10 +185,90 @@ colors, and size are the full set of what's customizable. For manual control the ## Deep-link setup -Standard app deep-link config — the wrapper handles the rest. Declare your LinkTrail host as a -Universal Link (iOS Associated Domains: `applinks:kick.linktrail.io`) and an App Links -`intent-filter` (Android), plus any custom scheme. LinkTrail infra hosts the -`apple-app-site-association` / `assetlinks.json` files for your link domains. +Declare each LinkTrail link host as a Universal Link (iOS Associated Domains: +`applinks:kick.linktrail.io`) and in an App Links `intent-filter` (Android). LinkTrail hosts the +`apple-app-site-association` / `assetlinks.json` files for your link domains. Custom URL schemes +are your own routing: the SDK only claims `https` LinkTrail links. + +**Android** — add an `autoVerify` filter to your main activity (`launchMode="singleTask"`, as in +the React Native template). React Native's `Linking` reads the intent, and the wrapper forwards it: + +```xml + + + + + + + +``` + +### iOS: forward links to React Native + +React Native's `Linking` only sees a URL if your `AppDelegate` hands it to `RCTLinkingManager`. +Universal Links arrive through `continue userActivity`, so forward **both** methods; without the +second one, tapping a LinkTrail link opens the app but `onLink` never fires. + +Swift `AppDelegate` (React Native 0.77+ template): + +```swift +import React + +// Inside your AppDelegate class: + +// Custom-scheme URLs. +func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] +) -> Bool { + RCTLinkingManager.application(app, open: url, options: options) +} + +// Universal Links (https://kick.linktrail.io/…). +func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void +) -> Bool { + RCTLinkingManager.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) +} +``` + +Objective-C `AppDelegate.mm` (React Native 0.76 template): + +```objc +#import + +// Inside @implementation AppDelegate: + +// Custom-scheme URLs. +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + options:(NSDictionary *)options +{ + return [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links (https://kick.linktrail.io/…). +- (BOOL)application:(UIApplication *)application + continueUserActivity:(nonnull NSUserActivity *)userActivity + restorationHandler:(nonnull void (^)(NSArray> *_Nullable))restorationHandler +{ + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; +} +``` + +**Expo:** after `npx expo prebuild`, check that the generated `AppDelegate` forwards both methods +to `RCTLinkingManager`. + +### `linkDomains` (required for custom domains from 0.1.0) **Custom link domains must be listed in `linkDomains`.** The SDK claims only `https` links on `linktrail.io` (or a subdomain) or on a `linkDomains` entry (or a subdomain): @@ -158,6 +282,18 @@ await LinkTrail.configure('lt_live_…', { linkDomains: ['go.acme.com'] }); fire `onError`, so route those yourself. Custom-scheme LinkTrail links (`myapp://slug`) are no longer handled; use the `https` link. Links on `*.linktrail.io` need no `linkDomains` entry. +**Upgrading from 0.0.x:** an empty `linkDomains` no longer means "handle every link". If you relied +on that with your own domain, list it: + +```ts +// 0.0.x: worked with no linkDomains +await LinkTrail.configure('lt_live_…'); +// 0.1.0: custom domains must be listed +await LinkTrail.configure('lt_live_…', { linkDomains: ['go.acme.com'] }); +``` + +See [CHANGELOG.md](CHANGELOG.md) for the full list of 0.1.0 changes. + ### `customData` values `customData` (and `utm`) is always a `Record`. Nested values set on the link