@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/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.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
- 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,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
- 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
- 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
- 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
  }
@@ -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 (0, expo_modules_core_1.requireOptionalNativeModule)("DL2");
9
+ return react_native_1.NativeModules.DL2 || null;
10
10
  }
11
11
  catch {
12
12
  return null;
@@ -75,7 +75,7 @@ export type DL2DeviceInfo = {
75
75
  lang?: string;
76
76
  tz?: string;
77
77
  };
78
- /** The native module (Expo Modules API). Every function is asynchronous. */
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 = {
@@ -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
@@ -1,50 +1,72 @@
1
- import ExpoModulesCore
1
+ import Foundation
2
+ import React
2
3
  import UIKit
3
4
 
4
- // Key-value storage, device info and the system paste button. Nothing else.
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
- public class DL2Module: Module {
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 definition() -> ModuleDefinition {
12
- Name("DL2")
13
+ @objc public static func requiresMainQueueSetup() -> Bool {
14
+ return false
15
+ }
13
16
 
14
- AsyncFunction("getItem") { (key: String) -> String? in
15
- return self.store.string(forKey: key)
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
- AsyncFunction("setItem") { (key: String, value: String) in
19
- self.store.set(value, forKey: key)
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
- AsyncFunction("removeItem") { (key: String) in
23
- self.store.removeObject(forKey: key)
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
- // The install referrer is an Android (Google Play) concept.
27
- AsyncFunction("getInstallReferrer") { () -> String? in
28
- return nil
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
- AsyncFunction("uuid") { () -> String in
32
- return UUID().uuidString.lowercased()
33
- }
40
+ @objc(uuid:rejecter:)
41
+ public func uuid(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
42
+ resolve(UUID().uuidString.lowercased())
43
+ }
34
44
 
35
- AsyncFunction("getDeviceInfo") { () -> [String: String] in
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
- return [
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
- View(DL2PasteButtonView.self) {
47
- Events("onPaste")
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 ExpoModulesCore
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
- final class DL2PasteButtonView: ExpoView {
8
- let onPaste = EventDispatcher()
8
+ @objc(DL2PasteButtonView)
9
+ public final class DL2PasteButtonView: UIView {
10
+ @objc public var onPaste: RCTDirectEventBlock?
9
11
  private var control: UIView?
10
12
 
11
- required init(appContext: AppContext? = nil) {
12
- super.init(appContext: appContext)
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
- override func layoutSubviews() {
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.1.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
- "expo-module.config.json",
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
  }
@@ -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
  }
@@ -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
- NativeView = requireNativeViewManager<NativeProps>("DL2");
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
  }