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