Compare commits

..

6 Commits

Author SHA1 Message Date
Claude
4af4be94a4 Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s
Server-side:
- Add push credential auto-creation for FCM incoming call notifications
- Add queue alert FCM notifications (data-only for background delivery)
- Add queue alert cancellation on call accept/disconnect
- Fix caller ID to show caller's number instead of Twilio number
- Fix FCM token storage when refresh_token is null
- Add pre_call_status tracking to revert agent status 30s after call ends
- Add SSE fallback polling for mobile app connectivity

Mobile app:
- Add Android telecom permissions and phone account registration
- Add VoiceFirebaseMessagingService for incoming call push handling
- Add insistent queue alert notifications with custom sound
- Fix caller number display on active call screen
- Add caller ID selection dropdown on dashboard
- Add phone numbers endpoint and provider support
- Add unit tests for CallInfo, QueueState, and CallProvider
- Remove local.properties from tracking, add .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:11:02 -08:00
Claude
78e6c5a4ee Fix fatal error: WP_REST_Server::get_request() does not exist
All checks were successful
Create Release / build (push) Successful in 6s
Store authenticated user ID on the auth object instance instead of
trying to retrieve it from the REST server request. This was the root
cause of all mobile API 500 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:44:46 -08:00
Claude
eedb7bdb8f Fix auto-updater to fetch newest release by creation date
All checks were successful
Create Release / build (push) Successful in 3s
Use /releases?limit=1 instead of /releases/latest which sorts by
semver tag. Date-based tags (2026.03.06-1805) have a hyphen that
semver treats as a prerelease separator, causing incorrect ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:59:23 -08:00
Claude
f8c9c23077 Move auto-update settings from Mobile App page to Settings page
All checks were successful
Create Release / build (push) Successful in 5s
- Relocate update section (version check, repo config, token) to Settings
- Fix download URL for private repos: append Gitea auth token
- Mobile App page now only has FCM/notification settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:57:34 -08:00
Claude
5d3035a62c Remove redundant update-version.yml workflow
All checks were successful
Create Release / build (push) Successful in 4s
release.yml already handles version stamping, zipping, and release creation on push to main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:34:33 -08:00
Claude
7df6090554 Fix mobile app: AccessToken for voice, Agent Manager for status, caller ID support
All checks were successful
Create Release / build (push) Successful in 3s
- Voice token: use AccessToken + VoiceGrant instead of browser-only ClientToken
- Agent status: delegate to TWP_Agent_Manager matching browser phone behavior
- Queue loading: add missing require_once for TWP_User_Queue_Manager
- Add /phone-numbers endpoint for caller ID selection
- Webhook: support CallerId param from mobile extraOptions
- Flutter: caller ID dropdown in dialer, error logging in all catch blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:06:35 -08:00
30 changed files with 1968 additions and 362 deletions

View File

@@ -1,53 +0,0 @@
name: Update Plugin Version
on:
release:
types: [created, edited]
jobs:
update-version:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get release tag
id: get_tag
run: echo "TAG=${GITEA_REF#refs/tags/}" >> $GITEA_ENV
- name: Update version in plugin file
run: |
# Replace version in main plugin file (both header and constant)
sed -i "s/Version: {auto_update_value_on_deploy}/Version: ${{ env.TAG }}/" twilio-wp-plugin.php
sed -i "s/TWP_VERSION', '{auto_update_value_on_deploy}/TWP_VERSION', '${{ env.TAG }}/" twilio-wp-plugin.php
# Verify changes
grep "Version:" twilio-wp-plugin.php
grep "TWP_VERSION" twilio-wp-plugin.php
- name: Commit changes
run: |
git config --local user.email "action@gitea.com"
git config --local user.name "Gitea Action"
git add twilio-wp-plugin.php
git commit -m "Update version to ${{ env.TAG }}" || echo "No changes to commit"
git push || echo "Nothing to push"
- name: Create plugin zip
run: |
mkdir -p /tmp/twilio-wp-plugin
rsync -av --exclude=".git" --exclude=".gitea" --exclude="build" . /tmp/twilio-wp-plugin/
cd /tmp
zip -r $GITEA_WORK_DIR/twilio-wp-plugin.zip twilio-wp-plugin
- name: Upload zip to release
uses: actions/upload-release-asset@v1
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
with:
upload_url: ${{ gitea.event.release.upload_url }}
asset_path: twilio-wp-plugin.zip
asset_name: twilio-wp-plugin.zip
asset_content_type: application/zip

