deep-link-expo 1.0.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +22 -0
  3. package/README.md +138 -0
  4. package/dist/attribution/attribution-context.d.ts +34 -0
  5. package/dist/attribution/attribution-context.d.ts.map +1 -0
  6. package/dist/attribution/attribution-context.js +98 -0
  7. package/dist/attribution/attribution-manager.d.ts +18 -0
  8. package/dist/attribution/attribution-manager.d.ts.map +1 -0
  9. package/dist/attribution/attribution-manager.js +94 -0
  10. package/dist/deeplink/deep-link-handler.d.ts +43 -0
  11. package/dist/deeplink/deep-link-handler.d.ts.map +1 -0
  12. package/dist/deeplink/deep-link-handler.js +150 -0
  13. package/dist/deeplink/url-parser.d.ts +13 -0
  14. package/dist/deeplink/url-parser.d.ts.map +1 -0
  15. package/dist/deeplink/url-parser.js +92 -0
  16. package/dist/errors/linkforty-error.d.ts +26 -0
  17. package/dist/errors/linkforty-error.d.ts.map +1 -0
  18. package/dist/errors/linkforty-error.js +50 -0
  19. package/dist/events/event-queue.d.ts +14 -0
  20. package/dist/events/event-queue.d.ts.map +1 -0
  21. package/dist/events/event-queue.js +68 -0
  22. package/dist/events/event-tracker.d.ts +18 -0
  23. package/dist/events/event-tracker.d.ts.map +1 -0
  24. package/dist/events/event-tracker.js +92 -0
  25. package/dist/fingerprint/fingerprint-collector.d.ts +8 -0
  26. package/dist/fingerprint/fingerprint-collector.d.ts.map +1 -0
  27. package/dist/fingerprint/fingerprint-collector.js +27 -0
  28. package/dist/index.d.ts +18 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +6 -0
  31. package/dist/linkforty-sdk.d.ts +53 -0
  32. package/dist/linkforty-sdk.d.ts.map +1 -0
  33. package/dist/linkforty-sdk.js +255 -0
  34. package/dist/logger.d.ts +7 -0
  35. package/dist/logger.d.ts.map +1 -0
  36. package/dist/logger.js +18 -0
  37. package/dist/models/attribution.d.ts +28 -0
  38. package/dist/models/attribution.d.ts.map +1 -0
  39. package/dist/models/attribution.js +9 -0
  40. package/dist/models/config.d.ts +72 -0
  41. package/dist/models/config.d.ts.map +1 -0
  42. package/dist/models/config.js +26 -0
  43. package/dist/models/create-link-options.d.ts +15 -0
  44. package/dist/models/create-link-options.d.ts.map +1 -0
  45. package/dist/models/create-link-options.js +1 -0
  46. package/dist/models/create-link-result.d.ts +8 -0
  47. package/dist/models/create-link-result.d.ts.map +1 -0
  48. package/dist/models/create-link-result.js +1 -0
  49. package/dist/models/deep-link-data.d.ts +14 -0
  50. package/dist/models/deep-link-data.d.ts.map +1 -0
  51. package/dist/models/deep-link-data.js +1 -0
  52. package/dist/models/device-fingerprint.d.ts +13 -0
  53. package/dist/models/device-fingerprint.d.ts.map +1 -0
  54. package/dist/models/device-fingerprint.js +1 -0
  55. package/dist/models/event-request.d.ts +13 -0
  56. package/dist/models/event-request.d.ts.map +1 -0
  57. package/dist/models/event-request.js +1 -0
  58. package/dist/models/install-response.d.ts +9 -0
  59. package/dist/models/install-response.d.ts.map +1 -0
  60. package/dist/models/install-response.js +1 -0
  61. package/dist/models/utm-parameters.d.ts +8 -0
  62. package/dist/models/utm-parameters.d.ts.map +1 -0
  63. package/dist/models/utm-parameters.js +1 -0
  64. package/dist/navigation/navigation-tracker.d.ts +52 -0
  65. package/dist/navigation/navigation-tracker.d.ts.map +1 -0
  66. package/dist/navigation/navigation-tracker.js +153 -0
  67. package/dist/network/network-manager.d.ts +13 -0
  68. package/dist/network/network-manager.d.ts.map +1 -0
  69. package/dist/network/network-manager.js +90 -0
  70. package/dist/storage/storage-keys.d.ts +8 -0
  71. package/dist/storage/storage-keys.d.ts.map +1 -0
  72. package/dist/storage/storage-keys.js +7 -0
  73. package/dist/storage/storage-manager.d.ts +20 -0
  74. package/dist/storage/storage-manager.d.ts.map +1 -0
  75. package/dist/storage/storage-manager.js +46 -0
  76. package/dist/version.d.ts +3 -0
  77. package/dist/version.d.ts.map +1 -0
  78. package/dist/version.js +3 -0
  79. package/llms.txt +392 -0
  80. package/package.json +83 -0
  81. package/src/attribution/attribution-context.ts +106 -0
  82. package/src/attribution/attribution-manager.ts +126 -0
  83. package/src/deeplink/deep-link-handler.ts +171 -0
  84. package/src/deeplink/url-parser.ts +96 -0
  85. package/src/errors/linkforty-error.ts +89 -0
  86. package/src/events/event-queue.ts +75 -0
  87. package/src/events/event-tracker.ts +118 -0
  88. package/src/fingerprint/fingerprint-collector.ts +35 -0
  89. package/src/index.ts +29 -0
  90. package/src/linkforty-sdk.ts +342 -0
  91. package/src/logger.ts +23 -0
  92. package/src/models/attribution.ts +29 -0
  93. package/src/models/config.ts +109 -0
  94. package/src/models/create-link-options.ts +15 -0
  95. package/src/models/create-link-result.ts +7 -0
  96. package/src/models/deep-link-data.ts +14 -0
  97. package/src/models/device-fingerprint.ts +12 -0
  98. package/src/models/event-request.ts +15 -0
  99. package/src/models/install-response.ts +9 -0
  100. package/src/models/utm-parameters.ts +7 -0
  101. package/src/navigation/navigation-tracker.ts +195 -0
  102. package/src/network/network-manager.ts +111 -0
  103. package/src/storage/storage-keys.ts +7 -0
  104. package/src/storage/storage-manager.ts +61 -0
  105. package/src/version.ts +3 -0
