Merge pull request 'feat: Firebase Cloud Messaging push notifications' (#61) from feat/dashboard-aggregated-chart into master

Reviewed-on: #61
This commit was merged in pull request #61.
This commit is contained in:
2026-06-13 13:01:58 -04:00
17 changed files with 379 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
} }
android { android {
@@ -65,6 +66,10 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.work:work-runtime-ktx:2.10.0") implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation(platform("com.google.firebase:firebase-bom:34.14.1"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-messaging-ktx:24.1.0")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
} }

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "402296861916",
"project_id": "servermanager-4cf7a",
"storage_bucket": "servermanager-4cf7a.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:402296861916:android:37b5195fc3d5cbcd7eb019",
"android_client_info": {
"package_name": "com.devlab.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBvcAfrLKY9XYHbc8Pv2dWTj-U4neSZ6nY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -42,6 +42,14 @@
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" /> android:exported="false" />
<service
android:name=".service.ServerManagerFirebaseService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View File

@@ -79,4 +79,7 @@ interface ApiService {
@GET("api/notifications/unread-count") @GET("api/notifications/unread-count")
suspend fun getUnreadCount(): UnreadCountResponse suspend fun getUnreadCount(): UnreadCountResponse
@POST("api/fcm/register")
suspend fun registerFcmToken(@Body request: FcmRegisterRequest): ApiResponse<Map<String, Boolean>>
} }

View File

@@ -0,0 +1,6 @@
package com.devlab.app.data.model
@kotlinx.serialization.Serializable
data class FcmRegisterRequest(
val token: String
)

View File

@@ -0,0 +1,76 @@
package com.devlab.app.service
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.model.AppNotification
import com.devlab.app.data.model.FcmRegisterRequest
import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ServerManagerFirebaseService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "New FCM token: $token")
CoroutineScope(Dispatchers.IO).launch {
val prefs = PreferencesManager(this@ServerManagerFirebaseService)
prefs.saveFcmToken(token)
val apiToken = prefs.getToken()
if (apiToken.isNotBlank()) {
registerTokenWithBackend(token, apiToken)
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
Log.d(TAG, "FCM message received: ${message.data}")
val title = message.notification?.title
?: message.data["title"]
?: "Notification"
val body = message.notification?.body
?: message.data["message"]
?: ""
val type = message.data["type"] ?: "info"
val noteId = (message.data["notification_id"] ?: "0").toIntOrNull() ?: 0
showNotification(title, body, type, noteId)
}
private fun showNotification(title: String, body: String, type: String, noteId: Int) {
val notification = AppNotification(
id = noteId,
title = title,
message = body,
type = type,
is_read = 0,
created_at = "",
)
NotificationHelper.showNotification(this, notification)
}
private suspend fun registerTokenWithBackend(token: String, apiToken: String) {
try {
RetrofitClient.setToken(apiToken)
val service = RetrofitClient.getApiService()
service.registerFcmToken(FcmRegisterRequest(token))
Log.d(TAG, "FCM token registered with backend")
} catch (e: Exception) {
Log.e(TAG, "Failed to register FCM token: ${e.message}")
}
}
companion object {
private const val TAG = "ServerManagerFCM"
}
}

View File

@@ -6,6 +6,7 @@ import com.devlab.app.ServerManagerApp
import com.devlab.app.data.api.RetrofitClient import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.api.SessionManager import com.devlab.app.data.api.SessionManager
import com.devlab.app.data.repository.AuthRepository import com.devlab.app.data.repository.AuthRepository
import com.devlab.app.data.model.FcmRegisterRequest
import com.devlab.app.util.PreferencesManager import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -77,6 +78,7 @@ class LoginViewModel : ViewModel() {
isConnected = true, isConnected = true,
error = null error = null
) )
registerFcmToken()
}, },
onFailure = { e -> onFailure = { e ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -95,4 +97,14 @@ class LoginViewModel : ViewModel() {
prefs.clear() prefs.clear()
} }
} }
private suspend fun registerFcmToken() {
try {
val fcmToken = prefs.getFcmToken()
if (fcmToken.isNotBlank()) {
val service = RetrofitClient.getApiService()
service.registerFcmToken(FcmRegisterRequest(fcmToken))
}
} catch (_: Exception) { }
}
} }

View File

@@ -20,6 +20,7 @@ class PreferencesManager(private val context: Context) {
private val KEY_EMAIL = stringPreferencesKey("email") private val KEY_EMAIL = stringPreferencesKey("email")
private val KEY_ROLE = stringPreferencesKey("role") private val KEY_ROLE = stringPreferencesKey("role")
private val KEY_USER_ID = intPreferencesKey("user_id") private val KEY_USER_ID = intPreferencesKey("user_id")
private val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token")
private val KEY_LAST_NOTIFICATION_CHECK = longPreferencesKey("last_notification_check") private val KEY_LAST_NOTIFICATION_CHECK = longPreferencesKey("last_notification_check")
} }
@@ -80,6 +81,14 @@ class PreferencesManager(private val context: Context) {
} }
} }
suspend fun saveFcmToken(token: String) {
context.dataStore.edit { prefs ->
prefs[KEY_FCM_TOKEN] = token
}
}
suspend fun getFcmToken(): String = context.dataStore.data.first()[KEY_FCM_TOKEN] ?: ""
suspend fun clear() { suspend fun clear() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }

View File

@@ -2,4 +2,5 @@ plugins {
id("com.android.application") version "9.2.1" apply false id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
} }

View File

@@ -60,6 +60,10 @@ return [
'rate_limit_window' => (int) ($_ENV['API_RATE_LIMIT_WINDOW'] ?? 60), 'rate_limit_window' => (int) ($_ENV['API_RATE_LIMIT_WINDOW'] ?? 60),
], ],
'fcm' => [
'server_key' => $_ENV['FCM_SERVER_KEY'] ?? '',
],
'log' => [ 'log' => [
'path' => $_ENV['LOG_PATH'] ?? __DIR__ . '/../logs/', 'path' => $_ENV['LOG_PATH'] ?? __DIR__ . '/../logs/',
'level' => $_ENV['LOG_LEVEL'] ?? 'warning', 'level' => $_ENV['LOG_LEVEL'] ?? 'warning',

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `user_fcm_tokens` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`token` VARCHAR(255) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_token` (`user_id`, `token`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

46
install/firebase-setup.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Firebase Cloud Messaging setup script
# =======================================
#
# This script creates placeholder files for FCM integration.
# You must replace them with real values from your Firebase project.
#
# Steps to set up Firebase:
#
# 1. Go to https://console.firebase.google.com/ and create a project
# (or use an existing one)
#
# 2. Add an Android app to your Firebase project with package name:
# com.devlab.app
#
# 3. Download google-services.json and place it at:
# android/app/google-services.json
#
# 4. Go to Project Settings > Cloud Messaging and copy the
# Server key (legacy) or Cloud Messaging API key
#
# 5. Set the server key in .env:
# FCM_SERVER_KEY=your_server_key_here
#
# 6. OR place your Firebase Admin service account key at:
# config/firebase-service-account.json
# (for the OAuth-based API - more secure)
#
# The app will work without FCM configured; push notifications
# simply won't be sent until Firebase is set up.
#
# ---
echo "Firebase setup script"
echo "====================="
echo ""
echo "To enable push notifications:"
echo ""
echo "1. Create a Firebase project: https://console.firebase.google.com"
echo "2. Add Android app with package: com.devlab.app"
echo "3. Download google-services.json → android/app/google-services.json"
echo "4. Get Server Key from Cloud Messaging settings"
echo "5. Add to .env: FCM_SERVER_KEY=your_key_here"
echo ""
echo "Without these steps, push notifications are disabled."
echo "The app will continue to work using polling as fallback."

Binary file not shown.

View File

@@ -124,6 +124,7 @@ $router->get('/api/profile', [ApiController::class, 'profile']);
$router->put('/api/profile', [ApiController::class, 'updateProfile']); $router->put('/api/profile', [ApiController::class, 'updateProfile']);
$router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']); $router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']);
$router->post('/api/metrics/push', [ApiController::class, 'pushMetrics']); $router->post('/api/metrics/push', [ApiController::class, 'pushMetrics']);
$router->post('/api/fcm/register', [ApiController::class, 'registerFcmToken']);
$router->post('/servers/:id/agent/install', [ServerController::class, 'agentInstall'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->post('/servers/:id/agent/install', [ServerController::class, 'agentInstall'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/servers/:id/agent/uninstall', [ServerController::class, 'agentUninstall'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->post('/servers/:id/agent/uninstall', [ServerController::class, 'agentUninstall'], [AuthMiddleware::class, CSRFMiddleware::class]);

View File

@@ -11,6 +11,7 @@ use ServerManager\Core\Validator;
use ServerManager\Models\Notification; use ServerManager\Models\Notification;
use ServerManager\Models\User; use ServerManager\Models\User;
use ServerManager\Services\AuditService; use ServerManager\Services\AuditService;
use ServerManager\Services\FCMService;
class AdminController class AdminController
{ {
@@ -280,13 +281,20 @@ class AdminController
} }
$notificationModel = new Notification(); $notificationModel = new Notification();
$notificationModel->create([ $noteId = $notificationModel->create([
'user_id' => $userId, 'user_id' => $userId,
'title' => $title, 'title' => $title,
'message' => $message, 'message' => $message,
'type' => $type, '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->auditService->log('notification_sent', 'notification', null, ['title' => $title, 'target' => $userId ? "user #{$userId}" : 'all users']);
$this->view->json(['success' => true, 'message' => 'Notification sent successfully.']); $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\SSHService;
use ServerManager\Services\AuditService; use ServerManager\Services\AuditService;
use ServerManager\Services\PermissionValidator; use ServerManager\Services\PermissionValidator;
use ServerManager\Services\FCMService;
use ServerManager\Models\CommandHistory; use ServerManager\Models\CommandHistory;
use ServerManager\Core\Validator; use ServerManager\Core\Validator;
@@ -696,6 +697,34 @@ class ApiController
$this->view->json(['success' => true]); $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 public function serverServices(int $id): void
{ {
$user = $this->authenticateRequest(); $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;
}
}