22
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -1580,10 +1580,127 @@ class TWP_Admin {
}
});
</script>
<hr>
<h2>Automatic Updates</h2>
<?php
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
// Handle manual update check
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_update_settings')) {
$update_result = $updater->manual_check_for_updates();
if (isset($update_result)) {
echo '<div class="notice notice-' . ($update_result['update_available'] ? 'warning' : 'success') . ' is-dismissible">';
echo '<p><strong>' . esc_html($update_result['message']) . '</strong></p></div>';
}
}
// Handle save update settings
if (isset($_POST['twp_save_update_settings']) && check_admin_referer('twp_update_settings')) {
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
echo '<div class="notice notice-success is-dismissible"><p><strong>Update settings saved.</strong></p></div>';
}
$update_status = $updater->get_update_status();
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', '');
?>
<form method="post" action="">
<?php wp_nonce_field('twp_update_settings'); ?>
<div class="card">
<table class="form-table">
<tr>
<th scope="row">Current Version</th>
<td>
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
<?php if ($update_status['update_available']): ?>
<span style="color: #d63638; margin-left: 10px;">
Update available: <?php echo esc_html($update_status['latest_version']); ?>
</span>
<?php else: ?>
<span style="color: #00a32a; margin-left: 10px;">Up to date</span>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
</th>
<td>
<label>
<input type="checkbox"
id="twp_auto_update_enabled"
name="twp_auto_update_enabled"
value="1"
<?php checked($auto_update_enabled); ?>>
Automatically check for updates every 12 hours
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_repo">Gitea Repository</label>
</th>
<td>
<input type="text"
id="twp_gitea_repo"
name="twp_gitea_repo"
value="<?php echo esc_attr($gitea_repo); ?>"
class="regular-text"
placeholder="org/repo-name">
<p class="description">
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_token">Gitea Access Token</label>
</th>
<td>
<input type="password"
id="twp_gitea_token"
name="twp_gitea_token"
value="<?php echo esc_attr($gitea_token); ?>"
class="regular-text">
<p class="description">
Optional. Required only for private repositories.
</p>
</td>
</tr>
<tr>
<th scope="row">Last Update Check</th>
<td>
<?php
$last_check = $update_status['last_check'];
if ($last_check > 0) {
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
} else {
echo 'Never';
}
?>
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
Check Now
</button>
</td>
</tr>
</table>
<p>
<button type="submit" name="twp_save_update_settings" class="button button-primary">
Save Update Settings
</button>
</p>
</div>
</form>
</div>
<?php
}
/**
* Display schedules page
*/

View File

