Compare commits

...

14 Commits

Author SHA1 Message Date
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
f8919af31a Fix SDK autoloader path for Twilio namespace
All checks were successful
Create Release / build (push) Successful in 3s
The SDK files are at twilio/sdk/Twilio/Rest/Client.php but the
autoloader was looking at twilio/sdk/Rest/Client.php. Fixed by
using the full class name in the path instead of stripping the
Twilio\ prefix.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:32:19 -08:00
3e4dff5c4e Add SDK persistence and configurable edge location
All checks were successful
Create Release / build (push) Successful in 4s
- Add external SDK installation (wp-content/twilio-sdk/) that survives
  WordPress plugin updates
- Add install-twilio-sdk-external.sh script for external SDK setup
- Update SDK loading to check external location first, internal fallback
- Add post-update detection hook to warn if SDK was deleted
- Add configurable Twilio Edge Location setting (default: roaming)
- Fix US calls failing due to hardcoded Sydney edge location

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:17:25 -08:00
f0806d7e67 Add comprehensive Android tablet debugging guide
All checks were successful
Create Release / build (push) Successful in 4s
Added detailed debugging documentation for troubleshooting browser phone
issues on Android tablets. Covers USB debugging setup, Chrome DevTools
connection, common issues, error codes, and testing procedures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 08:37:26 -08:00
61beadcd06 Fix browser phone connection and audio issues on Android tablets
All checks were successful
Create Release / build (push) Successful in 6s
Resolves issues where browser phone PWA failed to connect and calls would
immediately hang up when answered on Android tablets. Adds proper mobile
audio handling, device connection monitoring, and PWA notifications for
incoming calls.

Key changes:
- Add AudioContext initialization with mobile unlock for autoplay support
- Add Android-specific WebRTC constraints (echo cancellation, ICE restart)
- Add device connection state monitoring and auto-reconnection
- Add incoming call ringtone with vibration fallback
- Add PWA service worker notifications for background calls
- Add Page Visibility API for background call detection
- Improve call answer handler with connection state validation
- Add touch event support for mobile dialpad

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-12 13:21:29 -08:00
62 changed files with 4896 additions and 172 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

@@ -6,6 +6,25 @@
- **URL**: `https://phone.cloud-hosting.io/` - **URL**: `https://phone.cloud-hosting.io/`
- **Deployment**: rsync to Docker (remote server only, not local) - **Deployment**: rsync to Docker (remote server only, not local)
- **SDK**: Twilio PHP SDK v8.7.0 - **SDK**: Twilio PHP SDK v8.7.0
- **PHP**: 8.0+ required
- **Optional**: AWS SDK (`aws/aws-sdk-php`) for SNS SMS provider
- **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates)
## Commands
- **Install SDK (recommended)**: `./install-twilio-sdk-external.sh` (installs to `wp-content/twilio-sdk/`)
- **Install SDK (internal)**: `./install-twilio-sdk.sh` (installs to `vendor/`, lost on plugin update)
- **Test SDK**: `php test-sdk.php`
- **Composer install SDK**: `composer install-sdk`
- **Deploy**: rsync to Docker (remote server, see production path above)
- **CI/CD**: Gitea workflows in `.gitea/workflows/``release.yml`, `update-version.yml`
## Directory Structure
- `twilio-wp-plugin.php` — Main plugin file, constants, SDK loading
- `includes/` — All backend classes (28 class files)
- `admin/` — Admin UI class (`TWP_Admin`), mobile app settings page
- `assets/js/` — Browser phone JS, service worker
- `assets/images/`, `assets/sounds/` — Static assets
- `.gitea/workflows/` — CI/CD (release, version update)
## Phone Variable Names ## Phone Variable Names
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number` **Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
@@ -17,11 +36,20 @@
- **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control - **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control
- **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache - **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999) - **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1` - **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing - **TWP_Activator**: Creates 16 DB tables, run `ensure_tables_exist()` if missing
- **TWP_Core**: Main plugin orchestrator, hooks all classes together
- **TWP_SMS_Manager**: SMS abstraction with provider interface
- **TWP_SMS_Provider_Twilio** / **TWP_SMS_Provider_SNS**: SMS providers (Twilio default, AWS SNS optional)
- **TWP_Mobile_API**: REST API for mobile app
- **TWP_Mobile_Auth** / **TWP_Mobile_SSE** / **TWP_FCM**: Mobile auth, server-sent events, push notifications
- **TWP_Call_Queue**: Queue operations and management
- **TWP_Callback_Manager**: Callback request handling
- **TWP_Workflow**: Workflow step execution engine
- **TWP_Auto_Updater**: Plugin auto-update from Gitea releases
## Database ## Database
15 tables with `twp_` prefix. Key notes: 16 tables with `twp_` prefix. Key notes:
- `twp_call_queues`: User queues (general/personal/hold) - `twp_call_queues`: User queues (general/personal/hold)
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert - `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
- `twp_queued_calls`: Uses `enqueued_at` not `joined_at` - `twp_queued_calls`: Uses `enqueued_at` not `joined_at`
@@ -40,13 +68,6 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- Queue: Pass `waitUrl` as option in `enqueue()` - Queue: Pass `waitUrl` as option in `enqueue()`
- TwiML: Use SDK classes, not raw XML - TwiML: Use SDK classes, not raw XML
## Recent Changes (v2.3.0)
- Browser phone moved to admin-only
- Call control uses `find_customer_call_leg()` to prevent disconnections
- Auto-creates user queues/extensions when needed
- Firefox support added
- 1-min agent status auto-revert
## Development Notes ## Development Notes
- **API**: E.164 format (+1XXXXXXXXXX) - **API**: E.164 format (+1XXXXXXXXXX)
- **Database**: Use `$wpdb`, prepared statements - **Database**: Use `$wpdb`, prepared statements
@@ -59,7 +80,24 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
- User-specific queues with extensions - User-specific queues with extensions
- Browser phone at `admin.php?page=twilio-wp-browser-phone` - Browser phone at `admin.php?page=twilio-wp-browser-phone`
- ElevenLabs TTS with Alice fallback - ElevenLabs TTS with Alice fallback
- 68 AJAX actions, 26 REST endpoints - 77 AJAX actions, 35 REST endpoints
- Browser phone moved to admin-only (v2.3.0)
- Firefox, Chrome, Safari, Edge support
- 1-min agent status auto-revert
## 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
## 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)
## Changelog
See `README.md` for detailed version history. Current version: v2.8.9.
--- ---
*Updated: Sept 2025* *Updated: Mar 2026*

427
DEBUGGING-TABLET.md Normal file
View File

@@ -0,0 +1,427 @@
# Debugging Browser Phone on Android Tablet
This guide explains how to debug the Twilio WordPress Plugin browser phone on Android tablets (Samsung and other devices).
## Prerequisites
- Android tablet with Chrome browser
- USB cable to connect tablet to computer
- Computer with Chrome browser installed
- USB debugging enabled on tablet
---
## Part 1: Enable USB Debugging on Android Tablet
### Step 1: Enable Developer Options
1. Open **Settings** on your Android tablet
2. Scroll down to **About tablet** (or **About device**)
3. Find **Build number** (may be under "Software information")
4. Tap **Build number** 7 times rapidly
5. You'll see a message: "You are now a developer!"
### Step 2: Enable USB Debugging
1. Go back to **Settings**
2. Scroll down to **Developer options** (newly appeared)
3. Toggle **Developer options** to ON
4. Find **USB debugging** in the list
5. Toggle **USB debugging** to ON
6. Confirm the prompt if asked
### Step 3: Trust Your Computer
1. Connect tablet to computer via USB cable
2. Unlock your tablet screen
3. A popup will appear: "Allow USB debugging?"
4. Check **Always allow from this computer**
5. Tap **OK** or **Allow**
---
## Part 2: Connect Chrome DevTools
### On Your Computer
1. Open **Chrome browser** on your computer
2. Navigate to: `chrome://inspect`
3. You should see your tablet device listed under "Remote Target"
4. Wait a few seconds for the device to appear
### Inspect the Browser Phone Page
1. On your tablet, open Chrome and navigate to:
```
https://phone.cloud-hosting.io/wp-admin/admin.php?page=twilio-wp-browser-phone
```
2. On your computer's Chrome DevTools (`chrome://inspect`), you'll see the page listed
3. Click **inspect** next to the browser phone page
4. A DevTools window will open showing your tablet's browser
---
## Part 3: Debug Browser Phone Issues
### Check Console Logs
In the **Console** tab, look for key messages:
#### Successful Connection
```
Twilio SDK loaded successfully
Setting up Twilio Device...
Device detection - Android: true, Mobile: true
AudioContext created, state: running
Device registered successfully
Device connection state: connected
```
#### Connection Issues
```
Device not connected, state: disconnected
Failed to register device
Connection error: 31005
```
#### Call Issues
```
Answer button clicked
Device connection state: connected
Accepting call...
Call accepted and connected
```
### Monitor Network Requests
1. Switch to **Network** tab in DevTools
2. Filter by: `twp_` or `twilio`
3. Check for failed requests (red status codes)
4. Look for:
- `twp_generate_capability_token` - Should return 200
- `twp_get_phone_numbers` - Should return 200
- WebSocket connections to Twilio
### Check Device Registration
In the **Console** tab, type:
```javascript
device
```
This shows the current Twilio Device object. Check:
- `device.state` - Should be "registered"
- `device.token` - Should exist (long string)
### Check AudioContext State
In the **Console** tab, type:
```javascript
audioContext
```
Check:
- Should exist (not null)
- `audioContext.state` - Should be "running" (not "suspended")
---
## Part 4: Common Issues & Solutions
### Issue 1: "Device not connected" Error
**Symptoms**: Status shows "Disconnected", calls hang up immediately
**Debugging**:
```javascript
// In console, check:
deviceConnectionState
// Should be: "connected"
```
**Solutions**:
1. Check network connection (try WiFi vs cellular)
2. Refresh the page with cache cleared
3. Check console for token errors
4. Verify Twilio credentials in plugin settings
### Issue 2: Call Hangs Up Immediately When Answered
**Symptoms**: Click "Answer" button, call disconnects instantly
**Debugging**:
```javascript
// Check device state before answering:
deviceConnectionState
// Check for errors in call handler
```
**Look for console errors**:
- `31005` - Connection/network error
- `31201` - ICE connection failure
- `31208` - Media connection error
**Solutions**:
1. Grant microphone permissions (Settings > Site settings > Microphone)
2. Check AudioContext is running: `audioContext.state`
3. Try switching between WiFi and cellular data
4. Clear Chrome cache and reload
### Issue 3: No Sound/Ringtone on Incoming Call
**Symptoms**: Call arrives but no audio plays, no vibration
**Debugging**:
```javascript
// Check audio setup:
ringtoneAudio
audioContext.state
```
**Solutions**:
1. Tap screen to unlock AudioContext (mobile restriction)
2. Check if ringtone file exists (optional, vibration is fallback)
3. Verify device supports Vibration API: `'vibrate' in navigator`
4. Check browser volume settings
### Issue 4: No Browser Notifications
**Symptoms**: No notification when call arrives in background
**Debugging**:
```javascript
// Check notification permission:
Notification.permission
// Should be: "granted"
// Check service worker:
navigator.serviceWorker.controller
// Should exist
```
**Solutions**:
1. Grant notification permission: Settings > Site settings > Notifications
2. Check service worker registration in **Application** tab of DevTools
3. Look for service worker logs in console
4. Reinstall PWA if installed
### Issue 5: Microphone Permission Denied
**Symptoms**: Error message about microphone access
**Solutions**:
1. Chrome Settings > Site settings > Microphone
2. Find `phone.cloud-hosting.io`
3. Change permission to **Allow**
4. Refresh the browser phone page
---
## Part 5: Tablet-Specific Checks
### Check User Agent
In console:
```javascript
navigator.userAgent
```
Should include "Android" and "Chrome"
### Check WebRTC Support
```javascript
// Check getUserMedia support:
navigator.mediaDevices.getUserMedia
// Should be: function
// Check Notification support:
'Notification' in window
// Should be: true
// Check Service Worker support:
'serviceWorker' in navigator
// Should be: true
```
### Check Audio Constraints
Look in console for:
```
Twilio Device created with audio constraints: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
googEchoCancellation: true,
...
}
```
### Test Vibration
In console:
```javascript
navigator.vibrate([300, 200, 300])
```
Tablet should vibrate if supported.
---
## Part 6: Advanced Debugging
### Monitor Twilio Device Events
In console, add event listeners:
```javascript
device.on('registered', () => console.log('Device registered!'));
device.on('error', (error) => console.error('Device error:', error));
device.on('incoming', (call) => console.log('Incoming call:', call));
device.on('unregistered', () => console.log('Device unregistered'));
```
### Check Call State During Active Call
```javascript
// When in a call:
currentCall
currentCall.status()
currentCall.parameters
```
### Test Audio Context Resume
```javascript
// Try to resume manually:
audioContext.resume().then(() => {
console.log('AudioContext state:', audioContext.state);
});
```
### Check Token Expiry
```javascript
// See when token expires:
new Date(tokenExpiry)
```
---
## Part 7: Logging Important Information
### Collect Debugging Info
Run this in console to get a debug report:
```javascript
console.log('=== Browser Phone Debug Report ===');
console.log('User Agent:', navigator.userAgent);
console.log('Device State:', deviceConnectionState);
console.log('Device Registered:', device ? device.state : 'no device');
console.log('AudioContext:', audioContext ? audioContext.state : 'no context');
console.log('Notification Permission:', Notification.permission);
console.log('Service Worker:', navigator.serviceWorker.controller ? 'active' : 'inactive');
console.log('Current Call:', currentCall ? 'in call' : 'no call');
console.log('Token Expiry:', tokenExpiry ? new Date(tokenExpiry) : 'unknown');
```
### Check WordPress AJAX Responses
In **Network** tab:
1. Filter: `admin-ajax.php`
2. Click on request
3. Check **Preview** tab for response
4. Look for `success: true` or error messages
---
## Part 8: Testing Checklist
After fixing issues, test these scenarios:
- [ ] Device shows "Connected" status
- [ ] Can make outbound call successfully
- [ ] Can receive incoming call (see notification)
- [ ] Can answer incoming call without hang-up
- [ ] Ringtone plays or tablet vibrates
- [ ] Call audio is clear (echo cancellation working)
- [ ] Can switch between WiFi and cellular during call
- [ ] Browser notification appears when app in background
- [ ] Service worker logs appear in console
- [ ] No errors in console during call lifecycle
- [ ] Can put call on hold
- [ ] Can transfer call
- [ ] Call timer updates correctly
---
## Troubleshooting Specific Error Codes
### Twilio Error 31005
**Meaning**: WebSocket connection failed
**Causes**:
- Network connectivity issues
- Firewall blocking WebSocket
- Mobile network switching (WiFi ↔ cellular)
**Solutions**:
- Check internet connection
- Try different network
- Wait for ICE restart (enabled in config)
### Twilio Error 31201
**Meaning**: ICE connection failed
**Causes**:
- Restrictive NAT/firewall
- Mobile network issues
**Solutions**:
- Try different network
- Check WebRTC connectivity
- Enable mobile data if on WiFi only
### Twilio Error 31204
**Meaning**: Connection error
**Causes**:
- Media connection setup failed
- Signaling timeout
**Solutions**:
- Refresh page
- Check microphone permissions
- Verify audio constraints applied
### Twilio Error 31208
**Meaning**: Media connection failed
**Causes**:
- Microphone access denied
- Audio device issues
**Solutions**:
- Grant microphone permission
- Check device audio settings
- Restart browser
---
## Additional Resources
- Twilio Voice SDK Errors: https://www.twilio.com/docs/voice/sdks/javascript/errors
- Chrome DevTools Remote Debugging: https://developer.chrome.com/docs/devtools/remote-debugging/
- WebRTC Troubleshooting: https://webrtc.github.io/samples/
- Service Worker Debugging: https://developer.chrome.com/docs/workbox/
---
## Contact & Support
If issues persist after following this guide:
1. Collect debug report (see Part 7)
2. Take screenshots of console errors
3. Note tablet model and Android version
4. Report issue with collected information
**Important Files**:
- Browser phone implementation: `admin/class-twp-admin.php` (lines 7400-8300)
- Service worker: `assets/js/twp-service-worker.js`
- CLAUDE.md: Quick reference guide

