From 4bf923cd8824893798bd19a3d4752fbea98f8060 Mon Sep 17 00:00:00 2001 From: Ahsan Ali Date: Mon, 26 Oct 2026 23:34:28 +0000 Subject: [PATCH] feat: adopt native SDK 0.1.0 - Bump LinkTrailSDK to ~> 0.1.0 and io.linktrail:sdk to 0.1.0 - Paste button uses the native paste path; new optional grantsConsent prop (default: grant only while undecided) - Android reports X-LinkTrail-SDK: react-native/ - Document linkDomains migration and customData JSON-string values - Version 0.1.0 --- CHANGELOG.md | 37 ++++++++++++++++ LinktrailReactNative.podspec | 2 +- README.md | 23 +++++++--- android/build.gradle | 12 +++++- .../linktrail/reactnative/LinkTrailModule.kt | 4 ++ example/ios/Podfile.lock | 10 ++--- example/package-lock.json | 2 +- ios/LinkTrailPasteButtonComponentView.mm | 3 +- ios/LinkTrailPasteButtonView.swift | 43 ++++++++++--------- package-lock.json | 4 +- package.json | 2 +- src/LinkTrailPasteButton.tsx | 18 ++++++++ src/LinkTrailPasteButtonNativeComponent.ts | 7 +++ src/index.ts | 11 ++++- src/types.ts | 16 +++++-- 15 files changed, 150 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2690f8..39a2f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## 0.1.0 + +Adopts the native **0.1.0** SDKs: iOS `LinkTrailSDK ~> 0.1.0` (from `0.0.11`) and Android +`io.linktrail:sdk:0.1.0` (from `0.0.5`). This release has breaking changes. + +### Upgrading from 0.0.4 + +1. **Custom link domains must be listed in `linkDomains`; `handleDeepLink` now ignores + non-LinkTrail URLs.** Only `https` links on `linktrail.io` (or a subdomain) or on a + `linkDomains` entry (or a subdomain) are claimed. Any other URL resolves `null` from + `handleDeepLink`, with no network call and no `onError`. Custom-scheme LinkTrail links + (`myapp://slug`) are no longer handled; use the `https` link. If your links use your own + domain: `LinkTrail.configure(key, { linkDomains: ['go.acme.com'] })`. +2. **`` no longer grants consent after a denial** (iOS). New optional + `grantsConsent` prop: omitted (default) grants only while consent is undecided, `true` always + grants, `false` never touches consent. The tap now goes through the native SDK's paste path, so + a token pasted after a denial is kept for routing instead of being dropped. +3. **`setConsent(false)` wins even with `requireConsent: false`**: one `consent: false` install + with no device id is sent and events are dropped. +4. **`configure` is a singleton natively.** Calling it again with the same key and options is a + no-op; different options shut the previous native instance down and replace it. +5. **SDK state is no longer backed up** on either platform: a restored or migrated app starts + fresh and must ask for consent again. +6. **Rejected events are dropped, not queued** (400, 401, 403, 404, 409, 422, …); they still reach + `onError`. +7. **Log format**: the `WARN` level is now `WARNING` and logged URLs drop their query and fragment. + +### Added +- Android requests report the wrapper: `X-LinkTrail-SDK: react-native/` (read + from `package.json` at build time). iOS still reports `ios/`: the published + `LinkTrailSDK` 0.1.0 binary doesn't expose its wrapper hook to other modules yet. +- `grantsConsent` prop on ``. + +### Changed +- `customData` docs: values are always strings; nested objects and arrays arrive as JSON strings + on both platforms. + ## 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..431c30f 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.1.0" s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES", diff --git a/README.md b/README.md index 25b178f..6418df2 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ await LinkTrail.configure('lt_live_…', { | Prop | Type | Default | Notes | | --- | --- | --- | --- | +| `grantsConsent` | `boolean` | — (grant only while undecided) | Whether the tap records consent. Omitted: grants only if the user hasn't decided yet, so an earlier `setConsent(false)` stays. `true`: always grants (only for an explicit opt-in UI). `false`: never touches consent. | | `onTokenPasted` | `(token: string) => void` | — | Fires after the SDK reads the token. | | `displayMode` | `'iconAndLabel' \| 'iconOnly' \| 'labelOnly'` | `'labelOnly'` | What the control shows. | | `cornerStyle` | `'dynamic' \| 'fixed' \| 'capsule' \| 'large' \| 'medium' \| 'small'` | `'capsule'` | Corner rounding. | @@ -145,11 +146,23 @@ Universal Link (iOS Associated Domains: `applinks:kick.linktrail.io`) and an App `intent-filter` (Android), plus any custom scheme. LinkTrail infra hosts the `apple-app-site-association` / `assetlinks.json` files for your link domains. -**List every link host in `linkDomains`.** When `linkDomains` is non-empty, the SDK routes -re-engagement opens (app already installed) *only* for those hosts — a link on an unlisted host -opens the app but never navigates. Deferred (install-time) links skip this check and route -regardless, so a missing host can look fine on a fresh install yet fail once the app is installed. -Leave `linkDomains` empty (the default) to handle every parseable link. +**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): + +```ts +await LinkTrail.configure('lt_live_…', { linkDomains: ['go.acme.com'] }); +``` + +`handleDeepLink` now ignores non-LinkTrail URLs: any other URL (`mailto:`, OAuth callbacks, +`myapp://settings`, a link on an unlisted host) resolves `null`, makes no network call and doesn't +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. + +### `customData` values + +`customData` (and `utm`) is always a `Record`. Nested values set on the link +(objects, arrays) arrive as compact JSON strings, e.g. `{ cart: '{"sku":"aj1","qty":2}' }`; +`JSON.parse` them if you need the structure. ## Example app diff --git a/android/build.gradle b/android/build.gradle index 8af7e28..272d9dc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -25,6 +25,8 @@ if (!isNewArchitectureEnabled()) { ) } +def packageJson = new groovy.json.JsonSlurper().parse(file("../package.json")) + apply plugin: "com.android.library" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" @@ -34,7 +36,15 @@ android { compileSdkVersion getExtOrDefault("compileSdkVersion", 35) + buildFeatures { + buildConfig true + } + defaultConfig { + // The wrapper version sent in the `X-LinkTrail-SDK: react-native/` header, + // read from package.json at build time. + buildConfigField "String", "LINKTRAIL_RN_VERSION", "\"${packageJson.version}\"" + // The LinkTrail Android SDK requires API 26+. minSdkVersion getExtOrDefault("minSdkVersion", 26) targetSdkVersion getExtOrDefault("targetSdkVersion", 35) @@ -69,6 +79,6 @@ repositories { dependencies { implementation "com.facebook.react:react-android" - implementation "io.linktrail:sdk:0.0.5" + implementation "io.linktrail:sdk:0.1.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" } diff --git a/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt b/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt index 6f9673b..6f4aea6 100644 --- a/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt +++ b/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt @@ -12,6 +12,7 @@ import io.linktrail.LinkTrailError import io.linktrail.LinkTrailLogLevel import io.linktrail.LinkTrailOptions import io.linktrail.LinkTrailRetryPolicy +import io.linktrail.internal.LinkTrailWrapper import io.linktrail.model.LinkTrailAttribution import io.linktrail.model.LinkTrailDeepLink import io.linktrail.model.LinkTrailLinkSource @@ -34,6 +35,9 @@ class LinkTrailModule(reactContext: ReactApplicationContext) : // ── Configuration ────────────────────────────────────────────────────────── override fun configure(apiKey: String, options: ReadableMap?, promise: Promise) { + // Before configure, so the very first request already reports the wrapper: + // `X-LinkTrail-SDK: react-native/`. + LinkTrailWrapper.setPlatform("react-native", BuildConfig.LINKTRAIL_RN_VERSION) try { val sdk = LinkTrail.configure(reactApplicationContext, apiKey, parseOptions(options)) sdk.onAttribution { attribution -> emitOnAttribution(attributionToMap(attribution)) } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index a535ef0..075a72d 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.1.0): - boost - DoubleConversion - fast_float - fmt - glog - hermes-engine - - LinkTrailSDK (~> 0.0.11) + - LinkTrailSDK (~> 0.1.0) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -67,7 +67,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - LinkTrailSDK (0.0.11) + - LinkTrailSDK (0.1.0) - 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: 6f4eccc80546fe0cd08fb17d598aac809bb500b6 + LinkTrailSDK: 622127713ad7b158aae26e2c1a8ad143d74342b2 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 2ebc9cc0329ede460c5d52956ce5e1ab715c2c2c RCTRequired: b83daef658afafdc68d85b97ed082f4497a8ffa4 diff --git a/example/package-lock.json b/example/package-lock.json index 591cff1..0fe1575 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.1.0", "license": "MIT", "devDependencies": { "@react-native/eslint-config": "0.80.3", diff --git a/ios/LinkTrailPasteButtonComponentView.mm b/ios/LinkTrailPasteButtonComponentView.mm index 9f763cf..862f056 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:RCTNSStringFromString(toString(newProps.grantsConsent))]; } [super updateProps:props oldProps:oldProps]; } diff --git a/ios/LinkTrailPasteButtonView.swift b/ios/LinkTrailPasteButtonView.swift index 023d673..7f47104 100644 --- a/ios/LinkTrailPasteButtonView.swift +++ b/ios/LinkTrailPasteButtonView.swift @@ -13,7 +13,7 @@ public final class LinkTrailPasteButtonView: UIView { /// Called on the main thread with the raw pasted string after the SDK reads it. @objc public var onToken: ((String) -> Void)? - private var coordinator: PasteCoordinator? + private var coordinator: LinkTrailPasteButton.Coordinator? private weak var control: UIPasteControl? // Current appearance (RN props). Defaults mirror the JS/codegen defaults. @@ -21,6 +21,8 @@ public final class LinkTrailPasteButtonView: UIView { private var cornerStyleName = "capsule" private var foregroundColor: UIColor? private var fillColor: UIColor? + /// `"undecided"` (default: grant consent only while undecided), `"always"` or `"never"`. + private var grantsConsentName = "undecided" public override init(frame: CGRect) { super.init(frame: frame) @@ -35,9 +37,11 @@ public final class LinkTrailPasteButtonView: UIView { displayMode: String, cornerStyle: String, foregroundColor: UIColor?, - fillColor: UIColor? + fillColor: UIColor?, + grantsConsent: String ) { let unchanged = + grantsConsent == grantsConsentName && displayMode == displayModeName && cornerStyle == cornerStyleName && foregroundColor == self.foregroundColor && @@ -48,13 +52,22 @@ public final class LinkTrailPasteButtonView: UIView { cornerStyleName = cornerStyle self.foregroundColor = foregroundColor self.fillColor = fillColor + if grantsConsent != grantsConsentName { + grantsConsentName = grantsConsent + coordinator = nil // the SDK's coordinator captures grantsConsent at creation + } rebuild() } private func rebuild() { control?.removeFromSuperview() - let coord = coordinator ?? PasteCoordinator { [weak self] token in self?.onToken?(token) } + // The SDK's own paste coordinator: it applies the grantsConsent rule and sends the + // install with the click token (the same path as the native LinkTrailPasteButton). + let coord = coordinator ?? LinkTrailPasteButton( + grantsConsent: Self.grantsConsent(grantsConsentName), + onToken: { [weak self] token in self?.onToken?(token) } + ).makeCoordinator() coordinator = coord let configuration = UIPasteControl.Configuration() @@ -95,24 +108,12 @@ public final class LinkTrailPasteButtonView: UIView { } } - /// The `UIPasteControl` target: accepts pasted text and forwards it to the SDK. - final class PasteCoordinator: UIResponder { - private let onToken: (String) -> Void - - init(onToken: @escaping (String) -> Void) { - self.onToken = onToken - super.init() - pasteConfiguration = UIPasteConfiguration(forAccepting: NSString.self) - } - - override func paste(itemProviders: [NSItemProvider]) { - guard let provider = itemProviders.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else { return } - let onToken = self.onToken - provider.loadObject(ofClass: NSString.self) { object, _ in - guard let string = object as? String else { return } - LinkTrailSDK.LinkTrail.shared?.trackInstall(clickToken: string) - DispatchQueue.main.async { onToken(string) } - } + /// `nil` = grant only while undecided (the native default); `true` = always grant; `false` = never. + private static func grantsConsent(_ name: String) -> Bool? { + switch name { + case "always": return true + case "never": return false + default: return nil } } } diff --git a/package-lock.json b/package-lock.json index 2c1eed9..6e66b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linktrail-react-native", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linktrail-react-native", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@react-native/eslint-config": "0.80.3", diff --git a/package.json b/package.json index 41e3a33..ef5bd63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linktrail-react-native", - "version": "0.0.4", + "version": "0.1.0", "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..b662e7d 100644 --- a/src/LinkTrailPasteButton.tsx +++ b/src/LinkTrailPasteButton.tsx @@ -12,6 +12,16 @@ export interface LinkTrailPasteButtonProps extends ViewProps { displayMode?: 'iconAndLabel' | 'iconOnly' | 'labelOnly'; /** Corner rounding. Default `'capsule'`. */ cornerStyle?: 'dynamic' | 'fixed' | 'capsule' | 'large' | 'medium' | 'small'; + /** + * Whether the tap also records tracking consent (iOS). + * - omitted (default): grants consent **only while it is still undecided**; a + * user who already called `setConsent(false)` stays denied (the token is + * still used for routing). + * - `true`: the tap always grants consent, overriding an earlier denial. Use + * only when your UI makes the tap an explicit opt-in. + * - `false`: the tap never touches consent (collect it yourself). + */ + grantsConsent?: boolean; /** Label/icon color. */ foregroundColor?: ColorValue; /** Button fill color. */ @@ -29,12 +39,20 @@ export interface LinkTrailPasteButtonProps extends ViewProps { */ export function LinkTrailPasteButton({ onTokenPasted, + grantsConsent, ...nativeProps }: LinkTrailPasteButtonProps) { if (Platform.OS !== 'ios') return null; return ( onTokenPasted(event.nativeEvent.token) diff --git a/src/LinkTrailPasteButtonNativeComponent.ts b/src/LinkTrailPasteButtonNativeComponent.ts index 7ef6cbe..8d74f88 100644 --- a/src/LinkTrailPasteButtonNativeComponent.ts +++ b/src/LinkTrailPasteButtonNativeComponent.ts @@ -31,6 +31,13 @@ export interface NativeProps extends ViewProps { 'capsule' >; + /** + * How a tap affects tracking consent (maps to the native `grantsConsent: Bool?`): + * `'undecided'` (default, native `nil`) grants only while consent is undecided, + * `'always'` (`true`) always grants, `'never'` (`false`) never touches consent. + */ + grantsConsent?: WithDefault<'undecided' | 'always' | 'never', 'undecided'>; + /** Label/icon color (maps to `baseForegroundColor`). */ foregroundColor?: ColorValue; diff --git a/src/index.ts b/src/index.ts index a396586..6d21014 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,12 @@ export const LinkTrail = { * app launch, before any other LinkTrail API. The install is tracked * automatically unless `options.autoTrackInstall` is `false`. * - * Rejects with code `missing_api_key` when the key is blank. + * Rejects with code `missing_api_key` when the key is blank. Calling it again + * with the same key and options is a no-op; different options replace the + * native instance. + * + * Custom link domains must be listed in `options.linkDomains`; other URLs are + * ignored by `handleDeepLink`. */ async configure(apiKey: string, options: LinkTrailOptions = {}): Promise { const { autoHandleLinks = true, ...nativeOptions } = options; @@ -143,7 +148,9 @@ export const LinkTrail = { /** * Forwards an incoming URL to the SDK. Resolves with the resolved deep link, - * or `null` when the URL is not a LinkTrail link. You only need this when + * or `null` when the URL is not a LinkTrail link (not `https` on `linktrail.io` + * or a `linkDomains` host): those make no network call and fire no `onError`. + * You only need this when * `autoHandleLinks` is `false`; routing normally happens via `onLink`. */ handleDeepLink(url: string): Promise { diff --git a/src/types.ts b/src/types.ts index a290324..4ba0702 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,12 @@ export interface LinkTrailDeepLink { channel?: string; /** UTM parameters carried by the link. */ utm?: Record; - /** Arbitrary key/value payload configured on the link. */ + /** + * Arbitrary key/value payload configured on the link. Values are always + * strings: scalars arrive as text (`42`, `true`), and nested objects or + * arrays arrive as compact JSON strings (`'{"a":1}'`) — `JSON.parse` them + * if you need the structure. Same on iOS and Android. + */ customData?: Record; /** The destination path to route to, defaulting to `/` when none is set. */ path: string; @@ -79,9 +84,12 @@ export interface LinkTrailOptions { /** Retry policy applied to transient network failures. */ retryPolicy?: LinkTrailRetryPolicy; /** - * Link hosts the SDK should treat as its own (e.g. `['go.acme.com']`). - * When non-empty, `handleDeepLink` ignores URLs on other hosts. Empty = - * handle every parseable link. + * Your custom link domains (e.g. `['go.acme.com']`). The SDK claims only + * `https` links on `linktrail.io` (or a subdomain) or on one of these hosts + * (or a subdomain). Any other URL (`mailto:`, OAuth callbacks, + * `myapp://settings`) is ignored: `handleDeepLink` resolves `null`, with no + * network call and no `onError`. **Custom link domains must be listed here.** + * Custom-scheme LinkTrail links (`myapp://slug`) are no longer handled. */ linkDomains?: string[]; /**