@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
wp_die(__('You do not have sufficient permissions to access this page.'));
}
// Handle manual update check
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
$update_result = $updater->manual_check_for_updates();
}
// Handle test notification
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
@@ -49,10 +42,6 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
} else {
update_option('twp_fcm_service_account_json', '');
}
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
$settings_saved = true;
}
@@ -60,15 +49,6 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
$fcm_project_id = get_option('twp_fcm_project_id', '');
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', '');
// Get update status
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
$update_status = $updater->get_update_status();
// Get mobile app statistics
global $wpdb;
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
@@ -86,12 +66,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
</div>
<?php endif; ?>
<?php if (isset($update_result)): ?>
<div class="notice notice-<?php echo $update_result['update_available'] ? 'warning' : 'success'; ?> is-dismissible">
<p><strong><?php echo esc_html($update_result['message']); ?></strong></p>
</div>
<?php endif; ?>
<?php if (isset($notification_result)): ?>
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
@@ -183,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<?php endif; ?>
</div>
<!-- Auto-Update Settings -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Automatic Updates</h2>
<table class="form-table">
<tr>
<th scope="row">Current Version</th>
<td>
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
<?php if ($update_status['update_available']): ?>
<span style="color: #d63638; margin-left: 10px;">
⚠ Update available: <?php echo esc_html($update_status['latest_version']); ?>
</span>
<?php else: ?>
<span style="color: #00a32a; margin-left: 10px;">✓ Up to date</span>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
</th>
<td>
<label>
<input type="checkbox"
id="twp_auto_update_enabled"
name="twp_auto_update_enabled"
value="1"
<?php checked($auto_update_enabled); ?>>
Automatically check for updates every 12 hours
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_repo">Gitea Repository</label>
</th>
<td>
<input type="text"
id="twp_gitea_repo"
name="twp_gitea_repo"
value="<?php echo esc_attr($gitea_repo); ?>"
class="regular-text"
placeholder="org/repo-name">
<p class="description">
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_token">Gitea Access Token</label>
</th>
<td>
<input type="password"
id="twp_gitea_token"
name="twp_gitea_token"
value="<?php echo esc_attr($gitea_token); ?>"
class="regular-text"
placeholder="">
<p class="description">
Optional. Required only for private repositories. Create token at:
<a href="https://repo.anhonesthost.net/user/settings/applications" target="_blank">Gitea Settings > Applications</a>
</p>
</td>
</tr>
<tr>
<th scope="row">Last Update Check</th>
<td>
<?php
$last_check = $update_status['last_check'];
if ($last_check > 0) {
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
} else {
echo 'Never';
}
?>
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
Check Now
</button>
</td>
</tr>
</table>
</div>
<!-- API Documentation -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>API Endpoints</h2>

View File

@@ -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

View File

@@ -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);
}

View File

