Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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. **`<LinkTrailPasteButton/>` 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/<package version>` (read
from `package.json` at build time). iOS still reports `ios/<native version>`: the published
`LinkTrailSDK` 0.1.0 binary doesn't expose its wrapper hook to other modules yet.
- `grantsConsent` prop on `<LinkTrailPasteButton/>`.

### 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`).
Expand Down
2 changes: 1 addition & 1 deletion LinktrailReactNative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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<string, string>`. 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

Expand Down
12 changes: 11 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -34,7 +36,15 @@ android {

compileSdkVersion getExtOrDefault("compileSdkVersion", 35)

buildFeatures {
buildConfig true
}

defaultConfig {
// The wrapper version sent in the `X-LinkTrail-SDK: react-native/<version>` 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)
Expand Down Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<package.json version>`.
LinkTrailWrapper.setPlatform("react-native", BuildConfig.LINKTRAIL_RN_VERSION)
try {
val sdk = LinkTrail.configure(reactApplicationContext, apiKey, parseOptions(options))
sdk.onAttribution { attribution -> emitOnAttribution(attributionToMap(attribution)) }
Expand Down
10 changes: 5 additions & 5 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion ios/LinkTrailPasteButtonComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
43 changes: 22 additions & 21 deletions ios/LinkTrailPasteButtonView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ 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.
private var displayModeName = "labelOnly"
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)
Expand All @@ -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 &&
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/LinkTrailPasteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -29,12 +39,20 @@ export interface LinkTrailPasteButtonProps extends ViewProps {
*/
export function LinkTrailPasteButton({
onTokenPasted,
grantsConsent,
...nativeProps
}: LinkTrailPasteButtonProps) {
if (Platform.OS !== 'ios') return null;
return (
<LinkTrailPasteButtonNative
{...nativeProps}
grantsConsent={
grantsConsent === undefined
? 'undecided'
: grantsConsent
? 'always'
: 'never'
}
onTokenPasted={
onTokenPasted
? (event) => onTokenPasted(event.nativeEvent.token)
Expand Down
7 changes: 7 additions & 0 deletions src/LinkTrailPasteButtonNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const { autoHandleLinks = true, ...nativeOptions } = options;
Expand Down Expand Up @@ -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<LinkTrailDeepLink | null> {
Expand Down
Loading
Loading