Compare commits

..

20 Commits

Author SHA1 Message Date
Claude
46fc27f9bf Add runtime microphone permission request for WebRTC calls
The RECORD_AUDIO permission was declared in the manifest but never
requested at runtime, causing WebRTC to fail on Android 6+. Now
requests microphone permission on app startup before initializing
the WebView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:39:55 -07:00
Claude
a2ea99bb09 Fix FCM token registration and add queue reminder alerts
- Fix silent insert failure in FCM token registration (missing NOT NULL
  refresh_token column) so WebView app tokens are actually stored
- Add 1-minute queue reminder cron that re-sends FCM alerts for calls
  still waiting, with transient-based throttle to prevent duplicates
- Send FCM cancel on queue dequeue (answered/hangup/timeout), not just
  on final call status webhook
- Clean up new cron hook on plugin deactivation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:29:33 -07:00
Claude
d00a906d07 Add call history, dark mode toggle, caller ID persistence, and refactor phone page
Phone page improvements:
- Clear caller number display after call disconnects
- Add "Recent" tab with session call history (tap to call back)
- Persist outbound caller ID selection in localStorage
- Fix button overlap with proper z-index layering
- Add manual dark mode toggle (System/Light/Dark) in Settings
- Improve dark mode CSS for all UI elements

Refactor phone page into separate files:
- assets/mobile/phone.css (848 lines) — all CSS
- assets/mobile/phone.js (1065 lines) — all JavaScript
- assets/mobile/phone-template.php (267 lines) — HTML template
- includes/class-twp-mobile-phone-page.php (211 lines) — PHP controller
- PHP values passed to JS via window.twpConfig bridge

Flutter app:
- Replace FAB with slim AppBar (refresh + menu buttons)
- Fix dark mode colors using theme-aware colorScheme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:02:04 -07:00
Claude
621b0890a9 Replace native Twilio Voice SDK with WebView-based softphone
Rewrites the mobile app from a native Twilio Voice SDK integration
(Android Telecom/ConnectionService) to a thin WebView shell that loads
a standalone browser phone page from WordPress. This eliminates the
buggy Android phone account registration, fixes frequent logouts by
using 7-day WP session cookies instead of JWT tokens, and maintains
all existing call features (dialpad, queues, hold, transfer, requeue,
recording, caller ID, agent status).

Server-side:
- Add class-twp-mobile-phone-page.php: standalone /twp-phone/ endpoint
  with mobile-optimized UI, dark mode, tab navigation, and Flutter
  WebView JS bridge
- Extend auth cookie to 7 days for phone agents
- Add WP AJAX handler for FCM token registration (cookie auth)

Flutter app (v2.0.0):
- Replace 18 native files with 5-file WebView shell
- Login via wp-login.php in WebView (auto-detect redirect on success)
- Full-screen WebView with auto microphone grant for WebRTC
- FCM push notifications preserved for queue alerts
- Remove: twilio_voice, dio, provider, JWT auth, SSE, native call UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:11:25 -07:00
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
Claude
826fd3ae39 Fix Android build: bump AGP to 8.7, minSdk to 26, enable desugaring
All checks were successful
Create Release / build (push) Successful in 4s
- AGP 8.1.0 -> 8.7.0 (Flutter 3.41 minimum)
- Kotlin 1.8.22 -> 2.1.0
- minSdkVersion 24 -> 26 (twilio_voice requirement)
- Enable coreLibraryDesugaring for flutter_local_notifications
- Add placeholder google-services.json for build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:12:16 -08:00
Claude
5c6932f1d1 Add TWP Softphone Flutter app and complete mobile backend API
All checks were successful
Create Release / build (push) Successful in 4s
Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for
mobile VoIP, implement unhold_call() with call leg detection, wire FCM
push notifications into call queue and webhook missed call handlers,
add data-only FCM message support for Android background wake, and add
Twilio API Key / Push Credential settings fields.

Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth
with auto-refresh, SSE real-time queue updates, FCM push notifications,
Material 3 UI with dashboard, active call screen, dialpad, and call
controls (mute/speaker/hold/transfer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:01:23 -08:00
03692608cc Speed up browser phone initialization
All checks were successful
Create Release / build (push) Successful in 3s
- Add preload hint for Twilio SDK to start loading earlier
- Add DNS prefetch and preconnect for Twilio servers
- Check SDK immediately instead of waiting 500ms
- Reduce polling interval from 100ms to 50ms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:31:12 -08:00
b95d1dc461 Remove debug logging from browser phone
All checks were successful
Create Release / build (push) Successful in 4s
Debug code was added to diagnose mobile connection issues. The fix
(polling for SDK instead of waiting for window.load) is now working,
so removing the temporary debug output.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:27:26 -08:00
59df695530 Fix browser phone init not starting on mobile (window.load not firing)
All checks were successful
Create Release / build (push) Successful in 3s
The window.load event was never firing on mobile tablets, preventing
the browser phone from initializing. Changed to poll for Twilio SDK
availability instead of waiting for window.load event.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:12:18 -08:00
03b6e5d70f Add debug logging to browser phone for mobile troubleshooting
All checks were successful
Create Release / build (push) Successful in 4s
Adds visible debug output to track SDK loading and device registration
steps on mobile devices where the phone stays stuck on "Connecting".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:02:03 -08:00
58 changed files with 6302 additions and 459 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(scp:*)",
"Bash(grep:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)"
],
"deny": [],
"defaultMode": "acceptEdits"
}
}

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/`
- **Deployment**: rsync to Docker (remote server only, not local)
- **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)
## 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
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
**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_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1`
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing
- **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
- **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
15 tables with `twp_` prefix. Key notes:
16 tables with `twp_` prefix. Key notes:
- `twp_call_queues`: User queues (general/personal/hold)
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
- `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()`
- 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
- **API**: E.164 format (+1XXXXXXXXXX)
- **Database**: Use `$wpdb`, prepared statements
@@ -79,30 +80,51 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- User-specific queues with extensions
- Browser phone at `admin.php?page=twilio-wp-browser-phone`
- 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
- **Problem**: WordPress plugin updates delete entire plugin folder including `vendor/` SDK
- **Solution**: External SDK installation at `wp-content/twilio-sdk/` survives updates
- **Implementation**:
- 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
## Browser Phone Configuration
- **Edge Location**: `twp_twilio_edge` option, default `roaming`
- Options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
- Wrong edge causes immediate call failures (e.g., US calls with Sydney edge)
### US Calls Failing Fix (Browser Phone)
- **Problem**: Browser phone had hardcoded `edge: 'sydney'`, causing US calls to fail with immediate HANGUP
- **Solution**: Configurable edge location via WordPress settings
- **Implementation**:
- New setting: `twp_twilio_edge` with default value `roaming`
- Settings UI: Dropdown in admin settings with 8 edge options
- Browser phone JS: Uses `get_option('twp_twilio_edge', 'roaming')` instead of hardcoded value
- Edge options: roaming, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo
## Mobile App SSE (Server-Sent Events)
The mobile app uses SSE for real-time updates (queue changes, agent status). If SSE doesn't work (green dot stays red), the app automatically falls back to 5-second polling.
### Apache + PHP-FPM Buffering Fix
`mod_proxy_fcgi` buffers PHP output by default, which breaks SSE streaming. Fix by adding a config file on the server:
```bash
echo 'ProxyPassMatch "^/wp-json/twilio-mobile/v1/stream/events$" "unix:/run/php-fpm/www.sock|fcgi://localhost/home/shadowdao/public_html/index.php" flushpackets=on' > /etc/httpd/conf.d/twp-sse.conf
httpd -t && systemctl restart httpd
```
- **`flushpackets=on`** is the key — tells Apache to flush PHP-FPM output immediately
- This is a `ProxyPassMatch` directive — **cannot** go in `.htaccess`, must be server config
- The PHP-FPM socket path (`/run/php-fpm/www.sock`) must match `/etc/httpd/conf.d/php.conf`
- If the server uses nginx instead of Apache, add `X-Accel-Buffering: no` header (already in PHP code)
- If behind HAProxy with HTTP/2, the issue is Apache→client buffering, not HTTP/2 framing
### Diagnosis
```bash
# Check PHP-FPM proxy config
grep -r "fcgi\|php-fpm" /etc/httpd/conf.d/
# Check if flushpackets is configured
grep -r "flushpackets" /etc/httpd/conf.d/
# Test SSE endpoint (should stream data, not hang)
curl -N -H "Authorization: Bearer TOKEN" https://phone.cloud-hosting.io/wp-json/twilio-mobile/v1/stream/events
```
## Changelog
See `README.md` for detailed version history. Current version: v2.8.9.
---
*Updated: Jan 2026*
*Updated: Mar 2026*

View File

