@dl2app/react-native 0.1.0 → 0.2.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/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.2.0";
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,10 +529,18 @@ 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
- mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."),
543
+ mkErr("unknown", "DL2 native module not found. Rebuild the app (on iOS run pod install first; Expo Go is not supported); deferred links are off."),
491
544
  "configure",
492
545
  );
493
546
  }
@@ -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/index.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { Dimensions, Linking, PixelRatio, Platform } from "react-native";
2
- import { requireOptionalNativeModule } from "expo-modules-core";
1
+ import { Dimensions, Linking, NativeModules, PixelRatio, Platform } from "react-native";
3
2
  import { createDL2 } from "./core";
4
3
  import type { DL2Native } from "./types";
5
4
 
5
+ // A plain React Native module, linked by autolinking in bare React Native and in Expo alike.
6
6
  function loadNative(): DL2Native | null {
7
7
  try {
8
- return requireOptionalNativeModule<DL2Native>("DL2");
8
+ return (NativeModules.DL2 as DL2Native | undefined) || null;
9
9
  } catch {
10
10
  return null;
11
11
  }
package/src/types.ts CHANGED
@@ -84,7 +84,7 @@ export type DL2DeviceInfo = {
84
84
  tz?: string;
85
85
  };
86
86
 
87
- /** The native module (Expo Modules API). Every function is asynchronous. */
87
+ /** The native module (a plain React Native module). Every function is asynchronous. */
88
88
  export interface DL2Native {
89
89
  getItem(key: string): Promise<string | null>;
90
90
  setItem(key: string, value: string): Promise<void>;
@@ -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
 
@@ -1,5 +0,0 @@
1
- {
2
- "platforms": ["ios", "android"],
3
- "ios": { "modules": ["DL2Module"] },
4
- "android": { "modules": ["app.dl2.reactnative.DL2Module"] }
5
- }
package/ios/DL2.podspec DELETED
@@ -1,22 +0,0 @@
1
- require 'json'
2
-
3
- package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
-
5
- Pod::Spec.new do |s|
6
- s.name = 'DL2'
7
- s.version = package['version']
8
- s.summary = package['description']
9
- s.description = package['description']
10
- s.license = package['license']
11
- s.author = 'DL2'
12
- s.homepage = 'https://dl2.app'
13
- s.platforms = { :ios => '13.4' }
14
- s.swift_version = '5.4'
15
- s.source = { :git => 'https://github.com/dl2app/dl2-react-native.git', :tag => "v#{s.version}" }
16
- s.static_framework = true
17
-
18
- s.dependency 'ExpoModulesCore'
19
-
20
- s.source_files = '*.swift'
21
- s.resource_bundles = { 'DL2_Privacy' => ['PrivacyInfo.xcprivacy'] }
22
- end