Compare commits
18 Commits
v2.8.9
...
2026.03.06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc6fa8c3c | ||
|
|
d41b6aa535 | ||
|
|
4da794ed0c | ||
|
|
5adfa694c1 | ||
|
|
826fd3ae39 | ||
|
|
5c6932f1d1 | ||
| 03692608cc | |||
| b95d1dc461 | |||
| 59df695530 | |||
| 03b6e5d70f | |||
| f8919af31a | |||
| 3e4dff5c4e | |||
| f0806d7e67 | |||
| 61beadcd06 | |||
| 026edde33b | |||
| a3345ed854 | |||
| 384ad5e265 | |||
| 86dd477d4f |
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(scp:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)"
|
||||
],
|
||||
"deny": [],
|
||||
"defaultMode": "acceptEdits"
|
||||
}
|
||||
}
|
||||
82
.gitea/workflows/release.yml
Normal file
82
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
username: ${{ secrets.CI_USER }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
fetch-depth: 0 # Important: Fetch all history for commit messages
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=$(date +'%Y.%m.%d-%H%M')" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
# Find the most recent tag
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 --always 2>/dev/null || echo "none")
|
||||
|
||||
if [ "$LATEST_TAG" = "none" ]; then
|
||||
# If no previous tag exists, get all commits
|
||||
COMMITS=$(git log --pretty=format:"* %s (%h)" --no-merges)
|
||||
else
|
||||
# Get commits since the last tag
|
||||
COMMITS=$(git log --pretty=format:"* %s (%h)" ${LATEST_TAG}..HEAD --no-merges)
|
||||
fi
|
||||
|
||||
# Create release notes with header (without encoding newlines)
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "## What's New in ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_OUTPUT
|
||||
echo "$COMMITS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update plugin version
|
||||
run: |
|
||||
# Replace version placeholder with actual version
|
||||
sed -i "s/Version: {auto_update_value_on_deploy}/Version: ${{ steps.get_version.outputs.version }}/" twilio-wp-plugin.php
|
||||
sed -i "s/TWP_VERSION', '{auto_update_value_on_deploy}/TWP_VERSION', '${{ steps.get_version.outputs.version }}/" twilio-wp-plugin.php
|
||||
|
||||
# Verify the changes were made
|
||||
grep "Version:" twilio-wp-plugin.php
|
||||
grep "TWP_VERSION" twilio-wp-plugin.php
|
||||
|
||||
- name: Create ZIP archive
|
||||
run: |
|
||||
# Create a temp directory with the correct plugin folder name
|
||||
mkdir -p /tmp/twilio-wp-plugin
|
||||
|
||||
# Copy files to the temp directory (excluding git and other unnecessary files)
|
||||
cp -r * /tmp/twilio-wp-plugin/ 2>/dev/null || true
|
||||
|
||||
# Exclude .git and .gitea directories
|
||||
rm -rf /tmp/twilio-wp-plugin/.git /tmp/twilio-wp-plugin/.gitea 2>/dev/null || true
|
||||
|
||||
# Create the ZIP file with the proper structure
|
||||
cd /tmp
|
||||
zip -r $GITHUB_WORKSPACE/twilio-wp-plugin.zip twilio-wp-plugin
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: "${{ secrets.REPO_TOKEN }}"
|
||||
title: "Twilio WP Plugin Release ${{ steps.get_version.outputs.version }}"
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
body: "${{ steps.release_notes.outputs.notes }}"
|
||||
files: |
|
||||
twilio-wp-plugin.zip
|
||||
53
.gitea/workflows/update-version.yml
Normal file
53
.gitea/workflows/update-version.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Update Plugin Version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
update-version:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get release tag
|
||||
id: get_tag
|
||||
run: echo "TAG=${GITEA_REF#refs/tags/}" >> $GITEA_ENV
|
||||
|
||||
- name: Update version in plugin file
|
||||
run: |
|
||||
# Replace version in main plugin file (both header and constant)
|
||||
sed -i "s/Version: {auto_update_value_on_deploy}/Version: ${{ env.TAG }}/" twilio-wp-plugin.php
|
||||
sed -i "s/TWP_VERSION', '{auto_update_value_on_deploy}/TWP_VERSION', '${{ env.TAG }}/" twilio-wp-plugin.php
|
||||
|
||||
# Verify changes
|
||||
grep "Version:" twilio-wp-plugin.php
|
||||
grep "TWP_VERSION" twilio-wp-plugin.php
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config --local user.email "action@gitea.com"
|
||||
git config --local user.name "Gitea Action"
|
||||
git add twilio-wp-plugin.php
|
||||
git commit -m "Update version to ${{ env.TAG }}" || echo "No changes to commit"
|
||||
git push || echo "Nothing to push"
|
||||
|
||||
- name: Create plugin zip
|
||||
run: |
|
||||
mkdir -p /tmp/twilio-wp-plugin
|
||||
rsync -av --exclude=".git" --exclude=".gitea" --exclude="build" . /tmp/twilio-wp-plugin/
|
||||
cd /tmp
|
||||
zip -r $GITEA_WORK_DIR/twilio-wp-plugin.zip twilio-wp-plugin
|
||||
|
||||
- name: Upload zip to release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ gitea.event.release.upload_url }}
|
||||
asset_path: twilio-wp-plugin.zip
|
||||
asset_name: twilio-wp-plugin.zip
|
||||
asset_content_type: application/zip
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -6,6 +6,25 @@
|
||||
- **URL**: `https://phone.cloud-hosting.io/`
|
||||
- **Deployment**: rsync to Docker (remote server only, not local)
|
||||
- **SDK**: Twilio PHP SDK v8.7.0
|
||||
- **PHP**: 8.0+ required
|
||||
- **Optional**: AWS SDK (`aws/aws-sdk-php`) for SNS SMS provider
|
||||
- **External SDK**: `wp-content/twilio-sdk/` (survives plugin updates)
|
||||
|
||||
## Commands
|
||||
- **Install SDK (recommended)**: `./install-twilio-sdk-external.sh` (installs to `wp-content/twilio-sdk/`)
|
||||
- **Install SDK (internal)**: `./install-twilio-sdk.sh` (installs to `vendor/`, lost on plugin update)
|
||||
- **Test SDK**: `php test-sdk.php`
|
||||
- **Composer install SDK**: `composer install-sdk`
|
||||
- **Deploy**: rsync to Docker (remote server, see production path above)
|
||||
- **CI/CD**: Gitea workflows in `.gitea/workflows/` — `release.yml`, `update-version.yml`
|
||||
|
||||
## Directory Structure
|
||||
- `twilio-wp-plugin.php` — Main plugin file, constants, SDK loading
|
||||
- `includes/` — All backend classes (28 class files)
|
||||
- `admin/` — Admin UI class (`TWP_Admin`), mobile app settings page
|
||||
- `assets/js/` — Browser phone JS, service worker
|
||||
- `assets/images/`, `assets/sounds/` — Static assets
|
||||
- `.gitea/workflows/` — CI/CD (release, version update)
|
||||
|
||||
## Phone Variable Names
|
||||
**Use**: `incoming_number`, `agent_number`, `customer_number`, `workflow_number`, `queue_number`, `default_number`
|
||||
@@ -17,11 +36,20 @@
|
||||
- **TWP_Admin**: Has `find_customer_call_leg()` - CRITICAL for call control
|
||||
- **TWP_TTS_Helper**: ElevenLabs/Alice fallback, 30-day cache
|
||||
- **TWP_User_Queue_Manager**: Auto-creates queues/extensions (100-9999)
|
||||
- **TWP_Webhooks**: 26 endpoints at `twilio-webhook/v1`
|
||||
- **TWP_Activator**: Creates 15 DB tables, run `ensure_tables_exist()` if missing
|
||||
- **TWP_Webhooks**: 35 endpoints at `twilio-webhook/v1`
|
||||
- **TWP_Activator**: Creates 16 DB tables, run `ensure_tables_exist()` if missing
|
||||
- **TWP_Core**: Main plugin orchestrator, hooks all classes together
|
||||
- **TWP_SMS_Manager**: SMS abstraction with provider interface
|
||||
- **TWP_SMS_Provider_Twilio** / **TWP_SMS_Provider_SNS**: SMS providers (Twilio default, AWS SNS optional)
|
||||
- **TWP_Mobile_API**: REST API for mobile app
|
||||
- **TWP_Mobile_Auth** / **TWP_Mobile_SSE** / **TWP_FCM**: Mobile auth, server-sent events, push notifications
|
||||
- **TWP_Call_Queue**: Queue operations and management
|
||||
- **TWP_Callback_Manager**: Callback request handling
|
||||
- **TWP_Workflow**: Workflow step execution engine
|
||||
- **TWP_Auto_Updater**: Plugin auto-update from Gitea releases
|
||||
|
||||
## Database
|
||||
15 tables with `twp_` prefix. Key notes:
|
||||
16 tables with `twp_` prefix. Key notes:
|
||||
- `twp_call_queues`: User queues (general/personal/hold)
|
||||
- `twp_agent_status`: Has `auto_busy_at` for 1-min auto-revert
|
||||
- `twp_queued_calls`: Uses `enqueued_at` not `joined_at`
|
||||
@@ -40,13 +68,6 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
||||
- Queue: Pass `waitUrl` as option in `enqueue()`
|
||||
- TwiML: Use SDK classes, not raw XML
|
||||
|
||||
## Recent Changes (v2.3.0)
|
||||
- Browser phone moved to admin-only
|
||||
- Call control uses `find_customer_call_leg()` to prevent disconnections
|
||||
- Auto-creates user queues/extensions when needed
|
||||
- Firefox support added
|
||||
- 1-min agent status auto-revert
|
||||
|
||||
## Development Notes
|
||||
- **API**: E.164 format (+1XXXXXXXXXX)
|
||||
- **Database**: Use `$wpdb`, prepared statements
|
||||
@@ -59,7 +80,24 @@ $api->update_call($customer_call_sid, ['twiml' => $twiml_xml]);
|
||||
- User-specific queues with extensions
|
||||
- Browser phone at `admin.php?page=twilio-wp-browser-phone`
|
||||
- ElevenLabs TTS with Alice fallback
|
||||
- 68 AJAX actions, 26 REST endpoints
|
||||
- 77 AJAX actions, 35 REST endpoints
|
||||
- Browser phone moved to admin-only (v2.3.0)
|
||||
- Firefox, Chrome, Safari, Edge support
|
||||
- 1-min agent status auto-revert
|
||||
|
||||
## 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
427
DEBUGGING-TABLET.md
Normal 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
|
||||
62
README.md
62
README.md
@@ -8,11 +8,20 @@ This plugin **requires** the Twilio PHP SDK v8.7.0 to function. The plugin will
|
||||
|
||||
## 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
|
||||
chmod +x 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**:
|
||||
```bash
|
||||
@@ -23,6 +32,7 @@ This plugin **requires** the Twilio PHP SDK v8.7.0 to function. The plugin will
|
||||
- Go to **Twilio** → **Settings**
|
||||
- Enter Account SID and Auth Token
|
||||
- Configure default phone numbers
|
||||
- Set Twilio Edge Location (for browser phone - see Browser Phone Setup below)
|
||||
|
||||
4. **Set up Phone Numbers** in Twilio Console:
|
||||
- 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**:
|
||||
- Go to **Twilio** → **Settings**
|
||||
- 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
|
||||
|
||||
**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):
|
||||
- Navigate to **WordPress Admin** → **Twilio** → **Browser Phone**
|
||||
- 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
|
||||
- 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"
|
||||
|
||||
**Recommended Solution** (SDK survives plugin updates):
|
||||
```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
|
||||
# Test installation
|
||||
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
|
||||
- Verify queue exists and is active
|
||||
- Check agent group has members
|
||||
@@ -551,7 +593,19 @@ All webhooks are REST API endpoints under `/wp-json/twilio-webhook/v1/`:
|
||||
|
||||
## 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
|
||||
- **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
|
||||
@@ -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.
|
||||
@@ -120,6 +120,15 @@ class TWP_Admin {
|
||||
array($this, 'display_plugin_settings')
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'twilio-wp-plugin',
|
||||
'Mobile App',
|
||||
'Mobile App',
|
||||
'manage_options',
|
||||
'twilio-wp-mobile-app',
|
||||
array($this, 'display_mobile_app_settings')
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'twilio-wp-plugin',
|
||||
'Phone Schedules',
|
||||
@@ -338,6 +347,25 @@ class TWP_Admin {
|
||||
</td>
|
||||
</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>
|
||||
|
||||
<h2>Eleven Labs API Settings</h2>
|
||||
@@ -3809,6 +3837,7 @@ class TWP_Admin {
|
||||
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_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_voice_id');
|
||||
register_setting('twilio-wp-settings-group', 'twp_elevenlabs_model_id');
|
||||
@@ -6987,6 +7016,7 @@ class TWP_Admin {
|
||||
<div class="phone-interface">
|
||||
<div class="phone-display">
|
||||
<div id="phone-status">Ready</div>
|
||||
<div id="device-connection-status" style="font-size: 12px; color: #999; margin-top: 5px;">Loading...</div>
|
||||
<div id="phone-number-display"></div>
|
||||
<div id="call-timer" style="display: none;">00:00</div>
|
||||
</div>
|
||||
@@ -7415,6 +7445,11 @@ class TWP_Admin {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Preload and preconnect for faster loading -->
|
||||
<link rel="preload" href="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js" as="script">
|
||||
<link rel="dns-prefetch" href="//unpkg.com">
|
||||
<link rel="dns-prefetch" href="//chunderw-vpc-gll.twilio.com">
|
||||
<link rel="preconnect" href="https://chunderw-vpc-gll.twilio.com" crossorigin>
|
||||
<!-- Twilio Voice SDK v2 from unpkg CDN -->
|
||||
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
|
||||
<script>
|
||||
@@ -7425,6 +7460,210 @@ class TWP_Admin {
|
||||
var callStartTime = null;
|
||||
var tokenRefreshTimer = 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
|
||||
function waitForTwilioSDK(callback) {
|
||||
@@ -7441,6 +7680,18 @@ class TWP_Admin {
|
||||
// Initialize the browser phone
|
||||
function initializeBrowserPhone() {
|
||||
$('#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
|
||||
waitForTwilioSDK(function() {
|
||||
@@ -7459,9 +7710,11 @@ class TWP_Admin {
|
||||
// WordPress wp_send_json_error sends the error message as response.data
|
||||
var errorMsg = response.data || response.error || 'Unknown error';
|
||||
showError('Failed to initialize: ' + errorMsg);
|
||||
updateConnectionStatus('disconnected');
|
||||
}
|
||||
}).fail(function() {
|
||||
showError('Failed to connect to server');
|
||||
updateConnectionStatus('disconnected');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7509,36 +7762,77 @@ class TWP_Admin {
|
||||
throw new Error('Twilio Voice SDK not loaded');
|
||||
}
|
||||
|
||||
console.log('Setting up Twilio Device...');
|
||||
updateConnectionStatus('connecting');
|
||||
|
||||
// Request media permissions before setting up device
|
||||
const hasPermissions = await requestMediaPermissions();
|
||||
if (!hasPermissions) {
|
||||
updateConnectionStatus('disconnected');
|
||||
return; // Stop setup if permissions denied
|
||||
}
|
||||
|
||||
// Clean up existing device if any
|
||||
if (device) {
|
||||
console.log('Destroying existing device');
|
||||
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
|
||||
// Note: Voice SDK v2 uses Twilio.Device directly, not Twilio.Voice.Device
|
||||
device = new Twilio.Device(token, {
|
||||
logLevel: 1, // 0 = TRACE, 1 = DEBUG
|
||||
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
|
||||
// Device registered and ready
|
||||
device.on('registered', function() {
|
||||
console.log('Device registered successfully');
|
||||
$('#phone-status').text('Ready').css('color', '#4CAF50');
|
||||
$('#call-btn').prop('disabled', false);
|
||||
updateConnectionStatus('connected');
|
||||
});
|
||||
|
||||
// Device unregistered
|
||||
device.on('unregistered', function() {
|
||||
console.log('Device unregistered');
|
||||
updateConnectionStatus('disconnected');
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
device.on('error', function(error) {
|
||||
console.error('Twilio Device Error:', error);
|
||||
console.error('Error code:', error.code, 'Message:', error.message);
|
||||
updateConnectionStatus('disconnected');
|
||||
|
||||
var errorMsg = error.message || error.toString();
|
||||
|
||||
@@ -7551,6 +7845,15 @@ class TWP_Admin {
|
||||
errorMsg = 'Token error: ' + errorMsg + ' - The page will automatically try to refresh the token.';
|
||||
// Try to reinitialize after token error
|
||||
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);
|
||||
@@ -7558,16 +7861,32 @@ class TWP_Admin {
|
||||
|
||||
// Handle incoming calls
|
||||
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;
|
||||
var callerNumber = call.parameters.From || 'Unknown Number';
|
||||
|
||||
$('#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();
|
||||
$('#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
|
||||
setupCallHandlers(call);
|
||||
|
||||
if ($('#auto-answer').is(':checked')) {
|
||||
console.log('Auto-answer enabled, accepting call');
|
||||
call.accept();
|
||||
}
|
||||
});
|
||||
@@ -7590,6 +7909,8 @@ class TWP_Admin {
|
||||
function setupCallHandlers(call) {
|
||||
// Call accepted/connected
|
||||
call.on('accept', function() {
|
||||
console.log('Call accepted and connected');
|
||||
stopRingtone();
|
||||
$('#phone-status').text('Connected').css('color', '#2196F3');
|
||||
$('#call-btn').hide();
|
||||
$('#answer-btn').hide();
|
||||
@@ -7601,6 +7922,8 @@ class TWP_Admin {
|
||||
|
||||
// Call disconnected
|
||||
call.on('disconnect', function() {
|
||||
console.log('Call disconnected');
|
||||
stopRingtone();
|
||||
currentCall = null;
|
||||
$('#phone-status').text('Ready').css('color', '#4CAF50');
|
||||
$('#hangup-btn').hide();
|
||||
@@ -7618,6 +7941,8 @@ class TWP_Admin {
|
||||
|
||||
// Call rejected
|
||||
call.on('reject', function() {
|
||||
console.log('Call rejected');
|
||||
stopRingtone();
|
||||
currentCall = null;
|
||||
$('#phone-status').text('Ready').css('color', '#4CAF50');
|
||||
$('#answer-btn').hide();
|
||||
@@ -7626,6 +7951,8 @@ class TWP_Admin {
|
||||
|
||||
// Call cancelled (by caller before answer)
|
||||
call.on('cancel', function() {
|
||||
console.log('Call cancelled by caller');
|
||||
stopRingtone();
|
||||
currentCall = null;
|
||||
$('#phone-status').text('Missed Call').css('color', '#FF9800');
|
||||
$('#answer-btn').hide();
|
||||
@@ -7634,6 +7961,26 @@ class TWP_Admin {
|
||||
$('#phone-status').text('Ready').css('color', '#4CAF50');
|
||||
}, 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() {
|
||||
@@ -7744,11 +8091,15 @@ class TWP_Admin {
|
||||
$('#caller-id-select').html('<option value="">Error loading numbers</option>');
|
||||
});
|
||||
|
||||
// Dialpad functionality
|
||||
$('.dialpad-btn').on('click', function() {
|
||||
// Dialpad functionality (support both click and touch events)
|
||||
$('.dialpad-btn').on('click touchend', function(e) {
|
||||
e.preventDefault(); // Prevent duplicate events
|
||||
var digit = $(this).data('digit');
|
||||
var currentVal = $('#phone-number-input').val();
|
||||
$('#phone-number-input').val(currentVal + digit);
|
||||
|
||||
// Initialize AudioContext on user interaction (mobile requirement)
|
||||
initializeAudioContext();
|
||||
});
|
||||
|
||||
// Call button
|
||||
@@ -7809,8 +8160,45 @@ class TWP_Admin {
|
||||
|
||||
// Answer button
|
||||
$('#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();
|
||||
} catch (error) {
|
||||
console.error('Error accepting call:', error);
|
||||
showError('Failed to answer call: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7850,16 +8238,40 @@ class TWP_Admin {
|
||||
});
|
||||
|
||||
// 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() {
|
||||
setTimeout(function() {
|
||||
if (typeof Twilio === 'undefined') {
|
||||
showError('Twilio Voice SDK failed to load. Please check your internet connection and try refreshing the page.');
|
||||
console.error('Twilio SDK not found. Script may be blocked or failed to load.');
|
||||
} else {
|
||||
console.log('Twilio SDK loaded successfully');
|
||||
initializeBrowserPhone();
|
||||
}
|
||||
}, 1000);
|
||||
if (typeof Twilio !== 'undefined' && !device) {
|
||||
initializeBrowserPhone();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
@@ -10050,4 +10462,11 @@ class TWP_Admin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display mobile app settings page
|
||||
*/
|
||||
public function display_mobile_app_settings() {
|
||||
require_once TWP_PLUGIN_DIR . 'admin/mobile-app-settings.php';
|
||||
}
|
||||
|
||||
}
|
||||
397
admin/mobile-app-settings.php
Normal file
397
admin/mobile-app-settings.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
/**
|
||||
* Mobile App Settings Page
|
||||
*/
|
||||
|
||||
// Prevent direct access
|
||||
if (!defined('WPINC')) {
|
||||
die;
|
||||
}
|
||||
|
||||
// Check user capabilities
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_die(__('You do not have sufficient permissions to access this page.'));
|
||||
}
|
||||
|
||||
// Handle manual update check
|
||||
if (isset($_POST['twp_check_updates']) && check_admin_referer('twp_mobile_settings')) {
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||
$updater = new TWP_Auto_Updater();
|
||||
$update_result = $updater->manual_check_for_updates();
|
||||
}
|
||||
|
||||
// Handle test notification
|
||||
if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_settings')) {
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
||||
$fcm = new TWP_FCM();
|
||||
$test_user_id = get_current_user_id();
|
||||
$notification_sent = $fcm->send_test_notification($test_user_id);
|
||||
|
||||
if ($notification_sent) {
|
||||
$notification_result = array('success' => true, 'message' => 'Test notification sent successfully!');
|
||||
} else {
|
||||
$notification_result = array('success' => false, 'message' => 'Failed to send test notification. Check FCM configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
|
||||
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_gitea_repo', sanitize_text_field($_POST['twp_gitea_repo']));
|
||||
update_option('twp_gitea_token', sanitize_text_field($_POST['twp_gitea_token']));
|
||||
|
||||
$settings_saved = true;
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
$fcm_project_id = get_option('twp_fcm_project_id', '');
|
||||
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
|
||||
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
|
||||
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
|
||||
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
||||
$gitea_token = get_option('twp_gitea_token', '');
|
||||
|
||||
// Get update status
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||
$updater = new TWP_Auto_Updater();
|
||||
$update_status = $updater->get_update_status();
|
||||
|
||||
// Get mobile app statistics
|
||||
global $wpdb;
|
||||
$sessions_table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
$active_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table WHERE is_active = 1 AND expires_at > NOW()");
|
||||
$total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
|
||||
?>
|
||||
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
|
||||
|
||||
<?php if (isset($settings_saved)): ?>
|
||||
<div class="notice notice-success is-dismissible">
|
||||
<p><strong>Settings saved successfully!</strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($update_result)): ?>
|
||||
<div class="notice notice-<?php echo $update_result['update_available'] ? 'warning' : 'success'; ?> is-dismissible">
|
||||
<p><strong><?php echo esc_html($update_result['message']); ?></strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($notification_result)): ?>
|
||||
<div class="notice notice-<?php echo $notification_result['success'] ? 'success' : 'error'; ?> is-dismissible">
|
||||
<p><strong><?php echo esc_html($notification_result['message']); ?></strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($sa_json_error)): ?>
|
||||
<div class="notice notice-error is-dismissible">
|
||||
<p><strong><?php echo esc_html($sa_json_error); ?></strong></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="twp-mobile-settings">
|
||||
<!-- Mobile App Overview -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Mobile App Overview</h2>
|
||||
<table class="widefat">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>API Endpoint:</strong></td>
|
||||
<td><code><?php echo esc_html(site_url('/wp-json/twilio-mobile/v1')); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Active Sessions:</strong></td>
|
||||
<td><?php echo esc_html($active_sessions); ?> active / <?php echo esc_html($total_sessions); ?> total</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plugin Version:</strong></td>
|
||||
<td><?php echo esc_html(TWP_VERSION); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile App Settings Form -->
|
||||
<form method="post" action="">
|
||||
<?php wp_nonce_field('twp_mobile_settings'); ?>
|
||||
|
||||
<!-- FCM Configuration -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
|
||||
<p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
|
||||
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_fcm_project_id">Firebase Project ID</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_fcm_project_id"
|
||||
name="twp_fcm_project_id"
|
||||
value="<?php echo esc_attr($fcm_project_id); ?>"
|
||||
class="regular-text"
|
||||
placeholder="my-project-12345">
|
||||
<p class="description">
|
||||
Found in Firebase Console > Project Settings > General > Project ID
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_fcm_service_account_json">Service Account JSON</label>
|
||||
</th>
|
||||
<td>
|
||||
<textarea id="twp_fcm_service_account_json"
|
||||
name="twp_fcm_service_account_json"
|
||||
rows="6"
|
||||
class="large-text code"
|
||||
placeholder='Paste the entire contents of your service account JSON file...'><?php echo esc_textarea($fcm_service_account_json); ?></textarea>
|
||||
<p class="description">
|
||||
Generate in Firebase Console > Project Settings > Service Accounts > 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;">✓ Service account configured</p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($fcm_sa_configured): ?>
|
||||
<p>
|
||||
<button type="submit" name="twp_test_notification" class="button">
|
||||
Send Test Notification
|
||||
</button>
|
||||
<span class="description">Send a test notification to your devices</span>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Update Settings -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Automatic Updates</h2>
|
||||
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row">Current Version</th>
|
||||
<td>
|
||||
<strong><?php echo esc_html($update_status['current_version']); ?></strong>
|
||||
<?php if ($update_status['update_available']): ?>
|
||||
<span style="color: #d63638; margin-left: 10px;">
|
||||
⚠ Update available: <?php echo esc_html($update_status['latest_version']); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span style="color: #00a32a; margin-left: 10px;">✓ Up to date</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_auto_update_enabled">Enable Auto-Updates</label>
|
||||
</th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
id="twp_auto_update_enabled"
|
||||
name="twp_auto_update_enabled"
|
||||
value="1"
|
||||
<?php checked($auto_update_enabled); ?>>
|
||||
Automatically check for updates every 12 hours
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_gitea_repo">Gitea Repository</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_gitea_repo"
|
||||
name="twp_gitea_repo"
|
||||
value="<?php echo esc_attr($gitea_repo); ?>"
|
||||
class="regular-text"
|
||||
placeholder="org/repo-name">
|
||||
<p class="description">
|
||||
Format: organization/repository (e.g., wp-plugins/twilio-wp-plugin)
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_gitea_token">Gitea Access Token</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="password"
|
||||
id="twp_gitea_token"
|
||||
name="twp_gitea_token"
|
||||
value="<?php echo esc_attr($gitea_token); ?>"
|
||||
class="regular-text"
|
||||
placeholder="">
|
||||
<p class="description">
|
||||
Optional. Required only for private repositories. Create token at:
|
||||
<a href="https://repo.anhonesthost.net/user/settings/applications" target="_blank">Gitea Settings > Applications</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Last Update Check</th>
|
||||
<td>
|
||||
<?php
|
||||
$last_check = $update_status['last_check'];
|
||||
if ($last_check > 0) {
|
||||
echo esc_html(human_time_diff($last_check, current_time('timestamp')) . ' ago');
|
||||
} else {
|
||||
echo 'Never';
|
||||
}
|
||||
?>
|
||||
<button type="submit" name="twp_check_updates" class="button" style="margin-left: 15px;">
|
||||
Check Now
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>API Endpoints</h2>
|
||||
<p>Available REST API endpoints for mobile app development:</p>
|
||||
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Endpoint</th>
|
||||
<th>Method</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/auth/login</code></td>
|
||||
<td>POST</td>
|
||||
<td>Authenticate and get JWT tokens</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/auth/refresh</code></td>
|
||||
<td>POST</td>
|
||||
<td>Refresh access token</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/agent/status</code></td>
|
||||
<td>GET/POST</td>
|
||||
<td>Get or update agent status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/queues/state</code></td>
|
||||
<td>GET</td>
|
||||
<td>Get all queue states</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/calls/{call_sid}/accept</code></td>
|
||||
<td>POST</td>
|
||||
<td>Accept a queued call</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/stream/events</code></td>
|
||||
<td>GET</td>
|
||||
<td>Server-Sent Events stream for real-time updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/twilio-mobile/v1/voice/token</code></td>
|
||||
<td>GET</td>
|
||||
<td>Get Twilio Voice access token for VoIP</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 15px;">
|
||||
<strong>Authentication:</strong> All endpoints (except login/refresh) require
|
||||
<code>Authorization: Bearer <access_token></code> header.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="submit">
|
||||
<button type="submit" name="twp_save_mobile_settings" class="button button-primary">
|
||||
Save Settings
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<?php if ($active_sessions > 0): ?>
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Active Mobile Sessions</h2>
|
||||
<?php
|
||||
$sessions = $wpdb->get_results("
|
||||
SELECT s.user_id, s.device_info, s.logged_in_at, s.last_used, u.user_login, u.display_name
|
||||
FROM $sessions_table s
|
||||
JOIN {$wpdb->users} u ON s.user_id = u.ID
|
||||
WHERE s.is_active = 1 AND s.expires_at > NOW()
|
||||
ORDER BY s.last_used DESC
|
||||
LIMIT 20
|
||||
");
|
||||
?>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Device</th>
|
||||
<th>Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($sessions as $session): ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($session->display_name ?: $session->user_login); ?></td>
|
||||
<td><?php echo esc_html($session->device_info ?: 'Unknown device'); ?></td>
|
||||
<td><?php echo esc_html(human_time_diff(strtotime($session->last_used), current_time('timestamp')) . ' ago'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.twp-mobile-settings .card {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #ccd0d4;
|
||||
box-shadow: 0 1px 1px rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
.twp-mobile-settings .card h2 {
|
||||
margin-top: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f0f0f1;
|
||||
}
|
||||
|
||||
.twp-mobile-settings code {
|
||||
background: #f0f0f1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.twp-mobile-settings table.widefat td code {
|
||||
background: #f6f7f7;
|
||||
}
|
||||
</style>
|
||||
42
assets/images/README.md
Normal file
42
assets/images/README.md
Normal 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)
|
||||
@@ -72,7 +72,23 @@ self.addEventListener('push', function(event) {
|
||||
|
||||
// Handle messages from the main script
|
||||
self.addEventListener('message', function(event) {
|
||||
console.log('TWP Service Worker: Message received', event.data);
|
||||
|
||||
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
36
assets/sounds/README.md
Normal 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
|
||||
@@ -52,7 +52,8 @@ class TWP_Activator {
|
||||
'twp_callbacks',
|
||||
'twp_call_recordings',
|
||||
'twp_user_extensions',
|
||||
'twp_queue_assignments'
|
||||
'twp_queue_assignments',
|
||||
'twp_mobile_sessions'
|
||||
);
|
||||
|
||||
$missing_tables = array();
|
||||
@@ -362,6 +363,24 @@ class TWP_Activator {
|
||||
KEY started_at (started_at)
|
||||
) $charset_collate;";
|
||||
|
||||
// Mobile sessions table
|
||||
$table_mobile_sessions = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
$sql_mobile_sessions = "CREATE TABLE $table_mobile_sessions (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
user_id bigint(20) NOT NULL,
|
||||
refresh_token varchar(500) NOT NULL,
|
||||
fcm_token text,
|
||||
device_info text,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at datetime NOT NULL,
|
||||
last_used datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active tinyint(1) DEFAULT 1,
|
||||
PRIMARY KEY (id),
|
||||
KEY user_id (user_id),
|
||||
KEY is_active (is_active),
|
||||
KEY expires_at (expires_at)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta($sql_schedules);
|
||||
dbDelta($sql_queues);
|
||||
dbDelta($sql_queued_calls);
|
||||
@@ -377,6 +396,7 @@ class TWP_Activator {
|
||||
dbDelta($sql_recordings);
|
||||
dbDelta($sql_user_extensions);
|
||||
dbDelta($sql_queue_assignments);
|
||||
dbDelta($sql_mobile_sessions);
|
||||
|
||||
// Add missing columns for existing installations
|
||||
self::add_missing_columns();
|
||||
|
||||
291
includes/class-twp-auto-updater.php
Normal file
291
includes/class-twp-auto-updater.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
/**
|
||||
* Automatic Plugin Updater
|
||||
*
|
||||
* Checks for updates from Gitea repository and installs them automatically
|
||||
*/
|
||||
class TWP_Auto_Updater {
|
||||
|
||||
private $plugin_slug = 'twilio-wp-plugin';
|
||||
private $plugin_basename;
|
||||
private $gitea_repo = 'wp-plugins/twilio-wp-plugin';
|
||||
private $gitea_api_url;
|
||||
private $current_version;
|
||||
private $gitea_base_url = 'https://repo.anhonesthost.net';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->plugin_basename = plugin_basename(dirname(dirname(__FILE__)) . '/twilio-wp-plugin.php');
|
||||
$this->current_version = defined('TWP_VERSION') ? TWP_VERSION : '0.0.0';
|
||||
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize updater hooks
|
||||
*/
|
||||
public function init() {
|
||||
// Hook into WordPress update checks
|
||||
add_filter('pre_set_site_transient_update_plugins', array($this, 'check_for_update'));
|
||||
add_filter('plugins_api', array($this, 'plugin_info'), 10, 3);
|
||||
|
||||
// Add settings page for manual check
|
||||
add_action('admin_init', array($this, 'register_settings'));
|
||||
|
||||
// Add update check to admin notices
|
||||
if (get_option('twp_auto_update_enabled', '1') === '1') {
|
||||
add_action('admin_init', array($this, 'maybe_auto_check_updates'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register auto-update settings
|
||||
*/
|
||||
public function register_settings() {
|
||||
register_setting('twp_settings', 'twp_auto_update_enabled');
|
||||
register_setting('twp_settings', 'twp_gitea_repo');
|
||||
register_setting('twp_settings', 'twp_gitea_token'); // Optional for private repos
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates periodically
|
||||
*/
|
||||
public function maybe_auto_check_updates() {
|
||||
$last_check = get_option('twp_last_update_check', 0);
|
||||
$check_interval = 12 * HOUR_IN_SECONDS; // Check every 12 hours
|
||||
|
||||
if (time() - $last_check > $check_interval) {
|
||||
update_option('twp_last_update_check', time());
|
||||
// Force WordPress to check for updates
|
||||
wp_clean_plugins_cache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for plugin updates
|
||||
*/
|
||||
public function check_for_update($transient) {
|
||||
if (empty($transient->checked)) {
|
||||
return $transient;
|
||||
}
|
||||
|
||||
// Get Gitea repo from settings if available
|
||||
$custom_repo = get_option('twp_gitea_repo', '');
|
||||
if (!empty($custom_repo)) {
|
||||
$this->gitea_repo = $custom_repo;
|
||||
$this->gitea_api_url = $this->gitea_base_url . '/api/v1/repos/' . $this->gitea_repo . '/releases/latest';
|
||||
}
|
||||
|
||||
$update_info = $this->get_latest_release();
|
||||
|
||||
if ($update_info && version_compare($this->current_version, $update_info->version, '<')) {
|
||||
$plugin_data = array(
|
||||
'id' => 'twilio-wp-plugin',
|
||||
'slug' => $this->plugin_slug,
|
||||
'plugin' => $this->plugin_basename,
|
||||
'new_version' => $update_info->version,
|
||||
'url' => $update_info->homepage,
|
||||
'package' => $update_info->download_url,
|
||||
'tested' => '6.8',
|
||||
'requires' => '5.8',
|
||||
'requires_php' => '7.4',
|
||||
'icons' => array(),
|
||||
'banners' => array(),
|
||||
'compatibility' => new stdClass(),
|
||||
);
|
||||
|
||||
$transient->response[$this->plugin_basename] = (object) $plugin_data;
|
||||
|
||||
error_log("TWP Auto-Updater: New version {$update_info->version} available (current: {$this->current_version})");
|
||||
} else {
|
||||
error_log("TWP Auto-Updater: No updates available (current: {$this->current_version})");
|
||||
}
|
||||
|
||||
return $transient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin information for update screen
|
||||
*/
|
||||
public function plugin_info($false, $action, $args) {
|
||||
if ($action !== 'plugin_information') {
|
||||
return $false;
|
||||
}
|
||||
|
||||
if (!isset($args->slug) || $args->slug !== $this->plugin_slug) {
|
||||
return $false;
|
||||
}
|
||||
|
||||
$update_info = $this->get_latest_release();
|
||||
|
||||
if (!$update_info) {
|
||||
return $false;
|
||||
}
|
||||
|
||||
$plugin_info = new stdClass();
|
||||
$plugin_info->name = 'Twilio WP Plugin';
|
||||
$plugin_info->slug = $this->plugin_slug;
|
||||
$plugin_info->version = $update_info->version;
|
||||
$plugin_info->author = '<a href="https://cybercove.io/">Joshua Knapp</a>';
|
||||
$plugin_info->homepage = $update_info->homepage;
|
||||
$plugin_info->download_link = $update_info->download_url;
|
||||
$plugin_info->requires = '5.8';
|
||||
$plugin_info->tested = '6.8';
|
||||
$plugin_info->requires_php = '7.4';
|
||||
$plugin_info->last_updated = $update_info->release_date;
|
||||
$plugin_info->downloaded = 10;
|
||||
|
||||
$plugin_info->sections = array(
|
||||
'description' => '<p>Twilio WordPress Plugin for call management and mobile app support.</p>',
|
||||
'changelog' => '<pre>' . esc_html($update_info->changelog) . '</pre>',
|
||||
'installation' => '<p>Upload the plugin to your WordPress site and activate it.</p>'
|
||||
);
|
||||
|
||||
return $plugin_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest release information from Gitea
|
||||
*/
|
||||
private function get_latest_release() {
|
||||
// Check cache first (1 hour)
|
||||
$cache_key = 'twp_latest_release';
|
||||
$cached = get_transient($cache_key);
|
||||
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Use cURL for Gitea API
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->gitea_api_url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Accept: application/json'));
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, 'WordPress/Twilio-WP-Plugin-Updater');
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
// Add Gitea token if configured (for private repos)
|
||||
$gitea_token = get_option('twp_gitea_token', '');
|
||||
if (!empty($gitea_token)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||
'Accept: application/json',
|
||||
'Authorization: token ' . $gitea_token
|
||||
));
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$response || $http_code !== 200) {
|
||||
error_log("TWP Auto-Updater: Gitea API returned status $http_code");
|
||||
return false;
|
||||
}
|
||||
|
||||
$release = json_decode($response);
|
||||
|
||||
if (!$release || !isset($release->tag_name)) {
|
||||
error_log('TWP Auto-Updater: Invalid release data from Gitea');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse release information
|
||||
$version = ltrim($release->tag_name, 'v'); // Remove 'v' prefix if present
|
||||
$download_url = null;
|
||||
|
||||
// Find the zip asset
|
||||
if (isset($release->assets) && is_array($release->assets)) {
|
||||
foreach ($release->assets as $asset) {
|
||||
if (strpos($asset->name, '.zip') !== false) {
|
||||
$download_url = $asset->browser_download_url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to zipball if no asset found
|
||||
if (!$download_url) {
|
||||
$download_url = $release->zipball_url;
|
||||
}
|
||||
|
||||
// Format changelog
|
||||
$changelog = !empty($release->body) ? $release->body : 'No changelog provided for this release.';
|
||||
|
||||
// Handle empty changelog
|
||||
if (empty(trim($changelog))) {
|
||||
$changelog = "Version " . $version . "\n\n" .
|
||||
"Released on " . date('F j, Y', strtotime($release->published_at)) . "\n\n" .
|
||||
"* Updated plugin files";
|
||||
}
|
||||
|
||||
$update_info = (object) array(
|
||||
'version' => $version,
|
||||
'download_url' => $download_url,
|
||||
'homepage' => $this->gitea_base_url . '/' . $this->gitea_repo,
|
||||
'release_date' => $release->published_at,
|
||||
'description' => $changelog,
|
||||
'changelog' => $changelog
|
||||
);
|
||||
|
||||
// Cache for 1 hour
|
||||
set_transient($cache_key, $update_info, HOUR_IN_SECONDS);
|
||||
|
||||
return $update_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual update check (for admin page)
|
||||
*/
|
||||
public function manual_check_for_updates() {
|
||||
// Clear cache
|
||||
delete_transient('twp_latest_release');
|
||||
update_option('twp_last_update_check', 0);
|
||||
|
||||
// Force WordPress to check
|
||||
wp_clean_plugins_cache();
|
||||
delete_site_transient('update_plugins');
|
||||
|
||||
$update_info = $this->get_latest_release();
|
||||
|
||||
if (!$update_info) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => 'Failed to check for updates. Please check your internet connection and Gitea repository settings.'
|
||||
);
|
||||
}
|
||||
|
||||
if (version_compare($this->current_version, $update_info->version, '<')) {
|
||||
return array(
|
||||
'success' => true,
|
||||
'update_available' => true,
|
||||
'current_version' => $this->current_version,
|
||||
'latest_version' => $update_info->version,
|
||||
'message' => "Update available: Version {$update_info->version}. Go to Plugins page to update."
|
||||
);
|
||||
} else {
|
||||
return array(
|
||||
'success' => true,
|
||||
'update_available' => false,
|
||||
'current_version' => $this->current_version,
|
||||
'message' => 'You are running the latest version.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current update status
|
||||
*/
|
||||
public function get_update_status() {
|
||||
$update_info = $this->get_latest_release();
|
||||
|
||||
return array(
|
||||
'current_version' => $this->current_version,
|
||||
'latest_version' => $update_info ? $update_info->version : 'Unknown',
|
||||
'update_available' => $update_info && version_compare($this->current_version, $update_info->version, '<'),
|
||||
'last_check' => get_option('twp_last_update_check', 0),
|
||||
'auto_update_enabled' => get_option('twp_auto_update_enabled', '1') === '1'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -618,6 +618,13 @@ class TWP_Call_Queue {
|
||||
require_once dirname(__FILE__) . '/class-twp-agent-groups.php';
|
||||
$members = TWP_Agent_Groups::get_group_members($queue->agent_group_id);
|
||||
|
||||
// Send FCM push notifications to agents' mobile devices
|
||||
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
||||
$fcm = new TWP_FCM();
|
||||
foreach ($members as $member) {
|
||||
$fcm->notify_incoming_call($member->user_id, $caller_number, $queue->queue_name, '');
|
||||
}
|
||||
|
||||
if (empty($members)) {
|
||||
error_log("TWP: No members found in agent group {$queue->agent_group_id} for queue {$queue_id}");
|
||||
return;
|
||||
|
||||
@@ -34,6 +34,13 @@ class TWP_Core {
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-twilio-api.php';
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-elevenlabs-api.php';
|
||||
|
||||
// Mobile app classes
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-auth.php';
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-api.php';
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-mobile-sse.php';
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-fcm.php';
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-auto-updater.php';
|
||||
|
||||
// Feature classes
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-scheduler.php';
|
||||
require_once TWP_PLUGIN_DIR . 'includes/class-twp-call-queue.php';
|
||||
@@ -319,6 +326,20 @@ class TWP_Core {
|
||||
$webhooks = new TWP_Webhooks();
|
||||
$webhooks->register_endpoints();
|
||||
|
||||
// Initialize mobile app endpoints
|
||||
$mobile_auth = new TWP_Mobile_Auth();
|
||||
$mobile_auth->register_endpoints();
|
||||
|
||||
$mobile_api = new TWP_Mobile_API();
|
||||
$mobile_api->register_endpoints();
|
||||
|
||||
$mobile_sse = new TWP_Mobile_SSE();
|
||||
$mobile_sse->register_endpoints();
|
||||
|
||||
// Initialize auto-updater
|
||||
$updater = new TWP_Auto_Updater();
|
||||
$updater->init();
|
||||
|
||||
// Add custom cron schedules
|
||||
add_filter('cron_schedules', function($schedules) {
|
||||
$schedules['twp_every_minute'] = array(
|
||||
|
||||
344
includes/class-twp-fcm.php
Normal file
344
includes/class-twp-fcm.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
/**
|
||||
* Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
|
||||
*
|
||||
* Handles push notifications to mobile devices via FCM using
|
||||
* service account credentials and OAuth2 access tokens.
|
||||
*/
|
||||
class TWP_FCM {
|
||||
|
||||
private $project_id;
|
||||
private $service_account;
|
||||
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->project_id = get_option('twp_fcm_project_id', '');
|
||||
$sa_json = get_option('twp_fcm_service_account_json', '');
|
||||
if (!empty($sa_json)) {
|
||||
$this->service_account = json_decode($sa_json, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to user's devices
|
||||
*/
|
||||
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
|
||||
if (empty($this->project_id) || empty($this->service_account)) {
|
||||
error_log('TWP FCM: Project ID or service account not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get user's FCM tokens
|
||||
$tokens = $this->get_user_tokens($user_id);
|
||||
|
||||
if (empty($tokens)) {
|
||||
error_log("TWP FCM: No tokens found for user $user_id");
|
||||
return false;
|
||||
}
|
||||
|
||||
$success_count = 0;
|
||||
$failed_tokens = array();
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$result = $this->send_to_token($token, $title, $body, $data, $data_only);
|
||||
|
||||
if ($result['success']) {
|
||||
$success_count++;
|
||||
} else {
|
||||
$failed_tokens[] = $token;
|
||||
|
||||
// If token is invalid, remove it from database
|
||||
if ($result['error'] === 'invalid_token') {
|
||||
$this->remove_invalid_token($token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error_log("TWP FCM: Sent notification to $success_count/" . count($tokens) . " devices for user $user_id");
|
||||
|
||||
return $success_count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to specific token via FCM HTTP v2 API
|
||||
*/
|
||||
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
|
||||
$access_token = $this->get_access_token();
|
||||
if (!$access_token) {
|
||||
return array('success' => false, 'error' => 'auth_failed');
|
||||
}
|
||||
|
||||
// FCM v2 requires all data values to be strings
|
||||
$string_data = array();
|
||||
foreach ($data as $key => $value) {
|
||||
$string_data[$key] = is_string($value) ? $value : (string)$value;
|
||||
}
|
||||
$string_data['title'] = $title;
|
||||
$string_data['body'] = $body;
|
||||
$string_data['timestamp'] = (string)time();
|
||||
|
||||
// Build the v2 message payload
|
||||
$message = array(
|
||||
'token' => $token,
|
||||
'data' => $string_data,
|
||||
'android' => array(
|
||||
'priority' => 'high',
|
||||
),
|
||||
);
|
||||
|
||||
if (!$data_only) {
|
||||
$message['notification'] = array(
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
);
|
||||
$message['android']['notification'] = array(
|
||||
'sound' => 'default',
|
||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
||||
);
|
||||
}
|
||||
|
||||
$payload = array('message' => $message);
|
||||
|
||||
$url = sprintf($this->fcm_url_template, $this->project_id);
|
||||
|
||||
$headers = array(
|
||||
'Authorization: Bearer ' . $access_token,
|
||||
'Content-Type: application/json'
|
||||
);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
|
||||
$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 send notification. HTTP $http_code: $response");
|
||||
|
||||
$response_data = json_decode($response, true);
|
||||
$error_code = isset($response_data['error']['details'][0]['errorCode'])
|
||||
? $response_data['error']['details'][0]['errorCode'] : '';
|
||||
$error_status = isset($response_data['error']['status'])
|
||||
? $response_data['error']['status'] : '';
|
||||
|
||||
if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) ||
|
||||
$error_status === 'NOT_FOUND') {
|
||||
return array('success' => false, 'error' => 'invalid_token');
|
||||
}
|
||||
|
||||
return array('success' => false, 'error' => 'http_error');
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
private function get_user_tokens($user_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
return $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT fcm_token FROM $table
|
||||
WHERE user_id = %d
|
||||
AND is_active = 1
|
||||
AND fcm_token IS NOT NULL
|
||||
AND fcm_token != ''
|
||||
AND expires_at > NOW()",
|
||||
$user_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove invalid FCM token from database
|
||||
*/
|
||||
private function remove_invalid_token($token) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array('fcm_token' => null),
|
||||
array('fcm_token' => $token),
|
||||
array('%s'),
|
||||
array('%s')
|
||||
);
|
||||
|
||||
error_log("TWP FCM: Removed invalid token from database");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send incoming call notification
|
||||
*/
|
||||
public function notify_incoming_call($user_id, $from_number, $queue_name, $call_sid) {
|
||||
$title = 'Incoming Call';
|
||||
$body = "Call from $from_number in $queue_name queue";
|
||||
|
||||
$data = array(
|
||||
'type' => 'incoming_call',
|
||||
'call_sid' => $call_sid,
|
||||
'from_number' => $from_number,
|
||||
'queue_name' => $queue_name
|
||||
);
|
||||
|
||||
return $this->send_notification($user_id, $title, $body, $data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send queue timeout notification
|
||||
*/
|
||||
public function notify_queue_timeout($user_id, $queue_name, $waiting_count) {
|
||||
$title = 'Queue Alert';
|
||||
$body = "$queue_name has $waiting_count waiting call" . ($waiting_count > 1 ? 's' : '');
|
||||
|
||||
$data = array(
|
||||
'type' => 'queue_timeout',
|
||||
'queue_name' => $queue_name,
|
||||
'waiting_count' => $waiting_count
|
||||
);
|
||||
|
||||
return $this->send_notification($user_id, $title, $body, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send agent status change notification
|
||||
*/
|
||||
public function notify_status_change($user_id, $old_status, $new_status) {
|
||||
$title = 'Status Changed';
|
||||
$body = "Your status changed from $old_status to $new_status";
|
||||
|
||||
$data = array(
|
||||
'type' => 'status_change',
|
||||
'old_status' => $old_status,
|
||||
'new_status' => $new_status
|
||||
);
|
||||
|
||||
return $this->send_notification($user_id, $title, $body, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test notification (for settings page)
|
||||
*/
|
||||
public function send_test_notification($user_id) {
|
||||
$title = 'Test Notification';
|
||||
$body = 'This is a test notification from Twilio WordPress Plugin';
|
||||
|
||||
$data = array(
|
||||
'type' => 'test',
|
||||
'test' => 'true'
|
||||
);
|
||||
|
||||
return $this->send_notification($user_id, $title, $body, $data);
|
||||
}
|
||||
}
|
||||
755
includes/class-twp-mobile-api.php
Normal file
755
includes/class-twp-mobile-api.php
Normal file
@@ -0,0 +1,755 @@
|
||||
<?php
|
||||
/**
|
||||
* Mobile App REST API Endpoints
|
||||
*
|
||||
* Provides REST API endpoints for mobile app functionality
|
||||
*/
|
||||
class TWP_Mobile_API {
|
||||
|
||||
private $auth;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
// Initialize auth handler
|
||||
require_once plugin_dir_path(__FILE__) . 'class-twp-mobile-auth.php';
|
||||
$this->auth = new TWP_Mobile_Auth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API endpoints
|
||||
*/
|
||||
public function register_endpoints() {
|
||||
add_action('rest_api_init', function() {
|
||||
// Agent status endpoints
|
||||
register_rest_route('twilio-mobile/v1', '/agent/status', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'get_agent_status'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
register_rest_route('twilio-mobile/v1', '/agent/status', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'update_agent_status'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
// Queue state endpoint
|
||||
register_rest_route('twilio-mobile/v1', '/queues/state', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'get_queue_state'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
// Queue calls (specific queue)
|
||||
register_rest_route('twilio-mobile/v1', '/queues/(?P<id>\d+)/calls', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'get_queue_calls'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
// Call control endpoints
|
||||
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/accept', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'accept_call'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/reject', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'reject_call'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/hold', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'hold_call'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/unhold', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'unhold_call'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
register_rest_route('twilio-mobile/v1', '/calls/(?P<call_sid>[^/]+)/transfer', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'transfer_call'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
// FCM token registration
|
||||
register_rest_route('twilio-mobile/v1', '/fcm/register', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'register_fcm_token'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
// Agent phone number
|
||||
register_rest_route('twilio-mobile/v1', '/agent/phone', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'get_agent_phone'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
register_rest_route('twilio-mobile/v1', '/agent/phone', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'update_agent_phone'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
|
||||
// Voice token for VoIP
|
||||
register_rest_route('twilio-mobile/v1', '/voice/token', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'get_voice_token'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent status
|
||||
*/
|
||||
public function get_agent_status($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_agent_status';
|
||||
|
||||
$status = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT status, is_logged_in, current_call_sid, last_activity, available_for_queues FROM $table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
if (!$status) {
|
||||
// Create default status
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array('user_id' => $user_id, 'status' => 'offline', 'is_logged_in' => 0),
|
||||
array('%d', '%s', '%d')
|
||||
);
|
||||
|
||||
$status = (object) array(
|
||||
'status' => 'offline',
|
||||
'is_logged_in' => 0,
|
||||
'current_call_sid' => null,
|
||||
'last_activity' => current_time('mysql'),
|
||||
'available_for_queues' => 1
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'status' => $status->status,
|
||||
'is_logged_in' => (bool)$status->is_logged_in,
|
||||
'current_call_sid' => $status->current_call_sid,
|
||||
'last_activity' => $status->last_activity,
|
||||
'available_for_queues' => (bool)$status->available_for_queues
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent status
|
||||
*/
|
||||
public function update_agent_status($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$new_status = $request->get_param('status');
|
||||
$is_logged_in = $request->get_param('is_logged_in');
|
||||
|
||||
if (!in_array($new_status, array('available', 'busy', 'offline'))) {
|
||||
return new WP_Error('invalid_status', 'Status must be available, busy, or offline', array('status' => 400));
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_agent_status';
|
||||
|
||||
// Check if status exists
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
$data = array(
|
||||
'status' => $new_status,
|
||||
'last_activity' => current_time('mysql')
|
||||
);
|
||||
|
||||
if ($is_logged_in !== null) {
|
||||
$data['is_logged_in'] = $is_logged_in ? 1 : 0;
|
||||
if ($is_logged_in) {
|
||||
$data['logged_in_at'] = current_time('mysql');
|
||||
}
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
$data,
|
||||
array('user_id' => $user_id),
|
||||
array('%s', '%s'),
|
||||
array('%d')
|
||||
);
|
||||
} else {
|
||||
$data['user_id'] = $user_id;
|
||||
$wpdb->insert($table, $data);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Status updated successfully'
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue state (all queues user has access to)
|
||||
*/
|
||||
public function get_queue_state($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
|
||||
global $wpdb;
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||
|
||||
// Auto-create personal queues if they don't exist
|
||||
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
if (!$existing_extension) {
|
||||
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||
}
|
||||
|
||||
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||
$queues = $wpdb->get_results($wpdb->prepare("
|
||||
SELECT DISTINCT
|
||||
q.id,
|
||||
q.queue_name,
|
||||
q.queue_type,
|
||||
q.extension,
|
||||
COUNT(c.id) as waiting_count
|
||||
FROM $queues_table q
|
||||
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||
GROUP BY q.id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.queue_type = 'personal' THEN 1
|
||||
WHEN q.queue_type = 'hold' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.queue_name ASC
|
||||
", $user_id, $user_id));
|
||||
|
||||
$result = array();
|
||||
foreach ($queues as $queue) {
|
||||
$result[] = array(
|
||||
'id' => (int)$queue->id,
|
||||
'name' => $queue->queue_name,
|
||||
'type' => $queue->queue_type,
|
||||
'extension' => $queue->extension,
|
||||
'waiting_count' => (int)$queue->waiting_count
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'queues' => $result
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calls in a specific queue
|
||||
*/
|
||||
public function get_queue_calls($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$queue_id = (int)$request['id'];
|
||||
|
||||
// Verify user has access to this queue
|
||||
if (!$this->user_has_queue_access($user_id, $queue_id)) {
|
||||
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_queued_calls';
|
||||
|
||||
$calls = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT call_sid, from_number, to_number, position, status, joined_at, enqueued_at
|
||||
FROM $table
|
||||
WHERE queue_id = %d AND status = 'waiting'
|
||||
ORDER BY position ASC",
|
||||
$queue_id
|
||||
));
|
||||
|
||||
$result = array();
|
||||
foreach ($calls as $call) {
|
||||
$result[] = array(
|
||||
'call_sid' => $call->call_sid,
|
||||
'from_number' => $call->from_number,
|
||||
'to_number' => $call->to_number,
|
||||
'position' => (int)$call->position,
|
||||
'status' => $call->status,
|
||||
'wait_time' => $this->calculate_wait_time($call->enqueued_at ?: $call->joined_at)
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'calls' => $result
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a call (dequeue and connect to agent)
|
||||
*/
|
||||
public function accept_call($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$call_sid = $request['call_sid'];
|
||||
|
||||
// Get agent phone number
|
||||
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
||||
|
||||
if (empty($agent_number)) {
|
||||
return new WP_Error('no_phone', 'No phone number configured for agent', array('status' => 400));
|
||||
}
|
||||
|
||||
// Initialize Twilio API
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||
$twilio = new TWP_Twilio_API();
|
||||
|
||||
// Get call info from queue
|
||||
global $wpdb;
|
||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||
|
||||
$call = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $calls_table WHERE call_sid = %s AND status = 'waiting'",
|
||||
$call_sid
|
||||
));
|
||||
|
||||
if (!$call) {
|
||||
return new WP_Error('call_not_found', 'Call not found or no longer waiting', array('status' => 404));
|
||||
}
|
||||
|
||||
// Verify user has access to this queue
|
||||
if (!$this->user_has_queue_access($user_id, $call->queue_id)) {
|
||||
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect agent to call
|
||||
$agent_call = $twilio->create_call(
|
||||
$agent_number,
|
||||
$call->to_number,
|
||||
array(
|
||||
'url' => site_url('/wp-json/twilio-webhook/v1/connect-agent'),
|
||||
'statusCallback' => site_url('/wp-json/twilio-webhook/v1/agent-call-status'),
|
||||
'statusCallbackEvent' => array('completed', 'no-answer', 'busy', 'failed'),
|
||||
'timeout' => 30
|
||||
)
|
||||
);
|
||||
|
||||
// Update call record
|
||||
$wpdb->update(
|
||||
$calls_table,
|
||||
array(
|
||||
'status' => 'connecting',
|
||||
'agent_phone' => $agent_number,
|
||||
'agent_call_sid' => $agent_call->sid
|
||||
),
|
||||
array('call_sid' => $call_sid),
|
||||
array('%s', '%s', '%s'),
|
||||
array('%s')
|
||||
);
|
||||
|
||||
// Update agent status
|
||||
$status_table = $wpdb->prefix . 'twp_agent_status';
|
||||
$wpdb->update(
|
||||
$status_table,
|
||||
array('status' => 'busy', 'current_call_sid' => $call_sid),
|
||||
array('user_id' => $user_id),
|
||||
array('%s', '%s'),
|
||||
array('%d')
|
||||
);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Call accepted, connecting to agent',
|
||||
'agent_call_sid' => $agent_call->sid
|
||||
), 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a call (send to voicemail)
|
||||
*/
|
||||
public function reject_call($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$call_sid = $request['call_sid'];
|
||||
|
||||
global $wpdb;
|
||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||
|
||||
$call = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $calls_table WHERE call_sid = %s AND status = 'waiting'",
|
||||
$call_sid
|
||||
));
|
||||
|
||||
if (!$call) {
|
||||
return new WP_Error('call_not_found', 'Call not found or no longer waiting', array('status' => 404));
|
||||
}
|
||||
|
||||
// Verify user has access to this queue
|
||||
if (!$this->user_has_queue_access($user_id, $call->queue_id)) {
|
||||
return new WP_Error('forbidden', 'You do not have access to this queue', array('status' => 403));
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Twilio API
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||
$twilio = new TWP_Twilio_API();
|
||||
|
||||
// Redirect call to voicemail
|
||||
$twiml = new \Twilio\TwiML\VoiceResponse();
|
||||
$twiml->say('The agent is unavailable. Please leave a message after the tone.');
|
||||
$twiml->record(array(
|
||||
'action' => site_url('/wp-json/twilio-webhook/v1/voicemail-complete'),
|
||||
'maxLength' => 120,
|
||||
'transcribe' => true
|
||||
));
|
||||
$twiml->say('We did not receive a recording. Goodbye.');
|
||||
|
||||
$twilio->update_call($call_sid, array('twiml' => $twiml->asXML()));
|
||||
|
||||
// Update call status
|
||||
$wpdb->update(
|
||||
$calls_table,
|
||||
array('status' => 'voicemail', 'ended_at' => current_time('mysql')),
|
||||
array('call_sid' => $call_sid),
|
||||
array('%s', '%s'),
|
||||
array('%s')
|
||||
);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Call sent to voicemail'
|
||||
), 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
return new WP_Error('twilio_error', $e->getMessage(), array('status' => 500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a call
|
||||
*/
|
||||
public function hold_call($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$call_sid = $request['call_sid'];
|
||||
|
||||
try {
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||
|
||||
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
|
||||
$twilio = new TWP_Twilio_API();
|
||||
|
||||
// Find customer call leg
|
||||
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
|
||||
|
||||
if (!$customer_call_sid) {
|
||||
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
|
||||
}
|
||||
|
||||
// Get user's hold queue
|
||||
global $wpdb;
|
||||
$ext_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
|
||||
$extension = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT hold_queue_id FROM $ext_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
if (!$extension || !$extension->hold_queue_id) {
|
||||
return new WP_Error('no_hold_queue', 'No hold queue configured', array('status' => 400));
|
||||
}
|
||||
|
||||
$hold_queue = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT queue_name, wait_music_url FROM $queues_table WHERE id = %d",
|
||||
$extension->hold_queue_id
|
||||
));
|
||||
|
||||
// Put call on hold
|
||||
$twiml = new \Twilio\TwiML\VoiceResponse();
|
||||
$twiml->say('Please hold while we transfer your call.');
|
||||
$enqueue = $twiml->enqueue($hold_queue->queue_name, array(
|
||||
'waitUrl' => $hold_queue->wait_music_url ?: site_url('/wp-json/twilio-webhook/v1/queue-wait')
|
||||
));
|
||||
|
||||
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Call placed on hold'
|
||||
), 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
return new WP_Error('hold_error', $e->getMessage(), array('status' => 500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unhold a call (resume from hold queue)
|
||||
*/
|
||||
public function unhold_call($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$call_sid = $request['call_sid'];
|
||||
|
||||
try {
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||
|
||||
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
|
||||
$twilio = new TWP_Twilio_API();
|
||||
|
||||
// Find customer call leg
|
||||
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
|
||||
|
||||
if (!$customer_call_sid) {
|
||||
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
|
||||
}
|
||||
|
||||
// Build identity for this agent
|
||||
$user = get_userdata($user_id);
|
||||
$clean_name = preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login);
|
||||
if (empty($clean_name)) {
|
||||
$clean_name = 'user';
|
||||
}
|
||||
$identity = 'agent' . $user_id . $clean_name;
|
||||
|
||||
// Redirect customer back to agent's client
|
||||
$twiml = new \Twilio\TwiML\VoiceResponse();
|
||||
$dial = $twiml->dial();
|
||||
$dial->client($identity);
|
||||
|
||||
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Call resumed from hold'
|
||||
), 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
return new WP_Error('unhold_error', $e->getMessage(), array('status' => 500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a call to another extension/queue
|
||||
*/
|
||||
public function transfer_call($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$call_sid = $request['call_sid'];
|
||||
$target = $request->get_param('target'); // Extension number or queue ID
|
||||
|
||||
if (empty($target)) {
|
||||
return new WP_Error('missing_target', 'Transfer target is required', array('status' => 400));
|
||||
}
|
||||
|
||||
try {
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-admin.php';
|
||||
require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-twp-twilio-api.php';
|
||||
|
||||
$admin = new TWP_Admin('twilio-wp-plugin', TWP_VERSION);
|
||||
$twilio = new TWP_Twilio_API();
|
||||
|
||||
// Find customer call leg
|
||||
$customer_call_sid = $admin->find_customer_call_leg($call_sid, $twilio);
|
||||
|
||||
if (!$customer_call_sid) {
|
||||
return new WP_Error('call_not_found', 'Could not find customer call leg', array('status' => 404));
|
||||
}
|
||||
|
||||
// Look up target (extension or queue)
|
||||
global $wpdb;
|
||||
$ext_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
|
||||
// Try as extension first
|
||||
$target_queue = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT q.* FROM $queues_table q
|
||||
JOIN $ext_table e ON q.id = e.personal_queue_id
|
||||
WHERE e.extension = %s",
|
||||
$target
|
||||
));
|
||||
|
||||
// If not extension, try as queue ID
|
||||
if (!$target_queue && is_numeric($target)) {
|
||||
$target_queue = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $queues_table WHERE id = %d",
|
||||
$target
|
||||
));
|
||||
}
|
||||
|
||||
if (!$target_queue) {
|
||||
return new WP_Error('invalid_target', 'Transfer target not found', array('status' => 404));
|
||||
}
|
||||
|
||||
// Transfer to queue
|
||||
$twiml = new \Twilio\TwiML\VoiceResponse();
|
||||
$twiml->say('Transferring your call.');
|
||||
$twiml->enqueue($target_queue->queue_name, array(
|
||||
'waitUrl' => $target_queue->wait_music_url ?: site_url('/wp-json/twilio-webhook/v1/queue-wait')
|
||||
));
|
||||
|
||||
$twilio->update_call($customer_call_sid, array('twiml' => $twiml->asXML()));
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Call transferred successfully'
|
||||
), 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
return new WP_Error('transfer_error', $e->getMessage(), array('status' => 500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register FCM token for push notifications
|
||||
*/
|
||||
public function register_fcm_token($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$fcm_token = $request->get_param('fcm_token');
|
||||
$refresh_token = $request->get_param('refresh_token');
|
||||
|
||||
if (empty($fcm_token)) {
|
||||
return new WP_Error('missing_token', 'FCM token is required', array('status' => 400));
|
||||
}
|
||||
|
||||
$this->auth->update_fcm_token($user_id, $refresh_token, $fcm_token);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'FCM token registered successfully'
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent phone number
|
||||
*/
|
||||
public function get_agent_phone($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'phone_number' => $agent_number ?: null
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent phone number
|
||||
*/
|
||||
public function update_agent_phone($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
$phone_number = $request->get_param('phone_number');
|
||||
|
||||
if (empty($phone_number)) {
|
||||
return new WP_Error('missing_phone', 'Phone number is required', array('status' => 400));
|
||||
}
|
||||
|
||||
// Validate E.164 format
|
||||
if (!preg_match('/^\+[1-9]\d{1,14}$/', $phone_number)) {
|
||||
return new WP_Error('invalid_phone', 'Phone number must be in E.164 format (+1XXXXXXXXXX)', array('status' => 400));
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'twp_agent_phone', $phone_number);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Phone number updated successfully'
|
||||
), 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
|
||||
*/
|
||||
private function user_has_queue_access($user_id, $queue_id) {
|
||||
global $wpdb;
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
||||
|
||||
// Check if it's user's personal queue
|
||||
$is_personal = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $queues_table WHERE id = %d AND user_id = %d",
|
||||
$queue_id, $user_id
|
||||
));
|
||||
|
||||
if ($is_personal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user is assigned to this queue
|
||||
$is_assigned = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $assignments_table WHERE queue_id = %d AND user_id = %d",
|
||||
$queue_id, $user_id
|
||||
));
|
||||
|
||||
return (bool)$is_assigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate wait time in seconds
|
||||
*/
|
||||
private function calculate_wait_time($start_time) {
|
||||
if (!$start_time) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$start = strtotime($start_time);
|
||||
$now = current_time('timestamp');
|
||||
|
||||
return max(0, $now - $start);
|
||||
}
|
||||
}
|
||||
457
includes/class-twp-mobile-auth.php
Normal file
457
includes/class-twp-mobile-auth.php
Normal file
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
/**
|
||||
* Mobile App JWT Authentication Handler
|
||||
*
|
||||
* Handles JWT token generation, validation, and refresh for Android/iOS apps
|
||||
*/
|
||||
class TWP_Mobile_Auth {
|
||||
|
||||
private $secret_key;
|
||||
private $token_expiry = 86400; // 24 hours in seconds
|
||||
private $refresh_expiry = 2592000; // 30 days in seconds
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->secret_key = $this->get_secret_key();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate JWT secret key
|
||||
*/
|
||||
private function get_secret_key() {
|
||||
$key = get_option('twp_mobile_jwt_secret');
|
||||
|
||||
if (empty($key)) {
|
||||
// Generate a secure random key
|
||||
$key = bin2hex(random_bytes(32));
|
||||
update_option('twp_mobile_jwt_secret', $key);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API endpoints
|
||||
*/
|
||||
public function register_endpoints() {
|
||||
add_action('rest_api_init', function() {
|
||||
// Login endpoint
|
||||
register_rest_route('twilio-mobile/v1', '/auth/login', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'handle_login'),
|
||||
'permission_callback' => '__return_true'
|
||||
));
|
||||
|
||||
// Refresh token endpoint
|
||||
register_rest_route('twilio-mobile/v1', '/auth/refresh', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'handle_refresh'),
|
||||
'permission_callback' => '__return_true'
|
||||
));
|
||||
|
||||
// Logout endpoint
|
||||
register_rest_route('twilio-mobile/v1', '/auth/logout', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'handle_logout'),
|
||||
'permission_callback' => array($this, 'verify_token')
|
||||
));
|
||||
|
||||
// Validate token endpoint (for debugging)
|
||||
register_rest_route('twilio-mobile/v1', '/auth/validate', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'handle_validate'),
|
||||
'permission_callback' => array($this, 'verify_token')
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login request
|
||||
*/
|
||||
public function handle_login($request) {
|
||||
$username = $request->get_param('username');
|
||||
$password = $request->get_param('password');
|
||||
$fcm_token = $request->get_param('fcm_token'); // Optional
|
||||
$device_info = $request->get_param('device_info'); // Optional
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
return new WP_Error('missing_credentials', 'Username and password are required', array('status' => 400));
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
$user = wp_authenticate($username, $password);
|
||||
|
||||
if (is_wp_error($user)) {
|
||||
return new WP_Error('invalid_credentials', 'Invalid username or password', array('status' => 401));
|
||||
}
|
||||
|
||||
// Check if user has phone agent capabilities
|
||||
if (!user_can($user->ID, 'twp_access_browser_phone') && !user_can($user->ID, 'manage_options')) {
|
||||
return new WP_Error('insufficient_permissions', 'User does not have phone agent access', array('status' => 403));
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
$access_token = $this->generate_token($user->ID, 'access');
|
||||
$refresh_token = $this->generate_token($user->ID, 'refresh');
|
||||
|
||||
// Store session in database
|
||||
$this->store_session($user->ID, $refresh_token, $fcm_token, $device_info);
|
||||
|
||||
// Get user data
|
||||
$user_data = $this->get_user_data($user->ID);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'access_token' => $access_token,
|
||||
'refresh_token' => $refresh_token,
|
||||
'expires_in' => $this->token_expiry,
|
||||
'user' => $user_data
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token refresh request
|
||||
*/
|
||||
public function handle_refresh($request) {
|
||||
$refresh_token = $request->get_param('refresh_token');
|
||||
|
||||
if (empty($refresh_token)) {
|
||||
return new WP_Error('missing_token', 'Refresh token is required', array('status' => 400));
|
||||
}
|
||||
|
||||
// Verify refresh token
|
||||
$payload = $this->decode_token($refresh_token);
|
||||
|
||||
if (!$payload || $payload->type !== 'refresh') {
|
||||
return new WP_Error('invalid_token', 'Invalid refresh token', array('status' => 401));
|
||||
}
|
||||
|
||||
// Check if session exists and is valid
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
$session = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE user_id = %d AND refresh_token = %s AND is_active = 1 AND expires_at > NOW()",
|
||||
$payload->user_id,
|
||||
$refresh_token
|
||||
));
|
||||
|
||||
if (!$session) {
|
||||
return new WP_Error('invalid_session', 'Session expired or invalid', array('status' => 401));
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
$access_token = $this->generate_token($payload->user_id, 'access');
|
||||
|
||||
// Update last_used timestamp
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array('last_used' => current_time('mysql')),
|
||||
array('id' => $session->id),
|
||||
array('%s'),
|
||||
array('%d')
|
||||
);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'access_token' => $access_token,
|
||||
'expires_in' => $this->token_expiry
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout request
|
||||
*/
|
||||
public function handle_logout($request) {
|
||||
$user_id = $this->get_current_user_id();
|
||||
|
||||
if (!$user_id) {
|
||||
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||
}
|
||||
|
||||
// Get refresh token from request
|
||||
$refresh_token = $request->get_param('refresh_token');
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
if ($refresh_token) {
|
||||
// Invalidate specific session
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array('is_active' => 0),
|
||||
array('user_id' => $user_id, 'refresh_token' => $refresh_token),
|
||||
array('%d'),
|
||||
array('%d', '%s')
|
||||
);
|
||||
} else {
|
||||
// Invalidate all sessions for this user
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array('is_active' => 0),
|
||||
array('user_id' => $user_id),
|
||||
array('%d'),
|
||||
array('%d')
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'message' => 'Logged out successfully'
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token validation request
|
||||
*/
|
||||
public function handle_validate($request) {
|
||||
$user_id = $this->get_current_user_id();
|
||||
|
||||
if (!$user_id) {
|
||||
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||
}
|
||||
|
||||
$user_data = $this->get_user_data($user_id);
|
||||
|
||||
return new WP_REST_Response(array(
|
||||
'success' => true,
|
||||
'valid' => true,
|
||||
'user' => $user_data
|
||||
), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token
|
||||
*/
|
||||
private function generate_token($user_id, $type = 'access') {
|
||||
$issued_at = time();
|
||||
$expiry = $type === 'refresh' ? $this->refresh_expiry : $this->token_expiry;
|
||||
|
||||
$payload = array(
|
||||
'iat' => $issued_at,
|
||||
'exp' => $issued_at + $expiry,
|
||||
'user_id' => $user_id,
|
||||
'type' => $type
|
||||
);
|
||||
|
||||
return $this->encode_token($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple JWT encoding (header.payload.signature)
|
||||
*/
|
||||
private function encode_token($payload) {
|
||||
$header = array('typ' => 'JWT', 'alg' => 'HS256');
|
||||
|
||||
$segments = array();
|
||||
$segments[] = $this->base64url_encode(json_encode($header));
|
||||
$segments[] = $this->base64url_encode(json_encode($payload));
|
||||
|
||||
$signing_input = implode('.', $segments);
|
||||
$signature = hash_hmac('sha256', $signing_input, $this->secret_key, true);
|
||||
$segments[] = $this->base64url_encode($signature);
|
||||
|
||||
return implode('.', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple JWT decoding
|
||||
*/
|
||||
private function decode_token($token) {
|
||||
$segments = explode('.', $token);
|
||||
|
||||
if (count($segments) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($header_b64, $payload_b64, $signature_b64) = $segments;
|
||||
|
||||
// Verify signature
|
||||
$signing_input = $header_b64 . '.' . $payload_b64;
|
||||
$signature = $this->base64url_decode($signature_b64);
|
||||
$expected_signature = hash_hmac('sha256', $signing_input, $this->secret_key, true);
|
||||
|
||||
if (!hash_equals($signature, $expected_signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
$payload = json_decode($this->base64url_decode($payload_b64));
|
||||
|
||||
if (!$payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (isset($payload->exp) && $payload->exp < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private function base64url_encode($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL decode
|
||||
*/
|
||||
private function base64url_decode($data) {
|
||||
return base64_decode(strtr($data, '-_', '+/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify token (permission callback)
|
||||
*/
|
||||
public function verify_token($request) {
|
||||
$auth_header = $request->get_header('Authorization');
|
||||
|
||||
if (empty($auth_header)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>"
|
||||
if (preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) {
|
||||
$token = $matches[1];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $this->decode_token($token);
|
||||
|
||||
if (!$payload || $payload->type !== 'access') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store user ID for later use
|
||||
$request->set_param('_twp_user_id', $payload->user_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID from token
|
||||
*/
|
||||
public function get_current_user_id() {
|
||||
$request = rest_get_server()->get_request();
|
||||
return $request->get_param('_twp_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store session in database
|
||||
*/
|
||||
private function store_session($user_id, $refresh_token, $fcm_token = null, $device_info = null) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'user_id' => $user_id,
|
||||
'refresh_token' => $refresh_token,
|
||||
'fcm_token' => $fcm_token,
|
||||
'device_info' => $device_info,
|
||||
'created_at' => current_time('mysql'),
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + $this->refresh_expiry),
|
||||
'last_used' => current_time('mysql'),
|
||||
'is_active' => 1
|
||||
),
|
||||
array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data for response
|
||||
*/
|
||||
private function get_user_data($user_id) {
|
||||
$user = get_userdata($user_id);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get agent phone number
|
||||
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
||||
|
||||
// Get agent status
|
||||
global $wpdb;
|
||||
$status_table = $wpdb->prefix . 'twp_agent_status';
|
||||
$status = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT status, is_logged_in, current_call_sid FROM $status_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
// Get user extension
|
||||
$ext_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$extension = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT extension, direct_dial_number FROM $ext_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
return array(
|
||||
'id' => $user->ID,
|
||||
'username' => $user->user_login,
|
||||
'display_name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
'phone_number' => $agent_number,
|
||||
'extension' => $extension ? $extension->extension : null,
|
||||
'direct_dial' => $extension ? $extension->direct_dial_number : null,
|
||||
'status' => $status ? $status->status : 'offline',
|
||||
'is_logged_in' => $status ? (bool)$status->is_logged_in : false,
|
||||
'current_call_sid' => $status ? $status->current_call_sid : null,
|
||||
'capabilities' => array(
|
||||
'can_access_browser_phone' => user_can($user_id, 'twp_access_browser_phone'),
|
||||
'can_access_voicemails' => user_can($user_id, 'twp_access_voicemails'),
|
||||
'can_access_call_log' => user_can($user_id, 'twp_access_call_log'),
|
||||
'can_access_agent_queue' => user_can($user_id, 'twp_access_agent_queue'),
|
||||
'can_access_sms_inbox' => user_can($user_id, 'twp_access_sms_inbox'),
|
||||
'is_admin' => user_can($user_id, 'manage_options')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update FCM token for existing session
|
||||
*/
|
||||
public function update_fcm_token($user_id, $refresh_token, $fcm_token) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array('fcm_token' => $fcm_token),
|
||||
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
|
||||
array('%s'),
|
||||
array('%d', '%s', '%d')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active FCM tokens for a user
|
||||
*/
|
||||
public function get_user_fcm_tokens($user_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
return $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT fcm_token FROM $table WHERE user_id = %d AND is_active = 1 AND fcm_token IS NOT NULL AND expires_at > NOW()",
|
||||
$user_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions
|
||||
*/
|
||||
public static function cleanup_expired_sessions() {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
||||
|
||||
$wpdb->query("UPDATE $table SET is_active = 0 WHERE expires_at < NOW() AND is_active = 1");
|
||||
}
|
||||
}
|
||||
310
includes/class-twp-mobile-sse.php
Normal file
310
includes/class-twp-mobile-sse.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
/**
|
||||
* Server-Sent Events (SSE) Handler for Mobile App
|
||||
*
|
||||
* Provides real-time updates for queue state, incoming calls, and agent status
|
||||
*/
|
||||
class TWP_Mobile_SSE {
|
||||
|
||||
private $auth;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
require_once plugin_dir_path(__FILE__) . 'class-twp-mobile-auth.php';
|
||||
$this->auth = new TWP_Mobile_Auth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register SSE endpoint
|
||||
*/
|
||||
public function register_endpoints() {
|
||||
add_action('rest_api_init', function() {
|
||||
register_rest_route('twilio-mobile/v1', '/stream/events', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'stream_events'),
|
||||
'permission_callback' => array($this->auth, 'verify_token')
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream events to mobile app
|
||||
*/
|
||||
public function stream_events($request) {
|
||||
$user_id = $this->auth->get_current_user_id();
|
||||
|
||||
if (!$user_id) {
|
||||
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
||||
}
|
||||
|
||||
// Set headers for SSE
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no'); // Disable nginx buffering
|
||||
|
||||
// Disable PHP output buffering
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
@ini_set('zlib.output_compression', 0);
|
||||
@ini_set('implicit_flush', 1);
|
||||
ob_implicit_flush(1);
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
$this->send_event('connected', array('user_id' => $user_id, 'timestamp' => time()));
|
||||
|
||||
// Get initial state
|
||||
$last_check = time();
|
||||
$previous_state = $this->get_current_state($user_id);
|
||||
|
||||
// Stream loop - check for changes every 2 seconds
|
||||
$max_duration = 300; // 5 minutes max connection time
|
||||
$start_time = time();
|
||||
|
||||
while (time() - $start_time < $max_duration) {
|
||||
// Check if connection is still alive
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Get current state
|
||||
$current_state = $this->get_current_state($user_id);
|
||||
|
||||
// Compare and send updates
|
||||
$this->check_and_send_updates($previous_state, $current_state);
|
||||
|
||||
// Update previous state
|
||||
$previous_state = $current_state;
|
||||
|
||||
// Send heartbeat every 15 seconds
|
||||
if (time() - $last_check >= 15) {
|
||||
$this->send_event('heartbeat', array('timestamp' => time()));
|
||||
$last_check = time();
|
||||
}
|
||||
|
||||
// Sleep for 2 seconds
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
// Connection closing
|
||||
$this->send_event('disconnect', array('reason' => 'timeout', 'timestamp' => time()));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state for agent
|
||||
*/
|
||||
private function get_current_state($user_id) {
|
||||
global $wpdb;
|
||||
|
||||
$state = array(
|
||||
'agent_status' => $this->get_agent_status($user_id),
|
||||
'queues' => $this->get_queues_state($user_id),
|
||||
'current_call' => $this->get_current_call($user_id)
|
||||
);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent status
|
||||
*/
|
||||
private function get_agent_status($user_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'twp_agent_status';
|
||||
|
||||
$status = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT status, is_logged_in, current_call_sid FROM $table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
if (!$status) {
|
||||
return array('status' => 'offline', 'is_logged_in' => false, 'current_call_sid' => null);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => $status->status,
|
||||
'is_logged_in' => (bool)$status->is_logged_in,
|
||||
'current_call_sid' => $status->current_call_sid
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queues state
|
||||
*/
|
||||
private function get_queues_state($user_id) {
|
||||
global $wpdb;
|
||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||
|
||||
// Auto-create personal queues if they don't exist
|
||||
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
|
||||
if (!$existing_extension) {
|
||||
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||
}
|
||||
|
||||
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||
$queues = $wpdb->get_results($wpdb->prepare("
|
||||
SELECT DISTINCT
|
||||
q.id,
|
||||
q.queue_name,
|
||||
COUNT(c.id) as waiting_count,
|
||||
MIN(c.enqueued_at) as oldest_call_time
|
||||
FROM $queues_table q
|
||||
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||
GROUP BY q.id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.queue_type = 'personal' THEN 1
|
||||
WHEN q.queue_type = 'hold' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
q.queue_name ASC
|
||||
", $user_id, $user_id));
|
||||
|
||||
$result = array();
|
||||
foreach ($queues as $queue) {
|
||||
$result[$queue->id] = array(
|
||||
'id' => (int)$queue->id,
|
||||
'name' => $queue->queue_name,
|
||||
'waiting_count' => (int)$queue->waiting_count,
|
||||
'oldest_call_time' => $queue->oldest_call_time
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current call for agent
|
||||
*/
|
||||
private function get_current_call($user_id) {
|
||||
global $wpdb;
|
||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||
|
||||
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
||||
|
||||
if (!$agent_number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$call = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT call_sid, from_number, queue_id, status, joined_at
|
||||
FROM $calls_table
|
||||
WHERE agent_phone = %s AND status IN ('connecting', 'in_progress')
|
||||
ORDER BY joined_at DESC
|
||||
LIMIT 1",
|
||||
$agent_number
|
||||
));
|
||||
|
||||
if (!$call) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'call_sid' => $call->call_sid,
|
||||
'from_number' => $call->from_number,
|
||||
'queue_id' => (int)$call->queue_id,
|
||||
'status' => $call->status,
|
||||
'duration' => time() - strtotime($call->joined_at)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check state changes and send updates
|
||||
*/
|
||||
private function check_and_send_updates($previous, $current) {
|
||||
// Check agent status changes
|
||||
if ($previous['agent_status'] !== $current['agent_status']) {
|
||||
$this->send_event('agent_status_changed', $current['agent_status']);
|
||||
}
|
||||
|
||||
// Check queue changes
|
||||
$this->check_queue_changes($previous['queues'], $current['queues']);
|
||||
|
||||
// Check current call changes
|
||||
if ($previous['current_call'] !== $current['current_call']) {
|
||||
if ($current['current_call'] && !$previous['current_call']) {
|
||||
// New call started
|
||||
$this->send_event('call_started', $current['current_call']);
|
||||
} elseif (!$current['current_call'] && $previous['current_call']) {
|
||||
// Call ended
|
||||
$this->send_event('call_ended', $previous['current_call']);
|
||||
} elseif ($current['current_call'] && $previous['current_call']) {
|
||||
// Call status changed
|
||||
if ($current['current_call']['status'] !== $previous['current_call']['status']) {
|
||||
$this->send_event('call_status_changed', $current['current_call']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for queue changes
|
||||
*/
|
||||
private function check_queue_changes($previous_queues, $current_queues) {
|
||||
foreach ($current_queues as $queue_id => $current_queue) {
|
||||
$previous_queue = $previous_queues[$queue_id] ?? null;
|
||||
|
||||
if (!$previous_queue) {
|
||||
// New queue added
|
||||
$this->send_event('queue_added', $current_queue);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for waiting count changes
|
||||
if ($current_queue['waiting_count'] !== $previous_queue['waiting_count']) {
|
||||
if ($current_queue['waiting_count'] > $previous_queue['waiting_count']) {
|
||||
// New call in queue
|
||||
$this->send_event('call_enqueued', array(
|
||||
'queue_id' => $queue_id,
|
||||
'queue_name' => $current_queue['name'],
|
||||
'waiting_count' => $current_queue['waiting_count']
|
||||
));
|
||||
} else {
|
||||
// Call removed from queue
|
||||
$this->send_event('call_dequeued', array(
|
||||
'queue_id' => $queue_id,
|
||||
'queue_name' => $current_queue['name'],
|
||||
'waiting_count' => $current_queue['waiting_count']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed queues
|
||||
foreach ($previous_queues as $queue_id => $previous_queue) {
|
||||
if (!isset($current_queues[$queue_id])) {
|
||||
$this->send_event('queue_removed', array('queue_id' => $queue_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SSE event
|
||||
*/
|
||||
private function send_event($event_type, $data) {
|
||||
echo "event: $event_type\n";
|
||||
echo "data: " . json_encode($data) . "\n\n";
|
||||
|
||||
if (ob_get_level() > 0) {
|
||||
ob_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,26 @@ class TWP_Twilio_API {
|
||||
* Initialize Twilio SDK client
|
||||
*/
|
||||
private function init_sdk_client() {
|
||||
// Check if autoloader exists
|
||||
$autoloader_path = TWP_PLUGIN_DIR . 'vendor/autoload.php';
|
||||
if (!file_exists($autoloader_path)) {
|
||||
error_log('TWP Plugin: Autoloader not found at: ' . $autoloader_path);
|
||||
throw new Exception('Twilio SDK not found. Please run: ./install-twilio-sdk.sh');
|
||||
// Check for SDK autoloader - external location first (survives plugin updates)
|
||||
$autoloader_path = null;
|
||||
|
||||
// Priority 1: External SDK location (recommended)
|
||||
$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
|
||||
|
||||
@@ -9,9 +9,25 @@ class TWP_Webhooks {
|
||||
*/
|
||||
public function __construct() {
|
||||
// Load Twilio SDK if not already loaded
|
||||
// Check external location first (survives plugin updates), then internal
|
||||
if (!class_exists('\Twilio\Rest\Client')) {
|
||||
$autoloader_path = plugin_dir_path(dirname(__FILE__)) . 'vendor/autoload.php';
|
||||
if (file_exists($autoloader_path)) {
|
||||
$autoloader_path = null;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -461,6 +477,13 @@ class TWP_Webhooks {
|
||||
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
|
||||
}
|
||||
}
|
||||
|
||||
// Send FCM push notifications for missed browser call
|
||||
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
||||
$fcm = new TWP_FCM();
|
||||
foreach ($agents as $agent) {
|
||||
$fcm->notify_incoming_call($agent->ID, $customer_number, 'Browser Phone', '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -688,6 +711,13 @@ class TWP_Webhooks {
|
||||
$twilio_api->send_sms($agent_phone, $message, $twilio_number);
|
||||
}
|
||||
}
|
||||
|
||||
// Send FCM push notifications for missed call
|
||||
require_once dirname(__FILE__) . '/class-twp-fcm.php';
|
||||
$fcm = new TWP_FCM();
|
||||
foreach ($agents as $agent) {
|
||||
$fcm->notify_incoming_call($agent->ID, $customer_number, 'General', '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
158
install-twilio-sdk-external.sh
Executable file
158
install-twilio-sdk-external.sh
Executable 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
|
||||
@@ -79,8 +79,8 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
|
||||
}
|
||||
|
||||
// Convert class name to file path
|
||||
$relative_class = substr($class, 7); // Remove 'Twilio\'
|
||||
$file = __DIR__ . '/twilio/sdk/' . str_replace('\\', '/', $relative_class) . '.php';
|
||||
// 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;
|
||||
@@ -98,10 +98,10 @@ if (!defined('TWILIO_AUTOLOADER_REGISTERED')) {
|
||||
|
||||
// Load essential Twilio classes manually to ensure they're available
|
||||
$essential_classes = [
|
||||
__DIR__ . '/twilio/sdk/Rest/Client.php',
|
||||
__DIR__ . '/twilio/sdk/TwiML/VoiceResponse.php',
|
||||
__DIR__ . '/twilio/sdk/Exceptions/TwilioException.php',
|
||||
__DIR__ . '/twilio/sdk/Security/RequestValidator.php'
|
||||
__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) {
|
||||
|
||||
45
mobile/.gitignore
vendored
Normal file
45
mobile/.gitignore
vendored
Normal 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
45
mobile/.metadata
Normal 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'
|
||||
7
mobile/analysis_options.yaml
Normal file
7
mobile/analysis_options.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
avoid_print: true
|
||||
49
mobile/android/app/build.gradle
Normal file
49
mobile/android/app/build.gradle
Normal 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'
|
||||
}
|
||||
29
mobile/android/app/google-services.json
Normal file
29
mobile/android/app/google-services.json
Normal 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
9
mobile/android/app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
65
mobile/android/app/src/main/AndroidManifest.xml
Normal file
65
mobile/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.cloudhosting.twp.twp_softphone
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
mobile/android/app/src/main/res/values/styles.xml
Normal file
9
mobile/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal file
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal 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>
|
||||
18
mobile/android/build.gradle
Normal file
18
mobile/android/build.gradle
Normal 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
|
||||
}
|
||||
3
mobile/android/gradle.properties
Normal file
3
mobile/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
BIN
mobile/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
mobile/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
2
mobile/android/local.properties
Normal file
2
mobile/android/local.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
flutter.sdk=/opt/flutter
|
||||
sdk.dir=/opt/android-sdk
|
||||
26
mobile/android/settings.gradle
Normal file
26
mobile/android/settings.gradle
Normal 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
65
mobile/lib/app.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
8
mobile/lib/config/app_config.dart
Normal file
8
mobile/lib/config/app_config.dart
Normal 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
9
mobile/lib/main.dart
Normal 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());
|
||||
}
|
||||
38
mobile/lib/models/agent_status.dart
Normal file
38
mobile/lib/models/agent_status.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
mobile/lib/models/call_info.dart
Normal file
46
mobile/lib/models/call_info.dart
Normal 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;
|
||||
}
|
||||
66
mobile/lib/models/queue_state.dart
Normal file
66
mobile/lib/models/queue_state.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
28
mobile/lib/models/user.dart
Normal file
28
mobile/lib/models/user.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
88
mobile/lib/providers/agent_provider.dart
Normal file
88
mobile/lib/providers/agent_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
107
mobile/lib/providers/auth_provider.dart
Normal file
107
mobile/lib/providers/auth_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
143
mobile/lib/providers/call_provider.dart
Normal file
143
mobile/lib/providers/call_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
137
mobile/lib/screens/active_call_screen.dart
Normal file
137
mobile/lib/screens/active_call_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
mobile/lib/screens/dashboard_screen.dart
Normal file
177
mobile/lib/screens/dashboard_screen.dart
Normal 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),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
mobile/lib/screens/login_screen.dart
Normal file
165
mobile/lib/screens/login_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
68
mobile/lib/screens/settings_screen.dart
Normal file
68
mobile/lib/screens/settings_screen.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
mobile/lib/services/api_client.dart
Normal file
85
mobile/lib/services/api_client.dart
Normal 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();
|
||||
95
mobile/lib/services/auth_service.dart
Normal file
95
mobile/lib/services/auth_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
78
mobile/lib/services/push_notification_service.dart
Normal file
78
mobile/lib/services/push_notification_service.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
mobile/lib/services/sse_service.dart
Normal file
119
mobile/lib/services/sse_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
89
mobile/lib/services/voice_service.dart
Normal file
89
mobile/lib/services/voice_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
51
mobile/lib/widgets/agent_status_toggle.dart
Normal file
51
mobile/lib/widgets/agent_status_toggle.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
mobile/lib/widgets/call_controls.dart
Normal file
118
mobile/lib/widgets/call_controls.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/widgets/dialpad.dart
Normal file
55
mobile/lib/widgets/dialpad.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/lib/widgets/queue_card.dart
Normal file
37
mobile/lib/widgets/queue_card.dart
Normal 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
890
mobile/pubspec.lock
Normal 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
29
mobile/pubspec.yaml
Normal 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
|
||||
7
mobile/test/widget_test.dart
Normal file
7
mobile/test/widget_test.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('placeholder test', () {
|
||||
expect(1 + 1, 2);
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Twilio WP Plugin
|
||||
* Plugin URI: https://repo.anhonesthost.net/wp-plugins/twilio-wp-plugin
|
||||
* Description: WordPress plugin for Twilio integration with phone scheduling, call forwarding, queue management, and Eleven Labs TTS
|
||||
* Version: 2.8.9
|
||||
* Version: {auto_update_value_on_deploy}
|
||||
* Author: Josh Knapp
|
||||
* License: GPL v2 or later
|
||||
* Text Domain: twilio-wp-plugin
|
||||
@@ -15,11 +15,13 @@ if (!defined('WPINC')) {
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('TWP_VERSION', '2.8.9');
|
||||
define('TWP_VERSION', '{auto_update_value_on_deploy}');
|
||||
define('TWP_DB_VERSION', '1.6.2'); // Track database version separately
|
||||
define('TWP_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('TWP_PLUGIN_URL', plugin_dir_url(__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
|
||||
@@ -31,17 +33,27 @@ function twp_activate() {
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
$autoloader_path = TWP_PLUGIN_DIR . 'vendor/autoload.php';
|
||||
$sdk_installed = false;
|
||||
|
||||
if (file_exists($autoloader_path)) {
|
||||
// Try to load autoloader and check for classes
|
||||
require_once $autoloader_path;
|
||||
// Priority 1: Check external SDK location (survives plugin updates)
|
||||
$external_autoloader = TWP_EXTERNAL_SDK_DIR . 'autoload.php';
|
||||
if (file_exists($external_autoloader)) {
|
||||
require_once $external_autoloader;
|
||||
$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) {
|
||||
add_action('admin_notices', 'twp_sdk_missing_notice');
|
||||
}
|
||||
@@ -55,10 +67,12 @@ function twp_sdk_missing_notice() {
|
||||
<div class="notice notice-error is-dismissible">
|
||||
<h3>Twilio WordPress Plugin - SDK Required</h3>
|
||||
<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>
|
||||
<p>Or install via Composer: <code>composer install</code></p>
|
||||
<p><em>Plugin path: <?php echo TWP_PLUGIN_DIR; ?></em></p>
|
||||
<p style="margin-top: 10px;"><em>Plugin path: <?php echo esc_html(TWP_PLUGIN_DIR); ?></em></p>
|
||||
<p><em>External SDK path: <?php echo esc_html(TWP_EXTERNAL_SDK_DIR); ?></em></p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -126,6 +140,52 @@ function twp_deactivate() {
|
||||
register_activation_hook(__FILE__, 'twp_activate');
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user