Compare commits

..

10 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
Claude
8cc6fa8c3c Fix queue loading, null-safe models, autofill, and add outbound dialer
All checks were successful
Create Release / build (push) Successful in 4s
- Fix queue queries in mobile API and SSE to use twp_group_members
  (matching browser phone) instead of twp_queue_assignments
- Auto-create personal queues if user has no extension
- Make all model JSON parsing null-safe (handle null, string ints, bools)
- Add AutofillGroup and autofill hints to login form
- Add outbound calling with dialpad bottom sheet on dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:32:22 -08:00
Claude
d41b6aa535 Reuse existing Twilio credentials for mobile voice tokens
All checks were successful
Create Release / build (push) Successful in 3s
Mobile voice token endpoint now uses TWP_Twilio_API::generate_capability_token()
instead of separate API Key SID/Secret. Removes duplicate Twilio credential
fields from Mobile App settings page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:50:53 -08:00
Claude
4da794ed0c Remove Twilio Push Credential references and add real Firebase config
All checks were successful
Create Release / build (push) Successful in 4s
All FCM push notifications are handled by WordPress backend (TWP_FCM),
so Twilio Push Credentials are unnecessary. Also replaces placeholder
google-services.json with real Firebase project config (twp-softphone).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:43:21 -08:00
Claude
5adfa694c1 Migrate FCM from legacy v1 API to HTTP v2 with service account auth
All checks were successful
Create Release / build (push) Successful in 3s
Replace deprecated FCM server key authentication with Google service
account OAuth2 flow. The class now creates a signed JWT from the
service account credentials, exchanges it for a short-lived access
token (cached via WordPress transients), and sends messages to the
FCM v2 endpoint (projects/{id}/messages:send).

Settings page updated: FCM Server Key field replaced with Firebase
Project ID + Service Account JSON textarea with validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:04:33 -08:00
35 changed files with 2526 additions and 601 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

124
CLAUDE.md
View File

@@ -6,8 +6,26 @@
- **URL**: `https://phone.cloud-hosting.io/` - **URL**: `https://phone.cloud-hosting.io/`
- **Deployment**: rsync to Docker (remote server only, not local) - **Deployment**: rsync to Docker (remote server only, not local)
- **SDK**: Twilio PHP SDK v8.7.0 - **SDK**: Twilio PHP SDK v8.7.0
- **PHP**: 8.0+ required
- **Optional**: AWS SDK (`aws/aws-sdk-php`) for SNS SMS provider
- **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates) - **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates)
## Commands
- **Install SDK (recommended)**: `./install-twilio-sdk-external.sh` (installs to `wp-content/twilio-sdk/`)
- **Install SDK (internal)**: `./install-twilio-sdk.sh` (installs to `vendor/`, lost on plugin update)
- **Test SDK**: `php test-sdk.php`
- **Composer install SDK**: `composer install-sdk`
- **Deploy**: rsync to Docker (remote server, see production path above)
- **CI/CD**: Gitea workflows in `.gitea/workflows/``release.yml`, `update-version.yml`
## Directory Structure
- `twilio-wp-plugin.php` — Main plugin file, constants, SDK loading
- `includes/` — All backend classes (28 class files)
- `admin/` — Admin UI class (`TWP_Admin`), mobile app settings page
- `assets/js/` — Browser phone JS, service worker
- `assets/images/`, `assets/sounds/` — Static assets
- `.gitea/workflows/` — CI/CD (release, version update)
## Phone Variable Names ## Phone Variable Names
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number` **Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
**Don't use**: `from_number`, `to_number`, `phone_number`, `$agent_phone` **Don't use**: `from_number`, `to_number`, `phone_number`, `$agent_phone`
@@ -18,11 +36,20 @@
- **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control - **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control
- **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache - **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999) - **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1` - **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing - **TWP_Activator**: Creates 16 DB tables, run `ensure_tables_exist()` if missing
- **TWP_Core**: Main plugin orchestrator, hooks all classes together
- **TWP_SMS_Manager**: SMS abstraction with provider interface
- **TWP_SMS_Provider_Twilio** / **TWP_SMS_Provider_SNS**: SMS providers (Twilio default, AWS SNS optional)
- **TWP_Mobile_API**: REST API for mobile app
- **TWP_Mobile_Auth** / **TWP_Mobile_SSE** / **TWP_FCM**: Mobile auth, server-sent events, push notifications
- **TWP_Call_Queue**: Queue operations and management
- **TWP_Callback_Manager**: Callback request handling
- **TWP_Workflow**: Workflow step execution engine
- **TWP_Auto_Updater**: Plugin auto-update from Gitea releases
## Database ## Database
15 tables with `twp_` prefix. Key notes: 16 tables with `twp_` prefix. Key notes:
- `twp_call_queues`: User queues (general/personal/hold) - `twp_call_queues`: User queues (general/personal/hold)
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert - `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
- `twp_queued_calls`: Uses `enqueued_at` not `joined_at` - `twp_queued_calls`: Uses `enqueued_at` not `joined_at`
@@ -41,32 +68,6 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- Queue: Pass `waitUrl` as option in `enqueue()` - Queue: Pass `waitUrl` as option in `enqueue()`
- TwiML: Use SDK classes, not raw XML - TwiML: Use SDK classes, not raw XML
## Recent Changes (v2.3.0)
- Browser phone moved to admin-only
- Call control uses `find_customer_call_leg()` to prevent disconnections
- Auto-creates user queues/extensions when needed
- Firefox support added
- 1-min agent status auto-revert
## SDK Installation
- **External SDK (Recommended)**: Use `install-twilio-sdk-external.sh` to install SDK to `wp-content/twilio-sdk/`
- Survives WordPress plugin updates
- SDK location defined by `TWP_EXTERNAL_SDK_DIR` constant
- Loading priority: External first, then internal `vendor/` fallback
- **Internal SDK (Alternative)**: Use `install-twilio-sdk.sh` to install to `vendor/`
- Will be deleted when WordPress updates the plugin
- Requires reinstallation after each plugin update
- **SDK Loading**: Plugin checks external location first via autoloader, falls back to internal
- **Post-Update Detection**: Hook on `upgrader_process_complete` checks SDK status and shows warning
## Browser Phone Configuration
- **Edge Location Setting**: Configurable via Settings → Twilio Edge Location
- Default: `roaming` (auto-select closest edge)
- Options: ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
- Stored in: `twp_twilio_edge` option
- Used by: Browser phone JavaScript for WebRTC connection
- Critical: Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
## Development Notes ## Development Notes
- **API**: E.164 format (+1XXXXXXXXXX) - **API**: E.164 format (+1XXXXXXXXXX)
- **Database**: Use `$wpdb`, prepared statements - **Database**: Use `$wpdb`, prepared statements
@@ -79,30 +80,51 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- User-specific queues with extensions - User-specific queues with extensions
- Browser phone at `admin.php?page=twilio-wp-browser-phone` - Browser phone at `admin.php?page=twilio-wp-browser-phone`
- ElevenLabs TTS with Alice fallback - ElevenLabs TTS with Alice fallback
- 68 AJAX actions, 26 REST endpoints - 77 AJAX actions, 35 REST endpoints
- Browser phone moved to admin-only (v2.3.0)
- Firefox, Chrome, Safari, Edge support
- 1-min agent status auto-revert
## Recent Technical Changes (v2.8.9) ## SDK Loading
- **External SDK (Recommended)**: `wp-content/twilio-sdk/` — survives plugin updates
- **Internal SDK**: `vendor/` — deleted on plugin update, needs reinstall
- Loading priority: External first (`TWP_EXTERNAL_SDK_DIR`), then internal fallback
- Post-update hook (`upgrader_process_complete`) warns if SDK missing
### SDK Persistence Between Plugin Updates ## Browser Phone Configuration
- **Problem**: WordPress plugin updates delete entire plugin folder including `vendor/` SDK - **Edge Location**: `twp_twilio_edge` option, default `roaming`
- **Solution**: External SDK installation at `wp-content/twilio-sdk/` survives updates - Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
- **Implementation**: - Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
- New constant: `TWP_EXTERNAL_SDK_DIR` points to `wp-content/twilio-sdk/`
- Loading priority in `twp_check_sdk_installation()`: External first, internal fallback
- Classes updated: `TWP_Twilio_API`, `TWP_Webhooks` constructors check external location first
- New script: `install-twilio-sdk-external.sh` automates external installation
- Post-update hook: `twp_check_sdk_after_update()` detects missing SDK after updates
- Admin notices: `twp_sdk_missing_notice()` shows both installation options
- Warning system: `twp_show_sdk_update_warning()` via transient after plugin updates
### US Calls Failing Fix (Browser Phone) ## Mobile App SSE (Server-Sent Events)
- **Problem**: Browser phone had hardcoded `edge: 'sydney'`, causing US calls to fail with immediate HANGUP 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.
- **Solution**: Configurable edge location via WordPress settings
- **Implementation**: ### Apache + PHP-FPM Buffering Fix
- New setting: `twp_twilio_edge` with default value `roaming` `mod_proxy_fcgi` buffers PHP output by default, which breaks SSE streaming. Fix by adding a config file on the server:
- Settings UI: Dropdown in admin settings with 8 edge options
- Browser phone JS: Uses `get_option('twp_twilio_edge', 'roaming')` instead of hardcoded value ```bash
- Edge options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo 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.
--- ---
*Updated: Jan 2026* *Updated: Mar 2026*

