import { Platform, Linking, NativeModules, Dimensions, EmitterSubscription } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Clipboard from '@react-native-clipboard/clipboard'; import type { LinkHoppLink, LinkHoppConfig, LinkHoppLinkCallback, MatchResponse, } from './types'; const STORAGE_KEY = 'linkhopp_deferred_checked'; const DEFAULT_BASE_URL = 'https://linkhopp.com'; const REQUEST_TIMEOUT_MS = 10_000; const CLIPBOARD_PREFIX = 'linkhopp:'; const REFERRER_TOKEN_PATTERN = /linkhopp_token=([a-zA-Z0-9]{8,})/; /** * LinkHopp Deep Linking SDK for React Native. * * Usage: * ```ts * import { LinkHopp } from '@linkhopp/react-native'; * * LinkHopp.init({ apiKey: 'YOUR_KEY' }); * const link = await LinkHopp.checkDeferredLink(); * ``` */ class LinkHoppSDK { private apiKey: string = ''; private baseUrl: string = DEFAULT_BASE_URL; private initialized: boolean = false; private deferredCheckInProgress: boolean = false; private linkSubscription: EmitterSubscription | null = null; // -------------------------------------------------------------------------- // Public API // -------------------------------------------------------------------------- /** * Initialize the SDK. Must be called before any other method. * * @param config.apiKey Your API key from the LinkHopp dashboard. * @param config.baseUrl Custom worker URL (defaults to https://linkhopp.com). */ init(config: LinkHoppConfig): void { if (!config.apiKey || config.apiKey.length === 0) { throw new Error('LinkHopp: apiKey must not be empty'); } this.apiKey = config.apiKey; this.baseUrl = config.baseUrl?.replace(/\/+$/, '') || DEFAULT_BASE_URL; this.initialized = true; } /** * Check for a deferred deep link (post-install attribution). * * Returns a `LinkHoppLink` if a match was found, otherwise `null`. * Thread-safe: concurrent calls are ignored and return `null`. * This check runs at most once per installation. */ async checkDeferredLink(): Promise { this.requireInitialized(); // Prevent concurrent execution if (this.deferredCheckInProgress) { return null; } this.deferredCheckInProgress = true; try { // Only run once per installation const alreadyChecked = await AsyncStorage.getItem(STORAGE_KEY); if (alreadyChecked === 'true') { return null; } let token: string | null = null; // Strategy 1a: Play Install Referrer (Android) if (Platform.OS === 'android') { token = await this.getInstallReferrerToken(); } // Strategy 1b: Clipboard token (iOS) if (Platform.OS === 'ios' && token == null) { token = await this.getClipboardToken(); } // Build match request body with fingerprint signals const body: Record = {}; if (token) { body.token = token; } body.os = `${Platform.OS} ${Platform.Version}`; body.language = this.getDeviceLanguage(); body.timezone = this.getTimezone(); // Screen size für Fingerprinting (logische Pixel × PixelRatio) try { const screen = Dimensions.get('screen'); const { PixelRatio } = require('react-native'); const ratio = PixelRatio?.get?.() || 1; if (screen.width > 0 && screen.height > 0) { body.screen_size = `${Math.round(screen.width * ratio)}x${Math.round(screen.height * ratio)}`; } } catch {} // Device Model (Best-Effort via PlatformConstants) try { const constants = NativeModules.PlatformConstants; if (Platform.OS === 'ios') { body.device_model = constants?.interfaceIdiom || 'iOS Device'; } else { body.device_model = constants?.Model || 'Android Device'; } } catch { body.device_model = `${Platform.OS} Device`; } // Send match request const result = await this.postMatch(body); if (result && result.matched && result.link) { await AsyncStorage.setItem(STORAGE_KEY, 'true'); // Clear clipboard token on iOS after successful match if (Platform.OS === 'ios') { this.clearClipboardToken(); } return this.parseLink(result); } // Mark as checked even when no match was found await AsyncStorage.setItem(STORAGE_KEY, 'true'); return null; } catch (e) { console.warn('LinkHopp: deferred link check failed:', e); return null; } finally { this.deferredCheckInProgress = false; } } /** * Register a listener for incoming deep links while the app is running. * * Any previously registered listener is automatically removed to prevent * memory leaks. * * @param callback Invoked with a `LinkHoppLink` each time a link is received. */ handleLink(callback: LinkHoppLinkCallback): void { this.requireInitialized(); // Remove previous listener if (this.linkSubscription) { this.linkSubscription.remove(); this.linkSubscription = null; } this.linkSubscription = Linking.addEventListener('url', (event) => { try { callback({ destinationUrl: event.url, metadata: {}, campaign: null, utmSource: null, utmMedium: null, utmCampaign: null, matchMethod: 'direct', }); } catch (e) { console.warn('LinkHopp: handleLink callback error:', e); } }); } /** * Check whether the app was launched via a deep link (cold start). * * @returns The opening link as a `LinkHoppLink`, or `null`. */ async getInitialLink(): Promise { this.requireInitialized(); try { const url = await withTimeout(Linking.getInitialURL(), 5_000); if (url) { return { destinationUrl: url, metadata: {}, campaign: null, utmSource: null, utmMedium: null, utmCampaign: null, matchMethod: 'direct', }; } } catch (e) { console.warn('LinkHopp: getInitialLink failed:', e); } return null; } /** * Remove all listeners and release resources. * Call this in your root component's cleanup / unmount. */ dispose(): void { if (this.linkSubscription) { this.linkSubscription.remove(); this.linkSubscription = null; } } /** * Reset the deferred-check flag. Useful for testing. */ async resetDeferredCheck(): Promise { await AsyncStorage.removeItem(STORAGE_KEY); } // -------------------------------------------------------------------------- // Private helpers // -------------------------------------------------------------------------- private requireInitialized(): void { if (!this.initialized) { throw new Error( 'LinkHopp.init() must be called before using any other method', ); } } /** * Send the match request to the worker endpoint. */ private async postMatch( body: Record, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetch(`${this.baseUrl}/api/v1/match`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, }, body: JSON.stringify(body), signal: controller.signal, }); if (response.ok) { return (await response.json()) as MatchResponse; } console.warn( `LinkHopp: match request failed with status ${response.status}`, ); return null; } catch (e: unknown) { if (e instanceof DOMException && e.name === 'AbortError') { console.warn('LinkHopp: match request timed out'); } else { console.warn('LinkHopp: match request error:', e); } return null; } finally { clearTimeout(timer); } } /** * Android: extract the LinkHopp token from the Play Install Referrer. */ private async getInstallReferrerToken(): Promise { try { const { LinkHoppInstallReferrer } = NativeModules; if (!LinkHoppInstallReferrer) { // Native module not linked — this is expected during development return null; } const referrer: string | null = await withTimeout( LinkHoppInstallReferrer.getInstallReferrer(), 5_000, ); if (referrer) { const match = REFERRER_TOKEN_PATTERN.exec(referrer); return match ? match[1] : null; } } catch (e) { console.warn('LinkHopp: install referrer error:', e); } return null; } /** * iOS: read a token from the clipboard (format: "linkhopp:"). */ private async getClipboardToken(): Promise { try { const text = await Clipboard.getString(); const trimmed = text?.trim(); if (trimmed && trimmed.startsWith(CLIPBOARD_PREFIX) && trimmed.length > CLIPBOARD_PREFIX.length) { return trimmed; } } catch (e) { console.warn('LinkHopp: clipboard read error:', e); } return null; } /** * iOS: clear the clipboard token after a successful match. */ private clearClipboardToken(): void { try { Clipboard.setString(''); } catch { // Silently ignore — clipboard clearing is best-effort } } /** * Parse a successful MatchResponse into a LinkHoppLink. */ private parseLink(response: MatchResponse): LinkHoppLink { const link = response.link!; return { destinationUrl: link.destination_url ?? '', metadata: link.metadata ?? {}, campaign: link.campaign ?? null, utmSource: link.utm_source ?? null, utmMedium: link.utm_medium ?? null, utmCampaign: link.utm_campaign ?? null, matchMethod: response.method ?? null, }; } /** * Get the device language in BCP-47 format. */ private getDeviceLanguage(): string { try { if (Platform.OS === 'ios') { const settings = NativeModules.SettingsManager?.settings ?? NativeModules.I18nManager; return ( settings?.AppleLocale || settings?.AppleLanguages?.[0] || 'en' ); } if (Platform.OS === 'android') { return NativeModules.I18nManager?.localeIdentifier ?? 'en'; } } catch { // Fall through } return 'en'; } /** * Get the device timezone name (e.g. "Europe/Berlin"). */ private getTimezone(): string { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } } } // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- /** * Race a promise against a timeout. Rejects with an error if the timeout fires first. */ function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Timeout')), ms); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); }, ); }); } // --------------------------------------------------------------------------- // Singleton export // --------------------------------------------------------------------------- /** * Global LinkHopp instance. Use this directly: * * ```ts * import { LinkHopp } from '@linkhopp/react-native'; * LinkHopp.init({ apiKey: '...' }); * ``` */ export const LinkHopp = new LinkHoppSDK();