UNPKG

tonelisten-react-native

Version:

ToneListen React Native Framework - Audio tone detection for React Native apps

1,273 lines (1,130 loc) 59.8 kB
import { ToneListenReactNativeConfig, ToneSequenceEvent, DualToneResult, BridgeToneResult, GoertzelResult, TableCodes, AudioPermissionResult, AudioRecorderState, InAppContentPayload, EmitPoint } from '../types/ToneTypes'; // Import Device ID service import { DeviceIdService } from '../native/deviceId'; // Import React Native Geolocation let Geolocation: any = null; try { if (typeof require !== 'undefined') { const GeolocationModule = require('@react-native-community/geolocation'); console.log('🌍 Framework: Geolocation module loaded:', typeof GeolocationModule); console.log('🌍 Framework: Geolocation module keys:', Object.keys(GeolocationModule)); if (GeolocationModule) { // Handle ES6 default export if (GeolocationModule.default) { console.log('🌍 Framework: Using default export from geolocation module'); Geolocation = GeolocationModule.default; } else { console.log('🌍 Framework: Using direct geolocation module'); Geolocation = GeolocationModule; } // Set default configuration if (Geolocation && Geolocation.setRNConfiguration) { console.log('🌍 Framework: Setting RN configuration...'); Geolocation.setRNConfiguration({ skipPermissionRequests: false, authorizationLevel: 'whenInUse', }); console.log('🌍 Framework: RN configuration set successfully'); } console.log('🌍 Framework: Final Geolocation methods available:', Object.keys(Geolocation)); } } } catch (error) { console.log('🌍 Framework: React Native Geolocation not available:', error); } import { GoertzelDetector } from '../detectors/GoertzelDetector'; import { DualToneDetector } from '../detectors/DualToneDetector'; import { BridgeDetector } from '../detectors/BridgeDetector'; import { SequenceStateMachine, ConfigDefault } from '../detectors/SequenceStateMachine'; import { FrequencyTableLoader } from '../utils/FrequencyTableLoader'; import { NotificationCenter, NotificationNames } from '../utils/NotificationCenter'; import { DuplicateDetector } from '../utils/DuplicateDetector'; import { networkInfoCollector } from '../utils/NetworkInfo'; import { ToneAPIService } from '../network/ToneAPIService'; import { CustomToneManager } from './CustomToneManager'; import { Logger } from '../utils/Logger'; import { NativeAudioPipeline } from '../native/audio'; import { Platform } from 'react-native'; /** * ToneListen React Native Framework * Main class for tone detection in React Native apps * Based on Swift version 19 implementation */ export class ToneListenReactNative { private config: ToneListenReactNativeConfig; private isListening: boolean = false; private isInitialized: boolean = false; // Detection components private goertzelDetector: GoertzelDetector | null = null; private dualToneDetector: DualToneDetector | null = null; private bridgeDetector: BridgeDetector | null = null; private sequenceStateMachine: SequenceStateMachine | null = null; // Audio processing private audioContext: AudioContext | null = null; private analyser: AnalyserNode | null = null; private dataArray: Float32Array | null = null; private animationFrameId: number | null = null; private resumeListeners: Array<{ type: string; listener: any } > = []; private nativePipeline: NativeAudioPipeline | null = null; private preOpenedWindow: any | null = null; // Frequency table private frequencyTable: TableCodes | null = null; private frequenciesToDetect: number[] = []; // Notification and duplicate detection private notificationCenter: NotificationCenter; private duplicateDetector: DuplicateDetector; private apiService: ToneAPIService; private customToneManager: CustomToneManager; // In-app content private inAppContentEnabled: boolean = false; private presentContentOverride?: (content: any) => boolean | void; constructor(config: ToneListenReactNativeConfig = {}) { this.config = { sampleRate: 44100, bufferSize: 4800, tolerance: 5, bridgeTolerance: 10, minPower: 0.01, autoStart: false, debug: false, baseURL: 'https://api.toneadmin.com', apiKey: 'WaOIgO4Ccea1wk55mDZVVRBdmyh2HweXGHdOlrx2OYseIdwFcDLHmRcZiPAWegjvuytuytuy', ...config }; // Initialize notification center and duplicate detector this.notificationCenter = NotificationCenter.getInstance(); this.duplicateDetector = new DuplicateDetector(); // Initialize API service and custom tone manager const apiInit: { baseURL?: string; apiKey?: string; clientId?: string; corsProxyBaseURL?: string; useProxyOnWeb?: boolean } = {}; if (this.config.baseURL) apiInit.baseURL = this.config.baseURL; if (this.config.apiKey) apiInit.apiKey = this.config.apiKey; if (this.config.clientId) apiInit.clientId = this.config.clientId; // Pass optional proxy settings if (this.config.corsProxyBaseURL) apiInit.corsProxyBaseURL = this.config.corsProxyBaseURL; if (typeof this.config.useProxyOnWeb !== 'undefined') apiInit.useProxyOnWeb = this.config.useProxyOnWeb as boolean; this.apiService = new ToneAPIService(apiInit); this.customToneManager = CustomToneManager.shared; this.customToneManager.updateAPIService(this.apiService); Logger.init({ enabled: !!this.config.debug, tag: 'TLRN' }); Logger.info('ToneListenReactNative constructed with config', { ...this.config, apiKey: this.config.apiKey ? '***' : undefined }); // In-app content setup this.inAppContentEnabled = !!this.config.enableInAppContent; this.presentContentOverride = this.config.onPresentContent as any; } /** * Initialize the tone detection system */ public async initialize(): Promise<boolean> { try { Logger.info('Initializing...'); // Load frequency tables await this.loadFrequencyTables(); // Initialize detection components this.initializeDetectors(); // Initialize custom tone manager if clientId provided if (this.config.clientId) { await this.customToneManager.initialize(this.config.clientId); } // Update duplicate timing if clientId available await this.refreshDuplicateDetectionTiming(); this.isInitialized = true; Logger.info('Initialization complete'); return true; } catch (error) { Logger.error('Initialization failed', error); this.config.onError?.(error as Error); return false; } } /** * Start listening for tones */ public async start(): Promise<boolean> { if (!this.isInitialized) { const initialized = await this.initialize(); if (!initialized) return false; } if (this.isListening) { Logger.debug('Already listening'); return true; } try { // Request microphone permission const permissionGranted = await this.requestMicrophonePermission(); if (!permissionGranted) { this.config.onPermissionDenied?.(); return false; } const isRN = (typeof navigator !== 'undefined') && ((navigator as any).product === 'ReactNative'); console.log('🎯 ToneListenReactNative: Platform detection - isRN:', isRN, 'typeof window:', typeof window); if (isRN) { // Native (iOS/Android): use native audio pipeline console.log('🎯 ToneListenReactNative: Starting native audio processing...'); await this.startNativeAudioProcessing(); console.log('🎯 ToneListenReactNative: Native audio processing started successfully'); } else { // Web: Initialize audio context console.log('🎯 ToneListenReactNative: Starting web audio processing...'); await this.initializeAudioContext(); // Start audio processing via WebAudio this.startAudioProcessing(); // Install resume handlers (web only) this.installResumeHandlers(); console.log('🎯 ToneListenReactNative: Web audio processing started successfully'); } this.isListening = true; Logger.info('Started listening'); return true; } catch (error) { Logger.error('Failed to start', error); this.config.onError?.(error as Error); return false; } } /** * Stop listening for tones */ public stop(): void { if (!this.isListening) return; this.isListening = false; // Stop native pipeline if present if (this.nativePipeline) { try { this.nativePipeline.stop(); } catch {} this.nativePipeline = null; } // Stop web audio processing if present if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close(); } this.removeResumeHandlers(); // Reset state machine this.sequenceStateMachine?.reset(); Logger.info('Stopped listening'); } /** * Update configuration */ public updateConfig(newConfig: Partial<ToneListenReactNativeConfig>): void { this.config = { ...this.config, ...newConfig }; // Update detectors with new config this.goertzelDetector?.updateConfig(this.config); this.dualToneDetector?.updateConfig(this.config); // BridgeDetector doesn't have updateConfig method // Update API service and custom tone manager if (newConfig.baseURL !== undefined || newConfig.apiKey !== undefined) { const update: { baseURL?: string; apiKey?: string } = {}; if (this.config.baseURL) update.baseURL = this.config.baseURL; if (this.config.apiKey) update.apiKey = this.config.apiKey; this.apiService.updateConfiguration(update); } if (newConfig.clientId) { this.customToneManager.initialize(newConfig.clientId).catch(() => {}); } } /** * Get current state */ public getState(): AudioRecorderState { return { isRecording: this.isListening, isInitialized: this.isInitialized, currentTime: 0, // Not applicable for real-time processing duration: 0 // Not applicable for real-time processing }; } /** * Fetch governor time (duplicate detection timing) for current client and update detector */ public async refreshDuplicateDetectionTiming(): Promise<void> { if (!this.config.clientId) return; try { const resp = await this.apiService.getGovernorTime(this.config.clientId); const duplicateMs = resp?.duplicateDetectionTiming; // Safety: ignore unrealistic small values (< 1s) if (duplicateMs && duplicateMs >= 1000) { this.duplicateDetector.updateDuplicateDetectionTiming(duplicateMs); } } catch (_) {} } private async loadFrequencyTables(): Promise<void> { try { this.frequencyTable = await FrequencyTableLoader.loadFrequencyTables(); this.frequenciesToDetect = FrequencyTableLoader.getFrequenciesToDetect(this.frequencyTable); Logger.info(`Loaded ${this.frequenciesToDetect.length} frequencies to detect`); } catch (error) { Logger.error('Failed to load frequency tables', error); throw error; } } private initializeDetectors(): void { // Initialize Goertzel detector this.goertzelDetector = new GoertzelDetector(this.config); // Initialize dual tone detector this.dualToneDetector = new DualToneDetector(this.config, this.frequencyTable); this.dualToneDetector.setGoertzelDetector(this.goertzelDetector); // Initialize bridge detector this.bridgeDetector = new BridgeDetector(); // Initialize sequence state machine with resolveTag function like Swift version const resolveTag = (low: number, high: number): string | null => { if (!this.dualToneDetector) return null; const result = this.dualToneDetector.isDualTone(low, high); return result.ok ? (result.tag || null) : null; }; this.sequenceStateMachine = new SequenceStateMachine(ConfigDefault, resolveTag); // Set up sequence callback this.sequenceStateMachine.onSequence = (sequence: string, point: EmitPoint) => { const event: ToneSequenceEvent = { sequence, timestamp: Date.now(), confidence: 1.0, point }; Logger.info('Sequence detected', event.sequence); // Check for duplicates before emitting if (this.duplicateDetector.checkAndAddSequence(event.sequence)) { // Not a duplicate, emit the sequence this.config.onSequence?.(event); // Evaluate custom tone and notify callback try { const result = this.customToneManager.checkCustomTone(event.sequence); this.config.onCustomTone?.(result, event); // Present custom tone content if it has action data if (this.inAppContentEnabled && result.isCustom && result.matchedTone) { const matchedTone = result.matchedTone as any; if (matchedTone.actionType && matchedTone.actionUrl) { const content = { actionType: matchedTone.actionType, actionUrl: matchedTone.actionUrl, actionData: matchedTone.actionData, title: matchedTone.clientDescription || matchedTone.client || 'Custom Tone' }; this.presentContentDirectly(content); } } } catch (_) {} // ALWAYS send request to backend for user tracking (both custom and default tones) if (this.inAppContentEnabled && event.sequence && event.sequence.length > 0) { this.presentInAppContent(event.sequence).catch(() => {}); } // Post notification (matching Swift version) this.notificationCenter.post(NotificationNames.toneListenSequenceDidUpdate, null, { sequence: event.sequence, timestamp: event.timestamp, confidence: event.confidence }); } else { Logger.debug('Duplicate sequence ignored', event.sequence); } }; // Set onPartial callback like Swift version this.sequenceStateMachine.onPartial = (seq: any[]) => { // Optional: progress snapshots if you want to drive UI // No-op by default like Swift version }; } private async presentInAppContent(sequence: string): Promise<void> { try { console.log('🎯 Framework: presentInAppContent called with sequence:', sequence); const getDeviceCode = async (): Promise<string> => { try { const key = 'tlrn_device_code'; const ls = (typeof window !== 'undefined' ? window.localStorage : undefined); let code = ls?.getItem(key) || ''; if (!code) { try { // Try to get native device ID first console.log('🎯 Framework: Attempting to get native device ID...'); code = await DeviceIdService.getDeviceId(); console.log('🎯 Framework: Got native device ID:', code); } catch (nativeError) { console.warn('🎯 Framework: Native device ID failed, using fallback:', nativeError); // Fallback to platform-specific generation if (Platform.OS === 'ios') { code = `ios-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; console.log('🎯 Framework: Generated iOS fallback device code:', code); } else if (Platform.OS === 'android') { code = `android-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; console.log('🎯 Framework: Generated Android fallback device code:', code); } else { // Web fallback code = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') ? crypto.randomUUID() : `web-${Date.now()}`; console.log('🎯 Framework: Generated web fallback device code:', code); } } // Cache the device code ls?.setItem(key, code); } else { console.log('🎯 Framework: Using cached device code:', code); } return code; } catch (error) { console.error('🎯 Framework: Error generating device code:', error); // Platform-specific fallback if (Platform.OS === 'ios') { return 'ios-device-fallback'; } else if (Platform.OS === 'android') { return 'android-device-fallback'; } else { return 'web-device-fallback'; } } }; const getGeo = async (): Promise<{ lat: string; lon: string }> => { try { console.log('🌍 Framework: Starting location collection...'); console.log('🌍 Framework: Geolocation variable:', typeof Geolocation); console.log('🌍 Framework: Geolocation getCurrentPosition:', typeof Geolocation?.getCurrentPosition); // Try React Native geolocation first (for mobile apps) if (Geolocation && typeof Geolocation.getCurrentPosition === 'function') { try { console.log('🌍 Framework: Attempting React Native location...'); console.log('🌍 Framework: Geolocation module available:', typeof Geolocation); const pos: any = await new Promise((resolve, reject) => { let done = false; const timer = setTimeout(() => { if (!done) { console.log('🌍 Framework: React Native location request timed out'); reject(new Error('timeout')); } }, 10000); console.log('🌍 Framework: Calling getCurrentPosition...'); Geolocation.getCurrentPosition( (p: any) => { done = true; clearTimeout(timer); console.log('🌍 Framework: React Native location received:', p.coords?.latitude, p.coords?.longitude); resolve(p); }, (error: any) => { done = true; clearTimeout(timer); console.log('🌍 Framework: React Native location error:', error.message); reject(new Error('denied')); }, { enableHighAccuracy: true, timeout: 8000, maximumAge: 300000 // 5 minutes cache } ); }); const lat = String(pos.coords?.latitude ?? '0'); const lon = String(pos.coords?.longitude ?? '0'); console.log('🌍 Framework: React Native final coordinates:', lat, lon); return { lat, lon }; } catch (rnError) { console.log('🌍 Framework: React Native geolocation error:', rnError); } } else { console.log('🌍 Framework: React Native Geolocation not available or getCurrentPosition not a function'); console.log('🌍 Framework: Geolocation object:', Geolocation); console.log('🌍 Framework: getCurrentPosition type:', typeof Geolocation?.getCurrentPosition); } // Fallback to web browser geolocation if (typeof navigator !== 'undefined' && (navigator as any).geolocation) { console.log('🌍 Framework: Attempting web browser location...'); const pos: any = await new Promise((resolve, reject) => { let done = false; const timer = setTimeout(() => { if (!done) { console.log('🌍 Framework: Web location request timed out'); reject(new Error('timeout')); } }, 10000); (navigator as any).geolocation.getCurrentPosition( (p: any) => { done = true; clearTimeout(timer); console.log('🌍 Framework: Web location received:', p.coords?.latitude, p.coords?.longitude); resolve(p); }, (error: any) => { done = true; clearTimeout(timer); console.log('🌍 Framework: Web location error:', error.message); reject(new Error('denied')); }, { enableHighAccuracy: true, timeout: 8000, maximumAge: 300000 // 5 minutes cache } ); }); const lat = String(pos.coords?.latitude ?? '0'); const lon = String(pos.coords?.longitude ?? '0'); console.log('🌍 Framework: Web final coordinates:', lat, lon); return { lat, lon }; } } catch (error) { console.log('🌍 Framework: All location methods failed:', error); } console.log('🌍 Framework: Using default coordinates 0,0'); return { lat: '0', lon: '0' }; }; // Get native device information let deviceName = 'Unknown'; let deviceVersion = 'N/A'; let platform = 'Unknown'; try { console.log('📱 Framework: Collecting native device information...'); const deviceInfo = await DeviceIdService.getDeviceInfo(); if (Platform.OS === 'ios') { deviceName = deviceInfo.model || deviceInfo.name || 'iOS Device'; deviceVersion = deviceInfo.systemVersion || 'N/A'; platform = 'iOS'; console.log('📱 Framework: iOS device info:', { deviceName, deviceVersion, platform }); } else if (Platform.OS === 'android') { deviceName = `${deviceInfo.manufacturer || 'Unknown'} ${deviceInfo.model || 'Device'}`; deviceVersion = deviceInfo.osVersion || 'N/A'; platform = 'Android'; console.log('📱 Framework: Android device info:', { deviceName, deviceVersion, platform }); } else { // Web fallback const ua = (typeof navigator !== 'undefined' ? (navigator as any).userAgent || '' : ''); deviceName = (typeof navigator !== 'undefined' ? ((navigator as any).platform || 'Web') : 'Web'); deviceVersion = ua || 'N/A'; platform = 'Web'; console.log('📱 Framework: Web fallback device info:', { deviceName, deviceVersion, platform }); } } catch (error) { console.warn('📱 Framework: Failed to get native device info, using fallback:', error); // Fallback to web-based detection const ua = (typeof navigator !== 'undefined' ? (navigator as any).userAgent || '' : ''); deviceName = (typeof navigator !== 'undefined' ? ((navigator as any).platform || 'Web') : 'Web'); deviceVersion = ua || 'N/A'; platform = Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : 'Web'; console.log('📱 Framework: Using fallback device info:', { deviceName, deviceVersion, platform }); } const deviceCode = await getDeviceCode(); const geo = await getGeo(); // Collect real network information console.log('🌐 Framework: Collecting network information...'); const networkInfo = await networkInfoCollector.collectNetworkInfo(); const wifiArray = networkInfo.wifi; const btArray = networkInfo.bluetooth; const cellularInfo = networkInfo.cellular; // Match Swift SDK date formatting: dd-MM-yyyy HH:mm:ss (local time) const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); const now = new Date(); const tonePacketDate = `${pad(now.getDate())}-${pad(now.getMonth() + 1)}-${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const payload = { clientName: this.config.clientId || 'Unknown', deviceName, deviceVersion, platform, deviceCode, latitude: geo.lat, longitude: geo.lon, tonePacketDate, toneSequence: sequence, wifi: wifiArray, bluetooth: btArray, cellular: cellularInfo } as any; const resp = await this.apiService.sendToneDataRequest(payload); console.log('🎯 Framework: API response received:', resp); // Some endpoints wrap in { content, status, message } const root: any = (resp as any)?.content ?? (resp as any)?.data ?? resp; console.log('🎯 Framework: Extracted root content:', root); const content = { actionType: root?.actionType, actionUrl: root?.actionUrl || root?.address, actionData: root?.actionData, title: root?.title || root?.clientDescription }; console.log('🎯 Framework: Processed content object:', content); // Only proceed if we have valid content (not null/undefined) if (!content.actionType && !content.actionUrl && !content.actionData && !content.title) { console.log('🎯 Framework: No content to present - checking for fallback graphic'); // Check CustomToneManager for fallback graphic when no specific content is found try { const customToneResult = this.customToneManager.checkCustomTone(sequence); if (customToneResult.fallbackGraphic) { console.log('🎯 Framework: Found fallback graphic:', customToneResult.fallbackGraphic); const fallbackContent = { actionType: 'image', actionUrl: customToneResult.fallbackGraphic, actionData: customToneResult.fallbackGraphic, title: 'Default Tone' }; // Use the same display logic as regular content if (this.presentContentOverride) { try { this.presentContentOverride(fallbackContent); } catch {} } // Auto-display modal for fallback content if (!this.presentContentOverride) { console.log('🎯 Framework: Auto-displaying fallback content'); this.showAutoModal(fallbackContent); } Logger.info('Presenting fallback content', fallbackContent); return; } } catch (error) { console.log('🎯 Framework: Error checking fallback graphic:', error); } console.log('🎯 Framework: No content or fallback graphic available - skipping modal'); Logger.debug('No content to present - skipping modal'); return; } console.log('🎯 Framework: Content validation passed, proceeding to display'); // Allow consumer callback to inspect even when not actionable // For webpage content: direct opening on React Native, modal on web (to avoid popup blocking) if ((content.actionType || '').toLowerCase() === 'webpage' && content.actionUrl) { console.log('🌐 Framework: Detected webpage action:', content.actionUrl); // React Native: use direct Linking (no popup blocking issues) if (typeof window === 'undefined') { console.log('📱 Framework: React Native detected, using Linking for:', content.actionUrl); try { // eslint-disable-next-line @typescript-eslint/no-var-requires const { Linking } = require('react-native'); if (Linking && typeof Linking.openURL === 'function') { Linking.openURL(content.actionUrl); } else { console.log('⚠️ Framework: Linking not available, cannot open URL'); Logger.warn('Linking not available for URL opening'); } } catch (err) { Logger.error('Failed to open webpage action', err as any); } return; } // Web: show modal with action button to avoid popup blocking // The modal will be created in createWebModal() with the "Open Link" button console.log('🌐 Framework: Web detected, will show modal with action button to avoid popup blocking'); } if (this.presentContentOverride) { try { this.presentContentOverride(content); } catch {} } // Auto-display modal for content if no override is provided // For webpage content: only show modal on web (React Native uses direct Linking above) if (!this.presentContentOverride) { console.log('🎯 Framework: No presentContentOverride, checking shouldShowModal'); // Show modal for webpage content on web, or for non-webpage content on any platform const shouldShowModal = content.actionType !== 'webpage' || typeof window !== 'undefined'; console.log('🎯 Framework: shouldShowModal:', shouldShowModal, 'actionType:', content.actionType, 'typeof window:', typeof window); if (shouldShowModal) { console.log('🎯 Framework: Calling showAutoModal with content:', content); this.showAutoModal(content); } } else { console.log('🎯 Framework: presentContentOverride exists, skipping auto modal'); } Logger.info('Presenting in-app content', content); } catch (e) { Logger.error('Failed to fetch in-app content', e); } } private presentContentDirectly(content: InAppContentPayload): void { try { // Allow consumer callback to inspect even when not actionable // For webpage content: direct opening on React Native, modal on web (to avoid popup blocking) if ((content.actionType || '').toLowerCase() === 'webpage' && content.actionUrl) { console.log('🌐 Framework: Detected webpage action:', content.actionUrl); // React Native: use direct Linking (no popup blocking issues) if (typeof window === 'undefined') { console.log('📱 Framework: React Native detected, using Linking for:', content.actionUrl); try { // eslint-disable-next-line @typescript-eslint/no-var-requires const { Linking } = require('react-native'); if (Linking && typeof Linking.openURL === 'function') { Linking.openURL(content.actionUrl); } else { console.log('⚠️ Framework: Linking not available, cannot open URL'); Logger.warn('Linking not available for URL opening'); } } catch (e) { console.log('⚠️ Framework: Failed to open URL with Linking:', e); Logger.error('Failed to open URL with Linking', e); } } else { // Web: use window.open (may be blocked by popup blockers) console.log('🌐 Framework: Web detected, using window.open for:', content.actionUrl); try { const targetUrl = (() => { try { const u = new URL(content.actionUrl, window.location.href); if (u.protocol === 'http:' || u.protocol === 'https:') return u.toString(); } catch {} try { if (this.config.baseURL) { const u2 = new URL(content.actionUrl, this.config.baseURL); if (u2.protocol === 'http:' || u2.protocol === 'https:') return u2.toString(); } } catch {} return content.actionUrl; })(); window.open(targetUrl, '_blank', 'noopener,noreferrer'); } catch (e) { console.log('⚠️ Framework: Failed to open URL with window.open:', e); Logger.error('Failed to open URL with window.open', e); } } } // Auto-display modal for content if no override is provided // For webpage content: only show modal on web (React Native uses direct Linking above) if (!this.presentContentOverride) { // Show modal for webpage content on web, or for non-webpage content on any platform const shouldShowModal = content.actionType !== 'webpage' || typeof window !== 'undefined'; if (shouldShowModal) { this.showAutoModal(content); } } Logger.info('Presenting in-app content directly', content); } catch (e) { Logger.error('Failed to present content directly', e); } } private showAutoModal(content: any): void { try { console.log('🎯 Framework: showAutoModal called with content:', content); // For web platform, create a simple modal using DOM manipulation if (typeof window !== 'undefined' && typeof document !== 'undefined') { console.log('🎯 Framework: Web environment detected, creating web modal'); this.createWebModal(content); } else { // For React Native, use Alert to show content automatically console.log('📱 Framework: Auto-showing content for React Native:', content); this.showReactNativeAlert(content); } } catch (error) { console.log('⚠️ Framework: Error in showAutoModal:', error); Logger.error('Failed to show auto modal:', error); } } private showReactNativeAlert(content: any): void { try { // Check if this is media content (image/video) - use InAppContentModal const isImage = content.actionType === 'image'; const isVideo = content.actionType === 'video'; if (isImage || isVideo) { console.log('📱 Framework: Media content detected, using InAppContentModal for:', content.actionType); this.showInAppContentModal(content); return; } // For text/webpage content, use native Alert console.log('📱 Framework: Using native Alert for:', content.actionType); // Get React Native Alert const { Alert } = require('react-native'); if (!Alert) { console.log('⚠️ Framework: Alert not available in React Native'); return; } // Build alert content for non-media content let title = content.title || 'Tone Content'; let message = ''; if (content.actionType === 'webpage') { message = `Web Link Available!\n\nTap "Open Link" to visit:\n${content.actionUrl || 'No URL provided'}`; } else { message = `Content Type: ${content.actionType || 'Unknown'}`; if (content.actionUrl) { message += `\n\nURL: ${content.actionUrl}`; } if (content.actionData) { message += `\n\nMeta Data: ${JSON.stringify(content.actionData)}`; } } // Show alert with action buttons for non-media content Alert.alert( title, message, [ { text: 'Cancel', style: 'cancel', }, ...(content.actionUrl ? [{ text: 'Open Link', onPress: () => { try { const { Linking } = require('react-native'); if (Linking && Linking.openURL) { Linking.openURL(content.actionUrl); } } catch (e) { console.log('⚠️ Framework: Failed to open URL:', e); } } }] : []) ] ); console.log('📱 Framework: Alert displayed successfully for:', content.actionType); } catch (error) { console.log('⚠️ Framework: Failed to show React Native Alert:', error); Logger.error('Failed to show React Native Alert:', error); } } private showInAppContentModal(content: any): void { try { console.log('📱 Framework: Showing media content:', content); // For media content (images/videos), send notification to trigger InAppContentModal console.log('📱 Framework: Broadcasting media content notification:', content.actionType); this.notificationCenter.post('InAppContentReceived', null, content); console.log('📱 Framework: Media content notification sent for:', content.actionType); } catch (error) { console.log('⚠️ Framework: Failed to show media content:', error); Logger.error('Failed to show media content:', error); } } private createWebModal(content: any): void { try { // Remove any existing modal const existingModal = document.getElementById('tlrn-auto-modal'); if (existingModal) { existingModal.remove(); } // Create modal container const modal = document.createElement('div'); modal.id = 'tlrn-auto-modal'; modal.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.8); display: flex; justify-content: center; align-items: center; z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; // Create modal content const modalContent = document.createElement('div'); modalContent.style.cssText = ` background-color: #0f1b2a; border-radius: 16px; padding: 20px; border: 1px solid #224d78; max-width: 400px; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); color: #e8f3ff; `; // Add title if available if (content.title) { const title = document.createElement('h2'); title.textContent = content.title; title.style.cssText = ` color: #e8f3ff; font-weight: 800; font-size: 18px; margin: 0 0 16px 0; text-align: center; `; modalContent.appendChild(title); } // Add content based on type const url = content.actionUrl || content.actionData || ''; const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(url); const isVideo = /\.(mp4|mov|webm)$/i.test(url); if (isImage && url) { const imgContainer = document.createElement('div'); imgContainer.style.cssText = ` text-align: center; margin-bottom: 16px; `; const img = document.createElement('img'); img.src = url; img.style.cssText = ` max-width: 100%; max-height: 300px; border-radius: 12px; background-color: #1a2332; `; img.onerror = () => { img.style.display = 'none'; const errorText = document.createElement('div'); errorText.textContent = 'Failed to load image'; errorText.style.cssText = ` color: #a6d2ff; font-size: 14px; padding: 20px; background-color: #1a2332; border-radius: 12px; `; imgContainer.appendChild(errorText); }; imgContainer.appendChild(img); modalContent.appendChild(imgContainer); } else if (isVideo && url) { const videoContainer = document.createElement('div'); videoContainer.style.cssText = ` text-align: center; margin-bottom: 16px; `; const video = document.createElement('video'); video.src = url; video.controls = true; video.style.cssText = ` max-width: 100%; max-height: 300px; border-radius: 12px; background-color: #1a2332; `; videoContainer.appendChild(video); modalContent.appendChild(videoContainer); } else if (url) { const textContainer = document.createElement('div'); textContainer.style.cssText = ` margin-bottom: 16px; text-align: center; `; const text = document.createElement('div'); text.textContent = url; text.style.cssText = ` color: #a6d2ff; font-size: 14px; line-height: 20px; word-break: break-all; `; textContainer.appendChild(text); modalContent.appendChild(textContainer); } // Add action button for webpage content if ((content.actionType || '').toLowerCase() === 'webpage' && content.actionUrl) { const actionButton = document.createElement('button'); actionButton.textContent = 'Open Link'; actionButton.style.cssText = ` background-color: #4DA3FF; color: #000; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 800; font-size: 16px; cursor: pointer; display: block; margin: 0 auto 12px auto; `; actionButton.onclick = () => { // This is now a direct user gesture, so window.open should work try { const targetUrl = (() => { try { const u = new URL(content.actionUrl, window.location.href); if (u.protocol === 'http:' || u.protocol === 'https:') return u.toString(); } catch {} try { if (this.config.baseURL) { const u2 = new URL(content.actionUrl, this.config.baseURL); if (u2.protocol === 'http:' || u2.protocol === 'https:') return u2.toString(); } } catch {} return null; })(); if (targetUrl) { console.log('🔗 Framework: User clicked to open URL:', targetUrl); const handle = window.open(targetUrl, '_blank', 'noopener'); if (handle) { console.log('✅ Framework: Successfully opened URL in new tab'); } else { console.log('❌ Framework: Failed to open URL - popup blocked'); // Don't navigate current tab - let user know they need to allow popups alert('Please allow popups for this site to open links in new tabs, or copy the URL manually.'); } } } catch (error) { console.error('❌ Framework: Error opening URL:', error); } modal.remove(); }; modalContent.appendChild(actionButton); } // Add close button const closeButton = document.createElement('button'); closeButton.textContent = 'Close'; closeButton.style.cssText = ` background-color: #666; color: #fff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 800; font-size: 16px; cursor: pointer; display: block; margin: 0 auto; `; closeButton.onclick = () => { modal.remove(); }; modalContent.appendChild(closeButton); modal.appendChild(modalContent); // Close modal when clicking outside modal.onclick = (e) => { if (e.target === modal) { modal.remove(); } }; // Add escape key handler const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); // Add to document document.body.appendChild(modal); } catch (error) { Logger.error('Failed to create web modal:', error); } } private async requestMicrophonePermission(): Promise<boolean> { try { console.log('🎯 ToneListenReactNative: Requesting microphone permission...'); // Check if we're on React Native platform if (typeof window === 'undefined') { // React Native: Use native module for permission request try { // eslint-disable-next-line @typescript-eslint/no-var-requires const { NativeModules } = require('react-native'); const ToneListenAudioModule = NativeModules.ToneListenAudioModule; if (ToneListenAudioModule && typeof ToneListenAudioModule.requestMicrophonePermission === 'function') { console.log('🎯 ToneListenReactNative: Using native module for microphone permission'); const granted = await ToneListenAudioModule.requestMicrophonePermission(); console.log('🎯 ToneListenReactNative: Native permission result:', granted); return granted; } else { console.log('⚠️ ToneListenReactNative: Native module not available, assuming permission granted'); return true; } } catch (nativeError) { console.error('🎯 ToneListenReactNative: Native permission request failed:', nativeError); return false; } } else { // Web: Use browser APIs if (navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') { // Proactively query permissions on web for better UX try { // Some browsers support permissions query; ignore failures // @ts-ignore const status = await (navigator as any).permissions?.query?.({ name: 'microphone' }); if (status && status.state === 'denied') { return false; } } catch {} } return true; // Simulate granted permission for web } } catch (error) { console.error('🎯 ToneListenReactNative: Permission request failed:', error); return false; } } private async initializeAudioContext(): Promise<void> { try { // Create audio context this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: this.config.sampleRate || 44100 }); // Get user media const stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: this.config.sampleRate || 44100, channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false } }); // Create analyser node this.analyser = this.audioContext.createAnalyser(); // fftSize must be a power of two in WebAudio; find nearest power for (bufferSize*2) const desired = (this.config.bufferSize || 4800) * 2; const pow2 = Math.pow(2, Math.round(Math.log2(Math.max(32, Math.min(32768, desired))))); this.analyser.fftSize = pow2; this.analyser.smoothingTimeConstant = 0; // Connect audio source const source = this.audioContext.createMediaStreamSource(stream); source.connect(this.analyser); // Create data array this.dataArray = new Float32Array(this.analyser.frequencyBinCount); // Ensure the context is running (browsers may start suspended until a user gesture) if (this.audioContext.state === 'suspended') { await this.audioContext.resume().catch(() => {}); } console.log('🎯 ToneListenReactNative: Audio context initialized'); } catch (error) { console.error('🎯 ToneListenReactNative: Failed to initialize audio context:', error); throw error; } } private startAudioProcessing(): void { if (!this.analyser || !this.dataArray) return; const processAudio = () => { if (!this.isListening || !this.analyser || !this.dataArray) return; // Best-effort resume if the context is suspended (e.g., after refresh without user gesture) if (this.audioContext && this.audioContext.state === 'suspended') { this.audioContext.resume().catch(() => {}); } // Get audio data this.analyser.getFloatTimeDomainData(this.dataArray as any); // Process with Goertzel detector to get raw frequencies and powers const goertzelResults = this.goertzelDetector?.processAudioBuffer( this.dataArray, this.audioContext?.sampleRate || 44100 ) || []; if (goertzelResults.length > 0) { const freqs = goertzelResults.map(r => Math.round(r.frequency)); const powers = goertzelResults.map(r => r.power); this.handleDetectionFrame(freqs, powers); } // Continue processing this.animationFrameId = requestAnimationFrame(processAudio); }; // Start processing this.animationFrameId = requestAnimationFrame(processAudio); } private handleDetectionFrame(freqs: number[], powers: number[]): void { // Find the two highest power frequencies (matching Swift implementation exactly) let maxPower: number = Number.MIN_VALUE; let maxFrequency: number = 0; let maxPower2: number = Number.MIN_VALUE; let maxFrequency2: number = 0; for (let index = 0; index < freqs.length; index++) { const detectFreq = freqs[index]; const power = powers[index]; if (typeof detectFreq !== 'number' || typeof power !== 'number') continue; const gate: number = detectFreq >= 18000 ? 0.01 : 0.05; if (detectFreq >= 14000 && detectFreq <= 22000 && power > gate) { if (power > maxPower) { maxPower2 = maxPower; maxFrequency2 = maxFrequency; maxPower = power; maxFrequency = detectFreq; } else if (power > maxPower2) { maxPower2 = power; maxFrequency2 = detectFreq; } } } // Detect bridge tones const bridgeResult = this.bridgeDetector?.detect(freqs, powers) || null; // Prepare data for state machine const max1 = maxPower > Number.MIN_VALUE ? { f: maxFrequency, p: maxPower } : null; const max2 = maxPower2 > Number.MIN_VALUE ? { f: maxFrequency2, p: maxPower2 } : null; this.sequenceStateMachine?.feed(max1, max2, bridgeResult); // Emit individual detection events if (!bridgeResult?.bridge && max1 && max2) { const dualResult = this.dualToneDe