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[];
/**