Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- **User identity**: `LinkTrail.setUserId(id)`, `LinkTrail.logout()`, `LinkTrail.getUserId()`.
The id is validated (`^[A-Za-z0-9_.:-]{1,128}$`, rejects with `invalid_user_id`), persisted across
launches, and never transmitted while consent is denied/undecided. Delivery needs a native SDK
release with identity support; the pinned native SDKs are unchanged (see README).

## 0.0.4

- Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.11`** (from `0.0.10`).
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ LinkTrail.setConsent(storedGranted);
Set `requireConsent: false` to attribute at init without a consent step. See the example app's
[`src/consent.ts`](example/src/consent.ts) for the persist-and-replay pattern.

### User identity

Link events to your own user with `setUserId` / `logout`:

```ts
await LinkTrail.setUserId('u_8f3a91c2'); // after login
await LinkTrail.logout(); // after sign-out: clears the id locally, no network call
const current = await LinkTrail.getUserId(); // string | null
```

- **Never pass PII.** Use your app's own opaque internal id or a hash — not an email, phone number
or name.
- Valid ids are 1-128 characters from `A-Z a-z 0-9 _ - . :` (`^[A-Za-z0-9_.:-]{1,128}$`). Anything
else rejects with an error whose `code` is `invalid_user_id`; nothing is stored or sent.
- The id is persisted across launches. Calling `setUserId` with a different id replaces it.
- **Consent first:** the id is never transmitted while consent is denied or undecided (it is only
stored locally). Once consent is granted (`setConsent(true)`, or `requireConsent: false`) the id is
sent once to link the device's installs, then attached to later open/custom events. Revoking
consent stops sending it.
- Delivery is best-effort and never throws or blocks your app.

> **Native SDK requirement.** The native SDKs perform the network calls, so sending the id needs a
> native SDK release with identity support. This wrapper still pins the earlier releases
> (iOS `LinkTrailSDK ~> 0.0.11`, Android `io.linktrail:sdk:0.0.5`); with those the id is validated and
> persisted but **not transmitted**. Android picks up identity support automatically once the pin
> moves to a release exposing `setUserId` / `logout`; iOS additionally needs a small wrapper change
> at that point.

### Deferred attribution & the paste button (iOS)

On iOS, deferred attribution recovers a **click token** the tapped link left on the clipboard.
Expand Down
50 changes: 50 additions & 0 deletions android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,56 @@ class LinkTrailModule(reactContext: ReactApplicationContext) :
LinkTrail.shared?.setConsent(granted)
}

// ── User identity ──────────────────────────────────────────────────────────

private val identityPrefs
get() = reactApplicationContext.getSharedPreferences(
"io.linktrail.reactnative.identity",
android.content.Context.MODE_PRIVATE
)

override fun persistUserId(userId: String?, promise: Promise) {
identityPrefs.edit().apply {
if (userId != null) putString("userId", userId) else remove("userId")
}.apply()
promise.resolve(null)
}

override fun loadUserId(promise: Promise) {
promise.resolve(identityPrefs.getString("userId", null))
}

/**
* The pinned SDK (io.linktrail:sdk:0.0.5) has no identity API. Look the methods
* up reflectively so this compiles today and starts delivering as soon as the
* dependency moves to a release that adds `setUserId(String)` / `logout()`.
*/
override fun forwardUserId(userId: String?, promise: Promise) {
val sdk = LinkTrail.shared
if (sdk == null) {
promise.resolve(false)
return
}
try {
val methods = sdk.javaClass.methods
val target = if (userId != null) {
methods.firstOrNull { it.name == "setUserId" && it.parameterTypes.size == 1 }
?.let { it to arrayOf<Any?>(userId) }
} else {
methods.firstOrNull { it.name == "logout" && it.parameterTypes.isEmpty() }
?.let { it to arrayOf<Any?>() }
}
if (target == null) {
promise.resolve(false)
} else {
target.first.invoke(sdk, *target.second)
promise.resolve(true)
}
} catch (t: Throwable) {
promise.resolve(false) // best-effort
}
}

// ── Testing ────────────────────────────────────────────────────────────────

override fun resetForTesting() {
Expand Down
20 changes: 20 additions & 0 deletions ios/LinkTrailModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@ - (void)setConsent:(BOOL)granted
[_impl setConsent:granted];
}

- (void)persistUserId:(NSString *)userId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
[_impl persistUserId:userId resolve:resolve];
}

- (void)loadUserId:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
[_impl loadUserId:resolve];
}

- (void)forwardUserId:(NSString *)userId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
[_impl forwardUserId:userId resolve:resolve];
}

- (void)resetForTesting
{
[_impl resetForTesting];
Expand Down
24 changes: 24 additions & 0 deletions ios/LinkTrailModuleImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,30 @@ public final class LinkTrailModuleImpl: NSObject {
sdk?.setConsent(granted)
}

// MARK: - User identity

private static let userIdKey = "io.linktrail.reactnative.userId"

@objc public func persistUserId(_ userId: String?, resolve: @escaping Resolve) {
if let userId {
UserDefaults.standard.set(userId, forKey: Self.userIdKey)
} else {
UserDefaults.standard.removeObject(forKey: Self.userIdKey)
}
resolve(nil)
}

@objc public func loadUserId(_ resolve: @escaping Resolve) {
resolve(UserDefaults.standard.string(forKey: Self.userIdKey))
}

/// The pinned LinkTrailSDK (~> 0.0.11) has no identity API, and Swift offers no
/// way to probe for a missing method at runtime, so this reports `false`
/// ("not delivered") until the pod pin moves to a release that adds it.
@objc public func forwardUserId(_ userId: String?, resolve: @escaping Resolve) {
resolve(false)
}

// MARK: - Testing

@objc public func resetForTesting() {
Expand Down
Loading
Loading