View File

@@ -1580,6 +1580,123 @@ class TWP_Admin {
} }
}); });
</script> </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> </div>
<?php <?php
} }

View File

@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
wp_die(__('You do not have sufficient permissions to access this page.')); 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 // Handle test notification
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) { if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php'; require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
@@ -36,31 +29,26 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
// Save settings // Save settings
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) { if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key'])); update_option('twp_fcm_project_id', sanitize_text_field($_POST['twp_fcm_project_id']));
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0'); // Service account JSON — validate it parses as JSON before saving
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo'])); $sa_json_raw = isset($_POST['twp_fcm_service_account_json']) ? wp_unslash($_POST['twp_fcm_service_account_json']) : '';
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token'])); if (!empty($sa_json_raw)) {
update_option('twp_twilio_api_key_sid', sanitize_text_field($_POST['twp_twilio_api_key_sid'])); $sa_parsed = json_decode($sa_json_raw, true);
update_option('twp_twilio_api_key_secret', sanitize_text_field($_POST['twp_twilio_api_key_secret'])); if ($sa_parsed && isset($sa_parsed['client_email'], $sa_parsed['private_key'])) {
update_option('twp_fcm_push_credential_sid', sanitize_text_field($_POST['twp_fcm_push_credential_sid'])); update_option('twp_fcm_service_account_json', $sa_json_raw);
} else {
$sa_json_error = 'Invalid service account JSON — must contain client_email and private_key fields.';
}
} else {
update_option('twp_fcm_service_account_json', '');
}
$settings_saved = true; $settings_saved = true;
} }
// Get current settings // Get current settings
$fcm_server_key = get_option('twp_fcm_server_key', ''); $fcm_project_id = get_option('twp_fcm_project_id', '');
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1'; $fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin'); $fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
$gitea_token = get_option('twp_gitea_token', '');
$twilio_api_key_sid = get_option('twp_twilio_api_key_sid', '');
$twilio_api_key_secret = get_option('twp_twilio_api_key_secret', '');
$fcm_push_credential_sid = get_option('twp_fcm_push_credential_sid', '');
// 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 // Get mobile app statistics
global $wpdb; global $wpdb;
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions'; $sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
@@ -78,18 +66,18 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
</div> </div>
<?php endif; ?> <?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)): ?> <?php if (isset($notification_result)): ?>
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible"> <div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p> <p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($sa_json_error)): ?>
<div class="notice notice-error is-dismissible">
<p><strong><?php echo esc_html($sa_json_error); ?></strong></p>
</div>
<?php endif; ?>
<div class="twp-mobile-settings"> <div class="twp-mobile-settings">
<!-- Mobile App Overview --> <!-- Mobile App Overview -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;"> <div class="card" style="max-width: 100%; margin-bottom: 20px;">
@@ -118,76 +106,48 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<!-- FCM Configuration --> <!-- FCM Configuration -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;"> <div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Firebase Cloud Messaging (FCM)</h2> <h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
<p>Configure FCM to enable push notifications for the mobile app.</p> <p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th scope="row"> <th scope="row">
<label for="twp_fcm_server_key">FCM Server Key</label> <label for="twp_fcm_project_id">Firebase Project ID</label>
</th> </th>
<td> <td>
<input type="text" <input type="text"
id="twp_fcm_server_key" id="twp_fcm_project_id"
name="twp_fcm_server_key" name="twp_fcm_project_id"
value="<?php echo esc_attr($fcm_server_key); ?>" value="<?php echo esc_attr($fcm_project_id); ?>"
class="regular-text" class="regular-text"
placeholder="AAAA..."> placeholder="my-project-12345">
<p class="description"> <p class="description">
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key Found in Firebase Console &gt; Project Settings &gt; General &gt; Project ID
</p> </p>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
<label for="twp_twilio_api_key_sid">Twilio API Key SID</label> <label for="twp_fcm_service_account_json">Service Account JSON</label>
</th> </th>
<td> <td>
<input type="text" <textarea id="twp_fcm_service_account_json"
id="twp_twilio_api_key_sid" name="twp_fcm_service_account_json"
name="twp_twilio_api_key_sid" rows="6"
value="<?php echo esc_attr($twilio_api_key_sid); ?>" class="large-text code"
class="regular-text" placeholder='Paste the entire contents of your service account JSON file...'><?php echo esc_textarea($fcm_service_account_json); ?></textarea>
placeholder="SK...">
<p class="description"> <p class="description">
Create an API Key in Twilio Console &gt; Account &gt; API Keys. Required for mobile VoIP tokens. Generate in Firebase Console &gt; Project Settings &gt; Service Accounts &gt; Generate New Private Key.
</p> Paste the entire JSON file contents here. Must contain <code>client_email</code> and <code>private_key</code> fields.
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_twilio_api_key_secret">Twilio API Key Secret</label>
</th>
<td>
<input type="password"
id="twp_twilio_api_key_secret"
name="twp_twilio_api_key_secret"
value="<?php echo esc_attr($twilio_api_key_secret); ?>"
class="regular-text">
<p class="description">
The secret associated with the API Key SID above. Shown only once when key is created.
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_fcm_push_credential_sid">Push Credential SID</label>
</th>
<td>
<input type="text"
id="twp_fcm_push_credential_sid"
name="twp_fcm_push_credential_sid"
value="<?php echo esc_attr($fcm_push_credential_sid); ?>"
class="regular-text"
placeholder="CR...">
<p class="description">
Twilio Push Credential SID. Create in Twilio Console &gt; Messaging &gt; Push Credentials using your FCM server key. Required for incoming call push notifications.
</p> </p>
<?php if ($fcm_sa_configured): ?>
<p style="color: #00a32a; margin-top: 5px;">&#10003; Service account configured</p>
<?php endif; ?>
</td> </td>
</tr> </tr>
</table> </table>
<?php if (!empty($fcm_server_key)): ?> <?php if ($fcm_sa_configured): ?>
<p> <p>
<button type="submit" name="twp_test_notification" class="button"> <button type="submit" name="twp_test_notification" class="button">
Send Test Notification Send Test Notification
@@ -197,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<?php endif; ?> <?php endif; ?>
</div> </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 --> <!-- API Documentation -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;"> <div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>API Endpoints</h2> <h2>API Endpoints</h2>

View File

