From ed6a22d2f259c7c4b0c7ae1b15e78b84ed810e81 Mon Sep 17 00:00:00 2001 From: Ahsan Ali Date: Wed, 2 Sep 2026 21:13:36 +0100 Subject: [PATCH] feat: adopt iOS SDK 0.0.12 + paste button grants consent - Bump podspec dependency LinkTrailSDK ~> 0.0.11 -> ~> 0.0.12 - 0.0.12 makes consent hold-until-decision: nothing leaves the device until the app records a decision. The SDK's own paste button records consent on tap; the wrapper's UIPasteControl component did not, so a tap would produce a held install and deferred attribution would silently stop working. - Add a grantsConsent prop (default true, matching native) that records consent before the token-carrying install. Uses the public setConsent since persistConsent is internal to the SDK; safe because the paste flow requires autoTrackInstall: false. - Regenerate example Podfile.lock at LinkTrailSDK 0.0.12 - Bump wrapper version to 0.0.5; update README + CHANGELOG + JSDoc --- CHANGELOG.md | 17 +++++++++++++ LinktrailReactNative.podspec | 2 +- README.md | 28 ++++++++++++++++++---- example/ios/Podfile.lock | 10 ++++---- example/package-lock.json | 2 +- ios/LinkTrailPasteButtonComponentView.mm | 3 ++- ios/LinkTrailPasteButtonView.swift | 24 ++++++++++++++++++- package.json | 2 +- src/LinkTrailPasteButton.tsx | 9 ++++++- src/LinkTrailPasteButtonNativeComponent.ts | 12 ++++++++++ src/types.ts | 20 ++++++++++++---- 11 files changed, 109 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2690f8..bd56b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.0.5 + +- Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.12`** (from `0.0.11`). +- **Consent is now hold-until-decision** (iOS): until your app records a decision, nothing + leaves the device — no install, no clipboard read, no device identifier. The held install is + persisted, and each answer releases exactly one install (`setConsent(false)` sends an + uncounted one that still resolves the deferred link). Re-engagement links still route + without a decision. +- **`` now grants consent on tap** (new `grantsConsent` prop, default + `true`), matching the native SDK — without it the install the tap triggers would be held by + the deny-by-default gate. Set `grantsConsent={false}` when a separate consent UI owns the + decision. + +⚠️ **Migration:** an app that leaves `requireConsent` on (the default) and never calls +`setConsent` now sends nothing at all. Call `setConsent` when your consent UI resolves, or set +`requireConsent: false` if consent is handled elsewhere. + ## 0.0.4 - Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.11`** (from `0.0.10`). diff --git a/LinktrailReactNative.podspec b/LinktrailReactNative.podspec index 37ddda6..8bd7aae 100644 --- a/LinktrailReactNative.podspec +++ b/LinktrailReactNative.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |s| # The binary LinkTrail iOS SDK, published to the CocoaPods trunk — CocoaPods # resolves it automatically, so the consuming app needs no extra Podfile line. - s.dependency "LinkTrailSDK", "~> 0.0.11" + s.dependency "LinkTrailSDK", "~> 0.0.12" s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES", diff --git a/README.md b/README.md index 25b178f..c0bf42b 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,26 @@ to forward URLs yourself via `LinkTrail.handleDeepLink(url)`. ### 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 and hold-until-decision** for GDPR / ePrivacy. Until your app records a decision, +**nothing leaves the device**: no install, no clipboard read, no device identifier. The held install +is persisted, so a user who decides on a later launch is still attributed. + +Each answer releases it as **exactly one** install: + +| Answer | What goes out | +| --- | --- | +| `setConsent(true)` | the counted, attributed install — replaying any click token captured before the decision | +| `setConsent(false)` | one **uncounted** install with `consent: false` — the backend resolves the deferred link so the user still lands in the right place, but records nothing and sends no device id | + +**Re-engagement** links (app already installed) still route without a decision — they're resolved, +not recorded, so `onLink` fires either way. 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`. + +> ⚠️ **Migration (SDK 0.0.12):** an app that leaves `requireConsent` on and **never calls +> `setConsent`** now sends nothing at all — no install, no attribution, no deferred routing. Either +> call `setConsent` once your consent UI resolves, or set `requireConsent: false` if consent is +> handled elsewhere. Tapping `` counts as granting consent by default. ```ts await LinkTrail.configure('lt_live_…', { requireConsent: true }); @@ -94,7 +110,8 @@ 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 is a + deliberate affirmative act, so it also **records consent** by default (`grantsConsent`). - **`'none'`** — the SDK **never touches the clipboard** (no button, no alert); deferred matching falls back to probabilistic IP matching only. @@ -131,6 +148,7 @@ await LinkTrail.configure('lt_live_…', { | `cornerStyle` | `'dynamic' \| 'fixed' \| 'capsule' \| 'large' \| 'medium' \| 'small'` | `'capsule'` | Corner rounding. | | `foregroundColor` | `ColorValue` | system | Label/icon color. | | `fillColor` | `ColorValue` | system | Button background. | +| `grantsConsent` | `boolean` | `true` | Whether the tap also records tracking consent, so the install it triggers isn't held by the deny-by-default gate. Set `false` when a separate consent UI owns the decision. | | `style` | `ViewStyle` | — | Size/layout (width, height, margins…). | Requires **iOS 16+**. The on-screen label is the system **"Paste"** string — Apple's diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index a535ef0..d607992 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -37,14 +37,14 @@ PODS: - hermes-engine (0.80.3): - hermes-engine/Pre-built (= 0.80.3) - hermes-engine/Pre-built (0.80.3) - - LinktrailReactNative (0.0.4): + - LinktrailReactNative (0.0.5): - boost - DoubleConversion - fast_float - fmt - glog - hermes-engine - - LinkTrailSDK (~> 0.0.11) + - LinkTrailSDK (~> 0.0.12) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -67,7 +67,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - LinkTrailSDK (0.0.11) + - LinkTrailSDK (0.0.12) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2521,8 +2521,8 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: fff23161928673afcd95f63146e651726f68b384 - LinktrailReactNative: cdb6994035080e1edcb3697dcf8f2e287c5db30c - LinkTrailSDK: 53a14d30eb2cd9cbde0d41c3468be80a7a414e5d + LinktrailReactNative: e295a8248651884d674ae4f76cb6804c92e691dd + LinkTrailSDK: fa3517b4076dd364c454ce692ddb95c0de8f8ef4 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 2ebc9cc0329ede460c5d52956ce5e1ab715c2c2c RCTRequired: b83daef658afafdc68d85b97ed082f4497a8ffa4 diff --git a/example/package-lock.json b/example/package-lock.json index 591cff1..13369da 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -40,7 +40,7 @@ }, "..": { "name": "linktrail-react-native", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "devDependencies": { "@react-native/eslint-config": "0.80.3", diff --git a/ios/LinkTrailPasteButtonComponentView.mm b/ios/LinkTrailPasteButtonComponentView.mm index 9f763cf..9f81b2f 100644 --- a/ios/LinkTrailPasteButtonComponentView.mm +++ b/ios/LinkTrailPasteButtonComponentView.mm @@ -64,7 +64,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & [_pasteView configureWithDisplayMode:RCTNSStringFromString(toString(newProps.displayMode)) cornerStyle:RCTNSStringFromString(toString(newProps.cornerStyle)) foregroundColor:RCTUIColorFromSharedColor(newProps.foregroundColor) - fillColor:RCTUIColorFromSharedColor(newProps.fillColor)]; + fillColor:RCTUIColorFromSharedColor(newProps.fillColor) + grantsConsent:newProps.grantsConsent]; } [super updateProps:props oldProps:oldProps]; } diff --git a/ios/LinkTrailPasteButtonView.swift b/ios/LinkTrailPasteButtonView.swift index 023d673..c3ca2bc 100644 --- a/ios/LinkTrailPasteButtonView.swift +++ b/ios/LinkTrailPasteButtonView.swift @@ -6,6 +6,11 @@ import LinkTrailSDK /// (no "Allow Paste" alert) and hands it to the SDK via `trackInstall(clickToken:)`. /// Appearance is driven by the RN props (`displayMode`/`cornerStyle`/colors); /// size comes from the RN `style`. iOS 16+. +/// +/// The tap is a deliberate affirmative act, so by default it also records +/// tracking consent (`grantsConsent`) — mirroring the SDK's own paste button. +/// Without it the install would be held by the deny-by-default consent gate and +/// the token would never be sent. @available(iOS 16.0, *) @objc(LinkTrailPasteButtonView) public final class LinkTrailPasteButtonView: UIView { @@ -21,6 +26,7 @@ public final class LinkTrailPasteButtonView: UIView { private var cornerStyleName = "capsule" private var foregroundColor: UIColor? private var fillColor: UIColor? + private var grantsConsent = true public override init(frame: CGRect) { super.init(frame: frame) @@ -35,8 +41,13 @@ public final class LinkTrailPasteButtonView: UIView { displayMode: String, cornerStyle: String, foregroundColor: UIColor?, - fillColor: UIColor? + fillColor: UIColor?, + grantsConsent: Bool ) { + // Applied before the appearance early-return: this prop can change on its own. + self.grantsConsent = grantsConsent + coordinator?.grantsConsent = grantsConsent + let unchanged = displayMode == displayModeName && cornerStyle == cornerStyleName && @@ -55,6 +66,7 @@ public final class LinkTrailPasteButtonView: UIView { control?.removeFromSuperview() let coord = coordinator ?? PasteCoordinator { [weak self] token in self?.onToken?(token) } + coord.grantsConsent = grantsConsent coordinator = coord let configuration = UIPasteControl.Configuration() @@ -97,6 +109,9 @@ public final class LinkTrailPasteButtonView: UIView { /// The `UIPasteControl` target: accepts pasted text and forwards it to the SDK. final class PasteCoordinator: UIResponder { + /// Whether the tap also records tracking consent. Mirrors the RN prop. + var grantsConsent = true + private let onToken: (String) -> Void init(onToken: @escaping (String) -> Void) { @@ -108,8 +123,15 @@ public final class LinkTrailPasteButtonView: UIView { override func paste(itemProviders: [NSItemProvider]) { guard let provider = itemProviders.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else { return } let onToken = self.onToken + let grantsConsent = self.grantsConsent provider.loadObject(ofClass: NSString.self) { object, _ in guard let string = object as? String else { return } + // Order matters: consent first, so the install below carries `consent: true` + // instead of being held by the deny-by-default gate. Safe because the paste + // flow requires `autoTrackInstall: false` — with auto-tracking on, + // `setConsent` would fire its own token-less install and consume the + // one-shot, losing the deterministic token the user just handed us. + if grantsConsent { LinkTrailSDK.LinkTrail.shared?.setConsent(true) } LinkTrailSDK.LinkTrail.shared?.trackInstall(clickToken: string) DispatchQueue.main.async { onToken(string) } } diff --git a/package.json b/package.json index 41e3a33..14e6b00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linktrail-react-native", - "version": "0.0.4", + "version": "0.0.5", "description": "React Native SDK for LinkTrail — mobile attribution and deferred deep linking. A New Architecture TurboModule wrapping the native LinkTrail iOS and Android SDKs.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/LinkTrailPasteButton.tsx b/src/LinkTrailPasteButton.tsx index 096e58f..cf9dfb0 100644 --- a/src/LinkTrailPasteButton.tsx +++ b/src/LinkTrailPasteButton.tsx @@ -16,6 +16,12 @@ export interface LinkTrailPasteButtonProps extends ViewProps { foregroundColor?: ColorValue; /** Button fill color. */ fillColor?: ColorValue; + /** + * Whether the tap also records tracking consent. Default `true`, so the + * install the tap triggers isn't held by the deny-by-default consent gate. + * Set `false` when a separate consent UI owns the decision. + */ + grantsConsent?: boolean; } /** @@ -24,7 +30,8 @@ export interface LinkTrailPasteButtonProps extends ViewProps { * it to the SDK. Style it to match your theme via `displayMode` / `cornerStyle` / * `foregroundColor` / `fillColor`, and size it with `style` (width/height). * Configure the SDK with `clickTokenSource: 'pasteButton'` and - * `autoTrackInstall: false` so the install waits for the tap. Renders nothing on + * `autoTrackInstall: false` so the install waits for the tap. The tap also + * records consent by default (see `grantsConsent`). Renders nothing on * Android (which uses the Play Install Referrer, so no paste button is needed). */ export function LinkTrailPasteButton({ diff --git a/src/LinkTrailPasteButtonNativeComponent.ts b/src/LinkTrailPasteButtonNativeComponent.ts index 7ef6cbe..da969e6 100644 --- a/src/LinkTrailPasteButtonNativeComponent.ts +++ b/src/LinkTrailPasteButtonNativeComponent.ts @@ -14,6 +14,9 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati * theme via the props below (Apple restricts what's customizable on a paste * control); size it with the standard `style` (width/height). Use with * `clickTokenSource: 'pasteButton'` and `autoTrackInstall: false`. + * + * The tap also records tracking consent by default (`grantsConsent`), so the + * install it triggers isn't held by the deny-by-default consent gate. */ export interface NativeProps extends ViewProps { /** Fired (main thread) with the raw pasted string after the SDK reads it. */ @@ -36,6 +39,15 @@ export interface NativeProps extends ViewProps { /** Button fill color (maps to `baseBackgroundColor`). */ fillColor?: ColorValue; + + /** + * Whether the tap also records tracking consent. Default `true` — the + * deliberate tap is the affirmative act, so the install it triggers carries + * consent and isn't held by the deny-by-default gate. Set `false` when a + * separate consent UI (CMP) owns the decision: the tap then only supplies the + * token, and the install waits for your `setConsent(true)`. + */ + grantsConsent?: WithDefault; } export default codegenNativeComponent( diff --git a/src/types.ts b/src/types.ts index a290324..ddfd0be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,10 +91,22 @@ export interface LinkTrailOptions { autoTrackInstall?: boolean; /** * Gate attribution/tracking behind user consent (GDPR / ePrivacy). Default - * `true` — **deny-by-default**. While consent is unset or denied the SDK holds - * the install and drops events, but deep links are still **routed** (`onLink` - * fires) so the user reaches their destination. Call `setConsent(true)` to - * release tracking. Set `false` to attribute at init without a consent step. + * `true` — **deny-by-default and hold-until-decision**: until your app records + * a decision, **nothing leaves the device** (no install, no clipboard read, no + * device identifier). The held install is persisted, so a user who decides on + * a later launch is still attributed. + * + * Either answer releases it, as exactly one install: `setConsent(true)` sends + * the counted, attributed install (replaying any click token captured before + * the decision); `setConsent(false)` sends one **uncounted** install carrying + * `consent: false` — the backend resolves the deferred link so the user still + * lands in the right place, but records nothing and sends no device id. + * + * **Re-engagement** links (app already installed) still route without a + * decision — they're resolved, not recorded, so `onLink` fires either way. + * + * ⚠️ An app that leaves this on and **never calls `setConsent`** sends nothing + * at all. Set `false` to attribute at init when consent is handled elsewhere. */ requireConsent?: boolean; /**