feat: Firebase Cloud Messaging push notifications

Backend:
- New user_fcm_tokens table (migration 010) for storing device tokens
- FCMService sends push via Firebase HTTP legacy API (server key from .env)
- POST /api/fcm/register endpoint for Android to register its FCM token
- AdminController::sendNotification() triggers FCM push after DB insert
- FCM config added to config.php

Android:
- Firebase Cloud Messaging service (ServerManagerFirebaseService)
  receives push notifications and shows them via NotificationHelper
- FCM token registered with backend on new token and on login
- google-services.json template (must be replaced with real Firebase config)
- firebase-setup.sh script with configuration instructions
- FCM dependency + google-services plugin added to Gradle
- Bump to v1.8.0 (versionCode 12)
This commit is contained in:
2026-06-13 12:37:40 -04:00
parent bdc4d73d89
commit 136eebc4c3
17 changed files with 383 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ use ServerManager\Core\Validator;
use ServerManager\Models\Notification;
use ServerManager\Models\User;
use ServerManager\Services\AuditService;
use ServerManager\Services\FCMService;
class AdminController
{
@@ -280,13 +281,20 @@ class AdminController
}
$notificationModel = new Notification();
$notificationModel->create([
$noteId = $notificationModel->create([
'user_id' => $userId,
'title' => $title,
'message' => $message,
'type' => $type,
]);
$fcm = new FCMService();
if ($userId) {
$fcm->sendToUser($userId, $title, $message, $type, $noteId);
} else {
$fcm->sendToAll($title, $message, $type, $noteId);
}
$this->auditService->log('notification_sent', 'notification', null, ['title' => $title, 'target' => $userId ? "user #{$userId}" : 'all users']);
$this->view->json(['success' => true, 'message' => 'Notification sent successfully.']);

View File

@@ -13,6 +13,7 @@ use ServerManager\Services\MonitoringService;
use ServerManager\Services\SSHService;
use ServerManager\Services\AuditService;
use ServerManager\Services\PermissionValidator;
use ServerManager\Services\FCMService;
use ServerManager\Models\CommandHistory;
use ServerManager\Core\Validator;
@@ -696,6 +697,34 @@ class ApiController
$this->view->json(['success' => true]);
}
public function registerFcmToken(): void
{
$user = $this->authenticateRequest();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$token = $input['token'] ?? '';
if (empty($token)) {
$this->view->json(['error' => 'Token is required'], 400);
}
$db = \ServerManager\Core\Database::getInstance();
$existing = $db->fetch(
'SELECT id FROM user_fcm_tokens WHERE user_id = ? AND token = ?',
[(int) $user['id'], $token]
);
if (!$existing) {
$db->insert('user_fcm_tokens', [
'user_id' => (int) $user['id'],
'token' => $token,
'created_at' => date('Y-m-d H:i:s'),
]);
}
$this->view->json(['success' => true]);
}
public function serverServices(int $id): void
{
$user = $this->authenticateRequest();

131
src/Services/FCMService.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
use ServerManager\Core\App;
use ServerManager\Core\Database;
use ServerManager\Core\Logger;
class FCMService
{
private Database $db;
private Logger $logger;
private string $serverKey;
public function __construct()
{
$this->db = Database::getInstance();
$this->logger = Logger::getInstance();
$config = App::getConfig();
$this->serverKey = $config['fcm']['server_key'] ?? $_ENV['FCM_SERVER_KEY'] ?? '';
}
public function isConfigured(): bool
{
return !empty($this->serverKey);
}
public function sendToUser(int $userId, string $title, string $body, string $type = 'info', ?int $notificationId = null): bool
{
if (!$this->isConfigured()) {
return false;
}
$tokens = $this->db->fetchAll(
'SELECT token FROM user_fcm_tokens WHERE user_id = ?',
[$userId]
);
if (empty($tokens)) {
return false;
}
$success = true;
foreach ($tokens as $row) {
$result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId);
if (!$result) $success = false;
}
return $success;
}
public function sendToAll(string $title, string $body, string $type = 'info', ?int $notificationId = null): int
{
if (!$this->isConfigured()) {
return 0;
}
$tokens = $this->db->fetchAll('SELECT DISTINCT token FROM user_fcm_tokens');
if (empty($tokens)) return 0;
$sent = 0;
foreach ($tokens as $row) {
if ($this->sendToDevice($row['token'], $title, $body, $type, $notificationId)) {
$sent++;
}
}
return $sent;
}
private function sendToDevice(string $token, string $title, string $body, string $type, ?int $notificationId): bool
{
$payload = [
'to' => $token,
'priority' => 'high',
'notification' => [
'title' => $title,
'body' => $body,
'sound' => 'default',
],
'data' => [
'type' => $type,
'notification_id' => (string) ($notificationId ?? 0),
'click_action' => 'OPEN_NOTIFICATIONS',
],
'android' => [
'priority' => 'high',
'notification' => [
'channel_id' => match ($type) {
'error', 'warning' => 'notifications_alerts',
default => 'notifications_general',
},
'priority' => match ($type) {
'error' => 'high',
default => 'default',
},
'sound' => 'default',
],
],
];
$ch = curl_init('https://fcm.googleapis.com/fcm/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: key=' . $this->serverKey,
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->logger->error('FCM send failed', [
'http_code' => $httpCode,
'response' => substr($response, 0, 500),
]);
return false;
}
return true;
}
}