View File

@@ -8,11 +8,20 @@ This plugin **requires** the Twilio PHP SDK v8.7.0 to function. The plugin will
## Quick Installation ## Quick Installation
1. **Install the Twilio SDK** (Required): 1. **Install the Twilio SDK** (Required - Recommended Method):
```bash
chmod +x install-twilio-sdk-external.sh
./install-twilio-sdk-external.sh
```
This installs the SDK to `wp-content/twilio-sdk/` which **survives WordPress plugin updates**. The plugin will automatically detect and use this external SDK.
**Alternative Method** (SDK will be deleted during plugin updates):
```bash ```bash
chmod +x install-twilio-sdk.sh chmod +x install-twilio-sdk.sh
./install-twilio-sdk.sh ./install-twilio-sdk.sh
``` ```
This installs the SDK inside the plugin folder. You'll need to reinstall the SDK after each plugin update.
2. **Test the SDK installation**: 2. **Test the SDK installation**:
```bash ```bash
@@ -23,6 +32,7 @@ This plugin **requires** the Twilio PHP SDK v8.7.0 to function. The plugin will
- Go to **Twilio** → **Settings** - Go to **Twilio** → **Settings**
- Enter Account SID and Auth Token - Enter Account SID and Auth Token
- Configure default phone numbers - Configure default phone numbers
- Set Twilio Edge Location (for browser phone - see Browser Phone Setup below)
4. **Set up Phone Numbers** in Twilio Console: 4. **Set up Phone Numbers** in Twilio Console:
- Configure webhook URLs for voice and SMS - Configure webhook URLs for voice and SMS
@@ -333,8 +343,20 @@ Comprehensive redesign of hold, transfer, and requeue functionality with profess
2. **Configure in WordPress**: 2. **Configure in WordPress**:
- Go to **Twilio** → **Settings** - Go to **Twilio** → **Settings**
- Enter TwiML App SID - Enter TwiML App SID
- **Set Twilio Edge Location**: Select the edge location closest to your users (IMPORTANT)
- **Auto-select closest (Recommended)**: Automatically selects the best edge
- **US East (Ashburn)**: For East Coast USA users
- **US West (Umatilla)**: For West Coast USA users
- **Europe - Ireland (Dublin)**: For European users
- **Europe - Germany (Frankfurt)**: For Central European users
- **Singapore**: For Southeast Asian users
- **Sydney**: For Australian users
- **Tokyo**: For Japanese users
- **Sao Paulo**: For South American users
- Save settings - Save settings
**Note**: Selecting the wrong edge location can cause calls to fail immediately. If you're experiencing browser phone connection issues, verify your edge location is appropriate for your region.
3. **Access Browser Phone** (Admin Only): 3. **Access Browser Phone** (Admin Only):
- Navigate to **WordPress Admin** → **Twilio** → **Browser Phone** - Navigate to **WordPress Admin** → **Twilio** → **Browser Phone**
- Select caller ID from available numbers - Select caller ID from available numbers
@@ -432,14 +454,34 @@ Access the full browser phone interface at: **WordPress Admin → Twilio → Bro
- **Login Required**: Users must be logged in to access browser phone functionality - **Login Required**: Users must be logged in to access browser phone functionality
- Check TwiML App SID is configured in WordPress admin settings - Check TwiML App SID is configured in WordPress admin settings
#### Browser Phone Calls Failing Immediately
If browser phone calls disconnect immediately or show HANGUP errors:
- **Check Edge Location Setting**: Go to **Twilio** → **Settings** → **Twilio Edge Location**
- **US Users**: Should use "Auto-select closest" (roaming), "US East (Ashburn)", or "US West (Umatilla)"
- **International Users**: Select the edge location closest to your region
- **Problem**: Wrong edge location causes gateway to immediately reject calls
- **Solution**: Change edge location setting and try the call again (no restart needed)
#### "Twilio SDK classes not available" #### "Twilio SDK classes not available"
**Recommended Solution** (SDK survives plugin updates):
```bash ```bash
# Reinstall SDK # Install SDK to external location
./install-twilio-sdk-external.sh
# Test installation
php test-sdk.php
```
**Alternative Solution** (will need reinstall after plugin updates):
```bash
# Install SDK inside plugin folder
./install-twilio-sdk.sh ./install-twilio-sdk.sh
# Test installation # Test installation
php test-sdk.php php test-sdk.php
``` ```
**After WordPress Plugin Update**: If you get this error after updating the plugin and used the internal SDK method, you'll need to reinstall the SDK. This won't happen if you use the external SDK method.
#### Calls Not Routing to Queues #### Calls Not Routing to Queues
- Verify queue exists and is active - Verify queue exists and is active
- Check agent group has members - Check agent group has members
@@ -551,7 +593,19 @@ All webhooks are REST API endpoints under `/wp-json/twilio-webhook/v1/`:
## Version History ## Version History
### v2.3.0 (Current - September 2025) - ENTERPRISE READY ### v2.8.9 (Current - January 2026) - SDK PERSISTENCE & BROWSER PHONE FIXES
- **SDK PERSISTENCE**: External SDK installation option that survives WordPress plugin updates
- New installation script: `install-twilio-sdk-external.sh` installs SDK to `wp-content/twilio-sdk/`
- Automatic detection: Plugin checks external SDK location first, falls back to internal
- Post-update warnings: Notifies if SDK was deleted during plugin update
- Zero downtime: Phone system continues working through plugin updates
- **BROWSER PHONE FIX**: Resolved US calls failing immediately with HANGUP errors
- Made Twilio Edge Location configurable (was hardcoded to Sydney)
- New setting: Twilio Edge Location with 8 options (roaming/auto-select, ashburn, umatilla, dublin, frankfurt, singapore, sydney, tokyo, sao-paulo)
- Default: "roaming" (auto-select closest edge for optimal performance)
- Critical fix: US users can now make calls successfully (were failing with Sydney edge)
### v2.3.0 (September 2025) - ENTERPRISE READY
- **SECURITY ENHANCEMENT**: Removed frontend browser phone interface, moved to admin-only access for enhanced security - **SECURITY ENHANCEMENT**: Removed frontend browser phone interface, moved to admin-only access for enhanced security
- **ASSET REDUCTION**: Eliminated 108KB of frontend assets (browser-phone-frontend.js and CSS files) - **ASSET REDUCTION**: Eliminated 108KB of frontend assets (browser-phone-frontend.js and CSS files)
- **SHORTCODE SECURITY**: Browser phone shortcode now provides secure redirect with authentication checks - **SHORTCODE SECURITY**: Browser phone shortcode now provides secure redirect with authentication checks
@@ -611,4 +665,4 @@ This plugin integrates with Twilio services and requires a Twilio account.
--- ---
**Enterprise Ready v2.3.0** - Extension transfers, browser phone compatibility, and automatic agent management now production-ready with comprehensive reliability improvements. **Production Ready v2.8.9** - SDK persistence through plugin updates and configurable edge locations ensure zero-downtime phone operations.

View File

