@dl2app/react-native 0.0.0-stage → 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 +313 -2
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/dl2/reactnative/DL2Module.kt +134 -0
- package/app.plugin.js +3 -0
- package/build/plugin/src/withDL2.d.ts +62 -0
- package/build/plugin/src/withDL2.js +174 -0
- package/build/src/PasteButton.d.ts +14 -0
- package/build/src/PasteButton.js +44 -0
- package/build/src/core.d.ts +32 -0
- package/build/src/core.js +937 -0
- package/build/src/index.d.ts +22 -0
- package/build/src/index.js +33 -0
- package/build/src/types.d.ts +130 -0
- package/build/src/types.js +2 -0
- package/expo-module.config.json +5 -0
- package/ios/DL2Module.swift +59 -0
- package/ios/DL2PasteButtonView.swift +41 -0
- package/ios/DL2ReactNative.podspec +22 -0
- package/ios/PrivacyInfo.xcprivacy +76 -0
- package/package.json +35 -4
- package/plugin/src/withDL2.ts +202 -0
- package/src/PasteButton.ts +48 -0
- package/src/core.ts +922 -0
- package/src/index.ts +41 -0
- package/src/types.ts +127 -0
package/README.md
CHANGED
|
@@ -1,3 +1,314 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dl2app/react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DL2 deep links for React Native and Expo.
|
|
4
|
+
|
|
5
|
+
- Opens the right screen when a DL2 link opens your app (iOS Universal Links, Android App Links, optional custom scheme).
|
|
6
|
+
- Deferred deep links: a user who installs from a link lands on that link's screen on first launch.
|
|
7
|
+
- Event tracking with an offline queue.
|
|
8
|
+
- Never crashes your app: every method catches its own errors and reports them through `onError`.
|
|
9
|
+
- No IDFA, no advertising ID, no tracking. About 13 KB of JavaScript, minified.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
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. Version 0.1.1 was compiled for Android and iOS in a fresh Expo SDK 57 app.
|
|
15
|
+
- A development build. **Expo Go does not work**, because the SDK has native code and your app needs its own associated domains.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
### Expo
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx expo install @dl2app/react-native
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Add the config plugin to `app.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"expo": {
|
|
30
|
+
"plugins": [
|
|
31
|
+
["@dl2app/react-native", { "subdomain": "myapp", "domains": ["go.myapp.com"] }]
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then rebuild the native app: `npx expo prebuild --clean` and `npx expo run:ios` / `npx expo run:android`, or a new EAS build.
|
|
38
|
+
|
|
39
|
+
Plugin options:
|
|
40
|
+
|
|
41
|
+
| Option | Default | What it does |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `subdomain` | none | Your DL2 project subdomain. Adds `myapp.dl2.app` and `myapp-alt.dl2.app`. |
|
|
44
|
+
| `domains` | `[]` | Custom domains. Without `subdomain`, list every host here, including the `-alt` one. |
|
|
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
|
+
| `androidAutoVerify` | `true` | Adds `android:autoVerify="true"` to the intent filter. |
|
|
47
|
+
| `uriScheme` | none | Also registers a custom scheme such as `myapp`. |
|
|
48
|
+
| `rootDomain` | `dl2.app` | Only for self-hosted DL2. |
|
|
49
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
56
|
+
### Bare React Native
|
|
57
|
+
|
|
58
|
+
1. Install Expo Modules if your app does not have them: `npx install-expo-modules@latest`.
|
|
59
|
+
2. `npm install @dl2app/react-native`, then `cd ios && pod install`.
|
|
60
|
+
3. iOS: in Xcode, open your target, **Signing & Capabilities**, add **Associated Domains**, and add one line per host:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
applinks:myapp.dl2.app
|
|
64
|
+
applinks:myapp-alt.dl2.app
|
|
65
|
+
applinks:go.myapp.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
In `AppDelegate`, make sure React Native's `RCTLinkingManager` receives `application:continueUserActivity:restorationHandler:` and `application:openURL:options:` (the React Native template explains both under "Linking").
|
|
69
|
+
|
|
70
|
+
4. Android: in `android/app/src/main/AndroidManifest.xml`, inside the main `<activity>`:
|
|
71
|
+
|
|
72
|
+
```xml
|
|
73
|
+
<intent-filter android:autoVerify="true">
|
|
74
|
+
<action android:name="android.intent.action.VIEW" />
|
|
75
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
76
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
77
|
+
<data android:scheme="https" android:host="myapp.dl2.app" />
|
|
78
|
+
<data android:scheme="https" android:host="myapp-alt.dl2.app" />
|
|
79
|
+
<data android:scheme="https" android:host="go.myapp.com" />
|
|
80
|
+
</intent-filter>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The dashboard (Project, Setup) shows these snippets filled in with your hosts.
|
|
84
|
+
|
|
85
|
+
## Use
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import DL2 from "@dl2app/react-native";
|
|
89
|
+
|
|
90
|
+
// Once, as early as you can (top of App.tsx or app/_layout.tsx, outside any component).
|
|
91
|
+
DL2.configure({
|
|
92
|
+
publishableKey: "pk_live_xxx",
|
|
93
|
+
linkDomains: ["myapp.dl2.app", "myapp-alt.dl2.app", "go.myapp.com"],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// One listener for all three cases: cold start, a link while the app runs,
|
|
97
|
+
// and the deferred link on first launch after install.
|
|
98
|
+
const unsubscribe = DL2.onLink((event) => {
|
|
99
|
+
navigation.navigate(event.path, event.data);
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
With Expo Router, also add `app/+native-intent.ts` so the router does not try to route the short link itself. See `examples/rn-expo-example`.
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
### `DL2.configure(options)`
|
|
108
|
+
|
|
109
|
+
| Option | Default | |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `publishableKey` | required | `pk_live_...` from the dashboard. Safe to ship in the app. |
|
|
112
|
+
| `linkDomains` | required | Every host your links use. URLs on other hosts are ignored. |
|
|
113
|
+
| `uriScheme` | none | Custom scheme to handle as well, for example `"myapp"`. |
|
|
114
|
+
| `deferred.enabled` | `true` | Check for a deferred link on first launch. |
|
|
115
|
+
| `deferred.timeoutMs` | `4000` | Longest the first-launch check may take. |
|
|
116
|
+
| `deferred.useClipboard` | `false` | Allow clipboard matching on iOS. See below. |
|
|
117
|
+
| `apiBaseUrl` | `https://api.dl2.app` | |
|
|
118
|
+
| `debug` | `false` | Log what the SDK does to the console. |
|
|
119
|
+
| `onError` | none | Called with a `DL2Error` whenever something goes wrong. |
|
|
120
|
+
|
|
121
|
+
Calling `configure` again does nothing, apart from updating `debug` and `onError`.
|
|
122
|
+
|
|
123
|
+
### `DL2.onLink(listener)` returns `unsubscribe`
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
type DL2LinkEvent = {
|
|
127
|
+
url: string | null; // the URL that opened the app, or the matched short link
|
|
128
|
+
path: string; // "/invite/42"
|
|
129
|
+
params: Record<string, string>; // query parameters
|
|
130
|
+
data: Record<string, unknown>; // the link's deep link data
|
|
131
|
+
source: "universal_link" | "app_link" | "uri_scheme" | "deferred";
|
|
132
|
+
isFirstOpen: boolean;
|
|
133
|
+
matchMethod?: "install_referrer" | "fingerprint" | "clipboard"; // deferred only
|
|
134
|
+
confidence?: number; // deferred only, 0 to 1
|
|
135
|
+
linkId?: string;
|
|
136
|
+
utm?: Record<string, string>;
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- Links that arrive before any listener is registered are kept (up to 20) and delivered to the first listener.
|
|
141
|
+
- The same URL within 2 seconds is delivered once.
|
|
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.
|
|
145
|
+
|
|
146
|
+
### `DL2.getInitialLink()` returns `Promise<DL2LinkEvent | null>`
|
|
147
|
+
|
|
148
|
+
Resolves when the cold-start link and the deferred check are both finished, or have timed out. Use it to hold your splash screen until you know where to go. The same event also reaches `onLink`.
|
|
149
|
+
|
|
150
|
+
### `DL2.resolve(url)` returns `Promise<DL2LinkEvent | null>`
|
|
151
|
+
|
|
152
|
+
Resolves any DL2 link, for example one scanned from a QR code inside your app. Does not call `onLink`. Returns `null` on failure.
|
|
153
|
+
|
|
154
|
+
### `DL2.track(name, { value?, currency?, props? })`
|
|
155
|
+
|
|
156
|
+
Records an event, for example `DL2.track("purchase", { value: 19.99, currency: "USD", props: { sku: "A1" } })`. Events are tied to the link that brought the user in.
|
|
157
|
+
|
|
158
|
+
- Returns at once. Events are sent in order in the background.
|
|
159
|
+
- Offline, events wait in a queue that survives app restarts. The queue holds 100 events; beyond that the oldest are dropped.
|
|
160
|
+
- Failed deliveries are retried after 2, 4, 8 seconds and so on, up to 5 minutes apart. `DL2.flush()` tries immediately.
|
|
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`.
|
|
163
|
+
|
|
164
|
+
### `DL2.createLink({ path, data?, og?, utm? })` returns `Promise<{ url } | null>`
|
|
165
|
+
|
|
166
|
+
Creates a link from the app, for example a share or invite link. Works only if the project allows link creation with a publishable key.
|
|
167
|
+
|
|
168
|
+
### Clipboard matching on iOS (opt-in)
|
|
169
|
+
|
|
170
|
+
Off by default. On iOS, when a user installs from a link, matching uses network and device signals and can miss, for example behind a VPN or iCloud Private Relay. An exact alternative is a token on the clipboard.
|
|
171
|
+
|
|
172
|
+
The SDK **never reads the clipboard by itself**. There is no clipboard-reading function in the native module. Instead you show Apple's own paste button, and iOS hands the text over only when the user taps it, without the "pasted from" banner:
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
import DL2, { DL2PasteButton } from "@dl2app/react-native";
|
|
176
|
+
|
|
177
|
+
DL2.configure({ ..., deferred: { useClipboard: true } });
|
|
178
|
+
|
|
179
|
+
// For example on your first onboarding screen:
|
|
180
|
+
<DL2PasteButton style={{ width: 160, height: 44 }} onPaste={(text) => DL2.matchClipboard(text)} />
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`DL2PasteButton` needs iOS 16 or newer and renders nothing on older iOS and on Android. `DL2.matchClipboard(text)` does nothing unless `useClipboard` is on, it is the first launch, the text contains a DL2 token and no deferred link was delivered yet. On a match the event goes to `onLink` with `matchMethod: "clipboard"`.
|
|
184
|
+
|
|
185
|
+
### Other methods
|
|
186
|
+
|
|
187
|
+
- `DL2.getInstallId()`: the random ID the SDK created for this install.
|
|
188
|
+
- `DL2.getDebugInfo()`: install ID, first-launch state, queued events, last API responses and last error.
|
|
189
|
+
- `DL2.reset()`: for development. Forgets the install so the next `configure` behaves like a fresh install.
|
|
190
|
+
|
|
191
|
+
### Errors
|
|
192
|
+
|
|
193
|
+
`onError` receives `{ code, message, cause?, status? }`. No method throws or rejects.
|
|
194
|
+
|
|
195
|
+
| `code` | Meaning | What to do |
|
|
196
|
+
| --- | --- | --- |
|
|
197
|
+
| `not_configured` | A method ran before `configure`, or `configure` got bad options. | Call `configure` first with a key and `linkDomains`. |
|
|
198
|
+
| `network` | The request did not reach DL2 (retried once). | Usually the device is offline. Links still open using the URL itself. |
|
|
199
|
+
| `timeout` | No answer in time. | Same as `network`. |
|
|
200
|
+
| `invalid_key` | The key was rejected. | Copy the publishable key again from the dashboard. |
|
|
201
|
+
| `app_mismatch` | The bundle ID or package name is not an app of this project. | Add the app in the dashboard, or fix the ID. |
|
|
202
|
+
| `link_not_found` | The link does not exist. | The event still carries the URL's path. |
|
|
203
|
+
| `rate_limited` | Too many requests. | Events are retried later by themselves. |
|
|
204
|
+
| `unknown` | Anything else; see `message` and `cause`. | |
|
|
205
|
+
|
|
206
|
+
## How first launch works
|
|
207
|
+
|
|
208
|
+
1. `configure` loads or creates the install ID (stored in `UserDefaults` on iOS, `SharedPreferences` on Android).
|
|
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`.
|
|
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.
|
|
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.
|
|
214
|
+
|
|
215
|
+
## Troubleshooting
|
|
216
|
+
|
|
217
|
+
**The link opens the browser or the store even though the app is installed**
|
|
218
|
+
|
|
219
|
+
- You typed or pasted the link into the browser's address bar. Both platforms open apps only from a tap on a link. Put the link in Notes or Messages and tap it.
|
|
220
|
+
- You tapped it inside an app's built-in browser (Instagram, TikTok, Facebook, LinkedIn, Snapchat). These often do not hand links to apps. DL2 shows a page with an "Open the app" button that uses your `-alt` host, and a hint for opening the system browser. This is why the `-alt` host must be in your app's configuration.
|
|
221
|
+
- iOS, after a long-press and "Open in Safari": iOS remembers that choice for the domain. Long-press the link again and choose "Open in *your app*".
|
|
222
|
+
|
|
223
|
+
**Check the files Apple and Google read**
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
# iOS: must return JSON containing "TEAMID.your.bundle.id", with no redirect
|
|
227
|
+
curl -i https://myapp.dl2.app/.well-known/apple-app-site-association
|
|
228
|
+
# What Apple's CDN has cached. Devices read this copy, and it can lag by hours.
|
|
229
|
+
curl https://app-site-association.cdn-apple.com/a/v1/myapp.dl2.app
|
|
230
|
+
|
|
231
|
+
# Android: must list your package and the SHA-256 of the certificate that signed the installed build
|
|
232
|
+
curl -i https://myapp.dl2.app/.well-known/assetlinks.json
|
|
233
|
+
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://myapp.dl2.app&relation=delegate_permission/common.handle_all_urls"
|
|
234
|
+
adb shell pm get-app-links com.example.app # every host should say "verified"
|
|
235
|
+
adb shell pm verify-app-links --re-verify com.example.app
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Repeat for the `-alt` host and each custom domain. With Play App Signing, the fingerprint comes from Play Console, Test and release, App integrity, App signing key certificate, not from your local keystore. Add the debug and upload fingerprints too if you test those builds.
|
|
239
|
+
|
|
240
|
+
On iOS development builds, set `iosDeveloperMode: true` and turn on Settings, Developer, Associated Domains Development on the device, so iOS fetches the file from your host instead of Apple's CDN. Do not ship that setting to the App Store.
|
|
241
|
+
|
|
242
|
+
**Simulator and emulator limits**
|
|
243
|
+
|
|
244
|
+
- iOS Simulator: Universal Links work (`xcrun simctl openurl booted https://myapp.dl2.app/abc`). There is no App Store, so the install step of a deferred link cannot be tested. To test the first-launch call, tap a link in Simulator Safari, then run a fresh install of your build from Xcode within the matching window.
|
|
245
|
+
- Android emulator: use a Google Play system image. Without Play Store there is no install referrer, and the SDK reports none. Test App Links with `adb shell am start -a android.intent.action.VIEW -d "https://myapp.dl2.app/abc"`.
|
|
246
|
+
- The Play install referrer is only filled in for installs that come from the Play Store (internal testing tracks count). A build installed with `adb install` has none.
|
|
247
|
+
- Deferred links are delivered once per install. Use `DL2.reset()` or reinstall to test again.
|
|
248
|
+
|
|
249
|
+
**Nothing happens and there is no error**
|
|
250
|
+
|
|
251
|
+
- Turn on `debug: true` and read the `[DL2]` lines.
|
|
252
|
+
- "DL2 native module not found": the app was not rebuilt after installing the package, or it is running in Expo Go.
|
|
253
|
+
- The host of the URL is not in `linkDomains`. The SDK ignores URLs on other hosts.
|
|
254
|
+
|
|
255
|
+
**Deferred links do not match on iOS**
|
|
256
|
+
|
|
257
|
+
Matching needs the click and the first launch to come from the same network within the matching window. VPNs, iCloud Private Relay and a switch between Wi-Fi and mobile data all lower the match rate. That is expected: DL2 prefers no match over a wrong one.
|
|
258
|
+
|
|
259
|
+
## Privacy
|
|
260
|
+
|
|
261
|
+
What the SDK sends to DL2:
|
|
262
|
+
|
|
263
|
+
| Data | When | Why |
|
|
264
|
+
| --- | --- | --- |
|
|
265
|
+
| Install ID (random, made by the SDK, reset on reinstall) | Every request | Deliver a deferred link once; tie events to a link |
|
|
266
|
+
| OS version, app version, screen size, language, time zone | First launch | Match the install to the click |
|
|
267
|
+
| IP address (seen by the server; stored only truncated or as a rotating hash, kept 24 hours at most for matching) | Every request | Match the install to the click |
|
|
268
|
+
| Play install referrer (Android) | First launch | Exact match to the click |
|
|
269
|
+
| Clipboard text | Only if you enable it and the user taps the paste button | Exact match to the click |
|
|
270
|
+
| Events you send with `track` | When you call it | Your analytics |
|
|
271
|
+
|
|
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.
|
|
273
|
+
|
|
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.
|
|
275
|
+
|
|
276
|
+
### App Store: App Privacy answers
|
|
277
|
+
|
|
278
|
+
Suggested answers for the DL2 part of your app. Your own data use may add more.
|
|
279
|
+
|
|
280
|
+
| Question | Answer |
|
|
281
|
+
| --- | --- |
|
|
282
|
+
| Do you or your third-party partners collect data from this app? | Yes |
|
|
283
|
+
| Identifiers, Device ID | Collected. Purposes: App Functionality, Analytics. Linked to the user: No. Used for tracking: No. |
|
|
284
|
+
| Usage Data, Product Interaction | Collected (link opens, and events if you call `track`). Purposes: App Functionality, Analytics. Linked: No. Tracking: No. |
|
|
285
|
+
| Diagnostics, Other Diagnostic Data | Collected (OS version, screen size, language, time zone). Purpose: App Functionality. Linked: No. Tracking: No. |
|
|
286
|
+
| Location, Coarse Location | Not collected by the SDK. DL2 derives country and city from the IP address for click statistics and does not store the address. |
|
|
287
|
+
| Purchases | Declare it only if you send purchase values with `track`. |
|
|
288
|
+
| Is data used to track users? | No |
|
|
289
|
+
|
|
290
|
+
If your events include a user ID or anything that identifies a person in `props`, answer "Linked to the user: Yes" for Product Interaction.
|
|
291
|
+
|
|
292
|
+
### Google Play: Data safety answers
|
|
293
|
+
|
|
294
|
+
| Question | Answer |
|
|
295
|
+
| --- | --- |
|
|
296
|
+
| Does your app collect or share any of the required user data types? | Yes |
|
|
297
|
+
| Is all of the user data collected by your app encrypted in transit? | Yes |
|
|
298
|
+
| Device or other IDs | Collected, not shared. Purposes: App functionality, Analytics. Processed ephemerally: No. Required. |
|
|
299
|
+
| App activity, App interactions | Collected, not shared. Purposes: App functionality, Analytics. |
|
|
300
|
+
| App info and performance, Diagnostics | Collected, not shared. Purpose: App functionality. |
|
|
301
|
+
| Location | Not collected. |
|
|
302
|
+
| Financial info, Purchase history | Only if you send purchase values with `track`. |
|
|
303
|
+
| Can users request that data is deleted? | Answer according to your own policy. Reinstalling the app creates a new install ID. |
|
|
304
|
+
|
|
305
|
+
DL2 acts as your service provider (data processor), so sending data to DL2 is "collected", not "shared", under Google's definitions.
|
|
306
|
+
|
|
307
|
+
## Development
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
npm run typecheck # tsc with this package's tsconfig
|
|
311
|
+
npm run build # compiles src/ and plugin/src/ into build/ (app.plugin.js loads build/plugin/src/withDL2.js)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Tests live in the repository root: `npx vitest run tests/sdk-rn.test.ts`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'app.dl2.reactnative'
|
|
4
|
+
version = '0.1.1'
|
|
5
|
+
|
|
6
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
|
+
apply from: expoModulesCorePlugin
|
|
8
|
+
applyKotlinExpoModulesCorePlugin()
|
|
9
|
+
useCoreDependencies()
|
|
10
|
+
useDefaultAndroidSdkVersions()
|
|
11
|
+
useExpoPublishing()
|
|
12
|
+
|
|
13
|
+
android {
|
|
14
|
+
namespace "app.dl2.reactnative"
|
|
15
|
+
defaultConfig {
|
|
16
|
+
versionCode 1
|
|
17
|
+
versionName "0.1.1"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
dependencies {
|
|
22
|
+
// Play Install Referrer: carries the click ID through a Play Store install.
|
|
23
|
+
implementation 'com.android.installreferrer:installreferrer:2.2'
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package app.dl2.reactnative
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.SharedPreferences
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import com.android.installreferrer.api.InstallReferrerClient
|
|
8
|
+
import com.android.installreferrer.api.InstallReferrerStateListener
|
|
9
|
+
import expo.modules.kotlin.Promise
|
|
10
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
11
|
+
import expo.modules.kotlin.modules.Module
|
|
12
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
13
|
+
import java.util.Locale
|
|
14
|
+
import java.util.TimeZone
|
|
15
|
+
import java.util.UUID
|
|
16
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
17
|
+
|
|
18
|
+
// Key-value storage, device info, the install time, the launch intent's history flag and the
|
|
19
|
+
// Play Install Referrer. Nothing else.
|
|
20
|
+
class DL2Module : Module() {
|
|
21
|
+
private val context: Context
|
|
22
|
+
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
|
23
|
+
|
|
24
|
+
private val prefs: SharedPreferences
|
|
25
|
+
get() = context.getSharedPreferences("app.dl2.sdk", Context.MODE_PRIVATE)
|
|
26
|
+
|
|
27
|
+
override fun definition() = ModuleDefinition {
|
|
28
|
+
Name("DL2")
|
|
29
|
+
|
|
30
|
+
AsyncFunction("getItem") { key: String ->
|
|
31
|
+
prefs.getString(key, null)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
AsyncFunction("setItem") { key: String, value: String ->
|
|
35
|
+
prefs.edit().putString(key, value).apply()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
AsyncFunction("removeItem") { key: String ->
|
|
39
|
+
prefs.edit().remove(key).apply()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
AsyncFunction("uuid") {
|
|
43
|
+
UUID.randomUUID().toString()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
AsyncFunction("getDeviceInfo") {
|
|
47
|
+
val ctx = context
|
|
48
|
+
val info = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
|
49
|
+
mapOf(
|
|
50
|
+
"osVersion" to (Build.VERSION.RELEASE ?: ""),
|
|
51
|
+
"appId" to ctx.packageName,
|
|
52
|
+
"appVersion" to (info.versionName ?: ""),
|
|
53
|
+
"lang" to Locale.getDefault().toLanguageTag(),
|
|
54
|
+
"tz" to TimeZone.getDefault().id
|
|
55
|
+
)
|
|
56
|
+
}
|
|
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
|
+
|
|
72
|
+
// Resolves with the referrer string, or null when Play has none. Read once, then cached.
|
|
73
|
+
AsyncFunction("getInstallReferrer") { promise: Promise ->
|
|
74
|
+
val cached = prefs.getString(REFERRER_KEY, null)
|
|
75
|
+
if (cached != null) {
|
|
76
|
+
promise.resolve(cached)
|
|
77
|
+
return@AsyncFunction
|
|
78
|
+
}
|
|
79
|
+
val settled = AtomicBoolean(false)
|
|
80
|
+
val finish = { value: String? ->
|
|
81
|
+
if (settled.compareAndSet(false, true)) {
|
|
82
|
+
promise.resolve(value)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var client: InstallReferrerClient? = null
|
|
86
|
+
val end = {
|
|
87
|
+
try {
|
|
88
|
+
client?.endConnection()
|
|
89
|
+
} catch (e: Exception) {
|
|
90
|
+
// already closed
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
val c = InstallReferrerClient.newBuilder(context).build()
|
|
95
|
+
client = c
|
|
96
|
+
c.startConnection(object : InstallReferrerStateListener {
|
|
97
|
+
override fun onInstallReferrerSetupFinished(responseCode: Int) {
|
|
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
|
|
102
|
+
try {
|
|
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
|
+
}
|
|
109
|
+
} catch (e: Exception) {
|
|
110
|
+
referrer = null
|
|
111
|
+
} finally {
|
|
112
|
+
end()
|
|
113
|
+
}
|
|
114
|
+
finish(referrer)
|
|
115
|
+
}.start()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override fun onInstallReferrerServiceDisconnected() {
|
|
119
|
+
end()
|
|
120
|
+
finish(null)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
} catch (e: Exception) {
|
|
124
|
+
// No Play Store on this device (emulator image, Huawei, sideloaded build).
|
|
125
|
+
end()
|
|
126
|
+
finish(null)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
companion object {
|
|
132
|
+
private const val REFERRER_KEY = "install_referrer"
|
|
133
|
+
}
|
|
134
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo config plugin for @dl2app/react-native.
|
|
3
|
+
*
|
|
4
|
+
* It only edits the Expo config object (`ios.associatedDomains`, `android.intentFilters`,
|
|
5
|
+
* `scheme`); Expo's own prebuild steps turn those into entitlements and manifest entries.
|
|
6
|
+
* That keeps it a pure function: same input, same output, safe to run any number of times.
|
|
7
|
+
*/
|
|
8
|
+
export type DL2PluginOptions = {
|
|
9
|
+
/** Your DL2 project subdomain. Adds `{subdomain}.dl2.app` and `{subdomain}-alt.dl2.app`. */
|
|
10
|
+
subdomain?: string;
|
|
11
|
+
/** Only for self-hosted DL2. Default "dl2.app". */
|
|
12
|
+
rootDomain?: string;
|
|
13
|
+
/** Extra hosts (custom domains), or the full list if you do not pass `subdomain`. */
|
|
14
|
+
domains?: string[];
|
|
15
|
+
/** Adds `?mode=developer` to the iOS entries so iOS fetches the AASA file directly. Default false. */
|
|
16
|
+
iosDeveloperMode?: boolean;
|
|
17
|
+
/** Adds `android:autoVerify="true"` to the intent filter. Default true. */
|
|
18
|
+
androidAutoVerify?: boolean;
|
|
19
|
+
/** Optional custom URL scheme, without "://". */
|
|
20
|
+
uriScheme?: string;
|
|
21
|
+
};
|
|
22
|
+
export type NormalizedOptions = {
|
|
23
|
+
domains: string[];
|
|
24
|
+
iosDeveloperMode: boolean;
|
|
25
|
+
androidAutoVerify: boolean;
|
|
26
|
+
uriScheme: string | null;
|
|
27
|
+
};
|
|
28
|
+
type IntentFilterData = {
|
|
29
|
+
scheme?: string;
|
|
30
|
+
host?: string;
|
|
31
|
+
[k: string]: unknown;
|
|
32
|
+
};
|
|
33
|
+
type IntentFilter = {
|
|
34
|
+
action?: string;
|
|
35
|
+
autoVerify?: boolean;
|
|
36
|
+
data?: IntentFilterData | IntentFilterData[];
|
|
37
|
+
category?: string | string[];
|
|
38
|
+
[k: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
export type ExpoConfigLike = {
|
|
41
|
+
scheme?: string | string[];
|
|
42
|
+
ios?: {
|
|
43
|
+
associatedDomains?: string[];
|
|
44
|
+
[k: string]: unknown;
|
|
45
|
+
};
|
|
46
|
+
android?: {
|
|
47
|
+
intentFilters?: IntentFilter[];
|
|
48
|
+
[k: string]: unknown;
|
|
49
|
+
};
|
|
50
|
+
[k: string]: unknown;
|
|
51
|
+
};
|
|
52
|
+
/** Validates the plugin options and returns the full host list. Throws with a fix in the message. */
|
|
53
|
+
export declare function normalizeOptions(raw: unknown): NormalizedOptions;
|
|
54
|
+
/** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
|
|
55
|
+
export declare function mergeAssociatedDomains(existing: unknown, domains: string[], developerMode: boolean): string[];
|
|
56
|
+
/** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
|
|
57
|
+
export declare function mergeIntentFilters(existing: unknown, domains: string[], autoVerify: boolean, uriScheme: string | null): IntentFilter[];
|
|
58
|
+
export declare function mergeSchemes(existing: unknown, uriScheme: string | null): string | string[] | undefined;
|
|
59
|
+
/** The whole plugin as a pure function. Does not modify its input. */
|
|
60
|
+
export declare function applyDL2<T extends object>(config: T, rawOptions: unknown): T & ExpoConfigLike;
|
|
61
|
+
declare const withDL2: <T extends object>(config: T, options: DL2PluginOptions) => T & ExpoConfigLike;
|
|
62
|
+
export default withDL2;
|