@@ -480,6 +480,12 @@ class TWP_Activator {
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN auto_busy_at datetime DEFAULT NULL AFTER logged_in_at"); $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'; $table_schedules = $wpdb->prefix . 'twp_phone_schedules';
// Check if holiday_dates column exists // Check if holiday_dates column exists

View File

@@ -619,17 +619,17 @@ 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() { public static function revert_auto_busy_agents() {
global $wpdb; global $wpdb;
$table_name = $wpdb->prefix . 'twp_agent_status'; $table_name = $wpdb->prefix . 'twp_agent_status';
// Find agents who have been auto-busy for more than 1 minute and are still logged in // 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('-1 minute')); $cutoff_time = date('Y-m-d H:i:s', strtotime('-30 seconds'));
$auto_busy_agents = $wpdb->get_results($wpdb->prepare( $auto_busy_agents = $wpdb->get_results($wpdb->prepare(
"SELECT user_id, current_call_sid FROM $table_name "SELECT user_id, current_call_sid, pre_call_status FROM $table_name
WHERE status = 'busy' WHERE status = 'busy'
AND auto_busy_at IS NOT NULL AND auto_busy_at IS NOT NULL
AND auto_busy_at < %s AND auto_busy_at < %s
@@ -655,14 +655,22 @@ class TWP_Agent_Manager {
} }
} catch (Exception $e) { } catch (Exception $e) {
error_log("TWP Auto-Revert: Could not check call status for {$call_sid}: " . $e->getMessage()); 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 // Only revert if call is not active
if (!$call_active) { if (!$call_active) {
error_log("TWP Auto-Revert: Reverting user {$agent->user_id} from auto-busy to available"); $revert_to = !empty($agent->pre_call_status) ? $agent->pre_call_status : 'available';
self::set_agent_status($agent->user_id, 'available', null, false); 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')
);
} }
} }

View File

@@ -19,7 +19,7 @@ class TWP_Auto_Updater {
public function __construct() { public function __construct() {
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php'); $this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0'; $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', ''); $custom_repo = get_option('twp_gitea_repo', '');
if (!empty($custom_repo)) { if (!empty($custom_repo)) {
$this->gitea_repo = $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(); $update_info = $this->get_latest_release();
@@ -184,9 +184,16 @@ class TWP_Auto_Updater {
return false; 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'); error_log('TWP Auto-Updater: Invalid release data from Gitea');
return false; return false;
} }
@@ -210,6 +217,12 @@ class TWP_Auto_Updater {
$download_url = $release->zipball_url; $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 // Format changelog
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.'; $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) { if ($result !== false) {
// Notify agents via SMS when a new call enters the queue // Notify agents via SMS and FCM when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number']); self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']);
return $position; return $position;
} }
@@ -580,7 +580,7 @@ class TWP_Call_Queue {
/** /**
* Notify agents via SMS when a call enters the 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; global $wpdb;
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}"); error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
@@ -597,16 +597,8 @@ class TWP_Call_Queue {
return; 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 // Send Discord/Slack notification for incoming call
require_once dirname(__FILE__) . '/class-twp-notifications.php'; 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( TWP_Notifications::send_call_notification('incoming_call', array(
'type' => 'incoming_call', 'type' => 'incoming_call',
'caller' => $caller_number, 'caller' => $caller_number,
@@ -614,15 +606,34 @@ class TWP_Call_Queue {
'queue_id' => $queue_id 'queue_id' => $queue_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 // Get members of the assigned agent group
require_once dirname(__FILE__) . '/class-twp-agent-groups.php'; require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id); $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();
foreach ($members as $member) { 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)) { if (empty($members)) {

View File

@@ -1,27 +1,33 @@
<?php <?php
/** /**
* Firebase Cloud Messaging (FCM) Integration * Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
* *
* Handles push notifications to mobile devices via FCM * Handles push notifications to mobile devices via FCM using
* service account credentials and OAuth2 access tokens.
*/ */
class TWP_FCM { class TWP_FCM {
private $server_key; private $project_id;
private $fcm_url = 'https://fcm.googleapis.com/fcm/send'; private $service_account;
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
/** /**
* Constructor * Constructor
*/ */
public function __construct() { public function __construct() {
$this->server_key = get_option('twp_fcm_server_key', ''); $this->project_id = get_option('twp_fcm_project_id', '');
$sa_json = get_option('twp_fcm_service_account_json', '');
if (!empty($sa_json)) {
$this->service_account = json_decode($sa_json, true);
}
} }
/** /**
* Send push notification to user's devices * Send push notification to user's devices
*/ */
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) { public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
if (empty($this->server_key)) { if (empty($this->project_id) || empty($this->service_account)) {
error_log('TWP FCM: Server key not configured'); error_log('TWP FCM: Project ID or service account not configured');
return false; return false;
} }
@@ -57,47 +63,54 @@ class TWP_FCM {
} }
/** /**
* Send notification to specific token * Send notification to specific token via FCM HTTP v2 API
*/ */
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) { private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
if ($data_only) { $access_token = $this->get_access_token();
$payload = array( if (!$access_token) {
'to' => $token, return array('success' => false, 'error' => 'auth_failed');
'data' => array_merge($data, array( }
'title' => $title,
'body' => $body, // FCM v2 requires all data values to be strings
'timestamp' => time() $string_data = array();
)), foreach ($data as $key => $value) {
'priority' => 'high' $string_data[$key] = is_string($value) ? $value : (string)$value;
); }
} else { $string_data['title'] = $title;
$notification = array( $string_data['body'] = $body;
'title' => $title, $string_data['timestamp'] = (string)time();
'body' => $body,
'sound' => 'default', // Build the v2 message payload
$message = array(
'token' => $token,
'data' => $string_data,
'android' => array(
'priority' => 'high', 'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK' ),
); );
$payload = array( if (!$data_only) {
'to' => $token, $message['notification'] = array(
'notification' => $notification,
'data' => array_merge($data, array(
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'timestamp' => time() );
)), $message['android']['notification'] = array(
'priority' => 'high' 'sound' => 'default',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
); );
} }
$payload = array('message' => $message);
$url = sprintf($this->fcm_url_template, $this->project_id);
$headers = array( $headers = array(
'Authorization: key=' . $this->server_key, 'Authorization: Bearer ' . $access_token,
'Content-Type: application/json' 'Content-Type: application/json'
); );
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->fcm_url); curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -111,10 +124,14 @@ class TWP_FCM {
if ($http_code !== 200) { if ($http_code !== 200) {
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response"); error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
// Check if token is invalid
$response_data = json_decode($response, true); $response_data = json_decode($response, true);
if (isset($response_data['results'][0]['error']) && $error_code = isset($response_data['error']['details'][0]['errorCode'])
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) { ? $response_data['error']['details'][0]['errorCode'] : '';
$error_status = isset($response_data['error']['status'])
? $response_data['error']['status'] : '';
if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) ||
$error_status === 'NOT_FOUND') {
return array('success' => false, 'error' => 'invalid_token'); return array('success' => false, 'error' => 'invalid_token');
} }
@@ -124,6 +141,107 @@ class TWP_FCM {
return array('success' => true); return array('success' => true);
} }
/**
* Get OAuth2 access token from service account credentials.
* Caches the token in a transient until near expiry.
*/
private function get_access_token() {
$cached = get_transient('twp_fcm_access_token');
if ($cached) {
return $cached;
}
if (empty($this->service_account)) {
error_log('TWP FCM: Service account not configured');
return false;
}
$jwt = $this->create_jwt();
if (!$jwt) {
return false;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->service_account['token_uri']);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array(
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
)));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response");
return false;
}
$token_data = json_decode($response, true);
$access_token = $token_data['access_token'];
$expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600;
// Cache token for 5 minutes less than actual expiry
set_transient('twp_fcm_access_token', $access_token, max(60, $expires_in - 300));
return $access_token;
}
/**
* Create a signed JWT for the service account OAuth2 flow
*/
private function create_jwt() {
$sa = $this->service_account;
if (empty($sa['client_email']) || empty($sa['private_key']) || empty($sa['token_uri'])) {
error_log('TWP FCM: Service account JSON missing required fields');
return false;
}
$now = time();
$header = array('alg' => 'RS256', 'typ' => 'JWT');
$claims = array(
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => $sa['token_uri'],
'iat' => $now,
'exp' => $now + 3600,
);
$segments = array(
$this->base64url_encode(json_encode($header)),
$this->base64url_encode(json_encode($claims)),
);
$signing_input = implode('.', $segments);
$private_key = openssl_pkey_get_private($sa['private_key']);
if (!$private_key) {
error_log('TWP FCM: Failed to parse service account private key');
return false;
}
$signature = '';
if (!openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) {
error_log('TWP FCM: Failed to sign JWT');
return false;
}
$segments[] = $this->base64url_encode($signature);
return implode('.', $segments);
}
/**
* Base64url encode (RFC 4648)
*/
private function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/** /**
* Get all active FCM tokens for a user * Get all active FCM tokens for a user
*/ */
@@ -161,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) { public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Incoming Call'; $title = 'Call Waiting';
$body = "Call from $from_number in $queue_name queue"; $body = "Call from $from_number in $queue_name";
$data = array( $data = array(
'type' => 'incoming_call', 'type' => 'queue_alert',
'call_sid' => $call_sid, 'call_sid' => $call_sid,
'from_number' => $from_number, 'from_number' => $from_number,
'queue_name' => $queue_name 'queue_name' => $queue_name,
); );
return $this->send_notification($user_id, $title, $body, $data, true); 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 * Send queue timeout notification
*/ */
@@ -218,7 +382,7 @@ class TWP_FCM {
$data = array( $data = array(
'type' => 'test', 'type' => 'test',
'test' => true 'test' => 'true'
); );
return $this->send_notification($user_id, $title, $body, $data); return $this->send_notification($user_id, $title, $body, $data);

View File

@@ -106,6 +106,27 @@ class TWP_Mobile_API {
'callback' => array($this, 'get_voice_token'), 'callback' => array($this, 'get_voice_token'),
'permission_callback' => array($this->auth, 'verify_token') 'permission_callback' => array($this->auth, 'verify_token')
)); ));
// Phone numbers for caller ID
register_rest_route('twilio-mobile/v1', '/phone-numbers', array(
'methods' => 'GET',
'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')
));
}); });
} }
@@ -162,39 +183,16 @@ class TWP_Mobile_API {
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400)); return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
} }
global $wpdb; require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
$table = $wpdb->prefix . 'twp_agent_status'; require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
// Check if status exists
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
$user_id
));
$data = array(
'status' => $new_status,
'last_activity' => current_time('mysql')
);
// Handle login status change first (matches browser phone behavior)
if ($is_logged_in !== null) { if ($is_logged_in !== null) {
$data['is_logged_in'] = $is_logged_in ? 1 : 0; TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
if ($is_logged_in) {
$data['logged_in_at'] = current_time('mysql');
}
} }
if ($exists) { // Set agent status (handles auto_busy_at and all status fields)
$wpdb->update( TWP_Agent_Manager::set_agent_status($user_id, $new_status);
$table,
$data,
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
} else {
$data['user_id'] = $user_id;
$wpdb->insert($table, $data);
}
return new WP_REST_Response(array( return new WP_REST_Response(array(
'success' => true, 'success' => true,
@@ -211,44 +209,42 @@ class TWP_Mobile_API {
global $wpdb; global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues'; $queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls'; $calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments'; $groups_table = $wpdb->prefix . 'twp_group_members';
// Get queues assigned to this user // Auto-create personal queues if they don't exist
$queue_ids = $wpdb->get_col($wpdb->prepare( $extensions_table = $wpdb->prefix . 'twp_user_extensions';
"SELECT queue_id FROM $assignments_table WHERE user_id = %d", $existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id $user_id
)); ));
// Also include personal queues if (!$existing_extension) {
$personal_queue_ids = $wpdb->get_col($wpdb->prepare( require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
"SELECT id FROM $queues_table WHERE user_id = %d", TWP_User_Queue_Manager::create_user_queues($user_id);
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return new WP_REST_Response(array(
'success' => true,
'queues' => array()
), 200);
} }
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); // Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
// Get queue information with call counts SELECT DISTINCT
$queues = $wpdb->get_results("
SELECT
q.id, q.id,
q.queue_name, q.queue_name,
q.queue_type, q.queue_type,
q.extension, q.extension,
COUNT(c.id) as waiting_count COUNT(c.id) as waiting_count
FROM $queues_table q FROM $queues_table q
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting' LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str) WHERE (gm.user_id = %d AND gm.is_active = 1)
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
GROUP BY q.id GROUP BY q.id
"); ORDER BY
CASE
WHEN q.queue_type = 'personal' THEN 1
WHEN q.queue_type = 'hold' THEN 2
ELSE 3
END,
q.queue_name ASC
", $user_id, $user_id));
$result = array(); $result = array();
foreach ($queues as $queue) { foreach ($queues as $queue) {
@@ -315,12 +311,9 @@ class TWP_Mobile_API {
$user_id = $this->auth->get_current_user_id(); $user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid']; $call_sid = $request['call_sid'];
// Get agent phone number // Check for WebRTC client_identity parameter
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true); $body = $request->get_json_params();
$client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null;
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
}
// Initialize Twilio API // Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php'; require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
@@ -345,6 +338,79 @@ class TWP_Mobile_API {
} }
try { try {
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', '');
}
$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 // Connect agent to call
$agent_call = $twilio->create_call( $agent_call = $twilio->create_call(
$agent_number, $agent_number,
@@ -385,6 +451,7 @@ class TWP_Mobile_API {
'message' => 'Call accepted, connecting to agent', 'message' => 'Call accepted, connecting to agent',
'agent_call_sid' => $agent_call->sid 'agent_call_sid' => $agent_call->sid
), 200); ), 200);
}
} catch (Exception $e) { } catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500)); return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
@@ -689,32 +756,50 @@ class TWP_Mobile_API {
public function get_voice_token($request) { public function get_voice_token($request) {
$user_id = $this->auth->get_current_user_id(); $user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id); $user = get_userdata($user_id);
$identity = 'agent' . $user_id . preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login); $clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$clean_name = 'user';
}
$identity = 'agent' . $user_id . $clean_name;
try {
// Ensure Twilio SDK autoloader is loaded
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'); $account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token'); $auth_token = get_option('twp_twilio_auth_token');
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
$twiml_app_sid = get_option('twp_twiml_app_sid'); $twiml_app_sid = get_option('twp_twiml_app_sid');
$push_credential_sid = get_option('twp_fcm_push_credential_sid');
if (empty($api_key_sid) || empty($api_key_secret)) { if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) {
return new WP_Error('missing_api_key', 'Twilio API Key SID and Secret must be configured', array('status' => 500)); return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
} }
try { // AccessToken requires an API Key (not account credentials).
$token = new \Twilio\Jwt\AccessToken( // Auto-create and cache one if it doesn't exist yet.
$account_sid, $api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_sid, $api_key_secret = get_option('twp_twilio_api_key_secret');
$api_key_secret,
3600,
$identity
);
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 = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid); $voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true); $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)) { if (!empty($push_credential_sid)) {
$voiceGrant->setPushCredentialSid($push_credential_sid); $voiceGrant->setPushCredentialSid($push_credential_sid);
} }
@@ -732,6 +817,37 @@ class TWP_Mobile_API {
} }
} }
/**
* Get available Twilio phone numbers for caller ID
*/
public function get_phone_numbers($request) {
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
$result = $twilio->get_phone_numbers();
if (!$result['success']) {
return new WP_Error('twilio_error', $result['error'], array('status' => 500));
}
$phone_numbers = array();
foreach ($result['data']['incoming_phone_numbers'] as $number) {
$phone_numbers[] = array(
'phone_number' => $number['phone_number'],
'friendly_name' => $number['friendly_name'],
);
}
return new WP_REST_Response(array(
'success' => true,
'phone_numbers' => $phone_numbers
), 200);
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
}
}
/** /**
* Check if user has access to a queue * Check if user has access to a queue
*/ */
@@ -759,6 +875,79 @@ class TWP_Mobile_API {
return (bool)$is_assigned; 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 * Calculate wait time in seconds
*/ */

View File

@@ -9,6 +9,7 @@ class TWP_Mobile_Auth {
private $secret_key; private $secret_key;
private $token_expiry = 86400; // 24 hours in seconds private $token_expiry = 86400; // 24 hours in seconds
private $refresh_expiry = 2592000; // 30 days in seconds private $refresh_expiry = 2592000; // 30 days in seconds
private $current_user_id = null;
/** /**
* Constructor * Constructor
@@ -330,7 +331,7 @@ class TWP_Mobile_Auth {
} }
// Store user ID for later use // Store user ID for later use
$request->set_param('_twp_user_id', $payload->user_id); $this->current_user_id = $payload->user_id;
return true; return true;
} }
@@ -339,8 +340,7 @@ class TWP_Mobile_Auth {
* Get current user ID from token * Get current user ID from token
*/ */
public function get_current_user_id() { public function get_current_user_id() {
$request = rest_get_server()->get_request(); return $this->current_user_id;
return $request->get_param('_twp_user_id');
} }
/** /**
@@ -423,6 +423,7 @@ class TWP_Mobile_Auth {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions'; $table = $wpdb->prefix . 'twp_mobile_sessions';
if (!empty($refresh_token)) {
$wpdb->update( $wpdb->update(
$table, $table,
array('fcm_token' => $fcm_token), array('fcm_token' => $fcm_token),
@@ -430,6 +431,13 @@ class TWP_Mobile_Auth {
array('%s'), array('%s'),
array('%d', '%s', '%d') 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'), 'callback' => array($this, 'stream_events'),
'permission_callback' => array($this->auth, 'verify_token') '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 * Stream events to mobile app
*/ */
public function stream_events($request) { public function stream_events($request) {
error_log('TWP SSE: stream_events called');
$user_id = $this->auth->get_current_user_id(); $user_id = $this->auth->get_current_user_id();
error_log('TWP SSE: user_id=' . ($user_id ?: 'false'));
if (!$user_id) { if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401)); return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
@@ -56,6 +74,15 @@ class TWP_Mobile_SSE {
ob_end_flush(); 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 // Send initial connection event
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time())); $this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
@@ -142,38 +169,40 @@ class TWP_Mobile_SSE {
global $wpdb; global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues'; $queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls'; $calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments'; $groups_table = $wpdb->prefix . 'twp_group_members';
// Get queue IDs // Auto-create personal queues if they don't exist
$queue_ids = $wpdb->get_col($wpdb->prepare( $extensions_table = $wpdb->prefix . 'twp_user_extensions';
"SELECT queue_id FROM $assignments_table WHERE user_id = %d", $existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id $user_id
)); ));
$personal_queue_ids = $wpdb->get_col($wpdb->prepare( if (!$existing_extension) {
"SELECT id FROM $queues_table WHERE user_id = %d", TWP_User_Queue_Manager::create_user_queues($user_id);
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return array();
} }
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); // Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
$queues = $wpdb->get_results(" SELECT DISTINCT
SELECT
q.id, q.id,
q.queue_name, q.queue_name,
COUNT(c.id) as waiting_count, COUNT(c.id) as waiting_count,
MIN(c.enqueued_at) as oldest_call_time MIN(c.enqueued_at) as oldest_call_time
FROM $queues_table q FROM $queues_table q
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting' LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str) WHERE (gm.user_id = %d AND gm.is_active = 1)
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
GROUP BY q.id GROUP BY q.id
"); ORDER BY
CASE
WHEN q.queue_type = 'personal' THEN 1
WHEN q.queue_type = 'hold' THEN 2
ELSE 3
END,
q.queue_name ASC
", $user_id, $user_id));
$result = array(); $result = array();
foreach ($queues as $queue) { foreach ($queues as $queue) {

View File

@@ -329,6 +329,12 @@ class TWP_Webhooks {
*/ */
public function handle_browser_voice($request) { public function handle_browser_voice($request) {
$params = $request->get_params(); $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( $call_data = array(
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '', 'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
@@ -371,14 +377,42 @@ class TWP_Webhooks {
if (isset($params['To']) && !empty($params['To'])) { if (isset($params['To']) && !empty($params['To'])) {
$to_number = $params['To']; $to_number = $params['To'];
$from_number = isset($params['From']) ? $params['From'] : ''; // 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']) && preg_match('/^\+?\d+$/', $params['CallerId'])) {
$from_number = $params['CallerId'];
} 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 it's an outgoing call to a phone number
if (strpos($to_number, 'client:') !== 0) { if (strpos($to_number, 'client:') !== 0) {
$twiml .= '<Dial timeout="30"'; $twiml .= '<Dial timeout="30"';
// Add caller ID if provided // Add caller ID (required for outbound calls to phone numbers)
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) { if (!empty($from_number)) {
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"'; $twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
} }
@@ -397,6 +431,8 @@ class TWP_Webhooks {
$twiml .= '</Response>'; $twiml .= '</Response>';
error_log('TWP browser-voice TwiML: ' . $twiml);
return $this->send_twiml_response($twiml); return $this->send_twiml_response($twiml);
} }
@@ -908,13 +944,38 @@ class TWP_Webhooks {
// Update call status in queue if applicable // Update call status in queue if applicable
// Remove from queue for any terminal call state // Remove from queue for any terminal call state
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) { 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']); $queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
if ($queue_removed) { if ($queue_removed) {
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']); 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'] . ')'); 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 // Empty response
return new WP_REST_Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', 200, array( return new WP_REST_Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', 200, array(
'Content-Type' => 'text/xml; charset=utf-8' 'Content-Type' => 'text/xml; charset=utf-8'

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

@@ -1,13 +1,13 @@
{ {
"project_info": { "project_info": {
"project_number": "000000000000", "project_number": "187457540438",
"project_id": "twp-softphone-placeholder", "project_id": "twp-softphone",
"storage_bucket": "twp-softphone-placeholder.appspot.com" "storage_bucket": "twp-softphone.firebasestorage.app"
}, },
"client": [ "client": [
{ {
"client_info": { "client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000", "mobilesdk_app_id": "1:187457540438:android:d6d777270c23f6660946f7",
"android_client_info": { "android_client_info": {
"package_name": "io.cloudhosting.twp.twp_softphone" "package_name": "io.cloudhosting.twp.twp_softphone"
} }
@@ -15,7 +15,7 @@
"oauth_client": [], "oauth_client": [],
"api_key": [ "api_key": [
{ {
"current_key": "PLACEHOLDER_API_KEY" "current_key": "AIzaSyAdGJrWPy9b9arqHnlY5G_hsGiDcm6cchA"
} }
], ],
"services": { "services": {

View File

@@ -51,6 +51,15 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
</intent-filter> </intent-filter>
</activity> </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 <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />

Binary file not shown.

View File

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

View File

@@ -17,11 +17,11 @@ class AgentStatus {
factory AgentStatus.fromJson(Map<String, dynamic> json) { factory AgentStatus.fromJson(Map<String, dynamic> json) {
return AgentStatus( return AgentStatus(
status: _parseStatus(json['status'] as String), status: _parseStatus((json['status'] ?? 'offline') as String),
isLoggedIn: json['is_logged_in'] as bool, isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
currentCallSid: json['current_call_sid'] as String?, currentCallSid: json['current_call_sid'] as String?,
lastActivity: json['last_activity'] as String?, lastActivity: json['last_activity'] as String?,
availableForQueues: json['available_for_queues'] as bool? ?? true, availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0',
); );
} }

View File

@@ -15,13 +15,19 @@ class QueueInfo {
factory QueueInfo.fromJson(Map<String, dynamic> json) { factory QueueInfo.fromJson(Map<String, dynamic> json) {
return QueueInfo( return QueueInfo(
id: json['id'] as int, id: _toInt(json['id']),
name: json['name'] as String, name: (json['name'] ?? '') as String,
type: json['type'] as String, type: (json['type'] ?? '') as String,
extension: json['extension'] as String?, extension: json['extension'] as String?,
waitingCount: json['waiting_count'] as int, waitingCount: _toInt(json['waiting_count']),
); );
} }
static int _toInt(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
} }
class QueueCall { class QueueCall {
@@ -43,12 +49,18 @@ class QueueCall {
factory QueueCall.fromJson(Map<String, dynamic> json) { factory QueueCall.fromJson(Map<String, dynamic> json) {
return QueueCall( return QueueCall(
callSid: json['call_sid'] as String, callSid: (json['call_sid'] ?? '') as String,
fromNumber: json['from_number'] as String, fromNumber: (json['from_number'] ?? '') as String,
toNumber: json['to_number'] as String, toNumber: (json['to_number'] ?? '') as String,
position: json['position'] as int, position: _toInt(json['position']),
status: json['status'] as String, status: (json['status'] ?? '') as String,
waitTime: json['wait_time'] as int, waitTime: _toInt(json['wait_time']),
); );
} }
static int _toInt(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
} }

View File

@@ -13,10 +13,16 @@ class User {
factory User.fromJson(Map<String, dynamic> json) { factory User.fromJson(Map<String, dynamic> json) {
return User( return User(
id: json['user_id'] as int, id: _toInt(json['user_id']),
login: json['user_login'] as String, login: (json['user_login'] ?? '') as String,
displayName: json['display_name'] as String, displayName: (json['display_name'] ?? '') as String,
email: json['email'] as String?, email: json['email'] as String?,
); );
} }
static int _toInt(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
} }

View File

@@ -1,10 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/agent_status.dart'; import '../models/agent_status.dart';
import '../models/queue_state.dart'; import '../models/queue_state.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/sse_service.dart'; import '../services/sse_service.dart';
class PhoneNumber {
final String phoneNumber;
final String friendlyName;
PhoneNumber({required this.phoneNumber, required this.friendlyName});
factory PhoneNumber.fromJson(Map<String, dynamic> json) => PhoneNumber(
phoneNumber: json['phone_number'] as String,
friendlyName: json['friendly_name'] as String,
);
}
class AgentProvider extends ChangeNotifier { class AgentProvider extends ChangeNotifier {
final ApiClient _api; final ApiClient _api;
final SseService _sse; final SseService _sse;
@@ -12,12 +23,15 @@ class AgentProvider extends ChangeNotifier {
AgentStatus? _status; AgentStatus? _status;
List<QueueInfo> _queues = []; List<QueueInfo> _queues = [];
bool _sseConnected = false; bool _sseConnected = false;
List<PhoneNumber> _phoneNumbers = [];
StreamSubscription? _sseSub; StreamSubscription? _sseSub;
StreamSubscription? _connSub; StreamSubscription? _connSub;
Timer? _refreshTimer;
AgentStatus? get status => _status; AgentStatus? get status => _status;
List<QueueInfo> get queues => _queues; List<QueueInfo> get queues => _queues;
bool get sseConnected => _sseConnected; bool get sseConnected => _sseConnected;
List<PhoneNumber> get phoneNumbers => _phoneNumbers;
AgentProvider(this._api, this._sse) { AgentProvider(this._api, this._sse) {
_connSub = _sse.connectionState.listen((connected) { _connSub = _sse.connectionState.listen((connected) {
@@ -26,6 +40,11 @@ class AgentProvider extends ChangeNotifier {
}); });
_sseSub = _sse.events.listen(_handleSseEvent); _sseSub = _sse.events.listen(_handleSseEvent);
_refreshTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => fetchQueues(),
);
} }
Future<void> fetchStatus() async { Future<void> fetchStatus() async {
@@ -33,7 +52,10 @@ class AgentProvider extends ChangeNotifier {
final response = await _api.dio.get('/agent/status'); final response = await _api.dio.get('/agent/status');
_status = AgentStatus.fromJson(response.data); _status = AgentStatus.fromJson(response.data);
notifyListeners(); notifyListeners();
} catch (_) {} } catch (e) {
debugPrint('AgentProvider.fetchStatus error: $e');
if (e is DioException) debugPrint(' response: ${e.response?.data}');
}
} }
Future<void> updateStatus(AgentStatusValue newStatus) async { Future<void> updateStatus(AgentStatusValue newStatus) async {
@@ -49,7 +71,12 @@ class AgentProvider extends ChangeNotifier {
currentCallSid: _status?.currentCallSid, currentCallSid: _status?.currentCallSid,
); );
notifyListeners(); notifyListeners();
} catch (_) {} } catch (e) {
debugPrint('AgentProvider.updateStatus error: $e');
if (e is DioException) {
debugPrint('AgentProvider.updateStatus response: ${e.response?.data}');
}
}
} }
Future<void> fetchQueues() async { Future<void> fetchQueues() async {
@@ -60,11 +87,27 @@ class AgentProvider extends ChangeNotifier {
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>)) .map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
.toList(); .toList();
notifyListeners(); notifyListeners();
} catch (_) {} } catch (e) {
debugPrint('AgentProvider.fetchQueues error: $e');
if (e is DioException) debugPrint(' response: ${e.response?.data}');
}
}
Future<void> fetchPhoneNumbers() async {
try {
final response = await _api.dio.get('/phone-numbers');
final data = response.data;
_phoneNumbers = (data['phone_numbers'] as List)
.map((p) => PhoneNumber.fromJson(p as Map<String, dynamic>))
.toList();
notifyListeners();
} catch (e) {
debugPrint('AgentProvider.fetchPhoneNumbers error: $e');
}
} }
Future<void> refresh() async { Future<void> refresh() async {
await Future.wait([fetchStatus(), fetchQueues()]); await Future.wait([fetchStatus(), fetchQueues(), fetchPhoneNumbers()]);
} }
void _handleSseEvent(SseEvent event) { void _handleSseEvent(SseEvent event) {
@@ -81,6 +124,7 @@ class AgentProvider extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_refreshTimer?.cancel();
_sseSub?.cancel(); _sseSub?.cancel();
_connSub?.cancel(); _connSub?.cancel();
super.dispose(); super.dispose();

View File

@@ -10,10 +10,10 @@ enum AuthState { unauthenticated, authenticating, authenticated }
class AuthProvider extends ChangeNotifier { class AuthProvider extends ChangeNotifier {
final ApiClient _apiClient; final ApiClient _apiClient;
late final AuthService _authService; late AuthService _authService;
late final VoiceService _voiceService; late VoiceService _voiceService;
late final PushNotificationService _pushService; late PushNotificationService _pushService;
late final SseService _sseService; late SseService _sseService;
AuthState _state = AuthState.unauthenticated; AuthState _state = AuthState.unauthenticated;
User? _user; User? _user;
@@ -36,8 +36,9 @@ class AuthProvider extends ChangeNotifier {
} }
Future<void> tryRestoreSession() async { Future<void> tryRestoreSession() async {
final restored = await _authService.tryRestoreSession(); final user = await _authService.tryRestoreSession();
if (restored) { if (user != null) {
_user = user;
_state = AuthState.authenticated; _state = AuthState.authenticated;
await _initializeServices(); await _initializeServices();
notifyListeners(); notifyListeners();
@@ -63,13 +64,19 @@ class AuthProvider extends ChangeNotifier {
Future<void> _initializeServices() async { Future<void> _initializeServices() async {
try { try {
await _pushService.initialize(); await _pushService.initialize();
} catch (_) {} } catch (e) {
debugPrint('AuthProvider: push service init error: $e');
}
try { try {
await _voiceService.initialize(); await _voiceService.initialize(deviceToken: _pushService.fcmToken);
} catch (_) {} } catch (e) {
debugPrint('AuthProvider: voice service init error: $e');
}
try { try {
await _sseService.connect(); await _sseService.connect();
} catch (_) {} } catch (e) {
debugPrint('AuthProvider: SSE connect error: $e');
}
} }
Future<void> logout() async { Future<void> logout() async {
@@ -90,10 +97,18 @@ class AuthProvider extends ChangeNotifier {
} }
void _handleForceLogout() { void _handleForceLogout() {
_voiceService.dispose();
_sseService.disconnect();
_state = AuthState.unauthenticated; _state = AuthState.unauthenticated;
_user = null; _user = null;
_error = 'Session expired. Please log in again.'; _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(); notifyListeners();
} }

View File

@@ -10,6 +10,7 @@ class CallProvider extends ChangeNotifier {
Timer? _durationTimer; Timer? _durationTimer;
StreamSubscription? _eventSub; StreamSubscription? _eventSub;
DateTime? _connectedAt; DateTime? _connectedAt;
bool _pendingAutoAnswer = false;
CallInfo get callInfo => _callInfo; CallInfo get callInfo => _callInfo;
@@ -20,9 +21,13 @@ class CallProvider extends ChangeNotifier {
void _handleCallEvent(CallEvent event) { void _handleCallEvent(CallEvent event) {
switch (event) { switch (event) {
case CallEvent.incoming: case CallEvent.incoming:
_callInfo = _callInfo.copyWith( if (_pendingAutoAnswer) {
state: CallState.ringing, _pendingAutoAnswer = false;
); _callInfo = _callInfo.copyWith(state: CallState.connecting);
_voiceService.answer();
} else {
_callInfo = _callInfo.copyWith(state: CallState.ringing);
}
break; break;
case CallEvent.ringing: case CallEvent.ringing:
_callInfo = _callInfo.copyWith(state: CallState.connecting); _callInfo = _callInfo.copyWith(state: CallState.connecting);
@@ -47,21 +52,25 @@ class CallProvider extends ChangeNotifier {
break; break;
} }
// Update caller info from active call // Update caller info from active call (skip if call just ended)
if (_callInfo.state != CallState.idle) {
final call = TwilioVoice.instance.call; final call = TwilioVoice.instance.call;
final active = call.activeCall; final active = call.activeCall;
if (active != null) { if (active != null) {
if (_callInfo.callerNumber == null) {
_callInfo = _callInfo.copyWith( _callInfo = _callInfo.copyWith(
callerNumber: active.from, callerNumber: active.from,
); );
}
// Fetch SID asynchronously // Fetch SID asynchronously
call.getSid().then((sid) { call.getSid().then((sid) {
if (sid != null && sid != _callInfo.callSid) { if (sid != null && sid != _callInfo.callSid && _callInfo.isActive) {
_callInfo = _callInfo.copyWith(callSid: sid); _callInfo = _callInfo.copyWith(callSid: sid);
notifyListeners(); notifyListeners();
} }
}); });
} }
}
notifyListeners(); notifyListeners();
} }
@@ -85,7 +94,16 @@ class CallProvider extends ChangeNotifier {
Future<void> answer() => _voiceService.answer(); Future<void> answer() => _voiceService.answer();
Future<void> reject() => _voiceService.reject(); 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 { Future<void> toggleMute() async {
final newMuted = !_callInfo.isMuted; final newMuted = !_callInfo.isMuted;
@@ -103,6 +121,20 @@ class CallProvider extends ChangeNotifier {
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits); Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
Future<void> makeCall(String number, {String? callerId}) async {
_callInfo = _callInfo.copyWith(
state: CallState.connecting,
callerNumber: number,
);
notifyListeners();
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 { Future<void> holdCall() async {
final sid = _callInfo.callSid; final sid = _callInfo.callSid;
if (sid == null) return; if (sid == null) return;
@@ -125,6 +157,20 @@ class CallProvider extends ChangeNotifier {
await _voiceService.transferCall(sid, target); 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 @override
void dispose() { void dispose() {
_stopDurationTimer(); _stopDurationTimer();

View File

@@ -1,10 +1,16 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.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/agent_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/call_provider.dart'; import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart'; import '../widgets/agent_status_toggle.dart';
import '../widgets/dialpad.dart';
import '../widgets/queue_card.dart'; import '../widgets/queue_card.dart';
import 'active_call_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
@@ -15,28 +21,287 @@ class DashboardScreen extends StatefulWidget {
} }
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
bool _phoneAccountEnabled = true; // assume true until checked
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh(); 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();
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,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Number display
TextField(
controller: numberController,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
textAlign: TextAlign.center,
style: Theme.of(ctx).textTheme.headlineSmall,
decoration: InputDecoration(
hintText: 'Enter phone number',
suffixIcon: IconButton(
icon: const Icon(Icons.backspace_outlined),
onPressed: () {
final text = numberController.text;
if (text.isNotEmpty) {
numberController.text =
text.substring(0, text.length - 1);
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
}
},
),
),
),
// Caller ID selector (only if multiple numbers)
if (phoneNumbers.length > 1) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedCallerId,
decoration: const InputDecoration(
labelText: 'Caller ID',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
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
Dialpad(
onDigit: (digit) {
numberController.text += digit;
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
},
onClose: () => Navigator.pop(ctx),
),
const SizedBox(height: 8),
// Call button
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
icon: const Icon(Icons.call),
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
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),
],
),
);
},
);
},
);
}
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>(); final agent = context.watch<AgentProvider>();
final call = context.watch<CallProvider>();
// Navigate to active call screen when a call comes in // Android Telecom framework handles the call UI via the native InCallUI,
if (call.callInfo.isActive) { // so we don't navigate to our own ActiveCallScreen.
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
(route) => route.isFirst,
);
});
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -58,11 +323,27 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
], ],
), ),
floatingActionButton: FloatingActionButton(
onPressed: () => _showDialer(context),
child: const Icon(Icons.phone),
),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () => agent.refresh(), onRefresh: () => agent.refresh(),
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ 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 AgentStatusToggle(),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Queues', Text('Queues',
@@ -78,7 +359,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
else else
...agent.queues.map((q) => Padding( ...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8), 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,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -39,6 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
serverUrl = 'https://$serverUrl'; serverUrl = 'https://$serverUrl';
} }
TextInput.finishAutofillContext();
context.read<AuthProvider>().login( context.read<AuthProvider>().login(
serverUrl, serverUrl,
_usernameController.text.trim(), _usernameController.text.trim(),
@@ -55,6 +57,7 @@ class _LoginScreenState extends State<LoginScreen> {
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -80,6 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null, v == null || v.trim().isEmpty ? 'Required' : null,
), ),
@@ -91,6 +95,7 @@ class _LoginScreenState extends State<LoginScreen> {
prefixIcon: Icon(Icons.person), prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
autofillHints: const [AutofillHints.username],
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null, v == null || v.trim().isEmpty ? 'Required' : null,
), ),
@@ -110,6 +115,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
obscureText: _obscurePassword, obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
validator: (v) => validator: (v) =>
v == null || v.isEmpty ? 'Required' : null, v == null || v.isEmpty ? 'Required' : null,
), ),
@@ -145,6 +151,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
), ),
),
); );
} }

View File

@@ -1,4 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/user.dart'; import '../models/user.dart';
import 'api_client.dart'; import 'api_client.dart';
@@ -14,11 +16,15 @@ class AuthService {
{String? fcmToken}) async { {String? fcmToken}) async {
await _api.setBaseUrl(serverUrl); await _api.setBaseUrl(serverUrl);
final response = await _api.dio.post('/auth/login', data: { final response = await _api.dio.post(
'/auth/login',
data: {
'username': username, 'username': username,
'password': password, 'password': password,
if (fcmToken != null) 'fcm_token': fcmToken, if (fcmToken != null) 'fcm_token': fcmToken,
}); },
options: Options(receiveTimeout: const Duration(seconds: 60)),
);
final data = response.data; final data = response.data;
if (data['success'] != true) { if (data['success'] != true) {
@@ -27,24 +33,31 @@ class AuthService {
await _storage.write(key: 'access_token', value: data['access_token']); await _storage.write(key: 'access_token', value: data['access_token']);
await _storage.write(key: 'refresh_token', value: data['refresh_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); _scheduleRefresh(data['expires_in'] as int? ?? 3600);
return User.fromJson(data['user']); return User.fromJson(data['user']);
} }
Future<bool> tryRestoreSession() async { Future<User?> tryRestoreSession() async {
final token = await _storage.read(key: 'access_token'); final token = await _storage.read(key: 'access_token');
if (token == null) return false; if (token == null) return null;
await _api.restoreBaseUrl(); await _api.restoreBaseUrl();
if (_api.dio.options.baseUrl.isEmpty) return false; if (_api.dio.options.baseUrl.isEmpty) return null;
try { try {
final response = await _api.dio.get('/agent/status'); 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 (_) { } 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_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'api_client.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') @pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(); await Firebase.initializeApp();
// VoIP pushes are handled natively by twilio_voice plugin. final data = message.data;
// Other data messages can show a local notification if needed. 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 { class PushNotificationService {
@@ -15,6 +62,9 @@ class PushNotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance; final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications = final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
String? _fcmToken;
String? get fcmToken => _fcmToken;
PushNotificationService(this._api); PushNotificationService(this._api);
@@ -36,8 +86,12 @@ class PushNotificationService {
// Get and register FCM token // Get and register FCM token
final token = await _messaging.getToken(); final token = await _messaging.getToken();
debugPrint('FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
if (token != null) { if (token != null) {
_fcmToken = token;
await _registerToken(token); await _registerToken(token);
} else {
debugPrint('FCM: Failed to get token - Firebase may not be configured correctly');
} }
// Listen for token refresh // Listen for token refresh
@@ -60,7 +114,19 @@ class PushNotificationService {
// VoIP incoming_call is handled by twilio_voice natively // VoIP incoming_call is handled by twilio_voice natively
if (type == 'incoming_call') return; 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( _localNotifications.show(
message.hashCode, message.hashCode,
data['title'] ?? 'TWP Softphone', 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:convert';
import 'dart:math'; import 'dart:math';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import 'api_client.dart'; import 'api_client.dart';
@@ -25,6 +26,9 @@ class SseService {
Timer? _reconnectTimer; Timer? _reconnectTimer;
int _reconnectAttempt = 0; int _reconnectAttempt = 0;
bool _shouldReconnect = true; bool _shouldReconnect = true;
int _sseFailures = 0;
Timer? _pollTimer;
Map<String, dynamic>? _previousPollState;
Stream<SseEvent> get events => _eventController.stream; Stream<SseEvent> get events => _eventController.stream;
Stream<bool> get connectionState => _connectionController.stream; Stream<bool> get connectionState => _connectionController.stream;
@@ -34,34 +38,63 @@ class SseService {
Future<void> connect() async { Future<void> connect() async {
_shouldReconnect = true; _shouldReconnect = true;
_reconnectAttempt = 0; _reconnectAttempt = 0;
_sseFailures = 0;
await _doConnect(); await _doConnect();
} }
Future<void> _doConnect() async { 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?.cancel();
_cancelToken = CancelToken(); _cancelToken = CancelToken();
// Timer to detect if SSE stream never delivers data (Apache buffering)
Timer? firstDataTimer;
bool gotData = false;
try { try {
final token = await _storage.read(key: 'access_token'); 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( final response = await _api.dio.get(
'/stream/events', '/stream/events',
options: Options( options: Options(
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
responseType: ResponseType.stream, responseType: ResponseType.stream,
receiveTimeout: Duration.zero,
), ),
cancelToken: _cancelToken, cancelToken: _cancelToken,
); );
debugPrint('SSE: connected, status=${response.statusCode}');
_connectionController.add(true); _connectionController.add(true);
_reconnectAttempt = 0; _reconnectAttempt = 0;
_sseFailures = 0;
final stream = response.data.stream as Stream<List<int>>; final stream = response.data.stream as Stream<List<int>>;
String buffer = ''; String buffer = '';
await for (final chunk in stream) { await for (final chunk in stream) {
if (!gotData) {
gotData = true;
firstDataTimer.cancel();
debugPrint('SSE: first data received');
}
buffer += utf8.decode(chunk); buffer += utf8.decode(chunk);
final lines = buffer.split('\n'); final lines = buffer.split('\n');
buffer = lines.removeLast(); // keep incomplete line in buffer buffer = lines.removeLast();
String? eventName; String? eventName;
String? dataStr; String? dataStr;
@@ -82,8 +115,22 @@ class SseService {
} }
} }
} catch (e) { } catch (e) {
if (e is DioException && e.type == DioExceptionType.cancel) return; 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); _connectionController.add(false);
} else {
return; // User-initiated disconnect
}
} else {
debugPrint('SSE: stream error: $e');
_sseFailures++;
_connectionController.add(false);
}
} }
if (_shouldReconnect) { if (_shouldReconnect) {
@@ -104,9 +151,81 @@ class SseService {
_reconnectTimer = Timer(delay, _doConnect); _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() { void disconnect() {
_shouldReconnect = false; _shouldReconnect = false;
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_pollTimer?.cancel();
_pollTimer = null;
_cancelToken?.cancel(); _cancelToken?.cancel();
_connectionController.add(false); _connectionController.add(false);
} }

View File

@@ -1,4 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:twilio_voice/twilio_voice.dart'; import 'package:twilio_voice/twilio_voice.dart';
import 'api_client.dart'; import 'api_client.dart';
@@ -6,6 +9,8 @@ class VoiceService {
final ApiClient _api; final ApiClient _api;
Timer? _tokenRefreshTimer; Timer? _tokenRefreshTimer;
String? _identity; String? _identity;
String? _deviceToken;
StreamSubscription? _eventSubscription;
final StreamController<CallEvent> _callEventController = final StreamController<CallEvent> _callEventController =
StreamController<CallEvent>.broadcast(); StreamController<CallEvent>.broadcast();
@@ -13,11 +18,30 @@ class VoiceService {
VoiceService(this._api); 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(); await _fetchAndRegisterToken();
TwilioVoice.instance.callEventsListener.listen((event) { // Listen for call events (only once)
_eventSubscription ??= TwilioVoice.instance.callEventsListener.listen((event) {
if (!_callEventController.isClosed) {
_callEventController.add(event); _callEventController.add(event);
}
}); });
// Refresh token every 50 minutes // Refresh token every 50 minutes
@@ -34,9 +58,13 @@ class VoiceService {
final data = response.data; final data = response.data;
final token = data['token'] as String; final token = data['token'] as String;
_identity = data['identity'] 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) { } catch (e) {
// Token fetch failed - will retry on next interval debugPrint('VoiceService._fetchAndRegisterToken error: $e');
if (e is DioException) debugPrint(' response: ${e.response?.data}');
} }
} }
@@ -62,10 +90,41 @@ class VoiceService {
await TwilioVoice.instance.call.toggleSpeaker(speaker); await TwilioVoice.instance.call.toggleSpeaker(speaker);
} }
Future<bool> makeCall(String to, {String? callerId}) async {
try {
final extraOptions = <String, dynamic>{};
if (callerId != null && callerId.isNotEmpty) {
extraOptions['CallerId'] = callerId;
}
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;
}
}
Future<void> sendDigits(String digits) async { Future<void> sendDigits(String digits) async {
await TwilioVoice.instance.call.sendDigits(digits); 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 { Future<void> holdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/hold'); await _api.dio.post('/calls/$callSid/hold');
} }
@@ -80,6 +139,8 @@ class VoiceService {
void dispose() { void dispose() {
_tokenRefreshTimer?.cancel(); _tokenRefreshTimer?.cancel();
_eventSubscription?.cancel();
_eventSubscription = null;
_callEventController.close(); _callEventController.close();
} }
} }

View File

@@ -3,13 +3,15 @@ import '../models/queue_state.dart';
class QueueCard extends StatelessWidget { class QueueCard extends StatelessWidget {
final QueueInfo queue; final QueueInfo queue;
final VoidCallback? onTap;
const QueueCard({super.key, required this.queue}); const QueueCard({super.key, required this.queue, this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
child: ListTile( child: ListTile(
onTap: onTap,
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: queue.waitingCount > 0 backgroundColor: queue.waitingCount > 0
? Colors.orange.shade100 ? 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);
});
});
}