@@ -347,6 +347,25 @@ class TWP_Admin {
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Twilio Edge Location</th>
<td>
<?php $current_edge = get_option('twp_twilio_edge', 'roaming'); ?>
<select name="twp_twilio_edge" class="regular-text">
<option value="roaming" <?php selected($current_edge, 'roaming'); ?>>Auto-select closest (Recommended)</option>
<option value="ashburn" <?php selected($current_edge, 'ashburn'); ?>>US East (Ashburn)</option>
<option value="umatilla" <?php selected($current_edge, 'umatilla'); ?>>US West (Umatilla)</option>
<option value="dublin" <?php selected($current_edge, 'dublin'); ?>>Europe - Ireland (Dublin)</option>
<option value="frankfurt" <?php selected($current_edge, 'frankfurt'); ?>>Europe - Germany (Frankfurt)</option>
<option value="singapore" <?php selected($current_edge, 'singapore'); ?>>Asia Pacific (Singapore)</option>
<option value="sydney" <?php selected($current_edge, 'sydney'); ?>>Australia (Sydney)</option>
<option value="tokyo" <?php selected($current_edge, 'tokyo'); ?>>Japan (Tokyo)</option>
<option value="sao-paulo" <?php selected($current_edge, 'sao-paulo'); ?>>South America (Sao Paulo)</option>
</select>
<p class="description">Edge location for browser phone calls. Use "Auto-select closest" for best call quality, or select a specific region.</p>
</td>
</tr>
</table> </table>
<h2>Eleven Labs API Settings</h2> <h2>Eleven Labs API Settings</h2>
@@ -3818,6 +3837,7 @@ class TWP_Admin {
register_setting('twilio-wp-settings-group', 'twp_twilio_account_sid'); register_setting('twilio-wp-settings-group', 'twp_twilio_account_sid');
register_setting('twilio-wp-settings-group', 'twp_twilio_auth_token'); register_setting('twilio-wp-settings-group', 'twp_twilio_auth_token');
register_setting('twilio-wp-settings-group', 'twp_twiml_app_sid'); register_setting('twilio-wp-settings-group', 'twp_twiml_app_sid');
register_setting('twilio-wp-settings-group', 'twp_twilio_edge');
register_setting('twilio-wp-settings-group', 'twp_elevenlabs_api_key'); register_setting('twilio-wp-settings-group', 'twp_elevenlabs_api_key');
register_setting('twilio-wp-settings-group', 'twp_elevenlabs_voice_id'); register_setting('twilio-wp-settings-group', 'twp_elevenlabs_voice_id');
register_setting('twilio-wp-settings-group', 'twp_elevenlabs_model_id'); register_setting('twilio-wp-settings-group', 'twp_elevenlabs_model_id');
@@ -6996,6 +7016,7 @@ class TWP_Admin {
<div class="phone-interface"> <div class="phone-interface">
<div class="phone-display"> <div class="phone-display">
<div id="phone-status">Ready</div> <div id="phone-status">Ready</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="phone-number-display"></div>
<div id="call-timer" style="display: none;">00:00</div> <div id="call-timer" style="display: none;">00:00</div>
</div> </div>
@@ -7424,6 +7445,11 @@ class TWP_Admin {
} }
</style> </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 --> <!-- Twilio Voice SDK v2 from unpkg CDN -->
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script> <script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
<script> <script>
@@ -7434,6 +7460,210 @@ class TWP_Admin {
var callStartTime = null; var callStartTime = null;
var tokenRefreshTimer = null; var tokenRefreshTimer = null;
var tokenExpiry = null; var tokenExpiry = null;
var audioContext = null;
var ringtoneAudio = null;
var isPageVisible = true;
var deviceConnectionState = 'disconnected'; // disconnected, connecting, connected
var serviceWorkerRegistration = null;
// Initialize AudioContext for mobile audio playback
function initializeAudioContext() {
try {
if (!audioContext) {
// Create AudioContext with compatibility
var AudioContextClass = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContextClass();
console.log('AudioContext created, state:', audioContext.state);
}
// Resume AudioContext if suspended (required on mobile)
if (audioContext.state === 'suspended') {
audioContext.resume().then(function() {
console.log('AudioContext resumed successfully');
}).catch(function(err) {
console.error('Failed to resume AudioContext:', err);
});
}
return true;
} catch (error) {
console.error('Failed to initialize AudioContext:', error);
return false;
}
}
// Create and setup ringtone audio element
function setupRingtone() {
if (!ringtoneAudio) {
ringtoneAudio = new Audio();
// Use a simple sine wave tone or default ringtone
// For now, we'll use a data URI for a simple beep tone
ringtoneAudio.loop = true;
ringtoneAudio.volume = 0.7;
// Create a simple ringtone using Web Audio API
createRingtone();
}
}
// Create ringtone using Web Audio API for better mobile support
function createRingtone() {
// Use a simple base64-encoded beep tone (short MP3)
// This is a simple 1-second beep at 800Hz
// You can replace this with a custom ringtone file URL if you upload one
// For now, use a simple approach: HTML5 Audio with error fallback
// Note: On mobile, audio playback may be restricted, so we rely heavily on vibration
var ringtoneUrl = '<?php echo plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__)); ?>';
// Try to load the ringtone file
ringtoneAudio.src = ringtoneUrl;
// Fallback: if ringtone file fails to load, we'll just use vibration
ringtoneAudio.addEventListener('error', function(e) {
console.log('Ringtone file not found (this is normal), using vibration only for mobile');
// Don't show error - vibration is sufficient for mobile
}, { once: true });
// Try to preload
ringtoneAudio.load();
}
// Play ringtone for incoming call
function playRingtone() {
try {
// Initialize AudioContext on user interaction
initializeAudioContext();
if (ringtoneAudio) {
var playPromise = ringtoneAudio.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Ringtone playing');
}).catch(function(error) {
console.error('Ringtone play failed:', error);
// Fallback: vibrate on mobile
vibrateDevice([300, 200, 300, 200, 300]);
});
}
}
// Always vibrate on mobile for better notification
vibrateDevice([300, 200, 300, 200, 300]);
} catch (error) {
console.error('Error playing ringtone:', error);
}
}
// Stop ringtone
function stopRingtone() {
try {
if (ringtoneAudio) {
ringtoneAudio.pause();
ringtoneAudio.currentTime = 0;
}
} catch (error) {
console.error('Error stopping ringtone:', error);
}
}
// Vibrate device (mobile)
function vibrateDevice(pattern) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
// Register service worker for PWA notifications
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
var swPath = '<?php echo plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__)); ?>';
navigator.serviceWorker.register(swPath)
.then(function(registration) {
console.log('Service Worker registered:', registration);
serviceWorkerRegistration = registration;
// Request notification permission
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(function(permission) {
console.log('Notification permission:', permission);
});
}
})
.catch(function(error) {
console.error('Service Worker registration failed:', error);
});
}
}
// Send notification via service worker
function sendIncomingCallNotification(callerNumber) {
// Try browser notification first
if ('Notification' in window && Notification.permission === 'granted') {
if (serviceWorkerRegistration && serviceWorkerRegistration.active) {
serviceWorkerRegistration.active.postMessage({
type: 'SHOW_NOTIFICATION',
title: 'Incoming Call',
body: 'Call from ' + (callerNumber || 'Unknown Number'),
icon: '<?php echo plugins_url('assets/images/phone-icon.png', dirname(__FILE__)); ?>',
tag: 'incoming-call',
requireInteraction: true
});
} else {
// Fallback: show notification directly
new Notification('Incoming Call', {
body: 'Call from ' + (callerNumber || 'Unknown Number'),
icon: '<?php echo plugins_url('assets/images/phone-icon.png', dirname(__FILE__)); ?>',
tag: 'incoming-call',
requireInteraction: true
});
}
}
}
// Monitor page visibility for background call handling
function setupPageVisibility() {
document.addEventListener('visibilitychange', function() {
isPageVisible = !document.hidden;
console.log('Page visibility changed:', isPageVisible ? 'visible' : 'hidden');
// If page becomes visible, resume audio context
if (isPageVisible && audioContext) {
initializeAudioContext();
}
});
}
// Update device connection status in UI
function updateConnectionStatus(state) {
deviceConnectionState = state;
var statusText = '';
var statusColor = '';
switch(state) {
case 'connected':
statusText = 'Connected';
statusColor = '#4CAF50';
break;
case 'connecting':
statusText = 'Connecting...';
statusColor = '#FF9800';
break;
case 'disconnected':
statusText = 'Disconnected';
statusColor = '#f44336';
break;
default:
statusText = 'Unknown';
statusColor = '#999';
}
// Update status indicator (we'll add this to the UI)
$('#device-connection-status').text(statusText).css('color', statusColor);
}
// Wait for SDK to load // Wait for SDK to load
function waitForTwilioSDK(callback) { function waitForTwilioSDK(callback) {
@@ -7450,6 +7680,18 @@ class TWP_Admin {
// Initialize the browser phone // Initialize the browser phone
function initializeBrowserPhone() { function initializeBrowserPhone() {
$('#phone-status').text('Initializing...'); $('#phone-status').text('Initializing...');
updateConnectionStatus('connecting');
// Initialize audio and PWA features
setupRingtone();
registerServiceWorker();
setupPageVisibility();
// Initialize AudioContext on first user interaction
$(document).one('click touchstart', function() {
console.log('User interaction detected, initializing AudioContext');
initializeAudioContext();
});
// Wait for SDK before proceeding // Wait for SDK before proceeding
waitForTwilioSDK(function() { waitForTwilioSDK(function() {
@@ -7468,9 +7710,11 @@ class TWP_Admin {
// WordPress wp_send_json_error sends the error message as response.data // WordPress wp_send_json_error sends the error message as response.data
var errorMsg = response.data || response.error || 'Unknown error'; var errorMsg = response.data || response.error || 'Unknown error';
showError('Failed to initialize: ' + errorMsg); showError('Failed to initialize: ' + errorMsg);
updateConnectionStatus('disconnected');
} }
}).fail(function() { }).fail(function() {
showError('Failed to connect to server'); showError('Failed to connect to server');
updateConnectionStatus('disconnected');
}); });
}); });
} }
@@ -7518,36 +7762,77 @@ class TWP_Admin {
throw new Error('Twilio Voice SDK not loaded'); throw new Error('Twilio Voice SDK not loaded');
} }
console.log('Setting up Twilio Device...');
updateConnectionStatus('connecting');
// Request media permissions before setting up device // Request media permissions before setting up device
const hasPermissions = await requestMediaPermissions(); const hasPermissions = await requestMediaPermissions();
if (!hasPermissions) { if (!hasPermissions) {
updateConnectionStatus('disconnected');
return; // Stop setup if permissions denied return; // Stop setup if permissions denied
} }
// Clean up existing device if any // Clean up existing device if any
if (device) { if (device) {
console.log('Destroying existing device');
await device.destroy(); await device.destroy();
} }
// Detect if we're on Android/mobile for specific settings
var isAndroid = /Android/i.test(navigator.userAgent);
var isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
console.log('Device detection - Android:', isAndroid, 'Mobile:', isMobile);
// Android-specific audio constraints for better WebRTC performance
var audioConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
// Additional Android-specific settings
if (isAndroid) {
audioConstraints.googEchoCancellation = true;
audioConstraints.googNoiseSuppression = true;
audioConstraints.googAutoGainControl = true;
audioConstraints.googHighpassFilter = true;
}
// Setup Twilio Voice SDK v2 Device // Setup Twilio Voice SDK v2 Device
// Note: Voice SDK v2 uses Twilio.Device directly, not Twilio.Voice.Device // Note: Voice SDK v2 uses Twilio.Device directly, not Twilio.Voice.Device
device = new Twilio.Device(token, { device = new Twilio.Device(token, {
logLevel: 1, // 0 = TRACE, 1 = DEBUG logLevel: 1, // 0 = TRACE, 1 = DEBUG
codecPreferences: ['opus', 'pcmu'], codecPreferences: ['opus', 'pcmu'],
edge: 'sydney' // Or closest edge location edge: '<?php echo esc_js(get_option('twp_twilio_edge', 'roaming')); ?>',
enableIceRestart: true, // Important for mobile network switching
audioConstraints: audioConstraints,
maxCallSignalingTimeoutMs: 30000, // 30 seconds timeout for mobile
closeProtection: true // Warn before closing during call
}); });
console.log('Twilio Device created with audio constraints:', audioConstraints);
// Set up event handlers BEFORE registering // Set up event handlers BEFORE registering
// Device registered and ready // Device registered and ready
device.on('registered', function() { device.on('registered', function() {
console.log('Device registered successfully'); console.log('Device registered successfully');
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
$('#call-btn').prop('disabled', false); $('#call-btn').prop('disabled', false);
updateConnectionStatus('connected');
});
// Device unregistered
device.on('unregistered', function() {
console.log('Device unregistered');
updateConnectionStatus('disconnected');
}); });
// Handle errors // Handle errors
device.on('error', function(error) { device.on('error', function(error) {
console.error('Twilio Device Error:', error); console.error('Twilio Device Error:', error);
console.error('Error code:', error.code, 'Message:', error.message);
updateConnectionStatus('disconnected');
var errorMsg = error.message || error.toString(); var errorMsg = error.message || error.toString();
@@ -7560,6 +7845,15 @@ class TWP_Admin {
errorMsg = 'Token error: ' + errorMsg + ' - The page will automatically try to refresh the token.'; errorMsg = 'Token error: ' + errorMsg + ' - The page will automatically try to refresh the token.';
// Try to reinitialize after token error // Try to reinitialize after token error
setTimeout(initializeBrowserPhone, 5000); setTimeout(initializeBrowserPhone, 5000);
} else if (errorMsg.includes('31005') || errorMsg.includes('Connection error')) {
errorMsg = 'Connection error: Check your internet connection. If on mobile, try switching between WiFi and cellular data.';
// Retry connection
setTimeout(function() {
if (device) {
console.log('Attempting to reconnect device...');
device.register();
}
}, 3000);
} }
showError(errorMsg); showError(errorMsg);
@@ -7567,16 +7861,32 @@ class TWP_Admin {
// Handle incoming calls // Handle incoming calls
device.on('incoming', function(call) { device.on('incoming', function(call) {
console.log('Incoming call from:', call.parameters.From);
console.log('Call SID:', call.parameters.CallSid);
console.log('Device connection state:', deviceConnectionState);
currentCall = call; currentCall = call;
var callerNumber = call.parameters.From || 'Unknown Number';
$('#phone-status').text('Incoming Call').css('color', '#FF9800'); $('#phone-status').text('Incoming Call').css('color', '#FF9800');
$('#phone-number-display').text(call.parameters.From || 'Unknown Number'); $('#phone-number-display').text(callerNumber);
$('#call-btn').hide(); $('#call-btn').hide();
$('#answer-btn').show(); $('#answer-btn').show();
// Play ringtone and show notification
playRingtone();
// If page is in background, send notification
if (!isPageVisible) {
console.log('Page in background, sending notification');
sendIncomingCallNotification(callerNumber);
}
// Setup call event handlers // Setup call event handlers
setupCallHandlers(call); setupCallHandlers(call);
if ($('#auto-answer').is(':checked')) { if ($('#auto-answer').is(':checked')) {
console.log('Auto-answer enabled, accepting call');
call.accept(); call.accept();
} }
}); });
@@ -7599,6 +7909,8 @@ class TWP_Admin {
function setupCallHandlers(call) { function setupCallHandlers(call) {
// Call accepted/connected // Call accepted/connected
call.on('accept', function() { call.on('accept', function() {
console.log('Call accepted and connected');
stopRingtone();
$('#phone-status').text('Connected').css('color', '#2196F3'); $('#phone-status').text('Connected').css('color', '#2196F3');
$('#call-btn').hide(); $('#call-btn').hide();
$('#answer-btn').hide(); $('#answer-btn').hide();
@@ -7610,6 +7922,8 @@ class TWP_Admin {
// Call disconnected // Call disconnected
call.on('disconnect', function() { call.on('disconnect', function() {
console.log('Call disconnected');
stopRingtone();
currentCall = null; currentCall = null;
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
$('#hangup-btn').hide(); $('#hangup-btn').hide();
@@ -7627,6 +7941,8 @@ class TWP_Admin {
// Call rejected // Call rejected
call.on('reject', function() { call.on('reject', function() {
console.log('Call rejected');
stopRingtone();
currentCall = null; currentCall = null;
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
$('#answer-btn').hide(); $('#answer-btn').hide();
@@ -7635,6 +7951,8 @@ class TWP_Admin {
// Call cancelled (by caller before answer) // Call cancelled (by caller before answer)
call.on('cancel', function() { call.on('cancel', function() {
console.log('Call cancelled by caller');
stopRingtone();
currentCall = null; currentCall = null;
$('#phone-status').text('Missed Call').css('color', '#FF9800'); $('#phone-status').text('Missed Call').css('color', '#FF9800');
$('#answer-btn').hide(); $('#answer-btn').hide();
@@ -7643,6 +7961,26 @@ class TWP_Admin {
$('#phone-status').text('Ready').css('color', '#4CAF50'); $('#phone-status').text('Ready').css('color', '#4CAF50');
}, 3000); }, 3000);
}); });
// Call error
call.on('error', function(error) {
console.error('Call error:', error);
console.error('Error code:', error.code, 'Message:', error.message);
stopRingtone();
var errorMsg = error.message || error.toString();
// Specific error handling for Android/mobile
if (error.code === 31005) {
errorMsg = 'Connection failed: Check your network connection. Try switching between WiFi and cellular data.';
} else if (error.code === 31201 || error.code === 31204) {
errorMsg = 'Call setup failed: Please try again. If the problem persists, refresh the page.';
} else if (error.code === 31208) {
errorMsg = 'Media connection failed: Check microphone permissions and try again.';
}
showError('Call error: ' + errorMsg);
});
} }
function refreshToken() { function refreshToken() {
@@ -7753,11 +8091,15 @@ class TWP_Admin {
$('#caller-id-select').html('<option value="">Error loading numbers</option>'); $('#caller-id-select').html('<option value="">Error loading numbers</option>');
}); });
// Dialpad functionality // Dialpad functionality (support both click and touch events)
$('.dialpad-btn').on('click', function() { $('.dialpad-btn').on('click touchend', function(e) {
e.preventDefault(); // Prevent duplicate events
var digit = $(this).data('digit'); var digit = $(this).data('digit');
var currentVal = $('#phone-number-input').val(); var currentVal = $('#phone-number-input').val();
$('#phone-number-input').val(currentVal + digit); $('#phone-number-input').val(currentVal + digit);
// Initialize AudioContext on user interaction (mobile requirement)
initializeAudioContext();
}); });
// Call button // Call button
@@ -7818,8 +8160,45 @@ class TWP_Admin {
// Answer button // Answer button
$('#answer-btn').on('click', function() { $('#answer-btn').on('click', function() {
if (currentCall) { console.log('Answer button clicked');
console.log('Device connection state:', deviceConnectionState);
console.log('Current call:', currentCall);
if (!currentCall) {
console.error('No current call to answer');
showError('No incoming call to answer');
return;
}
// Check device connection state
if (deviceConnectionState !== 'connected') {
console.error('Device not connected, state:', deviceConnectionState);
showError('Phone not connected. Reconnecting...');
// Try to reconnect
if (device) {
device.register().then(function() {
console.log('Device reconnected, answering call');
if (currentCall) {
currentCall.accept();
}
}).catch(function(err) {
console.error('Failed to reconnect device:', err);
showError('Failed to reconnect. Please refresh the page.');
});
}
return;
}
// Initialize AudioContext before accepting (important for mobile)
initializeAudioContext();
try {
console.log('Accepting call...');
currentCall.accept(); currentCall.accept();
} catch (error) {
console.error('Error accepting call:', error);
showError('Failed to answer call: ' + error.message);
} }
}); });
@@ -7859,16 +8238,40 @@ class TWP_Admin {
}); });
// Check if SDK loaded and initialize // Check if SDK loaded and initialize
// 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() { $(window).on('load', function() {
setTimeout(function() { if (typeof Twilio !== 'undefined' && !device) {
if (typeof Twilio === 'undefined') { initializeBrowserPhone();
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 {
console.log('Twilio SDK loaded successfully');
initializeBrowserPhone();
}
}, 1000);
}); });
// Clean up on page unload // Clean up on page unload

View File

@@ -36,7 +36,19 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
// Save settings // Save settings
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) { if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key'])); update_option('twp_fcm_project_id', sanitize_text_field($_POST['twp_fcm_project_id']));
// 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', '');
}
update_option('twp_auto_update_enabled', isset($_POST['twp_auto_update_enabled']) ? '1' : '0'); 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_repo', sanitize_text_field($_POST['twp_gitea_repo']));
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token'])); update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
@@ -45,7 +57,9 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
} }
// Get current settings // Get current settings
$fcm_server_key = get_option('twp_fcm_server_key', ''); $fcm_project_id = get_option('twp_fcm_project_id', '');
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1'; $auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin'); $gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
$gitea_token = get_option('twp_gitea_token', ''); $gitea_token = get_option('twp_gitea_token', '');
@@ -84,6 +98,12 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($sa_json_error)): ?>
<div class="notice notice-error is-dismissible">
<p><strong><?php echo esc_html($sa_json_error); ?></strong></p>
</div>
<?php endif; ?>
<div class="twp-mobile-settings"> <div class="twp-mobile-settings">
<!-- Mobile App Overview --> <!-- Mobile App Overview -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;"> <div class="card" style="max-width: 100%; margin-bottom: 20px;">
@@ -112,29 +132,48 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<!-- FCM Configuration --> <!-- FCM Configuration -->
<div class="card" style="max-width: 100%; margin-bottom: 20px;"> <div class="card" style="max-width: 100%; margin-bottom: 20px;">
<h2>Firebase Cloud Messaging (FCM)</h2> <h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
<p>Configure FCM to enable push notifications for the mobile app.</p> <p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th scope="row"> <th scope="row">
<label for="twp_fcm_server_key">FCM Server Key</label> <label for="twp_fcm_project_id">Firebase Project ID</label>
</th> </th>
<td> <td>
<input type="text" <input type="text"
id="twp_fcm_server_key" id="twp_fcm_project_id"
name="twp_fcm_server_key" name="twp_fcm_project_id"
value="<?php echo esc_attr($fcm_server_key); ?>" value="<?php echo esc_attr($fcm_project_id); ?>"
class="regular-text" class="regular-text"
placeholder="AAAA..."> placeholder="my-project-12345">
<p class="description"> <p class="description">
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key Found in Firebase Console &gt; Project Settings &gt; General &gt; Project ID
</p> </p>
</td> </td>
</tr> </tr>
<tr>
<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> </table>
<?php if (!empty($fcm_server_key)): ?> <?php if ($fcm_sa_configured): ?>
<p> <p>
<button type="submit" name="twp_test_notification" class="button"> <button type="submit" name="twp_test_notification" class="button">
Send Test Notification Send Test Notification
@@ -273,6 +312,11 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
<td>GET</td> <td>GET</td>
<td>Server-Sent Events stream for real-time updates</td> <td>Server-Sent Events stream for real-time updates</td>
</tr> </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> </tbody>
</table> </table>

42
assets/images/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Browser Phone Images
## Required Images for PWA Notifications
Place the following image files in this directory for browser push notifications:
### phone-icon.png
- **Purpose**: Main notification icon
- **Size**: 192x192 pixels (recommended)
- **Format**: PNG with transparency
- **Usage**: Shown in browser notifications for incoming calls
### phone-badge.png
- **Purpose**: Small badge icon for notifications
- **Size**: 96x96 pixels or 72x72 pixels
- **Format**: PNG with transparency
- **Usage**: Displayed in the notification badge area (Android)
## Fallback Behavior
If these images are not present, the browser will:
- Use a default browser notification icon
- Still display text-based notifications
- Functionality will not be affected
## Image Creation Tips
- Use a simple, recognizable phone icon
- Ensure good contrast for visibility
- Test on both light and dark backgrounds
- Transparent backgrounds work best
- Use a consistent color scheme with your brand
## Example Resources
- Google Material Icons: https://fonts.google.com/icons
- Font Awesome: https://fontawesome.com/
- Flaticon: https://www.flaticon.com/
- Or create custom icons using tools like:
- Figma
- Adobe Illustrator
- Inkscape (free)

View File

@@ -72,7 +72,23 @@ self.addEventListener('push', function(event) {
// Handle messages from the main script // Handle messages from the main script
self.addEventListener('message', function(event) { self.addEventListener('message', function(event) {
console.log('TWP Service Worker: Message received', event.data);
if (event.data && event.data.type === 'SHOW_NOTIFICATION') { if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
self.registration.showNotification(event.data.title, event.data.options); const options = {
body: event.data.body || 'You have a new call waiting',
icon: event.data.icon || '/wp-content/plugins/twilio-wp-plugin/assets/images/phone-icon.png',
badge: '/wp-content/plugins/twilio-wp-plugin/assets/images/phone-badge.png',
vibrate: [300, 200, 300, 200, 300],
tag: event.data.tag || 'incoming-call',
requireInteraction: event.data.requireInteraction !== undefined ? event.data.requireInteraction : true,
data: event.data.data || {}
};
console.log('TWP Service Worker: Showing notification', event.data.title, options);
event.waitUntil(
self.registration.showNotification(event.data.title || 'Incoming Call', options)
);
} }
}); });

36
assets/sounds/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Ringtone Audio Files
## Custom Ringtone
To add a custom ringtone for incoming calls in the browser phone:
1. Place your ringtone audio file in this directory
2. Name it: `ringtone.mp3`
3. Recommended format: MP3, under 5 seconds, loopable
## Fallback Behavior
If no ringtone file is present, the browser phone will:
- Use device vibration on mobile devices (Android, iOS)
- Rely on browser notifications to alert users
- Display visual indicators in the browser interface
## Supported Audio Formats
- **MP3** (recommended) - Best compatibility across browsers
- **OGG** - Good for Firefox, Chrome
- **WAV** - Larger file size but universal support
## File Requirements
- Maximum file size: 100KB recommended
- Duration: 1-5 seconds (will loop)
- Sample rate: 44.1kHz or 48kHz
- Bitrate: 128kbps or higher
## Example Ringtone Sources
You can find free ringtone files at:
- https://freesound.org/
- https://incompetech.com/
- Or create your own using Audacity or similar tools

View File

@@ -618,6 +618,13 @@ class TWP_Call_Queue {
require_once dirname(__FILE__) . '/class-twp-agent-groups.php'; require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id); $members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
// Send FCM push notifications to agents' mobile devices
require_once dirname(__FILE__) . '/class-twp-fcm.php';
$fcm = new TWP_FCM();
foreach ($members as $member) {
$fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, '');
}
if (empty($members)) { if (empty($members)) {
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}"); error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
return; return;

View File

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

View File

@@ -99,6 +99,13 @@ class TWP_Mobile_API {
'callback' => array($this, 'update_agent_phone'), 'callback' => array($this, 'update_agent_phone'),
'permission_callback' => array($this->auth, 'verify_token') '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')
));
}); });
} }
@@ -204,44 +211,41 @@ class TWP_Mobile_API {
global $wpdb; global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues'; $queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls'; $calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments'; $groups_table = $wpdb->prefix . 'twp_group_members';
// Get queues assigned to this user // Auto-create personal queues if they don't exist
$queue_ids = $wpdb->get_col($wpdb->prepare( $extensions_table = $wpdb->prefix . 'twp_user_extensions';
"SELECT queue_id FROM $assignments_table WHERE user_id = %d", $existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id $user_id
)); ));
// Also include personal queues if (!$existing_extension) {
$personal_queue_ids = $wpdb->get_col($wpdb->prepare( TWP_User_Queue_Manager::create_user_queues($user_id);
"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);
} }
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); // Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
// Get queue information with call counts SELECT DISTINCT
$queues = $wpdb->get_results("
SELECT
q.id, q.id,
q.queue_name, q.queue_name,
q.queue_type, q.queue_type,
q.extension, q.extension,
COUNT(c.id) as waiting_count COUNT(c.id) as waiting_count
FROM $queues_table q FROM $queues_table q
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting' LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str) WHERE (gm.user_id = %d AND gm.is_active = 1)
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
GROUP BY q.id GROUP BY q.id
"); ORDER BY
CASE
WHEN q.queue_type = 'personal' THEN 1
WHEN q.queue_type = 'hold' THEN 2
ELSE 3
END,
q.queue_name ASC
", $user_id, $user_id));
$result = array(); $result = array();
foreach ($queues as $queue) { foreach ($queues as $queue) {
@@ -507,11 +511,46 @@ class TWP_Mobile_API {
* Unhold a call (resume from hold queue) * Unhold a call (resume from hold queue)
*/ */
public function unhold_call($request) { public function unhold_call($request) {
// Implementation would retrieve from hold queue and reconnect $user_id = $this->auth->get_current_user_id();
return new WP_REST_Response(array( $call_sid = $request['call_sid'];
'success' => true,
'message' => 'Unhold functionality - to be implemented with queue retrieval' try {
), 501); 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' => 'Call resumed from hold'
), 200);
} catch (Exception $e) {
return new WP_Error('unhold_error', $e->getMessage(), array('status' => 500));
}
} }
/** /**
@@ -641,6 +680,38 @@ class TWP_Mobile_API {
), 200); ), 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 {
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
$twilio = new TWP_Twilio_API();
$result = $twilio->generate_capability_token($identity);
if (!$result['success']) {
return new WP_Error('token_error', $result['error'], array('status' => 500));
}
return new WP_REST_Response(array(
'token' => $result['data']['token'],
'identity' => $result['data']['client_name'],
'expires_in' => $result['data']['expires_in']
), 200);
} catch (Exception $e) {
return new WP_Error('token_error', $e->getMessage(), array('status' => 500));
}
}
/** /**
* Check if user has access to a queue * Check if user has access to a queue
*/ */

View File

@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
global $wpdb; global $wpdb;
$queues_table = $wpdb->prefix . 'twp_call_queues'; $queues_table = $wpdb->prefix . 'twp_call_queues';
$calls_table = $wpdb->prefix . 'twp_queued_calls'; $calls_table = $wpdb->prefix . 'twp_queued_calls';
$assignments_table = $wpdb->prefix . 'twp_queue_assignments'; $groups_table = $wpdb->prefix . 'twp_group_members';
// Get queue IDs // Auto-create personal queues if they don't exist
$queue_ids = $wpdb->get_col($wpdb->prepare( $extensions_table = $wpdb->prefix . 'twp_user_extensions';
"SELECT queue_id FROM $assignments_table WHERE user_id = %d", $existing_extension = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$user_id $user_id
)); ));
$personal_queue_ids = $wpdb->get_col($wpdb->prepare( if (!$existing_extension) {
"SELECT id FROM $queues_table WHERE user_id = %d", TWP_User_Queue_Manager::create_user_queues($user_id);
$user_id
));
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
if (empty($all_queue_ids)) {
return array();
} }
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids)); // Get queues where user is a member of the assigned agent group OR personal/hold queues
$queues = $wpdb->get_results($wpdb->prepare("
$queues = $wpdb->get_results(" SELECT DISTINCT
SELECT
q.id, q.id,
q.queue_name, q.queue_name,
COUNT(c.id) as waiting_count, COUNT(c.id) as waiting_count,
MIN(c.enqueued_at) as oldest_call_time MIN(c.enqueued_at) as oldest_call_time
FROM $queues_table q FROM $queues_table q
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting' LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
WHERE q.id IN ($queue_ids_str) WHERE (gm.user_id = %d AND gm.is_active = 1)
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
GROUP BY q.id GROUP BY q.id
"); ORDER BY
CASE
WHEN q.queue_type = 'personal' THEN 1
WHEN q.queue_type = 'hold' THEN 2
ELSE 3
END,
q.queue_name ASC
", $user_id, $user_id));
$result = array(); $result = array();
foreach ($queues as $queue) { foreach ($queues as $queue) {

View File

@@ -41,11 +41,26 @@ class TWP_Twilio_API {
* Initialize Twilio SDK client * Initialize Twilio SDK client
*/ */
private function init_sdk_client() { private function init_sdk_client() {
// Check if autoloader exists // Check for SDK autoloader - external location first (survives plugin updates)
$autoloader_path = TWP_PLUGIN_DIR . 'vendor/autoload.php'; $autoloader_path = null;
if (!file_exists($autoloader_path)) {
error_log('TWP Plugin: Autoloader not found at: ' . $autoloader_path); // Priority 1: External SDK location (recommended)
throw new Exception('Twilio SDK not found. Please run: ./install-twilio-sdk.sh'); $external_autoloader = TWP_EXTERNAL_SDK_DIR . 'autoload.php';
if (file_exists($external_autoloader)) {
$autoloader_path = $external_autoloader;
}
// Priority 2: Internal vendor directory (fallback)
if (!$autoloader_path) {
$internal_autoloader = TWP_PLUGIN_DIR . 'vendor/autoload.php';
if (file_exists($internal_autoloader)) {
$autoloader_path = $internal_autoloader;
}
}
if (!$autoloader_path) {
error_log('TWP Plugin: Autoloader not found. Checked: ' . $external_autoloader . ' and ' . TWP_PLUGIN_DIR . 'vendor/autoload.php');
throw new Exception('Twilio SDK not found. Please run: ./install-twilio-sdk-external.sh');
} }
// Load the autoloader // Load the autoloader

View File

@@ -9,9 +9,25 @@ class TWP_Webhooks {
*/ */
public function __construct() { public function __construct() {
// Load Twilio SDK if not already loaded // Load Twilio SDK if not already loaded
// Check external location first (survives plugin updates), then internal
if (!class_exists('\Twilio\Rest\Client')) { if (!class_exists('\Twilio\Rest\Client')) {
$autoloader_path = plugin_dir_path(dirname(__FILE__)) . 'vendor/autoload.php'; $autoloader_path = null;
if (file_exists($autoloader_path)) {
// Priority 1: External SDK location
$external_autoloader = dirname(dirname(plugin_dir_path(dirname(__FILE__)))) . '/twilio-sdk/autoload.php';
if (file_exists($external_autoloader)) {
$autoloader_path = $external_autoloader;
}
// Priority 2: Internal vendor directory
if (!$autoloader_path) {
$internal_autoloader = plugin_dir_path(dirname(__FILE__)) . 'vendor/autoload.php';
if (file_exists($internal_autoloader)) {
$autoloader_path = $internal_autoloader;
}
}
if ($autoloader_path) {
require_once $autoloader_path; require_once $autoloader_path;
} }
} }
@@ -461,6 +477,13 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number); $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', '');
}
} }
/** /**
@@ -688,6 +711,13 @@ class TWP_Webhooks {
$twilio_api->send_sms($agent_phone, $message, $twilio_number); $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', '');
}
} }
/** /**

158
install-twilio-sdk-external.sh Executable file
View File

@@ -0,0 +1,158 @@
#!/bin/bash
# Script to install Twilio PHP SDK to an external location
# This prevents SDK from being deleted when WordPress updates the plugin
#
# Location: wp-content/twilio-sdk/ (outside plugin folder)
echo "Installing Twilio PHP SDK v8.7.0 to external location..."
echo "This will install the SDK outside the plugin folder to survive plugin updates."
# Check if we can download files
if ! command -v curl &> /dev/null; then
echo "ERROR: curl is required to download the SDK"
echo "Please install curl and try again"
exit 1
fi
if ! command -v tar &> /dev/null; then
echo "ERROR: tar is required to extract the SDK"
echo "Please install tar and try again"
exit 1
fi
# Get the script directory (plugin directory)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Calculate wp-content directory (two levels up from plugin)
# Plugin is at: wp-content/plugins/twilio-wp-plugin/
# We want: wp-content/twilio-sdk/
WP_CONTENT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
SDK_DIR="$WP_CONTENT_DIR/twilio-sdk"
echo "Plugin directory: $SCRIPT_DIR"
echo "SDK will be installed to: $SDK_DIR"
# Create SDK directory
mkdir -p "$SDK_DIR/twilio/sdk"
# Download the latest release (8.7.0)
echo "Downloading Twilio SDK from GitHub..."
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR"
if ! curl -L https://github.com/twilio/twilio-php/archive/refs/tags/8.7.0.tar.gz -o twilio-sdk.tar.gz; then
echo "ERROR: Failed to download Twilio SDK"
echo "Please check your internet connection and try again"
rm -rf "$TEMP_DIR"
exit 1
fi
# Extract the archive
echo "Extracting SDK files..."
if ! tar -xzf twilio-sdk.tar.gz; then
echo "ERROR: Failed to extract SDK files"
rm -rf "$TEMP_DIR"
exit 1
fi
# Check if the extracted directory exists
if [ ! -d "twilio-php-8.7.0/src" ]; then
echo "ERROR: Extracted SDK directory structure is unexpected"
rm -rf "$TEMP_DIR"
exit 1
fi
# Remove existing SDK if it exists
if [ -d "$SDK_DIR/twilio/sdk" ]; then
echo "Removing existing SDK installation..."
rm -rf "$SDK_DIR/twilio/sdk"
mkdir -p "$SDK_DIR/twilio/sdk"
fi
# Move the entire src directory to be the sdk
echo "Installing SDK files..."
if ! mv twilio-php-8.7.0/src/* "$SDK_DIR/twilio/sdk/"; then
echo "ERROR: Failed to move SDK files"
rm -rf "$TEMP_DIR"
exit 1
fi
# Create a comprehensive autoloader
cat > "$SDK_DIR/autoload.php" << 'EOF'
<?php
/**
* Twilio SDK v8.7.0 Autoloader (External Installation)
* This file loads the Twilio PHP SDK classes
*
* Location: wp-content/twilio-sdk/autoload.php
* This location survives WordPress plugin updates
*/
// Prevent multiple registrations
if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
define('TWILIO_AUTOLOADER_REGISTERED', true);
// Register the autoloader
spl_autoload_register(function ($class) {
// Only handle Twilio classes
if (strpos($class, 'Twilio\\') !== 0) {
return false;
}
// Convert class name to file path
// The SDK structure is: twilio/sdk/Twilio/Rest/Client.php for Twilio\Rest\Client
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $class) . '.php';
if (file_exists($file)) {
require_once $file;
return true;
}
return false;
});
// Try to load the SDK's own autoloader if it exists
$sdk_autoloader = __DIR__ . '/twilio/sdk/autoload.php';
if (file_exists($sdk_autoloader)) {
require_once $sdk_autoloader;
}
// Load essential Twilio classes manually to ensure they're available
$essential_classes = [
__DIR__ . '/twilio/sdk/Twilio/Rest/Client.php',
__DIR__ . '/twilio/sdk/Twilio/TwiML/VoiceResponse.php',
__DIR__ . '/twilio/sdk/Twilio/Exceptions/TwilioException.php',
__DIR__ . '/twilio/sdk/Twilio/Security/RequestValidator.php'
];
foreach ($essential_classes as $class_file) {
if (file_exists($class_file)) {
require_once $class_file;
}
}
}
EOF
# Clean up temp directory
cd "$SCRIPT_DIR"
rm -rf "$TEMP_DIR"
# Verify installation
echo ""
echo "Verifying installation..."
if [ -f "$SDK_DIR/autoload.php" ] && [ -d "$SDK_DIR/twilio/sdk" ]; then
echo "=============================================="
echo "Twilio SDK v8.7.0 installed successfully!"
echo "=============================================="
echo ""
echo "Installation location: $SDK_DIR"
echo ""
echo "This SDK is installed OUTSIDE the plugin folder,"
echo "so it will NOT be deleted when WordPress updates the plugin."
echo ""
echo "The plugin will automatically detect this external SDK."
else
echo "Installation failed - files not found"
exit 1
fi

View File

@@ -79,8 +79,8 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
} }
// Convert class name to file path // Convert class name to file path
$relative_class = substr($class, 7); // Remove 'Twilio\' // The SDK structure is: twilio/sdk/Twilio/Rest/Client.php for Twilio\Rest\Client
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $relative_class) . '.php'; $file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $class) . '.php';
if (file_exists($file)) { if (file_exists($file)) {
require_once $file; require_once $file;
@@ -98,10 +98,10 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
// Load essential Twilio classes manually to ensure they're available // Load essential Twilio classes manually to ensure they're available
$essential_classes = [ $essential_classes = [
__DIR__ . '/twilio/sdk/Rest/Client.php', __DIR__ . '/twilio/sdk/Twilio/Rest/Client.php',
__DIR__ . '/twilio/sdk/TwiML/VoiceResponse.php', __DIR__ . '/twilio/sdk/Twilio/TwiML/VoiceResponse.php',
__DIR__ . '/twilio/sdk/Exceptions/TwilioException.php', __DIR__ . '/twilio/sdk/Twilio/Exceptions/TwilioException.php',
__DIR__ . '/twilio/sdk/Security/RequestValidator.php' __DIR__ . '/twilio/sdk/Twilio/Security/RequestValidator.php'
]; ];
foreach ($essential_classes as $class_file) { foreach ($essential_classes as $class_file) {

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'

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"
}

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

@@ -0,0 +1,9 @@
# Twilio Voice SDK
-keep class com.twilio.** { *; }
-keep class tvo.webrtc.** { *; }
# Firebase
-keep class com.google.firebase.** { *; }
# Flutter
-keep class io.flutter.** { *; }

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,65 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Audio/Phone permissions for VoIP -->
<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 for active calls -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL"/>
<!-- 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"/>
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<!-- 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="TWP Softphone"
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 com.twilio.twilio_voice.TwilioVoicePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin twilio_voice, com.twilio.twilio_voice.TwilioVoicePlugin", e);
}
}
}

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,2 @@
flutter.sdk=/opt/flutter
sdk.dir=/opt/android-sdk

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"

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

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/api_client.dart';
import 'providers/auth_provider.dart';
import 'providers/agent_provider.dart';
import 'providers/call_provider.dart';
import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart';
class TwpSoftphoneApp extends StatefulWidget {
const TwpSoftphoneApp({super.key});
@override
State<TwpSoftphoneApp> createState() => _TwpSoftphoneAppState();
}
class _TwpSoftphoneAppState extends State<TwpSoftphoneApp> {
final _apiClient = ApiClient();
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) {
final auth = AuthProvider(_apiClient);
auth.tryRestoreSession();
return auth;
},
child: 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: Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.state == AuthState.authenticated) {
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => AgentProvider(
auth.apiClient,
auth.sseService,
)..refresh(),
),
ChangeNotifierProvider(
create: (_) => CallProvider(auth.voiceService),
),
],
child: const DashboardScreen(),
);
}
return const LoginScreen();
},
),
),
);
}
}

