tonelisten-react-native
Version:
ToneListen React Native Framework - Audio tone detection for React Native apps
1,273 lines (1,130 loc) • 59.8 kB
text/typescript
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