package/llms.txt ADDED
@@ -0,0 +1,392 @@
1
+ # @linkforty/mobile-sdk-expo
2
+
3
+ > Expo SDK for LinkForty — an open-source mobile attribution and deep link management platform. Pure JavaScript implementation for Expo apps. Handles deep linking, deferred deep linking (install attribution), in-app event tracking with offline queue, revenue tracking, and link creation. Works with LinkForty Cloud (linkforty.com) or self-hosted Core instances.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npx expo install @linkforty/mobile-sdk-expo expo-device expo-application expo-localization expo-linking @react-native-async-storage/async-storage
9
+ ```
10
+
11
+ All dependencies are Expo-compatible and use the Expo module system.
12
+
13
+ ## TypeScript Types
14
+
15
+ ```typescript
16
+ interface LinkFortyConfig {
17
+ /** Base URL of your LinkForty instance (e.g., 'https://go.yourdomain.com') */
18
+ baseUrl: string;
19
+ /** API key — only needed if calling createLink() */
20
+ apiKey?: string;
21
+ /**
22
+ * Public workspace token (LinkForty Cloud only). Recommended — required
23
+ * for organic installs to be attributed to your workspace. Find it in
24
+ * the dashboard under Workspace Settings → App Token. Format:
25
+ * `at_<32 hex chars>`. Safe to ship in your app bundle.
26
+ */
27
+ appToken?: string;
28
+ /** Enable debug logging (default: false) */
29
+ debug?: boolean;
30
+ /** Attribution window in hours (default: 168 = 7 days, range: 1-2160) */
31
+ attributionWindowHours?: number;
32
+ }
33
+
34
+ interface DeepLinkData {
35
+ shortCode: string;
36
+ iosUrl?: string;
37
+ androidUrl?: string;
38
+ webUrl?: string;
39
+ utmParameters?: {
40
+ source?: string;
41
+ medium?: string;
42
+ campaign?: string;
43
+ term?: string;
44
+ content?: string;
45
+ };
46
+ /** Custom key-value parameters set when the link was created */
47
+ customParameters?: Record<string, string>;
48
+ /** In-app destination path (e.g., '/product/123') */
49
+ deepLinkPath?: string;
50
+ /** Custom URI scheme (e.g., 'myapp') */
51
+ appScheme?: string;
52
+ clickedAt?: string;
53
+ linkId?: string;
54
+ }
55
+
56
+ interface InstallAttributionResponse {
57
+ installId: string;
58
+ attributed: boolean;
59
+ /** Attribution confidence score (0-100) */
60
+ confidenceScore: number;
61
+ /** Matched fingerprint factors (e.g., ['ip', 'user_agent', 'timezone']) */
62
+ matchedFactors: string[];
63
+ deepLinkData: DeepLinkData | {};
64
+ }
65
+
66
+ interface CreateLinkOptions {
67
+ /** Template ID (UUID) — auto-selected when omitted */
68
+ templateId?: string;
69
+ /** Template slug — only needed when templateId is provided */
70
+ templateSlug?: string;
71
+ /** Custom parameters embedded in the link */
72
+ deepLinkParameters?: Record<string, string>;
73
+ title?: string;
74
+ description?: string;
75
+ /** Custom short code (auto-generated if omitted) */
76
+ customCode?: string;
77
+ utmParameters?: {
78
+ source?: string;
79
+ medium?: string;
80
+ campaign?: string;
81
+ term?: string;
82
+ content?: string;
83
+ };
84
+ }
85
+
86
+ interface CreateLinkResult {
87
+ /** Full shareable URL (e.g., 'https://go.example.com/tmpl/abc123') */
88
+ url: string;
89
+ shortCode: string;
90
+ linkId: string;
91
+ }
92
+
93
+ /** All SDK errors are this type */
94
+ class LinkFortyError extends Error {
95
+ code: 'NOT_INITIALIZED' | 'ALREADY_INITIALIZED' | 'INVALID_CONFIGURATION'
96
+ | 'NETWORK_ERROR' | 'INVALID_RESPONSE' | 'DECODING_ERROR'
97
+ | 'INVALID_EVENT_DATA' | 'MISSING_API_KEY';
98
+ }
99
+
100
+ type DeepLinkCallback = (url: string, deepLinkData: DeepLinkData | null) => void;
101
+ type DeferredDeepLinkCallback = (deepLinkData: DeepLinkData | null) => void;
102
+ ```
103
+
104
+ ## SDK API
105
+
106
+ The SDK exports a singleton default export: `LinkFortySDK`.
107
+
108
+ ```typescript
109
+ import LinkFortySDK from '@linkforty/mobile-sdk-expo';
110
+ ```
111
+
112
+ ### initialize(config: LinkFortyConfig): Promise\<InstallAttributionResponse\>
113
+
114
+ Initialize the SDK, report the install, and return attribution data immediately. Must be called before any other method. HTTPS is required for `baseUrl` (except localhost for development).
115
+
116
+ ```typescript
117
+ const attribution = await LinkFortySDK.initialize({
118
+ baseUrl: 'https://go.yourdomain.com',
119
+ apiKey: 'lf_live_abc123', // optional — only for createLink()
120
+ appToken: 'at_a1b2c3d4...', // recommended for Cloud — enables organic-install attribution
121
+ debug: __DEV__,
122
+ attributionWindowHours: 168, // 7 days (default)
123
+ });
124
+
125
+ console.log(attribution.installId); // UUID
126
+ console.log(attribution.attributed); // true/false
127
+ console.log(attribution.confidenceScore); // 0-100
128
+ console.log(attribution.matchedFactors); // ['ip', 'user_agent', ...]
129
+ ```
130
+
131
+ **Property:** `LinkFortySDK.isInitialized: boolean` — check if SDK is initialized.
132
+
133
+ ### onDeepLink(callback: DeepLinkCallback): void
134
+
135
+ Register a callback for direct deep links — fires when user taps a LinkForty link and the app is already installed. Supports multiple callbacks.
136
+
137
+ ```typescript
138
+ LinkFortySDK.onDeepLink((url, data) => {
139
+ console.log('Deep link URL:', url);
140
+ if (data?.customParameters?.route) {
141
+ router.push(`/${data.customParameters.route}/${data.customParameters.id}`);
142
+ }
143
+ });
144
+ ```
145
+
146
+ ### onDeferredDeepLink(callback: DeferredDeepLinkCallback): void
147
+
148
+ Register a callback for deferred deep links — fires on first app launch if the install was attributed to a link click.
149
+
150
+ ```typescript
151
+ LinkFortySDK.onDeferredDeepLink((data) => {
152
+ if (data) {
153
+ console.log('Attributed install from:', data.shortCode);
154
+ router.push(`/${data.customParameters?.route}/${data.customParameters?.id}`);
155
+ }
156
+ });
157
+ ```
158
+
159
+ ### handleDeepLink(url: string): void
160
+
161
+ Manually pass a URL to the SDK for deep link handling. Use when you receive a URL through a channel other than the automatic listener.
162
+
163
+ ### trackEvent(name: string, properties?: Record\<string, unknown\>): Promise\<void\>
164
+
165
+ Track in-app events. Failed sends are queued offline (max 100 events) and retried automatically on next successful send.
166
+
167
+ ```typescript
168
+ await LinkFortySDK.trackEvent('add_to_cart', {
169
+ productId: '789',
170
+ price: 29.99,
171
+ });
172
+ ```
173
+
174
+ ### trackRevenue(amount: number, currency: string, properties?: Record\<string, unknown\>): Promise\<void\>
175
+
176
+ Track revenue events with a dedicated method for clearer analytics.
177
+
178
+ ```typescript
179
+ await LinkFortySDK.trackRevenue(29.99, 'USD', {
180
+ productId: '789',
181
+ orderId: 'order_123',
182
+ });
183
+ ```
184
+
185
+ ### createLink(options: CreateLinkOptions): Promise\<CreateLinkResult\>
186
+
187
+ Create a shareable LinkForty link from within the app. Requires `apiKey` in the init config.
188
+
189
+ ```typescript
190
+ const result = await LinkFortySDK.createLink({
191
+ deepLinkParameters: {
192
+ route: 'product',
193
+ productId: '789',
194
+ },
195
+ title: 'Check out this product!',
196
+ utmParameters: {
197
+ source: 'in_app_share',
198
+ medium: 'referral',
199
+ },
200
+ });
201
+
202
+ console.log(result.url); // https://go.yourdomain.com/tmpl/abc12345
203
+ console.log(result.shortCode); // abc12345
204
+ console.log(result.linkId); // uuid
205
+ ```
206
+
207
+ ### Offline Event Queue
208
+
209
+ ```typescript
210
+ LinkFortySDK.queuedEventCount; // number of events waiting to send
211
+ await LinkFortySDK.flushEvents(); // manually flush the queue
212
+ await LinkFortySDK.clearEventQueue(); // clear queue without sending
213
+ ```
214
+
215
+ ### Utility Methods
216
+
217
+ ```typescript
218
+ const installId = await LinkFortySDK.getInstallId();
219
+ const installData = await LinkFortySDK.getInstallData();
220
+ const isFirst = await LinkFortySDK.isFirstLaunch();
221
+
222
+ // Clear all stored data (e.g., on user logout)
223
+ await LinkFortySDK.clearData();
224
+
225
+ // Reset SDK to uninitialized state (does NOT clear stored data)
226
+ LinkFortySDK.reset();
227
+ ```
228
+
229
+ ## Platform Setup (app.json / app.config.js)
230
+
231
+ ```json
232
+ {
233
+ "expo": {
234
+ "ios": {
235
+ "associatedDomains": ["applinks:go.yourdomain.com"]
236
+ },
237
+ "android": {
238
+ "intentFilters": [
239
+ {
240
+ "action": "VIEW",
241
+ "autoVerify": true,
242
+ "data": [
243
+ {
244
+ "scheme": "https",
245
+ "host": "go.yourdomain.com"
246
+ }
247
+ ],
248
+ "category": ["BROWSABLE", "DEFAULT"]
249
+ }
250
+ ]
251
+ }
252
+ }
253
+ }
254
+ ```
255
+
256
+ LinkForty automatically serves `/.well-known/apple-app-site-association` (iOS) and `/.well-known/assetlinks.json` (Android) from your domain. No manual file hosting needed.
257
+
258
+ ## Complete Integration Example (Expo Router)
259
+
260
+ ```typescript
261
+ // app/_layout.tsx
262
+ import { useEffect } from 'react';
263
+ import { Stack, useRouter } from 'expo-router';
264
+ import { Share } from 'react-native';
265
+ import LinkFortySDK, { LinkFortyError } from '@linkforty/mobile-sdk-expo';
266
+
267
+ export default function RootLayout() {
268
+ const router = useRouter();
269
+
270
+ useEffect(() => {
271
+ async function initLinkForty() {
272
+ try {
273
+ const attribution = await LinkFortySDK.initialize({
274
+ baseUrl: 'https://go.yourdomain.com',
275
+ apiKey: 'lf_live_abc123',
276
+ appToken: 'at_a1b2c3d4...',
277
+ debug: __DEV__,
278
+ });
279
+
280
+ // Handle deferred deep links (first open after install)
281
+ LinkFortySDK.onDeferredDeepLink((data) => {
282
+ if (data?.customParameters?.route) {
283
+ router.push(`/${data.customParameters.route}/${data.customParameters.id}`);
284
+ }
285
+ });
286
+
287
+ // Handle direct deep links (app already installed)
288
+ LinkFortySDK.onDeepLink((url, data) => {
289
+ if (data?.customParameters?.route) {
290
+ router.push(`/${data.customParameters.route}/${data.customParameters.id}`);
291
+ }
292
+ });
293
+
294
+ if (attribution.attributed) {
295
+ console.log('Install attributed! Confidence:', attribution.confidenceScore);
296
+ }
297
+ } catch (error) {
298
+ if (error instanceof LinkFortyError) {
299
+ console.error('LinkForty error:', error.code, error.message);
300
+ }
301
+ }
302
+ }
303
+
304
+ initLinkForty();
305
+ }, []);
306
+
307
+ return (
308
+ <Stack>
309
+ <Stack.Screen name="index" />
310
+ <Stack.Screen name="product/[id]" />
311
+ <Stack.Screen name="profile/[id]" />
312
+ </Stack>
313
+ );
314
+ }
315
+
316
+ // Share a deep link from anywhere in your app
317
+ export async function shareProduct(productId: string, productName: string) {
318
+ const result = await LinkFortySDK.createLink({
319
+ deepLinkParameters: { route: 'product', productId },
320
+ title: productName,
321
+ utmParameters: { source: 'in_app_share', medium: 'referral' },
322
+ });
323
+
324
+ await Share.share({
325
+ message: `Check out ${productName}! ${result.url}`,
326
+ url: result.url,
327
+ });
328
+ }
329
+
330
+ // Track a conversion from anywhere in your app
331
+ export async function trackPurchase(orderId: string, amount: number) {
332
+ await LinkFortySDK.trackRevenue(amount, 'USD', { orderId });
333
+ }
334
+ ```
335
+
336
+ ## Error Handling
337
+
338
+ All SDK methods throw `LinkFortyError` with a `.code` property:
339
+
340
+ ```typescript
341
+ import LinkFortySDK, { LinkFortyError } from '@linkforty/mobile-sdk-expo';
342
+
343
+ try {
344
+ await LinkFortySDK.initialize({ baseUrl: 'https://go.yourdomain.com' });
345
+ } catch (error) {
346
+ if (error instanceof LinkFortyError) {
347
+ switch (error.code) {
348
+ case 'ALREADY_INITIALIZED':
349
+ // SDK was already initialized — safe to ignore
350
+ break;
351
+ case 'INVALID_CONFIGURATION':
352
+ console.error('Check your baseUrl:', error.message);
353
+ break;
354
+ case 'NETWORK_ERROR':
355
+ console.error('Could not reach server:', error.message);
356
+ break;
357
+ default:
358
+ console.error('LinkForty error:', error.code, error.message);
359
+ }
360
+ }
361
+ }
362
+ ```
363
+
364
+ Error codes: `NOT_INITIALIZED`, `ALREADY_INITIALIZED`, `INVALID_CONFIGURATION`, `NETWORK_ERROR`, `INVALID_RESPONSE`, `DECODING_ERROR`, `INVALID_EVENT_DATA`, `MISSING_API_KEY`.
365
+
366
+ ## Testing Deep Links
367
+
368
+ ```bash
369
+ # iOS Simulator
370
+ xcrun simctl openurl booted "https://go.yourdomain.com/abc123"
371
+
372
+ # Android Emulator
373
+ adb shell am start -a android.intent.action.VIEW -d "https://go.yourdomain.com/abc123"
374
+
375
+ # Expo CLI
376
+ npx uri-scheme open "https://go.yourdomain.com/abc123" --ios
377
+ npx uri-scheme open "https://go.yourdomain.com/abc123" --android
378
+ ```
379
+
380
+ ## Self-Hosted vs Cloud
381
+
382
+ The only change is the `baseUrl`:
383
+
384
+ ```typescript
385
+ // Cloud (managed SaaS)
386
+ await LinkFortySDK.initialize({ baseUrl: 'https://go.linkforty.com' });
387
+
388
+ // Self-hosted (@linkforty/core)
389
+ await LinkFortySDK.initialize({ baseUrl: 'https://links.yourdomain.com' });
390
+ ```
391
+
392
+ All SDK methods work identically with both.
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "deep-link-expo",
3
+ "version": "1.0.0",
4
+ "description": "Expo SDK for self-hosted deferred deep linking, attribution, and smart link routing.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "node scripts/gen-version.mjs && tsc",
9
+ "prepare": "npm run build",
10
+ "prepublishOnly": "npm run build",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "test:coverage": "vitest run --coverage"
14
+ },
15
+ "keywords": [
16
+ "deep-linking",
17
+ "deferred-deep-linking",
18
+ "mobile-attribution",
19
+ "expo",
20
+ "react-native",
21
+ "universal-links",
22
+ "app-links",
23
+ "self-hosted",
24
+ "open-source"
25
+ ],
26
+ "author": "Parth24072001",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/Parth24072001/deep-link.git",
31
+ "directory": "packages/sdk"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/Parth24072001/deep-link/issues"
35
+ },
36
+ "homepage": "https://github.com/Parth24072001/deep-link#readme",
37
+ "engines": {
38
+ "node": ">=20.0.0",
39
+ "npm": ">=10.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@react-navigation/native": ">=6.0.0",
43
+ "expo": ">=51.0.0",
44
+ "react": ">=18.0.0",
45
+ "react-native": ">=0.74.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@react-navigation/native": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "@react-native-async-storage/async-storage": "^2.1.0",
54
+ "expo-application": "~6.0.0",
55
+ "expo-device": "~7.0.0",
56
+ "expo-linking": "~7.0.0",
57
+ "expo-localization": "~16.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@semantic-release/changelog": "^6.0.3",
61
+ "@semantic-release/git": "^10.0.1",
62
+ "@semantic-release/github": "^10.3.5",
63
+ "@semantic-release/npm": "^13.1.5",
64
+ "@types/react": "^19.0.0",
65
+ "@vitest/coverage-v8": "^3.0.0",
66
+ "conventional-changelog-conventionalcommits": "^7.0.2",
67
+ "semantic-release": "^25.0.3",
68
+ "typescript": "^5.7.0",
69
+ "vitest": "^3.0.0"
70
+ },
71
+ "files": [
72
+ "dist/**/*",
73
+ "src/**/*",
74
+ "llms.txt",
75
+ "README.md",
76
+ "CHANGELOG.md",
77
+ "LICENSE"
78
+ ],
79
+ "publishConfig": {
80
+ "access": "public",
81
+ "registry": "https://registry.npmjs.org/"
82
+ }
83
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * AttributionContext — last-click attribution + session tracking (SIT-237).
3
+ *
4
+ * Mirrors the React Native SDK. Every deep-link open (deferred install OR direct
5
+ * re-engagement) pins an active attribution context to THAT link; the newest
6
+ * open supersedes the previous one. Every tracked event is stamped with the
7
+ * active context + a session id so the backend can credit the link under a
8
+ * last-click + window model. The active context is persisted so a reopen without
9
+ * a new click still attributes to the last link; the session is in-memory (a cold
10
+ * start is a new session).
11
+ *
12
+ * Uses AsyncStorage directly (not StorageManager) to stay self-contained.
13
+ */
14
+
15
+ import AsyncStorage from '@react-native-async-storage/async-storage';
16
+ import { STORAGE_KEYS } from '../storage/storage-keys';
17
+ import type { ActiveAttribution, AttributionStamp } from '../models/attribution';
18
+ import { logger } from '../logger';
19
+
20
+ /**
21
+ * RFC4122-v4-style id for session grouping. Not a security token — `Math.random`
22
+ * is sufficient and avoids a native crypto dependency.
23
+ */
24
+ function generateSessionId(): string {
25
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
26
+ const r = (Math.random() * 16) | 0;
27
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
28
+ return v.toString(16);
29
+ });
30
+ }
31
+
32
+ export class AttributionContext {
33
+ private active: ActiveAttribution | null = null;
34
+ private sessionId: string;
35
+ private loaded = false;
36
+
37
+ constructor() {
38
+ // Construction == cold start == a new session.
39
+ this.sessionId = generateSessionId();
40
+ }
41
+
42
+ /** Restore the persisted active context. Idempotent; never throws. */
43
+ async load(): Promise<void> {
44
+ if (this.loaded) return;
45
+ try {
46
+ const raw = await AsyncStorage.getItem(STORAGE_KEYS.ATTRIBUTION);
47
+ if (raw) {
48
+ this.active = JSON.parse(raw) as ActiveAttribution;
49
+ }
50
+ } catch (e) {
51
+ logger.warn('Failed to load attribution context:', e);
52
+ }
53
+ this.loaded = true;
54
+ }
55
+
56
+ /**
57
+ * Record a deep-link open. Newest open supersedes (last-click) and starts a new
58
+ * session. No-op when no `linkId` is known (organic open).
59
+ */
60
+ async recordDeepLinkOpen(linkId?: string | null, clickId?: string | null): Promise<void> {
61
+ if (!linkId) return;
62
+
63
+ this.active = {
64
+ linkId,
65
+ clickId: clickId ?? undefined,
66
+ openedAt: new Date().toISOString(),
67
+ };
68
+ this.sessionId = generateSessionId();
69
+
70
+ try {
71
+ await AsyncStorage.setItem(STORAGE_KEYS.ATTRIBUTION, JSON.stringify(this.active));
72
+ } catch (e) {
73
+ logger.warn('Failed to persist attribution context:', e);
74
+ }
75
+ logger.log('Attribution context set:', this.active, 'session:', this.sessionId);
76
+ }
77
+
78
+ /** Fields to merge into every event payload. */
79
+ getStamp(): AttributionStamp {
80
+ return {
81
+ attributedLinkId: this.active?.linkId,
82
+ attributedClickId: this.active?.clickId,
83
+ linkOpenedAt: this.active?.openedAt,
84
+ sessionId: this.sessionId,
85
+ };
86
+ }
87
+
88
+ getSessionId(): string {
89
+ return this.sessionId;
90
+ }
91
+
92
+ getActive(): ActiveAttribution | null {
93
+ return this.active;
94
+ }
95
+
96
+ /** Clear the persisted context and start a fresh session. */
97
+ async clear(): Promise<void> {
98
+ this.active = null;
99
+ this.sessionId = generateSessionId();
100
+ try {
101
+ await AsyncStorage.removeItem(STORAGE_KEYS.ATTRIBUTION);
102
+ } catch (e) {
103
+ logger.warn('Failed to clear attribution context:', e);
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,126 @@
1
+ import type { FingerprintCollectorProtocol } from '../fingerprint/fingerprint-collector';
2
+ import type { NetworkManagerProtocol } from '../network/network-manager';
3
+ import type { StorageManagerProtocol } from '../storage/storage-manager';
4
+ import type { InstallAttributionResponse } from '../models/install-response';
5
+ import type { DeepLinkData } from '../models/deep-link-data';
6
+ import { logger } from '../logger';
7
+ import { SDK_NAME, SDK_VERSION } from '../version';
8
+
9
+ export class AttributionManager {
10
+ private readonly network: NetworkManagerProtocol;
11
+ private readonly storage: StorageManagerProtocol;
12
+ private readonly fingerprint: FingerprintCollectorProtocol;
13
+
14
+ constructor(
15
+ network: NetworkManagerProtocol,
16
+ storage: StorageManagerProtocol,
17
+ fingerprint: FingerprintCollectorProtocol,
18
+ ) {
19
+ this.network = network;
20
+ this.storage = storage;
21
+ this.fingerprint = fingerprint;
22
+ }
23
+
24
+ async reportInstall(
25
+ attributionWindowHours: number,
26
+ deviceId?: string,
27
+ appToken?: string,
28
+ ): Promise<InstallAttributionResponse> {
29
+ const isFirst = await this.storage.isFirstLaunch();
30
+
31
+ if (!isFirst) {
32
+ return this.buildCachedResponse();
33
+ }
34
+
35
+ const fp = this.fingerprint.collect(attributionWindowHours, deviceId);
36
+ logger.log('Reporting install with fingerprint:', fp);
37
+
38
+ let response: InstallAttributionResponse;
39
+ try {
40
+ response = await this.network.request<InstallAttributionResponse>(
41
+ '/api/sdk/v1/install',
42
+ {
43
+ method: 'POST',
44
+ // appToken (when provided) lets Cloud scope organic installs
45
+ // to the right workspace. Omitted from the body when undefined.
46
+ // sdkName/sdkVersion (SIT-235) identify the SDK for health diagnostics.
47
+ body: JSON.stringify({
48
+ ...fp,
49
+ ...(appToken ? { appToken } : {}),
50
+ sdkName: SDK_NAME,
51
+ sdkVersion: SDK_VERSION,
52
+ }),
53
+ },
54
+ );
55
+ } catch (e) {
56
+ logger.error('Failed to report install:', e);
57
+ // Treat as organic on failure
58
+ await this.storage.setHasLaunched();
59
+ return {
60
+ installId: '',
61
+ attributed: false,
62
+ confidenceScore: 0,
63
+ matchedFactors: [],
64
+ deepLinkData: null,
65
+ };
66
+ }
67
+
68
+ logger.log('Install response:', response);
69
+
70
+ // Cache install ID
71
+ if (response.installId) {
72
+ await this.storage.saveInstallId(response.installId);
73
+ }
74
+
75
+ // Cache deep link data if attributed
76
+ if (response.attributed && response.deepLinkData) {
77
+ // Normalize deepLinkParameters -> customParameters
78
+ const deepLinkData: DeepLinkData = {
79
+ ...response.deepLinkData,
80
+ customParameters:
81
+ (response.deepLinkData as DeepLinkData & { deepLinkParameters?: Record<string, string> })
82
+ .deepLinkParameters ?? response.deepLinkData.customParameters,
83
+ };
84
+ await this.storage.saveInstallData(deepLinkData);
85
+ logger.log('Install attributed with confidence:', response.confidenceScore);
86
+
87
+ // Return with normalized data
88
+ response = { ...response, deepLinkData };
89
+ } else {
90
+ logger.log('Organic install (no attribution)');
91
+ }
92
+
93
+ await this.storage.setHasLaunched();
94
+ return response;
95
+ }
96
+
97
+ async getInstallId(): Promise<string | null> {
98
+ return this.storage.getInstallId();
99
+ }
100
+
101
+ async getInstallData(): Promise<DeepLinkData | null> {
102
+ return this.storage.getInstallData();
103
+ }
104
+
105
+ async isFirstLaunch(): Promise<boolean> {
106
+ return this.storage.isFirstLaunch();
107
+ }
108
+
109
+ async clearData(): Promise<void> {
110
+ await this.storage.clearAll();
111
+ logger.log('Attribution data cleared');
112
+ }
113
+
114
+ private async buildCachedResponse(): Promise<InstallAttributionResponse> {
115
+ const installId = await this.storage.getInstallId();
116
+ const deepLinkData = await this.storage.getInstallData();
117
+
118
+ return {
119
+ installId: installId ?? '',
120
+ attributed: deepLinkData !== null,
121
+ confidenceScore: deepLinkData ? 100 : 0,
122
+ matchedFactors: [],
123
+ deepLinkData,
124
+ };
125
+ }
126
+ }