View File

@@ -0,0 +1,8 @@
class AppConfig {
static const String appName = 'TWP Softphone';
static const Duration tokenRefreshInterval = Duration(minutes: 50);
static const Duration sseReconnectBase = Duration(seconds: 2);
static const Duration sseMaxReconnect = Duration(seconds: 60);
static const int sseServerTimeout = 300; // server closes after 5 min
static const String defaultScheme = 'https';
}

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,38 @@
enum AgentStatusValue { available, busy, offline }
class AgentStatus {
final AgentStatusValue status;
final bool isLoggedIn;
final String? currentCallSid;
final String? lastActivity;
final bool availableForQueues;
AgentStatus({
required this.status,
required this.isLoggedIn,
this.currentCallSid,
this.lastActivity,
this.availableForQueues = true,
});
factory AgentStatus.fromJson(Map<String, dynamic> json) {
return AgentStatus(
status: _parseStatus((json['status'] ?? 'offline') as String),
isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
currentCallSid: json['current_call_sid'] as String?,
lastActivity: json['last_activity'] as String?,
availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0',
);
}
static AgentStatusValue _parseStatus(String s) {
switch (s) {
case 'available':
return AgentStatusValue.available;
case 'busy':
return AgentStatusValue.busy;
default:
return AgentStatusValue.offline;
}
}
}