@@ -1580,6 +1580,123 @@ class TWP_Admin {
}
});
</script>
<hr>
<h2>Automatic Updates</h2>
<?php
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
// Handle manual update check
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_update_settings')) {
$update_result = $updater->manual_check_for_updates();
if (isset($update_result)) {
echo '<div class="notice notice-' . ($update_result['update_available'] ? 'warning' : 'success') . ' is-dismissible">';
echo '<p><strong>' . esc_html($update_result['message']) . '</strong></p></div>';
}
}
// Handle save update settings
if (isset($_POST['twp_save_update_settings']) && check_admin_referer('twp_update_settings')) {
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0');
update_option('twp_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
echo '<div class="notice notice-success is-dismissible"><p><strong>Update settings saved.</strong></p></div>';
}
$update_status = $updater->get_update_status();
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', '');
?>
<form method="post" action="">
<?php wp_nonce_field('twp_update_settings'); ?>
<div class="card">
<table class="form-table">
<tr>
<th scope="row">Current Version</th>
<td>
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
<?php if ($update_status['update_available']): ?>
<span style="color: #d63638; margin-left: 10px;">
Update available: <?php echo esc_html($update_status['latest_version']); ?>
</span>
<?php else: ?>
<span style="color: #00a32a; margin-left: 10px;">Up to date</span>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
</th>
<td>
<label>
<input type="checkbox"
id="twp_auto_update_enabled"
name="twp_auto_update_enabled"
value="1"
<?php checked($auto_update_enabled); ?>>
Automatically check for updates every 12 hours
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_repo">Gitea Repository</label>
</th>
<td>
<input type="text"
id="twp_gitea_repo"
name="twp_gitea_repo"
value="<?php echo esc_attr($gitea_repo); ?>"
class="regular-text"
placeholder="org/repo-name">
<p class="description">
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_token">Gitea Access Token</label>
</th>
<td>
<input type="password"
id="twp_gitea_token"
name="twp_gitea_token"
value="<?php echo esc_attr($gitea_token); ?>"
class="regular-text">
<p class="description">
Optional. Required only for private repositories.
</p>
</td>
</tr>
<tr>
<th scope="row">Last Update Check</th>
<td>
<?php
$last_check = $update_status['last_check'];
if ($last_check > 0) {
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
} else {
echo 'Never';
}
?>
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
Check Now
</button>
</td>
</tr>
</table>
<p>
<button type="submit" name="twp_save_update_settings" class="button button-primary">
Save Update Settings
</button>
</p>
</div>
</form>
</div>
<?php
}
@@ -7016,7 +7133,7 @@ class TWP_Admin {
<div class="phone-interface">
<div class="phone-display">
<div id="phone-status">Ready</div>
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Connecting...</div>
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Loading...</div>
<div id="phone-number-display"></div>
<div id="call-timer" style="display: none;">00:00</div>
</div>
@@ -7445,6 +7562,11 @@ class TWP_Admin {
}
</style>
<!-- Preload and preconnect for faster loading -->
<link rel="preload" href="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js" as="script">
<link rel="dns-prefetch" href="//unpkg.com">
<link rel="dns-prefetch" href="//chunderw-vpc-gll.twilio.com">
<link rel="preconnect" href="https://chunderw-vpc-gll.twilio.com" crossorigin>
<!-- Twilio Voice SDK v2 from unpkg CDN -->
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
<script>
@@ -8233,16 +8355,40 @@ class TWP_Admin {
});
// Check if SDK loaded and initialize
$(window).on('load', function() {
setTimeout(function() {
if (typeof Twilio === 'undefined') {
showError('Twilio Voice SDK failed to load. Please check your internet connection and try refreshing the page.');
console.error('Twilio SDK not found. Script may be blocked or failed to load.');
} else {
// Poll for Twilio SDK availability (window.load may not fire on mobile)
var sdkCheckAttempts = 0;
var maxSdkCheckAttempts = 100; // 5 seconds max (100 * 50ms)
function checkAndInitialize() {
sdkCheckAttempts++;
if (typeof Twilio !== 'undefined' && Twilio.Device) {
console.log('Twilio SDK loaded successfully');
initializeBrowserPhone();
} else if (sdkCheckAttempts < maxSdkCheckAttempts) {
// Keep checking every 50ms for faster response
setTimeout(checkAndInitialize, 50);
} else {
showError('Twilio Voice SDK failed to load. Please check your internet connection and try refreshing the page.');
console.error('Twilio SDK not found after ' + sdkCheckAttempts + ' attempts.');
}
}
// Check immediately - SDK script is synchronous so should be loaded
// If not ready yet (mobile), polling will catch it
if (typeof Twilio !== 'undefined' && Twilio.Device) {
console.log('Twilio SDK already loaded');
initializeBrowserPhone();
} else {
// Start polling immediately
checkAndInitialize();
}
// Also keep the window.load as backup for desktop
$(window).on('load', function() {
if (typeof Twilio !== 'undefined' && !device) {
initializeBrowserPhone();
}
}, 1000);
});
// Clean up on page unload

View File

@@ -13,13 +13,6 @@ if (!current_user_can('manage_options')) {
wp_die(__('You do not have sufficient permissions to access this page.'));
}
// Handle manual update check
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
$update_result = $updater->manual_check_for_updates();
}
// Handle test notification
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
@@ -36,25 +29,26 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
// Save 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_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']));
update_option('twp_fcm_project_id', sanitize_text_field($_POST['twp_fcm_project_id']));
// Service account JSON — validate it parses as JSON before saving
$sa_json_raw = isset($_POST['twp_fcm_service_account_json']) ? wp_unslash($_POST['twp_fcm_service_account_json']) : '';
if (!empty($sa_json_raw)) {
$sa_parsed = json_decode($sa_json_raw, true);
if ($sa_parsed && isset($sa_parsed['client_email'], $sa_parsed['private_key'])) {
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;
}
// Get current settings
$fcm_server_key = get_option('twp_fcm_server_key', '');
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', '');
// Get update status
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
$updater = new TWP_Auto_Updater();
$update_status = $updater->get_update_status();
$fcm_project_id = get_option('twp_fcm_project_id', '');
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
// Get mobile app statistics
global $wpdb;
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
@@ -72,18 +66,18 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
</div>
<?php endif; ?>
<?php if (isset($update_result)): ?>
<div class="notice notice-<?php echo $update_result['update_available'] ? 'warning' : 'success'; ?> is-dismissible">
<p><strong><?php echo esc_html($update_result['message']); ?></strong></p>
</div>
<?php endif; ?>
<?php if (isset($notification_result)): ?>
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
</div>
<?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">
<!-- Mobile App Overview -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
@@ -112,29 +106,48 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<!-- FCM Configuration -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Firebase Cloud Messaging (FCM)</h2>
<p>Configure FCM to enable push notifications for the mobile app.</p>
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
<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">
<tr>
<th scope="row">
<label for="twp_fcm_server_key">FCM Server Key</label>
<label for="twp_fcm_project_id">Firebase Project ID</label>
</th>
<td>
<input type="text"
id="twp_fcm_server_key"
name="twp_fcm_server_key"
value="<?php echo esc_attr($fcm_server_key); ?>"
id="twp_fcm_project_id"
name="twp_fcm_project_id"
value="<?php echo esc_attr($fcm_project_id); ?>"
class="regular-text"
placeholder="AAAA...">
placeholder="my-project-12345">
<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>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_fcm_service_account_json">Service Account JSON</label>
</th>
<td>
<textarea id="twp_fcm_service_account_json"
name="twp_fcm_service_account_json"
rows="6"
class="large-text code"
placeholder='Paste the entire contents of your service account JSON file...'><?php echo esc_textarea($fcm_service_account_json); ?></textarea>
<p class="description">
Generate in Firebase Console &gt; Project Settings &gt; Service Accounts &gt; Generate New Private Key.
Paste the entire JSON file contents here. Must contain <code>client_email</code> and <code>private_key</code> fields.
</p>
<?php if ($fcm_sa_configured): ?>
<p style="color: #00a32a; margin-top: 5px;">&#10003; Service account configured</p>
<?php endif; ?>
</td>
</tr>
</table>
<?php if (!empty($fcm_server_key)): ?>
<?php if ($fcm_sa_configured): ?>
<p>
<button type="submit" name="twp_test_notification" class="button">
Send Test Notification
@@ -144,91 +157,6 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<?php endif; ?>
</div>
<!-- Auto-Update Settings -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Automatic Updates</h2>
<table class="form-table">
<tr>
<th scope="row">Current Version</th>
<td>
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
<?php if ($update_status['update_available']): ?>
<span style="color: #d63638; margin-left: 10px;">
⚠ Update available: <?php echo esc_html($update_status['latest_version']); ?>
</span>
<?php else: ?>
<span style="color: #00a32a; margin-left: 10px;">✓ Up to date</span>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
</th>
<td>
<label>
<input type="checkbox"
id="twp_auto_update_enabled"
name="twp_auto_update_enabled"
value="1"
<?php checked($auto_update_enabled); ?>>
Automatically check for updates every 12 hours
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_repo">Gitea Repository</label>
</th>
<td>
<input type="text"
id="twp_gitea_repo"
name="twp_gitea_repo"
value="<?php echo esc_attr($gitea_repo); ?>"
class="regular-text"
placeholder="org/repo-name">
<p class="description">
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="twp_gitea_token">Gitea Access Token</label>
</th>
<td>
<input type="password"
id="twp_gitea_token"
name="twp_gitea_token"
value="<?php echo esc_attr($gitea_token); ?>"
class="regular-text"
placeholder="">
<p class="description">
Optional. Required only for private repositories. Create token at:
<a href="https://repo.anhonesthost.net/user/settings/applications" target="_blank">Gitea Settings > Applications</a>
</p>
</td>
</tr>
<tr>
<th scope="row">Last Update Check</th>
<td>
<?php
$last_check = $update_status['last_check'];
if ($last_check > 0) {
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
} else {
echo 'Never';
}
?>
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
Check Now
</button>
</td>
</tr>
</table>
</div>
<!-- API Documentation -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>API Endpoints</h2>
@@ -273,6 +201,11 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<td>GET</td>
<td>Server-Sent Events stream for real-time updates</td>
</tr>
<tr>
<td><code>/twilio-mobile/v1/voice/token</code></td>
<td>GET</td>
<td>Get Twilio Voice access token for VoIP</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,267 @@
<?php
/**
* Mobile Phone Page Template
*
* This template is require'd from TWP_Mobile_Phone_Page::render_page().
* All PHP variables ($extension_data, $is_logged_in, $agent_status, etc.)
* are in scope from the calling method.
*
* @package Twilio_WP_Plugin
*/
// Prevent direct access.
if (!defined('ABSPATH')) {
exit;
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<title>Phone - <?php echo esc_html(get_bloginfo('name')); ?></title>
<!-- jQuery (WordPress bundled) -->
<script src="<?php echo includes_url('js/jquery/jquery.min.js'); ?>"></script>
<!-- Preload Twilio SDK -->
<link rel="preload" href="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js" as="script">
<link rel="dns-prefetch" href="//unpkg.com">
<link rel="dns-prefetch" href="//chunderw-vpc-gll.twilio.com">
<link rel="preconnect" href="https://chunderw-vpc-gll.twilio.com" crossorigin>
<!-- Stylesheet -->
<link rel="stylesheet" href="<?php echo plugins_url('assets/mobile/phone.css', $plugin_file); ?>">
</head>
<body>
<div class="twp-app">
<!-- Agent Status Bar -->
<div class="agent-status-bar">
<div class="status-info">
<span class="extension-badge"><?php echo $extension_data ? esc_html($extension_data->extension) : '—'; ?></span>
<button id="login-toggle-btn" class="<?php echo $is_logged_in ? 'logged-in' : ''; ?>" onclick="toggleAgentLogin()">
<?php echo $is_logged_in ? 'Log Out' : 'Log In'; ?>
</button>
<select id="agent-status-select" onchange="updateAgentStatus(this.value)" <?php echo !$is_logged_in ? 'disabled' : ''; ?>>
<option value="available" <?php selected($agent_status->status ?? '', 'available'); ?>>Available</option>
<option value="busy" <?php selected($agent_status->status ?? '', 'busy'); ?>>Busy</option>
<option value="offline" <?php selected($agent_status->status ?? 'offline', 'offline'); ?>>Offline</option>
</select>
</div>
<div class="agent-stats">
<span>Today: <strong><?php echo esc_html($agent_stats['calls_today']); ?></strong></span>
<span>Total: <strong><?php echo esc_html($agent_stats['total_calls']); ?></strong></span>
<span>Avg: <strong><?php echo round($agent_stats['avg_duration'] ?? 0); ?>s</strong></span>
</div>
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn active" data-tab="phone">Phone</button>
<button class="tab-btn" data-tab="recent">Recent</button>
<button class="tab-btn" data-tab="queues">Queues</button>
<button class="tab-btn" data-tab="settings">Settings</button>
</div>
<!-- Notices container -->
<div id="twp-notices"></div>
<!-- Error display -->
<div id="browser-phone-error" style="display:none;"></div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Phone Tab -->
<div class="tab-pane active" id="tab-phone">
<div class="phone-interface">
<div class="phone-display">
<div id="phone-status">Ready</div>
<div id="device-connection-status">Loading...</div>
<div id="phone-number-display"></div>
<div id="call-timer" style="display:none;">00:00</div>
</div>
<input type="tel" id="phone-number-input" placeholder="Enter phone number" />
<div class="dialpad-grid">
<button class="dialpad-btn" data-digit="1">1</button>
<button class="dialpad-btn" data-digit="2">2<span>ABC</span></button>
<button class="dialpad-btn" data-digit="3">3<span>DEF</span></button>
<button class="dialpad-btn" data-digit="4">4<span>GHI</span></button>
<button class="dialpad-btn" data-digit="5">5<span>JKL</span></button>
<button class="dialpad-btn" data-digit="6">6<span>MNO</span></button>
<button class="dialpad-btn" data-digit="7">7<span>PQRS</span></button>
<button class="dialpad-btn" data-digit="8">8<span>TUV</span></button>
<button class="dialpad-btn" data-digit="9">9<span>WXYZ</span></button>
<button class="dialpad-btn" data-digit="*">*</button>
<button class="dialpad-btn" data-digit="0">0<span>+</span></button>
<button class="dialpad-btn" data-digit="#">#</button>
</div>
<div class="phone-controls">
<button id="call-btn" class="btn-phone btn-call">
&#128222; Call
</button>
<button id="hangup-btn" class="btn-phone btn-hangup" style="display:none;">
&#10060; Hang Up
</button>
<button id="answer-btn" class="btn-phone btn-answer" style="display:none;">
&#128222; Answer
</button>
</div>
<div id="admin-call-controls-panel" style="display:none;">
<div class="call-controls-grid">
<button id="admin-hold-btn" class="btn-ctrl" title="Hold">
&#9208; Hold
</button>
<button id="admin-transfer-btn" class="btn-ctrl" title="Transfer">
&#8618; Transfer
</button>
<button id="admin-requeue-btn" class="btn-ctrl" title="Requeue">
&#128260; Requeue
</button>
<button id="admin-record-btn" class="btn-ctrl" title="Record">
&#9210; Record
</button>
</div>
</div>
</div>
</div>
<!-- Recent Tab -->
<div class="tab-pane" id="tab-recent">
<div class="recent-panel">
<div class="recent-header">
<h4>Recent Calls</h4>
<button type="button" id="clear-history-btn" class="btn-sm">Clear</button>
</div>
<div id="recent-call-list">
<div class="recent-empty">No calls yet this session.</div>
</div>
</div>
</div>
<!-- Queues Tab -->
<div class="tab-pane" id="tab-queues">
<div class="queue-panel">
<div class="queue-header">
<h4>Your Queues</h4>
<?php if ($extension_data): ?>
<div class="user-extension-admin">Ext: <strong><?php echo esc_html($extension_data->extension); ?></strong></div>
<?php endif; ?>
</div>
<div id="admin-queue-list">
<div class="queue-loading">Loading your queues...</div>
</div>
<div class="queue-actions">
<button type="button" id="admin-refresh-queues" class="btn-refresh">Refresh Queues</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-pane" id="tab-settings">
<div class="settings-panel">
<!-- Caller ID -->
<div class="settings-section">
<h4>Outbound Caller ID</h4>
<select id="caller-id-select">
<option value="">Loading numbers...</option>
</select>
</div>
<!-- Auto-answer -->
<div class="settings-section">
<label><input type="checkbox" id="auto-answer" /> Auto-answer incoming calls</label>
</div>
<!-- Dark Mode -->
<div class="settings-section">
<h4>Appearance</h4>
<div class="dark-mode-options">
<button type="button" class="dark-mode-opt" data-theme="system">System</button>
<button type="button" class="dark-mode-opt" data-theme="light">Light</button>
<button type="button" class="dark-mode-opt" data-theme="dark">Dark</button>
</div>
</div>
<!-- Call Mode -->
<div class="settings-section">
<h4>Call Reception Mode</h4>
<div class="mode-selection">
<label class="mode-option <?php echo $current_mode === 'browser' ? 'active' : ''; ?>">
<input type="radio" name="call_mode" value="browser" <?php checked($current_mode, 'browser'); ?>>
<div class="mode-icon">&#128187;</div>
<div class="mode-details">
<strong>Browser Phone</strong>
<small>Calls ring in this browser</small>
</div>
</label>
<label class="mode-option <?php echo $current_mode === 'cell' ? 'active' : ''; ?>">
<input type="radio" name="call_mode" value="cell" <?php checked($current_mode, 'cell'); ?>>
<div class="mode-icon">&#128241;</div>
<div class="mode-details">
<strong>Cell Phone</strong>
<small>Forward to your mobile</small>
</div>
</label>
</div>
<div class="mode-status">
<div id="current-mode-display">
<strong>Current:</strong>
<span id="mode-text"><?php echo $current_mode === 'browser' ? 'Browser Phone' : 'Cell Phone'; ?></span>
</div>
<button type="button" id="save-mode-btn" style="display:none;">Save</button>
</div>
<div class="mode-info">
<div class="browser-mode-info" style="display:<?php echo $current_mode === 'browser' ? 'block' : 'none'; ?>;">
<p>Keep this page open to receive calls.</p>
</div>
<div class="cell-mode-info" style="display:<?php echo $current_mode === 'cell' ? 'block' : 'none'; ?>;">
<p>Calls forwarded to: <?php echo $user_phone ? esc_html($user_phone) : '<em>Not configured</em>'; ?></p>
</div>
</div>
</div>
<?php if (!$smart_routing_configured && current_user_can('manage_options')): ?>
<div class="setup-info">
<h4>Setup Required</h4>
<p>Update your phone number webhook to:</p>
<code><?php echo esc_html($smart_routing_webhook); ?></code>
<button type="button" class="btn-copy" onclick="copyToClipboard('<?php echo esc_js($smart_routing_webhook); ?>')">Copy</button>
</div>
<?php endif; ?>
</div>
</div>
</div><!-- .tab-content -->
</div><!-- .twp-app -->
<!-- Configuration for JavaScript -->
<script>
window.twpConfig = {
ajaxUrl: <?php echo wp_json_encode($ajax_url); ?>,
nonce: <?php echo wp_json_encode($nonce); ?>,
ringtoneUrl: <?php echo wp_json_encode($ringtone_url); ?>,
phoneIconUrl: <?php echo wp_json_encode($phone_icon_url); ?>,
swUrl: <?php echo wp_json_encode($sw_url); ?>,
twilioEdge: <?php echo wp_json_encode($twilio_edge); ?>
};
</script>
<!-- Twilio Voice SDK v2.11.0 -->
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
<!-- Phone JavaScript -->
<script src="<?php echo plugins_url('assets/mobile/phone.js', $plugin_file); ?>"></script>
</body>
</html>

848
assets/mobile/phone.css Normal file
View File

@@ -0,0 +1,848 @@
/* ===================================================================
CSS Reset & Base
=================================================================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #f5f6fa;
--bg-secondary: #ffffff;
--bg-phone: #1a1a2e;
--bg-display: #16213e;
--text-primary: #2c3e50;
--text-secondary: #7f8c8d;
--text-light: #ffffff;
--accent: #2196F3;
--accent-dark: #1976D2;
--success: #4CAF50;
--warning: #FF9800;
--danger: #f44336;
--border: #e0e0e0;
--shadow: 0 2px 8px rgba(0,0,0,0.1);
--radius: 12px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--bg-phone: #16213e;
--bg-display: #0a0a1a;
--text-primary: #ecf0f1;
--text-secondary: #95a5a6;
--border: #2c3e50;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
}
/* Manual dark mode override via class on <html> */
:root.dark-mode {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--bg-phone: #16213e;
--bg-display: #0a0a1a;
--text-primary: #ecf0f1;
--text-secondary: #95a5a6;
--border: #2c3e50;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
/* ===================================================================
Layout — full-screen flex column
=================================================================== */
.twp-app {
display: flex;
flex-direction: column;
height: 100%;
max-width: 500px;
margin: 0 auto;
padding-top: var(--safe-top);
padding-bottom: var(--safe-bottom);
}
/* ===================================================================
Agent Status Bar (compact)
=================================================================== */
.agent-status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
flex-wrap: wrap;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.extension-badge {
background: var(--accent);
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.agent-stats {
display: flex;
gap: 10px;
font-size: 11px;
color: var(--text-secondary);
}
#login-toggle-btn, #agent-status-select {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
}
#login-toggle-btn.logged-in {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
/* ===================================================================
Notices
=================================================================== */
.twp-notice {
padding: 8px 12px;
margin: 6px 10px;
border-radius: 6px;
font-size: 13px;
animation: fadeIn 0.2s ease;
}
.twp-notice-success { background: #e8f5e9; color: #2e7d32; }
.twp-notice-error { background: #ffebee; color: #c62828; }
.twp-notice-info { background: #e3f2fd; color: #1565c0; }
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
:root:not(.light-mode) .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
:root:not(.light-mode) .twp-notice-info { background: #0d47a1; color: #90caf9; }
}
.dark-mode .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
.dark-mode .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
.dark-mode .twp-notice-info { background: #0d47a1; color: #90caf9; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
/* ===================================================================
Tab Navigation
=================================================================== */
.tab-nav {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: 10px 0;
text-align: center;
font-size: 13px;
font-weight: 600;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ===================================================================
Tab Content
=================================================================== */
.tab-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.tab-pane { display: none; height: 100%; }
.tab-pane.active { display: flex; flex-direction: column; }
/* ===================================================================
Phone Interface
=================================================================== */
.phone-interface {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 12px;
}
/* Display */
.phone-display {
background: var(--bg-display);
color: var(--text-light);
padding: 16px;
border-radius: var(--radius);
text-align: center;
}
#phone-status {
font-size: 14px;
color: var(--success);
margin-bottom: 4px;
}
#device-connection-status {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
#phone-number-display {
font-size: 20px;
min-height: 28px;
font-weight: 600;
letter-spacing: 1px;
}
#call-timer {
font-size: 16px;
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
/* Input */
#phone-number-input {
width: 100%;
padding: 12px;
font-size: 20px;
text-align: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
}
#phone-number-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(33,150,243,0.2);
}
/* Dialpad */
.dialpad-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.dialpad-btn {
padding: 14px 0;
font-size: 22px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: var(--radius);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
position: relative;
transition: background 0.1s;
min-height: 54px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.dialpad-btn:active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.dialpad-btn span {
display: block;
font-size: 9px;
color: var(--text-secondary);
margin-top: 1px;
letter-spacing: 2px;
}
.dialpad-btn:active span { color: rgba(255,255,255,0.8); }
/* Phone Controls */
.phone-controls {
display: flex;
gap: 8px;
}
.phone-controls .btn-phone {
flex: 1;
height: 50px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s;
}
.btn-call {
background: var(--success);
color: #fff;
}
.btn-hangup {
background: var(--danger);
color: #fff;
}
.btn-answer {
background: var(--success);
color: #fff;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.4); }
50% { box-shadow: 0 0 0 10px rgba(76,175,80,0); }
}
/* Call Controls (hold/transfer/requeue/record) */
.call-controls-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.call-controls-grid .btn-ctrl {
padding: 10px 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.1s;
}
.btn-ctrl:active { background: #e3f2fd; }
.btn-ctrl.btn-active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Error display */
#browser-phone-error {
background: #ffebee;
color: #c62828;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
margin: 0 12px;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
}
.dark-mode #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
/* ===================================================================
Settings Tab
=================================================================== */
.settings-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.settings-section {
background: var(--bg-secondary);
padding: 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.settings-section h4 {
font-size: 14px;
margin-bottom: 10px;
color: var(--accent-dark);
}
.settings-section label {
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.settings-section select {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin-top: 6px;
}
/* Call mode radio cards */
.mode-selection {
display: flex;
gap: 10px;
margin: 10px 0;
}
.mode-option {
display: flex;
align-items: center;
padding: 12px;
border: 2px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
background: var(--bg-primary);
}
.mode-option.active {
border-color: var(--accent);
background: #e3f2fd;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .mode-option.active { background: #0d47a1; }
}
.dark-mode .mode-option.active { background: #0d47a1; }
.mode-option input[type="radio"] { margin: 0 8px 0 0; }
.mode-icon { font-size: 20px; margin-right: 8px; }
.mode-details strong { display: block; font-size: 13px; }
.mode-details small { font-size: 11px; color: var(--text-secondary); }
.mode-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 13px;
}
.mode-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
#save-mode-btn {
padding: 6px 16px;
border: none;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
/* Setup info box */
.setup-info {
background: #fff3cd;
padding: 12px;
border-radius: 8px;
border-left: 4px solid #ffc107;
font-size: 12px;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .setup-info { background: #4a3800; color: #ffe082; }
}
.dark-mode .setup-info { background: #4a3800; color: #ffe082; }
.setup-info h4 { margin-bottom: 6px; color: #856404; font-size: 13px; }
.setup-info code {
display: block;
padding: 6px 8px;
background: rgba(0,0,0,0.05);
border-radius: 4px;
font-size: 11px;
word-break: break-all;
margin: 6px 0;
}
.btn-copy {
font-size: 11px;
padding: 2px 8px;
border: 1px solid var(--border);
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
}
/* ===================================================================
Queue Tab
=================================================================== */
.queue-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.queue-header h4 { font-size: 15px; }
.user-extension-admin {
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
color: var(--accent-dark);
border: 1px solid var(--border);
}
.queue-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
.queue-item.queue-type-personal { border-left: 4px solid var(--success); }
.queue-item.queue-type-hold { border-left: 4px solid var(--warning); }
.queue-item.queue-type-general { border-left: 4px solid var(--accent); }
.queue-item.has-calls { background: #fff3cd; }
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .queue-item.has-calls { background: #4a3800; }
}
.dark-mode .queue-item.has-calls { background: #4a3800; }
.queue-info { flex: 1; }
.queue-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
}
.queue-details {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
display: flex;
gap: 10px;
}
.queue-waiting.has-calls {
color: var(--danger);
font-weight: bold;
}
.queue-loading {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: 20px;
}
.queue-actions { text-align: center; }
.btn-sm {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
}
.btn-sm:active { background: #e3f2fd; }
.btn-refresh {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
/* ===================================================================
Recent Tab
=================================================================== */
.recent-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
height: 100%;
}
.recent-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.recent-header h4 { font-size: 15px; }
.recent-empty {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: 40px 20px;
font-size: 14px;
}
.recent-item {
display: flex;
align-items: center;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
gap: 10px;
cursor: pointer;
transition: background 0.1s;
}
.recent-item:active { background: var(--bg-primary); }
.recent-direction {
font-size: 18px;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.recent-info { flex: 1; min-width: 0; }
.recent-number {
font-weight: 600;
font-size: 14px;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-meta {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
display: flex;
gap: 10px;
}
.recent-callback {
flex-shrink: 0;
padding: 6px 12px;
background: var(--success);
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
/* ===================================================================
Dialog / Overlay (transfer, requeue)
=================================================================== */
.twp-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
.twp-dialog {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-secondary);
padding: 20px;
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 10000;
width: 90%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
color: var(--text-primary);
}
.twp-dialog h3 { margin: 0 0 12px 0; font-size: 16px; }
.twp-dialog input[type="text"],
.twp-dialog input[type="tel"] {
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin: 8px 0;
}
.twp-dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.btn-primary {
padding: 8px 18px;
border: none;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
padding: 8px 18px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.agent-option, .queue-option {
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
cursor: pointer;
background: var(--bg-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.agent-option.selected, .queue-option.selected {
background: #e3f2fd;
border-color: var(--accent);
}
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .agent-option.selected, :root:not(.light-mode) .queue-option.selected { background: #0d47a1; }
}
.dark-mode .agent-option.selected, .dark-mode .queue-option.selected { background: #0d47a1; }
/* ===================================================================
Z-Index Layering & Overlap Fixes
=================================================================== */
.twp-app { position: relative; z-index: 1; }
.agent-status-bar { position: relative; z-index: 10; }
.tab-nav { position: relative; z-index: 10; }
.tab-content { position: relative; z-index: 1; }
.phone-interface { position: relative; z-index: 1; overflow-y: auto; }
.phone-controls { position: relative; z-index: 2; }
#admin-call-controls-panel { position: relative; z-index: 2; margin-top: 4px; }
.call-controls-grid { position: relative; z-index: 2; }
.twp-overlay { z-index: 9999; }
.twp-dialog { z-index: 10000; }
/* Ensure tab panes scroll properly */
.tab-pane.active { overflow-y: auto; -webkit-overflow-scrolling: touch; }
#tab-phone.active { overflow: hidden; }
.phone-interface { flex: 1; overflow-y: auto; min-height: 0; }
/* Prevent call controls from overlapping dialpad */
.dialpad-grid { position: relative; z-index: 1; flex-shrink: 0; }
/* Dark mode for btn-ctrl active state */
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) .btn-ctrl:active { background: #0d47a1; color: #fff; }
:root:not(.light-mode) .btn-sm:active { background: #0d47a1; color: #fff; }
}
.dark-mode .btn-ctrl:active { background: #0d47a1; color: #fff; }
.dark-mode .btn-sm:active { background: #0d47a1; color: #fff; }
/* ===================================================================
Comprehensive Dark Mode Enhancements
=================================================================== */
/* Shared dark mode rules — applied by media query or manual toggle */
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) input[type="tel"],
:root:not(.light-mode) input[type="text"],
:root:not(.light-mode) select {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border);
}
:root:not(.light-mode) .settings-section h4 { color: #64b5f6; }
:root:not(.light-mode) .setup-info h4 { color: #ffe082; }
:root:not(.light-mode) .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
:root:not(.light-mode) .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
:root:not(.light-mode) .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
:root:not(.light-mode) .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
}
.dark-mode input[type="tel"],
.dark-mode input[type="text"],
.dark-mode select {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border);
}
.dark-mode .settings-section h4 { color: #64b5f6; }
.dark-mode .setup-info h4 { color: #ffe082; }
.dark-mode .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
.dark-mode .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
.dark-mode .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
.dark-mode .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
/* Dark mode toggle switch styling */
.dark-mode-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.dark-mode-toggle .toggle-label {
font-size: 13px;
color: var(--text-primary);
}
.toggle-switch {
position: relative;
width: 48px;
height: 26px;
cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 26px;
transition: background 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--accent);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(22px);
}
.dark-mode-options {
display: flex;
gap: 8px;
margin-top: 8px;
}
.dark-mode-opt {
flex: 1;
padding: 8px 4px;
text-align: center;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.dark-mode-opt.active {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}

1065
assets/mobile/phone.js Normal file

File diff suppressed because it is too large Load Diff

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");
}
// Add pre_call_status column to store status before a call set agent to busy
$pre_call_exists = $wpdb->get_results("SHOW COLUMNS FROM $table_agent_status LIKE 'pre_call_status'");
if (empty($pre_call_exists)) {
$wpdb->query("ALTER TABLE $table_agent_status ADD COLUMN pre_call_status varchar(20) DEFAULT NULL AFTER auto_busy_at");
}
$table_schedules = $wpdb->prefix . 'twp_phone_schedules';
// Check if holiday_dates column exists

View File

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

View File

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

View File

@@ -33,8 +33,8 @@ class TWP_Call_Queue {
);
if ($result !== false) {
// Notify agents via SMS when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number']);
// Notify agents via SMS and FCM when a new call enters the queue
self::notify_agents_for_queue($queue_id, $call_data['from_number'], $call_data['call_sid']);
return $position;
}
@@ -577,10 +577,73 @@ class TWP_Call_Queue {
return $status;
}
/**
* Cron callback: re-send FCM queue alerts every minute for calls still waiting.
* Only alerts for calls that have been waiting > 60 seconds (initial alert
* already sent on entry). Skips re-alerting for the same call within 55 seconds
* using a short transient to avoid overlap with the 60-second cron.
*/
public static function send_queue_reminders() {
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$queue_table = $wpdb->prefix . 'twp_call_queues';
// Find calls waiting longer than 60 seconds
$waiting_calls = $wpdb->get_results(
"SELECT c.*, q.queue_name, q.user_id AS queue_owner_id, q.agent_group_id
FROM $calls_table c
JOIN $queue_table q ON q.id = c.queue_id
WHERE c.status = 'waiting'
AND c.joined_at <= DATE_SUB(NOW(), INTERVAL 60 SECOND)"
);
if (empty($waiting_calls)) {
return;
}
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($waiting_calls as $call) {
// Throttle: skip if we reminded for this call within the last 55 seconds
$transient_key = 'twp_queue_remind_' . $call->call_sid;
if (get_transient($transient_key)) {
continue;
}
set_transient($transient_key, 1, 55);
$waiting_minutes = max(1, round((time() - strtotime($call->joined_at)) / 60));
$title = 'Call Still Waiting';
$body = "Call from {$call->from_number} waiting {$waiting_minutes}m in {$call->queue_name}";
$notified_users = array();
// Notify queue owner
if (!empty($call->queue_owner_id)) {
$fcm->notify_queue_alert($call->queue_owner_id, $call->from_number, $call->queue_name, $call->call_sid);
$notified_users[] = $call->queue_owner_id;
}
// Notify agent group members
if (!empty($call->agent_group_id)) {
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($call->agent_group_id);
foreach ($members as $member) {
if (!in_array($member->user_id, $notified_users)) {
$fcm->notify_queue_alert($member->user_id, $call->from_number, $call->queue_name, $call->call_sid);
$notified_users[] = $member->user_id;
}
}
}
error_log("TWP Queue Reminder: Re-alerted " . count($notified_users) . " user(s) for call {$call->call_sid} waiting {$waiting_minutes}m");
}
}
/**
* Notify agents via SMS when a call enters the queue
*/
private static function notify_agents_for_queue($queue_id, $caller_number) {
private static function notify_agents_for_queue($queue_id, $caller_number, $call_sid = '') {
global $wpdb;
error_log("TWP: notify_agents_for_queue called for queue {$queue_id}, caller {$caller_number}");
@@ -597,16 +660,8 @@ class TWP_Call_Queue {
return;
}
if (!$queue->agent_group_id) {
error_log("TWP: No agent group assigned to queue {$queue_id}, skipping SMS notifications");
return;
}
error_log("TWP: Found queue '{$queue->queue_name}' with agent group {$queue->agent_group_id}");
// Send Discord/Slack notification for incoming call
require_once dirname(__FILE__) . '/class-twp-notifications.php';
error_log("TWP: Triggering Discord/Slack notification for incoming call");
TWP_Notifications::send_call_notification('incoming_call', array(
'type' => 'incoming_call',
'caller' => $caller_number,
@@ -614,10 +669,36 @@ class TWP_Call_Queue {
'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
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)) {
$fcm->notify_queue_alert($member->user_id, $caller_number, $queue->queue_name, $call_sid);
$notified_users[] = $member->user_id;
}
}
if (empty($members)) {
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
return;

View File

@@ -39,6 +39,7 @@ class TWP_Core {
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-phone-page.php';
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
// Feature classes
@@ -255,6 +256,9 @@ class TWP_Core {
// Initialize Shortcodes
TWP_Shortcodes::init();
// Initialize standalone mobile phone page (/twp-phone/)
new TWP_Mobile_Phone_Page();
// Scheduled events
$scheduler = new TWP_Scheduler();
$this->loader->add_action('twp_check_schedules', $scheduler, 'check_active_schedules');
@@ -262,6 +266,9 @@ class TWP_Core {
$queue = new TWP_Call_Queue();
$this->loader->add_action('twp_process_queue', $queue, 'process_waiting_calls');
// Queue reminder alerts (re-send FCM every minute for waiting calls)
add_action('twp_queue_reminders', array('TWP_Call_Queue', 'send_queue_reminders'));
// Callback processing
$this->loader->add_action('twp_process_callbacks', 'TWP_Callback_Manager', 'process_callbacks');
@@ -277,6 +284,10 @@ class TWP_Core {
wp_schedule_event(time(), 'twp_every_30_seconds', 'twp_process_queue');
}
if (!wp_next_scheduled('twp_queue_reminders')) {
wp_schedule_event(time(), 'twp_every_minute', 'twp_queue_reminders');
}
if (!wp_next_scheduled('twp_process_callbacks')) {
wp_schedule_event(time(), 'twp_every_minute', 'twp_process_callbacks');
}

View File

@@ -11,6 +11,7 @@ class TWP_Deactivator {
// Clear scheduled events
wp_clear_scheduled_hook('twp_check_schedules');
wp_clear_scheduled_hook('twp_process_queue');
wp_clear_scheduled_hook('twp_queue_reminders');
wp_clear_scheduled_hook('twp_auto_revert_agents');
// Flush rewrite rules

View File

@@ -1,27 +1,33 @@
<?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 {
private $server_key;
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
private $project_id;
private $service_account;
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
/**
* Constructor
*/
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
*/
public function send_notification($user_id, $title, $body, $data = array()) {
if (empty($this->server_key)) {
error_log('TWP FCM: Server key not configured');
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
if (empty($this->project_id) || empty($this->service_account)) {
error_log('TWP FCM: Project ID or service account not configured');
return false;
}
@@ -37,7 +43,7 @@ class TWP_FCM {
$failed_tokens = array();
foreach ($tokens as $token) {
$result = $this->send_to_token($token, $title, $body, $data);
$result = $this->send_to_token($token, $title, $body, $data, $data_only);
if ($result['success']) {
$success_count++;
@@ -57,35 +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()) {
$notification = array(
'title' => $title,
'body' => $body,
'sound' => 'default',
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
$access_token = $this->get_access_token();
if (!$access_token) {
return array('success' => false, 'error' => 'auth_failed');
}
// FCM v2 requires all data values to be strings
$string_data = array();
foreach ($data as $key => $value) {
$string_data[$key] = is_string($value) ? $value : (string)$value;
}
$string_data['title'] = $title;
$string_data['body'] = $body;
$string_data['timestamp'] = (string)time();
// Build the v2 message payload
$message = array(
'token' => $token,
'data' => $string_data,
'android' => array(
'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
),
);
$payload = array(
'to' => $token,
'notification' => $notification,
'data' => array_merge($data, array(
if (!$data_only) {
$message['notification'] = array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
$message['android']['notification'] = array(
'sound' => 'default',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
);
}
$payload = array('message' => $message);
$url = sprintf($this->fcm_url_template, $this->project_id);
$headers = array(
'Authorization: key=' . $this->server_key,
'Authorization: Bearer ' . $access_token,
'Content-Type: application/json'
);
$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_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -99,10 +124,14 @@ class TWP_FCM {
if ($http_code !== 200) {
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
// Check if token is invalid
$response_data = json_decode($response, true);
if (isset($response_data['results'][0]['error']) &&
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
$error_code = isset($response_data['error']['details'][0]['errorCode'])
? $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');
}
@@ -112,6 +141,107 @@ class TWP_FCM {
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
*/
@@ -149,20 +279,66 @@ class TWP_FCM {
}
/**
* Send incoming call notification
* Send queue alert notification (call entered queue).
* Uses data-only message so it works in background/killed state.
*/
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Incoming Call';
$body = "Call from $from_number in $queue_name queue";
public function notify_queue_alert($user_id, $from_number, $queue_name, $call_sid) {
$title = 'Call Waiting';
$body = "Call from $from_number in $queue_name";
$data = array(
'type' => 'incoming_call',
'type' => 'queue_alert',
'call_sid' => $call_sid,
'from_number' => $from_number,
'queue_name' => $queue_name
'queue_name' => $queue_name,
);
return $this->send_notification($user_id, $title, $body, $data);
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;
}
}
}
}
/**
@@ -206,7 +382,7 @@ class TWP_FCM {
$data = array(
'type' => 'test',
'test' => true
'test' => 'true'
);
return $this->send_notification($user_id, $title, $body, $data);

View File

@@ -99,6 +99,34 @@ class TWP_Mobile_API {
'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token')
));
// Voice token for VoIP
register_rest_route('twilio-mobile/v1', '/voice/token', array(
'methods' => 'GET',
'callback' => array($this, 'get_voice_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')
));
});
}
@@ -155,39 +183,16 @@ class TWP_Mobile_API {
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
}
global $wpdb;
$table = $wpdb->prefix . 'twp_agent_status';
// 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')
);
require_once plugin_dir_path(__FILE__) . 'class-twp-agent-manager.php';
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
// Handle login status change first (matches browser phone behavior)
if ($is_logged_in !== null) {
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
if ($is_logged_in) {
$data['logged_in_at'] = current_time('mysql');
}
TWP_Agent_Manager::set_agent_login_status($user_id, (bool)$is_logged_in);
}
if ($exists) {
$wpdb->update(
$table,
$data,
array('user_id' => $user_id),
array('%s', '%s'),
array('%d')
);
} else {
$data['user_id'] = $user_id;
$wpdb->insert($table, $data);
}
// Set agent status (handles auto_busy_at and all status fields)
TWP_Agent_Manager::set_agent_status($user_id, $new_status);
return new WP_REST_Response(array(
'success' => true,
@@ -204,44 +209,42 @@ class TWP_Mobile_API {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$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
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
// Auto-create personal queues if they don't exist
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id
));
// Also include personal queues
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$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);
if (!$existing_extension) {
require_once plugin_dir_path(__FILE__) . 'class-twp-user-queue-manager.php';
TWP_User_Queue_Manager::create_user_queues($user_id);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
// Get queue information with call counts
$queues = $wpdb->get_results("
SELECT
// Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
SELECT DISTINCT
q.id,
q.queue_name,
q.queue_type,
q.extension,
COUNT(c.id) as waiting_count
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'
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
");
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();
foreach ($queues as $queue) {
@@ -308,12 +311,9 @@ class TWP_Mobile_API {
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
// Get agent phone number
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
if (empty($agent_number)) {
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
}
// Check for WebRTC client_identity parameter
$body = $request->get_json_params();
$client_identity = isset($body['client_identity']) ? sanitize_text_field($body['client_identity']) : null;
// Initialize Twilio API
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
@@ -338,6 +338,79 @@ class TWP_Mobile_API {
}
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
$agent_call = $twilio->create_call(
$agent_number,
@@ -378,6 +451,7 @@ class TWP_Mobile_API {
'message' => 'Call accepted, connecting to agent',
'agent_call_sid' => $agent_call->sid
), 200);
}
} catch (Exception $e) {
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
@@ -507,11 +581,46 @@ class TWP_Mobile_API {
* Unhold a call (resume from hold queue)
*/
public function unhold_call($request) {
// Implementation would retrieve from hold queue and reconnect
$user_id = $this->auth->get_current_user_id();
$call_sid = $request['call_sid'];
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
$twilio = new TWP_Twilio_API();
// Find customer call leg
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
if (!$customer_call_sid) {
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
}
// Build identity for this agent
$user = get_userdata($user_id);
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
if (empty($clean_name)) {
$clean_name = 'user';
}
$identity = 'agent' . $user_id . $clean_name;
// Redirect customer back to agent's client
$twiml = new \Twilio\TwiML\VoiceResponse();
$dial = $twiml->dial();
$dial->client($identity);
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
return new WP_REST_Response(array(
'success' => true,
'message' => 'Unhold functionality - to be implemented with queue retrieval'
), 501);
'message' => 'Call resumed from hold'
), 200);
} catch (Exception $e) {
return new WP_Error('unhold_error', $e->getMessage(), array('status' => 500));
}
}
/**
@@ -641,6 +750,104 @@ class TWP_Mobile_API {
), 200);
}
/**
* Get Voice access token for VoIP
*/
public function get_voice_token($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
$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');
$auth_token = get_option('twp_twilio_auth_token');
$twiml_app_sid = get_option('twp_twiml_app_sid');
if (empty($account_sid) || empty($auth_token) || empty($twiml_app_sid)) {
return new WP_Error('token_error', 'Twilio credentials not configured', array('status' => 500));
}
// AccessToken requires an API Key (not account credentials).
// Auto-create and cache one if it doesn't exist yet.
$api_key_sid = get_option('twp_twilio_api_key_sid');
$api_key_secret = get_option('twp_twilio_api_key_secret');
if (empty($api_key_sid) || empty($api_key_secret)) {
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
$newKey = $client->newKeys->create(['friendlyName' => 'TWP Mobile Voice']);
$api_key_sid = $newKey->sid;
$api_key_secret = $newKey->secret;
update_option('twp_twilio_api_key_sid', $api_key_sid);
update_option('twp_twilio_api_key_secret', $api_key_secret);
}
$token = new \Twilio\Jwt\AccessToken($account_sid, $api_key_sid, $api_key_secret, 3600, $identity);
$voiceGrant = new \Twilio\Jwt\Grants\VoiceGrant();
$voiceGrant->setOutgoingApplicationSid($twiml_app_sid);
$voiceGrant->setIncomingAllow(true);
// Include FCM push credential for incoming call notifications.
// Auto-create from the stored Firebase service account JSON if not yet created.
$push_credential_sid = get_option('twp_twilio_push_credential_sid');
if (empty($push_credential_sid)) {
$push_credential_sid = $this->ensure_push_credential($account_sid, $auth_token);
}
if (!empty($push_credential_sid)) {
$voiceGrant->setPushCredentialSid($push_credential_sid);
}
$token->addGrant($voiceGrant);
return new WP_REST_Response(array(
'token' => $token->toJWT(),
'identity' => $identity,
'expires_in' => 3600
), 200);
} catch (Exception $e) {
return new WP_Error('token_error', $e->getMessage(), array('status' => 500));
}
}
/**
* 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
*/
@@ -668,6 +875,79 @@ class TWP_Mobile_API {
return (bool)$is_assigned;
}
/**
* Admin endpoint to force re-creation of the Twilio Push Credential.
*/
public function setup_push_credential($request) {
$user_id = $this->auth->get_current_user_id();
$user = get_userdata($user_id);
if (!user_can($user, 'manage_options')) {
return new WP_Error('forbidden', 'Admin access required', array('status' => 403));
}
try {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
new TWP_Twilio_API();
$account_sid = get_option('twp_twilio_account_sid');
$auth_token = get_option('twp_twilio_auth_token');
// Force re-creation by clearing existing SID
delete_option('twp_twilio_push_credential_sid');
$sid = $this->ensure_push_credential($account_sid, $auth_token);
if (empty($sid)) {
return new WP_Error('credential_error', 'Failed to create push credential. Check that Firebase service account JSON is configured in Mobile App Settings.', array('status' => 500));
}
return new WP_REST_Response(array(
'success' => true,
'credential_sid' => $sid,
), 200);
} catch (Exception $e) {
error_log('TWP setup_push_credential error: ' . $e->getMessage());
return new WP_Error('credential_error', $e->getMessage(), array('status' => 500));
}
}
/**
* Auto-create Twilio Push Credential from the stored Firebase service account JSON.
* Returns the credential SID or empty string on failure.
*/
private function ensure_push_credential($account_sid, $auth_token) {
$sa_json = get_option('twp_fcm_service_account_json', '');
if (empty($sa_json)) {
return '';
}
$sa = json_decode($sa_json, true);
if (!$sa || empty($sa['project_id']) || empty($sa['private_key'])) {
error_log('TWP: Firebase service account JSON is invalid');
return '';
}
try {
$client = new \Twilio\Rest\Client($account_sid, $auth_token);
$credential = $client->notify->v1->credentials->create(
'fcm',
[
'friendlyName' => 'TWP Mobile FCM',
'secret' => $sa_json,
]
);
update_option('twp_twilio_push_credential_sid', $credential->sid);
error_log('TWP: Created Twilio push credential: ' . $credential->sid);
return $credential->sid;
} catch (Exception $e) {
error_log('TWP ensure_push_credential error: ' . $e->getMessage());
return '';
}
}
/**
* Calculate wait time in seconds
*/

View File

@@ -9,6 +9,7 @@ class TWP_Mobile_Auth {
private $secret_key;
private $token_expiry = 86400; // 24 hours in seconds
private $refresh_expiry = 2592000; // 30 days in seconds
private $current_user_id = null;
/**
* Constructor
@@ -330,7 +331,7 @@ class TWP_Mobile_Auth {
}
// Store user ID for later use
$request->set_param('_twp_user_id', $payload->user_id);
$this->current_user_id = $payload->user_id;
return true;
}
@@ -339,8 +340,7 @@ class TWP_Mobile_Auth {
* Get current user ID from token
*/
public function get_current_user_id() {
$request = rest_get_server()->get_request();
return $request->get_param('_twp_user_id');
return $this->current_user_id;
}
/**
@@ -423,6 +423,7 @@ class TWP_Mobile_Auth {
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
if (!empty($refresh_token)) {
$wpdb->update(
$table,
array('fcm_token' => $fcm_token),
@@ -430,6 +431,13 @@ class TWP_Mobile_Auth {
array('%s'),
array('%d', '%s', '%d')
);
} else {
// No refresh token — update the most recent active session for this user
$wpdb->query($wpdb->prepare(
"UPDATE $table SET fcm_token = %s WHERE user_id = %d AND is_active = 1 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
$fcm_token, $user_id
));
}
}
/**

View File

@@ -0,0 +1,226 @@
<?php
/**
* Standalone Mobile Phone Page
*
* Registers a front-end endpoint at /twp-phone/ that serves the browser phone UI
* without any wp-admin chrome. Designed for mobile WebView usage.
*
* @package Twilio_WP_Plugin
*/
class TWP_Mobile_Phone_Page {
/**
* The endpoint slug.
*/
const ENDPOINT = 'twp-phone';
/**
* Constructor — wire up hooks.
*/
public function __construct() {
add_action('init', array($this, 'register_rewrite'));
add_action('template_redirect', array($this, 'handle_request'));
add_filter('query_vars', array($this, 'add_query_var'));
// Extend session cookie for phone agents.
add_filter('auth_cookie_expiration', array($this, 'extend_agent_cookie'), 10, 3);
// AJAX action for FCM token registration (uses WP cookie auth).
add_action('wp_ajax_twp_register_fcm_token', array($this, 'ajax_register_fcm_token'));
}
/**
* AJAX handler: register FCM token for the current user.
*/
public function ajax_register_fcm_token() {
check_ajax_referer('twp_ajax_nonce', 'nonce');
$fcm_token = sanitize_text_field($_POST['fcm_token'] ?? '');
if (empty($fcm_token)) {
wp_send_json_error('Missing FCM token');
}
$user_id = get_current_user_id();
if (!$user_id) {
wp_send_json_error('Not authenticated');
}
// Store FCM token (same as TWP_Mobile_API::register_fcm_token)
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
// Update existing session or insert new one
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT id FROM $table WHERE user_id = %d AND fcm_token = %s AND is_active = 1",
$user_id, $fcm_token
));
if ($existing) {
// Refresh the expiry on existing session
$wpdb->update($table,
array('expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS)),
array('id' => $existing->id),
array('%s'),
array('%d')
);
} else {
$wpdb->insert($table, array(
'user_id' => $user_id,
'refresh_token' => 'webview-' . wp_generate_password(32, false),
'fcm_token' => $fcm_token,
'device_info' => 'WebView Mobile App',
'is_active' => 1,
'created_at' => current_time('mysql'),
'expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS),
));
if ($wpdb->last_error) {
error_log('TWP FCM: Failed to insert token: ' . $wpdb->last_error);
wp_send_json_error('Failed to store token');
}
}
error_log("TWP FCM: Token registered for user $user_id");
wp_send_json_success('FCM token registered');
}
/**
* Register custom rewrite rule.
*/
public function register_rewrite() {
add_rewrite_rule(
'^' . self::ENDPOINT . '/?$',
'index.php?twp_phone_page=1',
'top'
);
}
/**
* Expose query variable.
*
* @param array $vars Existing query vars.
* @return array
*/
public function add_query_var($vars) {
$vars[] = 'twp_phone_page';
return $vars;
}
/**
* Handle the request on template_redirect.
*/
public function handle_request() {
if (!get_query_var('twp_phone_page')) {
return;
}
// Authentication check — redirect to login if not authenticated.
if (!is_user_logged_in()) {
$redirect_url = home_url('/' . self::ENDPOINT . '/');
wp_redirect(wp_login_url($redirect_url));
exit;
}
// Capability check.
if (!current_user_can('twp_access_browser_phone')) {
wp_die(
'You do not have permission to access the browser phone.',
'Access Denied',
array('response' => 403)
);
}
// Render the standalone page and exit.
$this->render_page();
exit;
}
/**
* Extend auth cookie to 7 days for phone agents.
*
* @param int $expiration Default expiration in seconds.
* @param int $user_id User ID.
* @param bool $remember Whether "Remember Me" was checked.
* @return int
*/
public function extend_agent_cookie($expiration, $user_id, $remember) {
$user = get_userdata($user_id);
if ($user && $user->has_cap('twp_access_browser_phone')) {
return 7 * DAY_IN_SECONDS;
}
return $expiration;
}
// ------------------------------------------------------------------
// Rendering
// ------------------------------------------------------------------
/**
* Output the complete standalone HTML page.
*/
private function render_page() {
// Gather data needed by the template (same as display_browser_phone_page).
$current_user_id = get_current_user_id();
global $wpdb;
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$extension_data = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$current_user_id
));
if (!$extension_data) {
TWP_User_Queue_Manager::create_user_queues($current_user_id);
$extension_data = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$current_user_id
));
}
$agent_status = TWP_Agent_Manager::get_agent_status($current_user_id);
$agent_stats = TWP_Agent_Manager::get_agent_stats($current_user_id);
$is_logged_in = TWP_Agent_Manager::is_agent_logged_in($current_user_id);
$current_mode = get_user_meta($current_user_id, 'twp_call_mode', true);
if (empty($current_mode)) {
$current_mode = 'cell';
}
$user_phone = get_user_meta($current_user_id, 'twp_phone_number', true);
// Smart routing check (for admin-only setup notice).
$smart_routing_configured = false;
try {
$twilio = new TWP_Twilio_API();
$phone_numbers = $twilio->get_phone_numbers();
if ($phone_numbers['success']) {
$smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
if (isset($number['voice_url']) && strpos($number['voice_url'], 'smart-routing') !== false) {
$smart_routing_configured = true;
break;
}
}
}
} catch (Exception $e) {
// Silently continue.
}
// Nonce for AJAX.
$nonce = wp_create_nonce('twp_ajax_nonce');
// URLs.
$ajax_url = admin_url('admin-ajax.php');
$ringtone_url = plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__));
$phone_icon_url = plugins_url('assets/images/phone-icon.png', dirname(__FILE__));
$sw_url = plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__));
$twilio_edge = esc_js(get_option('twp_twilio_edge', 'roaming'));
$smart_routing_webhook = home_url('/wp-json/twilio-webhook/v1/smart-routing');
// Plugin file reference for plugins_url() in template.
$plugin_file = dirname(__FILE__) . '/../twilio-wp-plugin.php';
// Load the template (all variables above are in scope).
require TWP_PLUGIN_DIR . 'assets/mobile/phone-template.php';
}
}

View File

@@ -26,14 +26,32 @@ class TWP_Mobile_SSE {
'callback' => array($this, 'stream_events'),
'permission_callback' => array($this->auth, 'verify_token')
));
register_rest_route('twilio-mobile/v1', '/stream/poll', array(
'methods' => 'GET',
'callback' => array($this, 'poll_state'),
'permission_callback' => array($this->auth, 'verify_token')
));
});
}
/**
* Return current state as JSON (polling alternative to SSE)
*/
public function poll_state($request) {
$user_id = $this->auth->get_current_user_id();
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
}
return rest_ensure_response($this->get_current_state($user_id));
}
/**
* Stream events to mobile app
*/
public function stream_events($request) {
error_log('TWP SSE: stream_events called');
$user_id = $this->auth->get_current_user_id();
error_log('TWP SSE: user_id=' . ($user_id ?: 'false'));
if (!$user_id) {
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
@@ -56,6 +74,15 @@ class TWP_Mobile_SSE {
ob_end_flush();
}
// Flush padding to overcome Apache/HTTP2 frame buffering.
// SSE comments (lines starting with ':') are ignored by clients.
// We send >4KB to ensure the first HTTP/2 DATA frame is flushed.
echo ':' . str_repeat(' ', 4096) . "\n\n";
if (ob_get_level() > 0) ob_flush();
flush();
error_log('TWP SSE: padding flushed, sending connected event');
// Send initial connection event
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
@@ -142,38 +169,40 @@ class TWP_Mobile_SSE {
global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
$groups_table = $wpdb->prefix . 'twp_group_members';
// Get queue IDs
$queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
// Auto-create personal queues if they don't exist
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id
));
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $queues_table WHERE user_id = %d",
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return array();
if (!$existing_extension) {
TWP_User_Queue_Manager::create_user_queues($user_id);
}
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
$queues = $wpdb->get_results("
SELECT
// Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
SELECT DISTINCT
q.id,
q.queue_name,
COUNT(c.id) as waiting_count,
MIN(c.enqueued_at) as oldest_call_time
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'
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
");
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();
foreach ($queues as $queue) {

View File

@@ -329,6 +329,12 @@ class TWP_Webhooks {
*/
public function handle_browser_voice($request) {
$params = $request->get_params();
error_log('TWP browser-voice webhook params: ' . json_encode(array(
'From' => $params['From'] ?? '',
'To' => $params['To'] ?? '',
'CallerId' => $params['CallerId'] ?? '',
'CallSid' => $params['CallSid'] ?? '',
)));
$call_data = array(
'CallSid' => isset($params['CallSid']) ? $params['CallSid'] : '',
@@ -371,14 +377,42 @@ class TWP_Webhooks {
if (isset($params['To']) && !empty($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 (strpos($to_number, 'client:') !== 0) {
$twiml .= '<Dial timeout="30"';
// Add caller ID if provided
if (!empty($from_number) && strpos($from_number, 'client:') !== 0) {
// Add caller ID (required for outbound calls to phone numbers)
if (!empty($from_number)) {
$twiml .= ' callerId="' . htmlspecialchars($from_number) . '"';
}
@@ -397,6 +431,8 @@ class TWP_Webhooks {
$twiml .= '</Response>';
error_log('TWP browser-voice TwiML: ' . $twiml);
return $this->send_twiml_response($twiml);
}
@@ -477,6 +513,13 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed browser call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'Browser Phone', '');
}
}
/**
@@ -704,6 +747,13 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
}
}
// Send FCM push notifications for missed call
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($agents as $agent) {
$fcm->notify_incoming_call($agent->ID, $customer_number, 'General', '');
}
}
/**
@@ -894,13 +944,38 @@ class TWP_Webhooks {
// Update call status in queue if applicable
// Remove from queue for any terminal call state
if (in_array($status_data['CallStatus'], ['completed', 'busy', 'failed', 'canceled', 'no-answer'])) {
// Get queue_id before removing so we can send cancel notifications
global $wpdb;
$calls_table = $wpdb->prefix . 'twp_queued_calls';
$queued_call = $wpdb->get_row($wpdb->prepare(
"SELECT queue_id FROM $calls_table WHERE call_sid = %s",
$status_data['CallSid']
));
$queue_removed = TWP_Call_Queue::remove_from_queue($status_data['CallSid']);
if ($queue_removed) {
TWP_Call_Logger::log_action($status_data['CallSid'], 'Call removed from queue due to status: ' . $status_data['CallStatus']);
error_log('TWP Status Webhook: Removed call ' . $status_data['CallSid'] . ' from queue (status: ' . $status_data['CallStatus'] . ')');
// Cancel queue alert notifications on agents' devices
if ($queued_call) {
require_once plugin_dir_path(__FILE__) . 'class-twp-fcm.php';
$fcm = new TWP_FCM();
$fcm->cancel_queue_alert_for_queue($queued_call->queue_id, $status_data['CallSid']);
}
}
// Set auto_busy_at for agents whose call just ended, so they revert after 30s
$agent_status_table = $wpdb->prefix . 'twp_agent_status';
$wpdb->query($wpdb->prepare(
"UPDATE $agent_status_table
SET auto_busy_at = %s, current_call_sid = NULL
WHERE current_call_sid = %s AND status = 'busy'",
current_time('mysql'),
$status_data['CallSid']
));
}
// Empty response
return new WP_REST_Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', 200, array(
'Content-Type' => 'text/xml; charset=utf-8'
@@ -1201,6 +1276,20 @@ class TWP_Webhooks {
if ($updated) {
error_log('TWP Queue Action: Updated call status to ' . $status);
// Cancel FCM queue alerts when call leaves the queue for any reason
if (in_array($status, array('answered', 'hangup', 'transferred', 'timeout', 'completed'))) {
$queued_call = $wpdb->get_row($wpdb->prepare(
"SELECT queue_id FROM $table_name WHERE call_sid = %s",
$call_sid
));
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, $call_sid);
error_log('TWP Queue Action: Sent FCM cancel for call ' . $call_sid);
}
}
} else {
error_log('TWP Queue Action: No call found to update with SID ' . $call_sid);
}

45
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
mobile/.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: web
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

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

@@ -0,0 +1,7 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_declarations: true
avoid_print: true

View File

@@ -0,0 +1,49 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services"
}
android {
namespace = "io.cloudhosting.twp.twp_softphone"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
defaultConfig {
applicationId = "io.cloudhosting.twp.twp_softphone"
minSdkVersion 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled true
}
buildTypes {
release {
signingConfig = signingConfigs.debug
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation platform('com.google.firebase:firebase-bom:33.0.0')
implementation 'com.google.firebase:firebase-messaging'
}

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "187457540438",
"project_id": "twp-softphone",
"storage_bucket": "twp-softphone.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:187457540438:android:d6d777270c23f6660946f7",
"android_client_info": {
"package_name": "io.cloudhosting.twp.twp_softphone"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAdGJrWPy9b9arqHnlY5G_hsGiDcm6cchA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

14
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,14 @@
# Twilio Voice SDK
-keep class com.twilio.** { *; }
-keep class tvo.webrtc.** { *; }
# Firebase
-keep class com.google.firebase.** { *; }
# Flutter
-keep class io.flutter.** { *; }
# Play Core (not used but referenced by Flutter engine)
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.**
-dontwarn com.google.android.play.core.tasks.**

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,64 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Audio permissions for WebRTC -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- Full screen intent for incoming calls on lock screen -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Push notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="Twilio-WP"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:showOnLockScreen="true"
android:turnScreenOn="true">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- FCM click action -->
<intent-filter>
<action android:name="FLUTTER_NOTIFICATION_CLICK"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package io.cloudhosting.twp.twp_softphone
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@@ -0,0 +1,49 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_messaging, io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "com.google.gms.google-services" version "4.4.0" apply false
}
include ":app"

87
mobile/lib/app.dart Normal file
View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'screens/login_screen.dart';
import 'screens/phone_screen.dart';
class TwpSoftphoneApp extends StatefulWidget {
const TwpSoftphoneApp({super.key});
@override
State<TwpSoftphoneApp> createState() => _TwpSoftphoneAppState();
}
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
static const _storage = FlutterSecureStorage();
String? _serverUrl;
bool _loading = true;
@override
void initState() {
super.initState();
_checkSavedSession();
}
Future<void> _checkSavedSession() async {
final url = await _storage.read(key: 'server_url');
if (mounted) {
setState(() {
_serverUrl = url;
_loading = false;
});
}
}
void _onLoginSuccess(String serverUrl) {
setState(() {
_serverUrl = serverUrl;
});
}
void _onLogout() async {
await _storage.delete(key: 'server_url');
if (mounted) {
setState(() {
_serverUrl = null;
});
}
}
void _onSessionExpired() {
// Server URL is still saved, but session cookie is gone.
// Show login screen but keep the server URL pre-filled.
if (mounted) {
setState(() {
_serverUrl = null;
});
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TWP Softphone',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.dark,
),
home: _loading
? const Scaffold(
body: Center(child: CircularProgressIndicator()),
)
: _serverUrl != null
? PhoneScreen(
serverUrl: _serverUrl!,
onLogout: _onLogout,
onSessionExpired: _onSessionExpired,
)
: LoginScreen(onLoginSuccess: _onLoginSuccess),
);
}
}

9
mobile/lib/main.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(const TwpSoftphoneApp());
}

View File

@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Login screen that loads wp-login.php in a WebView.
///
/// When the user successfully logs in, WordPress redirects to /twp-phone/.
/// We detect that URL change and report login success to the parent.
class LoginScreen extends StatefulWidget {
final void Function(String serverUrl) onLoginSuccess;
const LoginScreen({super.key, required this.onLoginSuccess});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
static const _storage = FlutterSecureStorage();
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
bool _showWebView = false;
bool _webViewLoading = true;
String? _error;
late WebViewController _webViewController;
@override
void initState() {
super.initState();
_loadSavedServer();
}
Future<void> _loadSavedServer() async {
final saved = await _storage.read(key: 'server_url');
if (saved != null && mounted) {
_serverController.text = saved;
}
}
void _startLogin() {
if (!_formKey.currentState!.validate()) return;
var serverUrl = _serverController.text.trim();
if (!serverUrl.startsWith('http')) {
serverUrl = 'https://$serverUrl';
}
// Remove trailing slash
serverUrl = serverUrl.replaceAll(RegExp(r'/+$'), '');
setState(() {
_showWebView = true;
_webViewLoading = true;
_error = null;
});
final loginUrl =
'$serverUrl/wp-login.php?redirect_to=${Uri.encodeComponent('$serverUrl/twp-phone/')}';
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
// Check if we've been redirected to the phone page (login success)
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
_onLoginComplete(serverUrl);
}
},
onPageFinished: (url) {
if (mounted) {
setState(() => _webViewLoading = false);
}
// Also check on page finish in case redirect was instant
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
_onLoginComplete(serverUrl);
}
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_showWebView = false;
_error =
'Could not connect to server: ${error.description}';
});
}
},
),
)
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
..loadRequest(Uri.parse(loginUrl));
}
Future<void> _onLoginComplete(String serverUrl) async {
// Save server URL for next launch
await _storage.write(key: 'server_url', value: serverUrl);
if (mounted) {
widget.onLoginSuccess(serverUrl);
}
}
void _cancelLogin() {
setState(() {
_showWebView = false;
_error = null;
});
}
@override
Widget build(BuildContext context) {
if (_showWebView) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _cancelLogin,
),
title: const Text('Sign In'),
),
body: Stack(
children: [
WebViewWidget(controller: _webViewController),
if (_webViewLoading)
const Center(child: CircularProgressIndicator()),
],
),
);
}
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.phone_in_talk,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'TWP Softphone',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-site.com',
prefixIcon: Icon(Icons.dns),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null,
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: _startLogin,
child: const Text('Connect'),
),
),
],
),
),
),
),
),
);
}
@override
void dispose() {
_serverController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import '../services/push_notification_service.dart';
/// Full-screen WebView that loads the TWP phone page.
///
/// Handles:
/// - Microphone permission grants for WebRTC
/// - JavaScript bridge (TwpMobile channel) for native communication
/// - Session expiry detection (redirect to wp-login.php)
/// - Back button confirmation to prevent accidental exit
/// - Network error retry UI
class PhoneScreen extends StatefulWidget {
final String serverUrl;
final VoidCallback onLogout;
final VoidCallback onSessionExpired;
const PhoneScreen({
super.key,
required this.serverUrl,
required this.onLogout,
required this.onSessionExpired,
});
@override
State<PhoneScreen> createState() => _PhoneScreenState();
}
class _PhoneScreenState extends State<PhoneScreen> with WidgetsBindingObserver {
late final WebViewController _controller;
late final PushNotificationService _pushService;
bool _loading = true;
bool _hasError = false;
String? _errorMessage;
bool _sessionExpired = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pushService = PushNotificationService();
_requestPermissionsAndInit();
}
Future<void> _requestPermissionsAndInit() async {
// Request microphone permission before initializing WebView
final micStatus = await Permission.microphone.request();
if (micStatus.isDenied || micStatus.isPermanentlyDenied) {
debugPrint('TWP: Microphone permission denied: $micStatus');
}
_initWebView();
_initPush();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<void> _initPush() async {
await _pushService.initialize();
}
void _initWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
if (mounted) {
setState(() {
_loading = true;
_hasError = false;
});
}
// Detect session expiry: if we get redirected to wp-login.php
if (url.contains('/wp-login.php')) {
_sessionExpired = true;
}
},
onPageFinished: (url) {
if (mounted) {
setState(() => _loading = false);
}
if (_sessionExpired && url.contains('/wp-login.php')) {
widget.onSessionExpired();
return;
}
_sessionExpired = false;
// Inject the FCM token into the page if available
_injectFcmToken();
},
onWebResourceError: (error) {
// Only handle main frame errors
if (error.isForMainFrame ?? true) {
if (mounted) {
setState(() {
_loading = false;
_hasError = true;
_errorMessage = error.description;
});
}
}
},
onNavigationRequest: (request) {
// Allow all navigation within our server
if (request.url.startsWith(widget.serverUrl)) {
return NavigationDecision.navigate;
}
// Allow blob: and data: URLs (for downloads, etc.)
if (request.url.startsWith('blob:') ||
request.url.startsWith('data:')) {
return NavigationDecision.navigate;
}
// Block external navigation
return NavigationDecision.prevent;
},
),
)
..addJavaScriptChannel(
'TwpMobile',
onMessageReceived: _handleJsMessage,
);
// Configure Android-specific settings
final androidController =
_controller.platform as AndroidWebViewController;
// Auto-grant microphone permission for WebRTC calls
androidController.setOnPlatformPermissionRequest(
(PlatformWebViewPermissionRequest request) {
request.grant();
},
);
// Allow media playback without user gesture (for ringtones)
androidController.setMediaPlaybackRequiresUserGesture(false);
// Load the phone page
final phoneUrl = '${widget.serverUrl}/twp-phone/';
_controller.loadRequest(Uri.parse(phoneUrl));
}
void _handleJsMessage(JavaScriptMessage message) {
final msg = message.message;
if (msg == 'onSessionExpired') {
widget.onSessionExpired();
} else if (msg == 'requestFcmToken') {
_injectFcmToken();
} else if (msg == 'onPageReady') {
// Phone page loaded successfully
_injectFcmToken();
}
}
Future<void> _injectFcmToken() async {
final token = _pushService.fcmToken;
if (token != null) {
// Send the FCM token to the web page via the TwpMobile bridge
await _controller.runJavaScript(
'if (window.TwpMobile && window.TwpMobile.setFcmToken) { window.TwpMobile.setFcmToken("$token"); }',
);
}
}
Future<void> _retry() async {
setState(() {
_hasError = false;
_loading = true;
});
final phoneUrl = '${widget.serverUrl}/twp-phone/';
await _controller.loadRequest(Uri.parse(phoneUrl));
}
Future<bool> _onWillPop() async {
// Check if WebView can go back
if (await _controller.canGoBack()) {
await _controller.goBack();
return false;
}
// Show confirmation dialog
if (!mounted) return true;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit'),
content: const Text('Are you sure you want to exit the phone?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Exit'),
),
],
),
);
return result ?? false;
}
void _confirmLogout() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text(
'This will clear your session. You will need to sign in again.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (result == true) {
// Clear WebView cookies
await WebViewCookieManager().clearCookies();
widget.onLogout();
}
}
@override
Widget build(BuildContext context) {
// ignore: deprecated_member_use
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
appBar: _hasError
? null
: AppBar(
toolbarHeight: 40,
titleSpacing: 12,
title: Text(
'TWP Softphone',
style: Theme.of(context).textTheme.titleSmall,
),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Reload',
visualDensity: VisualDensity.compact,
onPressed: () => _controller.reload(),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20),
tooltip: 'Menu',
padding: EdgeInsets.zero,
onSelected: (value) {
if (value == 'logout') _confirmLogout();
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'logout',
child: ListTile(
leading: Icon(Icons.logout),
title: Text('Logout'),
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
body: SafeArea(
top: _hasError, // AppBar already handles top safe area when visible
child: Stack(
children: [
if (!_hasError) WebViewWidget(controller: _controller),
if (_hasError) _buildErrorView(),
if (_loading && !_hasError)
const Center(child: CircularProgressIndicator()),
],
),
),
),
);
}
Widget _buildErrorView() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
'Connection Error',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Could not load the phone page.',
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onLogout,
child: const Text('Change Server'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,143 @@
import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
/// Notification ID for queue alerts (fixed so we can cancel it).
const int _queueAlertNotificationId = 9001;
/// Background handler -- must be top-level function.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
final data = message.data;
final type = data['type'];
if (type == 'queue_alert') {
await _showQueueAlertNotification(data);
} else if (type == 'queue_alert_cancel') {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.cancel(_queueAlertNotificationId);
}
}
/// 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),
);
}
/// Push notification service for queue alerts and general notifications.
///
/// FCM token registration is handled via the WebView JavaScript bridge
/// instead of a REST API call. The token is exposed via [fcmToken] and
/// injected into the web page by [PhoneScreen].
class PushNotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
String? _fcmToken;
String? get fcmToken => _fcmToken;
Future<void> initialize() async {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
criticalAlert: true,
);
// Initialize local notifications
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidSettings);
await _localNotifications.initialize(initSettings);
// Get FCM token
final token = await _messaging.getToken();
debugPrint(
'FCM token: ${token != null ? "${token.substring(0, 20)}..." : "NULL"}');
if (token != null) {
_fcmToken = token;
} else {
debugPrint(
'FCM: Failed to get token - Firebase may not be configured correctly');
}
// Listen for token refresh
_messaging.onTokenRefresh.listen((token) {
_fcmToken = token;
});
// Handle foreground messages
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
}
void _handleForegroundMessage(RemoteMessage message) {
final data = message.data;
final type = data['type'];
// Queue alert -- show insistent notification
if (type == 'queue_alert') {
_showQueueAlertNotification(data);
return;
}
// Queue alert cancel -- dismiss notification
if (type == 'queue_alert_cancel') {
_localNotifications.cancel(_queueAlertNotificationId);
return;
}
// Show local notification for other types (missed call, etc.)
_localNotifications.show(
message.hashCode,
data['title'] ?? 'TWP Softphone',
data['body'] ?? '',
const NotificationDetails(
android: AndroidNotificationDetails(
'twp_general',
'General Notifications',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
/// Cancel any active queue alert.
void cancelQueueAlert() {
_localNotifications.cancel(_queueAlertNotificationId);
}
}

554
mobile/pubspec.lock Normal file
View File

@@ -0,0 +1,554 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
url: "https://pub.dev"
source: hosted
version: "1.3.59"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.dev"
source: hosted
version: "1.1.1"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.19.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
ffi:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
url: "https://pub.dev"
source: hosted
version: "3.15.2"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
url: "https://pub.dev"
source: hosted
version: "6.0.2"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
url: "https://pub.dev"
source: hosted
version: "2.24.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
url: "https://pub.dev"
source: hosted
version: "15.2.10"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
url: "https://pub.dev"
source: hosted
version: "4.6.10"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
url: "https://pub.dev"
source: hosted
version: "3.10.10"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
url: "https://pub.dev"
source: hosted
version: "4.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.dev"
source: hosted
version: "10.0.7"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.dev"
source: hosted
version: "3.0.8"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.15.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
url: "https://pub.dev"
source: hosted
version: "2.2.17"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.dev"
source: hosted
version: "14.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
url: "https://pub.dev"
source: hosted
version: "4.13.0"
webview_flutter_android:
dependency: "direct main"
description:
name: webview_flutter_android
sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3"
url: "https://pub.dev"
source: hosted
version: "4.10.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.dev"
source: hosted
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f
url: "https://pub.dev"
source: hosted
version: "3.23.0"
win32:
dependency: transitive
description:
name: win32
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
url: "https://pub.dev"
source: hosted
version: "5.10.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.0"

26
mobile/pubspec.yaml Normal file
View File

@@ -0,0 +1,26 @@
name: twp_softphone
description: TWP Softphone - WebView client for Twilio WordPress Plugin
publish_to: 'none'
version: 2.0.1+7
environment:
sdk: ^3.5.0
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
flutter_secure_storage: ^10.0.0
flutter_local_notifications: ^17.0.0
webview_flutter: ^4.10.0
webview_flutter_android: ^4.3.0
permission_handler: ^11.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

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

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twp_softphone/app.dart';
import 'package:twp_softphone/screens/login_screen.dart';
void main() {
group('TwpSoftphoneApp', () {
testWidgets('shows loading indicator on startup', (tester) async {
await tester.pumpWidget(const TwpSoftphoneApp());
expect(find.byType(TwpSoftphoneApp), findsOneWidget);
expect(find.bySubtype<CircularProgressIndicator>(), findsOneWidget);
});
});
group('LoginScreen', () {
testWidgets('renders server URL field and connect button', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: LoginScreen(onLoginSuccess: (_) {}),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Server URL'), findsOneWidget);
expect(find.text('Connect'), findsOneWidget);
expect(find.text('TWP Softphone'), findsOneWidget);
});
testWidgets('validates empty server URL on submit', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: LoginScreen(onLoginSuccess: (_) {}),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.text('Required'), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder test', () {
expect(1 + 1, 2);
});
}

113
test-deploy.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Test harness for TWP WebView Softphone deployment
# Run after deploying PHP files and flushing rewrite rules
SERVER="https://phone.cloud-hosting.io"
PASS=0
FAIL=0
check() {
local desc="$1"
local result="$2"
if [ "$result" = "0" ]; then
echo " PASS: $desc"
PASS=$((PASS + 1))
else
echo " FAIL: $desc"
FAIL=$((FAIL + 1))
fi
}
echo "=== TWP WebView Softphone - Deployment Test Harness ==="
echo ""
# 1. Test standalone phone page exists (should redirect to login for unauthenticated)
echo "[1] Standalone Phone Page (/twp-phone/)"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" -L --max-redirs 0 "$SERVER/twp-phone/" 2>/dev/null)
HTTP_CODE=$(echo "$RESPONSE" | cut -d: -f1)
REDIRECT=$(echo "$RESPONSE" | cut -d: -f2-)
# Should redirect (302) to wp-login.php for unauthenticated users
if [ "$HTTP_CODE" = "302" ] && echo "$REDIRECT" | grep -q "wp-login"; then
check "Unauthenticated redirect to wp-login.php" 0
else
check "Unauthenticated redirect to wp-login.php (got $HTTP_CODE, redirect: $REDIRECT)" 1
fi
# 2. Test that wp-login.php page loads
echo ""
echo "[2] WordPress Login Page"
LOGIN_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$SERVER/wp-login.php" 2>/dev/null)
check "wp-login.php returns 200" "$([ "$LOGIN_RESPONSE" = "200" ] && echo 0 || echo 1)"
# 3. Test authenticated access (login and get cookies, then access /twp-phone/)
echo ""
echo "[3] Authenticated Access"
# Try to log in and get session cookies
COOKIE_JAR="/tmp/twp-test-cookies.txt"
rm -f "$COOKIE_JAR"
# Login - use the test credentials if available
LOGIN_RESULT=$(curl -s -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" \
-d "log=admin&pwd=admin&rememberme=forever&redirect_to=$SERVER/twp-phone/&wp-submit=Log+In" \
"$SERVER/wp-login.php" 2>/dev/null)
if [ "$LOGIN_RESULT" = "302" ]; then
# Follow redirect to /twp-phone/
PAGE_RESULT=$(curl -s -b "$COOKIE_JAR" -w "%{http_code}" -o /tmp/twp-phone-page.html "$SERVER/twp-phone/" 2>/dev/null)
check "Authenticated /twp-phone/ returns 200" "$([ "$PAGE_RESULT" = "200" ] && echo 0 || echo 1)"
if [ "$PAGE_RESULT" = "200" ]; then
# Check page content
check "Page contains Twilio SDK" "$(grep -q 'twilio.min.js' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains dialpad" "$(grep -q 'dialpad' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains ajaxurl" "$(grep -q 'ajaxurl' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains TwpMobile bridge" "$(grep -q 'TwpMobile' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains twpNonce" "$(grep -q 'twpNonce' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page has mobile viewport" "$(grep -q 'viewport-fit=cover' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page has dark mode CSS" "$(grep -q 'prefers-color-scheme' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "No WP admin bar" "$(grep -q 'wp-admin-bar' /tmp/twp-phone-page.html && echo 1 || echo 0)"
check "Page contains phone-number-input" "$(grep -q 'phone-number-input' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains caller-id-select" "$(grep -q 'caller-id-select' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains hold/transfer buttons" "$(grep -q 'hold-btn' /tmp/twp-phone-page.html && echo 0 || echo 1)"
check "Page contains queue tab" "$(grep -q 'queue' /tmp/twp-phone-page.html && echo 0 || echo 1)"
fi
else
echo " SKIP: Could not log in (HTTP $LOGIN_RESULT) - manual auth testing required"
fi
# 4. Test AJAX endpoint availability
echo ""
echo "[4] AJAX Endpoints"
if [ -f "$COOKIE_JAR" ] && [ "$LOGIN_RESULT" = "302" ]; then
# Test that admin-ajax.php is accessible with cookies
AJAX_RESULT=$(curl -s -b "$COOKIE_JAR" -o /dev/null -w "%{http_code}" \
-d "action=twp_generate_capability_token&nonce=test" \
"$SERVER/wp-admin/admin-ajax.php" 2>/dev/null)
# Should return 200 (even if nonce fails, it means AJAX is working)
check "admin-ajax.php accessible" "$([ "$AJAX_RESULT" = "200" ] || [ "$AJAX_RESULT" = "400" ] || [ "$AJAX_RESULT" = "403" ] && echo 0 || echo 1)"
fi
# 5. Test 7-day cookie expiration
echo ""
echo "[5] Session Cookie"
if [ -f "$COOKIE_JAR" ]; then
# Check if cookies have extended expiry
COOKIE_EXISTS=$(grep -c "wordpress_logged_in" "$COOKIE_JAR" 2>/dev/null)
check "Login cookies set" "$([ "$COOKIE_EXISTS" -gt 0 ] && echo 0 || echo 1)"
fi
# Cleanup
rm -f "$COOKIE_JAR" /tmp/twp-phone-page.html
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
echo ""
if [ "$FAIL" -gt 0 ]; then
echo "Some tests failed. Review output above."
exit 1
else
echo "All tests passed!"
exit 0
fi