@dl2app/react-native 0.1.0 → 0.1.1

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
@@ -11,7 +11,7 @@ DL2 deep links for React Native and Expo.
11
11
  ## Requirements
12
12
 
13
13
  - React Native 0.74 or newer, old or new architecture.
14
- - Expo SDK 51 or newer, or a bare React Native app with Expo Modules installed.
14
+ - Expo SDK 51 or newer, or a bare React Native app with Expo Modules. Version 0.1.1 was compiled for Android and iOS in a fresh Expo SDK 57 app.
15
15
  - A development build. **Expo Go does not work**, because the SDK has native code and your app needs its own associated domains.
16
16
 
17
17
  ## Install
@@ -42,14 +42,14 @@ Plugin options:
42
42
  | --- | --- | --- |
43
43
  | `subdomain` | none | Your DL2 project subdomain. Adds `myapp.dl2.app` and `myapp-alt.dl2.app`. |
44
44
  | `domains` | `[]` | Custom domains. Without `subdomain`, list every host here, including the `-alt` one. |
45
- | `iosDeveloperMode` | `false` | Adds `?mode=developer` to the iOS entries. Use it for development builds only. |
45
+ | `iosDeveloperMode` | `false` | Adds `?mode=developer` to the iOS entries the plugin adds (entries you already list are left as they are). Use it for development builds only. |
46
46
  | `androidAutoVerify` | `true` | Adds `android:autoVerify="true"` to the intent filter. |
47
47
  | `uriScheme` | none | Also registers a custom scheme such as `myapp`. |
48
48
  | `rootDomain` | `dl2.app` | Only for self-hosted DL2. |
49
49
 
50
50
  You need `subdomain` or at least one entry in `domains`. The plugin stops the build with a message that says what to change when an option is wrong, for example a host written with `https://`.
51
51
 
52
- The plugin adds to `ios.associatedDomains`, `android.intentFilters` and `scheme`. It keeps the entries you already have and is safe to run any number of times. The Play Install Referrer library comes with the SDK's own Gradle file, so there is nothing to add for it.
52
+ The plugin adds to `ios.associatedDomains`, `android.intentFilters` and `scheme`. It keeps the entries you already have and is safe to run any number of times. An existing https intent filter only counts as covering a host when it is not limited to a path, port or type. The Play Install Referrer library comes with the SDK's own Gradle file, so there is nothing to add for it.
53
53
 
54
54
  Always include the `-alt` host. Pages on your main link host use it for their "Open in app" button: iOS only opens an app from a tap that crosses to a different domain.
55
55
 