View File

@@ -0,0 +1,46 @@
enum CallState { idle, ringing, connecting, connected, disconnected }
class CallInfo {
final CallState state;
final String? callSid;
final String? callerNumber;
final Duration duration;
final bool isMuted;
final bool isSpeakerOn;
final bool isOnHold;
const CallInfo({
this.state = CallState.idle,
this.callSid,
this.callerNumber,
this.duration = Duration.zero,
this.isMuted = false,
this.isSpeakerOn = false,
this.isOnHold = false,
});
CallInfo copyWith({
CallState? state,
String? callSid,
String? callerNumber,
Duration? duration,
bool? isMuted,
bool? isSpeakerOn,
bool? isOnHold,
}) {
return CallInfo(
state: state ?? this.state,
callSid: callSid ?? this.callSid,
callerNumber: callerNumber ?? this.callerNumber,
duration: duration ?? this.duration,
isMuted: isMuted ?? this.isMuted,
isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn,
isOnHold: isOnHold ?? this.isOnHold,
);
}
bool get isActive =>
state == CallState.ringing ||
state == CallState.connecting ||
state == CallState.connected;
}

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/agent_status.dart';
import '../models/queue_state.dart';
import '../services/api_client.dart';
import '../services/sse_service.dart';
class AgentProvider extends ChangeNotifier {
final ApiClient _api;
final SseService _sse;
AgentStatus? _status;
List<QueueInfo> _queues = [];
bool _sseConnected = false;
StreamSubscription? _sseSub;
StreamSubscription? _connSub;
AgentStatus? get status => _status;
List<QueueInfo> get queues => _queues;
bool get sseConnected => _sseConnected;
AgentProvider(this._api, this._sse) {
_connSub = _sse.connectionState.listen((connected) {
_sseConnected = connected;
notifyListeners();
});
_sseSub = _sse.events.listen(_handleSseEvent);
}
Future<void> fetchStatus() async {
try {
final response = await _api.dio.get('/agent/status');
_status = AgentStatus.fromJson(response.data);
notifyListeners();
} catch (_) {}
}
Future<void> updateStatus(AgentStatusValue newStatus) async {
final statusStr = newStatus.name;
try {
await _api.dio.post('/agent/status', data: {
'status': statusStr,
'is_logged_in': true,
});
_status = AgentStatus(
status: newStatus,
isLoggedIn: true,
currentCallSid: _status?.currentCallSid,
);
notifyListeners();
} catch (_) {}
}
Future<void> fetchQueues() async {
try {
final response = await _api.dio.get('/queues/state');
final data = response.data;
_queues = (data['queues'] as List)
.map((q) => QueueInfo.fromJson(q as Map<String, dynamic>))
.toList();
notifyListeners();
} catch (_) {}
}
Future<void> refresh() async {
await Future.wait([fetchStatus(), fetchQueues()]);
}
void _handleSseEvent(SseEvent event) {
switch (event.event) {
case 'call_enqueued':
case 'call_dequeued':
fetchQueues();
break;
case 'agent_status_changed':
fetchStatus();
break;
}
}
@override
void dispose() {
_sseSub?.cancel();
_connSub?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../services/api_client.dart';
import '../services/auth_service.dart';
import '../services/voice_service.dart';
import '../services/push_notification_service.dart';
import '../services/sse_service.dart';
enum AuthState { unauthenticated, authenticating, authenticated }
class AuthProvider extends ChangeNotifier {
final ApiClient _apiClient;
late final AuthService _authService;
late final VoiceService _voiceService;
late final PushNotificationService _pushService;
late final SseService _sseService;
AuthState _state = AuthState.unauthenticated;
User? _user;
String? _error;
AuthState get state => _state;
User? get user => _user;
String? get error => _error;
VoiceService get voiceService => _voiceService;
SseService get sseService => _sseService;
ApiClient get apiClient => _apiClient;
AuthProvider(this._apiClient) {
_authService = AuthService(_apiClient);
_voiceService = VoiceService(_apiClient);
_pushService = PushNotificationService(_apiClient);
_sseService = SseService(_apiClient);
_apiClient.onForceLogout = _handleForceLogout;
}
Future<void> tryRestoreSession() async {
final restored = await _authService.tryRestoreSession();
if (restored) {
_state = AuthState.authenticated;
await _initializeServices();
notifyListeners();
}
}
Future<void> login(String serverUrl, String username, String password) async {
_state = AuthState.authenticating;
_error = null;
notifyListeners();
try {
_user = await _authService.login(serverUrl, username, password);
_state = AuthState.authenticated;
await _initializeServices();
} catch (e) {
_state = AuthState.unauthenticated;
_error = e.toString().replaceFirst('Exception: ', '');
}
notifyListeners();
}
Future<void> _initializeServices() async {
try {
await _pushService.initialize();
} catch (_) {}
try {
await _voiceService.initialize();
} catch (_) {}
try {
await _sseService.connect();
} catch (_) {}
}
Future<void> logout() async {
_voiceService.dispose();
_sseService.disconnect();
await _authService.logout();
_state = AuthState.unauthenticated;
_user = null;
_error = null;
// Re-create services for potential re-login
_voiceService = VoiceService(_apiClient);
_pushService = PushNotificationService(_apiClient);
_sseService = SseService(_apiClient);
notifyListeners();
}
void _handleForceLogout() {
_state = AuthState.unauthenticated;
_user = null;
_error = 'Session expired. Please log in again.';
_sseService.disconnect();
notifyListeners();
}
@override
void dispose() {
_authService.dispose();
_voiceService.dispose();
_sseService.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,143 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:twilio_voice/twilio_voice.dart';
import '../models/call_info.dart';
import '../services/voice_service.dart';
class CallProvider extends ChangeNotifier {
final VoiceService _voiceService;
CallInfo _callInfo = const CallInfo();
Timer? _durationTimer;
StreamSubscription? _eventSub;
DateTime? _connectedAt;
CallInfo get callInfo => _callInfo;
CallProvider(this._voiceService) {
_eventSub = _voiceService.callEvents.listen(_handleCallEvent);
}
void _handleCallEvent(CallEvent event) {
switch (event) {
case CallEvent.incoming:
_callInfo = _callInfo.copyWith(
state: CallState.ringing,
);
break;
case CallEvent.ringing:
_callInfo = _callInfo.copyWith(state: CallState.connecting);
break;
case CallEvent.connected:
_connectedAt = DateTime.now();
_callInfo = _callInfo.copyWith(state: CallState.connected);
_startDurationTimer();
break;
case CallEvent.callEnded:
_stopDurationTimer();
_callInfo = const CallInfo(); // reset to idle
break;
case CallEvent.returningCall:
_callInfo = _callInfo.copyWith(state: CallState.connecting);
break;
case CallEvent.reconnecting:
break;
case CallEvent.reconnected:
break;
default:
break;
}
// Update caller info from active call
final call = TwilioVoice.instance.call;
final active = call.activeCall;
if (active != null) {
_callInfo = _callInfo.copyWith(
callerNumber: active.from,
);
// Fetch SID asynchronously
call.getSid().then((sid) {
if (sid != null && sid != _callInfo.callSid) {
_callInfo = _callInfo.copyWith(callSid: sid);
notifyListeners();
}
});
}
notifyListeners();
}
void _startDurationTimer() {
_durationTimer?.cancel();
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_connectedAt != null) {
_callInfo = _callInfo.copyWith(
duration: DateTime.now().difference(_connectedAt!),
);
notifyListeners();
}
});
}
void _stopDurationTimer() {
_durationTimer?.cancel();
_connectedAt = null;
}
Future<void> answer() => _voiceService.answer();
Future<void> reject() => _voiceService.reject();
Future<void> hangUp() => _voiceService.hangUp();
Future<void> toggleMute() async {
final newMuted = !_callInfo.isMuted;
await _voiceService.toggleMute(newMuted);
_callInfo = _callInfo.copyWith(isMuted: newMuted);
notifyListeners();
}
Future<void> toggleSpeaker() async {
final newSpeaker = !_callInfo.isSpeakerOn;
await _voiceService.toggleSpeaker(newSpeaker);
_callInfo = _callInfo.copyWith(isSpeakerOn: newSpeaker);
notifyListeners();
}
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
Future<void> makeCall(String number) async {
_callInfo = _callInfo.copyWith(
state: CallState.connecting,
callerNumber: number,
);
notifyListeners();
await _voiceService.makeCall(number);
}
Future<void> holdCall() async {
final sid = _callInfo.callSid;
if (sid == null) return;
await _voiceService.holdCall(sid);
_callInfo = _callInfo.copyWith(isOnHold: true);
notifyListeners();
}
Future<void> unholdCall() async {
final sid = _callInfo.callSid;
if (sid == null) return;
await _voiceService.unholdCall(sid);
_callInfo = _callInfo.copyWith(isOnHold: false);
notifyListeners();
}
Future<void> transferCall(String target) async {
final sid = _callInfo.callSid;
if (sid == null) return;
await _voiceService.transferCall(sid, target);
}
@override
void dispose() {
_stopDurationTimer();
_eventSub?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/call_provider.dart';
import '../models/call_info.dart';
import '../widgets/call_controls.dart';
import '../widgets/dialpad.dart';
class ActiveCallScreen extends StatefulWidget {
const ActiveCallScreen({super.key});
@override
State<ActiveCallScreen> createState() => _ActiveCallScreenState();
}
class _ActiveCallScreenState extends State<ActiveCallScreen> {
bool _showDialpad = false;
@override
Widget build(BuildContext context) {
final call = context.watch<CallProvider>();
final info = call.callInfo;
// Pop back when call ends
if (info.state == CallState.idle) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop();
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
// Caller info
Text(
info.callerNumber ?? 'Unknown',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_stateLabel(info.state),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
if (info.state == CallState.connected)
Text(
_formatDuration(info.duration),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(flex: 2),
// Dialpad overlay
if (_showDialpad)
Dialpad(
onDigit: (d) => call.sendDigits(d),
onClose: () => setState(() => _showDialpad = false),
),
// Controls
if (!_showDialpad)
CallControls(
callInfo: info,
onMute: () => call.toggleMute(),
onSpeaker: () => call.toggleSpeaker(),
onHold: () =>
info.isOnHold ? call.unholdCall() : call.holdCall(),
onDialpad: () => setState(() => _showDialpad = true),
onTransfer: () => _showTransferDialog(context, call),
onHangUp: () => call.hangUp(),
),
const Spacer(),
],
),
),
);
}
String _stateLabel(CallState state) {
switch (state) {
case CallState.ringing:
return 'Ringing...';
case CallState.connecting:
return 'Connecting...';
case CallState.connected:
return 'Connected';
case CallState.disconnected:
return 'Disconnected';
case CallState.idle:
return '';
}
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.toString().padLeft(2, '0');
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
void _showTransferDialog(BuildContext context, CallProvider call) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Transfer Call'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Extension or Queue ID',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final target = controller.text.trim();
if (target.isNotEmpty) {
call.transferCall(target);
Navigator.pop(ctx);
}
},
child: const Text('Transfer'),
),
],
),
);
}
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/agent_provider.dart';
import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart';
import '../widgets/dialpad.dart';
import '../widgets/queue_card.dart';
import 'active_call_screen.dart';
import 'settings_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh();
});
}
void _showDialer(BuildContext context) {
final numberController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Number display
TextField(
controller: numberController,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
textAlign: TextAlign.center,
style: Theme.of(ctx).textTheme.headlineSmall,
decoration: InputDecoration(
hintText: 'Enter phone number',
suffixIcon: IconButton(
icon: const Icon(Icons.backspace_outlined),
onPressed: () {
final text = numberController.text;
if (text.isNotEmpty) {
numberController.text =
text.substring(0, text.length - 1);
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
}
},
),
),
),
const SizedBox(height: 16),
// Dialpad
Dialpad(
onDigit: (digit) {
numberController.text += digit;
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
},
onClose: () => Navigator.pop(ctx),
),
const SizedBox(height: 8),
// Call button
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
icon: const Icon(Icons.call),
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().makeCall(number);
Navigator.pop(ctx);
}
},
),
const SizedBox(height: 16),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
final call = context.watch<CallProvider>();
// Navigate to active call screen when a call comes in
if (call.callInfo.isActive) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
(route) => route.isFirst,
);
});
}
return Scaffold(
appBar: AppBar(
title: const Text('TWP Softphone'),
actions: [
// SSE connection indicator
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.circle,
size: 12,
color: agent.sseConnected ? Colors.green : Colors.red,
),
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SettingsScreen())),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showDialer(context),
child: const Icon(Icons.phone),
),
body: RefreshIndicator(
onRefresh: () => agent.refresh(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const AgentStatusToggle(),
const SizedBox(height: 24),
Text('Queues',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (agent.queues.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No queues assigned')),
),
)
else
...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: QueueCard(queue: q),
)),
],
),
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void initState() {
super.initState();
_loadSavedServer();
}
Future<void> _loadSavedServer() async {
const storage = FlutterSecureStorage();
final saved = await storage.read(key: 'server_url');
if (saved != null && mounted) {
_serverController.text = saved;
}
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
var serverUrl = _serverController.text.trim();
if (!serverUrl.startsWith('http')) {
serverUrl = 'https://$serverUrl';
}
TextInput.finishAutofillContext();
context.read<AuthProvider>().login(
serverUrl,
_usernameController.text.trim(),
_passwordController.text,
);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: AutofillGroup(
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,
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
autofillHints: const [AutofillHints.username],
validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
if (auth.error != null) ...[
const SizedBox(height: 16),
Text(
auth.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: auth.state == AuthState.authenticating
? null
: _submit,
child: auth.state == AuthState.authenticating
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Connect'),
),
),
],
),
),
),
),
),
),
);
}
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../providers/auth_provider.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
String? _serverUrl;
@override
void initState() {
super.initState();
_loadServerUrl();
}
Future<void> _loadServerUrl() async {
const storage = FlutterSecureStorage();
final url = await storage.read(key: 'server_url');
if (mounted) setState(() => _serverUrl = url);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.dns),
title: const Text('Server'),
subtitle: Text(_serverUrl ?? 'Not configured'),
),
if (auth.user != null) ...[
ListTile(
leading: const Icon(Icons.person),
title: const Text('User'),
subtitle: Text(auth.user!.displayName),
),
ListTile(
leading: const Icon(Icons.badge),
title: const Text('Login'),
subtitle: Text(auth.user!.login),
),
],
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Logout', style: TextStyle(color: Colors.red)),
onTap: () async {
await auth.logout();
if (context.mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
),
],
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class ApiClient {
late final Dio dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
VoidCallback? onForceLogout;
ApiClient() {
dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
final refreshed = await _tryRefreshToken();
if (refreshed) {
final opts = error.requestOptions;
final token = await _storage.read(key: 'access_token');
opts.headers['Authorization'] = 'Bearer $token';
try {
final response = await dio.fetch(opts);
return handler.resolve(response);
} catch (e) {
return handler.next(error);
}
} else {
onForceLogout?.call();
}
}
handler.next(error);
},
));
}
Future<void> setBaseUrl(String serverUrl) async {
final url = serverUrl.endsWith('/')
? serverUrl.substring(0, serverUrl.length - 1)
: serverUrl;
dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
await _storage.write(key: 'server_url', value: url);
}
Future<void> restoreBaseUrl() async {
final url = await _storage.read(key: 'server_url');
if (url != null) {
dio.options.baseUrl = '$url/wp-json/twilio-mobile/v1';
}
}
Future<bool> _tryRefreshToken() async {
try {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) return false;
final response = await dio.post(
'/auth/refresh',
data: {'refresh_token': refreshToken},
options: Options(headers: {'Authorization': ''}),
);
if (response.statusCode == 200 && response.data['success'] == true) {
await _storage.write(
key: 'access_token', value: response.data['access_token']);
if (response.data['refresh_token'] != null) {
await _storage.write(
key: 'refresh_token', value: response.data['refresh_token']);
}
return true;
}
} catch (_) {}
return false;
}
}
typedef VoidCallback = void Function();

