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

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
@@ -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.3",
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;