@@ -140,6 +140,8 @@ type DL2LinkEvent = {
140
140
  - Links that arrive before any listener is registered are kept (up to 20) and delivered to the first listener.
141
141
  - The same URL within 2 seconds is delivered once.
142
142
  - If the DL2 API cannot be reached within 3 seconds, the event is built from the URL itself (`path` and `params` from the URL, empty `data`), so your app still navigates.
143
+ - A link in your own `uriScheme` is always built from the URL, without a request: DL2 only stores https links.
144
+ - Android: a link is not delivered again when the app is reopened from Recents after its process was stopped.
143
145
 
144
146
  ### `DL2.getInitialLink()` returns `Promise<DL2LinkEvent | null>`
145
147
 
@@ -156,7 +158,8 @@ Records an event, for example `DL2.track("purchase", { value: 19.99, currency: "
156
158
  - Returns at once. Events are sent in order in the background.
157
159
  - Offline, events wait in a queue that survives app restarts. The queue holds 100 events; beyond that the oldest are dropped.
158
160
  - Failed deliveries are retried after 2, 4, 8 seconds and so on, up to 5 minutes apart. `DL2.flush()` tries immediately.
159
- - Delivery is at least once: after a timeout an event can arrive twice. Events are stamped with the time the server receives them.
161
+ - Delivery is at least once: after a timeout an event can be sent twice. Each event carries an `event_id` made when it is queued, the same on every retry, so the server can count it once. Events are stamped with the time the server receives them.
162
+ - An event the server rejects (a 4xx answer other than 429) is dropped and reported through `onError`.
160
163
 
161
164
  ### `DL2.createLink({ path, data?, og?, utm? })` returns `Promise<{ url } | null>`
162
165
 
@@ -205,7 +208,9 @@ DL2.configure({ ..., deferred: { useClipboard: true } });
205
208
  1. `configure` loads or creates the install ID (stored in `UserDefaults` on iOS, `SharedPreferences` on Android).
206
209
  2. If the app was opened by a DL2 link, that link is used. On a first launch it is reported with `POST /v1/sdk/open` (`launch_url` set); later with `POST /v1/sdk/resolve`.
207
210
  3. Otherwise, on first launch only, the SDK asks `POST /v1/sdk/open` whether this install came from a link. Android sends the Play install referrer, which is exact. iOS is matched on network and device signals; DL2 returns nothing rather than risk giving a user someone else's link.
208
- 4. If the device is offline on first launch, the check is tried again on the next launch, three launches at most.
211
+ 4. If the device is offline on first launch, or the API answers 429 or 5xx, the check is tried again on the next launch, three launches at most.
212
+ 5. When the app was installed more than 24 hours ago (for example when the SDK arrives in an app update), the first-launch call carries `skip_fingerprint: true`: the install is recorded and exact matches (Play install referrer, clipboard) still work, but it is not matched on network and device signals.
213
+ 6. Android: when a backup or device transfer restores the SDK's storage into a new install, the restored install ID, first-launch flag and queued events are discarded and the launch counts as a first launch.
209
214
 
210
215
  ## Troubleshooting
211
216
 
@@ -266,7 +271,7 @@ What the SDK sends to DL2:
266
271
 
267
272
  The SDK does not read the IDFA or the Android advertising ID, does not use any other device identifier, does not track users across apps or sites, and DL2 does not sell or share this data. You do not need the App Tracking Transparency prompt for DL2.
268
273
 
269
- The package includes `ios/PrivacyInfo.xcprivacy` (no tracking, no tracking domains, `UserDefaults` reason `CA92.1`). Xcode merges it into your app's privacy report.
274
+ The package includes `ios/PrivacyInfo.xcprivacy` (no tracking, no tracking domains, `UserDefaults` reason `CA92.1`, file timestamp reason `C617.1`: the creation date of the app's own Library directory, read as the install time). Xcode merges it into your app's privacy report.
270
275
 
271
276
  ### App Store: App Privacy answers
272
277
 
@@ -1,7 +1,7 @@
1
1
  apply plugin: 'com.android.library'
2
2
 
3
3
  group = 'app.dl2.reactnative'
4
- version = '0.1.0'
4
+ version = '0.1.1'
5
5
 
6
6
  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
7
  apply from: expoModulesCorePlugin
@@ -14,7 +14,7 @@ android {
14
14
  namespace "app.dl2.reactnative"
15
15
  defaultConfig {
16
16
  versionCode 1
17
- versionName "0.1.0"
17
+ versionName "0.1.1"
18
18
  }
19
19
  }
20
20
 
@@ -1,6 +1,7 @@
1
1
  package app.dl2.reactnative
2
2
 
3
3
  import android.content.Context
4
+ import android.content.Intent
4
5
  import android.content.SharedPreferences
5
6
  import android.os.Build
6
7
  import com.android.installreferrer.api.InstallReferrerClient
@@ -14,7 +15,8 @@ import java.util.TimeZone
14
15
  import java.util.UUID
15
16
  import java.util.concurrent.atomic.AtomicBoolean
16
17
 
17
- // Key-value storage, device info and the Play Install Referrer. Nothing else.
18
+ // Key-value storage, device info, the install time, the launch intent's history flag and the
19
+ // Play Install Referrer. Nothing else.
18
20
  class DL2Module : Module() {
19
21
  private val context: Context
20
22
  get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
@@ -53,6 +55,20 @@ class DL2Module : Module() {
53
55
  )
54
56
  }
55
57
 
58
+ // When the app was installed, in ms since epoch. An update keeps it; a reinstall or a
59
+ // restore onto another device does not.
60
+ AsyncFunction("getInstalledAt") {
61
+ val ctx = context
62
+ ctx.packageManager.getPackageInfo(ctx.packageName, 0).firstInstallTime.toDouble()
63
+ }
64
+
65
+ // True when the activity was reopened from Recents: its intent is the one from the original
66
+ // launch, so the URL in it was already delivered.
67
+ AsyncFunction("isLaunchedFromHistory") {
68
+ val flags = appContext.currentActivity?.intent?.flags ?: 0
69
+ (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
70
+ }
71
+
56
72
  // Resolves with the referrer string, or null when Play has none. Read once, then cached.
57
73
  AsyncFunction("getInstallReferrer") { promise: Promise ->
58
74
  val cached = prefs.getString(REFERRER_KEY, null)
@@ -66,36 +82,47 @@ class DL2Module : Module() {
66
82
  promise.resolve(value)
67
83
  }
68
84
  }
85
+ var client: InstallReferrerClient? = null
86
+ val end = {
87
+ try {
88
+ client?.endConnection()
89
+ } catch (e: Exception) {
90
+ // already closed
91
+ }
92
+ }
69
93
  try {
70
- val client = InstallReferrerClient.newBuilder(context).build()
71
- client.startConnection(object : InstallReferrerStateListener {
94
+ val c = InstallReferrerClient.newBuilder(context).build()
95
+ client = c
96
+ c.startConnection(object : InstallReferrerStateListener {
72
97
  override fun onInstallReferrerSetupFinished(responseCode: Int) {
73
- var referrer: String? = null
74
- try {
75
- if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
76
- referrer = client.installReferrer?.installReferrer
77
- if (referrer != null) {
78
- prefs.edit().putString(REFERRER_KEY, referrer).apply()
79
- }
80
- }
81
- } catch (e: Exception) {
82
- referrer = null
83
- } finally {
98
+ // Called on the main thread, and reading the referrer is a call into the Play
99
+ // Store's process: do it on a thread of its own.
100
+ Thread {
101
+ var referrer: String? = null
84
102
  try {
85
- client.endConnection()
103
+ if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
104
+ referrer = c.installReferrer?.installReferrer
105
+ if (referrer != null) {
106
+ prefs.edit().putString(REFERRER_KEY, referrer).apply()
107
+ }
108
+ }
86
109
  } catch (e: Exception) {
87
- // already closed
110
+ referrer = null
111
+ } finally {
112
+ end()
88
113
  }
89
- }
90
- finish(referrer)
114
+ finish(referrer)
115
+ }.start()
91
116
  }
92
117
 
93
118
  override fun onInstallReferrerServiceDisconnected() {
119
+ end()
94
120
  finish(null)
95
121
  }
96
122
  })
97
123
  } catch (e: Exception) {
98
124
  // No Play Store on this device (emulator image, Huawei, sideloaded build).
125
+ end()
99
126
  finish(null)
100
127
  }
101
128
  }
@@ -51,7 +51,7 @@ export type ExpoConfigLike = {
51
51
  };
52
52
  /** Validates the plugin options and returns the full host list. Throws with a fix in the message. */
53
53
  export declare function normalizeOptions(raw: unknown): NormalizedOptions;
54
- /** Adds `applinks:` entries, keeping every entry the app already has. */
54
+ /** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
55
55
  export declare function mergeAssociatedDomains(existing: unknown, domains: string[], developerMode: boolean): string[];
56
56
  /** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
57
57
  export declare function mergeIntentFilters(existing: unknown, domains: string[], autoVerify: boolean, uriScheme: string | null): IntentFilter[];
@@ -94,16 +94,14 @@ function normalizeOptions(raw) {
94
94
  uriScheme,
95
95
  };
96
96
  }
97
- /** Adds `applinks:` entries, keeping every entry the app already has. */
97
+ /** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
98
98
  function mergeAssociatedDomains(existing, domains, developerMode) {
99
99
  const out = Array.isArray(existing) ? existing.filter((e) => typeof e === "string") : [];
100
100
  for (const d of domains) {
101
- const wanted = `applinks:${d}${developerMode ? "?mode=developer" : ""}`;
102
- const i = out.findIndex((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`);
103
- if (i < 0)
104
- out.push(wanted);
105
- else
106
- out[i] = wanted; // same host already listed: only the mode can differ
101
+ // A host the app already lists keeps its own mode: the option only applies to entries added here.
102
+ if (out.some((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`))
103
+ continue;
104
+ out.push(`applinks:${d}${developerMode ? "?mode=developer" : ""}`);
107
105
  }
108
106
  return out;
109
107
  }
@@ -113,6 +111,11 @@ function list(v) {
113
111
  function isBrowsableView(f) {
114
112
  return f.action === "VIEW" && list(f.category).indexOf("BROWSABLE") >= 0;
115
113
  }
114
+ const RESTRICTIONS = ["port", "path", "pathPrefix", "pathPattern", "pathSuffix", "pathAdvancedPattern", "mimeType"];
115
+ /** Android applies every path, port and type in a filter to all of its hosts, so one of them narrows the whole filter. */
116
+ function isRestricted(f) {
117
+ return list(f.data).some((d) => RESTRICTIONS.some((k) => d[k] !== undefined && d[k] !== null && d[k] !== ""));
118
+ }
116
119
  /** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
117
120
  function mergeIntentFilters(existing, domains, autoVerify, uriScheme) {
118
121
  const out = Array.isArray(existing) ? existing.slice() : [];
@@ -121,8 +124,10 @@ function mergeIntentFilters(existing, domains, autoVerify, uriScheme) {
121
124
  for (const f of out) {
122
125
  if (!f || !isBrowsableView(f))
123
126
  continue;
127
+ // A filter limited to some paths does not open every link of the host.
128
+ const open = !isRestricted(f);
124
129
  for (const d of list(f.data)) {
125
- if (d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true))
130
+ if (open && d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true))
126
131
  covered.add(d.host.toLowerCase());
127
132
  if (d.scheme && !d.host)
128
133
  schemes.add(d.scheme);
@@ -1,5 +1,5 @@
1
1
  import type { DL2Config, DL2CreateLinkInput, DL2DebugInfo, DL2Deps, DL2LinkEvent, DL2TrackOptions } from "./types";
2
- export declare const SDK_VERSION = "0.1.0";
2
+ export declare const SDK_VERSION = "0.1.1";
3
3
  type Listener = (event: DL2LinkEvent) => void;
4
4
  type Parsed = {
5
5
  scheme: string;
package/build/src/core.js CHANGED
@@ -4,12 +4,15 @@ exports.SDK_VERSION = void 0;
4
4
  exports.parseUrl = parseUrl;
5
5
  exports.extractClipboardToken = extractClipboardToken;
6
6
  exports.createDL2 = createDL2;
7
- exports.SDK_VERSION = "0.1.0";
7
+ exports.SDK_VERSION = "0.1.1";
8
8
  const K_ID = "install_id";
9
9
  const K_DONE = "first_launch_done";
10
10
  const K_ATTEMPTS = "open_attempts";
11
11
  const K_QUEUE = "event_queue";
12
12
  const K_REFERRER = "install_referrer";
13
+ const K_INSTALLED_AT = "installed_at";
14
+ // Everything that belongs to one install. Dropped when storage turns out to come from another.
15
+ const INSTALL_KEYS = [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER];
13
16
  const RESOLVE_TIMEOUT_MS = 3000;
14
17
  const EVENT_TIMEOUT_MS = 5000;
15
18
  const REFERRER_TIMEOUT_MS = 1500;
@@ -19,6 +22,8 @@ const MAX_PENDING_LINKS = 20;
19
22
  const MAX_OPEN_ATTEMPTS = 3;
20
23
  const RETRY_BASE_MS = 2000;
21
24
  const RETRY_MAX_MS = 300000;
25
+ // The server's longest fingerprint window. An older install can only be matched exactly.
26
+ const MAX_MATCH_WINDOW_MS = 24 * 60 * 60 * 1000;
22
27
  function mkErr(code, message, cause, status) {
23
28
  const e = { code, message };
24
29
  if (cause !== undefined)
@@ -89,11 +94,14 @@ function createDL2(deps) {
89
94
  let gen = 0;
90
95
  let installId = null;
91
96
  let firstLaunch = false;
97
+ let skipFingerprint = false;
92
98
  let storageOk = true;
93
99
  let matched = false;
94
100
  let info = null;
95
101
  let ready = null;
96
102
  let initial = null;
103
+ // The first-launch check that runs beside a launch link in the app's own scheme.
104
+ let recording = null;
97
105
  let sub = null;
98
106
  const listeners = new Set();
99
107
  let pending = [];
@@ -350,7 +358,7 @@ function createDL2(deps) {
350
358
  info = { osVersion: native.osVersion || "", appId: native.appId || "", appVersion: native.appVersion || "", lang: lang || "", tz: tz || "" };
351
359
  return info;
352
360
  }
353
- /** Request body of POST /v1/sdk/open. Every field is always present. */
361
+ /** Request body of POST /v1/sdk/open. Every field is always present, except skip_fingerprint. */
354
362
  async function openBody(extra) {
355
363
  const d = await deviceInfo();
356
364
  let screen = { w: 0, h: 0, dpr: 1 };
@@ -360,7 +368,7 @@ function createDL2(deps) {
360
368
  catch {
361
369
  /* keep defaults */
362
370
  }
363
- return {
371
+ const body = {
364
372
  install_id: installId,
365
373
  is_first_launch: firstLaunch,
366
374
  platform: deps.platform,
@@ -375,10 +383,14 @@ function createDL2(deps) {
375
383
  clipboard_token: extra.clipboard_token || null,
376
384
  launch_url: extra.launch_url || null,
377
385
  };
386
+ return skipFingerprint ? { ...body, skip_fingerprint: true } : body;
378
387
  }
379
- /** True when the server answered, so there is no point asking again on the next launch. */
388
+ /**
389
+ * True when the server gave a final answer, so there is no point asking again on the next
390
+ * launch. A 429 or 5xx is not one: like the native SDKs, the check is tried on a later launch.
391
+ */
380
392
  function answered(e) {
381
- return !(isErr(e) && (e.code === "network" || e.code === "timeout"));
393
+ return !retryable(e);
382
394
  }
383
395
  async function finishFirstLaunch(serverAnswered) {
384
396
  if (!firstLaunch || !storageOk)
@@ -389,7 +401,7 @@ function createDL2(deps) {
389
401
  await removeItem(K_ATTEMPTS);
390
402
  }
391
403
  else {
392
- // Offline on first launch: ask again next launch, a few times at most.
404
+ // Offline or the server is struggling: ask again next launch, a few times at most.
393
405
  await setItem(K_ATTEMPTS, String(attempts));
394
406
  }
395
407
  }
@@ -412,6 +424,15 @@ function createDL2(deps) {
412
424
  if (ready)
413
425
  await ready;
414
426
  const first = isInitial && firstLaunch;
427
+ if (source === "uri_scheme") {
428
+ // The server only knows https links: the event comes from the URL, with no round trip.
429
+ // On a first launch runInitial() still records the install.
430
+ if (g !== gen)
431
+ return null;
432
+ const event = toEvent(null, url, source, first);
433
+ emit(event);
434
+ return event;
435
+ }
415
436
  let payload = null;
416
437
  let ok = false;
417
438
  try {
@@ -422,7 +443,7 @@ function createDL2(deps) {
422
443
  ok = true;
423
444
  }
424
445
  else {
425
- payload = await call("/v1/sdk/resolve", { url, source }, RESOLVE_TIMEOUT_MS);
446
+ payload = await call("/v1/sdk/resolve", { url, source, install_id: installId }, RESOLVE_TIMEOUT_MS);
426
447
  lastResolveResponse = payload;
427
448
  }
428
449
  }
@@ -444,7 +465,32 @@ function createDL2(deps) {
444
465
  });
445
466
  return lastHandled;
446
467
  }
468
+ /** Install time from the native module, in ms since epoch. Null when it cannot be read. */
469
+ async function installedAt() {
470
+ try {
471
+ const t = deps.native && deps.native.getInstalledAt ? await deps.native.getInstalledAt() : null;
472
+ return typeof t === "number" && isFinite(t) && t > 0 ? t : null;
473
+ }
474
+ catch {
475
+ return null;
476
+ }
477
+ }
447
478
  async function init(g) {
479
+ const installed = await installedAt();
480
+ // Android backup and device transfer restore the SDK's storage into a new install. The
481
+ // install time gives that away: start over, or the deferred link is lost and two devices
482
+ // share one install_id. iOS has no real install time, only a directory date, so it is not
483
+ // trusted for this.
484
+ if (installed !== null && deps.platform === "android") {
485
+ const stamp = await getItem(K_INSTALLED_AT);
486
+ if (stamp !== null && stamp !== String(installed)) {
487
+ log("storage was restored from another install: starting fresh");
488
+ for (const k of INSTALL_KEYS)
489
+ await removeItem(k);
490
+ }
491
+ if (stamp !== String(installed) && storageOk)
492
+ await setItem(K_INSTALLED_AT, String(installed));
493
+ }
448
494
  let id = await getItem(K_ID);
449
495
  if (!id) {
450
496
  try {
@@ -470,8 +516,17 @@ function createDL2(deps) {
470
516
  // Without working storage every launch would look like the first one and could claim
471
517
  // someone else's link, so deferred matching is skipped.
472
518
  firstLaunch = storageOk && !!deps.native && done !== "1";
473
- if (Array.isArray(stored))
474
- queue = stored.concat(queue).slice(-MAX_QUEUED_EVENTS);
519
+ // Installed long ago and only now running the SDK (it shipped in an app update): a
520
+ // fingerprint match could only be somebody else's click. Exact matches still apply.
521
+ skipFingerprint = installed !== null && now() - installed > MAX_MATCH_WINDOW_MS;
522
+ if (Array.isArray(stored)) {
523
+ const old = stored.filter((e) => !!e && typeof e === "object" && typeof e.name === "string");
524
+ // Events stored by a version that had no event_id get one now.
525
+ for (const e of old)
526
+ if (typeof e.event_id !== "string" || !e.event_id)
527
+ e.event_id = fallbackUuid(random);
528
+ queue = old.concat(queue).slice(-MAX_QUEUED_EVENTS);
529
+ }
475
530
  if (!deps.native) {
476
531
  report(mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."), "configure");
477
532
  }
@@ -489,11 +544,37 @@ function createDL2(deps) {
489
544
  }
490
545
  if (g !== gen || !cfg)
491
546
  return null;
547
+ if (url && deps.platform === "android" && deps.native && deps.native.isLaunchedFromHistory) {
548
+ // Reopened from Recents after the process died: Android hands back the intent of the
549
+ // original launch, and its link was already delivered then.
550
+ try {
551
+ if (await deps.native.isLaunchedFromHistory())
552
+ url = null;
553
+ }
554
+ catch {
555
+ /* unknown: treat the URL as new */
556
+ }
557
+ if (g !== gen || !cfg)
558
+ return null;
559
+ }
560
+ const parsed = url ? parseUrl(url) : null;
492
561
  const handled = url ? handleUrl(url, true) : undefined;
493
- if (handled)
562
+ if (handled) {
563
+ if (firstLaunch && parsed && sourceOf(parsed) === "uri_scheme") {
564
+ // Handled without the server, so the install is recorded by the usual first-launch
565
+ // call. The app already has its link: a deferred match is not delivered on top of it.
566
+ recording = firstOpen(g, false).then(() => undefined, (e) => report(e, "configure"));
567
+ }
494
568
  return handled;
569
+ }
495
570
  if (!firstLaunch)
496
571
  return null;
572
+ return firstOpen(g, true);
573
+ }
574
+ /** The first-launch POST /v1/sdk/open without a launch URL: records the install and asks for a deferred link. */
575
+ async function firstOpen(g, deliver) {
576
+ if (!cfg)
577
+ return null;
497
578
  if (!cfg.deferred) {
498
579
  await finishFirstLaunch(true);
499
580
  return null;
@@ -518,7 +599,10 @@ function createDL2(deps) {
518
599
  ok = true;
519
600
  if (g === gen && r && r.match) {
520
601
  matched = true;
521
- event = toEvent(r.match, null, "deferred", true);
602
+ if (deliver)
603
+ event = toEvent(r.match, null, "deferred", true);
604
+ else
605
+ log("deferred match not delivered: the app was opened by a link");
522
606
  }
523
607
  }
524
608
  catch (e) {
@@ -559,6 +643,8 @@ function createDL2(deps) {
559
643
  // Wait for the launch check so the server knows this install before its first event.
560
644
  if (initial)
561
645
  await initial;
646
+ if (recording)
647
+ await recording;
562
648
  while (queue.length && g === gen && cfg) {
563
649
  const e = queue[0];
564
650
  try {
@@ -694,7 +780,10 @@ function createDL2(deps) {
694
780
  if (!p)
695
781
  throw mkErr("link_not_found", "resolve() needs a full URL such as https://myapp.dl2.app/abc");
696
782
  const source = sourceOf(p);
697
- const payload = await call("/v1/sdk/resolve", { url, source }, RESOLVE_TIMEOUT_MS);
783
+ // The app's own scheme never reaches the server: it has no such links.
784
+ if (source === "uri_scheme" && isOurs(p))
785
+ return toEvent(null, url, source, false);
786
+ const payload = await call("/v1/sdk/resolve", { url, source, install_id: installId }, RESOLVE_TIMEOUT_MS);
698
787
  lastResolveResponse = payload;
699
788
  return toEvent(payload, url, source, false);
700
789
  });
@@ -706,7 +795,7 @@ function createDL2(deps) {
706
795
  if (typeof name !== "string" || !name.trim())
707
796
  throw mkErr("unknown", "track() needs an event name");
708
797
  const o = options || {};
709
- const e = { name: name.trim() };
798
+ const e = { event_id: fallbackUuid(random), name: name.trim() };
710
799
  if (typeof o.value === "number" && isFinite(o.value))
711
800
  e.value = o.value;
712
801
  if (typeof o.currency === "string" && o.currency)
@@ -717,7 +806,9 @@ function createDL2(deps) {
717
806
  if (queue.length > MAX_QUEUED_EVENTS)
718
807
  queue.shift(); // bounded: the oldest event goes first
719
808
  persistQueue();
720
- flush().catch(() => undefined);
809
+ // While a retry is waiting, the new event goes out with it: the backoff holds.
810
+ if (!retryTimer)
811
+ flush().catch(() => undefined);
721
812
  });
722
813
  }
723
814
  function createLink(input) {
@@ -726,7 +817,18 @@ function createDL2(deps) {
726
817
  throw mkErr("not_configured", "Call DL2.configure() before createLink()");
727
818
  if (!input || typeof input.path !== "string")
728
819
  throw mkErr("unknown", "createLink() needs a path");
729
- const r = await call("/v1/sdk/links", input, EVENT_TIMEOUT_MS);
820
+ // The API takes the preview fields flat (og_title, …), not as the nested `og` object.
821
+ const { og, ...rest } = input;
822
+ const body = { ...rest };
823
+ if (og && typeof og === "object") {
824
+ if (og.title)
825
+ body.og_title = og.title;
826
+ if (og.description)
827
+ body.og_description = og.description;
828
+ if (og.image)
829
+ body.og_image_url = og.image;
830
+ }
831
+ const r = await call("/v1/sdk/links", body, EVENT_TIMEOUT_MS);
730
832
  if (!r || typeof r.url !== "string")
731
833
  throw mkErr("unknown", "The server did not return a link URL");
732
834
  return { url: r.url };
@@ -779,8 +881,10 @@ function createDL2(deps) {
779
881
  cfg = null;
780
882
  ready = null;
781
883
  initial = null;
884
+ recording = null;
782
885
  installId = null;
783
886
  firstLaunch = false;
887
+ skipFingerprint = false;
784
888
  storageOk = true;
785
889
  matched = false;
786
890
  info = null;
@@ -791,7 +895,7 @@ function createDL2(deps) {
791
895
  lastHandled = null;
792
896
  lastOpenResponse = null;
793
897
  lastResolveResponse = null;
794
- for (const k of [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER])
898
+ for (const k of [...INSTALL_KEYS, K_INSTALLED_AT])
795
899
  await removeItem(k);
796
900
  });
797
901
  }
@@ -83,6 +83,16 @@ export interface DL2Native {
83
83
  /** Android: Play Install Referrer string. iOS: always null. */
84
84
  getInstallReferrer(): Promise<string | null>;
85
85
  getDeviceInfo(): Promise<DL2DeviceInfo>;
86
+ /**
87
+ * When the app was installed, in ms since epoch, or null when unknown. Android: the package's
88
+ * firstInstallTime. iOS: the creation date of the app's data container.
89
+ */
90
+ getInstalledAt?(): Promise<number | null>;
91
+ /**
92
+ * Android: true when the activity was reopened from Recents, so its intent (and the URL in it)
93
+ * is the one from the original launch. iOS does not have it.
94
+ */
95
+ isLaunchedFromHistory?(): Promise<boolean>;
86
96
  uuid?(): Promise<string>;
87
97
  }
88
98
  export type DL2FetchResponse = {
@@ -1,7 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import UIKit
3
3
 
4
- // Key-value storage, device info and the system paste button. Nothing else.
4
+ // Key-value storage, device info, the install time and the system paste button. Nothing else.
5
5
  // The clipboard is never read here: text only arrives when the user taps DL2PasteButtonView.
6
6
  public class DL2Module: Module {
7
7
  private var store: UserDefaults {
@@ -43,6 +43,15 @@ public class DL2Module: Module {
43
43
  ]
44
44
  }
45
45
 
46
+ // When the app was installed, in ms since epoch. iOS has no API for it; the data
47
+ // container's Library directory is created with the install. Nil when it cannot be read.
48
+ AsyncFunction("getInstalledAt") { () -> Double? in
49
+ guard let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first,
50
+ let created = (try? FileManager.default.attributesOfItem(atPath: dir.path))?[.creationDate] as? Date
51
+ else { return nil }
52
+ return (created.timeIntervalSince1970 * 1000).rounded()
53
+ }
54
+
46
55
  View(DL2PasteButtonView.self) {
47
56
  Events("onPaste")
48
57
  }
@@ -3,7 +3,7 @@ require 'json'
3
3
  package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
4
 
5
5
  Pod::Spec.new do |s|
6
- s.name = 'DL2'
6
+ s.name = 'DL2ReactNative'
7
7
  s.version = package['version']
8
8
  s.summary = package['description']
9
9
  s.description = package['description']
@@ -18,5 +18,5 @@ Pod::Spec.new do |s|
18
18
  s.dependency 'ExpoModulesCore'
19
19
 
20
20
  s.source_files = '*.swift'
21
- s.resource_bundles = { 'DL2_Privacy' => ['PrivacyInfo.xcprivacy'] }
21
+ s.resource_bundles = { 'DL2ReactNative_Privacy' => ['PrivacyInfo.xcprivacy'] }
22
22
  end
@@ -62,6 +62,15 @@
62
62
  <string>CA92.1</string>
63
63
  </array>
64
64
  </dict>
65
+ <!-- File timestamp: the creation date of the app's own Library directory, read as the install time. -->
66
+ <dict>
67
+ <key>NSPrivacyAccessedAPIType</key>
68
+ <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
69
+ <key>NSPrivacyAccessedAPITypeReasons</key>
70
+ <array>
71
+ <string>C617.1</string>
72
+ </array>
73
+ </dict>
65
74
  </array>
66
75
  </dict>
67
76
  </plist>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dl2app/react-native",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DL2App deep links for React Native and Expo: universal links, app links, deferred deep links and events.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://dl2.app/docs",
@@ -120,14 +120,13 @@ export function normalizeOptions(raw: unknown): NormalizedOptions {
120
120
  };
121
121
  }
122
122
 
123
- /** Adds `applinks:` entries, keeping every entry the app already has. */
123
+ /** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
124
124
  export function mergeAssociatedDomains(existing: unknown, domains: string[], developerMode: boolean): string[] {
125
125
  const out: string[] = Array.isArray(existing) ? existing.filter((e): e is string => typeof e === "string") : [];
126
126
  for (const d of domains) {
127
- const wanted = `applinks:${d}${developerMode ? "?mode=developer" : ""}`;
128
- const i = out.findIndex((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`);
129
- if (i < 0) out.push(wanted);
130
- else out[i] = wanted; // same host already listed: only the mode can differ
127
+ // A host the app already lists keeps its own mode: the option only applies to entries added here.
128
+ if (out.some((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`)) continue;
129
+ out.push(`applinks:${d}${developerMode ? "?mode=developer" : ""}`);
131
130
  }
132
131
  return out;
133
132
  }
@@ -140,6 +139,13 @@ function isBrowsableView(f: IntentFilter): boolean {
140
139
  return f.action === "VIEW" && list(f.category).indexOf("BROWSABLE") >= 0;
141
140
  }
142
141
 
142
+ const RESTRICTIONS = ["port", "path", "pathPrefix", "pathPattern", "pathSuffix", "pathAdvancedPattern", "mimeType"];
143
+
144
+ /** Android applies every path, port and type in a filter to all of its hosts, so one of them narrows the whole filter. */
145
+ function isRestricted(f: IntentFilter): boolean {
146
+ return list(f.data).some((d) => RESTRICTIONS.some((k) => d[k] !== undefined && d[k] !== null && d[k] !== ""));
147
+ }
148
+
143
149
  /** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
144
150
  export function mergeIntentFilters(existing: unknown, domains: string[], autoVerify: boolean, uriScheme: string | null): IntentFilter[] {
145
151
  const out: IntentFilter[] = Array.isArray(existing) ? (existing as IntentFilter[]).slice() : [];
@@ -147,8 +153,10 @@ export function mergeIntentFilters(existing: unknown, domains: string[], autoVer
147
153
  const schemes = new Set<string>();
148
154
  for (const f of out) {
149
155
  if (!f || !isBrowsableView(f)) continue;
156
+ // A filter limited to some paths does not open every link of the host.
157
+ const open = !isRestricted(f);
150
158
  for (const d of list(f.data)) {
151
- if (d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true)) covered.add(d.host.toLowerCase());
159
+ if (open && d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true)) covered.add(d.host.toLowerCase());
152
160
  if (d.scheme && !d.host) schemes.add(d.scheme);
153
161
  }
154
162
  }
package/src/core.ts CHANGED
@@ -11,13 +11,16 @@ import type {
11
11
  DL2TrackOptions,
12
12
  } from "./types";
13
13
 
14
- export const SDK_VERSION = "0.1.0";
14
+ export const SDK_VERSION = "0.1.1";
15
15
 
16
16
  const K_ID = "install_id";
17
17
  const K_DONE = "first_launch_done";
18
18
  const K_ATTEMPTS = "open_attempts";
19
19
  const K_QUEUE = "event_queue";
20
20
  const K_REFERRER = "install_referrer";
21
+ const K_INSTALLED_AT = "installed_at";
22
+ // Everything that belongs to one install. Dropped when storage turns out to come from another.
23
+ const INSTALL_KEYS = [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER];
21
24
 
22
25
  const RESOLVE_TIMEOUT_MS = 3000;
23
26
  const EVENT_TIMEOUT_MS = 5000;
@@ -28,9 +31,13 @@ const MAX_PENDING_LINKS = 20;
28
31
  const MAX_OPEN_ATTEMPTS = 3;
29
32
  const RETRY_BASE_MS = 2000;
30
33
  const RETRY_MAX_MS = 300000;
34
+ // The server's longest fingerprint window. An older install can only be matched exactly.
35
+ const MAX_MATCH_WINDOW_MS = 24 * 60 * 60 * 1000;
31
36
 
32
37
  type Listener = (event: DL2LinkEvent) => void;
33
- type QueuedEvent = { name: string; value?: number; currency?: string; props?: Record<string, unknown> };
38
+ // event_id is made once, when the event is queued: a retry of a request that did arrive sends
39
+ // the same id, so the server can count the event once.
40
+ type QueuedEvent = { event_id?: string; name: string; value?: number; currency?: string; props?: Record<string, unknown> };
34
41
  type Parsed = { scheme: string; host: string; path: string; params: Record<string, string> };
35
42
  type Payload = {
36
43
  source?: string;
@@ -123,11 +130,14 @@ export function createDL2(deps: DL2Deps) {
123
130
  let gen = 0;
124
131
  let installId: string | null = null;
125
132
  let firstLaunch = false;
133
+ let skipFingerprint = false;
126
134
  let storageOk = true;
127
135
  let matched = false;
128
136
  let info: DL2DeviceInfo | null = null;
129
137
  let ready: Promise<void> | null = null;
130
138
  let initial: Promise<DL2LinkEvent | null> | null = null;
139
+ // The first-launch check that runs beside a launch link in the app's own scheme.
140
+ let recording: Promise<void> | null = null;
131
141
  let sub: { remove(): void } | null = null;
132
142
  const listeners = new Set<Listener>();
133
143
  let pending: DL2LinkEvent[] = [];
@@ -373,7 +383,7 @@ export function createDL2(deps: DL2Deps) {
373
383
  return info;
374
384
  }
375
385
 
376
- /** Request body of POST /v1/sdk/open. Every field is always present. */
386
+ /** Request body of POST /v1/sdk/open. Every field is always present, except skip_fingerprint. */
377
387
  async function openBody(extra: { install_referrer?: string | null; clipboard_token?: string | null; launch_url?: string | null }) {
378
388
  const d = await deviceInfo();
379
389
  let screen = { w: 0, h: 0, dpr: 1 };
@@ -382,7 +392,7 @@ export function createDL2(deps: DL2Deps) {
382
392
  } catch {
383
393
  /* keep defaults */
384
394
  }
385
- return {
395
+ const body = {
386
396
  install_id: installId,
387
397
  is_first_launch: firstLaunch,
388
398
  platform: deps.platform,
@@ -397,11 +407,15 @@ export function createDL2(deps: DL2Deps) {
397
407
  clipboard_token: extra.clipboard_token || null,
398
408
  launch_url: extra.launch_url || null,
399
409
  };
410
+ return skipFingerprint ? { ...body, skip_fingerprint: true } : body;
400
411
  }
401
412
 
402
- /** True when the server answered, so there is no point asking again on the next launch. */
413
+ /**
414
+ * True when the server gave a final answer, so there is no point asking again on the next
415
+ * launch. A 429 or 5xx is not one: like the native SDKs, the check is tried on a later launch.
416
+ */
403
417
  function answered(e: unknown): boolean {
404
- return !(isErr(e) && (e.code === "network" || e.code === "timeout"));
418
+ return !retryable(e);
405
419
  }
406
420
 
407
421
  async function finishFirstLaunch(serverAnswered: boolean) {
@@ -411,7 +425,7 @@ export function createDL2(deps: DL2Deps) {
411
425
  await setItem(K_DONE, "1");
412
426
  await removeItem(K_ATTEMPTS);
413
427
  } else {
414
- // Offline on first launch: ask again next launch, a few times at most.
428
+ // Offline or the server is struggling: ask again next launch, a few times at most.
415
429
  await setItem(K_ATTEMPTS, String(attempts));
416
430
  }
417
431
  }
@@ -432,6 +446,14 @@ export function createDL2(deps: DL2Deps) {
432
446
  lastHandled = (async () => {
433
447
  if (ready) await ready;
434
448
  const first = isInitial && firstLaunch;
449
+ if (source === "uri_scheme") {
450
+ // The server only knows https links: the event comes from the URL, with no round trip.
451
+ // On a first launch runInitial() still records the install.
452
+ if (g !== gen) return null;
453
+ const event = toEvent(null, url, source, first);
454
+ emit(event);
455
+ return event;
456
+ }
435
457
  let payload: Payload | null = null;
436
458
  let ok = false;
437
459
  try {
@@ -441,7 +463,7 @@ export function createDL2(deps: DL2Deps) {
441
463
  payload = (r && r.match) || null;
442
464
  ok = true;
443
465
  } else {
444
- payload = await call("/v1/sdk/resolve", { url, source }, RESOLVE_TIMEOUT_MS);
466
+ payload = await call("/v1/sdk/resolve", { url, source, install_id: installId }, RESOLVE_TIMEOUT_MS);
445
467
  lastResolveResponse = payload;
446
468
  }
447
469
  } catch (e) {
@@ -461,7 +483,30 @@ export function createDL2(deps: DL2Deps) {
461
483
  return lastHandled;
462
484
  }
463
485
 
486
+ /** Install time from the native module, in ms since epoch. Null when it cannot be read. */
487
+ async function installedAt(): Promise<number | null> {
488
+ try {
489
+ const t = deps.native && deps.native.getInstalledAt ? await deps.native.getInstalledAt() : null;
490
+ return typeof t === "number" && isFinite(t) && t > 0 ? t : null;
491
+ } catch {
492
+ return null;
493
+ }
494
+ }
495
+
464
496
  async function init(g: number) {
497
+ const installed = await installedAt();
498
+ // Android backup and device transfer restore the SDK's storage into a new install. The
499
+ // install time gives that away: start over, or the deferred link is lost and two devices
500
+ // share one install_id. iOS has no real install time, only a directory date, so it is not
501
+ // trusted for this.
502
+ if (installed !== null && deps.platform === "android") {
503
+ const stamp = await getItem(K_INSTALLED_AT);
504
+ if (stamp !== null && stamp !== String(installed)) {
505
+ log("storage was restored from another install: starting fresh");
506
+ for (const k of INSTALL_KEYS) await removeItem(k);
507
+ }
508
+ if (stamp !== String(installed) && storageOk) await setItem(K_INSTALLED_AT, String(installed));
509
+ }
465
510
  let id = await getItem(K_ID);
466
511
  if (!id) {
467
512
  try {
@@ -484,7 +529,15 @@ export function createDL2(deps: DL2Deps) {
484
529
  // Without working storage every launch would look like the first one and could claim
485
530
  // someone else's link, so deferred matching is skipped.
486
531
  firstLaunch = storageOk && !!deps.native && done !== "1";
487
- if (Array.isArray(stored)) queue = (stored as QueuedEvent[]).concat(queue).slice(-MAX_QUEUED_EVENTS);
532
+ // Installed long ago and only now running the SDK (it shipped in an app update): a
533
+ // fingerprint match could only be somebody else's click. Exact matches still apply.
534
+ skipFingerprint = installed !== null && now() - installed > MAX_MATCH_WINDOW_MS;
535
+ if (Array.isArray(stored)) {
536
+ const old = (stored as QueuedEvent[]).filter((e) => !!e && typeof e === "object" && typeof e.name === "string");
537
+ // Events stored by a version that had no event_id get one now.
538
+ for (const e of old) if (typeof e.event_id !== "string" || !e.event_id) e.event_id = fallbackUuid(random);
539
+ queue = old.concat(queue).slice(-MAX_QUEUED_EVENTS);
540
+ }
488
541
  if (!deps.native) {
489
542
  report(
490
543
  mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."),
@@ -503,9 +556,36 @@ export function createDL2(deps: DL2Deps) {
503
556
  report(mkErr("unknown", "Linking.getInitialURL failed", e), "configure");
504
557
  }
505
558
  if (g !== gen || !cfg) return null;
559
+ if (url && deps.platform === "android" && deps.native && deps.native.isLaunchedFromHistory) {
560
+ // Reopened from Recents after the process died: Android hands back the intent of the
561
+ // original launch, and its link was already delivered then.
562
+ try {
563
+ if (await deps.native.isLaunchedFromHistory()) url = null;
564
+ } catch {
565
+ /* unknown: treat the URL as new */
566
+ }
567
+ if (g !== gen || !cfg) return null;
568
+ }
569
+ const parsed = url ? parseUrl(url) : null;
506
570
  const handled = url ? handleUrl(url, true) : undefined;
507
- if (handled) return handled;
571
+ if (handled) {
572
+ if (firstLaunch && parsed && sourceOf(parsed) === "uri_scheme") {
573
+ // Handled without the server, so the install is recorded by the usual first-launch
574
+ // call. The app already has its link: a deferred match is not delivered on top of it.
575
+ recording = firstOpen(g, false).then(
576
+ () => undefined,
577
+ (e) => report(e, "configure"),
578
+ );
579
+ }
580
+ return handled;
581
+ }
508
582
  if (!firstLaunch) return null;
583
+ return firstOpen(g, true);
584
+ }
585
+
586
+ /** The first-launch POST /v1/sdk/open without a launch URL: records the install and asks for a deferred link. */
587
+ async function firstOpen(g: number, deliver: boolean): Promise<DL2LinkEvent | null> {
588
+ if (!cfg) return null;
509
589
  if (!cfg.deferred) {
510
590
  await finishFirstLaunch(true);
511
591
  return null;
@@ -529,7 +609,8 @@ export function createDL2(deps: DL2Deps) {
529
609
  ok = true;
530
610
  if (g === gen && r && r.match) {
531
611
  matched = true;
532
- event = toEvent(r.match, null, "deferred", true);
612
+ if (deliver) event = toEvent(r.match, null, "deferred", true);
613
+ else log("deferred match not delivered: the app was opened by a link");
533
614
  }
534
615
  } catch (e) {
535
616
  ok = answered(e);
@@ -567,6 +648,7 @@ export function createDL2(deps: DL2Deps) {
567
648
  flushing = (async () => {
568
649
  // Wait for the launch check so the server knows this install before its first event.
569
650
  if (initial) await initial;
651
+ if (recording) await recording;
570
652
  while (queue.length && g === gen && cfg) {
571
653
  const e = queue[0];
572
654
  try {
@@ -698,7 +780,9 @@ export function createDL2(deps: DL2Deps) {
698
780
  const p = parseUrl(url);
699
781
  if (!p) throw mkErr("link_not_found", "resolve() needs a full URL such as https://myapp.dl2.app/abc");
700
782
  const source = sourceOf(p);
701
- const payload = await call("/v1/sdk/resolve", { url, source }, RESOLVE_TIMEOUT_MS);
783
+ // The app's own scheme never reaches the server: it has no such links.
784
+ if (source === "uri_scheme" && isOurs(p)) return toEvent(null, url, source, false);
785
+ const payload = await call("/v1/sdk/resolve", { url, source, install_id: installId }, RESOLVE_TIMEOUT_MS);
702
786
  lastResolveResponse = payload;
703
787
  return toEvent(payload, url, source, false);
704
788
  });
@@ -709,14 +793,15 @@ export function createDL2(deps: DL2Deps) {
709
793
  if (!cfg) throw mkErr("not_configured", "Call DL2.configure() before track()");
710
794
  if (typeof name !== "string" || !name.trim()) throw mkErr("unknown", "track() needs an event name");
711
795
  const o = options || {};
712
- const e: QueuedEvent = { name: name.trim() };
796
+ const e: QueuedEvent = { event_id: fallbackUuid(random), name: name.trim() };
713
797
  if (typeof o.value === "number" && isFinite(o.value)) e.value = o.value;
714
798
  if (typeof o.currency === "string" && o.currency) e.currency = o.currency.toUpperCase();
715
799
  if (o.props && typeof o.props === "object") e.props = JSON.parse(JSON.stringify(o.props));
716
800
  queue.push(e);
717
801
  if (queue.length > MAX_QUEUED_EVENTS) queue.shift(); // bounded: the oldest event goes first
718
802
  persistQueue();
719
- flush().catch(() => undefined);
803
+ // While a retry is waiting, the new event goes out with it: the backoff holds.
804
+ if (!retryTimer) flush().catch(() => undefined);
720
805
  });
721
806
  }
722
807
 
@@ -724,7 +809,15 @@ export function createDL2(deps: DL2Deps) {
724
809
  return guardAsync("createLink", null, async () => {
725
810
  if (!cfg) throw mkErr("not_configured", "Call DL2.configure() before createLink()");
726
811
  if (!input || typeof input.path !== "string") throw mkErr("unknown", "createLink() needs a path");
727
- const r = await call("/v1/sdk/links", input, EVENT_TIMEOUT_MS);
812
+ // The API takes the preview fields flat (og_title, …), not as the nested `og` object.
813
+ const { og, ...rest } = input;
814
+ const body: Record<string, unknown> = { ...rest };
815
+ if (og && typeof og === "object") {
816
+ if (og.title) body.og_title = og.title;
817
+ if (og.description) body.og_description = og.description;
818
+ if (og.image) body.og_image_url = og.image;
819
+ }
820
+ const r = await call("/v1/sdk/links", body, EVENT_TIMEOUT_MS);
728
821
  if (!r || typeof r.url !== "string") throw mkErr("unknown", "The server did not return a link URL");
729
822
  return { url: r.url };
730
823
  });
@@ -770,8 +863,10 @@ export function createDL2(deps: DL2Deps) {
770
863
  cfg = null;
771
864
  ready = null;
772
865
  initial = null;
866
+ recording = null;
773
867
  installId = null;
774
868
  firstLaunch = false;
869
+ skipFingerprint = false;
775
870
  storageOk = true;
776
871
  matched = false;
777
872
  info = null;
@@ -782,7 +877,7 @@ export function createDL2(deps: DL2Deps) {
782
877
  lastHandled = null;
783
878
  lastOpenResponse = null;
784
879
  lastResolveResponse = null;
785
- for (const k of [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER]) await removeItem(k);
880
+ for (const k of [...INSTALL_KEYS, K_INSTALLED_AT]) await removeItem(k);
786
881
  });
787
882
  }
788
883
 
package/src/types.ts CHANGED
@@ -92,6 +92,16 @@ export interface DL2Native {
92
92
  /** Android: Play Install Referrer string. iOS: always null. */
93
93
  getInstallReferrer(): Promise<string | null>;
94
94
  getDeviceInfo(): Promise<DL2DeviceInfo>;
95
+ /**
96
+ * When the app was installed, in ms since epoch, or null when unknown. Android: the package's
97
+ * firstInstallTime. iOS: the creation date of the app's data container.
98
+ */
99
+ getInstalledAt?(): Promise<number | null>;
100
+ /**
101
+ * Android: true when the activity was reopened from Recents, so its intent (and the URL in it)
102
+ * is the one from the original launch. iOS does not have it.
103
+ */
104
+ isLaunchedFromHistory?(): Promise<boolean>;
95
105
  uuid?(): Promise<string>;
96
106
  }
97
107