View File

@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/user.dart';
import 'api_client.dart';
class AuthService {
final ApiClient _api;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
Timer? _refreshTimer;
AuthService(this._api);
Future<User> login(String serverUrl, String username, String password,
{String? fcmToken}) async {
await _api.setBaseUrl(serverUrl);
final response = await _api.dio.post('/auth/login', data: {
'username': username,
'password': password,
if (fcmToken != null) 'fcm_token': fcmToken,
});
final data = response.data;
if (data['success'] != true) {
throw Exception(data['message'] ?? 'Login failed');
}
await _storage.write(key: 'access_token', value: data['access_token']);
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
return User.fromJson(data['user']);
}
Future<bool> tryRestoreSession() async {
final token = await _storage.read(key: 'access_token');
if (token == null) return false;
await _api.restoreBaseUrl();
if (_api.dio.options.baseUrl.isEmpty) return false;
try {
final response = await _api.dio.get('/agent/status');
return response.statusCode == 200;
} catch (_) {
return false;
}
}
Future<void> refreshToken() async {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) throw Exception('No refresh token');
final response = await _api.dio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final data = response.data;
if (data['success'] != true) {
throw Exception('Token refresh failed');
}
await _storage.write(key: 'access_token', value: data['access_token']);
if (data['refresh_token'] != null) {
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
}
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
}
void _scheduleRefresh(int expiresInSeconds) {
_refreshTimer?.cancel();
// Refresh 2 minutes before expiry
final refreshIn = Duration(seconds: expiresInSeconds - 120);
if (refreshIn.isNegative) return;
_refreshTimer = Timer(refreshIn, () async {
try {
await refreshToken();
} catch (_) {}
});
}
Future<void> logout() async {
_refreshTimer?.cancel();
try {
await _api.dio.post('/auth/logout');
} catch (_) {}
await _storage.deleteAll();
}
void dispose() {
_refreshTimer?.cancel();
}
}

