@linkforty/mobile-sdk-react-native 1.1.2 → 1.1.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.3] - 2026-02-12
11
+ ### Fixed
12
+ - Replaced `new URL()` and `URLSearchParams` usage with manual string parsing — `URL.pathname` is not implemented in React Native's Hermes engine, which caused `parseURL()` to crash silently and skip server-side deep link resolution entirely
13
+ - Rounded screen dimensions to integers in `FingerprintCollector` — Android's `Dimensions.get('window')` returns floats (e.g. `434.717`) which caused PostgreSQL INSERT errors on INTEGER columns in the install endpoint
14
+
10
15
  ## [1.1.2] - 2026-02-12
11
16
  ### Added
12
17
  - `createLink()` method for creating short links programmatically from mobile apps via the LinkForty API
package/README.md CHANGED
@@ -4,20 +4,19 @@ Official React Native SDK for [LinkForty](https://github.com/linkforty/core) - O
4
4
 
5
5
  ## Features
6
6
 
7
- - **Deferred Deep Linking** - Route new users to specific content after install
8
- - **Direct Deep Linking** - Handle Universal Links (iOS) and App Links (Android)
9
- - **Probabilistic Attribution** - Match installs to clicks with 70%+ confidence
10
- - **Event Tracking** - Track in-app events with attribution
11
- - **Privacy-Focused** - No persistent device IDs required
12
- - **TypeScript Support** - Full type definitions included
13
- - **Works with Core & Cloud** - Compatible with self-hosted and Cloud instances
7
+ - **Direct Deep Linking** - Handle Universal Links (iOS) and App Links (Android) with automatic server-side resolution
8
+ - **Deferred Deep Linking** - Route new users to specific content after install
9
+ - **Link Creation** - Create short links programmatically from your app
10
+ - **Probabilistic Attribution** - Match installs to clicks via device fingerprinting
11
+ - **Event Tracking** - Track in-app events with attribution
12
+ - **Privacy-Focused** - No persistent device IDs required
13
+ - **TypeScript Support** - Full type definitions included
14
+ - **Works with Core & Cloud** - Compatible with self-hosted and Cloud instances
14
15
 
15
16
  ## Installation
16
17
 
17
18
  ```bash
18
- npm i @linkforty/mobile-sdk-react-native
19
- # or
20
- yarn add @linkforty/mobile-sdk-react-native
19
+ npm install @linkforty/mobile-sdk-react-native
21
20
  ```
22
21
 
23
22
  ### Requirements
@@ -26,9 +25,7 @@ yarn add @linkforty/mobile-sdk-react-native
26
25
  - React >= 17.0.0
27
26
  - Node.js >= 20.0.0
28
27
 
29
- ### Additional Dependencies
30
-
31
- This SDK requires the following peer dependencies:
28
+ ### Peer Dependencies
32
29
 
33
30
  ```bash
34
31
  npm install @react-native-async-storage/async-storage react-native-device-info
@@ -44,16 +41,15 @@ cd ios && pod install
44
41
 
45
42
  2. Configure Universal Links in Xcode:
46
43
  - Open your project in Xcode
47
- - Select your app target Signing & Capabilities
44
+ - Select your app target > Signing & Capabilities
48
45
  - Add "Associated Domains" capability
49
- - Add domain: `applinks:go.yourdomain.com` (replace with your LinkForty or custom domain)
46
+ - Add domain: `applinks:go.yourdomain.com` (replace with your LinkForty domain)
50
47
 
51
- 3. Create AASA file on your server at:
48
+ 3. Host an Apple App Site Association (AASA) file at:
52
49
  ```
53
50
  https://go.yourdomain.com/.well-known/apple-app-site-association
54
51
  ```
55
52
 
56
- Example AASA file:
57
53
  ```json
58
54
  {
59
55
  "applinks": {
@@ -70,7 +66,7 @@ cd ios && pod install
70
66
 
71
67
  ### Android Setup
72
68
 
73
- 1. Configure App Links in `android/app/src/main/AndroidManifest.xml`:
69
+ 1. Add an App Links intent filter in `android/app/src/main/AndroidManifest.xml`:
74
70
 
75
71
  ```xml
76
72
  <activity android:name=".MainActivity">
@@ -84,12 +80,11 @@ cd ios && pod install
84
80
  </activity>
85
81
  ```
86
82
 
87
- 2. Create Digital Asset Links file on your server at:
83
+ 2. Host a Digital Asset Links file at:
88
84
  ```
89
85
  https://go.yourdomain.com/.well-known/assetlinks.json
90
86
  ```
91
87
 
92
- Example assetlinks.json:
93
88
  ```json
94
89
  [{
95
90
  "relation": ["delegate_permission/common.handle_all_urls"],
@@ -101,187 +96,223 @@ cd ios && pod install
101
96
  }]
102
97
  ```
103
98
 
99
+ 3. Ensure `MainActivity` preserves the Intent for React Native. In `MainActivity.kt`:
100
+
101
+ ```kotlin
102
+ override fun onNewIntent(intent: Intent) {
103
+ setIntent(intent) // Required for React Native's Linking.getInitialURL()
104
+ super.onNewIntent(intent)
105
+ }
106
+ ```
107
+
108
+ > **Note:** If you use other SDKs that consume Intent data (e.g., CleverTap), make sure they receive a **copy** of the URI in `onCreate` rather than consuming the original Intent, otherwise `Linking.getInitialURL()` may return `null`.
109
+
104
110
  ## Quick Start
105
111
 
106
112
  ```typescript
107
113
  import LinkForty from '@linkforty/mobile-sdk-react-native';
108
114
 
109
- // Initialize SDK (call in App.tsx or index.js)
115
+ // 1. Initialize the SDK (call once at app startup)
110
116
  await LinkForty.init({
111
117
  baseUrl: 'https://go.yourdomain.com',
112
- apiKey: 'optional-for-cloud-users', // Optional
113
- debug: __DEV__, // Enable debug logging in development
118
+ apiKey: 'your-api-key', // Required for createLink(), optional otherwise
119
+ debug: __DEV__,
114
120
  });
115
121
 
116
- // Handle deferred deep links (new installs)
117
- LinkForty.onDeferredDeepLink((deepLinkData) => {
118
- if (deepLinkData) {
119
- // User clicked a link before installing
120
- console.log('Deferred deep link:', deepLinkData);
122
+ // 2. Handle direct deep links (user taps a link while app is installed)
123
+ LinkForty.onDeepLink((url, data) => {
124
+ if (data?.customParameters) {
125
+ const { route, id } = data.customParameters;
126
+ // Navigate to the target screen
127
+ navigation.navigate(route, { id });
128
+ }
129
+ });
121
130
 
122
- // Navigate to specific content
123
- navigation.navigate('Product', {
124
- id: deepLinkData.utmParameters?.content
125
- });
131
+ // 3. Handle deferred deep links (user installs app after tapping a link)
132
+ LinkForty.onDeferredDeepLink((data) => {
133
+ if (data?.customParameters) {
134
+ const { route, id } = data.customParameters;
135
+ navigation.navigate(route, { id });
126
136
  } else {
127
- // Organic install (no link clicked)
128
- console.log('Organic install');
137
+ console.log('Organic install no link clicked');
129
138
  }
130
139
  });
140
+ ```
131
141
 
132
- // Handle direct deep links (existing users)
133
- LinkForty.onDeepLink((url, deepLinkData) => {
134
- console.log('Deep link opened:', url, deepLinkData);
142
+ ## API Reference
135
143
 
136
- if (deepLinkData) {
137
- // Navigate to specific content
138
- navigation.navigate('Product', {
139
- id: deepLinkData.utmParameters?.content
140
- });
141
- }
142
- });
144
+ ### `init(config)`
143
145
 
144
- // Track in-app events
145
- await LinkForty.trackEvent('purchase', {
146
- amount: 99.99,
147
- currency: 'USD',
148
- productId: 'premium_plan'
146
+ Initialize the SDK. Must be called before any other method.
147
+
148
+ ```typescript
149
+ await LinkForty.init({
150
+ baseUrl: 'https://go.yourdomain.com', // Required
151
+ apiKey: 'your-api-key', // Optional (required for createLink)
152
+ debug: true, // Optional (default: false)
153
+ attributionWindow: 7, // Optional, in days (default: 7)
149
154
  });
150
155
  ```
151
156
 
152
- ## API Reference
153
-
154
- ### `init(config: LinkFortyConfig): Promise<void>`
157
+ | Parameter | Type | Required | Description |
158
+ |-----------|------|----------|-------------|
159
+ | `baseUrl` | `string` | Yes | Base URL of your LinkForty instance |
160
+ | `apiKey` | `string` | No | API key for authenticated endpoints (Cloud) |
161
+ | `debug` | `boolean` | No | Enable debug logging |
162
+ | `attributionWindow` | `number` | No | Attribution window in days (default: 7) |
155
163
 
156
- Initialize the SDK. Must be called before using any other methods.
164
+ ---
157
165
 
158
- **Parameters:**
166
+ ### `onDeepLink(callback)`
159
167
 
160
- - `config.baseUrl` (string, required) - Base URL of your LinkForty instance
161
- - `config.apiKey` (string, optional) - API key for Cloud authentication
162
- - `config.debug` (boolean, optional) - Enable debug logging
163
- - `config.attributionWindow` (number, optional) - Attribution window in days (default: 7)
168
+ Listen for direct deep links. Called when the app is opened via a Universal Link (iOS) or App Link (Android).
164
169
 
165
- **Example:**
170
+ When the URL matches your LinkForty `baseUrl`, the SDK automatically resolves it via the server to retrieve the full link data including `customParameters`, UTM parameters, and metadata. If resolution fails, the SDK falls back to local URL parsing.
166
171
 
167
172
  ```typescript
168
- await LinkForty.init({
169
- baseUrl: 'https://go.yourdomain.com',
170
- debug: true,
171
- attributionWindow: 7,
173
+ LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => {
174
+ console.log('Link URL:', url);
175
+
176
+ if (data?.customParameters) {
177
+ const { route, id } = data.customParameters;
178
+ navigation.navigate(route, { id });
179
+ }
172
180
  });
173
181
  ```
174
182
 
175
- ### `onDeferredDeepLink(callback: (deepLinkData: DeepLinkData | null) => void): void`
183
+ | Callback Parameter | Type | Description |
184
+ |--------------------|------|-------------|
185
+ | `url` | `string` | The full URL that opened the app |
186
+ | `data` | `DeepLinkData \| null` | Parsed link data, or `null` if parsing failed |
176
187
 
177
- Register a callback for deferred deep links. Called when the app is launched for the first time after installation.
178
-
179
- **Parameters:**
188
+ ---
180
189
 
181
- - `callback` - Function called with deep link data or `null` for organic installs
190
+ ### `onDeferredDeepLink(callback)`
182
191
 
183
- **Example:**
192
+ Listen for deferred deep links. Called on first launch after install if the user clicked a LinkForty link before installing.
184
193
 
185
194
  ```typescript
186
- LinkForty.onDeferredDeepLink((deepLinkData) => {
187
- if (deepLinkData) {
188
- // Attributed install
189
- console.log('User came from:', deepLinkData.utmParameters?.source);
195
+ LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => {
196
+ if (data) {
197
+ // Attributed install — user clicked a link before installing
198
+ console.log('Came from:', data.utmParameters?.source);
199
+ const { route, id } = data.customParameters || {};
200
+ if (route) navigation.navigate(route, { id });
190
201
  } else {
191
- // Organic install
202
+ // Organic install or attribution failed
192
203
  console.log('Organic install');
193
204
  }
194
205
  });
195
206
  ```
196
207
 
197
- ### `onDeepLink(callback: (url: string, deepLinkData: DeepLinkData | null) => void): void`
208
+ | Callback Parameter | Type | Description |
209
+ |--------------------|------|-------------|
210
+ | `data` | `DeepLinkData \| null` | Attributed link data, or `null` for organic installs |
198
211
 
199
- Register a callback for direct deep links. Called when the app is opened via a Universal Link (iOS) or App Link (Android).
212
+ ---
200
213
 
201
- **Parameters:**
214
+ ### `createLink(options)`
202
215
 
203
- - `callback` - Function called with the full URL and parsed deep link data
216
+ Create a short link programmatically. Requires `apiKey` to be set in `init()`.
204
217
 
205
- **Example:**
218
+ When `templateId` is omitted, the server auto-selects your organization's default template.
206
219
 
207
220
  ```typescript
208
- LinkForty.onDeepLink((url, deepLinkData) => {
209
- if (deepLinkData?.shortCode) {
210
- // Navigate based on link
211
- navigation.navigate('Details', { id: deepLinkData.shortCode });
212
- }
221
+ const result = await LinkForty.createLink({
222
+ deepLinkParameters: { route: 'VIDEO_VIEWER', id: 'e4338ed6-...' },
223
+ title: 'Check out this video',
224
+ utmParameters: { source: 'share', medium: 'app' },
213
225
  });
226
+
227
+ console.log(result.url); // https://go.yourdomain.com/tmpl/abc123
228
+ console.log(result.shortCode); // abc123
229
+ console.log(result.linkId); // uuid
214
230
  ```
215
231
 
216
- ### `trackEvent(name: string, properties?: Record<string, any>): Promise<void>`
232
+ **Options:**
217
233
 
218
- Track an in-app event with optional properties.
234
+ | Parameter | Type | Required | Description |
235
+ |-----------|------|----------|-------------|
236
+ | `deepLinkParameters` | `Record<string, string>` | No | Custom parameters embedded in the link (e.g., `{ route, id }`) |
237
+ | `title` | `string` | No | Link title for internal reference |
238
+ | `description` | `string` | No | Link description |
239
+ | `customCode` | `string` | No | Custom short code (auto-generated if omitted) |
240
+ | `utmParameters` | `object` | No | UTM parameters (`source`, `medium`, `campaign`, `term`, `content`) |
241
+ | `templateId` | `string` | No | Template UUID (auto-selected if omitted) |
242
+ | `templateSlug` | `string` | No | Template slug (only needed with `templateId`) |
219
243
 
220
- **Parameters:**
244
+ **Returns:** `CreateLinkResult`
221
245
 
222
- - `name` - Event name (e.g., 'purchase', 'signup', 'add_to_cart')
223
- - `properties` - Optional event properties
246
+ | Field | Type | Description |
247
+ |-------|------|-------------|
248
+ | `url` | `string` | Full shareable URL |
249
+ | `shortCode` | `string` | The generated short code |
250
+ | `linkId` | `string` | Link UUID |
224
251
 
225
- **Example:**
252
+ ---
253
+
254
+ ### `trackEvent(name, properties?)`
255
+
256
+ Track an in-app event for attribution analytics. Requires a successful install report (automatic on first launch).
226
257
 
227
258
  ```typescript
228
259
  await LinkForty.trackEvent('purchase', {
229
260
  amount: 99.99,
230
261
  currency: 'USD',
231
262
  productId: 'premium_plan',
232
- category: 'subscription'
233
263
  });
234
264
  ```
235
265
 
236
- ### `getInstallData(): Promise<DeepLinkData | null>`
266
+ | Parameter | Type | Required | Description |
267
+ |-----------|------|----------|-------------|
268
+ | `name` | `string` | Yes | Event name (e.g., `'purchase'`, `'signup'`) |
269
+ | `properties` | `Record<string, any>` | No | Arbitrary event properties |
237
270
 
238
- Get cached install attribution data.
271
+ ---
239
272
 
240
- **Returns:** Deep link data from install or `null` if not attributed
273
+ ### `getInstallData()`
241
274
 
242
- **Example:**
275
+ Retrieve cached install attribution data from a previous deferred deep link.
243
276
 
244
277
  ```typescript
245
- const installData = await LinkForty.getInstallData();
246
- if (installData) {
247
- console.log('Install source:', installData.utmParameters?.source);
278
+ const data = await LinkForty.getInstallData();
279
+ if (data) {
280
+ console.log('Install attributed to:', data.utmParameters?.source);
248
281
  }
249
282
  ```
250
283
 
251
- ### `getInstallId(): Promise<string | null>`
284
+ **Returns:** `DeepLinkData | null`
252
285
 
253
- Get the unique install ID for this app installation.
286
+ ---
254
287
 
255
- **Returns:** Install ID or `null` if not available
288
+ ### `getInstallId()`
256
289
 
257
- **Example:**
290
+ Get the unique install ID assigned by the LinkForty server on first launch.
258
291
 
259
292
  ```typescript
260
293
  const installId = await LinkForty.getInstallId();
261
- console.log('Install ID:', installId);
262
294
  ```
263
295
 
264
- ### `clearData(): Promise<void>`
296
+ **Returns:** `string | null`
265
297
 
266
- Clear all cached SDK data. Useful for testing.
298
+ ---
299
+
300
+ ### `clearData()`
267
301
 
268
- **Example:**
302
+ Clear all cached SDK data (install ID, attribution data, first-launch flag). The next app launch will behave as a fresh install.
269
303
 
270
304
  ```typescript
271
305
  await LinkForty.clearData();
272
- // App will behave as if it's a fresh install
273
306
  ```
274
307
 
275
- ## TypeScript Types
308
+ ## Types
276
309
 
277
310
  ### `DeepLinkData`
278
311
 
279
312
  ```typescript
280
313
  interface DeepLinkData {
281
314
  shortCode: string;
282
- iosUrl?: string;
283
- androidUrl?: string;
284
- webUrl?: string;
315
+ customParameters?: Record<string, string>;
285
316
  utmParameters?: {
286
317
  source?: string;
287
318
  medium?: string;
@@ -289,7 +320,11 @@ interface DeepLinkData {
289
320
  term?: string;
290
321
  content?: string;
291
322
  };
292
- customParameters?: Record<string, string>;
323
+ iosUrl?: string;
324
+ androidUrl?: string;
325
+ webUrl?: string;
326
+ deepLinkPath?: string;
327
+ appScheme?: string;
293
328
  clickedAt?: string;
294
329
  linkId?: string;
295
330
  }
@@ -306,79 +341,94 @@ interface LinkFortyConfig {
306
341
  }
307
342
  ```
308
343
 
309
- ## Advanced Usage
344
+ ### `CreateLinkOptions`
310
345
 
311
- ### Testing Deferred Deep Linking
346
+ ```typescript
347
+ interface CreateLinkOptions {
348
+ templateId?: string;
349
+ templateSlug?: string;
350
+ deepLinkParameters?: Record<string, string>;
351
+ title?: string;
352
+ description?: string;
353
+ customCode?: string;
354
+ utmParameters?: {
355
+ source?: string;
356
+ medium?: string;
357
+ campaign?: string;
358
+ term?: string;
359
+ content?: string;
360
+ };
361
+ }
362
+ ```
312
363
 
313
- 1. **Uninstall the app** or clear all data:
314
- ```typescript
315
- await LinkForty.clearData();
316
- ```
364
+ ### `CreateLinkResult`
317
365
 
318
- 2. **Click a LinkForty link** on your device (in Safari/Chrome, not in the app)
366
+ ```typescript
367
+ interface CreateLinkResult {
368
+ url: string;
369
+ shortCode: string;
370
+ linkId: string;
371
+ }
372
+ ```
319
373
 
320
- 3. **Install/Open the app** from App Store/Play Store
374
+ ## How Deep Linking Works
321
375
 
322
- 4. **Check logs** - you should see attribution data in the `onDeferredDeepLink` callback
376
+ ### Direct Deep Links (App Installed)
323
377
 
324
- ### Using with Self-Hosted LinkForty Core
378
+ When a user taps a LinkForty URL and the app is already installed:
325
379
 
326
- ```typescript
327
- // Point to your self-hosted instance
328
- await LinkForty.init({
329
- baseUrl: 'http://localhost:3000', // or your domain
330
- debug: true,
331
- });
332
- ```
380
+ 1. The OS intercepts the URL via App Links (Android) or Universal Links (iOS) and opens your app directly
381
+ 2. The SDK receives the URL via React Native's `Linking` API
382
+ 3. The SDK calls your LinkForty server's resolve endpoint to retrieve the full link data (`customParameters`, UTM params, etc.)
383
+ 4. Your `onDeepLink` callback fires with the resolved data
384
+ 5. Your app navigates to the target screen
333
385
 
334
- ### Using with LinkForty Cloud
335
-
336
- ```typescript
337
- // Add API key for Cloud authentication
338
- await LinkForty.init({
339
- baseUrl: 'https://go.yourdomain.com',
340
- apiKey: 'your-api-key-here',
341
- debug: false,
342
- });
343
- ```
386
+ ### Deferred Deep Links (App Not Installed)
344
387
 
345
- ### Custom Attribution Window
388
+ When a user taps a LinkForty URL and the app is **not** installed:
346
389
 
347
- ```typescript
348
- // Change attribution window to 14 days
349
- await LinkForty.init({
350
- baseUrl: 'https://go.yourdomain.com',
351
- attributionWindow: 14, // days
352
- });
353
- ```
390
+ 1. The LinkForty server records a click with the user's device fingerprint
391
+ 2. The user is redirected to the App Store / Play Store
392
+ 3. After installing and opening the app, the SDK reports the install with the device's fingerprint
393
+ 4. The server matches the fingerprint to the original click (probabilistic attribution)
394
+ 5. Your `onDeferredDeepLink` callback fires with the matched link data
395
+ 6. Your app navigates to the content the user originally clicked on
354
396
 
355
397
  ## Troubleshooting
356
398
 
357
399
  ### Deep links not working on iOS
358
400
 
359
- 1. Verify your AASA file is accessible at `https://go.yourdomain.com/.well-known/apple-app-site-association`
360
- 2. Check that your Team ID and Bundle ID are correct
361
- 3. Make sure "Associated Domains" capability is added in Xcode
362
- 4. Test with a real device (Universal Links don't work in simulator)
401
+ 1. Verify AASA file is accessible at `https://yourdomain.com/.well-known/apple-app-site-association`
402
+ 2. Check that Team ID and Bundle ID are correct in the AASA file
403
+ 3. Confirm "Associated Domains" capability is added in Xcode with `applinks:yourdomain.com`
404
+ 4. Test on a real device (Universal Links don't work in the simulator)
363
405
 
364
406
  ### Deep links not working on Android
365
407
 
366
- 1. Verify your assetlinks.json file is accessible at `https://go.yourdomain.com/.well-known/assetlinks.json`
367
- 2. Check that your package name and SHA256 fingerprint are correct
368
- 3. Run `adb shell pm get-app-links com.yourapp` to verify link verification status
369
- 4. Make sure `android:autoVerify="true"` is set in your intent filter
408
+ 1. Verify assetlinks.json is accessible at `https://yourdomain.com/.well-known/assetlinks.json`
409
+ 2. Check package name and SHA256 fingerprint are correct
410
+ 3. Run `adb shell pm get-app-links com.yourapp` to check link verification status
411
+ 4. Confirm `android:autoVerify="true"` is set in your intent filter
412
+ 5. Ensure `MainActivity` calls `setIntent(intent)` in `onNewIntent` (see [Android Setup](#android-setup))
413
+
414
+ ### `getInitialURL()` returns null on Android
415
+
416
+ This usually means another SDK or library is consuming the Intent data before React Native reads it. In `MainActivity.kt`:
417
+
418
+ - Call `setIntent(intent)` in `onNewIntent` so React Native sees the updated Intent on warm starts
419
+ - If using CleverTap or similar SDKs, pass a **copy** of the URI to them in `onCreate` rather than the original Intent data
370
420
 
371
421
  ### Deferred deep links not attributing
372
422
 
373
- 1. Make sure you're testing on first install (or call `clearData()`)
374
- 2. Check debug logs for fingerprint data
375
- 3. Verify your LinkForty backend is receiving the install event
423
+ 1. Confirm this is a first install (or call `clearData()` first)
424
+ 2. Enable `debug: true` and check logs for fingerprint data
425
+ 3. Verify your LinkForty server received the install event
376
426
  4. Ensure the click and install happen within the attribution window (default: 7 days)
377
- 5. Try clicking the link and installing from the same network
427
+ 5. Test on the same network for best fingerprint match accuracy
378
428
 
379
429
  ### TypeScript errors
380
430
 
381
- Make sure you have the latest type definitions installed:
431
+ Ensure peer dependencies are installed:
382
432
 
383
433
  ```bash
384
434
  npm install --save-dev @types/react @types/react-native
@@ -386,25 +436,18 @@ npm install --save-dev @types/react @types/react-native
386
436
 
387
437
  ## Contributing
388
438
 
389
- Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
439
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
390
440
 
391
441
  ## License
392
442
 
393
- MIT License - see [LICENSE](LICENSE) file for details
443
+ MIT License - see [LICENSE](LICENSE) for details.
394
444
 
395
445
  ## Support
396
446
 
397
- - **Documentation:** Coming soon (self-hosting guide available in Core repository)
398
- - **Issues:** [Report on GitHub](https://github.com/linkforty/react-native-sdk/issues)
399
- - **Questions:** Open a GitHub Discussion or Issue
447
+ - **Issues:** [GitHub Issues](https://github.com/linkforty/react-native-sdk/issues)
448
+ - **Documentation:** [LinkForty Core](https://github.com/linkforty/core)
400
449
 
401
450
  ## Related Projects
402
451
 
403
452
  - [LinkForty Cloud](https://linkforty.com) - Cloud platform with dashboard and API
404
453
  - [LinkForty Core](https://github.com/linkforty/core) - Self-hosted open-source backend
405
- - **iOS SDK** - Native Swift SDK (planned for future release)
406
- - **Android SDK** - Native Kotlin SDK (planned for future release)
407
-
408
- ---
409
-
410
- Made with ❤️ by the LinkForty team
@@ -12,6 +12,16 @@ export declare class DeepLinkHandler {
12
12
  private callback;
13
13
  private baseUrl;
14
14
  private resolveFn;
15
+ /**
16
+ * Parse a URL string manually.
17
+ * The URL/URLSearchParams APIs are not fully implemented in React Native's Hermes engine
18
+ * (URL.pathname throws "not implemented"), so we parse with basic string operations.
19
+ */
20
+ private static parseUrlString;
21
+ /**
22
+ * Build a query string from key-value pairs without URLSearchParams.
23
+ */
24
+ private static buildQueryString;
15
25
  /**
16
26
  * Initialize deep link listener
17
27
  * @param baseUrl - LinkForty instance base URL for detecting LinkForty URLs
@@ -1 +1 @@
1
- {"version":3,"file":"DeepLinkHandler.d.ts","sourceRoot":"","sources":["../src/DeepLinkHandler.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/E,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,SAAS,CAAgC;IAEjD;;;;;OAKG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,EAAE,SAAS,CAAC,EAAE,eAAe,GAAG,IAAI;IAgB1F;;OAEG;IACH,OAAO,IAAI,IAAI;IAKf;;;;OAIG;IACH,OAAO,CAAC,cAAc,CAuBpB;IAEF;;;;OAIG;YACW,UAAU;IA2CxB;;;OAGG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;CA4C3C"}
1
+ {"version":3,"file":"DeepLinkHandler.d.ts","sourceRoot":"","sources":["../src/DeepLinkHandler.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/E,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,SAAS,CAAgC;IAEjD;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,cAAc;IAsC7B;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,gBAAgB;IAM/B;;;;;OAKG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,EAAE,SAAS,CAAC,EAAE,eAAe,GAAG,IAAI;IAqB1F;;OAEG;IACH,OAAO,IAAI,IAAI;IAKf;;;;OAIG;IACH,OAAO,CAAC,cAAc,CAiCpB;IAEF;;;;OAIG;YACW,UAAU;IAgDxB;;;OAGG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;CA+C3C"}
@@ -13,6 +13,51 @@ export class DeepLinkHandler {
13
13
  callback = null;
14
14
  baseUrl = null;
15
15
  resolveFn = null;
16
+ /**
17
+ * Parse a URL string manually.
18
+ * The URL/URLSearchParams APIs are not fully implemented in React Native's Hermes engine
19
+ * (URL.pathname throws "not implemented"), so we parse with basic string operations.
20
+ */
21
+ static parseUrlString(url) {
22
+ try {
23
+ const protocolEnd = url.indexOf('://');
24
+ if (protocolEnd === -1)
25
+ return null;
26
+ const afterProtocol = url.substring(protocolEnd + 3);
27
+ const pathStart = afterProtocol.indexOf('/');
28
+ const pathAndQuery = pathStart === -1 ? '/' : afterProtocol.substring(pathStart);
29
+ const hashIndex = pathAndQuery.indexOf('#');
30
+ const withoutHash = hashIndex === -1 ? pathAndQuery : pathAndQuery.substring(0, hashIndex);
31
+ const queryStart = withoutHash.indexOf('?');
32
+ const pathname = queryStart === -1 ? withoutHash : withoutHash.substring(0, queryStart);
33
+ const queryString = queryStart === -1 ? '' : withoutHash.substring(queryStart + 1);
34
+ const searchParams = new Map();
35
+ if (queryString) {
36
+ for (const pair of queryString.split('&')) {
37
+ const eqIndex = pair.indexOf('=');
38
+ if (eqIndex === -1) {
39
+ searchParams.set(decodeURIComponent(pair), '');
40
+ }
41
+ else {
42
+ searchParams.set(decodeURIComponent(pair.substring(0, eqIndex)), decodeURIComponent(pair.substring(eqIndex + 1)));
43
+ }
44
+ }
45
+ }
46
+ return { pathname, searchParams };
47
+ }
48
+ catch (error) {
49
+ console.error('[LinkForty] Failed to parse URL string:', error);
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Build a query string from key-value pairs without URLSearchParams.
55
+ */
56
+ static buildQueryString(params) {
57
+ return Object.entries(params)
58
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
59
+ .join('&');
60
+ }
16
61
  /**
17
62
  * Initialize deep link listener
18
63
  * @param baseUrl - LinkForty instance base URL for detecting LinkForty URLs
@@ -23,13 +68,18 @@ export class DeepLinkHandler {
23
68
  this.baseUrl = baseUrl;
24
69
  this.callback = callback;
25
70
  this.resolveFn = resolveFn || null;
71
+ console.log('[LinkForty] DeepLinkHandler.initialize: baseUrl=', baseUrl, 'hasResolveFn=', !!resolveFn);
26
72
  // Listen for deep links when app is already open
27
73
  Linking.addEventListener('url', this.handleDeepLink);
28
74
  // Check if app was opened via deep link
29
75
  Linking.getInitialURL().then((url) => {
76
+ console.log('[LinkForty] getInitialURL result:', url);
30
77
  if (url) {
31
78
  this.handleDeepLink({ url });
32
79
  }
80
+ else {
81
+ console.warn('[LinkForty] getInitialURL returned null — app may not have been opened via deep link, or Android consumed the Intent');
82
+ }
33
83
  });
34
84
  }
35
85
  /**
@@ -45,25 +95,35 @@ export class DeepLinkHandler {
45
95
  * Falls back to local URL parsing on failure.
46
96
  */
47
97
  handleDeepLink = async ({ url }) => {
98
+ console.log('[LinkForty] handleDeepLink called with url:', url);
48
99
  if (!this.callback || !url) {
100
+ console.warn('[LinkForty] handleDeepLink early return: callback=', !!this.callback, 'url=', url);
49
101
  return;
50
102
  }
51
103
  // Parse locally first (for fallback and to detect LinkForty URLs)
52
104
  const localData = this.parseURL(url);
105
+ console.log('[LinkForty] localData parsed:', JSON.stringify(localData));
53
106
  // If this is a LinkForty URL and we have a resolver, try the server
54
107
  if (localData && this.resolveFn && this.baseUrl && url.startsWith(this.baseUrl)) {
55
108
  try {
109
+ console.log('[LinkForty] Resolving URL via server...');
56
110
  const resolvedData = await this.resolveURL(url);
111
+ console.log('[LinkForty] Resolve result:', JSON.stringify(resolvedData));
57
112
  if (resolvedData) {
58
113
  this.callback(url, resolvedData);
59
114
  return;
60
115
  }
116
+ console.warn('[LinkForty] Resolve returned null, falling back to local parse');
61
117
  }
62
118
  catch (error) {
63
119
  console.warn('[LinkForty] Failed to resolve URL from server, falling back to local parse:', error);
64
120
  }
65
121
  }
66
- // Fallback to locally-parsed data
122
+ else {
123
+ console.warn('[LinkForty] Skipping server resolve: localData=', !!localData, 'resolveFn=', !!this.resolveFn, 'baseUrl=', this.baseUrl, 'urlMatch=', url.startsWith(this.baseUrl || ''));
124
+ }
125
+ // Fallback to locally-parsed data (no customParameters from server)
126
+ console.warn('[LinkForty] Using local fallback — customParameters will be empty for App Link URLs');
67
127
  this.callback(url, localData);
68
128
  };
69
129
  /**
@@ -75,8 +135,11 @@ export class DeepLinkHandler {
75
135
  if (!this.resolveFn) {
76
136
  return null;
77
137
  }
78
- const parsedUrl = new URL(url);
79
- const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
138
+ const parsed = DeepLinkHandler.parseUrlString(url);
139
+ if (!parsed) {
140
+ return null;
141
+ }
142
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
80
143
  if (pathSegments.length === 0) {
81
144
  return null;
82
145
  }
@@ -92,16 +155,17 @@ export class DeepLinkHandler {
92
155
  try {
93
156
  const fingerprint = await FingerprintCollector.collect();
94
157
  const [sw, sh] = fingerprint.screenResolution.split('x');
95
- const queryParams = new URLSearchParams();
96
- queryParams.set('fp_tz', fingerprint.timezone);
97
- queryParams.set('fp_lang', fingerprint.language);
98
- queryParams.set('fp_sw', sw);
99
- queryParams.set('fp_sh', sh);
100
- queryParams.set('fp_platform', fingerprint.platform);
158
+ const fpParams = {
159
+ fp_tz: fingerprint.timezone,
160
+ fp_lang: fingerprint.language,
161
+ fp_sw: sw,
162
+ fp_sh: sh,
163
+ fp_platform: fingerprint.platform,
164
+ };
101
165
  if (fingerprint.osVersion) {
102
- queryParams.set('fp_pv', fingerprint.osVersion);
166
+ fpParams.fp_pv = fingerprint.osVersion;
103
167
  }
104
- resolvePath += `?${queryParams.toString()}`;
168
+ resolvePath += `?${DeepLinkHandler.buildQueryString(fpParams)}`;
105
169
  }
106
170
  catch (error) {
107
171
  // If fingerprint collection fails, still resolve without it
@@ -115,28 +179,31 @@ export class DeepLinkHandler {
115
179
  */
116
180
  parseURL(url) {
117
181
  try {
118
- const parsedUrl = new URL(url);
119
182
  // Check if this is a LinkForty URL
120
183
  if (this.baseUrl && !url.startsWith(this.baseUrl)) {
121
184
  return null;
122
185
  }
186
+ const parsed = DeepLinkHandler.parseUrlString(url);
187
+ if (!parsed) {
188
+ return null;
189
+ }
123
190
  // Extract short code from path (last segment handles both /shortCode and /templateSlug/shortCode)
124
- const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
191
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
125
192
  const shortCode = pathSegments[pathSegments.length - 1];
126
193
  if (!shortCode) {
127
194
  return null;
128
195
  }
129
196
  // Extract UTM parameters
130
197
  const utmParameters = {
131
- source: parsedUrl.searchParams.get('utm_source') || undefined,
132
- medium: parsedUrl.searchParams.get('utm_medium') || undefined,
133
- campaign: parsedUrl.searchParams.get('utm_campaign') || undefined,
134
- term: parsedUrl.searchParams.get('utm_term') || undefined,
135
- content: parsedUrl.searchParams.get('utm_content') || undefined,
198
+ source: parsed.searchParams.get('utm_source') || undefined,
199
+ medium: parsed.searchParams.get('utm_medium') || undefined,
200
+ campaign: parsed.searchParams.get('utm_campaign') || undefined,
201
+ term: parsed.searchParams.get('utm_term') || undefined,
202
+ content: parsed.searchParams.get('utm_content') || undefined,
136
203
  };
137
204
  // Extract all other query parameters as custom parameters
138
205
  const customParameters = {};
139
- parsedUrl.searchParams.forEach((value, key) => {
206
+ parsed.searchParams.forEach((value, key) => {
140
207
  if (!key.startsWith('utm_')) {
141
208
  customParameters[key] = value;
142
209
  }
@@ -1 +1 @@
1
- {"version":3,"file":"FingerprintCollector.d.ts","sourceRoot":"","sources":["../src/FingerprintCollector.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,qBAAa,oBAAoB;IAC/B;;OAEG;WACU,OAAO,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAmBlD;;OAEG;WACU,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC;IAiBnD;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,WAAW;IAS1B;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,WAAW;CAuB3B"}
1
+ {"version":3,"file":"FingerprintCollector.d.ts","sourceRoot":"","sources":["../src/FingerprintCollector.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,qBAAa,oBAAoB;IAC/B;;OAEG;WACU,OAAO,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAoBlD;;OAEG;WACU,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC;IAoBnD;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,WAAW;IAS1B;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,WAAW;CAuB3B"}
@@ -11,11 +11,12 @@ export class FingerprintCollector {
11
11
  const { width, height } = Dimensions.get('window');
12
12
  const timezone = this.getTimezone();
13
13
  const language = this.getLanguage();
14
+ // Round dimensions — Android returns floats (e.g. 434.717) but the server expects integers
14
15
  const fingerprint = {
15
16
  userAgent: await DeviceInfo.getUserAgent(),
16
17
  timezone,
17
18
  language,
18
- screenResolution: `${width}x${height}`,
19
+ screenResolution: `${Math.round(width)}x${Math.round(height)}`,
19
20
  platform: Platform.OS,
20
21
  deviceModel: await DeviceInfo.getModel(),
21
22
  osVersion: await DeviceInfo.getSystemVersion(),
@@ -28,17 +29,23 @@ export class FingerprintCollector {
28
29
  */
29
30
  static async generateQueryParams() {
30
31
  const fingerprint = await this.collect();
31
- const params = new URLSearchParams({
32
+ // Build query string manually — URLSearchParams is not reliable in Hermes
33
+ const params = {
32
34
  ua: fingerprint.userAgent,
33
35
  tz: fingerprint.timezone,
34
36
  lang: fingerprint.language,
35
37
  screen: fingerprint.screenResolution,
36
38
  platform: fingerprint.platform,
37
- ...(fingerprint.deviceModel && { model: fingerprint.deviceModel }),
38
- ...(fingerprint.osVersion && { os: fingerprint.osVersion }),
39
- ...(fingerprint.appVersion && { app_version: fingerprint.appVersion }),
40
- });
41
- return params.toString();
39
+ };
40
+ if (fingerprint.deviceModel)
41
+ params.model = fingerprint.deviceModel;
42
+ if (fingerprint.osVersion)
43
+ params.os = fingerprint.osVersion;
44
+ if (fingerprint.appVersion)
45
+ params.app_version = fingerprint.appVersion;
46
+ return Object.entries(params)
47
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
48
+ .join('&');
42
49
  }
43
50
  /**
44
51
  * Get device timezone
@@ -255,7 +255,7 @@ export class LinkFortySDK {
255
255
  // Parse screen resolution (e.g., "1080x1920" -> [1080, 1920])
256
256
  const [screenWidth, screenHeight] = fingerprint.screenResolution
257
257
  .split('x')
258
- .map(Number);
258
+ .map((v) => Math.round(Number(v)));
259
259
  // Convert attribution window from days to hours
260
260
  const attributionWindowHours = (this.config.attributionWindow || 7) * 24;
261
261
  // Call install endpoint with flattened structure matching backend contract
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linkforty/mobile-sdk-react-native",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "React Native SDK for LinkForty - Open-source deep linking and mobile attribution platform",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -45,6 +45,7 @@
45
45
  "react-native": ">=0.64.0"
46
46
  },
47
47
  "dependencies": {
48
+ "@linkforty/mobile-sdk-react-native": "file:linkforty-mobile-sdk-react-native-1.1.2.tgz",
48
49
  "@react-native-async-storage/async-storage": "^2.2.0",
49
50
  "react-native-device-info": "^15.0.0"
50
51
  },
@@ -17,6 +17,58 @@ export class DeepLinkHandler {
17
17
  private baseUrl: string | null = null;
18
18
  private resolveFn: ResolveFunction | null = null;
19
19
 
20
+ /**
21
+ * Parse a URL string manually.
22
+ * The URL/URLSearchParams APIs are not fully implemented in React Native's Hermes engine
23
+ * (URL.pathname throws "not implemented"), so we parse with basic string operations.
24
+ */
25
+ private static parseUrlString(url: string): { pathname: string; searchParams: Map<string, string> } | null {
26
+ try {
27
+ const protocolEnd = url.indexOf('://');
28
+ if (protocolEnd === -1) return null;
29
+
30
+ const afterProtocol = url.substring(protocolEnd + 3);
31
+ const pathStart = afterProtocol.indexOf('/');
32
+ const pathAndQuery = pathStart === -1 ? '/' : afterProtocol.substring(pathStart);
33
+
34
+ const hashIndex = pathAndQuery.indexOf('#');
35
+ const withoutHash = hashIndex === -1 ? pathAndQuery : pathAndQuery.substring(0, hashIndex);
36
+
37
+ const queryStart = withoutHash.indexOf('?');
38
+ const pathname = queryStart === -1 ? withoutHash : withoutHash.substring(0, queryStart);
39
+ const queryString = queryStart === -1 ? '' : withoutHash.substring(queryStart + 1);
40
+
41
+ const searchParams = new Map<string, string>();
42
+ if (queryString) {
43
+ for (const pair of queryString.split('&')) {
44
+ const eqIndex = pair.indexOf('=');
45
+ if (eqIndex === -1) {
46
+ searchParams.set(decodeURIComponent(pair), '');
47
+ } else {
48
+ searchParams.set(
49
+ decodeURIComponent(pair.substring(0, eqIndex)),
50
+ decodeURIComponent(pair.substring(eqIndex + 1)),
51
+ );
52
+ }
53
+ }
54
+ }
55
+
56
+ return { pathname, searchParams };
57
+ } catch (error) {
58
+ console.error('[LinkForty] Failed to parse URL string:', error);
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build a query string from key-value pairs without URLSearchParams.
65
+ */
66
+ private static buildQueryString(params: Record<string, string>): string {
67
+ return Object.entries(params)
68
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
69
+ .join('&');
70
+ }
71
+
20
72
  /**
21
73
  * Initialize deep link listener
22
74
  * @param baseUrl - LinkForty instance base URL for detecting LinkForty URLs
@@ -28,13 +80,18 @@ export class DeepLinkHandler {
28
80
  this.callback = callback;
29
81
  this.resolveFn = resolveFn || null;
30
82
 
83
+ console.log('[LinkForty] DeepLinkHandler.initialize: baseUrl=', baseUrl, 'hasResolveFn=', !!resolveFn);
84
+
31
85
  // Listen for deep links when app is already open
32
86
  Linking.addEventListener('url', this.handleDeepLink);
33
87
 
34
88
  // Check if app was opened via deep link
35
89
  Linking.getInitialURL().then((url) => {
90
+ console.log('[LinkForty] getInitialURL result:', url);
36
91
  if (url) {
37
92
  this.handleDeepLink({ url });
93
+ } else {
94
+ console.warn('[LinkForty] getInitialURL returned null — app may not have been opened via deep link, or Android consumed the Intent');
38
95
  }
39
96
  });
40
97
  }
@@ -53,27 +110,37 @@ export class DeepLinkHandler {
53
110
  * Falls back to local URL parsing on failure.
54
111
  */
55
112
  private handleDeepLink = async ({ url }: { url: string }): Promise<void> => {
113
+ console.log('[LinkForty] handleDeepLink called with url:', url);
114
+
56
115
  if (!this.callback || !url) {
116
+ console.warn('[LinkForty] handleDeepLink early return: callback=', !!this.callback, 'url=', url);
57
117
  return;
58
118
  }
59
119
 
60
120
  // Parse locally first (for fallback and to detect LinkForty URLs)
61
121
  const localData = this.parseURL(url);
122
+ console.log('[LinkForty] localData parsed:', JSON.stringify(localData));
62
123
 
63
124
  // If this is a LinkForty URL and we have a resolver, try the server
64
125
  if (localData && this.resolveFn && this.baseUrl && url.startsWith(this.baseUrl)) {
65
126
  try {
127
+ console.log('[LinkForty] Resolving URL via server...');
66
128
  const resolvedData = await this.resolveURL(url);
129
+ console.log('[LinkForty] Resolve result:', JSON.stringify(resolvedData));
67
130
  if (resolvedData) {
68
131
  this.callback(url, resolvedData);
69
132
  return;
70
133
  }
134
+ console.warn('[LinkForty] Resolve returned null, falling back to local parse');
71
135
  } catch (error) {
72
136
  console.warn('[LinkForty] Failed to resolve URL from server, falling back to local parse:', error);
73
137
  }
138
+ } else {
139
+ console.warn('[LinkForty] Skipping server resolve: localData=', !!localData, 'resolveFn=', !!this.resolveFn, 'baseUrl=', this.baseUrl, 'urlMatch=', url.startsWith(this.baseUrl || ''));
74
140
  }
75
141
 
76
- // Fallback to locally-parsed data
142
+ // Fallback to locally-parsed data (no customParameters from server)
143
+ console.warn('[LinkForty] Using local fallback — customParameters will be empty for App Link URLs');
77
144
  this.callback(url, localData);
78
145
  };
79
146
 
@@ -87,8 +154,12 @@ export class DeepLinkHandler {
87
154
  return null;
88
155
  }
89
156
 
90
- const parsedUrl = new URL(url);
91
- const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
157
+ const parsed = DeepLinkHandler.parseUrlString(url);
158
+ if (!parsed) {
159
+ return null;
160
+ }
161
+
162
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
92
163
 
93
164
  if (pathSegments.length === 0) {
94
165
  return null;
@@ -106,17 +177,18 @@ export class DeepLinkHandler {
106
177
  try {
107
178
  const fingerprint = await FingerprintCollector.collect();
108
179
  const [sw, sh] = fingerprint.screenResolution.split('x');
109
- const queryParams = new URLSearchParams();
110
- queryParams.set('fp_tz', fingerprint.timezone);
111
- queryParams.set('fp_lang', fingerprint.language);
112
- queryParams.set('fp_sw', sw);
113
- queryParams.set('fp_sh', sh);
114
- queryParams.set('fp_platform', fingerprint.platform);
180
+ const fpParams: Record<string, string> = {
181
+ fp_tz: fingerprint.timezone,
182
+ fp_lang: fingerprint.language,
183
+ fp_sw: sw,
184
+ fp_sh: sh,
185
+ fp_platform: fingerprint.platform,
186
+ };
115
187
  if (fingerprint.osVersion) {
116
- queryParams.set('fp_pv', fingerprint.osVersion);
188
+ fpParams.fp_pv = fingerprint.osVersion;
117
189
  }
118
190
 
119
- resolvePath += `?${queryParams.toString()}`;
191
+ resolvePath += `?${DeepLinkHandler.buildQueryString(fpParams)}`;
120
192
  } catch (error) {
121
193
  // If fingerprint collection fails, still resolve without it
122
194
  console.warn('[LinkForty] Fingerprint collection failed, resolving without fingerprint:', error);
@@ -131,15 +203,18 @@ export class DeepLinkHandler {
131
203
  */
132
204
  parseURL(url: string): DeepLinkData | null {
133
205
  try {
134
- const parsedUrl = new URL(url);
135
-
136
206
  // Check if this is a LinkForty URL
137
207
  if (this.baseUrl && !url.startsWith(this.baseUrl)) {
138
208
  return null;
139
209
  }
140
210
 
211
+ const parsed = DeepLinkHandler.parseUrlString(url);
212
+ if (!parsed) {
213
+ return null;
214
+ }
215
+
141
216
  // Extract short code from path (last segment handles both /shortCode and /templateSlug/shortCode)
142
- const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
217
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
143
218
  const shortCode = pathSegments[pathSegments.length - 1];
144
219
 
145
220
  if (!shortCode) {
@@ -148,16 +223,16 @@ export class DeepLinkHandler {
148
223
 
149
224
  // Extract UTM parameters
150
225
  const utmParameters = {
151
- source: parsedUrl.searchParams.get('utm_source') || undefined,
152
- medium: parsedUrl.searchParams.get('utm_medium') || undefined,
153
- campaign: parsedUrl.searchParams.get('utm_campaign') || undefined,
154
- term: parsedUrl.searchParams.get('utm_term') || undefined,
155
- content: parsedUrl.searchParams.get('utm_content') || undefined,
226
+ source: parsed.searchParams.get('utm_source') || undefined,
227
+ medium: parsed.searchParams.get('utm_medium') || undefined,
228
+ campaign: parsed.searchParams.get('utm_campaign') || undefined,
229
+ term: parsed.searchParams.get('utm_term') || undefined,
230
+ content: parsed.searchParams.get('utm_content') || undefined,
156
231
  };
157
232
 
158
233
  // Extract all other query parameters as custom parameters
159
234
  const customParameters: Record<string, string> = {};
160
- parsedUrl.searchParams.forEach((value, key) => {
235
+ parsed.searchParams.forEach((value, key) => {
161
236
  if (!key.startsWith('utm_')) {
162
237
  customParameters[key] = value;
163
238
  }
@@ -15,11 +15,12 @@ export class FingerprintCollector {
15
15
  const timezone = this.getTimezone();
16
16
  const language = this.getLanguage();
17
17
 
18
+ // Round dimensions — Android returns floats (e.g. 434.717) but the server expects integers
18
19
  const fingerprint: DeviceFingerprint = {
19
20
  userAgent: await DeviceInfo.getUserAgent(),
20
21
  timezone,
21
22
  language,
22
- screenResolution: `${width}x${height}`,
23
+ screenResolution: `${Math.round(width)}x${Math.round(height)}`,
23
24
  platform: Platform.OS,
24
25
  deviceModel: await DeviceInfo.getModel(),
25
26
  osVersion: await DeviceInfo.getSystemVersion(),
@@ -35,18 +36,21 @@ export class FingerprintCollector {
35
36
  static async generateQueryParams(): Promise<string> {
36
37
  const fingerprint = await this.collect();
37
38
 
38
- const params = new URLSearchParams({
39
+ // Build query string manually — URLSearchParams is not reliable in Hermes
40
+ const params: Record<string, string> = {
39
41
  ua: fingerprint.userAgent,
40
42
  tz: fingerprint.timezone,
41
43
  lang: fingerprint.language,
42
44
  screen: fingerprint.screenResolution,
43
45
  platform: fingerprint.platform,
44
- ...(fingerprint.deviceModel && { model: fingerprint.deviceModel }),
45
- ...(fingerprint.osVersion && { os: fingerprint.osVersion }),
46
- ...(fingerprint.appVersion && { app_version: fingerprint.appVersion }),
47
- });
46
+ };
47
+ if (fingerprint.deviceModel) params.model = fingerprint.deviceModel;
48
+ if (fingerprint.osVersion) params.os = fingerprint.osVersion;
49
+ if (fingerprint.appVersion) params.app_version = fingerprint.appVersion;
48
50
 
49
- return params.toString();
51
+ return Object.entries(params)
52
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
53
+ .join('&');
50
54
  }
51
55
 
52
56
  /**
@@ -310,7 +310,7 @@ export class LinkFortySDK {
310
310
  // Parse screen resolution (e.g., "1080x1920" -> [1080, 1920])
311
311
  const [screenWidth, screenHeight] = fingerprint.screenResolution
312
312
  .split('x')
313
- .map(Number);
313
+ .map((v) => Math.round(Number(v)));
314
314
 
315
315
  // Convert attribution window from days to hours
316
316
  const attributionWindowHours = (this.config.attributionWindow || 7) * 24;