@tolinku/react-native-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,6 +149,30 @@ const link = await Tolinku.deferred.claimBySignals({
149
149
  });
150
150
  ```
151
151
 
152
+ On Android the Play Install Referrer is the deterministic mechanism: a Tolinku
153
+ link attaches a token to the store URL, Play keeps it through the install, and
154
+ this SDK reads it back on first launch. It names the exact click, survives for
155
+ days, and does not depend on the network the device was on. Device signals are
156
+ the fallback, and the only option on iOS, where no equivalent exists.
157
+
158
+ Prefer `claimDeferredLink()` over choosing a mechanism yourself:
159
+
160
+ ```ts
161
+ // Referrer first, device signals as the fallback.
162
+ const link = await Tolinku.deferred.claimDeferredLink({
163
+ appspaceId: '64f0a1b2c3d4e5f60718',
164
+ });
165
+ if (link) routeTo(link.deep_link_path);
166
+ ```
167
+
168
+ Call it once on first launch. Calling again is safe, but a claim is consumed the
169
+ first time it succeeds, so a second call returns nothing.
170
+
171
+ The Android side is bundled with this package, so there is nothing else to
172
+ install. Autolinking is per-platform, so an iOS-only app never builds it. In
173
+ Expo Go the native module is absent and Android falls back to signal matching;
174
+ a development build gets the referrer.
175
+
152
176
  `appspaceId` is your Appspace ID, not your subdomain or slug. Copy it from the dashboard
153
177
  under **Integrate** or **Settings**. It looks like `64f0a1b2c3d4e5f60718`.
154
178
 
@@ -0,0 +1,74 @@
1
+ // Android side of @tolinku/react-native-sdk.
2
+ //
3
+ // Exists only to read the Play Install Referrer, which is the deterministic way
4
+ // a deferred link survives an install on Android. It is bundled rather than
5
+ // delegated to a third-party package because the referrer token is Tolinku's
6
+ // own mechanism: we mint it on the link and read it back here.
7
+ //
8
+ // Autolinking is per-platform, so this is not built when compiling for iOS. An
9
+ // iOS-only app pays nothing for its presence.
10
+ //
11
+ // To verify compilation outside a host app:
12
+ // printf 'rootProject.name = "t"\n' > android/settings.gradle
13
+ // ../../android-sdk/gradlew -p android assembleRelease \
14
+ // -PtolinkuReactAndroidVersion=0.73.11 -Pandroid.useAndroidX=true
15
+ // rm android/settings.gradle
16
+
17
+ buildscript {
18
+ repositories {
19
+ google()
20
+ mavenCentral()
21
+ }
22
+ dependencies {
23
+ classpath("com.android.tools.build:gradle:8.1.4")
24
+ }
25
+ }
26
+
27
+ apply plugin: "com.android.library"
28
+
29
+ def safeExtGet(prop, fallback) {
30
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
31
+ }
32
+
33
+ android {
34
+ // The peer range starts at React Native 0.72, whose AGP 7.4 supports
35
+ // namespace, so the old manifest package attribute is not needed.
36
+ namespace "com.tolinku.rnsdk"
37
+
38
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
39
+
40
+ defaultConfig {
41
+ // The Install Referrer library needs 21; the host app decides above that.
42
+ minSdkVersion safeExtGet("minSdkVersion", 21)
43
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
44
+ }
45
+
46
+ compileOptions {
47
+ sourceCompatibility JavaVersion.VERSION_1_8
48
+ targetCompatibility JavaVersion.VERSION_1_8
49
+ }
50
+
51
+ lintOptions {
52
+ abortOnError false
53
+ }
54
+ }
55
+
56
+ repositories {
57
+ google()
58
+ mavenCentral()
59
+ }
60
+
61
+ dependencies {
62
+ // Unversioned on purpose: the React Native Gradle plugin in the host app
63
+ // pins this to whatever version the app is on, so the library follows the
64
+ // app rather than forcing one. A version is supplied only for the standalone
65
+ // build used to verify compilation outside an app.
66
+ def rnVersion = project.findProperty("tolinkuReactAndroidVersion")
67
+ if (rnVersion) {
68
+ implementation "com.facebook.react:react-android:${rnVersion}"
69
+ } else {
70
+ implementation "com.facebook.react:react-android"
71
+ }
72
+
73
+ implementation "com.android.installreferrer:installreferrer:2.2"
74
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,107 @@
1
+ package com.tolinku.rnsdk;
2
+
3
+ import androidx.annotation.NonNull;
4
+
5
+ import com.android.installreferrer.api.InstallReferrerClient;
6
+ import com.android.installreferrer.api.InstallReferrerStateListener;
7
+ import com.facebook.react.bridge.Promise;
8
+ import com.facebook.react.bridge.ReactApplicationContext;
9
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
10
+ import com.facebook.react.bridge.ReactMethod;
11
+
12
+ import java.util.concurrent.atomic.AtomicBoolean;
13
+
14
+ /**
15
+ * Reads the Play Install Referrer.
16
+ *
17
+ * A Tolinku link sends an Android visitor to the store with
18
+ * {@code referrer=tolk_token=<token>} attached. Play keeps that string through
19
+ * the install and hands it back on first launch, which names the exact click
20
+ * instead of inferring it from device signals.
21
+ *
22
+ * Only the raw referrer string is returned. Finding our token inside it is done
23
+ * in JavaScript, where it is covered by tests that run on every platform rather
24
+ * than only where an Android toolchain exists.
25
+ */
26
+ public class TolinkuInstallReferrerModule extends ReactContextBaseJavaModule {
27
+
28
+ public static final String NAME = "TolinkuInstallReferrer";
29
+
30
+ public TolinkuInstallReferrerModule(ReactApplicationContext reactContext) {
31
+ super(reactContext);
32
+ }
33
+
34
+ @Override
35
+ @NonNull
36
+ public String getName() {
37
+ return NAME;
38
+ }
39
+
40
+ /**
41
+ * Resolves with the raw referrer string, or null when there is nothing to
42
+ * report.
43
+ *
44
+ * Never rejects. An organic install, a device without Play Services, and a
45
+ * store other than Play are all ordinary outcomes rather than errors, and
46
+ * the caller falls back to signal matching for every one of them. Rejecting
47
+ * would turn a routine absence into an unhandled promise on first launch.
48
+ */
49
+ @ReactMethod
50
+ public void getInstallReferrer(final Promise promise) {
51
+ final InstallReferrerClient client;
52
+ try {
53
+ client = InstallReferrerClient.newBuilder(getReactApplicationContext()).build();
54
+ } catch (Throwable t) {
55
+ promise.resolve(null);
56
+ return;
57
+ }
58
+
59
+ // The Play listener can fire more than once on some devices, and
60
+ // resolving a promise twice is an error in React Native.
61
+ final AtomicBoolean settled = new AtomicBoolean(false);
62
+
63
+ try {
64
+ client.startConnection(new InstallReferrerStateListener() {
65
+ @Override
66
+ public void onInstallReferrerSetupFinished(int responseCode) {
67
+ if (!settled.compareAndSet(false, true)) {
68
+ return;
69
+ }
70
+ String referrer = null;
71
+ try {
72
+ if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
73
+ referrer = client.getInstallReferrer().getInstallReferrer();
74
+ }
75
+ } catch (Throwable t) {
76
+ referrer = null;
77
+ } finally {
78
+ endQuietly(client);
79
+ }
80
+ promise.resolve(referrer);
81
+ }
82
+
83
+ @Override
84
+ public void onInstallReferrerServiceDisconnected() {
85
+ if (!settled.compareAndSet(false, true)) {
86
+ return;
87
+ }
88
+ endQuietly(client);
89
+ promise.resolve(null);
90
+ }
91
+ });
92
+ } catch (Throwable t) {
93
+ if (settled.compareAndSet(false, true)) {
94
+ endQuietly(client);
95
+ promise.resolve(null);
96
+ }
97
+ }
98
+ }
99
+
100
+ private static void endQuietly(InstallReferrerClient client) {
101
+ try {
102
+ client.endConnection();
103
+ } catch (Throwable ignored) {
104
+ // Already gone.
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,30 @@
1
+ package com.tolinku.rnsdk;
2
+
3
+ import androidx.annotation.NonNull;
4
+
5
+ import com.facebook.react.ReactPackage;
6
+ import com.facebook.react.bridge.NativeModule;
7
+ import com.facebook.react.bridge.ReactApplicationContext;
8
+ import com.facebook.react.uimanager.ViewManager;
9
+
10
+ import java.util.ArrayList;
11
+ import java.util.Collections;
12
+ import java.util.List;
13
+
14
+ /** Registers the install referrer module with React Native's autolinking. */
15
+ public class TolinkuInstallReferrerPackage implements ReactPackage {
16
+
17
+ @Override
18
+ @NonNull
19
+ public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
20
+ List<NativeModule> modules = new ArrayList<>();
21
+ modules.add(new TolinkuInstallReferrerModule(reactContext));
22
+ return modules;
23
+ }
24
+
25
+ @Override
26
+ @NonNull
27
+ public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
28
+ return Collections.emptyList();
29
+ }
30
+ }
package/dist/index.d.mts CHANGED
@@ -319,13 +319,68 @@ declare class Referrals {
319
319
  }>;
320
320
  }
321
321
 
322
+ /**
323
+ * Pull our token out of a Play referrer string.
324
+ *
325
+ * The referrer is shared. A developer's own `utm_source` and anything else they
326
+ * attached sit in the same string, so the token is found among the pairs rather
327
+ * than assumed to be the whole value. A percent-encoded `%3D` is tolerated
328
+ * because Play normally decodes it, and that assumption is not worth a lost
329
+ * install if it is ever wrong.
330
+ */
331
+ declare function parseInstallReferrer(referrer: string | null | undefined): string | null;
332
+ /** Anything that can hand back a Play referrer string. */
333
+ type ReferrerProvider = () => Promise<string | null> | string | null;
334
+ /**
335
+ * Best effort at the referrer token for this install.
336
+ *
337
+ * Android only, and null everywhere else: there is no equivalent on iOS, which
338
+ * is why signal matching exists at all. Never throws, because attribution must
339
+ * not be able to fail a first launch.
340
+ */
341
+ declare function getInstallReferrerToken(provider?: ReferrerProvider): Promise<string | null>;
342
+
322
343
  declare class Deferred {
323
344
  private client;
324
345
  constructor(client: HttpClient);
325
346
  /** Claim a deferred deep link by referrer token (from Play Store referrer or clipboard) */
326
- claimByToken(token: string): Promise<DeferredLink | null>;
347
+ claimByToken(token: string, appspaceId?: string): Promise<DeferredLink | null>;
348
+ /**
349
+ * Recover the link that led to this install, trying both mechanisms.
350
+ *
351
+ * The Play Install Referrer is asked first on Android: it names the exact
352
+ * click, survives for days, and does not care which network the device was
353
+ * on. Device signals are the fallback, and the only option on iOS, where no
354
+ * equivalent exists.
355
+ *
356
+ * Call once on first launch. Safe to call again, but a claim is consumed the
357
+ * first time it succeeds, so a second call returns null.
358
+ *
359
+ * Reading the referrer needs a native Play Services binding, which this
360
+ * package deliberately does not bundle. Pass `referrerProvider`, or install a
361
+ * supported referrer package and it is used automatically. Without either,
362
+ * Android falls back to signal matching.
363
+ */
364
+ claimDeferredLink(options: {
365
+ appspaceId: string;
366
+ referrerProvider?: ReferrerProvider;
367
+ /** Claim again even if an attempt was already recorded. For tests. */
368
+ force?: boolean;
369
+ }): Promise<DeferredLink | null>;
370
+ private alreadyAttempted;
371
+ private rememberAttempt;
327
372
  /** Claim a deferred deep link by device signal matching */
328
373
  claimBySignals(options: ClaimBySignalsOptions): Promise<DeferredLink | null>;
374
+ /**
375
+ * The signal claim, with whether the server actually answered.
376
+ *
377
+ * `settled` separates "nothing is waiting for this device", which no amount
378
+ * of asking will change, from "the request never got there". Both surface as
379
+ * null to callers of claimBySignals, but claimDeferredLink has to tell them
380
+ * apart: recording an attempt that never reached the server would spend an
381
+ * install's one chance at attribution on a dropped connection.
382
+ */
383
+ private attemptSignals;
329
384
  }
330
385
 
331
386
  /**
@@ -338,7 +393,7 @@ declare class Deferred {
338
393
  * await Tolinku.track('signup', { source: 'onboarding' });
339
394
  */
340
395
  declare class Tolinku {
341
- static readonly VERSION = "0.1.0";
396
+ static readonly VERSION = "0.4.0";
342
397
  private static client;
343
398
  private static analyticsInstance;
344
399
  private static ecommerceInstance;
@@ -352,6 +407,15 @@ declare class Tolinku {
352
407
  * If init() is called a second time without calling destroy() first,
353
408
  * a warning is logged and the existing instance is returned.
354
409
  */
410
+ /**
411
+ * Configure the SDK.
412
+ *
413
+ * The name the Android, iOS and Flutter SDKs use for this. {@link init} does
414
+ * the same thing and still works; it is what this package shipped and
415
+ * breaking it would serve nobody. It is meant for deprecation later, once
416
+ * moving off it is a one-line change rather than a surprise.
417
+ */
418
+ static configure(config: TolinkuConfig): void;
355
419
  static init(config: TolinkuConfig): void;
356
420
  /** Check whether the SDK has been initialized. */
357
421
  static isConfigured(): boolean;
@@ -417,4 +481,4 @@ declare function TolinkuMessages({ trigger, triggerValue, onDismiss, onButtonPre
417
481
  */
418
482
  declare function isSafeUrl(url: string): boolean;
419
483
 
420
- export { type AddPaymentInfoParams, type AddToCartParams, type AddToWishlistParams, type BeginCheckoutParams, type ClaimBySignalsOptions, type CompleteReferralOptions, type CompleteReferralResult, type CreateReferralOptions, type CreateReferralResult, type DeferredLink, type EcommerceItem, type LeaderboardEntry, type Message, type MessageComponent, type MessageContent, type MilestoneOptions, type MilestoneResult, type PurchaseParams, type RateParams, type ReferralInfo, type RefundParams, type RemoveFromCartParams, type ResolvedTolinkuConfig, type SearchParams, type ShareParams, type ShowMessageOptions, type SpendCreditsParams, Tolinku, type TolinkuConfig, TolinkuError, TolinkuMessages, type TrackProperties, type ViewItemParams, isSafeUrl };
484
+ export { type AddPaymentInfoParams, type AddToCartParams, type AddToWishlistParams, type BeginCheckoutParams, type ClaimBySignalsOptions, type CompleteReferralOptions, type CompleteReferralResult, type CreateReferralOptions, type CreateReferralResult, type DeferredLink, type EcommerceItem, type LeaderboardEntry, type Message, type MessageComponent, type MessageContent, type MilestoneOptions, type MilestoneResult, type PurchaseParams, type RateParams, type ReferralInfo, type ReferrerProvider, type RefundParams, type RemoveFromCartParams, type ResolvedTolinkuConfig, type SearchParams, type ShareParams, type ShowMessageOptions, type SpendCreditsParams, Tolinku, type TolinkuConfig, TolinkuError, TolinkuMessages, type TrackProperties, type ViewItemParams, getInstallReferrerToken, isSafeUrl, parseInstallReferrer };
package/dist/index.d.ts CHANGED
@@ -319,13 +319,68 @@ declare class Referrals {
319
319
  }>;
320
320
  }
321
321
 
322
+ /**
323
+ * Pull our token out of a Play referrer string.
324
+ *
325
+ * The referrer is shared. A developer's own `utm_source` and anything else they
326
+ * attached sit in the same string, so the token is found among the pairs rather
327
+ * than assumed to be the whole value. A percent-encoded `%3D` is tolerated
328
+ * because Play normally decodes it, and that assumption is not worth a lost
329
+ * install if it is ever wrong.
330
+ */
331
+ declare function parseInstallReferrer(referrer: string | null | undefined): string | null;
332
+ /** Anything that can hand back a Play referrer string. */
333
+ type ReferrerProvider = () => Promise<string | null> | string | null;
334
+ /**
335
+ * Best effort at the referrer token for this install.
336
+ *
337
+ * Android only, and null everywhere else: there is no equivalent on iOS, which
338
+ * is why signal matching exists at all. Never throws, because attribution must
339
+ * not be able to fail a first launch.
340
+ */
341
+ declare function getInstallReferrerToken(provider?: ReferrerProvider): Promise<string | null>;
342
+
322
343
  declare class Deferred {
323
344
  private client;
324
345
  constructor(client: HttpClient);
325
346
  /** Claim a deferred deep link by referrer token (from Play Store referrer or clipboard) */
326
- claimByToken(token: string): Promise<DeferredLink | null>;
347
+ claimByToken(token: string, appspaceId?: string): Promise<DeferredLink | null>;
348
+ /**
349
+ * Recover the link that led to this install, trying both mechanisms.
350
+ *
351
+ * The Play Install Referrer is asked first on Android: it names the exact
352
+ * click, survives for days, and does not care which network the device was
353
+ * on. Device signals are the fallback, and the only option on iOS, where no
354
+ * equivalent exists.
355
+ *
356
+ * Call once on first launch. Safe to call again, but a claim is consumed the
357
+ * first time it succeeds, so a second call returns null.
358
+ *
359
+ * Reading the referrer needs a native Play Services binding, which this
360
+ * package deliberately does not bundle. Pass `referrerProvider`, or install a
361
+ * supported referrer package and it is used automatically. Without either,
362
+ * Android falls back to signal matching.
363
+ */
364
+ claimDeferredLink(options: {
365
+ appspaceId: string;
366
+ referrerProvider?: ReferrerProvider;
367
+ /** Claim again even if an attempt was already recorded. For tests. */
368
+ force?: boolean;
369
+ }): Promise<DeferredLink | null>;
370
+ private alreadyAttempted;
371
+ private rememberAttempt;
327
372
  /** Claim a deferred deep link by device signal matching */
328
373
  claimBySignals(options: ClaimBySignalsOptions): Promise<DeferredLink | null>;
374
+ /**
375
+ * The signal claim, with whether the server actually answered.
376
+ *
377
+ * `settled` separates "nothing is waiting for this device", which no amount
378
+ * of asking will change, from "the request never got there". Both surface as
379
+ * null to callers of claimBySignals, but claimDeferredLink has to tell them
380
+ * apart: recording an attempt that never reached the server would spend an
381
+ * install's one chance at attribution on a dropped connection.
382
+ */
383
+ private attemptSignals;
329
384
  }
330
385
 
331
386
  /**
@@ -338,7 +393,7 @@ declare class Deferred {
338
393
  * await Tolinku.track('signup', { source: 'onboarding' });
339
394
  */
340
395
  declare class Tolinku {
341
- static readonly VERSION = "0.1.0";
396
+ static readonly VERSION = "0.4.0";
342
397
  private static client;
343
398
  private static analyticsInstance;
344
399
  private static ecommerceInstance;
@@ -352,6 +407,15 @@ declare class Tolinku {
352
407
  * If init() is called a second time without calling destroy() first,
353
408
  * a warning is logged and the existing instance is returned.
354
409
  */
410
+ /**
411
+ * Configure the SDK.
412
+ *
413
+ * The name the Android, iOS and Flutter SDKs use for this. {@link init} does
414
+ * the same thing and still works; it is what this package shipped and
415
+ * breaking it would serve nobody. It is meant for deprecation later, once
416
+ * moving off it is a one-line change rather than a surprise.
417
+ */
418
+ static configure(config: TolinkuConfig): void;
355
419
  static init(config: TolinkuConfig): void;
356
420
  /** Check whether the SDK has been initialized. */
357
421
  static isConfigured(): boolean;
@@ -417,4 +481,4 @@ declare function TolinkuMessages({ trigger, triggerValue, onDismiss, onButtonPre
417
481
  */
418
482
  declare function isSafeUrl(url: string): boolean;
419
483
 
420
- export { type AddPaymentInfoParams, type AddToCartParams, type AddToWishlistParams, type BeginCheckoutParams, type ClaimBySignalsOptions, type CompleteReferralOptions, type CompleteReferralResult, type CreateReferralOptions, type CreateReferralResult, type DeferredLink, type EcommerceItem, type LeaderboardEntry, type Message, type MessageComponent, type MessageContent, type MilestoneOptions, type MilestoneResult, type PurchaseParams, type RateParams, type ReferralInfo, type RefundParams, type RemoveFromCartParams, type ResolvedTolinkuConfig, type SearchParams, type ShareParams, type ShowMessageOptions, type SpendCreditsParams, Tolinku, type TolinkuConfig, TolinkuError, TolinkuMessages, type TrackProperties, type ViewItemParams, isSafeUrl };
484
+ export { type AddPaymentInfoParams, type AddToCartParams, type AddToWishlistParams, type BeginCheckoutParams, type ClaimBySignalsOptions, type CompleteReferralOptions, type CompleteReferralResult, type CreateReferralOptions, type CreateReferralResult, type DeferredLink, type EcommerceItem, type LeaderboardEntry, type Message, type MessageComponent, type MessageContent, type MilestoneOptions, type MilestoneResult, type PurchaseParams, type RateParams, type ReferralInfo, type ReferrerProvider, type RefundParams, type RemoveFromCartParams, type ResolvedTolinkuConfig, type SearchParams, type ShareParams, type ShowMessageOptions, type SpendCreditsParams, Tolinku, type TolinkuConfig, TolinkuError, TolinkuMessages, type TrackProperties, type ViewItemParams, getInstallReferrerToken, isSafeUrl, parseInstallReferrer };
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
10
  var AsyncStorage__default = /*#__PURE__*/_interopDefault(AsyncStorage);
11
11
 
12
12
  // src/types.ts
13
- var SDK_VERSION = "0.1.0";
13
+ var SDK_VERSION = "0.4.0";
14
14
 
15
15
  // src/debug.ts
16
16
  var _debugEnabled = false;
@@ -630,32 +630,126 @@ var Referrals = class {
630
630
  });
631
631
  }
632
632
  };
633
+ var TOKEN_KEY = "tolk_token";
634
+ function parseInstallReferrer(referrer) {
635
+ if (!referrer || !referrer.trim()) return null;
636
+ let decoded = referrer;
637
+ try {
638
+ decoded = decodeURIComponent(referrer);
639
+ } catch {
640
+ }
641
+ const pair = decoded.split("&").map((p) => p.trim()).find((p) => p.startsWith(`${TOKEN_KEY}=`));
642
+ if (!pair) return null;
643
+ const token = pair.slice(TOKEN_KEY.length + 1);
644
+ return token.trim() ? token : null;
645
+ }
646
+ function nativeProvider() {
647
+ const native = reactNative.NativeModules?.TolinkuInstallReferrer;
648
+ if (!native || typeof native.getInstallReferrer !== "function") return null;
649
+ return () => native.getInstallReferrer();
650
+ }
651
+ async function getInstallReferrerToken(provider) {
652
+ if (reactNative.Platform.OS !== "android") return null;
653
+ const source = provider ?? nativeProvider();
654
+ if (!source) return null;
655
+ try {
656
+ const referrer = await source();
657
+ return parseInstallReferrer(referrer);
658
+ } catch (err) {
659
+ debugWarn(`Install referrer lookup failed: ${err.message}`);
660
+ return null;
661
+ }
662
+ }
663
+
664
+ // src/deferred.ts
665
+ var CLAIMED_KEY = "tolinku_deferred_claimed";
633
666
  var Deferred = class {
634
667
  constructor(client) {
635
668
  this.client = client;
636
669
  }
637
670
  /** Claim a deferred deep link by referrer token (from Play Store referrer or clipboard) */
638
- async claimByToken(token) {
671
+ async claimByToken(token, appspaceId) {
639
672
  if (!token || !token.trim()) {
640
673
  throw new Error("Tolinku: token is required and must not be blank for claimByToken.");
641
674
  }
642
675
  try {
643
- return await this.client.getPublic("/v1/api/deferred/claim", { token });
676
+ return await this.client.getPublic("/v1/api/deferred/claim", {
677
+ token,
678
+ ...appspaceId ? { appspace_id: appspaceId } : {}
679
+ });
644
680
  } catch (err) {
645
681
  debugWarn(`Deferred claimByToken failed: ${err.message}`);
646
682
  return null;
647
683
  }
648
684
  }
685
+ /**
686
+ * Recover the link that led to this install, trying both mechanisms.
687
+ *
688
+ * The Play Install Referrer is asked first on Android: it names the exact
689
+ * click, survives for days, and does not care which network the device was
690
+ * on. Device signals are the fallback, and the only option on iOS, where no
691
+ * equivalent exists.
692
+ *
693
+ * Call once on first launch. Safe to call again, but a claim is consumed the
694
+ * first time it succeeds, so a second call returns null.
695
+ *
696
+ * Reading the referrer needs a native Play Services binding, which this
697
+ * package deliberately does not bundle. Pass `referrerProvider`, or install a
698
+ * supported referrer package and it is used automatically. Without either,
699
+ * Android falls back to signal matching.
700
+ */
701
+ async claimDeferredLink(options) {
702
+ if (!options.appspaceId || !options.appspaceId.trim()) {
703
+ throw new Error("Tolinku: appspaceId is required and must not be blank for claimDeferredLink.");
704
+ }
705
+ if (!options.force && await this.alreadyAttempted()) return null;
706
+ const token = await getInstallReferrerToken(options.referrerProvider);
707
+ if (token) {
708
+ const byToken = await this.claimByToken(token, options.appspaceId).catch(() => null);
709
+ if (byToken) {
710
+ await this.rememberAttempt();
711
+ return byToken;
712
+ }
713
+ }
714
+ const { link, settled } = await this.attemptSignals({ appspaceId: options.appspaceId });
715
+ if (settled) await this.rememberAttempt();
716
+ return link;
717
+ }
718
+ async alreadyAttempted() {
719
+ try {
720
+ return await AsyncStorage__default.default.getItem(CLAIMED_KEY) !== null;
721
+ } catch {
722
+ return false;
723
+ }
724
+ }
725
+ async rememberAttempt() {
726
+ try {
727
+ await AsyncStorage__default.default.setItem(CLAIMED_KEY, (/* @__PURE__ */ new Date()).toISOString());
728
+ } catch {
729
+ }
730
+ }
649
731
  /** Claim a deferred deep link by device signal matching */
650
732
  async claimBySignals(options) {
651
733
  if (!options.appspaceId || !options.appspaceId.trim()) {
652
734
  throw new Error("Tolinku: appspaceId is required and must not be blank for claimBySignals.");
653
735
  }
736
+ return (await this.attemptSignals(options)).link;
737
+ }
738
+ /**
739
+ * The signal claim, with whether the server actually answered.
740
+ *
741
+ * `settled` separates "nothing is waiting for this device", which no amount
742
+ * of asking will change, from "the request never got there". Both surface as
743
+ * null to callers of claimBySignals, but claimDeferredLink has to tell them
744
+ * apart: recording an attempt that never reached the server would spend an
745
+ * install's one chance at attribution on a dropped connection.
746
+ */
747
+ async attemptSignals(options) {
654
748
  try {
655
749
  const { width, height } = reactNative.Dimensions.get("screen");
656
750
  const resolvedTimezone = options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
657
751
  const resolvedLanguage = options.language || (typeof Intl !== "undefined" && typeof Intl.DateTimeFormat === "function" ? Intl.DateTimeFormat().resolvedOptions().locale : void 0) || "en";
658
- return await this.client.postPublic("/v1/api/deferred/claim-by-signals", {
752
+ const link = await this.client.postPublic("/v1/api/deferred/claim-by-signals", {
659
753
  appspace_id: options.appspaceId,
660
754
  timezone: resolvedTimezone,
661
755
  language: resolvedLanguage,
@@ -665,20 +759,21 @@ var Deferred = class {
665
759
  device_pixel_ratio: options.devicePixelRatio || reactNative.PixelRatio.get(),
666
760
  os_version: options.osVersion || String(reactNative.Platform.Version)
667
761
  });
762
+ return { link, settled: true };
668
763
  } catch (err) {
669
764
  const status = err?.statusCode ?? err?.status;
670
765
  if (status === 404) {
671
766
  debugWarn("Deferred claimBySignals: no match for this device.");
672
- return null;
767
+ return { link: null, settled: true };
673
768
  }
674
769
  if (status === 403) {
675
770
  console.warn(
676
771
  `[Tolinku] claimBySignals failed with HTTP 403. Check that appspaceId is your Appspace ID (copy it from the dashboard under Settings), not your subdomain or slug. ${err.message}`
677
772
  );
678
- return null;
773
+ return { link: null, settled: false };
679
774
  }
680
775
  debugWarn(`Deferred claimBySignals failed: ${err.message}`);
681
- return null;
776
+ return { link: null, settled: false };
682
777
  }
683
778
  }
684
779
  };
@@ -768,6 +863,17 @@ var _Tolinku = class _Tolinku {
768
863
  * If init() is called a second time without calling destroy() first,
769
864
  * a warning is logged and the existing instance is returned.
770
865
  */
866
+ /**
867
+ * Configure the SDK.
868
+ *
869
+ * The name the Android, iOS and Flutter SDKs use for this. {@link init} does
870
+ * the same thing and still works; it is what this package shipped and
871
+ * breaking it would serve nobody. It is meant for deprecation later, once
872
+ * moving off it is a one-line change rather than a surprise.
873
+ */
874
+ static configure(config) {
875
+ _Tolinku.init(config);
876
+ }
771
877
  static init(config) {
772
878
  if (!config.apiKey) throw new Error("Tolinku: apiKey is required");
773
879
  if (_Tolinku._initialized) {
@@ -1205,6 +1311,8 @@ function TolinkuMessages({
1205
1311
  exports.Tolinku = Tolinku;
1206
1312
  exports.TolinkuError = TolinkuError;
1207
1313
  exports.TolinkuMessages = TolinkuMessages;
1314
+ exports.getInstallReferrerToken = getInstallReferrerToken;
1208
1315
  exports.isSafeUrl = isSafeUrl;
1316
+ exports.parseInstallReferrer = parseInstallReferrer;
1209
1317
  //# sourceMappingURL=index.js.map
1210
1318
  //# sourceMappingURL=index.js.map