From 61beadcd063fcec8d49155cb4b03d69a8a247eb7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 12 Jan 2026 13:21:29 -0800 Subject: [PATCH] Fix browser phone connection and audio issues on Android tablets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves issues where browser phone PWA failed to connect and calls would immediately hang up when answered on Android tablets. Adds proper mobile audio handling, device connection monitoring, and PWA notifications for incoming calls. Key changes: - Add AudioContext initialization with mobile unlock for autoplay support - Add Android-specific WebRTC constraints (echo cancellation, ICE restart) - Add device connection state monitoring and auto-reconnection - Add incoming call ringtone with vibration fallback - Add PWA service worker notifications for background calls - Add Page Visibility API for background call detection - Improve call answer handler with connection state validation - Add touch event support for mobile dialpad 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- admin/class-twp-admin.php | 396 ++++++++++++++++++++++++++++++-- assets/images/README.md | 42 ++++ assets/js/twp-service-worker.js | 18 +- assets/sounds/README.md | 36 +++ 4 files changed, 470 insertions(+), 22 deletions(-) create mode 100644 assets/images/README.md create mode 100644 assets/sounds/README.md diff --git a/admin/class-twp-admin.php b/admin/class-twp-admin.php index f80e6c9..3bad851 100644 --- a/admin/class-twp-admin.php +++ b/admin/class-twp-admin.php @@ -6996,6 +6996,7 @@ class TWP_Admin {
Ready
+
Connecting...
@@ -7434,7 +7435,211 @@ class TWP_Admin { var callStartTime = null; var tokenRefreshTimer = null; var tokenExpiry = null; - + var audioContext = null; + var ringtoneAudio = null; + var isPageVisible = true; + var deviceConnectionState = 'disconnected'; // disconnected, connecting, connected + var serviceWorkerRegistration = null; + + // Initialize AudioContext for mobile audio playback + function initializeAudioContext() { + try { + if (!audioContext) { + // Create AudioContext with compatibility + var AudioContextClass = window.AudioContext || window.webkitAudioContext; + audioContext = new AudioContextClass(); + console.log('AudioContext created, state:', audioContext.state); + } + + // Resume AudioContext if suspended (required on mobile) + if (audioContext.state === 'suspended') { + audioContext.resume().then(function() { + console.log('AudioContext resumed successfully'); + }).catch(function(err) { + console.error('Failed to resume AudioContext:', err); + }); + } + + return true; + } catch (error) { + console.error('Failed to initialize AudioContext:', error); + return false; + } + } + + // Create and setup ringtone audio element + function setupRingtone() { + if (!ringtoneAudio) { + ringtoneAudio = new Audio(); + // Use a simple sine wave tone or default ringtone + // For now, we'll use a data URI for a simple beep tone + ringtoneAudio.loop = true; + ringtoneAudio.volume = 0.7; + + // Create a simple ringtone using Web Audio API + createRingtone(); + } + } + + // Create ringtone using Web Audio API for better mobile support + function createRingtone() { + // Use a simple base64-encoded beep tone (short MP3) + // This is a simple 1-second beep at 800Hz + // You can replace this with a custom ringtone file URL if you upload one + + // For now, use a simple approach: HTML5 Audio with error fallback + // Note: On mobile, audio playback may be restricted, so we rely heavily on vibration + var ringtoneUrl = ''; + + // Try to load the ringtone file + ringtoneAudio.src = ringtoneUrl; + + // Fallback: if ringtone file fails to load, we'll just use vibration + ringtoneAudio.addEventListener('error', function(e) { + console.log('Ringtone file not found (this is normal), using vibration only for mobile'); + // Don't show error - vibration is sufficient for mobile + }, { once: true }); + + // Try to preload + ringtoneAudio.load(); + } + + // Play ringtone for incoming call + function playRingtone() { + try { + // Initialize AudioContext on user interaction + initializeAudioContext(); + + if (ringtoneAudio) { + var playPromise = ringtoneAudio.play(); + + if (playPromise !== undefined) { + playPromise.then(function() { + console.log('Ringtone playing'); + }).catch(function(error) { + console.error('Ringtone play failed:', error); + // Fallback: vibrate on mobile + vibrateDevice([300, 200, 300, 200, 300]); + }); + } + } + + // Always vibrate on mobile for better notification + vibrateDevice([300, 200, 300, 200, 300]); + + } catch (error) { + console.error('Error playing ringtone:', error); + } + } + + // Stop ringtone + function stopRingtone() { + try { + if (ringtoneAudio) { + ringtoneAudio.pause(); + ringtoneAudio.currentTime = 0; + } + } catch (error) { + console.error('Error stopping ringtone:', error); + } + } + + // Vibrate device (mobile) + function vibrateDevice(pattern) { + if ('vibrate' in navigator) { + navigator.vibrate(pattern); + } + } + + // Register service worker for PWA notifications + function registerServiceWorker() { + if ('serviceWorker' in navigator) { + var swPath = ''; + + navigator.serviceWorker.register(swPath) + .then(function(registration) { + console.log('Service Worker registered:', registration); + serviceWorkerRegistration = registration; + + // Request notification permission + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission().then(function(permission) { + console.log('Notification permission:', permission); + }); + } + }) + .catch(function(error) { + console.error('Service Worker registration failed:', error); + }); + } + } + + // Send notification via service worker + function sendIncomingCallNotification(callerNumber) { + // Try browser notification first + if ('Notification' in window && Notification.permission === 'granted') { + if (serviceWorkerRegistration && serviceWorkerRegistration.active) { + serviceWorkerRegistration.active.postMessage({ + type: 'SHOW_NOTIFICATION', + title: 'Incoming Call', + body: 'Call from ' + (callerNumber || 'Unknown Number'), + icon: '', + tag: 'incoming-call', + requireInteraction: true + }); + } else { + // Fallback: show notification directly + new Notification('Incoming Call', { + body: 'Call from ' + (callerNumber || 'Unknown Number'), + icon: '', + tag: 'incoming-call', + requireInteraction: true + }); + } + } + } + + // Monitor page visibility for background call handling + function setupPageVisibility() { + document.addEventListener('visibilitychange', function() { + isPageVisible = !document.hidden; + console.log('Page visibility changed:', isPageVisible ? 'visible' : 'hidden'); + + // If page becomes visible, resume audio context + if (isPageVisible && audioContext) { + initializeAudioContext(); + } + }); + } + + // Update device connection status in UI + function updateConnectionStatus(state) { + deviceConnectionState = state; + var statusText = ''; + var statusColor = ''; + + switch(state) { + case 'connected': + statusText = 'Connected'; + statusColor = '#4CAF50'; + break; + case 'connecting': + statusText = 'Connecting...'; + statusColor = '#FF9800'; + break; + case 'disconnected': + statusText = 'Disconnected'; + statusColor = '#f44336'; + break; + default: + statusText = 'Unknown'; + statusColor = '#999'; + } + + // Update status indicator (we'll add this to the UI) + $('#device-connection-status').text(statusText).css('color', statusColor); + } + // Wait for SDK to load function waitForTwilioSDK(callback) { if (typeof Twilio !== 'undefined' && Twilio.Device) { @@ -7450,7 +7655,19 @@ class TWP_Admin { // Initialize the browser phone function initializeBrowserPhone() { $('#phone-status').text('Initializing...'); - + updateConnectionStatus('connecting'); + + // Initialize audio and PWA features + setupRingtone(); + registerServiceWorker(); + setupPageVisibility(); + + // Initialize AudioContext on first user interaction + $(document).one('click touchstart', function() { + console.log('User interaction detected, initializing AudioContext'); + initializeAudioContext(); + }); + // Wait for SDK before proceeding waitForTwilioSDK(function() { // Get capability token (access token for v2) @@ -7468,9 +7685,11 @@ class TWP_Admin { // WordPress wp_send_json_error sends the error message as response.data var errorMsg = response.data || response.error || 'Unknown error'; showError('Failed to initialize: ' + errorMsg); + updateConnectionStatus('disconnected'); } }).fail(function() { showError('Failed to connect to server'); + updateConnectionStatus('disconnected'); }); }); } @@ -7517,25 +7736,57 @@ class TWP_Admin { if (typeof Twilio === 'undefined' || !Twilio.Device) { throw new Error('Twilio Voice SDK not loaded'); } - + + console.log('Setting up Twilio Device...'); + updateConnectionStatus('connecting'); + // Request media permissions before setting up device const hasPermissions = await requestMediaPermissions(); if (!hasPermissions) { + updateConnectionStatus('disconnected'); return; // Stop setup if permissions denied } - + // Clean up existing device if any if (device) { + console.log('Destroying existing device'); await device.destroy(); } - + + // Detect if we're on Android/mobile for specific settings + var isAndroid = /Android/i.test(navigator.userAgent); + var isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + + console.log('Device detection - Android:', isAndroid, 'Mobile:', isMobile); + + // Android-specific audio constraints for better WebRTC performance + var audioConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }; + + // Additional Android-specific settings + if (isAndroid) { + audioConstraints.googEchoCancellation = true; + audioConstraints.googNoiseSuppression = true; + audioConstraints.googAutoGainControl = true; + audioConstraints.googHighpassFilter = true; + } + // Setup Twilio Voice SDK v2 Device // Note: Voice SDK v2 uses Twilio.Device directly, not Twilio.Voice.Device device = new Twilio.Device(token, { logLevel: 1, // 0 = TRACE, 1 = DEBUG codecPreferences: ['opus', 'pcmu'], - edge: 'sydney' // Or closest edge location + edge: 'sydney', // Or closest edge location + enableIceRestart: true, // Important for mobile network switching + audioConstraints: audioConstraints, + maxCallSignalingTimeoutMs: 30000, // 30 seconds timeout for mobile + closeProtection: true // Warn before closing during call }); + + console.log('Twilio Device created with audio constraints:', audioConstraints); // Set up event handlers BEFORE registering // Device registered and ready @@ -7543,14 +7794,23 @@ class TWP_Admin { console.log('Device registered successfully'); $('#phone-status').text('Ready').css('color', '#4CAF50'); $('#call-btn').prop('disabled', false); + updateConnectionStatus('connected'); }); - + + // Device unregistered + device.on('unregistered', function() { + console.log('Device unregistered'); + updateConnectionStatus('disconnected'); + }); + // Handle errors device.on('error', function(error) { console.error('Twilio Device Error:', error); - + console.error('Error code:', error.code, 'Message:', error.message); + updateConnectionStatus('disconnected'); + var errorMsg = error.message || error.toString(); - + // Provide specific help for common errors if (errorMsg.includes('valid callerId must be provided')) { errorMsg = 'Caller ID error: Make sure you select a verified Twilio phone number as Caller ID. The number must be purchased through your Twilio account.'; @@ -7560,23 +7820,48 @@ class TWP_Admin { errorMsg = 'Token error: ' + errorMsg + ' - The page will automatically try to refresh the token.'; // Try to reinitialize after token error setTimeout(initializeBrowserPhone, 5000); + } else if (errorMsg.includes('31005') || errorMsg.includes('Connection error')) { + errorMsg = 'Connection error: Check your internet connection. If on mobile, try switching between WiFi and cellular data.'; + // Retry connection + setTimeout(function() { + if (device) { + console.log('Attempting to reconnect device...'); + device.register(); + } + }, 3000); } - + showError(errorMsg); }); - + // Handle incoming calls device.on('incoming', function(call) { + console.log('Incoming call from:', call.parameters.From); + console.log('Call SID:', call.parameters.CallSid); + console.log('Device connection state:', deviceConnectionState); + currentCall = call; + var callerNumber = call.parameters.From || 'Unknown Number'; + $('#phone-status').text('Incoming Call').css('color', '#FF9800'); - $('#phone-number-display').text(call.parameters.From || 'Unknown Number'); + $('#phone-number-display').text(callerNumber); $('#call-btn').hide(); $('#answer-btn').show(); - + + // Play ringtone and show notification + playRingtone(); + + // If page is in background, send notification + if (!isPageVisible) { + console.log('Page in background, sending notification'); + sendIncomingCallNotification(callerNumber); + } + // Setup call event handlers setupCallHandlers(call); - + if ($('#auto-answer').is(':checked')) { + console.log('Auto-answer enabled, accepting call'); call.accept(); } }); @@ -7599,6 +7884,8 @@ class TWP_Admin { function setupCallHandlers(call) { // Call accepted/connected call.on('accept', function() { + console.log('Call accepted and connected'); + stopRingtone(); $('#phone-status').text('Connected').css('color', '#2196F3'); $('#call-btn').hide(); $('#answer-btn').hide(); @@ -7607,9 +7894,11 @@ class TWP_Admin { $('#admin-call-controls-panel').show(); startCallTimer(); }); - + // Call disconnected call.on('disconnect', function() { + console.log('Call disconnected'); + stopRingtone(); currentCall = null; $('#phone-status').text('Ready').css('color', '#4CAF50'); $('#hangup-btn').hide(); @@ -7619,22 +7908,26 @@ class TWP_Admin { $('#admin-call-controls-panel').hide(); $('#call-timer').hide(); stopCallTimer(); - + // Reset button states $('#admin-hold-btn').text('Hold').removeClass('btn-active'); $('#admin-record-btn').text('Record').removeClass('btn-active'); }); - + // Call rejected call.on('reject', function() { + console.log('Call rejected'); + stopRingtone(); currentCall = null; $('#phone-status').text('Ready').css('color', '#4CAF50'); $('#answer-btn').hide(); $('#call-btn').show(); }); - + // Call cancelled (by caller before answer) call.on('cancel', function() { + console.log('Call cancelled by caller'); + stopRingtone(); currentCall = null; $('#phone-status').text('Missed Call').css('color', '#FF9800'); $('#answer-btn').hide(); @@ -7643,6 +7936,26 @@ class TWP_Admin { $('#phone-status').text('Ready').css('color', '#4CAF50'); }, 3000); }); + + // Call error + call.on('error', function(error) { + console.error('Call error:', error); + console.error('Error code:', error.code, 'Message:', error.message); + stopRingtone(); + + var errorMsg = error.message || error.toString(); + + // Specific error handling for Android/mobile + if (error.code === 31005) { + errorMsg = 'Connection failed: Check your network connection. Try switching between WiFi and cellular data.'; + } else if (error.code === 31201 || error.code === 31204) { + errorMsg = 'Call setup failed: Please try again. If the problem persists, refresh the page.'; + } else if (error.code === 31208) { + errorMsg = 'Media connection failed: Check microphone permissions and try again.'; + } + + showError('Call error: ' + errorMsg); + }); } function refreshToken() { @@ -7753,11 +8066,15 @@ class TWP_Admin { $('#caller-id-select').html(''); }); - // Dialpad functionality - $('.dialpad-btn').on('click', function() { + // Dialpad functionality (support both click and touch events) + $('.dialpad-btn').on('click touchend', function(e) { + e.preventDefault(); // Prevent duplicate events var digit = $(this).data('digit'); var currentVal = $('#phone-number-input').val(); $('#phone-number-input').val(currentVal + digit); + + // Initialize AudioContext on user interaction (mobile requirement) + initializeAudioContext(); }); // Call button @@ -7818,8 +8135,45 @@ class TWP_Admin { // Answer button $('#answer-btn').on('click', function() { - if (currentCall) { + console.log('Answer button clicked'); + console.log('Device connection state:', deviceConnectionState); + console.log('Current call:', currentCall); + + if (!currentCall) { + console.error('No current call to answer'); + showError('No incoming call to answer'); + return; + } + + // Check device connection state + if (deviceConnectionState !== 'connected') { + console.error('Device not connected, state:', deviceConnectionState); + showError('Phone not connected. Reconnecting...'); + + // Try to reconnect + if (device) { + device.register().then(function() { + console.log('Device reconnected, answering call'); + if (currentCall) { + currentCall.accept(); + } + }).catch(function(err) { + console.error('Failed to reconnect device:', err); + showError('Failed to reconnect. Please refresh the page.'); + }); + } + return; + } + + // Initialize AudioContext before accepting (important for mobile) + initializeAudioContext(); + + try { + console.log('Accepting call...'); currentCall.accept(); + } catch (error) { + console.error('Error accepting call:', error); + showError('Failed to answer call: ' + error.message); } }); diff --git a/assets/images/README.md b/assets/images/README.md new file mode 100644 index 0000000..6b831d2 --- /dev/null +++ b/assets/images/README.md @@ -0,0 +1,42 @@ +# Browser Phone Images + +## Required Images for PWA Notifications + +Place the following image files in this directory for browser push notifications: + +### phone-icon.png +- **Purpose**: Main notification icon +- **Size**: 192x192 pixels (recommended) +- **Format**: PNG with transparency +- **Usage**: Shown in browser notifications for incoming calls + +### phone-badge.png +- **Purpose**: Small badge icon for notifications +- **Size**: 96x96 pixels or 72x72 pixels +- **Format**: PNG with transparency +- **Usage**: Displayed in the notification badge area (Android) + +## Fallback Behavior + +If these images are not present, the browser will: +- Use a default browser notification icon +- Still display text-based notifications +- Functionality will not be affected + +## Image Creation Tips + +- Use a simple, recognizable phone icon +- Ensure good contrast for visibility +- Test on both light and dark backgrounds +- Transparent backgrounds work best +- Use a consistent color scheme with your brand + +## Example Resources + +- Google Material Icons: https://fonts.google.com/icons +- Font Awesome: https://fontawesome.com/ +- Flaticon: https://www.flaticon.com/ +- Or create custom icons using tools like: + - Figma + - Adobe Illustrator + - Inkscape (free) diff --git a/assets/js/twp-service-worker.js b/assets/js/twp-service-worker.js index 4daa4c6..e0acde5 100644 --- a/assets/js/twp-service-worker.js +++ b/assets/js/twp-service-worker.js @@ -72,7 +72,23 @@ self.addEventListener('push', function(event) { // Handle messages from the main script self.addEventListener('message', function(event) { + console.log('TWP Service Worker: Message received', event.data); + if (event.data && event.data.type === 'SHOW_NOTIFICATION') { - self.registration.showNotification(event.data.title, event.data.options); + const options = { + body: event.data.body || 'You have a new call waiting', + icon: event.data.icon || '/wp-content/plugins/twilio-wp-plugin/assets/images/phone-icon.png', + badge: '/wp-content/plugins/twilio-wp-plugin/assets/images/phone-badge.png', + vibrate: [300, 200, 300, 200, 300], + tag: event.data.tag || 'incoming-call', + requireInteraction: event.data.requireInteraction !== undefined ? event.data.requireInteraction : true, + data: event.data.data || {} + }; + + console.log('TWP Service Worker: Showing notification', event.data.title, options); + + event.waitUntil( + self.registration.showNotification(event.data.title || 'Incoming Call', options) + ); } }); \ No newline at end of file diff --git a/assets/sounds/README.md b/assets/sounds/README.md new file mode 100644 index 0000000..a1f93af --- /dev/null +++ b/assets/sounds/README.md @@ -0,0 +1,36 @@ +# Ringtone Audio Files + +## Custom Ringtone + +To add a custom ringtone for incoming calls in the browser phone: + +1. Place your ringtone audio file in this directory +2. Name it: `ringtone.mp3` +3. Recommended format: MP3, under 5 seconds, loopable + +## Fallback Behavior + +If no ringtone file is present, the browser phone will: +- Use device vibration on mobile devices (Android, iOS) +- Rely on browser notifications to alert users +- Display visual indicators in the browser interface + +## Supported Audio Formats + +- **MP3** (recommended) - Best compatibility across browsers +- **OGG** - Good for Firefox, Chrome +- **WAV** - Larger file size but universal support + +## File Requirements + +- Maximum file size: 100KB recommended +- Duration: 1-5 seconds (will loop) +- Sample rate: 44.1kHz or 48kHz +- Bitrate: 128kbps or higher + +## Example Ringtone Sources + +You can find free ringtone files at: +- https://freesound.org/ +- https://incompetech.com/ +- Or create your own using Audacity or similar tools