@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 +5 -0
- package/README.md +214 -171
- package/dist/DeepLinkHandler.d.ts +10 -0
- package/dist/DeepLinkHandler.d.ts.map +1 -1
- package/dist/DeepLinkHandler.js +86 -19
- package/dist/FingerprintCollector.d.ts.map +1 -1
- package/dist/FingerprintCollector.js +14 -7
- package/dist/LinkFortySDK.js +1 -1
- package/package.json +2 -1
- package/src/DeepLinkHandler.ts +95 -20
- package/src/FingerprintCollector.ts +11 -7
- package/src/LinkFortySDK.ts +1 -1
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
|
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
|
-
###
|
|
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
|
|
44
|
+
- Select your app target > Signing & Capabilities
|
|
48
45
|
- Add "Associated Domains" capability
|
|
49
|
-
- Add domain: `applinks:go.yourdomain.com` (replace with your LinkForty
|
|
46
|
+
- Add domain: `applinks:go.yourdomain.com` (replace with your LinkForty domain)
|
|
50
47
|
|
|
51
|
-
3.
|
|
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.
|
|
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.
|
|
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
|
|
115
|
+
// 1. Initialize the SDK (call once at app startup)
|
|
110
116
|
await LinkForty.init({
|
|
111
117
|
baseUrl: 'https://go.yourdomain.com',
|
|
112
|
-
apiKey: '
|
|
113
|
-
debug: __DEV__,
|
|
118
|
+
apiKey: 'your-api-key', // Required for createLink(), optional otherwise
|
|
119
|
+
debug: __DEV__,
|
|
114
120
|
});
|
|
115
121
|
|
|
116
|
-
// Handle
|
|
117
|
-
LinkForty.
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
console.log('Organic install');
|
|
137
|
+
console.log('Organic install — no link clicked');
|
|
129
138
|
}
|
|
130
139
|
});
|
|
140
|
+
```
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
LinkForty.onDeepLink((url, deepLinkData) => {
|
|
134
|
-
console.log('Deep link opened:', url, deepLinkData);
|
|
142
|
+
## API Reference
|
|
135
143
|
|
|
136
|
-
|
|
137
|
-
// Navigate to specific content
|
|
138
|
-
navigation.navigate('Product', {
|
|
139
|
-
id: deepLinkData.utmParameters?.content
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
});
|
|
144
|
+
### `init(config)`
|
|
143
145
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
164
|
+
---
|
|
157
165
|
|
|
158
|
-
|
|
166
|
+
### `onDeepLink(callback)`
|
|
159
167
|
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
**Parameters:**
|
|
188
|
+
---
|
|
180
189
|
|
|
181
|
-
|
|
190
|
+
### `onDeferredDeepLink(callback)`
|
|
182
191
|
|
|
183
|
-
|
|
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((
|
|
187
|
-
if (
|
|
188
|
-
// Attributed install
|
|
189
|
-
console.log('
|
|
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
|
-
|
|
208
|
+
| Callback Parameter | Type | Description |
|
|
209
|
+
|--------------------|------|-------------|
|
|
210
|
+
| `data` | `DeepLinkData \| null` | Attributed link data, or `null` for organic installs |
|
|
198
211
|
|
|
199
|
-
|
|
212
|
+
---
|
|
200
213
|
|
|
201
|
-
|
|
214
|
+
### `createLink(options)`
|
|
202
215
|
|
|
203
|
-
|
|
216
|
+
Create a short link programmatically. Requires `apiKey` to be set in `init()`.
|
|
204
217
|
|
|
205
|
-
|
|
218
|
+
When `templateId` is omitted, the server auto-selects your organization's default template.
|
|
206
219
|
|
|
207
220
|
```typescript
|
|
208
|
-
LinkForty.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
232
|
+
**Options:**
|
|
217
233
|
|
|
218
|
-
|
|
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
|
-
**
|
|
244
|
+
**Returns:** `CreateLinkResult`
|
|
221
245
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
+
---
|
|
239
272
|
|
|
240
|
-
|
|
273
|
+
### `getInstallData()`
|
|
241
274
|
|
|
242
|
-
|
|
275
|
+
Retrieve cached install attribution data from a previous deferred deep link.
|
|
243
276
|
|
|
244
277
|
```typescript
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
-
console.log('Install
|
|
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
|
-
|
|
284
|
+
**Returns:** `DeepLinkData | null`
|
|
252
285
|
|
|
253
|
-
|
|
286
|
+
---
|
|
254
287
|
|
|
255
|
-
|
|
288
|
+
### `getInstallId()`
|
|
256
289
|
|
|
257
|
-
|
|
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
|
-
|
|
296
|
+
**Returns:** `string | null`
|
|
265
297
|
|
|
266
|
-
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
### `clearData()`
|
|
267
301
|
|
|
268
|
-
|
|
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
|
-
##
|
|
308
|
+
## Types
|
|
276
309
|
|
|
277
310
|
### `DeepLinkData`
|
|
278
311
|
|
|
279
312
|
```typescript
|
|
280
313
|
interface DeepLinkData {
|
|
281
314
|
shortCode: string;
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
+
### `CreateLinkOptions`
|
|
310
345
|
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
```typescript
|
|
315
|
-
await LinkForty.clearData();
|
|
316
|
-
```
|
|
364
|
+
### `CreateLinkResult`
|
|
317
365
|
|
|
318
|
-
|
|
366
|
+
```typescript
|
|
367
|
+
interface CreateLinkResult {
|
|
368
|
+
url: string;
|
|
369
|
+
shortCode: string;
|
|
370
|
+
linkId: string;
|
|
371
|
+
}
|
|
372
|
+
```
|
|
319
373
|
|
|
320
|
-
|
|
374
|
+
## How Deep Linking Works
|
|
321
375
|
|
|
322
|
-
|
|
376
|
+
### Direct Deep Links (App Installed)
|
|
323
377
|
|
|
324
|
-
|
|
378
|
+
When a user taps a LinkForty URL and the app is already installed:
|
|
325
379
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
388
|
+
When a user taps a LinkForty URL and the app is **not** installed:
|
|
346
389
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
360
|
-
2. Check that
|
|
361
|
-
3.
|
|
362
|
-
4. Test
|
|
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
|
|
367
|
-
2. Check
|
|
368
|
-
3. Run `adb shell pm get-app-links com.yourapp` to
|
|
369
|
-
4.
|
|
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.
|
|
374
|
-
2.
|
|
375
|
-
3. Verify your LinkForty
|
|
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.
|
|
427
|
+
5. Test on the same network for best fingerprint match accuracy
|
|
378
428
|
|
|
379
429
|
### TypeScript errors
|
|
380
430
|
|
|
381
|
-
|
|
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!
|
|
439
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
390
440
|
|
|
391
441
|
## License
|
|
392
442
|
|
|
393
|
-
MIT License - see [LICENSE](LICENSE)
|
|
443
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
394
444
|
|
|
395
445
|
## Support
|
|
396
446
|
|
|
397
|
-
- **
|
|
398
|
-
- **
|
|
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;
|
|
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"}
|
package/dist/DeepLinkHandler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
166
|
+
fpParams.fp_pv = fingerprint.osVersion;
|
|
103
167
|
}
|
|
104
|
-
resolvePath += `?${
|
|
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 =
|
|
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:
|
|
132
|
-
medium:
|
|
133
|
-
campaign:
|
|
134
|
-
term:
|
|
135
|
-
content:
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
package/dist/LinkFortySDK.js
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/src/DeepLinkHandler.ts
CHANGED
|
@@ -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
|
|
91
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
188
|
+
fpParams.fp_pv = fingerprint.osVersion;
|
|
117
189
|
}
|
|
118
190
|
|
|
119
|
-
resolvePath += `?${
|
|
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 =
|
|
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:
|
|
152
|
-
medium:
|
|
153
|
-
campaign:
|
|
154
|
-
term:
|
|
155
|
-
content:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
+
return Object.entries(params)
|
|
52
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
53
|
+
.join('&');
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/**
|
package/src/LinkFortySDK.ts
CHANGED
|
@@ -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;
|