View File

@@ -0,0 +1,78 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'api_client.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// VoIP pushes are handled natively by twilio_voice plugin.
// Other data messages can show a local notification if needed.
}
class PushNotificationService {
final ApiClient _api;
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
PushNotificationService(this._api);
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 and register FCM token
final token = await _messaging.getToken();
if (token != null) {
await _registerToken(token);
}
// Listen for token refresh
_messaging.onTokenRefresh.listen(_registerToken);
// Handle foreground messages (non-VoIP)
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
}
Future<void> _registerToken(String token) async {
try {
await _api.dio.post('/fcm/register', data: {'fcm_token': token});
} catch (_) {}
}
void _handleForegroundMessage(RemoteMessage message) {
final data = message.data;
final type = data['type'];
// VoIP incoming_call is handled by twilio_voice natively
if (type == 'incoming_call') return;
// Show local notification for other types (missed call, queue alert, etc.)
_localNotifications.show(
message.hashCode,
data['title'] ?? 'TWP Softphone',
data['body'] ?? '',
const NotificationDetails(
android: AndroidNotificationDetails(
'twp_general',
'General Notifications',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/app_config.dart';
import 'api_client.dart';
class SseEvent {
final String event;
final Map<String, dynamic> data;
SseEvent({required this.event, required this.data});
}
class SseService {
final ApiClient _api;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
final StreamController<SseEvent> _eventController =
StreamController<SseEvent>.broadcast();
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
CancelToken? _cancelToken;
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
bool _shouldReconnect = true;
Stream<SseEvent> get events => _eventController.stream;
Stream<bool> get connectionState => _connectionController.stream;
SseService(this._api);
Future<void> connect() async {
_shouldReconnect = true;
_reconnectAttempt = 0;
await _doConnect();
}
Future<void> _doConnect() async {
_cancelToken?.cancel();
_cancelToken = CancelToken();
try {
final token = await _storage.read(key: 'access_token');
final response = await _api.dio.get(
'/stream/events',
options: Options(
headers: {'Authorization': 'Bearer $token'},
responseType: ResponseType.stream,
),
cancelToken: _cancelToken,
);
_connectionController.add(true);
_reconnectAttempt = 0;
final stream = response.data.stream as Stream<List<int>>;
String buffer = '';
await for (final chunk in stream) {
buffer += utf8.decode(chunk);
final lines = buffer.split('\n');
buffer = lines.removeLast(); // keep incomplete line in buffer
String? eventName;
String? dataStr;
for (final line in lines) {
if (line.startsWith('event:')) {
eventName = line.substring(6).trim();
} else if (line.startsWith('data:')) {
dataStr = line.substring(5).trim();
} else if (line.isEmpty && eventName != null && dataStr != null) {
try {
final data = jsonDecode(dataStr) as Map<String, dynamic>;
_eventController.add(SseEvent(event: eventName, data: data));
} catch (_) {}
eventName = null;
dataStr = null;
}
}
}
} catch (e) {
if (e is DioException && e.type == DioExceptionType.cancel) return;
_connectionController.add(false);
}
if (_shouldReconnect) {
_scheduleReconnect();
}
}
void _scheduleReconnect() {
_reconnectTimer?.cancel();
final delay = Duration(
milliseconds: min(
AppConfig.sseMaxReconnect.inMilliseconds,
AppConfig.sseReconnectBase.inMilliseconds *
pow(2, _reconnectAttempt).toInt(),
),
);
_reconnectAttempt++;
_reconnectTimer = Timer(delay, _doConnect);
}
void disconnect() {
_shouldReconnect = false;
_reconnectTimer?.cancel();
_cancelToken?.cancel();
_connectionController.add(false);
}
void dispose() {
disconnect();
_eventController.close();
_connectionController.close();
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:twilio_voice/twilio_voice.dart';
import 'api_client.dart';
class VoiceService {
final ApiClient _api;
Timer? _tokenRefreshTimer;
String? _identity;
final StreamController<CallEvent> _callEventController =
StreamController<CallEvent>.broadcast();
Stream<CallEvent> get callEvents => _callEventController.stream;
VoiceService(this._api);
Future<void> initialize() async {
await _fetchAndRegisterToken();
TwilioVoice.instance.callEventsListener.listen((event) {
_callEventController.add(event);
});
// Refresh token every 50 minutes
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = Timer.periodic(
const Duration(minutes: 50),
(_) => _fetchAndRegisterToken(),
);
}
Future<void> _fetchAndRegisterToken() async {
try {
final response = await _api.dio.get('/voice/token');
final data = response.data;
final token = data['token'] as String;
_identity = data['identity'] as String;
await TwilioVoice.instance.setTokens(accessToken: token);
} catch (e) {
// Token fetch failed - will retry on next interval
}
}
String? get identity => _identity;
Future<void> answer() async {
await TwilioVoice.instance.call.answer();
}
Future<void> reject() async {
await TwilioVoice.instance.call.hangUp();
}
Future<void> hangUp() async {
await TwilioVoice.instance.call.hangUp();
}
Future<void> toggleMute(bool mute) async {
await TwilioVoice.instance.call.toggleMute(mute);
}
Future<void> toggleSpeaker(bool speaker) async {
await TwilioVoice.instance.call.toggleSpeaker(speaker);
}
Future<bool> makeCall(String to) async {
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
}
Future<void> sendDigits(String digits) async {
await TwilioVoice.instance.call.sendDigits(digits);
}
Future<void> holdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/hold');
}
Future<void> unholdCall(String callSid) async {
await _api.dio.post('/calls/$callSid/unhold');
}
Future<void> transferCall(String callSid, String target) async {
await _api.dio.post('/calls/$callSid/transfer', data: {'target': target});
}
void dispose() {
_tokenRefreshTimer?.cancel();
_callEventController.close();
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/agent_status.dart';
import '../providers/agent_provider.dart';
class AgentStatusToggle extends StatelessWidget {
const AgentStatusToggle({super.key});
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
final current = agent.status?.status ?? AgentStatusValue.offline;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Agent Status',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
SegmentedButton<AgentStatusValue>(
segments: const [
ButtonSegment(
value: AgentStatusValue.available,
label: Text('Available'),
icon: Icon(Icons.circle, color: Colors.green, size: 12),
),
ButtonSegment(
value: AgentStatusValue.busy,
label: Text('Busy'),
icon: Icon(Icons.circle, color: Colors.orange, size: 12),
),
ButtonSegment(
value: AgentStatusValue.offline,
label: Text('Offline'),
icon: Icon(Icons.circle, color: Colors.red, size: 12),
),
],
selected: {current},
onSelectionChanged: (selection) {
agent.updateStatus(selection.first);
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import '../models/call_info.dart';
class CallControls extends StatelessWidget {
final CallInfo callInfo;
final VoidCallback onMute;
final VoidCallback onSpeaker;
final VoidCallback onHold;
final VoidCallback onDialpad;
final VoidCallback onTransfer;
final VoidCallback onHangUp;
const CallControls({
super.key,
required this.callInfo,
required this.onMute,
required this.onSpeaker,
required this.onHold,
required this.onDialpad,
required this.onTransfer,
required this.onHangUp,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: callInfo.isMuted ? Icons.mic_off : Icons.mic,
label: 'Mute',
active: callInfo.isMuted,
onTap: onMute,
),
_ControlButton(
icon: callInfo.isSpeakerOn
? Icons.volume_up
: Icons.volume_down,
label: 'Speaker',
active: callInfo.isSpeakerOn,
onTap: onSpeaker,
),
_ControlButton(
icon: callInfo.isOnHold ? Icons.play_arrow : Icons.pause,
label: callInfo.isOnHold ? 'Resume' : 'Hold',
active: callInfo.isOnHold,
onTap: onHold,
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: Icons.dialpad,
label: 'Dialpad',
onTap: onDialpad,
),
_ControlButton(
icon: Icons.phone_forwarded,
label: 'Transfer',
onTap: onTransfer,
),
],
),
const SizedBox(height: 24),
FloatingActionButton.large(
onPressed: onHangUp,
backgroundColor: Colors.red,
child: const Icon(Icons.call_end, color: Colors.white, size: 36),
),
],
),
);
}
}
class _ControlButton extends StatelessWidget {
final IconData icon;
final String label;
final bool active;
final VoidCallback onTap;
const _ControlButton({
required this.icon,
required this.label,
this.active = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: onTap,
icon: Icon(icon),
style: IconButton.styleFrom(
backgroundColor: active
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: active
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
class Dialpad extends StatelessWidget {
final void Function(String digit) onDigit;
final VoidCallback onClose;
const Dialpad({super.key, required this.onDigit, required this.onClose});
static const _keys = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['*', '0', '#'],
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
..._keys.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: row
.map((key) => Padding(
padding: const EdgeInsets.all(4),
child: InkWell(
onTap: () => onDigit(key),
borderRadius: BorderRadius.circular(40),
child: Container(
width: 64,
height: 64,
alignment: Alignment.center,
child: Text(
key,
style: Theme.of(context)
.textTheme
.headlineSmall,
),
),
),
))
.toList(),
)),
const SizedBox(height: 8),
TextButton(
onPressed: onClose,
child: const Text('Close'),
),
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import '../models/queue_state.dart';
class QueueCard extends StatelessWidget {
final QueueInfo queue;
const QueueCard({super.key, required this.queue});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: queue.waitingCount > 0
? Colors.orange.shade100
: Colors.green.shade100,
child: Text(
'${queue.waitingCount}',
style: TextStyle(
color: queue.waitingCount > 0 ? Colors.orange : Colors.green,
fontWeight: FontWeight.bold,
),
),
),
title: Text(queue.name),
subtitle: Text(
queue.waitingCount > 0
? '${queue.waitingCount} waiting'
: 'No calls waiting',
),
trailing: queue.extension != null
? Chip(label: Text('Ext ${queue.extension}'))
: null,
),
);
}
}

890
mobile/pubspec.lock Normal file
View File

@@ -0,0 +1,890 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
url: "https://pub.dev"
source: hosted
version: "1.3.59"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev"
source: hosted
version: "10.0.1"
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: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
url: "https://pub.dev"
source: hosted
version: "4.0.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462"
url: "https://pub.dev"
source: hosted
version: "2.12.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
url: "https://pub.dev"
source: hosted
version: "8.12.4"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
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"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
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"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
js_notifications:
dependency: transitive
description:
name: js_notifications
sha256: "980280649b29d618669866bdbf99e4a813009033101a434652d231eaf976c975"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0"
url: "https://pub.dev"
source: hosted
version: "6.13.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
url: "https://pub.dev"
source: hosted
version: "0.17.5"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
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: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
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"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.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"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
simple_print:
dependency: transitive
description:
name: simple_print
sha256: "49b6796fb93b557bbba4eca687b8521d3d20ffee47d74d8a0857f6ee0727042b"
url: "https://pub.dev"
source: hosted
version: "0.0.1+2"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae"
url: "https://pub.dev"
source: hosted
version: "1.3.10"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
twilio_voice:
dependency: "direct main"
description:
name: twilio_voice
sha256: "010ac416dc8bcc842486407aec2e6f97fd5bb34b521c04fd4a4a5710f9ec045b"
url: "https://pub.dev"
source: hosted
version: "0.3.2+2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_callkit:
dependency: transitive
description:
name: web_callkit
sha256: ca05b0fd79366ea072c1ea4982c8a7880ad219e4d1cc74a3a541b010533febee
url: "https://pub.dev"
source: hosted
version: "0.0.4+1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
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: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

29
mobile/pubspec.yaml Normal file
View File

@@ -0,0 +1,29 @@
name: twp_softphone
description: TWP Softphone - VoIP client for Twilio WordPress Plugin
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.5.0
dependencies:
flutter:
sdk: flutter
twilio_voice: ^0.3.0
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
dio: ^5.4.0
flutter_secure_storage: ^10.0.0
provider: ^6.1.0
flutter_local_notifications: ^17.0.0
json_annotation: ^4.8.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
build_runner: ^2.4.0
json_serializable: ^6.7.0
flutter:
uses-material-design: true

View File

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

View File

@@ -20,6 +20,8 @@ define('TWP_DB_VERSION', '1.6.2'); // Track database version separately
define('TWP_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('TWP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('TWP_PLUGIN_URL', plugin_dir_url(__FILE__)); define('TWP_PLUGIN_URL', plugin_dir_url(__FILE__));
define('TWP_PLUGIN_BASENAME', plugin_basename(__FILE__)); define('TWP_PLUGIN_BASENAME', plugin_basename(__FILE__));
// External SDK location - survives plugin updates (wp-content/twilio-sdk/)
define('TWP_EXTERNAL_SDK_DIR', dirname(dirname(TWP_PLUGIN_DIR)) . '/twilio-sdk/');
/** /**
* Plugin activation hook * Plugin activation hook
@@ -31,17 +33,27 @@ function twp_activate() {
/** /**
* Check if Twilio SDK is installed and show admin notice if not * Check if Twilio SDK is installed and show admin notice if not
* Checks external location first (survives plugin updates), then internal fallback
*/ */
function twp_check_sdk_installation() { function twp_check_sdk_installation() {
$autoloader_path = TWP_PLUGIN_DIR . 'vendor/autoload.php';
$sdk_installed = false; $sdk_installed = false;
if (file_exists($autoloader_path)) { // Priority 1: Check external SDK location (survives plugin updates)
// Try to load autoloader and check for classes $external_autoloader = TWP_EXTERNAL_SDK_DIR . 'autoload.php';
require_once $autoloader_path; if (file_exists($external_autoloader)) {
require_once $external_autoloader;
$sdk_installed = class_exists('Twilio\Rest\Client'); $sdk_installed = class_exists('Twilio\Rest\Client');
} }
// Priority 2: Fall back to internal vendor directory
if (!$sdk_installed) {
$internal_autoloader = TWP_PLUGIN_DIR . 'vendor/autoload.php';
if (file_exists($internal_autoloader)) {
require_once $internal_autoloader;
$sdk_installed = class_exists('Twilio\Rest\Client');
}
}
if (!$sdk_installed) { if (!$sdk_installed) {
add_action('admin_notices', 'twp_sdk_missing_notice'); add_action('admin_notices', 'twp_sdk_missing_notice');
} }
@@ -55,10 +67,12 @@ function twp_sdk_missing_notice() {
<div class="notice notice-error is-dismissible"> <div class="notice notice-error is-dismissible">
<h3>Twilio WordPress Plugin - SDK Required</h3> <h3>Twilio WordPress Plugin - SDK Required</h3>
<p><strong>The Twilio PHP SDK is required for this plugin to work.</strong></p> <p><strong>The Twilio PHP SDK is required for this plugin to work.</strong></p>
<p>To install the SDK, run this command in your plugin directory:</p> <p><strong>Recommended:</strong> Install SDK to external location (survives plugin updates):</p>
<code>chmod +x install-twilio-sdk-external.sh && ./install-twilio-sdk-external.sh</code>
<p style="margin-top: 10px;"><strong>Alternative:</strong> Install SDK inside plugin folder:</p>
<code>chmod +x install-twilio-sdk.sh && ./install-twilio-sdk.sh</code> <code>chmod +x install-twilio-sdk.sh && ./install-twilio-sdk.sh</code>
<p>Or install via Composer: <code>composer install</code></p> <p style="margin-top: 10px;"><em>Plugin path: <?php echo esc_html(TWP_PLUGIN_DIR); ?></em></p>
<p><em>Plugin path: <?php echo TWP_PLUGIN_DIR; ?></em></p> <p><em>External SDK path: <?php echo esc_html(TWP_EXTERNAL_SDK_DIR); ?></em></p>
</div> </div>
<?php <?php
} }
@@ -126,6 +140,52 @@ function twp_deactivate() {
register_activation_hook(__FILE__, 'twp_activate'); register_activation_hook(__FILE__, 'twp_activate');
register_deactivation_hook(__FILE__, 'twp_deactivate'); register_deactivation_hook(__FILE__, 'twp_deactivate');
/**
* Check SDK status after plugin updates
* Shows warning if SDK was deleted during update and external SDK not available
*/
function twp_check_sdk_after_update($upgrader_object, $options) {
// Only run for plugin updates
if ($options['action'] !== 'update' || $options['type'] !== 'plugin') {
return;
}
// Check if this plugin was updated
$updated_plugins = isset($options['plugins']) ? $options['plugins'] : array();
if (!in_array(TWP_PLUGIN_BASENAME, $updated_plugins)) {
return;
}
// Check if SDK is available
$external_sdk = file_exists(TWP_EXTERNAL_SDK_DIR . 'autoload.php');
$internal_sdk = file_exists(TWP_PLUGIN_DIR . 'vendor/autoload.php');
if (!$external_sdk && !$internal_sdk) {
// Set a transient to show warning on next admin page load
set_transient('twp_sdk_update_warning', true, 60 * 5);
}
}
add_action('upgrader_process_complete', 'twp_check_sdk_after_update', 10, 2);
/**
* Show SDK update warning
*/
function twp_show_sdk_update_warning() {
if (get_transient('twp_sdk_update_warning')) {
delete_transient('twp_sdk_update_warning');
?>
<div class="notice notice-warning is-dismissible">
<h3>Twilio WordPress Plugin - SDK Reinstall Required</h3>
<p><strong>The plugin was updated and the Twilio SDK needs to be reinstalled.</strong></p>
<p>To prevent this in the future, install the SDK to the external location:</p>
<code>cd <?php echo esc_html(TWP_PLUGIN_DIR); ?> && ./install-twilio-sdk-external.sh</code>
<p style="margin-top: 10px;">The external SDK at <code><?php echo esc_html(TWP_EXTERNAL_SDK_DIR); ?></code> survives plugin updates.</p>
</div>
<?php
}
}
add_action('admin_notices', 'twp_show_sdk_update_warning');
/** /**
* Core plugin class * Core plugin class
*/ */