@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/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.
|
|
7
|
+
exports.SDK_VERSION = "0.2.0";
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 !(
|
|
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
|
|
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,10 +516,19 @@ 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
|
-
|
|
474
|
-
|
|
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
|
-
report(mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."), "configure");
|
|
531
|
+
report(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."), "configure");
|
|
477
532
|
}
|
|
478
533
|
}
|
|
479
534
|
async function runInitial(g) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
898
|
+
for (const k of [...INSTALL_KEYS, K_INSTALLED_AT])
|
|
795
899
|
await removeItem(k);
|
|
796
900
|
});
|
|
797
901
|
}
|
package/build/src/index.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SDK_VERSION = exports.extractClipboardToken = exports.parseUrl = exports.DL2PasteButton = exports.DL2 = void 0;
|
|
4
4
|
const react_native_1 = require("react-native");
|
|
5
|
-
const expo_modules_core_1 = require("expo-modules-core");
|
|
6
5
|
const core_1 = require("./core");
|
|
6
|
+
// A plain React Native module, linked by autolinking in bare React Native and in Expo alike.
|
|
7
7
|
function loadNative() {
|
|
8
8
|
try {
|
|
9
|
-
return
|
|
9
|
+
return react_native_1.NativeModules.DL2 || null;
|
|
10
10
|
}
|
|
11
11
|
catch {
|
|
12
12
|
return null;
|
package/build/src/types.d.ts
CHANGED
|
@@ -75,7 +75,7 @@ export type DL2DeviceInfo = {
|
|
|
75
75
|
lang?: string;
|
|
76
76
|
tz?: string;
|
|
77
77
|
};
|
|
78
|
-
/** The native module (
|
|
78
|
+
/** The native module (a plain React Native module). Every function is asynchronous. */
|
|
79
79
|
export interface DL2Native {
|
|
80
80
|
getItem(key: string): Promise<string | null>;
|
|
81
81
|
setItem(key: string, value: string): Promise<void>;
|
|
@@ -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 = {
|
package/ios/DL2Module.m
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
#import <React/RCTViewManager.h>
|
|
3
|
+
|
|
4
|
+
// Exposes the Swift classes to React Native. The implementations are in DL2Module.swift and
|
|
5
|
+
// DL2PasteButtonView.swift.
|
|
6
|
+
@interface RCT_EXTERN_REMAP_MODULE(DL2, DL2ReactNativeModule, NSObject)
|
|
7
|
+
|
|
8
|
+
RCT_EXTERN_METHOD(getItem:(NSString *)key resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
9
|
+
RCT_EXTERN_METHOD(setItem:(NSString *)key value:(NSString *)value resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
10
|
+
RCT_EXTERN_METHOD(removeItem:(NSString *)key resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
11
|
+
RCT_EXTERN_METHOD(getInstallReferrer:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
12
|
+
RCT_EXTERN_METHOD(uuid:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
13
|
+
RCT_EXTERN_METHOD(getDeviceInfo:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
14
|
+
RCT_EXTERN_METHOD(getInstalledAt:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
15
|
+
|
|
16
|
+
@end
|
|
17
|
+
|
|
18
|
+
@interface RCT_EXTERN_REMAP_MODULE(DL2PasteButton, DL2PasteButtonManager, RCTViewManager)
|
|
19
|
+
|
|
20
|
+
RCT_EXPORT_VIEW_PROPERTY(onPaste, RCTDirectEventBlock)
|
|
21
|
+
|
|
22
|
+
@end
|
package/ios/DL2Module.swift
CHANGED
|
@@ -1,50 +1,72 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
2
3
|
import UIKit
|
|
3
4
|
|
|
4
|
-
// Key-value storage, device info and the
|
|
5
|
+
// Key-value storage, device info and the install time. Nothing else.
|
|
5
6
|
// The clipboard is never read here: text only arrives when the user taps DL2PasteButtonView.
|
|
6
|
-
|
|
7
|
+
@objc(DL2ReactNativeModule)
|
|
8
|
+
public class DL2Module: NSObject {
|
|
7
9
|
private var store: UserDefaults {
|
|
8
10
|
return UserDefaults(suiteName: "app.dl2.sdk") ?? UserDefaults.standard
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
public func
|
|
12
|
-
|
|
13
|
+
@objc public static func requiresMainQueueSetup() -> Bool {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
@objc(getItem:resolver:rejecter:)
|
|
18
|
+
public func getItem(_ key: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
|
19
|
+
resolve(store.string(forKey: key))
|
|
20
|
+
}
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
@objc(setItem:value:resolver:rejecter:)
|
|
23
|
+
public func setItem(_ key: String, value: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
|
24
|
+
store.set(value, forKey: key)
|
|
25
|
+
resolve(nil)
|
|
26
|
+
}
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
@objc(removeItem:resolver:rejecter:)
|
|
29
|
+
public func removeItem(_ key: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
|
30
|
+
store.removeObject(forKey: key)
|
|
31
|
+
resolve(nil)
|
|
32
|
+
}
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
// The install referrer is an Android (Google Play) concept.
|
|
35
|
+
@objc(getInstallReferrer:rejecter:)
|
|
36
|
+
public func getInstallReferrer(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
|
37
|
+
resolve(nil)
|
|
38
|
+
}
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
@objc(uuid:rejecter:)
|
|
41
|
+
public func uuid(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
|
42
|
+
resolve(UUID().uuidString.lowercased())
|
|
43
|
+
}
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
@objc(getDeviceInfo:rejecter:)
|
|
46
|
+
public func getDeviceInfo(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
47
|
+
// UIDevice belongs to the main thread; module methods run on a background queue.
|
|
48
|
+
DispatchQueue.main.async {
|
|
36
49
|
let bundle = Bundle.main
|
|
37
|
-
|
|
50
|
+
resolve([
|
|
38
51
|
"osVersion": UIDevice.current.systemVersion,
|
|
39
52
|
"appId": bundle.bundleIdentifier ?? "",
|
|
40
53
|
"appVersion": (bundle.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "",
|
|
41
54
|
"lang": Locale.preferredLanguages.first ?? "",
|
|
42
55
|
"tz": TimeZone.current.identifier
|
|
43
|
-
]
|
|
56
|
+
])
|
|
44
57
|
}
|
|
58
|
+
}
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
// When the app was installed, in ms since epoch. iOS has no API for it; the data
|
|
61
|
+
// container's Library directory is created with the install. Nil when it cannot be read.
|
|
62
|
+
@objc(getInstalledAt:rejecter:)
|
|
63
|
+
public func getInstalledAt(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
|
64
|
+
guard let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first,
|
|
65
|
+
let created = (try? FileManager.default.attributesOfItem(atPath: dir.path))?[.creationDate] as? Date
|
|
66
|
+
else {
|
|
67
|
+
resolve(nil)
|
|
68
|
+
return
|
|
48
69
|
}
|
|
70
|
+
resolve((created.timeIntervalSince1970 * 1000).rounded())
|
|
49
71
|
}
|
|
50
72
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
2
3
|
import UIKit
|
|
3
4
|
|
|
4
5
|
// Hosts the system paste button (UIPasteControl, iOS 16+). iOS gives the app the clipboard
|
|
5
6
|
// content only after the user taps it, without the "pasted from" banner or a permission alert.
|
|
6
7
|
// On older iOS the view stays empty.
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
@objc(DL2PasteButtonView)
|
|
9
|
+
public final class DL2PasteButtonView: UIView {
|
|
10
|
+
@objc public var onPaste: RCTDirectEventBlock?
|
|
9
11
|
private var control: UIView?
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
super.init(
|
|
13
|
+
public override init(frame: CGRect) {
|
|
14
|
+
super.init(frame: frame)
|
|
13
15
|
pasteConfiguration = UIPasteConfiguration(acceptableTypeIdentifiers: ["public.plain-text", "public.url"])
|
|
14
16
|
if #available(iOS 16.0, *) {
|
|
15
17
|
let configuration = UIPasteControl.Configuration()
|
|
@@ -22,20 +24,35 @@ final class DL2PasteButtonView: ExpoView {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
required init?(coder: NSCoder) {
|
|
28
|
+
fatalError("init(coder:) is not used")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public override func layoutSubviews() {
|
|
26
32
|
super.layoutSubviews()
|
|
27
33
|
control?.frame = bounds
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
override func paste(itemProviders: [NSItemProvider]) {
|
|
36
|
+
public override func paste(itemProviders: [NSItemProvider]) {
|
|
31
37
|
guard let provider = itemProviders.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else {
|
|
32
38
|
return
|
|
33
39
|
}
|
|
34
40
|
_ = provider.loadObject(ofClass: NSString.self) { [weak self] value, _ in
|
|
35
41
|
guard let text = value as? String else { return }
|
|
36
42
|
DispatchQueue.main.async {
|
|
37
|
-
self?.onPaste(["text": text])
|
|
43
|
+
self?.onPaste?(["text": text])
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
}
|
|
48
|
+
|
|
49
|
+
@objc(DL2PasteButtonManager)
|
|
50
|
+
public final class DL2PasteButtonManager: RCTViewManager {
|
|
51
|
+
public override static func requiresMainQueueSetup() -> Bool {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public override func view() -> UIView! {
|
|
56
|
+
return DL2PasteButtonView()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build",
|
|
14
14
|
"plugin/src",
|
|
15
15
|
"app.plugin.js",
|
|
16
|
-
"
|
|
16
|
+
"DL2ReactNative.podspec",
|
|
17
17
|
"ios",
|
|
18
18
|
"android/build.gradle",
|
|
19
19
|
"android/src",
|
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
"keywords": ["react-native", "expo", "deep-linking", "universal-links", "app-links", "deferred-deep-link"],
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"expo": ">=51.0.0",
|
|
30
|
-
"expo-modules-core": "*",
|
|
31
30
|
"react": "*",
|
|
32
31
|
"react-native": ">=0.74.0"
|
|
33
32
|
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"expo": { "optional": true }
|
|
35
|
+
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"typescript": "^5.9.3"
|
|
36
38
|
}
|
package/plugin/src/withDL2.ts
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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/PasteButton.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createElement, type ComponentType, type ReactElement } from "react";
|
|
2
|
-
import { Platform } from "react-native";
|
|
3
|
-
import { requireNativeViewManager } from "expo-modules-core";
|
|
2
|
+
import { Platform, requireNativeComponent, UIManager } from "react-native";
|
|
4
3
|
|
|
5
4
|
export type DL2PasteButtonProps = {
|
|
6
5
|
/** Called with the pasted text after the user taps the button. */
|
|
@@ -16,7 +15,8 @@ let NativeView: ComponentType<NativeProps> | null | undefined;
|
|
|
16
15
|
function nativeView(): ComponentType<NativeProps> | null {
|
|
17
16
|
if (NativeView === undefined) {
|
|
18
17
|
try {
|
|
19
|
-
|
|
18
|
+
// Asking for a view the app was not built with fails only when it renders, so check first.
|
|
19
|
+
NativeView = UIManager.hasViewManagerConfig("DL2PasteButton") ? requireNativeComponent<NativeProps>("DL2PasteButton") : null;
|
|
20
20
|
} catch {
|
|
21
21
|
NativeView = null;
|
|
22
22
|
}
|