diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1af619f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Dependencies +vendor/ +node_modules/ + +# Build artifacts +mobile/android/.gradle/ +mobile/android/build/ +mobile/android/app/build/ +mobile/build/ +mobile/.dart_tool/ + +# Local config (machine-specific paths) +mobile/android/local.properties + +# IDE +.idea/ +*.iml +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md index 55218f0..e60ac1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,33 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]); - Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo - Wrong edge causes immediate call failures (e.g., US calls with Sydney edge) +## Mobile App SSE (Server-Sent Events) +The mobile app uses SSE for real-time updates (queue changes, agent status). If SSE doesn't work (green dot stays red), the app automatically falls back to 5-second polling. + +### Apache + PHP-FPM Buffering Fix +`mod_proxy_fcgi` buffers PHP output by default, which breaks SSE streaming. Fix by adding a config file on the server: + +```bash +echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/home/shadowdao/public_html/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf +httpd -t && systemctl restart httpd +``` + +- **`flushpackets=on`** is the key — tells Apache to flush PHP-FPM output immediately +- This is a `ProxyPassMatch` directive — **cannot** go in `.htaccess`, must be server config +- The PHP-FPM socket path (`/run/php-fpm/www.sock`) must match `/etc/httpd/conf.d/php.conf` +- If the server uses nginx instead of Apache, add `X-Accel-Buffering: no` header (already in PHP code) +- If behind HAProxy with HTTP/2, the issue is Apache→client buffering, not HTTP/2 framing + +### Diagnosis +```bash +# Check PHP-FPM proxy config +grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/ +# Check if flushpackets is configured +grep -r "flushpackets" /etc/httpd/conf.d/ +# Test SSE endpoint (should stream data, not hang) +curl -N -H "Authorization: Bearer TOKEN" https://phone.cloud-hosting.io/wp-json/twilio-mobile/v1/stream/events +``` + ## Changelog See `README.md` for detailed version history. Current version: v2.8.9. diff --git a/includes/class-twp-activator.php b/includes/class-twp-activator.php index 0152161..d6e73a2 100644 --- a/includes/class-twp-activator.php +++ b/includes/class-twp-activator.php @@ -479,7 +479,13 @@ class TWP_Activator { if (empty($auto_busy_at_exists)) { $wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at"); } - + + // Add pre_call_status column to store status before a call set agent to busy + $pre_call_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_agent_status LIKE 'pre_call_status'"); + if (empty($pre_call_exists)) { + $wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN pre_call_status varchar(20) DEFAULT NULL AFTER auto_busy_at"); + } + $table_schedules = $wpdb->prefix . 'twp_phone_schedules'; // Check if holiday_dates column exists diff --git a/includes/class-twp-agent-manager.php b/includes/class-twp-agent-manager.php index 1c2724f..97911f9 100644 --- a/includes/class-twp-agent-manager.php +++ b/includes/class-twp-agent-manager.php @@ -619,35 +619,35 @@ class TWP_Agent_Manager { } /** - * Check and revert agents from auto-busy to available after 1 minute + * Check and revert agents from auto-busy to their previous status after 30 seconds */ public static function revert_auto_busy_agents() { global $wpdb; $table_name = $wpdb->prefix . 'twp_agent_status'; - - // Find agents who have been auto-busy for more than 1 minute and are still logged in - $cutoff_time = date('Y-m-d H:i:s', strtotime('-1 minute')); - + + // Find agents who have been auto-busy for more than 30 seconds and are still logged in + $cutoff_time = date('Y-m-d H:i:s', strtotime('-30 seconds')); + $auto_busy_agents = $wpdb->get_results($wpdb->prepare( - "SELECT user_id, current_call_sid FROM $table_name - WHERE status = 'busy' - AND auto_busy_at IS NOT NULL + "SELECT user_id, current_call_sid, pre_call_status FROM $table_name + WHERE status = 'busy' + AND auto_busy_at IS NOT NULL AND auto_busy_at < %s AND is_logged_in = 1", $cutoff_time )); - + foreach ($auto_busy_agents as $agent) { // Verify the call is actually finished before reverting $call_sid = $agent->current_call_sid; $call_active = false; - + if ($call_sid) { // Check if call is still active using Twilio API try { $api = new TWP_Twilio_API(); $call_status = $api->get_call_status($call_sid); - + // If call is still in progress, don't revert yet if (in_array($call_status, ['queued', 'ringing', 'in-progress'])) { $call_active = true; @@ -655,17 +655,25 @@ class TWP_Agent_Manager { } } catch (Exception $e) { error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage()); - // If we can't check call status, assume it's finished and proceed with revert } } - + // Only revert if call is not active if (!$call_active) { - error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from auto-busy to available"); - self::set_agent_status($agent->user_id, 'available', null, false); + $revert_to = !empty($agent->pre_call_status) ? $agent->pre_call_status : 'available'; + error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from busy to {$revert_to}"); + self::set_agent_status($agent->user_id, $revert_to, null, false); + // Clear pre_call_status + $wpdb->update( + $table_name, + array('pre_call_status' => null), + array('user_id' => $agent->user_id), + array('%s'), + array('%d') + ); } } - + return count($auto_busy_agents); } diff --git a/includes/class-twp-call-queue.php b/includes/class-twp-call-queue.php index 611d0a5..ee71b0c 100644 --- a/includes/class-twp-call-queue.php +++ b/includes/class-twp-call-queue.php @@ -33,8 +33,8 @@ class TWP_Call_Queue { ); if ($result !== false) { - // Notify agents via SMS when a new call enters the queue - self::notify_agents_for_queue($queue_id, $call_data['from_number']); + // Notify agents via SMS and FCM when a new call enters the queue + self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']); return $position; } @@ -580,49 +580,60 @@ class TWP_Call_Queue { /** * Notify agents via SMS when a call enters the queue */ - private static function notify_agents_for_queue($queue_id, $caller_number) { + private static function notify_agents_for_queue($queue_id, $caller_number, $call_sid = '') { global $wpdb; - + error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}"); - + // Get queue information including assigned agent group and phone number $queue_table = $wpdb->prefix . 'twp_call_queues'; $queue = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $queue_table WHERE id = %d", $queue_id )); - + if (!$queue) { error_log("TWP: Queue {$queue_id} not found in database"); return; } - - if (!$queue->agent_group_id) { - error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications"); - return; - } - - error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}"); - + // Send Discord/Slack notification for incoming call require_once dirname(__FILE__) . '/class-twp-notifications.php'; - error_log("TWP: Triggering Discord/Slack notification for incoming call"); TWP_Notifications::send_call_notification('incoming_call', array( 'type' => 'incoming_call', 'caller' => $caller_number, 'queue' => $queue->queue_name, 'queue_id' => $queue_id )); - - // Get members of the assigned agent group - require_once dirname(__FILE__) . '/class-twp-agent-groups.php'; - $members = TWP_Agent_Groups::get_group_members($queue->agent_group_id); // Send FCM push notifications to agents' mobile devices require_once dirname(__FILE__) . '/class-twp-fcm.php'; $fcm = new TWP_FCM(); + $notified_users = array(); + + // Always notify personal queue owner + if (!empty($queue->user_id)) { + $fcm->notify_queue_alert($queue->user_id, $caller_number, $queue->queue_name, $call_sid); + $notified_users[] = $queue->user_id; + error_log("TWP: FCM queue alert sent to queue owner user {$queue->user_id}"); + } + + if (!$queue->agent_group_id) { + error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications"); + return; + } + + error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}"); + + // Get members of the assigned agent group + require_once dirname(__FILE__) . '/class-twp-agent-groups.php'; + $members = TWP_Agent_Groups::get_group_members($queue->agent_group_id); + foreach ($members as $member) { - $fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, ''); + if (!in_array($member->user_id, $notified_users)) { + $fcm->notify_queue_alert($member->user_id, $caller_number, $queue->queue_name, $call_sid); + $notified_users[] = $member->user_id; + } } if (empty($members)) { diff --git a/includes/class-twp-fcm.php b/includes/class-twp-fcm.php index 579f4ec..84a494e 100644 --- a/includes/class-twp-fcm.php +++ b/includes/class-twp-fcm.php @@ -279,22 +279,68 @@ class TWP_FCM { } /** - * Send incoming call notification + * Send queue alert notification (call entered queue). + * Uses data-only message so it works in background/killed state. */ - public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) { - $title = 'Incoming Call'; - $body = "Call from $from_number in $queue_name queue"; + public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) { + $title = 'Call Waiting'; + $body = "Call from $from_number in $queue_name"; $data = array( - 'type' => 'incoming_call', + 'type' => 'queue_alert', 'call_sid' => $call_sid, 'from_number' => $from_number, - 'queue_name' => $queue_name + 'queue_name' => $queue_name, ); return $this->send_notification($user_id, $title, $body, $data, true); } + /** + * Cancel queue alert notification (call answered or caller disconnected). + */ + public function notify_queue_alert_cancel($user_id, $call_sid) { + $data = array( + 'type' => 'queue_alert_cancel', + 'call_sid' => $call_sid, + ); + + return $this->send_notification($user_id, '', '', $data, true); + } + + /** + * Send queue alert cancel to all agents assigned to a queue. + */ + public function cancel_queue_alert_for_queue($queue_id, $call_sid) { + global $wpdb; + $queue_table = $wpdb->prefix . 'twp_call_queues'; + + $queue = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $queue_table WHERE id = %d", $queue_id + )); + if (!$queue) return; + + $notified_users = array(); + + // Notify personal queue owner + if (!empty($queue->user_id)) { + $this->notify_queue_alert_cancel($queue->user_id, $call_sid); + $notified_users[] = $queue->user_id; + } + + // Notify agent group members + if (!empty($queue->agent_group_id)) { + require_once dirname(__FILE__) . '/class-twp-agent-groups.php'; + $members = TWP_Agent_Groups::get_group_members($queue->agent_group_id); + foreach ($members as $member) { + if (!in_array($member->user_id, $notified_users)) { + $this->notify_queue_alert_cancel($member->user_id, $call_sid); + $notified_users[] = $member->user_id; + } + } + } + } + /** * Send queue timeout notification */ diff --git a/includes/class-twp-mobile-api.php b/includes/class-twp-mobile-api.php index 276ae3c..d09b083 100644 --- a/includes/class-twp-mobile-api.php +++ b/includes/class-twp-mobile-api.php @@ -113,6 +113,20 @@ class TWP_Mobile_API { 'callback' => array($this, 'get_phone_numbers'), 'permission_callback' => array($this->auth, 'verify_token') )); + + // Outbound call (click-to-call via server) + register_rest_route('twilio-mobile/v1', '/calls/outbound', array( + 'methods' => 'POST', + 'callback' => array($this, 'initiate_outbound_call'), + 'permission_callback' => array($this->auth, 'verify_token') + )); + + // FCM push credential setup (admin only) + register_rest_route('twilio-mobile/v1', '/admin/push-credential', array( + 'methods' => 'POST', + 'callback' => array($this, 'setup_push_credential'), + 'permission_callback' => array($this->auth, 'verify_token') + )); }); } @@ -297,12 +311,9 @@ class TWP_Mobile_API { $user_id = $this->auth->get_current_user_id(); $call_sid = $request['call_sid']; - // Get agent phone number - $agent_number = get_user_meta($user_id, 'twp_agent_phone', true); - - if (empty($agent_number)) { - return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400)); - } + // Check for WebRTC client_identity parameter + $body = $request->get_json_params(); + $client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null; // Initialize Twilio API require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php'; @@ -327,46 +338,120 @@ class TWP_Mobile_API { } try { - // Connect agent to call - $agent_call = $twilio->create_call( - $agent_number, - $call->to_number, - array( - 'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'), - 'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'), - 'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'), - 'timeout' => 30 - ) - ); + if (!empty($client_identity)) { + // WebRTC path: redirect the queued call to the Twilio Client device + // Use the original caller's number as caller ID so it shows on the agent's device + $caller_id = $call->from_number; + if (empty($caller_id)) { + $caller_id = $call->to_number; + } + if (empty($caller_id)) { + $caller_id = get_option('twp_caller_id_number', ''); + } - // Update call record - $wpdb->update( - $calls_table, - array( - 'status' => 'connecting', - 'agent_phone' => $agent_number, + $twiml = '' . htmlspecialchars($client_identity) . ''; + + error_log('TWP accept_call: call_sid=' . $call_sid . ' client=' . $client_identity . ' twiml=' . $twiml); + + $result = $twilio->update_call($call_sid, array('twiml' => $twiml)); + + error_log('TWP accept_call result: ' . json_encode($result)); + if (!$result['success']) { + return new WP_Error('twilio_error', $result['error'] ?? 'Failed to update call', array('status' => 500)); + } + + // Update call record + $wpdb->update( + $calls_table, + array( + 'status' => 'connecting', + 'agent_phone' => 'client:' . $client_identity, + ), + array('call_sid' => $call_sid), + array('%s', '%s'), + array('%s') + ); + + // Save current status before setting busy, so we can revert after call ends + $status_table = $wpdb->prefix . 'twp_agent_status'; + $current = $wpdb->get_row($wpdb->prepare( + "SELECT status FROM $status_table WHERE user_id = %d", $user_id + )); + $pre_call_status = ($current && $current->status !== 'busy') ? $current->status : null; + + $wpdb->update( + $status_table, + array( + 'status' => 'busy', + 'current_call_sid' => $call_sid, + 'pre_call_status' => $pre_call_status, + 'auto_busy_at' => null, + ), + array('user_id' => $user_id), + array('%s', '%s', '%s', '%s'), + array('%d') + ); + + // Cancel queue alert notifications on all agents' devices + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-fcm.php'; + $fcm = new TWP_FCM(); + $fcm->cancel_queue_alert_for_queue($call->queue_id, $call_sid); + + return new WP_REST_Response(array( + 'success' => true, + 'message' => 'Call accepted via WebRTC client', + 'call_sid' => $call_sid + ), 200); + + } else { + // Phone-based path (original flow): dial the agent's phone number + $agent_number = get_user_meta($user_id, 'twp_agent_phone', true); + + if (empty($agent_number)) { + return new WP_Error('no_phone', 'No phone number configured for agent and no client_identity provided', array('status' => 400)); + } + + // Connect agent to call + $agent_call = $twilio->create_call( + $agent_number, + $call->to_number, + array( + 'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'), + 'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'), + 'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'), + 'timeout' => 30 + ) + ); + + // Update call record + $wpdb->update( + $calls_table, + array( + 'status' => 'connecting', + 'agent_phone' => $agent_number, + 'agent_call_sid' => $agent_call->sid + ), + array('call_sid' => $call_sid), + array('%s', '%s', '%s'), + array('%s') + ); + + // Update agent status + $status_table = $wpdb->prefix . 'twp_agent_status'; + $wpdb->update( + $status_table, + array('status' => 'busy', 'current_call_sid' => $call_sid), + array('user_id' => $user_id), + array('%s', '%s'), + array('%d') + ); + + return new WP_REST_Response(array( + 'success' => true, + 'message' => 'Call accepted, connecting to agent', 'agent_call_sid' => $agent_call->sid - ), - array('call_sid' => $call_sid), - array('%s', '%s', '%s'), - array('%s') - ); - - // Update agent status - $status_table = $wpdb->prefix . 'twp_agent_status'; - $wpdb->update( - $status_table, - array('status' => 'busy', 'current_call_sid' => $call_sid), - array('user_id' => $user_id), - array('%s', '%s'), - array('%d') - ); - - return new WP_REST_Response(array( - 'success' => true, - 'message' => 'Call accepted, connecting to agent', - 'agent_call_sid' => $agent_call->sid - ), 200); + ), 200); + } } catch (Exception $e) { return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500)); @@ -690,11 +775,35 @@ class TWP_Mobile_API { return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500)); } - // AccessToken for mobile Voice SDK (not ClientToken which is browser-only) - $token = new \Twilio\Jwt\AccessToken($account_sid, $account_sid, $auth_token, 3600, $identity); + // AccessToken requires an API Key (not account credentials). + // Auto-create and cache one if it doesn't exist yet. + $api_key_sid = get_option('twp_twilio_api_key_sid'); + $api_key_secret = get_option('twp_twilio_api_key_secret'); + + if (empty($api_key_sid) || empty($api_key_secret)) { + $client = new \Twilio\Rest\Client($account_sid, $auth_token); + $newKey = $client->newKeys->create(['friendlyName' => 'TWP Mobile Voice']); + $api_key_sid = $newKey->sid; + $api_key_secret = $newKey->secret; + update_option('twp_twilio_api_key_sid', $api_key_sid); + update_option('twp_twilio_api_key_secret', $api_key_secret); + } + + $token = new \Twilio\Jwt\AccessToken($account_sid, $api_key_sid, $api_key_secret, 3600, $identity); $voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant(); $voiceGrant->setOutgoingApplicationSid($twiml_app_sid); $voiceGrant->setIncomingAllow(true); + + // Include FCM push credential for incoming call notifications. + // Auto-create from the stored Firebase service account JSON if not yet created. + $push_credential_sid = get_option('twp_twilio_push_credential_sid'); + if (empty($push_credential_sid)) { + $push_credential_sid = $this->ensure_push_credential($account_sid, $auth_token); + } + if (!empty($push_credential_sid)) { + $voiceGrant->setPushCredentialSid($push_credential_sid); + } + $token->addGrant($voiceGrant); return new WP_REST_Response(array( @@ -766,6 +875,79 @@ class TWP_Mobile_API { return (bool)$is_assigned; } + /** + * Admin endpoint to force re-creation of the Twilio Push Credential. + */ + public function setup_push_credential($request) { + $user_id = $this->auth->get_current_user_id(); + $user = get_userdata($user_id); + if (!user_can($user, 'manage_options')) { + return new WP_Error('forbidden', 'Admin access required', array('status' => 403)); + } + + try { + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php'; + new TWP_Twilio_API(); + + $account_sid = get_option('twp_twilio_account_sid'); + $auth_token = get_option('twp_twilio_auth_token'); + + // Force re-creation by clearing existing SID + delete_option('twp_twilio_push_credential_sid'); + $sid = $this->ensure_push_credential($account_sid, $auth_token); + + if (empty($sid)) { + return new WP_Error('credential_error', 'Failed to create push credential. Check that Firebase service account JSON is configured in Mobile App Settings.', array('status' => 500)); + } + + return new WP_REST_Response(array( + 'success' => true, + 'credential_sid' => $sid, + ), 200); + + } catch (Exception $e) { + error_log('TWP setup_push_credential error: ' . $e->getMessage()); + return new WP_Error('credential_error', $e->getMessage(), array('status' => 500)); + } + } + + /** + * Auto-create Twilio Push Credential from the stored Firebase service account JSON. + * Returns the credential SID or empty string on failure. + */ + private function ensure_push_credential($account_sid, $auth_token) { + $sa_json = get_option('twp_fcm_service_account_json', ''); + if (empty($sa_json)) { + return ''; + } + + $sa = json_decode($sa_json, true); + if (!$sa || empty($sa['project_id']) || empty($sa['private_key'])) { + error_log('TWP: Firebase service account JSON is invalid'); + return ''; + } + + try { + $client = new \Twilio\Rest\Client($account_sid, $auth_token); + + $credential = $client->notify->v1->credentials->create( + 'fcm', + [ + 'friendlyName' => 'TWP Mobile FCM', + 'secret' => $sa_json, + ] + ); + + update_option('twp_twilio_push_credential_sid', $credential->sid); + error_log('TWP: Created Twilio push credential: ' . $credential->sid); + return $credential->sid; + + } catch (Exception $e) { + error_log('TWP ensure_push_credential error: ' . $e->getMessage()); + return ''; + } + } + /** * Calculate wait time in seconds */ diff --git a/includes/class-twp-mobile-auth.php b/includes/class-twp-mobile-auth.php index 805f19d..2cf2e23 100644 --- a/includes/class-twp-mobile-auth.php +++ b/includes/class-twp-mobile-auth.php @@ -423,13 +423,21 @@ class TWP_Mobile_Auth { global $wpdb; $table = $wpdb->prefix . 'twp_mobile_sessions'; - $wpdb->update( - $table, - array('fcm_token' => $fcm_token), - array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1), - array('%s'), - array('%d', '%s', '%d') - ); + if (!empty($refresh_token)) { + $wpdb->update( + $table, + array('fcm_token' => $fcm_token), + array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1), + array('%s'), + array('%d', '%s', '%d') + ); + } else { + // No refresh token — update the most recent active session for this user + $wpdb->query($wpdb->prepare( + "UPDATE $table SET fcm_token = %s WHERE user_id = %d AND is_active = 1 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", + $fcm_token, $user_id + )); + } } /** diff --git a/includes/class-twp-mobile-sse.php b/includes/class-twp-mobile-sse.php index b85c3a3..d73a2f0 100644 --- a/includes/class-twp-mobile-sse.php +++ b/includes/class-twp-mobile-sse.php @@ -26,14 +26,32 @@ class TWP_Mobile_SSE { 'callback' => array($this, 'stream_events'), 'permission_callback' => array($this->auth, 'verify_token') )); + register_rest_route('twilio-mobile/v1', '/stream/poll', array( + 'methods' => 'GET', + 'callback' => array($this, 'poll_state'), + 'permission_callback' => array($this->auth, 'verify_token') + )); }); } + /** + * Return current state as JSON (polling alternative to SSE) + */ + public function poll_state($request) { + $user_id = $this->auth->get_current_user_id(); + if (!$user_id) { + return new WP_Error('unauthorized', 'Invalid token', array('status' => 401)); + } + return rest_ensure_response($this->get_current_state($user_id)); + } + /** * Stream events to mobile app */ public function stream_events($request) { + error_log('TWP SSE: stream_events called'); $user_id = $this->auth->get_current_user_id(); + error_log('TWP SSE: user_id=' . ($user_id ?: 'false')); if (!$user_id) { return new WP_Error('unauthorized', 'Invalid token', array('status' => 401)); @@ -56,6 +74,15 @@ class TWP_Mobile_SSE { ob_end_flush(); } + // Flush padding to overcome Apache/HTTP2 frame buffering. + // SSE comments (lines starting with ':') are ignored by clients. + // We send >4KB to ensure the first HTTP/2 DATA frame is flushed. + echo ':' . str_repeat(' ', 4096) . "\n\n"; + if (ob_get_level() > 0) ob_flush(); + flush(); + + error_log('TWP SSE: padding flushed, sending connected event'); + // Send initial connection event $this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time())); diff --git a/includes/class-twp-webhooks.php b/includes/class-twp-webhooks.php index 811d293..aec5f5c 100644 --- a/includes/class-twp-webhooks.php +++ b/includes/class-twp-webhooks.php @@ -329,7 +329,13 @@ class TWP_Webhooks { */ public function handle_browser_voice($request) { $params = $request->get_params(); - + error_log('TWP browser-voice webhook params: ' . json_encode(array( + 'From' => $params['From'] ?? '', + 'To' => $params['To'] ?? '', + 'CallerId' => $params['CallerId'] ?? '', + 'CallSid' => $params['CallSid'] ?? '', + ))); + $call_data = array( 'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '', 'From' => isset($params['From']) ? $params['From'] : '', @@ -371,23 +377,45 @@ class TWP_Webhooks { if (isset($params['To']) && !empty($params['To'])) { $to_number = $params['To']; - // Mobile SDK sends CallerId via extraOptions; browser sends From as phone number + // Mobile SDK sends From as identity (e.g. "agent2jknapp"), browser sends From as phone number + // Only use CallerId/From if it looks like a phone number (starts with + or is all digits) $from_number = ''; - if (!empty($params['CallerId']) && strpos($params['CallerId'], 'client:') !== 0) { + if (!empty($params['CallerId']) && preg_match('/^\+?\d+$/', $params['CallerId'])) { $from_number = $params['CallerId']; - } elseif (!empty($params['From']) && strpos($params['From'], 'client:') !== 0) { + } elseif (!empty($params['From']) && preg_match('/^\+?\d+$/', $params['From'])) { $from_number = $params['From']; } - + + // Fall back to default caller ID if no valid one provided + if (empty($from_number)) { + $from_number = get_option('twp_caller_id_number', ''); + } + if (empty($from_number)) { + $from_number = get_option('twp_default_sms_number', ''); + } + // Last resort: fetch first Twilio number from API + if (empty($from_number)) { + try { + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php'; + $twilio = new TWP_Twilio_API(); + $numbers = $twilio->get_phone_numbers(); + if (!empty($numbers['data']['incoming_phone_numbers'][0]['phone_number'])) { + $from_number = $numbers['data']['incoming_phone_numbers'][0]['phone_number']; + } + } catch (\Exception $e) { + error_log('TWP browser-voice: failed to fetch default number: ' . $e->getMessage()); + } + } + // If it's an outgoing call to a phone number if (strpos($to_number, 'client:') !== 0) { $twiml .= ''; $twiml .= ''; @@ -400,9 +428,11 @@ class TWP_Webhooks { } else { $twiml .= 'No destination number provided.'; } - + $twiml .= ''; - + + error_log('TWP browser-voice TwiML: ' . $twiml); + return $this->send_twiml_response($twiml); } @@ -914,11 +944,36 @@ class TWP_Webhooks { // Update call status in queue if applicable // Remove from queue for any terminal call state if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) { + // Get queue_id before removing so we can send cancel notifications + global $wpdb; + $calls_table = $wpdb->prefix . 'twp_queued_calls'; + $queued_call = $wpdb->get_row($wpdb->prepare( + "SELECT queue_id FROM $calls_table WHERE call_sid = %s", + $status_data['CallSid'] + )); + $queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']); if ($queue_removed) { TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']); error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')'); + + // Cancel queue alert notifications on agents' devices + if ($queued_call) { + require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php'; + $fcm = new TWP_FCM(); + $fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $status_data['CallSid']); + } } + + // Set auto_busy_at for agents whose call just ended, so they revert after 30s + $agent_status_table = $wpdb->prefix . 'twp_agent_status'; + $wpdb->query($wpdb->prepare( + "UPDATE $agent_status_table + SET auto_busy_at = %s, current_call_sid = NULL + WHERE current_call_sid = %s AND status = 'busy'", + current_time('mysql'), + $status_data['CallSid'] + )); } // Empty response diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..83fbdac --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,176 @@ +# TWP Softphone — Mobile App + +Flutter-based VoIP softphone client for the Twilio WordPress Plugin. Uses the Twilio Voice SDK (WebRTC) to make and receive calls via the Android Telecom framework. + +## Requirements + +- Flutter 3.29+ (tested with 3.41.4) +- Android device/tablet (API 26+) +- TWP WordPress plugin installed and configured on server +- Twilio account with Voice capability + +## Quick Start + +```bash +cd mobile +flutter pub get +flutter build apk --debug +adb install build/app/outputs/flutter-apk/app-debug.apk +``` + +## Server Setup + +The app connects to your WordPress site running the TWP plugin. The server must have: + +1. **TWP Plugin** installed and activated +2. **Twilio credentials** configured (Account SID, Auth Token) +3. **At least one Twilio phone number** purchased +4. **A WordPress user** with agent permissions + +### SSE (Server-Sent Events) — Apache + PHP-FPM + +The app uses SSE for real-time updates (queue changes, agent status). On Apache with PHP-FPM, `mod_proxy_fcgi` buffers output by default, which breaks SSE streaming. + +**Fix** — Create a config file on the web server: + +```bash +echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/path/to/wordpress/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf +httpd -t && systemctl restart httpd +``` + +> **Adjust the paths:** +> - Socket path must match your PHP-FPM config (check `grep fcgi /etc/httpd/conf.d/php.conf`) +> - Document root must match your WordPress installation path + +**Diagnosis** — If the green connection dot stays red: + +```bash +# Check current PHP-FPM proxy config +grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/ + +# Check if flushpackets is configured +grep -r "flushpackets" /etc/httpd/conf.d/ + +# Test SSE endpoint (should stream data continuously, not hang) +curl -N -H "Authorization: Bearer YOUR_TOKEN" \ + https://your-site.com/wp-json/twilio-mobile/v1/stream/events +``` + +**Notes:** +- `flushpackets=on` is a `ProxyPassMatch` directive — it **cannot** go in `.htaccess` +- If using **nginx** instead of Apache, the `X-Accel-Buffering: no` header (already in the PHP code) handles this automatically +- The app automatically falls back to 5-second polling if SSE fails, so the app still works without this config — just with higher latency + +## App Setup (Android) + +### First Launch + +1. Open the app and enter your server URL (e.g., `https://phone.cloud-hosting.io`) +2. Log in with your WordPress credentials +3. Grant permissions when prompted: + - Microphone (required for calls) + - Phone/Call (required for Android Telecom integration) + +### Phone Account + +Android requires a registered and **enabled** phone account for VoIP apps. The app registers automatically, but enabling must be done manually: + +1. If prompted, tap **"Open Settings"** to go to Android's Phone Account settings +2. Find **"TWP Softphone"** in the list and toggle it **ON** +3. Return to the app + +If you skipped this step, tap the orange warning card on the dashboard. + +> **Path:** Settings → Apps → Default apps → Phone → Calling accounts → TWP Softphone + +### Making Calls + +1. Tap the phone FAB (bottom right) to open the dialer +2. Enter the phone number +3. Caller ID is auto-selected from your Twilio numbers +4. Tap **Call** — the Android system call screen (InCallUI) handles the active call + +### Receiving Calls + +Incoming calls appear via Android's native call UI. Answer/reject using the standard Android interface. + +> **Note:** FCM push notifications are required for receiving calls when the app is in the background. This requires `google-services.json` in `android/app/`. + +### Queue Management + +- View assigned queues on the dashboard +- Tap a queue with waiting calls to see callers +- Tap **Accept** to take a call from the queue + +### Agent Status + +Toggle between **Available**, **Busy**, and **Offline** using the status bar at the top of the dashboard. + +## Development + +### Project Structure + +``` +lib/ +├── config/ # App configuration +├── models/ # Data models (CallInfo, QueueState, AgentStatus, User) +├── providers/ # State management (AuthProvider, CallProvider, AgentProvider) +├── screens/ # UI screens (Login, Dashboard, Settings, ActiveCall) +├── services/ # API/SDK services (VoiceService, SseService, ApiClient, AuthService) +├── widgets/ # Reusable widgets (Dialpad, QueueCard, AgentStatusToggle) +└── main.dart # App entry point +``` + +### Running Tests + +```bash +flutter test +``` + +34 tests covering CallInfo, QueueState, and CallProvider. + +### Building + +```bash +# Debug APK +flutter build apk --debug + +# Release APK (requires signing config) +flutter build apk --release +``` + +### ADB Deployment (WiFi) + +```bash +# Connect to device +adb connect DEVICE_IP:PORT + +# Install +adb install -r build/app/outputs/flutter-apk/app-debug.apk + +# Launch +adb shell am start -n io.cloudhosting.twp.twp_softphone/.MainActivity + +# View logs +adb logcat -s flutter +``` + +### Key Dependencies + +| Package | Purpose | +|---------|---------| +| `twilio_voice` | Twilio Voice SDK (WebRTC calling) | +| `provider` | State management | +| `dio` | HTTP client (REST API, SSE) | +| `firebase_messaging` | FCM push for incoming calls | +| `flutter_secure_storage` | Secure token storage | + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| Green dot stays red | SSE buffering — see [Server Setup](#sse-server-sent-events--apache--php-fpm) | +| "No registered phone account" | Enable phone account in Android Settings (see [Phone Account](#phone-account)) | +| Calls fail with "Invalid callerId" | Server webhook needs phone number validation — check `handle_browser_voice` in `class-twp-webhooks.php` | +| App hangs on login | Check server is reachable: `curl https://your-site.com/wp-json/twilio-mobile/v1/auth/login` | +| No incoming calls | Ensure FCM is configured (`google-services.json`) and phone account is enabled | diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 90ed91a..9f22cb5 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -51,6 +51,15 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/raw/queue_alert.ogg b/mobile/android/app/src/main/res/raw/queue_alert.ogg new file mode 100644 index 0000000..0b3c90e Binary files /dev/null and b/mobile/android/app/src/main/res/raw/queue_alert.ogg differ diff --git a/mobile/android/local.properties b/mobile/android/local.properties deleted file mode 100644 index a83169f..0000000 --- a/mobile/android/local.properties +++ /dev/null @@ -1,2 +0,0 @@ -flutter.sdk=/opt/flutter -sdk.dir=/opt/android-sdk diff --git a/mobile/lib/providers/agent_provider.dart b/mobile/lib/providers/agent_provider.dart index f6068c9..0586dd1 100644 --- a/mobile/lib/providers/agent_provider.dart +++ b/mobile/lib/providers/agent_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../models/agent_status.dart'; import '../models/queue_state.dart'; @@ -25,6 +26,7 @@ class AgentProvider extends ChangeNotifier { List _phoneNumbers = []; StreamSubscription? _sseSub; StreamSubscription? _connSub; + Timer? _refreshTimer; AgentStatus? get status => _status; List get queues => _queues; @@ -38,6 +40,11 @@ class AgentProvider extends ChangeNotifier { }); _sseSub = _sse.events.listen(_handleSseEvent); + + _refreshTimer = Timer.periodic( + const Duration(seconds: 15), + (_) => fetchQueues(), + ); } Future fetchStatus() async { @@ -45,7 +52,10 @@ class AgentProvider extends ChangeNotifier { final response = await _api.dio.get('/agent/status'); _status = AgentStatus.fromJson(response.data); notifyListeners(); - } catch (e) { debugPrint('AgentProvider.fetchStatus error: $e'); } + } catch (e) { + debugPrint('AgentProvider.fetchStatus error: $e'); + if (e is DioException) debugPrint(' response: ${e.response?.data}'); + } } Future updateStatus(AgentStatusValue newStatus) async { @@ -61,7 +71,12 @@ class AgentProvider extends ChangeNotifier { currentCallSid: _status?.currentCallSid, ); notifyListeners(); - } catch (e) { debugPrint('AgentProvider.updateStatus error: $e'); } + } catch (e) { + debugPrint('AgentProvider.updateStatus error: $e'); + if (e is DioException) { + debugPrint('AgentProvider.updateStatus response: ${e.response?.data}'); + } + } } Future fetchQueues() async { @@ -72,7 +87,10 @@ class AgentProvider extends ChangeNotifier { .map((q) => QueueInfo.fromJson(q as Map)) .toList(); notifyListeners(); - } catch (e) { debugPrint('AgentProvider.fetchQueues error: $e'); } + } catch (e) { + debugPrint('AgentProvider.fetchQueues error: $e'); + if (e is DioException) debugPrint(' response: ${e.response?.data}'); + } } Future fetchPhoneNumbers() async { @@ -106,6 +124,7 @@ class AgentProvider extends ChangeNotifier { @override void dispose() { + _refreshTimer?.cancel(); _sseSub?.cancel(); _connSub?.cancel(); super.dispose(); diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 2ab4545..d53442c 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -10,10 +10,10 @@ enum AuthState { unauthenticated, authenticating, authenticated } class AuthProvider extends ChangeNotifier { final ApiClient _apiClient; - late final AuthService _authService; - late final VoiceService _voiceService; - late final PushNotificationService _pushService; - late final SseService _sseService; + late AuthService _authService; + late VoiceService _voiceService; + late PushNotificationService _pushService; + late SseService _sseService; AuthState _state = AuthState.unauthenticated; User? _user; @@ -36,8 +36,9 @@ class AuthProvider extends ChangeNotifier { } Future tryRestoreSession() async { - final restored = await _authService.tryRestoreSession(); - if (restored) { + final user = await _authService.tryRestoreSession(); + if (user != null) { + _user = user; _state = AuthState.authenticated; await _initializeServices(); notifyListeners(); @@ -67,7 +68,7 @@ class AuthProvider extends ChangeNotifier { debugPrint('AuthProvider: push service init error: $e'); } try { - await _voiceService.initialize(); + await _voiceService.initialize(deviceToken: _pushService.fcmToken); } catch (e) { debugPrint('AuthProvider: voice service init error: $e'); } @@ -96,10 +97,18 @@ class AuthProvider extends ChangeNotifier { } void _handleForceLogout() { + _voiceService.dispose(); + _sseService.disconnect(); + _state = AuthState.unauthenticated; _user = null; _error = 'Session expired. Please log in again.'; - _sseService.disconnect(); + + // Re-create services for potential re-login + _voiceService = VoiceService(_apiClient); + _pushService = PushNotificationService(_apiClient); + _sseService = SseService(_apiClient); + notifyListeners(); } diff --git a/mobile/lib/providers/call_provider.dart b/mobile/lib/providers/call_provider.dart index 2d36dfe..8093242 100644 --- a/mobile/lib/providers/call_provider.dart +++ b/mobile/lib/providers/call_provider.dart @@ -10,6 +10,7 @@ class CallProvider extends ChangeNotifier { Timer? _durationTimer; StreamSubscription? _eventSub; DateTime? _connectedAt; + bool _pendingAutoAnswer = false; CallInfo get callInfo => _callInfo; @@ -20,9 +21,13 @@ class CallProvider extends ChangeNotifier { void _handleCallEvent(CallEvent event) { switch (event) { case CallEvent.incoming: - _callInfo = _callInfo.copyWith( - state: CallState.ringing, - ); + if (_pendingAutoAnswer) { + _pendingAutoAnswer = false; + _callInfo = _callInfo.copyWith(state: CallState.connecting); + _voiceService.answer(); + } else { + _callInfo = _callInfo.copyWith(state: CallState.ringing); + } break; case CallEvent.ringing: _callInfo = _callInfo.copyWith(state: CallState.connecting); @@ -47,20 +52,24 @@ class CallProvider extends ChangeNotifier { break; } - // Update caller info from active call - final call = TwilioVoice.instance.call; - final active = call.activeCall; - if (active != null) { - _callInfo = _callInfo.copyWith( - callerNumber: active.from, - ); - // Fetch SID asynchronously - call.getSid().then((sid) { - if (sid != null && sid != _callInfo.callSid) { - _callInfo = _callInfo.copyWith(callSid: sid); - notifyListeners(); + // Update caller info from active call (skip if call just ended) + if (_callInfo.state != CallState.idle) { + final call = TwilioVoice.instance.call; + final active = call.activeCall; + if (active != null) { + if (_callInfo.callerNumber == null) { + _callInfo = _callInfo.copyWith( + callerNumber: active.from, + ); } - }); + // Fetch SID asynchronously + call.getSid().then((sid) { + if (sid != null && sid != _callInfo.callSid && _callInfo.isActive) { + _callInfo = _callInfo.copyWith(callSid: sid); + notifyListeners(); + } + }); + } } notifyListeners(); @@ -85,7 +94,16 @@ class CallProvider extends ChangeNotifier { Future answer() => _voiceService.answer(); Future reject() => _voiceService.reject(); - Future hangUp() => _voiceService.hangUp(); + Future hangUp() async { + await _voiceService.hangUp(); + // If SDK didn't fire callEnded (e.g. no active SDK call), reset manually + if (_callInfo.state != CallState.idle) { + _stopDurationTimer(); + _callInfo = const CallInfo(); + _pendingAutoAnswer = false; + notifyListeners(); + } + } Future toggleMute() async { final newMuted = !_callInfo.isMuted; @@ -109,7 +127,12 @@ class CallProvider extends ChangeNotifier { callerNumber: number, ); notifyListeners(); - await _voiceService.makeCall(number, callerId: callerId); + final success = await _voiceService.makeCall(number, callerId: callerId); + if (!success) { + debugPrint('CallProvider.makeCall: call.place() returned false'); + _callInfo = const CallInfo(); // reset to idle + notifyListeners(); + } } Future holdCall() async { @@ -134,6 +157,20 @@ class CallProvider extends ChangeNotifier { await _voiceService.transferCall(sid, target); } + Future acceptQueueCall(String callSid) async { + _pendingAutoAnswer = true; + _callInfo = _callInfo.copyWith(state: CallState.connecting); + notifyListeners(); + try { + await _voiceService.acceptQueueCall(callSid); + } catch (e) { + debugPrint('CallProvider.acceptQueueCall error: $e'); + _pendingAutoAnswer = false; + _callInfo = const CallInfo(); + notifyListeners(); + } + } + @override void dispose() { _stopDurationTimer(); diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 92f1771..1fd740e 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -1,11 +1,16 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:provider/provider.dart'; +import 'package:twilio_voice/twilio_voice.dart'; +import '../models/queue_state.dart'; import '../providers/agent_provider.dart'; +import '../providers/auth_provider.dart'; import '../providers/call_provider.dart'; import '../widgets/agent_status_toggle.dart'; import '../widgets/dialpad.dart'; import '../widgets/queue_card.dart'; -import 'active_call_screen.dart'; import 'settings_screen.dart'; class DashboardScreen extends StatefulWidget { @@ -16,17 +21,74 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State { + bool _phoneAccountEnabled = true; // assume true until checked + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().refresh(); + _checkPhoneAccount(); }); } + Future _checkPhoneAccount() async { + if (!kIsWeb && Platform.isAndroid) { + final enabled = await TwilioVoice.instance.isPhoneAccountEnabled(); + if (mounted && !enabled) { + setState(() => _phoneAccountEnabled = false); + _showPhoneAccountDialog(); + } else if (mounted) { + setState(() => _phoneAccountEnabled = true); + } + } + } + + void _showPhoneAccountDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Enable Phone Account'), + content: const Text( + 'TWP Softphone needs to be enabled as a calling account to make and receive calls.\n\n' + 'Tap "Open Settings" below, then find "TWP Softphone" in the list and toggle it ON.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Later'), + ), + FilledButton( + onPressed: () async { + Navigator.pop(ctx); + await TwilioVoice.instance.openPhoneAccountSettings(); + // Poll until enabled or user comes back + for (int i = 0; i < 30; i++) { + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + final enabled = await TwilioVoice.instance.isPhoneAccountEnabled(); + if (enabled) { + setState(() => _phoneAccountEnabled = true); + return; + } + } + // Re-check one more time when coming back + _checkPhoneAccount(); + }, + child: const Text('Open Settings'), + ), + ], + ), + ); + } + void _showDialer(BuildContext context) { final numberController = TextEditingController(); - String? selectedCallerId; + final phoneNumbers = context.read().phoneNumbers; + // Auto-select first phone number as caller ID + String? selectedCallerId = + phoneNumbers.isNotEmpty ? phoneNumbers.first.phoneNumber : null; showModalBottomSheet( context: context, @@ -35,7 +97,6 @@ class _DashboardScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) { - final phoneNumbers = context.read().phoneNumbers; return StatefulBuilder( builder: (ctx, setSheetState) { return Padding( @@ -72,8 +133,8 @@ class _DashboardScreenState extends State { ), ), ), - // Caller ID selector - if (phoneNumbers.isNotEmpty) ...[ + // Caller ID selector (only if multiple numbers) + if (phoneNumbers.length > 1) ...[ const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedCallerId, @@ -82,22 +143,24 @@ class _DashboardScreenState extends State { isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Default'), - ), - ...phoneNumbers.map((p) => DropdownMenuItem( - value: p.phoneNumber, - child: Text('${p.friendlyName} (${p.phoneNumber})'), - )), - ], + items: phoneNumbers.map((p) => DropdownMenuItem( + value: p.phoneNumber, + child: Text('${p.friendlyName} (${p.phoneNumber})'), + )).toList(), onChanged: (value) { setSheetState(() { selectedCallerId = value; }); }, ), + ] else if (phoneNumbers.length == 1) ...[ + const SizedBox(height: 8), + Text( + 'Caller ID: ${phoneNumbers.first.phoneNumber}', + style: Theme.of(ctx).textTheme.bodySmall?.copyWith( + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ), ], const SizedBox(height: 16), // Dialpad @@ -125,10 +188,15 @@ class _DashboardScreenState extends State { label: const Text('Call'), onPressed: () { final number = numberController.text.trim(); - if (number.isNotEmpty) { - context.read().makeCall(number, callerId: selectedCallerId); - Navigator.pop(ctx); + if (number.isEmpty) return; + if (selectedCallerId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No caller ID available. Add a phone number first.')), + ); + return; } + context.read().makeCall(number, callerId: selectedCallerId); + Navigator.pop(ctx); }, ), const SizedBox(height: 16), @@ -141,20 +209,99 @@ class _DashboardScreenState extends State { ); } + void _showQueueCalls(BuildContext context, QueueInfo queue) { + final voiceService = context.read().voiceService; + final callProvider = context.read(); + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return FutureBuilder>>( + future: voiceService.getQueueCalls(queue.id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text('Error loading calls: ${snapshot.error}'), + ), + ); + } + + final calls = (snapshot.data ?? []) + .map((c) => QueueCall.fromJson(c)) + .toList(); + + if (calls.isEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: Text('No calls waiting')), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + '${queue.name} - Waiting Calls', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(height: 8), + ...calls.map((call) => ListTile( + leading: const CircleAvatar( + child: Icon(Icons.phone_in_talk), + ), + title: Text(call.fromNumber), + subtitle: Text('Waiting ${_formatWaitTime(call.waitTime)}'), + trailing: FilledButton.icon( + icon: const Icon(Icons.call, size: 18), + label: const Text('Accept'), + onPressed: () { + Navigator.pop(ctx); + callProvider.acceptQueueCall(call.callSid); + // Cancel queue alert notification + FlutterLocalNotificationsPlugin().cancel(9001); + }, + ), + )), + ], + ), + ); + }, + ); + }, + ); + } + + String _formatWaitTime(int seconds) { + if (seconds < 60) return '${seconds}s'; + final minutes = seconds ~/ 60; + final secs = seconds % 60; + return '${minutes}m ${secs}s'; + } + @override Widget build(BuildContext context) { final agent = context.watch(); - final call = context.watch(); - // Navigate to active call screen when a call comes in - if (call.callInfo.isActive) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute(builder: (_) => const ActiveCallScreen()), - (route) => route.isFirst, - ); - }); - } + // Android Telecom framework handles the call UI via the native InCallUI, + // so we don't navigate to our own ActiveCallScreen. return Scaffold( appBar: AppBar( @@ -185,6 +332,18 @@ class _DashboardScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ + if (!_phoneAccountEnabled) + Card( + color: Colors.orange.shade50, + child: ListTile( + leading: Icon(Icons.warning, color: Colors.orange.shade700), + title: const Text('Phone Account Not Enabled'), + subtitle: const Text('Tap to enable calling in settings'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showPhoneAccountDialog(), + ), + ), + if (!_phoneAccountEnabled) const SizedBox(height: 8), const AgentStatusToggle(), const SizedBox(height: 24), Text('Queues', @@ -200,7 +359,12 @@ class _DashboardScreenState extends State { else ...agent.queues.map((q) => Padding( padding: const EdgeInsets.only(bottom: 8), - child: QueueCard(queue: q), + child: QueueCard( + queue: q, + onTap: q.waitingCount > 0 + ? () => _showQueueCalls(context, q) + : null, + ), )), ], ), diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 1f0fd76..ee08fc9 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/user.dart'; import 'api_client.dart'; @@ -14,11 +16,15 @@ class AuthService { {String? fcmToken}) async { await _api.setBaseUrl(serverUrl); - final response = await _api.dio.post('/auth/login', data: { - 'username': username, - 'password': password, - if (fcmToken != null) 'fcm_token': fcmToken, - }); + final response = await _api.dio.post( + '/auth/login', + data: { + 'username': username, + 'password': password, + if (fcmToken != null) 'fcm_token': fcmToken, + }, + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); final data = response.data; if (data['success'] != true) { @@ -27,24 +33,31 @@ class AuthService { await _storage.write(key: 'access_token', value: data['access_token']); await _storage.write(key: 'refresh_token', value: data['refresh_token']); + await _storage.write(key: 'user_data', value: jsonEncode(data['user'])); _scheduleRefresh(data['expires_in'] as int? ?? 3600); return User.fromJson(data['user']); } - Future tryRestoreSession() async { + Future tryRestoreSession() async { final token = await _storage.read(key: 'access_token'); - if (token == null) return false; + if (token == null) return null; await _api.restoreBaseUrl(); - if (_api.dio.options.baseUrl.isEmpty) return false; + if (_api.dio.options.baseUrl.isEmpty) return null; try { final response = await _api.dio.get('/agent/status'); - return response.statusCode == 200; + if (response.statusCode != 200) return null; + + final userData = await _storage.read(key: 'user_data'); + if (userData != null) { + return User.fromJson(jsonDecode(userData) as Map); + } + return null; } catch (_) { - return false; + return null; } } diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart index 481818b..d5a468a 100644 --- a/mobile/lib/services/push_notification_service.dart +++ b/mobile/lib/services/push_notification_service.dart @@ -1,13 +1,60 @@ +import 'dart:typed_data'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'api_client.dart'; +/// Notification ID for queue alerts (fixed so we can cancel it). +const int _queueAlertNotificationId = 9001; + +/// Background handler — must be top-level function. @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { await Firebase.initializeApp(); - // VoIP pushes are handled natively by twilio_voice plugin. - // Other data messages can show a local notification if needed. + final data = message.data; + final type = data['type']; + + if (type == 'queue_alert') { + await _showQueueAlertNotification(data); + } else if (type == 'queue_alert_cancel') { + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.cancel(_queueAlertNotificationId); + } + // VoIP pushes handled natively by twilio_voice plugin. +} + +/// Show an insistent queue alert notification (works from background handler too). +Future _showQueueAlertNotification(Map data) async { + final plugin = FlutterLocalNotificationsPlugin(); + + final title = data['title'] ?? 'Call Waiting'; + final body = data['body'] ?? 'New call in queue'; + + final androidDetails = AndroidNotificationDetails( + 'twp_queue_alerts', + 'Queue Alerts', + channelDescription: 'Alerts when calls are waiting in queue', + importance: Importance.max, + priority: Priority.max, + playSound: true, + sound: const RawResourceAndroidNotificationSound('queue_alert'), + enableVibration: true, + vibrationPattern: Int64List.fromList([0, 500, 200, 500, 200, 500]), + ongoing: true, + autoCancel: false, + category: AndroidNotificationCategory.alarm, + additionalFlags: Int32List.fromList([4]), // FLAG_INSISTENT = 4 + fullScreenIntent: true, + visibility: NotificationVisibility.public, + ); + + await plugin.show( + _queueAlertNotificationId, + title, + body, + NotificationDetails(android: androidDetails), + ); } class PushNotificationService { @@ -15,6 +62,9 @@ class PushNotificationService { final FirebaseMessaging _messaging = FirebaseMessaging.instance; final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); + String? _fcmToken; + + String? get fcmToken => _fcmToken; PushNotificationService(this._api); @@ -36,8 +86,12 @@ class PushNotificationService { // Get and register FCM token final token = await _messaging.getToken(); + debugPrint('FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}'); if (token != null) { + _fcmToken = token; await _registerToken(token); + } else { + debugPrint('FCM: Failed to get token - Firebase may not be configured correctly'); } // Listen for token refresh @@ -60,7 +114,19 @@ class PushNotificationService { // VoIP incoming_call is handled by twilio_voice natively if (type == 'incoming_call') return; - // Show local notification for other types (missed call, queue alert, etc.) + // Queue alert — show insistent notification + if (type == 'queue_alert') { + _showQueueAlertNotification(data); + return; + } + + // Queue alert cancel — dismiss notification + if (type == 'queue_alert_cancel') { + _localNotifications.cancel(_queueAlertNotificationId); + return; + } + + // Show local notification for other types (missed call, etc.) _localNotifications.show( message.hashCode, data['title'] ?? 'TWP Softphone', @@ -75,4 +141,9 @@ class PushNotificationService { ), ); } + + /// Cancel any active queue alert (called when agent accepts a call in-app). + void cancelQueueAlert() { + _localNotifications.cancel(_queueAlertNotificationId); + } } diff --git a/mobile/lib/services/sse_service.dart b/mobile/lib/services/sse_service.dart index acf9e28..5fa9ddf 100644 --- a/mobile/lib/services/sse_service.dart +++ b/mobile/lib/services/sse_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../config/app_config.dart'; import 'api_client.dart'; @@ -25,6 +26,9 @@ class SseService { Timer? _reconnectTimer; int _reconnectAttempt = 0; bool _shouldReconnect = true; + int _sseFailures = 0; + Timer? _pollTimer; + Map? _previousPollState; Stream get events => _eventController.stream; Stream get connectionState => _connectionController.stream; @@ -34,34 +38,63 @@ class SseService { Future connect() async { _shouldReconnect = true; _reconnectAttempt = 0; + _sseFailures = 0; await _doConnect(); } Future _doConnect() async { + // After 2 SSE failures, fall back to polling + if (_sseFailures >= 2) { + debugPrint('SSE: falling back to polling after $_sseFailures failures'); + _startPolling(); + return; + } + _cancelToken?.cancel(); _cancelToken = CancelToken(); + // Timer to detect if SSE stream never delivers data (Apache buffering) + Timer? firstDataTimer; + bool gotData = false; + try { final token = await _storage.read(key: 'access_token'); + debugPrint('SSE: connecting via stream (attempt ${_sseFailures + 1})'); + + firstDataTimer = Timer(const Duration(seconds: 8), () { + if (!gotData) { + debugPrint('SSE: no data received in 8s, cancelling'); + _cancelToken?.cancel(); + } + }); + final response = await _api.dio.get( '/stream/events', options: Options( headers: {'Authorization': 'Bearer $token'}, responseType: ResponseType.stream, + receiveTimeout: Duration.zero, ), cancelToken: _cancelToken, ); + debugPrint('SSE: connected, status=${response.statusCode}'); _connectionController.add(true); _reconnectAttempt = 0; + _sseFailures = 0; final stream = response.data.stream as Stream>; String buffer = ''; await for (final chunk in stream) { + if (!gotData) { + gotData = true; + firstDataTimer.cancel(); + debugPrint('SSE: first data received'); + } buffer += utf8.decode(chunk); final lines = buffer.split('\n'); - buffer = lines.removeLast(); // keep incomplete line in buffer + buffer = lines.removeLast(); String? eventName; String? dataStr; @@ -82,8 +115,22 @@ class SseService { } } } catch (e) { - if (e is DioException && e.type == DioExceptionType.cancel) return; - _connectionController.add(false); + firstDataTimer?.cancel(); + // Distinguish user-initiated cancel from timeout cancel + if (e is DioException && e.type == DioExceptionType.cancel) { + if (!gotData && _shouldReconnect) { + // Cancelled by our firstDataTimer — count as SSE failure + debugPrint('SSE: stream timed out (no data), failure ${_sseFailures + 1}'); + _sseFailures++; + _connectionController.add(false); + } else { + return; // User-initiated disconnect + } + } else { + debugPrint('SSE: stream error: $e'); + _sseFailures++; + _connectionController.add(false); + } } if (_shouldReconnect) { @@ -104,9 +151,81 @@ class SseService { _reconnectTimer = Timer(delay, _doConnect); } + // Polling fallback when SSE streaming doesn't work + void _startPolling() { + _pollTimer?.cancel(); + _previousPollState = null; + _poll(); + _pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _poll()); + } + + Future _poll() async { + if (!_shouldReconnect) return; + try { + final response = await _api.dio.get('/stream/poll'); + final data = Map.from(response.data); + _connectionController.add(true); + + if (_previousPollState != null) { + _diffAndEmit(_previousPollState!, data); + } + _previousPollState = data; + } catch (e) { + debugPrint('SSE poll error: $e'); + _connectionController.add(false); + } + } + + void _diffAndEmit(Map prev, Map curr) { + final prevStatus = prev['agent_status']?.toString(); + final currStatus = curr['agent_status']?.toString(); + if (prevStatus != currStatus) { + _eventController.add(SseEvent( + event: 'agent_status_changed', + data: (curr['agent_status'] as Map?) ?? {}, + )); + } + + final prevQueues = prev['queues'] as Map? ?? {}; + final currQueues = curr['queues'] as Map? ?? {}; + for (final entry in currQueues.entries) { + final currQueue = Map.from(entry.value); + final prevQueue = prevQueues[entry.key] as Map?; + if (prevQueue == null) { + _eventController.add(SseEvent(event: 'queue_added', data: currQueue)); + continue; + } + final currCount = currQueue['waiting_count'] as int? ?? 0; + final prevCount = prevQueue['waiting_count'] as int? ?? 0; + if (currCount > prevCount) { + _eventController.add(SseEvent(event: 'call_enqueued', data: currQueue)); + } else if (currCount < prevCount) { + _eventController.add(SseEvent(event: 'call_dequeued', data: currQueue)); + } + } + + final prevCall = prev['current_call']?.toString(); + final currCall = curr['current_call']?.toString(); + if (prevCall != currCall) { + if (curr['current_call'] != null && prev['current_call'] == null) { + _eventController.add(SseEvent( + event: 'call_started', + data: curr['current_call'] as Map, + )); + } else if (curr['current_call'] == null && prev['current_call'] != null) { + _eventController.add(SseEvent( + event: 'call_ended', + data: prev['current_call'] as Map, + )); + } + } + } + void disconnect() { _shouldReconnect = false; _reconnectTimer?.cancel(); + _pollTimer?.cancel(); + _pollTimer = null; _cancelToken?.cancel(); _connectionController.add(false); } diff --git a/mobile/lib/services/voice_service.dart b/mobile/lib/services/voice_service.dart index 46cd22f..53e0155 100644 --- a/mobile/lib/services/voice_service.dart +++ b/mobile/lib/services/voice_service.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:twilio_voice/twilio_voice.dart'; import 'api_client.dart'; @@ -7,6 +9,8 @@ class VoiceService { final ApiClient _api; Timer? _tokenRefreshTimer; String? _identity; + String? _deviceToken; + StreamSubscription? _eventSubscription; final StreamController _callEventController = StreamController.broadcast(); @@ -14,11 +18,30 @@ class VoiceService { VoiceService(this._api); - Future initialize() async { + Future initialize({String? deviceToken}) async { + _deviceToken = deviceToken; + debugPrint('VoiceService.initialize: deviceToken=${deviceToken != null ? "present (${deviceToken.length} chars)" : "NULL"}'); + + // Request permissions (Android telecom requires these) + await TwilioVoice.instance.requestMicAccess(); + if (!kIsWeb && Platform.isAndroid) { + await TwilioVoice.instance.requestReadPhoneStatePermission(); + await TwilioVoice.instance.requestReadPhoneNumbersPermission(); + await TwilioVoice.instance.requestCallPhonePermission(); + await TwilioVoice.instance.requestManageOwnCallsPermission(); + // Register phone account with Android telecom + // (enabling is handled by dashboard UI with a user-friendly dialog) + await TwilioVoice.instance.registerPhoneAccount(); + } + + // Fetch token and register await _fetchAndRegisterToken(); - TwilioVoice.instance.callEventsListener.listen((event) { - _callEventController.add(event); + // Listen for call events (only once) + _eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) { + if (!_callEventController.isClosed) { + _callEventController.add(event); + } }); // Refresh token every 50 minutes @@ -35,9 +58,13 @@ class VoiceService { final data = response.data; final token = data['token'] as String; _identity = data['identity'] as String; - await TwilioVoice.instance.setTokens(accessToken: token); + await TwilioVoice.instance.setTokens( + accessToken: token, + deviceToken: _deviceToken ?? 'no-fcm', + ); } catch (e) { debugPrint('VoiceService._fetchAndRegisterToken error: $e'); + if (e is DioException) debugPrint(' response: ${e.response?.data}'); } } @@ -69,11 +96,14 @@ class VoiceService { if (callerId != null && callerId.isNotEmpty) { extraOptions['CallerId'] = callerId; } - return await TwilioVoice.instance.call.place( + debugPrint('VoiceService.makeCall: to=$to, from=$_identity, extras=$extraOptions'); + final result = await TwilioVoice.instance.call.place( to: to, from: _identity ?? '', extraOptions: extraOptions, ) ?? false; + debugPrint('VoiceService.makeCall: result=$result'); + return result; } catch (e) { debugPrint('VoiceService.makeCall error: $e'); return false; @@ -84,6 +114,17 @@ class VoiceService { await TwilioVoice.instance.call.sendDigits(digits); } + Future>> getQueueCalls(int queueId) async { + final response = await _api.dio.get('/queues/$queueId/calls'); + return List>.from(response.data['calls'] ?? []); + } + + Future acceptQueueCall(String callSid) async { + await _api.dio.post('/calls/$callSid/accept', data: { + 'client_identity': _identity, + }); + } + Future holdCall(String callSid) async { await _api.dio.post('/calls/$callSid/hold'); } @@ -98,6 +139,8 @@ class VoiceService { void dispose() { _tokenRefreshTimer?.cancel(); + _eventSubscription?.cancel(); + _eventSubscription = null; _callEventController.close(); } } diff --git a/mobile/lib/widgets/queue_card.dart b/mobile/lib/widgets/queue_card.dart index e8b3477..ad352a8 100644 --- a/mobile/lib/widgets/queue_card.dart +++ b/mobile/lib/widgets/queue_card.dart @@ -3,13 +3,15 @@ import '../models/queue_state.dart'; class QueueCard extends StatelessWidget { final QueueInfo queue; + final VoidCallback? onTap; - const QueueCard({super.key, required this.queue}); + const QueueCard({super.key, required this.queue, this.onTap}); @override Widget build(BuildContext context) { return Card( child: ListTile( + onTap: onTap, leading: CircleAvatar( backgroundColor: queue.waitingCount > 0 ? Colors.orange.shade100 diff --git a/mobile/test/call_info_test.dart b/mobile/test/call_info_test.dart new file mode 100644 index 0000000..11694eb --- /dev/null +++ b/mobile/test/call_info_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:twp_softphone/models/call_info.dart'; + +void main() { + group('CallInfo', () { + test('default state is idle', () { + const info = CallInfo(); + expect(info.state, CallState.idle); + expect(info.callSid, isNull); + expect(info.callerNumber, isNull); + expect(info.duration, Duration.zero); + expect(info.isMuted, false); + expect(info.isSpeakerOn, false); + expect(info.isOnHold, false); + }); + + test('isActive returns true for ringing, connecting, connected', () { + expect(const CallInfo(state: CallState.ringing).isActive, true); + expect(const CallInfo(state: CallState.connecting).isActive, true); + expect(const CallInfo(state: CallState.connected).isActive, true); + }); + + test('isActive returns false for idle and disconnected', () { + expect(const CallInfo(state: CallState.idle).isActive, false); + expect(const CallInfo(state: CallState.disconnected).isActive, false); + }); + + test('copyWith preserves unmodified fields', () { + const original = CallInfo( + state: CallState.connected, + callSid: 'CA123', + callerNumber: '+1234567890', + isMuted: true, + ); + + final modified = original.copyWith(isSpeakerOn: true); + expect(modified.state, CallState.connected); + expect(modified.callSid, 'CA123'); + expect(modified.callerNumber, '+1234567890'); + expect(modified.isMuted, true); + expect(modified.isSpeakerOn, true); + }); + + test('copyWith can change state', () { + const info = CallInfo(state: CallState.connecting); + final updated = info.copyWith(state: CallState.connected); + expect(updated.state, CallState.connected); + }); + + test('copyWith with callerNumber preserves it', () { + const info = CallInfo(callerNumber: '+19095737372'); + final updated = info.copyWith(state: CallState.connected); + expect(updated.callerNumber, '+19095737372'); + }); + + test('reset to idle clears all fields', () { + // Verify a complex state exists + const connected = CallInfo( + state: CallState.connected, + callSid: 'CA123', + callerNumber: '+1234567890', + isMuted: true, + isSpeakerOn: true, + isOnHold: true, + duration: Duration(seconds: 30), + ); + expect(connected.isActive, true); + + // Simulating what callEnded does + const reset = CallInfo(); + expect(reset.state, CallState.idle); + expect(reset.callSid, isNull); + expect(reset.callerNumber, isNull); + expect(reset.isActive, false); + }); + }); + + group('CallState transitions', () { + test('outbound call flow: idle -> connecting -> connected -> idle', () { + var info = const CallInfo(); + expect(info.state, CallState.idle); + + // makeCall sets connecting + callerNumber + info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372'); + expect(info.state, CallState.connecting); + expect(info.callerNumber, '+19095737372'); + expect(info.isActive, true); + + // SDK fires connected + info = info.copyWith(state: CallState.connected); + expect(info.state, CallState.connected); + expect(info.callerNumber, '+19095737372'); // preserved + expect(info.isActive, true); + + // callEnded resets + info = const CallInfo(); + expect(info.state, CallState.idle); + expect(info.isActive, false); + }); + + test('inbound call flow: idle -> ringing -> connected -> idle', () { + var info = const CallInfo(); + + info = info.copyWith(state: CallState.ringing); + expect(info.isActive, true); + + // callerNumber set from active.from + info = info.copyWith(callerNumber: '+18005551234'); + expect(info.callerNumber, '+18005551234'); + + info = info.copyWith(state: CallState.connected); + expect(info.state, CallState.connected); + + info = const CallInfo(); + expect(info.state, CallState.idle); + }); + + test('outbound callerNumber not overwritten by null copyWith', () { + var info = const CallInfo( + state: CallState.connecting, + callerNumber: '+19095737372', + ); + + // copyWith without callerNumber should preserve it + info = info.copyWith(state: CallState.connected); + expect(info.callerNumber, '+19095737372'); + }); + }); +} diff --git a/mobile/test/call_provider_test.dart b/mobile/test/call_provider_test.dart new file mode 100644 index 0000000..152dae7 --- /dev/null +++ b/mobile/test/call_provider_test.dart @@ -0,0 +1,367 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twilio_voice/twilio_voice.dart'; +import 'package:twp_softphone/models/call_info.dart'; +import 'package:twp_softphone/providers/call_provider.dart'; +import 'package:twp_softphone/services/voice_service.dart'; + +/// Minimal mock of VoiceService for testing CallProvider logic. +/// Only stubs methods that CallProvider calls directly. +class MockVoiceService implements VoiceService { + final StreamController _eventController = + StreamController.broadcast(); + bool makeCallResult = true; + bool acceptQueueCallShouldThrow = false; + String? lastCallTo; + String? lastCallerId; + bool answerCalled = false; + bool hangUpCalled = false; + + @override + Stream get callEvents => _eventController.stream; + + void emitEvent(CallEvent event) => _eventController.add(event); + + @override + Future makeCall(String to, {String? callerId}) async { + lastCallTo = to; + lastCallerId = callerId; + return makeCallResult; + } + + @override + Future answer() async { + answerCalled = true; + } + + @override + Future hangUp() async { + hangUpCalled = true; + } + + @override + Future reject() async {} + + @override + Future toggleMute(bool mute) async {} + + @override + Future toggleSpeaker(bool speaker) async {} + + @override + Future sendDigits(String digits) async {} + + @override + Future>> getQueueCalls(int queueId) async => []; + + @override + Future acceptQueueCall(String callSid) async { + if (acceptQueueCallShouldThrow) { + throw Exception('Network error'); + } + } + + @override + Future holdCall(String callSid) async {} + + @override + Future unholdCall(String callSid) async {} + + @override + Future transferCall(String callSid, String target) async {} + + @override + Future initialize({String? deviceToken}) async {} + + @override + String? get identity => 'agent2testuser'; + + @override + void dispose() { + _eventController.close(); + } + + // Unused stubs required by the interface + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('CallProvider.makeCall', () { + late MockVoiceService mockVoice; + late CallProvider provider; + + setUp(() { + mockVoice = MockVoiceService(); + provider = CallProvider(mockVoice); + }); + + tearDown(() { + provider.dispose(); + mockVoice.dispose(); + }); + + test('sets state to connecting and passes number', () async { + mockVoice.makeCallResult = true; + await provider.makeCall('+19095737372'); + + expect(mockVoice.lastCallTo, '+19095737372'); + expect(provider.callInfo.state, CallState.connecting); + expect(provider.callInfo.callerNumber, '+19095737372'); + }); + + test('passes callerId when provided', () async { + mockVoice.makeCallResult = true; + await provider.makeCall('+19095737372', callerId: '+19516215107'); + + expect(mockVoice.lastCallerId, '+19516215107'); + }); + + test('resets to idle when call.place() returns false', () async { + mockVoice.makeCallResult = false; + await provider.makeCall('+19095737372'); + + expect(provider.callInfo.state, CallState.idle); + expect(provider.callInfo.callerNumber, isNull); + }); + + test('stays connecting when call.place() returns true', () async { + mockVoice.makeCallResult = true; + await provider.makeCall('+19095737372'); + + expect(provider.callInfo.state, CallState.connecting); + expect(provider.callInfo.callerNumber, '+19095737372'); + }); + }); + + group('CallProvider.hangUp', () { + late MockVoiceService mockVoice; + late CallProvider provider; + + setUp(() { + mockVoice = MockVoiceService(); + provider = CallProvider(mockVoice); + }); + + tearDown(() { + provider.dispose(); + mockVoice.dispose(); + }); + + test('resets to idle even if SDK does not fire callEnded', () async { + // Simulate a connecting state + mockVoice.makeCallResult = true; + await provider.makeCall('+19095737372'); + expect(provider.callInfo.state, CallState.connecting); + + // Hang up without SDK firing callEnded + await provider.hangUp(); + + expect(provider.callInfo.state, CallState.idle); + expect(provider.callInfo.callerNumber, isNull); + expect(mockVoice.hangUpCalled, true); + }); + }); + + group('CallProvider state transitions', () { + test('outbound: connecting preserves callerNumber through state changes', () { + // Simulating what CallProvider does internally + var info = const CallInfo(); + + // makeCall sets connecting + callerNumber + info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372'); + expect(info.state, CallState.connecting); + expect(info.callerNumber, '+19095737372'); + + // SDK fires connected — callerNumber preserved + info = info.copyWith(state: CallState.connected); + expect(info.state, CallState.connected); + expect(info.callerNumber, '+19095737372'); + }); + + test('makeCall failure resets cleanly to idle', () { + var info = const CallInfo(); + + // makeCall sets connecting + info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372'); + expect(info.state, CallState.connecting); + + // call.place() returns false -> reset + info = const CallInfo(); + expect(info.state, CallState.idle); + expect(info.callerNumber, isNull); + expect(info.isActive, false); + }); + }); + + group('CallProvider.acceptQueueCall', () { + late MockVoiceService mockVoice; + late CallProvider provider; + + setUp(() { + mockVoice = MockVoiceService(); + provider = CallProvider(mockVoice); + }); + + tearDown(() { + provider.dispose(); + mockVoice.dispose(); + }); + + test('sets state to connecting before server call', () async { + await provider.acceptQueueCall('CA123abc'); + expect(provider.callInfo.state, CallState.connecting); + }); + + test('auto-answers incoming call after acceptQueueCall', () async { + await provider.acceptQueueCall('CA123abc'); + + // Simulate the FCM incoming call event that arrives after server redirect + mockVoice.emitEvent(CallEvent.incoming); + + // Allow the stream listener to process + await Future.delayed(Duration.zero); + + // Should have auto-answered + expect(mockVoice.answerCalled, true); + expect(provider.callInfo.state, CallState.connecting); + }); + + test('normal incoming call shows ringing without auto-answer', () async { + // Without calling acceptQueueCall first + mockVoice.emitEvent(CallEvent.incoming); + + await Future.delayed(Duration.zero); + + expect(mockVoice.answerCalled, false); + expect(provider.callInfo.state, CallState.ringing); + }); + + test('connected event after auto-answer sets connected state', () async { + await provider.acceptQueueCall('CA123abc'); + + mockVoice.emitEvent(CallEvent.incoming); + await Future.delayed(Duration.zero); + expect(mockVoice.answerCalled, true); + + mockVoice.emitEvent(CallEvent.connected); + await Future.delayed(Duration.zero); + expect(provider.callInfo.state, CallState.connected); + }); + + test('resets to idle on API error and clears pendingAutoAnswer', () async { + mockVoice.acceptQueueCallShouldThrow = true; + await provider.acceptQueueCall('CA123abc'); + + // Should have reset to idle after error + expect(provider.callInfo.state, CallState.idle); + + // Future incoming call should NOT be auto-answered + mockVoice.emitEvent(CallEvent.incoming); + await Future.delayed(Duration.zero); + expect(mockVoice.answerCalled, false); + expect(provider.callInfo.state, CallState.ringing); + }); + }); + + group('CallProvider.hangUp edge cases', () { + late MockVoiceService mockVoice; + late CallProvider provider; + + setUp(() { + mockVoice = MockVoiceService(); + provider = CallProvider(mockVoice); + }); + + tearDown(() { + provider.dispose(); + mockVoice.dispose(); + }); + + test('hangUp when already idle is a no-op', () async { + expect(provider.callInfo.state, CallState.idle); + await provider.hangUp(); + expect(provider.callInfo.state, CallState.idle); + expect(mockVoice.hangUpCalled, true); + }); + + test('hangUp clears pendingAutoAnswer flag', () async { + await provider.acceptQueueCall('CA123abc'); + expect(provider.callInfo.state, CallState.connecting); + + await provider.hangUp(); + expect(provider.callInfo.state, CallState.idle); + + // Incoming call should NOT auto-answer after hangUp cleared the flag + mockVoice.emitEvent(CallEvent.incoming); + await Future.delayed(Duration.zero); + expect(mockVoice.answerCalled, false); + expect(provider.callInfo.state, CallState.ringing); + }); + }); + + group('CallProvider.toggleMute and toggleSpeaker', () { + late MockVoiceService mockVoice; + late CallProvider provider; + + setUp(() { + mockVoice = MockVoiceService(); + provider = CallProvider(mockVoice); + }); + + tearDown(() { + provider.dispose(); + mockVoice.dispose(); + }); + + test('toggleMute flips isMuted state', () async { + expect(provider.callInfo.isMuted, false); + await provider.toggleMute(); + expect(provider.callInfo.isMuted, true); + await provider.toggleMute(); + expect(provider.callInfo.isMuted, false); + }); + + test('toggleSpeaker flips isSpeakerOn state', () async { + expect(provider.callInfo.isSpeakerOn, false); + await provider.toggleSpeaker(); + expect(provider.callInfo.isSpeakerOn, true); + await provider.toggleSpeaker(); + expect(provider.callInfo.isSpeakerOn, false); + }); + }); + + group('CallProvider.callEnded', () { + late MockVoiceService mockVoice; + late CallProvider provider; + + setUp(() { + mockVoice = MockVoiceService(); + provider = CallProvider(mockVoice); + }); + + tearDown(() { + provider.dispose(); + mockVoice.dispose(); + }); + + test('callEnded resets state completely', () async { + // Set up a connected call + mockVoice.makeCallResult = true; + await provider.makeCall('+19095737372'); + + mockVoice.emitEvent(CallEvent.connected); + await Future.delayed(Duration.zero); + expect(provider.callInfo.state, CallState.connected); + expect(provider.callInfo.callerNumber, '+19095737372'); + + // End the call + mockVoice.emitEvent(CallEvent.callEnded); + await Future.delayed(Duration.zero); + expect(provider.callInfo.state, CallState.idle); + expect(provider.callInfo.callerNumber, isNull); + expect(provider.callInfo.callSid, isNull); + expect(provider.callInfo.isActive, false); + }); + }); +} diff --git a/mobile/test/queue_state_test.dart b/mobile/test/queue_state_test.dart new file mode 100644 index 0000000..70379f2 --- /dev/null +++ b/mobile/test/queue_state_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:twp_softphone/models/queue_state.dart'; + +void main() { + group('QueueInfo', () { + test('parses from JSON with all fields', () { + final json = { + 'id': 1, + 'name': 'General', + 'type': 'general', + 'extension': '100', + 'waiting_count': 3, + }; + + final queue = QueueInfo.fromJson(json); + expect(queue.id, 1); + expect(queue.name, 'General'); + expect(queue.type, 'general'); + expect(queue.extension, '100'); + expect(queue.waitingCount, 3); + }); + + test('parses from JSON with string numbers', () { + final json = { + 'id': '5', + 'name': 'Support', + 'type': 'personal', + 'waiting_count': '0', + }; + + final queue = QueueInfo.fromJson(json); + expect(queue.id, 5); + expect(queue.waitingCount, 0); + expect(queue.extension, isNull); + }); + + test('handles missing fields gracefully', () { + final json = {}; + final queue = QueueInfo.fromJson(json); + expect(queue.id, 0); + expect(queue.name, ''); + expect(queue.type, ''); + expect(queue.extension, isNull); + expect(queue.waitingCount, 0); + }); + }); + + group('QueueCall', () { + test('parses from JSON', () { + final json = { + 'call_sid': 'CA123abc', + 'from_number': '+18005551234', + 'to_number': '+19095737372', + 'position': 1, + 'status': 'waiting', + 'wait_time': 45, + }; + + final call = QueueCall.fromJson(json); + expect(call.callSid, 'CA123abc'); + expect(call.fromNumber, '+18005551234'); + expect(call.toNumber, '+19095737372'); + expect(call.position, 1); + expect(call.status, 'waiting'); + expect(call.waitTime, 45); + }); + + test('handles string wait_time', () { + final json = { + 'call_sid': 'CA456', + 'from_number': '+1800', + 'to_number': '+1900', + 'position': '2', + 'status': 'waiting', + 'wait_time': '120', + }; + + final call = QueueCall.fromJson(json); + expect(call.position, 2); + expect(call.waitTime, 120); + }); + + test('handles missing fields gracefully', () { + final json = {}; + final call = QueueCall.fromJson(json); + expect(call.callSid, ''); + expect(call.fromNumber, ''); + expect(call.position, 0); + expect(call.waitTime, 0); + }); + }); +}