@linkforty/mobile-sdk-react-native 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@linkforty/mobile-sdk-react-native",
3
+ "version": "1.0.0",
4
+ "description": "React Native SDK for LinkForty - Open-source deep linking and mobile attribution platform",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "prepare": "npm run build",
10
+ "prepublishOnly": "npm run build",
11
+ "test": "echo \"Error: no test specified\" && exit 1"
12
+ },
13
+ "keywords": [
14
+ "linkforty",
15
+ "deep-linking",
16
+ "deferred-deep-linking",
17
+ "mobile-attribution",
18
+ "react-native",
19
+ "universal-links",
20
+ "app-links",
21
+ "app-attribution",
22
+ "branch-alternative",
23
+ "appsflyer-alternative",
24
+ "adjust-alternative",
25
+ "self-hosted",
26
+ "open-source",
27
+ "privacy-focused"
28
+ ],
29
+ "author": "LinkForty",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/linkforty/react-native-sdk.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/linkforty/react-native-sdk/issues"
37
+ },
38
+ "homepage": "https://github.com/linkforty/react-native-sdk#readme",
39
+ "engines": {
40
+ "node": ">=20.0.0",
41
+ "npm": ">=10.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=18.3.0",
45
+ "react-native": ">=0.76.0"
46
+ },
47
+ "dependencies": {
48
+ "@react-native-async-storage/async-storage": "^2.2.0",
49
+ "react-native-device-info": "^15.0.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/react": "^19.1.0",
53
+ "typescript": "^5.9.0"
54
+ },
55
+ "files": [
56
+ "dist/**/*",
57
+ "src/**/*",
58
+ "examples/**/*",
59
+ "README.md",
60
+ "CHANGELOG.md",
61
+ "CONTRIBUTING.md",
62
+ "LICENSE"
63
+ ],
64
+ "publishConfig": {
65
+ "access": "public",
66
+ "registry": "https://registry.npmjs.org/"
67
+ }
68
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * DeepLinkHandler - Handles Universal Links (iOS) and App Links (Android)
3
+ */
4
+
5
+ import { Linking } from 'react-native';
6
+ import type { DeepLinkData, DeepLinkCallback } from './types';
7
+
8
+ export class DeepLinkHandler {
9
+ private callback: DeepLinkCallback | null = null;
10
+ private baseUrl: string | null = null;
11
+
12
+ /**
13
+ * Initialize deep link listener
14
+ */
15
+ initialize(baseUrl: string, callback: DeepLinkCallback): void {
16
+ this.baseUrl = baseUrl;
17
+ this.callback = callback;
18
+
19
+ // Listen for deep links when app is already open
20
+ Linking.addEventListener('url', this.handleDeepLink);
21
+
22
+ // Check if app was opened via deep link
23
+ Linking.getInitialURL().then((url) => {
24
+ if (url) {
25
+ this.handleDeepLink({ url });
26
+ }
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Clean up listeners
32
+ */
33
+ cleanup(): void {
34
+ Linking.removeAllListeners('url');
35
+ this.callback = null;
36
+ }
37
+
38
+ /**
39
+ * Handle incoming deep link
40
+ */
41
+ private handleDeepLink = ({ url }: { url: string }): void => {
42
+ if (!this.callback || !url) {
43
+ return;
44
+ }
45
+
46
+ const deepLinkData = this.parseURL(url);
47
+ this.callback(url, deepLinkData);
48
+ };
49
+
50
+ /**
51
+ * Parse deep link URL and extract data
52
+ */
53
+ parseURL(url: string): DeepLinkData | null {
54
+ try {
55
+ const parsedUrl = new URL(url);
56
+
57
+ // Check if this is a LinkForty URL
58
+ if (this.baseUrl && !url.startsWith(this.baseUrl)) {
59
+ return null;
60
+ }
61
+
62
+ // Extract short code from path
63
+ const shortCode = parsedUrl.pathname.replace('/', '');
64
+
65
+ if (!shortCode) {
66
+ return null;
67
+ }
68
+
69
+ // Extract UTM parameters
70
+ const utmParameters = {
71
+ source: parsedUrl.searchParams.get('utm_source') || undefined,
72
+ medium: parsedUrl.searchParams.get('utm_medium') || undefined,
73
+ campaign: parsedUrl.searchParams.get('utm_campaign') || undefined,
74
+ term: parsedUrl.searchParams.get('utm_term') || undefined,
75
+ content: parsedUrl.searchParams.get('utm_content') || undefined,
76
+ };
77
+
78
+ // Extract all other query parameters as custom parameters
79
+ const customParameters: Record<string, string> = {};
80
+ parsedUrl.searchParams.forEach((value, key) => {
81
+ if (!key.startsWith('utm_')) {
82
+ customParameters[key] = value;
83
+ }
84
+ });
85
+
86
+ return {
87
+ shortCode,
88
+ utmParameters,
89
+ customParameters: Object.keys(customParameters).length > 0 ? customParameters : undefined,
90
+ };
91
+ } catch (error) {
92
+ console.error('Failed to parse deep link URL:', error);
93
+ return null;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * FingerprintCollector - Collects device fingerprint data for attribution matching
3
+ */
4
+
5
+ import { Platform, Dimensions, NativeModules } from 'react-native';
6
+ import DeviceInfo from 'react-native-device-info';
7
+ import type { DeviceFingerprint } from './types';
8
+
9
+ export class FingerprintCollector {
10
+ /**
11
+ * Collect device fingerprint data
12
+ */
13
+ static async collect(): Promise<DeviceFingerprint> {
14
+ const { width, height } = Dimensions.get('window');
15
+ const timezone = this.getTimezone();
16
+ const language = this.getLanguage();
17
+
18
+ const fingerprint: DeviceFingerprint = {
19
+ userAgent: await DeviceInfo.getUserAgent(),
20
+ timezone,
21
+ language,
22
+ screenResolution: `${width}x${height}`,
23
+ platform: Platform.OS,
24
+ deviceModel: await DeviceInfo.getModel(),
25
+ osVersion: await DeviceInfo.getSystemVersion(),
26
+ appVersion: await DeviceInfo.getVersion(),
27
+ };
28
+
29
+ return fingerprint;
30
+ }
31
+
32
+ /**
33
+ * Generate query parameters from fingerprint for URL
34
+ */
35
+ static async generateQueryParams(): Promise<string> {
36
+ const fingerprint = await this.collect();
37
+
38
+ const params = new URLSearchParams({
39
+ ua: fingerprint.userAgent,
40
+ tz: fingerprint.timezone,
41
+ lang: fingerprint.language,
42
+ screen: fingerprint.screenResolution,
43
+ platform: fingerprint.platform,
44
+ ...(fingerprint.deviceModel && { model: fingerprint.deviceModel }),
45
+ ...(fingerprint.osVersion && { os: fingerprint.osVersion }),
46
+ ...(fingerprint.appVersion && { app_version: fingerprint.appVersion }),
47
+ });
48
+
49
+ return params.toString();
50
+ }
51
+
52
+ /**
53
+ * Get device timezone
54
+ */
55
+ private static getTimezone(): string {
56
+ try {
57
+ // Get timezone using Intl API
58
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
59
+ } catch (error) {
60
+ return 'UTC';
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Get device language
66
+ */
67
+ private static getLanguage(): string {
68
+ try {
69
+ // Try to get locale from NativeModules
70
+ const locale = Platform.select({
71
+ ios: NativeModules.SettingsManager?.settings?.AppleLocale ||
72
+ NativeModules.SettingsManager?.settings?.AppleLanguages?.[0],
73
+ android: NativeModules.I18nManager?.localeIdentifier,
74
+ });
75
+
76
+ if (locale) {
77
+ return locale;
78
+ }
79
+
80
+ // Fallback to navigator if available
81
+ if (typeof navigator !== 'undefined' && navigator.language) {
82
+ return navigator.language;
83
+ }
84
+
85
+ return 'en-US';
86
+ } catch (error) {
87
+ return 'en-US';
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * LinkFortySDK - Main SDK class for LinkForty deep linking and attribution
3
+ */
4
+
5
+ import AsyncStorage from '@react-native-async-storage/async-storage';
6
+ import { FingerprintCollector } from './FingerprintCollector';
7
+ import { DeepLinkHandler } from './DeepLinkHandler';
8
+ import type {
9
+ LinkFortyConfig,
10
+ InstallAttributionResponse,
11
+ DeepLinkData,
12
+ DeferredDeepLinkCallback,
13
+ DeepLinkCallback,
14
+ } from './types';
15
+
16
+ const STORAGE_KEYS = {
17
+ INSTALL_ID: '@linkforty:install_id',
18
+ INSTALL_DATA: '@linkforty:install_data',
19
+ FIRST_LAUNCH: '@linkforty:first_launch',
20
+ };
21
+
22
+ export class LinkFortySDK {
23
+ private config: LinkFortyConfig | null = null;
24
+ private deepLinkHandler: DeepLinkHandler | null = null;
25
+ private deferredDeepLinkCallback: DeferredDeepLinkCallback | null = null;
26
+ private installId: string | null = null;
27
+ private initialized: boolean = false;
28
+
29
+ /**
30
+ * Initialize the SDK
31
+ */
32
+ async init(config: LinkFortyConfig): Promise<void> {
33
+ if (this.initialized) {
34
+ console.warn('LinkForty SDK already initialized');
35
+ return;
36
+ }
37
+
38
+ this.config = config;
39
+
40
+ if (config.debug) {
41
+ console.log('[LinkForty] Initializing SDK with config:', config);
42
+ }
43
+
44
+ // Check if this is first launch
45
+ const isFirstLaunch = await this.isFirstLaunch();
46
+
47
+ if (isFirstLaunch) {
48
+ // Report install and get deferred deep link
49
+ await this.reportInstall();
50
+ } else {
51
+ // Load cached install data
52
+ await this.loadInstallData();
53
+ }
54
+
55
+ // Initialize deep link handler for direct deep links
56
+ this.deepLinkHandler = new DeepLinkHandler();
57
+
58
+ this.initialized = true;
59
+
60
+ if (config.debug) {
61
+ console.log('[LinkForty] SDK initialized successfully');
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get install attribution data (deferred deep link)
67
+ */
68
+ async getInstallData(): Promise<DeepLinkData | null> {
69
+ const cached = await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_DATA);
70
+ if (cached) {
71
+ return JSON.parse(cached);
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Register callback for deferred deep links (new installs)
78
+ */
79
+ onDeferredDeepLink(callback: DeferredDeepLinkCallback): void {
80
+ this.deferredDeepLinkCallback = callback;
81
+
82
+ // If we already have install data, call the callback immediately
83
+ this.getInstallData().then((data) => {
84
+ if (data && this.deferredDeepLinkCallback) {
85
+ this.deferredDeepLinkCallback(data);
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Register callback for direct deep links (existing users)
92
+ */
93
+ onDeepLink(callback: DeepLinkCallback): void {
94
+ if (!this.config) {
95
+ throw new Error('SDK not initialized. Call init() first.');
96
+ }
97
+
98
+ if (!this.deepLinkHandler) {
99
+ this.deepLinkHandler = new DeepLinkHandler();
100
+ }
101
+
102
+ this.deepLinkHandler.initialize(this.config.baseUrl, callback);
103
+ }
104
+
105
+ /**
106
+ * Track in-app event
107
+ */
108
+ async trackEvent(name: string, properties?: Record<string, any>): Promise<void> {
109
+ if (!this.config) {
110
+ throw new Error('SDK not initialized. Call init() first.');
111
+ }
112
+
113
+ if (!this.installId) {
114
+ console.warn('[LinkForty] Cannot track event: No install ID available');
115
+ return;
116
+ }
117
+
118
+ // Backend expects: { installId, eventName, eventData, timestamp }
119
+ const requestBody = {
120
+ installId: this.installId,
121
+ eventName: name,
122
+ eventData: properties || {},
123
+ timestamp: new Date().toISOString(),
124
+ };
125
+
126
+ try {
127
+ const response = await this.apiRequest('/api/sdk/v1/event', {
128
+ method: 'POST',
129
+ body: JSON.stringify(requestBody),
130
+ });
131
+
132
+ if (this.config.debug) {
133
+ console.log('[LinkForty] Event tracked:', name, response);
134
+ }
135
+ } catch (error) {
136
+ console.error('[LinkForty] Failed to track event:', error);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get install ID
142
+ */
143
+ async getInstallId(): Promise<string | null> {
144
+ if (this.installId) {
145
+ return this.installId;
146
+ }
147
+
148
+ const cached = await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID);
149
+ if (cached) {
150
+ this.installId = cached;
151
+ }
152
+
153
+ return this.installId;
154
+ }
155
+
156
+ /**
157
+ * Clear all cached data (for testing)
158
+ */
159
+ async clearData(): Promise<void> {
160
+ await AsyncStorage.multiRemove([
161
+ STORAGE_KEYS.INSTALL_ID,
162
+ STORAGE_KEYS.INSTALL_DATA,
163
+ STORAGE_KEYS.FIRST_LAUNCH,
164
+ ]);
165
+
166
+ this.installId = null;
167
+
168
+ if (this.config?.debug) {
169
+ console.log('[LinkForty] All data cleared');
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Check if this is the first app launch
175
+ */
176
+ private async isFirstLaunch(): Promise<boolean> {
177
+ const firstLaunchFlag = await AsyncStorage.getItem(STORAGE_KEYS.FIRST_LAUNCH);
178
+
179
+ if (!firstLaunchFlag) {
180
+ await AsyncStorage.setItem(STORAGE_KEYS.FIRST_LAUNCH, 'true');
181
+ return true;
182
+ }
183
+
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Report app installation to LinkForty backend
189
+ */
190
+ private async reportInstall(): Promise<void> {
191
+ if (!this.config) {
192
+ return;
193
+ }
194
+
195
+ try {
196
+ // Collect device fingerprint
197
+ const fingerprint = await FingerprintCollector.collect();
198
+
199
+ if (this.config.debug) {
200
+ console.log('[LinkForty] Reporting install with fingerprint:', fingerprint);
201
+ }
202
+
203
+ // Parse screen resolution (e.g., "1080x1920" -> [1080, 1920])
204
+ const [screenWidth, screenHeight] = fingerprint.screenResolution
205
+ .split('x')
206
+ .map(Number);
207
+
208
+ // Convert attribution window from days to hours
209
+ const attributionWindowHours = (this.config.attributionWindow || 7) * 24;
210
+
211
+ // Call install endpoint with flattened structure matching backend contract
212
+ const response = await this.apiRequest<InstallAttributionResponse>(
213
+ '/api/sdk/v1/install',
214
+ {
215
+ method: 'POST',
216
+ body: JSON.stringify({
217
+ userAgent: fingerprint.userAgent,
218
+ timezone: fingerprint.timezone,
219
+ language: fingerprint.language,
220
+ screenWidth,
221
+ screenHeight,
222
+ platform: fingerprint.platform,
223
+ platformVersion: fingerprint.osVersion,
224
+ deviceId: undefined, // Optional: Can add IDFA/GAID if available
225
+ attributionWindowHours,
226
+ }),
227
+ }
228
+ );
229
+
230
+ if (this.config.debug) {
231
+ console.log('[LinkForty] Install response:', response);
232
+ }
233
+
234
+ // Store install ID
235
+ if (response.installId) {
236
+ this.installId = response.installId;
237
+ await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_ID, response.installId);
238
+ }
239
+
240
+ // If attributed, store deep link data
241
+ if (response.attributed && response.deepLinkData) {
242
+ await AsyncStorage.setItem(
243
+ STORAGE_KEYS.INSTALL_DATA,
244
+ JSON.stringify(response.deepLinkData)
245
+ );
246
+
247
+ // Call deferred deep link callback if registered
248
+ if (this.deferredDeepLinkCallback) {
249
+ this.deferredDeepLinkCallback(response.deepLinkData as DeepLinkData);
250
+ }
251
+
252
+ if (this.config.debug) {
253
+ console.log('[LinkForty] Install attributed with confidence:', response.confidenceScore);
254
+ console.log('[LinkForty] Matched factors:', response.matchedFactors);
255
+ }
256
+ } else {
257
+ if (this.config.debug) {
258
+ console.log('[LinkForty] Install not attributed (organic install)');
259
+ }
260
+
261
+ // Call callback with null to indicate organic install
262
+ if (this.deferredDeepLinkCallback) {
263
+ this.deferredDeepLinkCallback(null);
264
+ }
265
+ }
266
+ } catch (error) {
267
+ console.error('[LinkForty] Failed to report install:', error);
268
+
269
+ // Call callback with null on error
270
+ if (this.deferredDeepLinkCallback) {
271
+ this.deferredDeepLinkCallback(null);
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Load cached install data
278
+ */
279
+ private async loadInstallData(): Promise<void> {
280
+ this.installId = await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID);
281
+ }
282
+
283
+ /**
284
+ * Make API request to LinkForty backend
285
+ */
286
+ private async apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
287
+ if (!this.config) {
288
+ throw new Error('SDK not initialized');
289
+ }
290
+
291
+ const url = `${this.config.baseUrl}${endpoint}`;
292
+
293
+ const headers: Record<string, string> = {
294
+ 'Content-Type': 'application/json',
295
+ };
296
+
297
+ // Add API key if provided
298
+ if (this.config.apiKey) {
299
+ headers['Authorization'] = `Bearer ${this.config.apiKey}`;
300
+ }
301
+
302
+ const response = await fetch(url, {
303
+ ...options,
304
+ headers: {
305
+ ...headers,
306
+ ...options.headers,
307
+ },
308
+ });
309
+
310
+ if (!response.ok) {
311
+ const error = await response.json().catch(() => ({ message: 'Network error' }));
312
+ throw new Error(error.message || `HTTP ${response.status}`);
313
+ }
314
+
315
+ return response.json();
316
+ }
317
+ }
318
+
319
+ // Export singleton instance
320
+ export default new LinkFortySDK();
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * LinkForty React Native SDK
3
+ *
4
+ * Deep linking and mobile attribution for React Native apps
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ // Export singleton SDK instance as default
10
+ export { default } from './LinkFortySDK';
11
+
12
+ // Export classes
13
+ export { LinkFortySDK } from './LinkFortySDK';
14
+ export { FingerprintCollector } from './FingerprintCollector';
15
+ export { DeepLinkHandler } from './DeepLinkHandler';
16
+
17
+ // Export types
18
+ export type {
19
+ LinkFortyConfig,
20
+ DeviceFingerprint,
21
+ DeepLinkData,
22
+ InstallAttributionResponse,
23
+ EventData,
24
+ DeferredDeepLinkCallback,
25
+ DeepLinkCallback,
26
+ } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * LinkForty React Native SDK Type Definitions
3
+ */
4
+
5
+ /**
6
+ * Configuration options for initializing the LinkForty SDK
7
+ */
8
+ export interface LinkFortyConfig {
9
+ /** Base URL of your LinkForty instance (e.g., 'https://go.yourdomain.com') */
10
+ baseUrl: string;
11
+ /** Optional API key for Cloud authentication */
12
+ apiKey?: string;
13
+ /** Enable debug logging */
14
+ debug?: boolean;
15
+ /** Custom attribution window in days (default: 7) */
16
+ attributionWindow?: number;
17
+ }
18
+
19
+ /**
20
+ * Device fingerprint data collected for attribution matching
21
+ */
22
+ export interface DeviceFingerprint {
23
+ /** IP address (server-side) */
24
+ ip?: string;
25
+ /** User agent string */
26
+ userAgent: string;
27
+ /** Device timezone */
28
+ timezone: string;
29
+ /** Device language */
30
+ language: string;
31
+ /** Screen resolution (width x height) */
32
+ screenResolution: string;
33
+ /** Device platform (ios/android) */
34
+ platform: string;
35
+ /** Device model */
36
+ deviceModel?: string;
37
+ /** OS version */
38
+ osVersion?: string;
39
+ /** App version */
40
+ appVersion?: string;
41
+ }
42
+
43
+ /**
44
+ * Deep link data containing URLs and parameters
45
+ */
46
+ export interface DeepLinkData {
47
+ /** Short code that was clicked */
48
+ shortCode: string;
49
+ /** iOS app URL */
50
+ iosUrl?: string;
51
+ /** Android app URL */
52
+ androidUrl?: string;
53
+ /** Web fallback URL */
54
+ webUrl?: string;
55
+ /** UTM parameters */
56
+ utmParameters?: {
57
+ source?: string;
58
+ medium?: string;
59
+ campaign?: string;
60
+ term?: string;
61
+ content?: string;
62
+ };
63
+ /** Custom query parameters */
64
+ customParameters?: Record<string, string>;
65
+ /** Click timestamp */
66
+ clickedAt?: string;
67
+ /** Link ID */
68
+ linkId?: string;
69
+ }
70
+
71
+ /**
72
+ * Response from install attribution endpoint
73
+ * Matches backend contract from /api/sdk/v1/install
74
+ */
75
+ export interface InstallAttributionResponse {
76
+ /** Install event UUID */
77
+ installId: string;
78
+ /** Whether attribution was successful */
79
+ attributed: boolean;
80
+ /** Attribution confidence score (0-100) */
81
+ confidenceScore: number;
82
+ /** Array of matched fingerprint factors (e.g., ['ip', 'user_agent', 'timezone', 'language', 'screen']) */
83
+ matchedFactors: string[];
84
+ /** Deep link data if attributed (contains shortCode, URLs, UTM params, confidence, etc.) */
85
+ deepLinkData: DeepLinkData | Record<string, never>;
86
+ }
87
+
88
+ /**
89
+ * Event data for tracking in-app events
90
+ */
91
+ export interface EventData {
92
+ /** Event name (e.g., 'purchase', 'signup', 'add_to_cart') */
93
+ name: string;
94
+ /** Event properties */
95
+ properties?: Record<string, any>;
96
+ /** Revenue amount (for conversion events) */
97
+ revenue?: number;
98
+ /** Currency code (e.g., 'USD') */
99
+ currency?: string;
100
+ /** Install ID for attribution */
101
+ installId?: string;
102
+ }
103
+
104
+ /**
105
+ * Callback function for deferred deep links
106
+ */
107
+ export type DeferredDeepLinkCallback = (deepLinkData: DeepLinkData | null) => void;
108
+
109
+ /**
110
+ * Callback function for direct deep links
111
+ */
112
+ export type DeepLinkCallback = (url: string, deepLinkData: DeepLinkData | null) => void;