@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/DL2ReactNative.podspec +26 -0
- package/README.md +64 -36
- package/android/build.gradle +30 -9
- package/android/src/main/java/app/dl2/reactnative/DL2Module.kt +109 -55
- package/android/src/main/java/app/dl2/reactnative/DL2Package.kt +17 -0
- package/build/plugin/src/withDL2.d.ts +1 -1
- package/build/plugin/src/withDL2.js +13 -8
- package/build/src/PasteButton.js +2 -2
- package/build/src/core.d.ts +1 -1
- package/build/src/core.js +121 -17
- package/build/src/index.js +2 -2
- package/build/src/types.d.ts +11 -1
- package/ios/DL2Module.m +22 -0
- package/ios/DL2Module.swift +48 -26
- package/ios/DL2PasteButtonView.swift +25 -8
- package/ios/PrivacyInfo.xcprivacy +9 -0
- package/package.json +5 -3
- package/plugin/src/withDL2.ts +14 -6
- package/src/PasteButton.ts +3 -3
- package/src/core.ts +112 -17
- package/src/index.ts +3 -3
- package/src/types.ts +11 -1
- package/expo-module.config.json +0 -5
- package/ios/DL2.podspec +0 -22
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 !(
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
|
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 (
|
|
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
|
|
package/expo-module.config.json
DELETED
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
|