@@ -19,7 +19,7 @@ class TWP_Auto_Updater {
public function __construct() {
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0';
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases?limit=1&draft=false';
}
/**
@@ -74,7 +74,7 @@ class TWP_Auto_Updater {
$custom_repo = get_option('twp_gitea_repo', '');
if (!empty($custom_repo)) {
$this->gitea_repo = $custom_repo;
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases?limit=1&draft=false';
}
$update_info = $this->get_latest_release();
@@ -184,9 +184,16 @@ class TWP_Auto_Updater {
return false;
}
$release = json_decode($response);
$releases = json_decode($response);
if (!$release || !isset($release->tag_name)) {
if (!$releases || !is_array($releases) || empty($releases)) {
error_log('TWP Auto-Updater: No releases found from Gitea');
return false;
}
$release = $releases[0];
if (!isset($release->tag_name)) {
error_log('TWP Auto-Updater: Invalid release data from Gitea');
return false;
}
@@ -210,6 +217,12 @@ class TWP_Auto_Updater {
$download_url = $release->zipball_url;
}
// Append auth token to download URL for private repos
if (!empty($gitea_token) && $download_url) {
$separator = (strpos($download_url, '?') !== false) ? '&' : '?';
$download_url .= $separator . 'token=' . urlencode($gitea_token);
}
// Format changelog
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';

View File

@@ -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)) {

View File

@@ -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
*/

View File

@@ -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 = '<Response><Dial callerId="' . htmlspecialchars($caller_id) . '"><Client>' . htmlspecialchars($client_identity) . '</Client></Dial></Response>';
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
*/

View File

@@ -9,6 +9,7 @@ class TWP_Mobile_Auth {
private $secret_key;
private $token_expiry = 86400; // 24 hours in seconds
private $refresh_expiry = 2592000; // 30 days in seconds
private $current_user_id = null;
/**
* Constructor
@@ -330,7 +331,7 @@ class TWP_Mobile_Auth {
}
// Store user ID for later use
$request->set_param('_twp_user_id', $payload->user_id);
$this->current_user_id = $payload->user_id;
return true;
}
@@ -339,8 +340,7 @@ class TWP_Mobile_Auth {
* Get current user ID from token
*/
public function get_current_user_id() {
$request = rest_get_server()->get_request();
return $request->get_param('_twp_user_id');
return $this->current_user_id;
}
/**
@@ -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
));
}
}
/**

View File

@@ -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()));

View File

@@ -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 .= '<Dial timeout="30"';
// Add caller ID if provided
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) {
// Add caller ID (required for outbound calls to phone numbers)
if (!empty($from_number)) {
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
}
$twiml .= '>';
$twiml .= '<Number>' . htmlspecialchars($to_number) . '</Number>';
$twiml .= '</Dial>';
@@ -400,9 +428,11 @@ class TWP_Webhooks {
} else {
$twiml .= '<Say voice="alice">No destination number provided.</Say>';
}
$twiml .= '</Response>';
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

176
mobile/README.md Normal file
View File

@@ -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 |

View File

@@ -51,6 +51,15 @@
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<!-- Twilio Voice FCM handler — must have higher priority than Flutter's default -->
<service
android:name="com.twilio.twilio_voice.fcm.VoiceFirebaseMessagingService"
android:exported="false">
<intent-filter android:priority="10">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="flutterEmbedding"
android:value="2" />

Binary file not shown.

View File

@@ -1,2 +0,0 @@
flutter.sdk=/opt/flutter
sdk.dir=/opt/android-sdk

View File

@@ -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<PhoneNumber> _phoneNumbers = [];
StreamSubscription? _sseSub;
StreamSubscription? _connSub;
Timer? _refreshTimer;
AgentStatus? get status => _status;
List<QueueInfo> 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<void> 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<void> 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<void> fetchQueues() async {
@@ -72,7 +87,10 @@ class AgentProvider extends ChangeNotifier {
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
.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<void> fetchPhoneNumbers() async {
@@ -106,6 +124,7 @@ class AgentProvider extends ChangeNotifier {
@override
void dispose() {
_refreshTimer?.cancel();
_sseSub?.cancel();
_connSub?.cancel();
super.dispose();

View File

@@ -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<void> 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();
}

View File

@@ -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<void> answer() => _voiceService.answer();
Future<void> reject() => _voiceService.reject();
Future<void> hangUp() => _voiceService.hangUp();
Future<void> 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<void> 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<void> holdCall() async {
@@ -134,6 +157,20 @@ class CallProvider extends ChangeNotifier {
await _voiceService.transferCall(sid, target);
}
Future<void> 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();

View File

@@ -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<DashboardScreen> {
bool _phoneAccountEnabled = true; // assume true until checked
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh();
_checkPhoneAccount();
});
}
Future<void> _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<AgentProvider>().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<DashboardScreen> {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
@@ -72,32 +133,34 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
),
),
// Caller ID selector
if (phoneNumbers.isNotEmpty) ...[
// Caller ID selector (only if multiple numbers)
if (phoneNumbers.length > 1) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: selectedCallerId,
initialValue: selectedCallerId,
decoration: const InputDecoration(
labelText: 'Caller ID',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('Default'),
),
...phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)),
],
items: phoneNumbers.map((p) => DropdownMenuItem<String>(
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<DashboardScreen> {
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().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<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
},
),
const SizedBox(height: 16),
@@ -141,20 +209,99 @@ class _DashboardScreenState extends State<DashboardScreen> {
);
}
void _showQueueCalls(BuildContext context, QueueInfo queue) {
final voiceService = context.read<AuthProvider>().voiceService;
final callProvider = context.read<CallProvider>();
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return FutureBuilder<List<Map<String, dynamic>>>(
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<AgentProvider>();
final call = context.watch<CallProvider>();
// 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<DashboardScreen> {
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<DashboardScreen> {
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,
),
)),
],
),

View File

@@ -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<bool> tryRestoreSession() async {
Future<User?> 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<String, dynamic>);
}
return null;
} catch (_) {
return false;
return null;
}
}

View File

@@ -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<void> _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<void> _showQueueAlertNotification(Map<String, dynamic> 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);
}
}

View File

@@ -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<String, dynamic>? _previousPollState;
Stream<SseEvent> get events => _eventController.stream;
Stream<bool> get connectionState => _connectionController.stream;
@@ -34,34 +38,63 @@ class SseService {
Future<void> connect() async {
_shouldReconnect = true;
_reconnectAttempt = 0;
_sseFailures = 0;
await _doConnect();
}
Future<void> _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<List<int>>;
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<void> _poll() async {
if (!_shouldReconnect) return;
try {
final response = await _api.dio.get('/stream/poll');
final data = Map<String, dynamic>.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<String, dynamic> prev, Map<String, dynamic> 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<String, dynamic>?) ?? {},
));
}
final prevQueues = prev['queues'] as Map<String, dynamic>? ?? {};
final currQueues = curr['queues'] as Map<String, dynamic>? ?? {};
for (final entry in currQueues.entries) {
final currQueue = Map<String, dynamic>.from(entry.value);
final prevQueue = prevQueues[entry.key] as Map<String, dynamic>?;
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<String, dynamic>,
));
} else if (curr['current_call'] == null && prev['current_call'] != null) {
_eventController.add(SseEvent(
event: 'call_ended',
data: prev['current_call'] as Map<String, dynamic>,
));
}
}
}
void disconnect() {
_shouldReconnect = false;
_reconnectTimer?.cancel();
_pollTimer?.cancel();
_pollTimer = null;
_cancelToken?.cancel();
_connectionController.add(false);
}

View File

@@ -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<CallEvent> _callEventController =
StreamController<CallEvent>.broadcast();
@@ -14,11 +18,30 @@ class VoiceService {
VoiceService(this._api);
Future<void> initialize() async {
Future<void> 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<List<Map<String, dynamic>>> getQueueCalls(int queueId) async {
final response = await _api.dio.get('/queues/$queueId/calls');
return List<Map<String, dynamic>>.from(response.data['calls'] ?? []);
}
Future<void> acceptQueueCall(String callSid) async {
await _api.dio.post('/calls/$callSid/accept', data: {
'client_identity': _identity,
});
}
Future<void> 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();
}
}

View File

@@ -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

View File

@@ -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');
});
});
}

View File

@@ -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<CallEvent> _eventController =
StreamController<CallEvent>.broadcast();
bool makeCallResult = true;
bool acceptQueueCallShouldThrow = false;
String? lastCallTo;
String? lastCallerId;
bool answerCalled = false;
bool hangUpCalled = false;
@override
Stream<CallEvent> get callEvents => _eventController.stream;
void emitEvent(CallEvent event) => _eventController.add(event);
@override
Future<bool> makeCall(String to, {String? callerId}) async {
lastCallTo = to;
lastCallerId = callerId;
return makeCallResult;
}
@override
Future<void> answer() async {
answerCalled = true;
}
@override
Future<void> hangUp() async {
hangUpCalled = true;
}
@override
Future<void> reject() async {}
@override
Future<void> toggleMute(bool mute) async {}
@override
Future<void> toggleSpeaker(bool speaker) async {}
@override
Future<void> sendDigits(String digits) async {}
@override
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
@override
Future<void> acceptQueueCall(String callSid) async {
if (acceptQueueCallShouldThrow) {
throw Exception('Network error');
}
}
@override
Future<void> holdCall(String callSid) async {}
@override
Future<void> unholdCall(String callSid) async {}
@override
Future<void> transferCall(String callSid, String target) async {}
@override
Future<void> 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);
});
});
}

View File

@@ -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 = <String, dynamic>{};
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 = <String, dynamic>{};
final call = QueueCall.fromJson(json);
expect(call.callSid, '');
expect(call.fromNumber, '');
expect(call.position, 0);
expect(call.waitTime, 0);
});
});
}