Compare commits
2 Commits
2026.03.06
...
2026.03.06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5adfa694c1 | ||
|
|
826fd3ae39 |
@@ -36,7 +36,19 @@ if (isset($_POST['twp_test_notification']) && check_admin_referer('twp_mobile_se
|
||||
|
||||
// Save settings
|
||||
if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile_settings')) {
|
||||
update_option('twp_fcm_server_key', sanitize_text_field($_POST['twp_fcm_server_key']));
|
||||
update_option('twp_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']));
|
||||
@@ -48,7 +60,9 @@ if (isset($_POST['twp_save_mobile_settings']) && check_admin_referer('twp_mobile
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
$fcm_server_key = get_option('twp_fcm_server_key', '');
|
||||
$fcm_project_id = get_option('twp_fcm_project_id', '');
|
||||
$fcm_service_account_json = get_option('twp_fcm_service_account_json', '');
|
||||
$fcm_sa_configured = !empty($fcm_service_account_json) && !empty($fcm_project_id);
|
||||
$auto_update_enabled = get_option('twp_auto_update_enabled', '1') === '1';
|
||||
$gitea_repo = get_option('twp_gitea_repo', 'wp-plugins/twilio-wp-plugin');
|
||||
$gitea_token = get_option('twp_gitea_token', '');
|
||||
@@ -90,6 +104,12 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
</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;">
|
||||
@@ -118,26 +138,45 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
|
||||
<!-- FCM Configuration -->
|
||||
<div class="card" style="max-width: 100%; margin-bottom: 20px;">
|
||||
<h2>Firebase Cloud Messaging (FCM)</h2>
|
||||
<p>Configure FCM to enable push notifications for the mobile app.</p>
|
||||
<h2>Firebase Cloud Messaging (FCM) — HTTP v2 API</h2>
|
||||
<p>Configure FCM using a service account for push notifications. The legacy server key API has been retired by Google.</p>
|
||||
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_fcm_server_key">FCM Server Key</label>
|
||||
<label for="twp_fcm_project_id">Firebase Project ID</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text"
|
||||
id="twp_fcm_server_key"
|
||||
name="twp_fcm_server_key"
|
||||
value="<?php echo esc_attr($fcm_server_key); ?>"
|
||||
id="twp_fcm_project_id"
|
||||
name="twp_fcm_project_id"
|
||||
value="<?php echo esc_attr($fcm_project_id); ?>"
|
||||
class="regular-text"
|
||||
placeholder="AAAA...">
|
||||
placeholder="my-project-12345">
|
||||
<p class="description">
|
||||
Get your server key from Firebase Console > Project Settings > Cloud Messaging > Server Key
|
||||
Found in Firebase Console > 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>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="twp_twilio_api_key_sid">Twilio API Key SID</label>
|
||||
@@ -181,13 +220,13 @@ $total_sessions = $wpdb->get_var("SELECT COUNT(*) FROM $sessions_table");
|
||||
class="regular-text"
|
||||
placeholder="CR...">
|
||||
<p class="description">
|
||||
Twilio Push Credential SID. Create in Twilio Console > Messaging > Push Credentials using your FCM server key. Required for incoming call push notifications.
|
||||
Twilio Push Credential SID. Create in Twilio Console > Messaging > Push Credentials using your FCM service account JSON. Required for incoming call push notifications.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if (!empty($fcm_server_key)): ?>
|
||||
<?php if ($fcm_sa_configured): ?>
|
||||
<p>
|
||||
<button type="submit" name="twp_test_notification" class="button">
|
||||
Send Test Notification
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* Firebase Cloud Messaging (FCM) Integration
|
||||
* Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
|
||||
*
|
||||
* Handles push notifications to mobile devices via FCM
|
||||
* Handles push notifications to mobile devices via FCM using
|
||||
* service account credentials and OAuth2 access tokens.
|
||||
*/
|
||||
class TWP_FCM {
|
||||
|
||||
private $server_key;
|
||||
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
|
||||
private $project_id;
|
||||
private $service_account;
|
||||
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->server_key = get_option('twp_fcm_server_key', '');
|
||||
$this->project_id = get_option('twp_fcm_project_id', '');
|
||||
$sa_json = get_option('twp_fcm_service_account_json', '');
|
||||
if (!empty($sa_json)) {
|
||||
$this->service_account = json_decode($sa_json, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to user's devices
|
||||
*/
|
||||
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
|
||||
if (empty($this->server_key)) {
|
||||
error_log('TWP FCM: Server key not configured');
|
||||
if (empty($this->project_id) || empty($this->service_account)) {
|
||||
error_log('TWP FCM: Project ID or service account not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,47 +63,54 @@ class TWP_FCM {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to specific token
|
||||
* Send notification to specific token via FCM HTTP v2 API
|
||||
*/
|
||||
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
|
||||
if ($data_only) {
|
||||
$payload = array(
|
||||
'to' => $token,
|
||||
'data' => array_merge($data, array(
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'timestamp' => time()
|
||||
)),
|
||||
'priority' => 'high'
|
||||
);
|
||||
} else {
|
||||
$notification = array(
|
||||
$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,
|
||||
'sound' => 'default',
|
||||
'priority' => 'high',
|
||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
|
||||
);
|
||||
|
||||
$payload = array(
|
||||
'to' => $token,
|
||||
'notification' => $notification,
|
||||
'data' => array_merge($data, array(
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'timestamp' => time()
|
||||
)),
|
||||
'priority' => 'high'
|
||||
$message['android']['notification'] = array(
|
||||
'sound' => 'default',
|
||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
||||
);
|
||||
}
|
||||
|
||||
$payload = array('message' => $message);
|
||||
|
||||
$url = sprintf($this->fcm_url_template, $this->project_id);
|
||||
|
||||
$headers = array(
|
||||
'Authorization: key=' . $this->server_key,
|
||||
'Authorization: Bearer ' . $access_token,
|
||||
'Content-Type: application/json'
|
||||
);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->fcm_url);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -111,10 +124,14 @@ class TWP_FCM {
|
||||
if ($http_code !== 200) {
|
||||
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
|
||||
|
||||
// Check if token is invalid
|
||||
$response_data = json_decode($response, true);
|
||||
if (isset($response_data['results'][0]['error']) &&
|
||||
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
|
||||
$error_code = isset($response_data['error']['details'][0]['errorCode'])
|
||||
? $response_data['error']['details'][0]['errorCode'] : '';
|
||||
$error_status = isset($response_data['error']['status'])
|
||||
? $response_data['error']['status'] : '';
|
||||
|
||||
if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) ||
|
||||
$error_status === 'NOT_FOUND') {
|
||||
return array('success' => false, 'error' => 'invalid_token');
|
||||
}
|
||||
|
||||
@@ -124,6 +141,107 @@ class TWP_FCM {
|
||||
return array('success' => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth2 access token from service account credentials.
|
||||
* Caches the token in a transient until near expiry.
|
||||
*/
|
||||
private function get_access_token() {
|
||||
$cached = get_transient('twp_fcm_access_token');
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (empty($this->service_account)) {
|
||||
error_log('TWP FCM: Service account not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
$jwt = $this->create_jwt();
|
||||
if (!$jwt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->service_account['token_uri']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array(
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
)));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($http_code !== 200) {
|
||||
error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response");
|
||||
return false;
|
||||
}
|
||||
|
||||
$token_data = json_decode($response, true);
|
||||
$access_token = $token_data['access_token'];
|
||||
$expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600;
|
||||
|
||||
// Cache token for 5 minutes less than actual expiry
|
||||
set_transient('twp_fcm_access_token', $access_token, max(60, $expires_in - 300));
|
||||
|
||||
return $access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed JWT for the service account OAuth2 flow
|
||||
*/
|
||||
private function create_jwt() {
|
||||
$sa = $this->service_account;
|
||||
|
||||
if (empty($sa['client_email']) || empty($sa['private_key']) || empty($sa['token_uri'])) {
|
||||
error_log('TWP FCM: Service account JSON missing required fields');
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$header = array('alg' => 'RS256', 'typ' => 'JWT');
|
||||
$claims = array(
|
||||
'iss' => $sa['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => $sa['token_uri'],
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
);
|
||||
|
||||
$segments = array(
|
||||
$this->base64url_encode(json_encode($header)),
|
||||
$this->base64url_encode(json_encode($claims)),
|
||||
);
|
||||
|
||||
$signing_input = implode('.', $segments);
|
||||
|
||||
$private_key = openssl_pkey_get_private($sa['private_key']);
|
||||
if (!$private_key) {
|
||||
error_log('TWP FCM: Failed to parse service account private key');
|
||||
return false;
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
if (!openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) {
|
||||
error_log('TWP FCM: Failed to sign JWT');
|
||||
return false;
|
||||
}
|
||||
|
||||
$segments[] = $this->base64url_encode($signature);
|
||||
|
||||
return implode('.', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64url encode (RFC 4648)
|
||||
*/
|
||||
private function base64url_encode($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active FCM tokens for a user
|
||||
*/
|
||||
@@ -218,7 +336,7 @@ class TWP_FCM {
|
||||
|
||||
$data = array(
|
||||
'type' => 'test',
|
||||
'test' => true
|
||||
'test' => 'true'
|
||||
);
|
||||
|
||||
return $this->send_notification($user_id, $title, $body, $data);
|
||||
|
||||
@@ -11,6 +11,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
@@ -21,7 +22,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.cloudhosting.twp.twp_softphone"
|
||||
minSdkVersion 24
|
||||
minSdkVersion 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@@ -42,6 +43,7 @@ flutter {
|
||||
}
|
||||
|
||||
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": "000000000000",
|
||||
"project_id": "twp-softphone-placeholder",
|
||||
"storage_bucket": "twp-softphone-placeholder.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "io.cloudhosting.twp.twp_softphone"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "PLACEHOLDER_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -18,8 +18,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user