@dl2app/react-native 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/android/build.gradle +2 -2
- package/android/src/main/java/app/dl2/reactnative/DL2Module.kt +45 -18
- package/build/plugin/src/withDL2.d.ts +1 -1
- package/build/plugin/src/withDL2.js +13 -8
- package/build/src/core.d.ts +1 -1
- package/build/src/core.js +120 -16
- package/build/src/types.d.ts +10 -0
- package/ios/DL2Module.swift +10 -1
- package/ios/{DL2.podspec → DL2ReactNative.podspec} +2 -2
- package/ios/PrivacyInfo.xcprivacy +9 -0
- package/package.json +1 -1
- package/plugin/src/withDL2.ts +14 -6
- package/src/core.ts +111 -16
- package/src/types.ts +10 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ DL2 deep links for React Native and Expo.
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
13
|
- React Native 0.74 or newer, old or new architecture.
|
|
14
|
-
- Expo SDK 51 or newer, or a bare React Native app with Expo Modules
|
|
14
|
+
- Expo SDK 51 or newer, or a bare React Native app with Expo Modules. Version 0.1.1 was compiled for Android and iOS in a fresh Expo SDK 57 app.
|
|
15
15
|
- A development build. **Expo Go does not work**, because the SDK has native code and your app needs its own associated domains.
|
|
16
16
|
|
|
17
17
|
## Install
|
|
@@ -42,14 +42,14 @@ Plugin options:
|
|
|
42
42
|
| --- | --- | --- |
|
|
43
43
|
| `subdomain` | none | Your DL2 project subdomain. Adds `myapp.dl2.app` and `myapp-alt.dl2.app`. |
|
|
44
44
|
| `domains` | `[]` | Custom domains. Without `subdomain`, list every host here, including the `-alt` one. |
|
|
45
|
-
| `iosDeveloperMode` | `false` | Adds `?mode=developer` to the iOS entries. Use it for development builds only. |
|
|
45
|
+
| `iosDeveloperMode` | `false` | Adds `?mode=developer` to the iOS entries the plugin adds (entries you already list are left as they are). Use it for development builds only. |
|
|
46
46
|
| `androidAutoVerify` | `true` | Adds `android:autoVerify="true"` to the intent filter. |
|
|
47
47
|
| `uriScheme` | none | Also registers a custom scheme such as `myapp`. |
|
|
48
48
|
| `rootDomain` | `dl2.app` | Only for self-hosted DL2. |
|
|
49
49
|
|
|
50
50
|
You need `subdomain` or at least one entry in `domains`. The plugin stops the build with a message that says what to change when an option is wrong, for example a host written with `https://`.
|
|
51
51
|
|
|
52
|
-
The plugin adds to `ios.associatedDomains`, `android.intentFilters` and `scheme`. It keeps the entries you already have and is safe to run any number of times. The Play Install Referrer library comes with the SDK's own Gradle file, so there is nothing to add for it.
|
|
52
|
+
The plugin adds to `ios.associatedDomains`, `android.intentFilters` and `scheme`. It keeps the entries you already have and is safe to run any number of times. An existing https intent filter only counts as covering a host when it is not limited to a path, port or type. The Play Install Referrer library comes with the SDK's own Gradle file, so there is nothing to add for it.
|
|
53
53
|
|
|
54
54
|
Always include the `-alt` host. Pages on your main link host use it for their "Open in app" button: iOS only opens an app from a tap that crosses to a different domain.
|
|
55
55
|
|
|
@@ -140,6 +140,8 @@ type DL2LinkEvent = {
|
|
|
140
140
|
- Links that arrive before any listener is registered are kept (up to 20) and delivered to the first listener.
|
|
141
141
|
- The same URL within 2 seconds is delivered once.
|
|
142
142
|
- If the DL2 API cannot be reached within 3 seconds, the event is built from the URL itself (`path` and `params` from the URL, empty `data`), so your app still navigates.
|
|
143
|
+
- A link in your own `uriScheme` is always built from the URL, without a request: DL2 only stores https links.
|
|
144
|
+
- Android: a link is not delivered again when the app is reopened from Recents after its process was stopped.
|
|
143
145
|
|
|
144
146
|
### `DL2.getInitialLink()` returns `Promise<DL2LinkEvent | null>`
|
|
145
147
|
|
|
@@ -156,7 +158,8 @@ Records an event, for example `DL2.track("purchase", { value: 19.99, currency: "
|
|
|
156
158
|
- Returns at once. Events are sent in order in the background.
|
|
157
159
|
- Offline, events wait in a queue that survives app restarts. The queue holds 100 events; beyond that the oldest are dropped.
|
|
158
160
|
- Failed deliveries are retried after 2, 4, 8 seconds and so on, up to 5 minutes apart. `DL2.flush()` tries immediately.
|
|
159
|
-
- Delivery is at least once: after a timeout an event can
|
|
161
|
+
- Delivery is at least once: after a timeout an event can be sent twice. Each event carries an `event_id` made when it is queued, the same on every retry, so the server can count it once. Events are stamped with the time the server receives them.
|
|
162
|
+
- An event the server rejects (a 4xx answer other than 429) is dropped and reported through `onError`.
|
|
160
163
|
|
|
161
164
|
### `DL2.createLink({ path, data?, og?, utm? })` returns `Promise<{ url } | null>`
|
|
162
165
|
|
|
@@ -205,7 +208,9 @@ DL2.configure({ ..., deferred: { useClipboard: true } });
|
|
|
205
208
|
1. `configure` loads or creates the install ID (stored in `UserDefaults` on iOS, `SharedPreferences` on Android).
|
|
206
209
|
2. If the app was opened by a DL2 link, that link is used. On a first launch it is reported with `POST /v1/sdk/open` (`launch_url` set); later with `POST /v1/sdk/resolve`.
|
|
207
210
|
3. Otherwise, on first launch only, the SDK asks `POST /v1/sdk/open` whether this install came from a link. Android sends the Play install referrer, which is exact. iOS is matched on network and device signals; DL2 returns nothing rather than risk giving a user someone else's link.
|
|
208
|
-
4. If the device is offline on first launch, the check is tried again on the next launch, three launches at most.
|
|
211
|
+
4. If the device is offline on first launch, or the API answers 429 or 5xx, the check is tried again on the next launch, three launches at most.
|
|
212
|
+
5. When the app was installed more than 24 hours ago (for example when the SDK arrives in an app update), the first-launch call carries `skip_fingerprint: true`: the install is recorded and exact matches (Play install referrer, clipboard) still work, but it is not matched on network and device signals.
|
|
213
|
+
6. Android: when a backup or device transfer restores the SDK's storage into a new install, the restored install ID, first-launch flag and queued events are discarded and the launch counts as a first launch.
|
|
209
214
|
|
|
210
215
|
## Troubleshooting
|
|
211
216
|
|
|
@@ -266,7 +271,7 @@ What the SDK sends to DL2:
|
|
|
266
271
|
|
|
267
272
|
The SDK does not read the IDFA or the Android advertising ID, does not use any other device identifier, does not track users across apps or sites, and DL2 does not sell or share this data. You do not need the App Tracking Transparency prompt for DL2.
|
|
268
273
|
|
|
269
|
-
The package includes `ios/PrivacyInfo.xcprivacy` (no tracking, no tracking domains, `UserDefaults` reason `CA92.1`). Xcode merges it into your app's privacy report.
|
|
274
|
+
The package includes `ios/PrivacyInfo.xcprivacy` (no tracking, no tracking domains, `UserDefaults` reason `CA92.1`, file timestamp reason `C617.1`: the creation date of the app's own Library directory, read as the install time). Xcode merges it into your app's privacy report.
|
|
270
275
|
|
|
271
276
|
### App Store: App Privacy answers
|
|
272
277
|
|
package/android/build.gradle
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
apply plugin: 'com.android.library'
|
|
2
2
|
|
|
3
3
|
group = 'app.dl2.reactnative'
|
|
4
|
-
version = '0.1.
|
|
4
|
+
version = '0.1.1'
|
|
5
5
|
|
|
6
6
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
7
|
apply from: expoModulesCorePlugin
|
|
@@ -14,7 +14,7 @@ android {
|
|
|
14
14
|
namespace "app.dl2.reactnative"
|
|
15
15
|
defaultConfig {
|
|
16
16
|
versionCode 1
|
|
17
|
-
versionName "0.1.
|
|
17
|
+
versionName "0.1.1"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package app.dl2.reactnative
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
4
5
|
import android.content.SharedPreferences
|
|
5
6
|
import android.os.Build
|
|
6
7
|
import com.android.installreferrer.api.InstallReferrerClient
|
|
@@ -14,7 +15,8 @@ import java.util.TimeZone
|
|
|
14
15
|
import java.util.UUID
|
|
15
16
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
16
17
|
|
|
17
|
-
// Key-value storage, device info
|
|
18
|
+
// Key-value storage, device info, the install time, the launch intent's history flag and the
|
|
19
|
+
// Play Install Referrer. Nothing else.
|
|
18
20
|
class DL2Module : Module() {
|
|
19
21
|
private val context: Context
|
|
20
22
|
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
|
@@ -53,6 +55,20 @@ class DL2Module : Module() {
|
|
|
53
55
|
)
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
// When the app was installed, in ms since epoch. An update keeps it; a reinstall or a
|
|
59
|
+
// restore onto another device does not.
|
|
60
|
+
AsyncFunction("getInstalledAt") {
|
|
61
|
+
val ctx = context
|
|
62
|
+
ctx.packageManager.getPackageInfo(ctx.packageName, 0).firstInstallTime.toDouble()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// True when the activity was reopened from Recents: its intent is the one from the original
|
|
66
|
+
// launch, so the URL in it was already delivered.
|
|
67
|
+
AsyncFunction("isLaunchedFromHistory") {
|
|
68
|
+
val flags = appContext.currentActivity?.intent?.flags ?: 0
|
|
69
|
+
(flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
// Resolves with the referrer string, or null when Play has none. Read once, then cached.
|
|
57
73
|
AsyncFunction("getInstallReferrer") { promise: Promise ->
|
|
58
74
|
val cached = prefs.getString(REFERRER_KEY, null)
|
|
@@ -66,36 +82,47 @@ class DL2Module : Module() {
|
|
|
66
82
|
promise.resolve(value)
|
|
67
83
|
}
|
|
68
84
|
}
|
|
85
|
+
var client: InstallReferrerClient? = null
|
|
86
|
+
val end = {
|
|
87
|
+
try {
|
|
88
|
+
client?.endConnection()
|
|
89
|
+
} catch (e: Exception) {
|
|
90
|
+
// already closed
|
|
91
|
+
}
|
|
92
|
+
}
|
|
69
93
|
try {
|
|
70
|
-
val
|
|
71
|
-
client
|
|
94
|
+
val c = InstallReferrerClient.newBuilder(context).build()
|
|
95
|
+
client = c
|
|
96
|
+
c.startConnection(object : InstallReferrerStateListener {
|
|
72
97
|
override fun onInstallReferrerSetupFinished(responseCode: Int) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (referrer != null) {
|
|
78
|
-
prefs.edit().putString(REFERRER_KEY, referrer).apply()
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
} catch (e: Exception) {
|
|
82
|
-
referrer = null
|
|
83
|
-
} finally {
|
|
98
|
+
// Called on the main thread, and reading the referrer is a call into the Play
|
|
99
|
+
// Store's process: do it on a thread of its own.
|
|
100
|
+
Thread {
|
|
101
|
+
var referrer: String? = null
|
|
84
102
|
try {
|
|
85
|
-
|
|
103
|
+
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
|
|
104
|
+
referrer = c.installReferrer?.installReferrer
|
|
105
|
+
if (referrer != null) {
|
|
106
|
+
prefs.edit().putString(REFERRER_KEY, referrer).apply()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
86
109
|
} catch (e: Exception) {
|
|
87
|
-
|
|
110
|
+
referrer = null
|
|
111
|
+
} finally {
|
|
112
|
+
end()
|
|
88
113
|
}
|
|
89
|
-
|
|
90
|
-
|
|
114
|
+
finish(referrer)
|
|
115
|
+
}.start()
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
override fun onInstallReferrerServiceDisconnected() {
|
|
119
|
+
end()
|
|
94
120
|
finish(null)
|
|
95
121
|
}
|
|
96
122
|
})
|
|
97
123
|
} catch (e: Exception) {
|
|
98
124
|
// No Play Store on this device (emulator image, Huawei, sideloaded build).
|
|
125
|
+
end()
|
|
99
126
|
finish(null)
|
|
100
127
|
}
|
|
101
128
|
}
|
|
@@ -51,7 +51,7 @@ export type ExpoConfigLike = {
|
|
|
51
51
|
};
|
|
52
52
|
/** Validates the plugin options and returns the full host list. Throws with a fix in the message. */
|
|
53
53
|
export declare function normalizeOptions(raw: unknown): NormalizedOptions;
|
|
54
|
-
/** Adds `applinks:` entries, keeping every entry the app already has. */
|
|
54
|
+
/** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
|
|
55
55
|
export declare function mergeAssociatedDomains(existing: unknown, domains: string[], developerMode: boolean): string[];
|
|
56
56
|
/** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
|
|
57
57
|
export declare function mergeIntentFilters(existing: unknown, domains: string[], autoVerify: boolean, uriScheme: string | null): IntentFilter[];
|
|
@@ -94,16 +94,14 @@ function normalizeOptions(raw) {
|
|
|
94
94
|
uriScheme,
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
|
-
/** Adds `applinks:` entries, keeping every entry the app already has. */
|
|
97
|
+
/** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
|
|
98
98
|
function mergeAssociatedDomains(existing, domains, developerMode) {
|
|
99
99
|
const out = Array.isArray(existing) ? existing.filter((e) => typeof e === "string") : [];
|
|
100
100
|
for (const d of domains) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
else
|
|
106
|
-
out[i] = wanted; // same host already listed: only the mode can differ
|
|
101
|
+
// A host the app already lists keeps its own mode: the option only applies to entries added here.
|
|
102
|
+
if (out.some((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`))
|
|
103
|
+
continue;
|
|
104
|
+
out.push(`applinks:${d}${developerMode ? "?mode=developer" : ""}`);
|
|
107
105
|
}
|
|
108
106
|
return out;
|
|
109
107
|
}
|
|
@@ -113,6 +111,11 @@ function list(v) {
|
|
|
113
111
|
function isBrowsableView(f) {
|
|
114
112
|
return f.action === "VIEW" && list(f.category).indexOf("BROWSABLE") >= 0;
|
|
115
113
|
}
|
|
114
|
+
const RESTRICTIONS = ["port", "path", "pathPrefix", "pathPattern", "pathSuffix", "pathAdvancedPattern", "mimeType"];
|
|
115
|
+
/** Android applies every path, port and type in a filter to all of its hosts, so one of them narrows the whole filter. */
|
|
116
|
+
function isRestricted(f) {
|
|
117
|
+
return list(f.data).some((d) => RESTRICTIONS.some((k) => d[k] !== undefined && d[k] !== null && d[k] !== ""));
|
|
118
|
+
}
|
|
116
119
|
/** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
|
|
117
120
|
function mergeIntentFilters(existing, domains, autoVerify, uriScheme) {
|
|
118
121
|
const out = Array.isArray(existing) ? existing.slice() : [];
|
|
@@ -121,8 +124,10 @@ function mergeIntentFilters(existing, domains, autoVerify, uriScheme) {
|
|
|
121
124
|
for (const f of out) {
|
|
122
125
|
if (!f || !isBrowsableView(f))
|
|
123
126
|
continue;
|
|
127
|
+
// A filter limited to some paths does not open every link of the host.
|
|
128
|
+
const open = !isRestricted(f);
|
|
124
129
|
for (const d of list(f.data)) {
|
|
125
|
-
if (d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true))
|
|
130
|
+
if (open && d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true))
|
|
126
131
|
covered.add(d.host.toLowerCase());
|
|
127
132
|
if (d.scheme && !d.host)
|
|
128
133
|
schemes.add(d.scheme);
|
package/build/src/core.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DL2Config, DL2CreateLinkInput, DL2DebugInfo, DL2Deps, DL2LinkEvent, DL2TrackOptions } from "./types";
|
|
2
|
-
export declare const SDK_VERSION = "0.1.
|
|
2
|
+
export declare const SDK_VERSION = "0.1.1";
|
|
3
3
|
type Listener = (event: DL2LinkEvent) => void;
|
|
4
4
|
type Parsed = {
|
|
5
5
|
scheme: string;
|
package/build/src/core.js
CHANGED
|
@@ -4,12 +4,15 @@ exports.SDK_VERSION = void 0;
|
|
|
4
4
|
exports.parseUrl = parseUrl;
|
|
5
5
|
exports.extractClipboardToken = extractClipboardToken;
|
|
6
6
|
exports.createDL2 = createDL2;
|
|
7
|
-
exports.SDK_VERSION = "0.1.
|
|
7
|
+
exports.SDK_VERSION = "0.1.1";
|
|
8
8
|
const K_ID = "install_id";
|
|
9
9
|
const K_DONE = "first_launch_done";
|
|
10
10
|
const K_ATTEMPTS = "open_attempts";
|
|
11
11
|
const K_QUEUE = "event_queue";
|
|
12
12
|
const K_REFERRER = "install_referrer";
|
|
13
|
+
const K_INSTALLED_AT = "installed_at";
|
|
14
|
+
// Everything that belongs to one install. Dropped when storage turns out to come from another.
|
|
15
|
+
const INSTALL_KEYS = [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER];
|
|
13
16
|
const RESOLVE_TIMEOUT_MS = 3000;
|
|
14
17
|
const EVENT_TIMEOUT_MS = 5000;
|
|
15
18
|
const REFERRER_TIMEOUT_MS = 1500;
|
|
@@ -19,6 +22,8 @@ const MAX_PENDING_LINKS = 20;
|
|
|
19
22
|
const MAX_OPEN_ATTEMPTS = 3;
|
|
20
23
|
const RETRY_BASE_MS = 2000;
|
|
21
24
|
const RETRY_MAX_MS = 300000;
|
|
25
|
+
// The server's longest fingerprint window. An older install can only be matched exactly.
|
|
26
|
+
const MAX_MATCH_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
22
27
|
function mkErr(code, message, cause, status) {
|
|
23
28
|
const e = { code, message };
|
|
24
29
|
if (cause !== undefined)
|
|
@@ -89,11 +94,14 @@ function createDL2(deps) {
|
|
|
89
94
|
let gen = 0;
|
|
90
95
|
let installId = null;
|
|
91
96
|
let firstLaunch = false;
|
|
97
|
+
let skipFingerprint = false;
|
|
92
98
|
let storageOk = true;
|
|
93
99
|
let matched = false;
|
|
94
100
|
let info = null;
|
|
95
101
|
let ready = null;
|
|
96
102
|
let initial = null;
|
|
103
|
+
// The first-launch check that runs beside a launch link in the app's own scheme.
|
|
104
|
+
let recording = null;
|
|
97
105
|
let sub = null;
|
|
98
106
|
const listeners = new Set();
|
|
99
107
|
let pending = [];
|
|
@@ -350,7 +358,7 @@ function createDL2(deps) {
|
|
|
350
358
|
info = { osVersion: native.osVersion || "", appId: native.appId || "", appVersion: native.appVersion || "", lang: lang || "", tz: tz || "" };
|
|
351
359
|
return info;
|
|
352
360
|
}
|
|
353
|
-
/** Request body of POST /v1/sdk/open. Every field is always present. */
|
|
361
|
+
/** Request body of POST /v1/sdk/open. Every field is always present, except skip_fingerprint. */
|
|
354
362
|
async function openBody(extra) {
|
|
355
363
|
const d = await deviceInfo();
|
|
356
364
|
let screen = { w: 0, h: 0, dpr: 1 };
|
|
@@ -360,7 +368,7 @@ function createDL2(deps) {
|
|
|
360
368
|
catch {
|
|
361
369
|
/* keep defaults */
|
|
362
370
|
}
|
|
363
|
-
|
|
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,8 +516,17 @@ function createDL2(deps) {
|
|
|
470
516
|
// Without working storage every launch would look like the first one and could claim
|
|
471
517
|
// someone else's link, so deferred matching is skipped.
|
|
472
518
|
firstLaunch = storageOk && !!deps.native && done !== "1";
|
|
473
|
-
|
|
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
531
|
report(mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."), "configure");
|
|
477
532
|
}
|
|
@@ -489,11 +544,37 @@ function createDL2(deps) {
|
|
|
489
544
|
}
|
|
490
545
|
if (g !== gen || !cfg)
|
|
491
546
|
return null;
|
|
547
|
+
if (url && deps.platform === "android" && deps.native && deps.native.isLaunchedFromHistory) {
|
|
548
|
+
// Reopened from Recents after the process died: Android hands back the intent of the
|
|
549
|
+
// original launch, and its link was already delivered then.
|
|
550
|
+
try {
|
|
551
|
+
if (await deps.native.isLaunchedFromHistory())
|
|
552
|
+
url = null;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
/* unknown: treat the URL as new */
|
|
556
|
+
}
|
|
557
|
+
if (g !== gen || !cfg)
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
const parsed = url ? parseUrl(url) : null;
|
|
492
561
|
const handled = url ? handleUrl(url, true) : undefined;
|
|
493
|
-
if (handled)
|
|
562
|
+
if (handled) {
|
|
563
|
+
if (firstLaunch && parsed && sourceOf(parsed) === "uri_scheme") {
|
|
564
|
+
// Handled without the server, so the install is recorded by the usual first-launch
|
|
565
|
+
// call. The app already has its link: a deferred match is not delivered on top of it.
|
|
566
|
+
recording = firstOpen(g, false).then(() => undefined, (e) => report(e, "configure"));
|
|
567
|
+
}
|
|
494
568
|
return handled;
|
|
569
|
+
}
|
|
495
570
|
if (!firstLaunch)
|
|
496
571
|
return null;
|
|
572
|
+
return firstOpen(g, true);
|
|
573
|
+
}
|
|
574
|
+
/** The first-launch POST /v1/sdk/open without a launch URL: records the install and asks for a deferred link. */
|
|
575
|
+
async function firstOpen(g, deliver) {
|
|
576
|
+
if (!cfg)
|
|
577
|
+
return null;
|
|
497
578
|
if (!cfg.deferred) {
|
|
498
579
|
await finishFirstLaunch(true);
|
|
499
580
|
return null;
|
|
@@ -518,7 +599,10 @@ function createDL2(deps) {
|
|
|
518
599
|
ok = true;
|
|
519
600
|
if (g === gen && r && r.match) {
|
|
520
601
|
matched = true;
|
|
521
|
-
|
|
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/types.d.ts
CHANGED
|
@@ -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.swift
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
import UIKit
|
|
3
3
|
|
|
4
|
-
// Key-value storage, device info and the system paste button. Nothing else.
|
|
4
|
+
// Key-value storage, device info, the install time and the system paste button. Nothing else.
|
|
5
5
|
// The clipboard is never read here: text only arrives when the user taps DL2PasteButtonView.
|
|
6
6
|
public class DL2Module: Module {
|
|
7
7
|
private var store: UserDefaults {
|
|
@@ -43,6 +43,15 @@ public class DL2Module: Module {
|
|
|
43
43
|
]
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// When the app was installed, in ms since epoch. iOS has no API for it; the data
|
|
47
|
+
// container's Library directory is created with the install. Nil when it cannot be read.
|
|
48
|
+
AsyncFunction("getInstalledAt") { () -> Double? in
|
|
49
|
+
guard let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first,
|
|
50
|
+
let created = (try? FileManager.default.attributesOfItem(atPath: dir.path))?[.creationDate] as? Date
|
|
51
|
+
else { return nil }
|
|
52
|
+
return (created.timeIntervalSince1970 * 1000).rounded()
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
View(DL2PasteButtonView.self) {
|
|
47
56
|
Events("onPaste")
|
|
48
57
|
}
|
|
@@ -3,7 +3,7 @@ require 'json'
|
|
|
3
3
|
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
4
|
|
|
5
5
|
Pod::Spec.new do |s|
|
|
6
|
-
s.name = '
|
|
6
|
+
s.name = 'DL2ReactNative'
|
|
7
7
|
s.version = package['version']
|
|
8
8
|
s.summary = package['description']
|
|
9
9
|
s.description = package['description']
|
|
@@ -18,5 +18,5 @@ Pod::Spec.new do |s|
|
|
|
18
18
|
s.dependency 'ExpoModulesCore'
|
|
19
19
|
|
|
20
20
|
s.source_files = '*.swift'
|
|
21
|
-
s.resource_bundles = { '
|
|
21
|
+
s.resource_bundles = { 'DL2ReactNative_Privacy' => ['PrivacyInfo.xcprivacy'] }
|
|
22
22
|
end
|
|
@@ -62,6 +62,15 @@
|
|
|
62
62
|
<string>CA92.1</string>
|
|
63
63
|
</array>
|
|
64
64
|
</dict>
|
|
65
|
+
<!-- File timestamp: the creation date of the app's own Library directory, read as the install time. -->
|
|
66
|
+
<dict>
|
|
67
|
+
<key>NSPrivacyAccessedAPIType</key>
|
|
68
|
+
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
|
69
|
+
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
70
|
+
<array>
|
|
71
|
+
<string>C617.1</string>
|
|
72
|
+
</array>
|
|
73
|
+
</dict>
|
|
65
74
|
</array>
|
|
66
75
|
</dict>
|
|
67
76
|
</plist>
|
package/package.json
CHANGED
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/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.
|
|
14
|
+
export const SDK_VERSION = "0.1.1";
|
|
15
15
|
|
|
16
16
|
const K_ID = "install_id";
|
|
17
17
|
const K_DONE = "first_launch_done";
|
|
18
18
|
const K_ATTEMPTS = "open_attempts";
|
|
19
19
|
const K_QUEUE = "event_queue";
|
|
20
20
|
const K_REFERRER = "install_referrer";
|
|
21
|
+
const K_INSTALLED_AT = "installed_at";
|
|
22
|
+
// Everything that belongs to one install. Dropped when storage turns out to come from another.
|
|
23
|
+
const INSTALL_KEYS = [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER];
|
|
21
24
|
|
|
22
25
|
const RESOLVE_TIMEOUT_MS = 3000;
|
|
23
26
|
const EVENT_TIMEOUT_MS = 5000;
|
|
@@ -28,9 +31,13 @@ const MAX_PENDING_LINKS = 20;
|
|
|
28
31
|
const MAX_OPEN_ATTEMPTS = 3;
|
|
29
32
|
const RETRY_BASE_MS = 2000;
|
|
30
33
|
const RETRY_MAX_MS = 300000;
|
|
34
|
+
// The server's longest fingerprint window. An older install can only be matched exactly.
|
|
35
|
+
const MAX_MATCH_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
31
36
|
|
|
32
37
|
type Listener = (event: DL2LinkEvent) => void;
|
|
33
|
-
|
|
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,7 +529,15 @@ export function createDL2(deps: DL2Deps) {
|
|
|
484
529
|
// Without working storage every launch would look like the first one and could claim
|
|
485
530
|
// someone else's link, so deferred matching is skipped.
|
|
486
531
|
firstLaunch = storageOk && !!deps.native && done !== "1";
|
|
487
|
-
|
|
532
|
+
// Installed long ago and only now running the SDK (it shipped in an app update): a
|
|
533
|
+
// fingerprint match could only be somebody else's click. Exact matches still apply.
|
|
534
|
+
skipFingerprint = installed !== null && now() - installed > MAX_MATCH_WINDOW_MS;
|
|
535
|
+
if (Array.isArray(stored)) {
|
|
536
|
+
const old = (stored as QueuedEvent[]).filter((e) => !!e && typeof e === "object" && typeof e.name === "string");
|
|
537
|
+
// Events stored by a version that had no event_id get one now.
|
|
538
|
+
for (const e of old) if (typeof e.event_id !== "string" || !e.event_id) e.event_id = fallbackUuid(random);
|
|
539
|
+
queue = old.concat(queue).slice(-MAX_QUEUED_EVENTS);
|
|
540
|
+
}
|
|
488
541
|
if (!deps.native) {
|
|
489
542
|
report(
|
|
490
543
|
mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."),
|
|
@@ -503,9 +556,36 @@ export function createDL2(deps: DL2Deps) {
|
|
|
503
556
|
report(mkErr("unknown", "Linking.getInitialURL failed", e), "configure");
|
|
504
557
|
}
|
|
505
558
|
if (g !== gen || !cfg) return null;
|
|
559
|
+
if (url && deps.platform === "android" && deps.native && deps.native.isLaunchedFromHistory) {
|
|
560
|
+
// Reopened from Recents after the process died: Android hands back the intent of the
|
|
561
|
+
// original launch, and its link was already delivered then.
|
|
562
|
+
try {
|
|
563
|
+
if (await deps.native.isLaunchedFromHistory()) url = null;
|
|
564
|
+
} catch {
|
|
565
|
+
/* unknown: treat the URL as new */
|
|
566
|
+
}
|
|
567
|
+
if (g !== gen || !cfg) return null;
|
|
568
|
+
}
|
|
569
|
+
const parsed = url ? parseUrl(url) : null;
|
|
506
570
|
const handled = url ? handleUrl(url, true) : undefined;
|
|
507
|
-
if (handled)
|
|
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/types.ts
CHANGED
|
@@ -92,6 +92,16 @@ export interface DL2Native {
|
|
|
92
92
|
/** Android: Play Install Referrer string. iOS: always null. */
|
|
93
93
|
getInstallReferrer(): Promise<string | null>;
|
|
94
94
|
getDeviceInfo(): Promise<DL2DeviceInfo>;
|
|
95
|
+
/**
|
|
96
|
+
* When the app was installed, in ms since epoch, or null when unknown. Android: the package's
|
|
97
|
+
* firstInstallTime. iOS: the creation date of the app's data container.
|
|
98
|
+
*/
|
|
99
|
+
getInstalledAt?(): Promise<number | null>;
|
|
100
|
+
/**
|
|
101
|
+
* Android: true when the activity was reopened from Recents, so its intent (and the URL in it)
|
|
102
|
+
* is the one from the original launch. iOS does not have it.
|
|
103
|
+
*/
|
|
104
|
+
isLaunchedFromHistory?(): Promise<boolean>;
|
|
95
105
|
uuid?(): Promise<string>;
|
|
96
106
|
}
|
|
97
107
|
|