diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 08bace4..410c754 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
+ id("com.google.gms.google-services")
}
android {
@@ -65,6 +66,10 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.1")
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-test-manifest")
}
diff --git a/android/app/google-services.json b/android/app/google-services.json
new file mode 100644
index 0000000..6ec9fe4
--- /dev/null
+++ b/android/app/google-services.json
@@ -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"
+}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 43c2fba..f9a13c7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,6 +42,14 @@
android:foregroundServiceType="dataSync"
android:exported="false" />
+
+
+
+
+
+
>
}
diff --git a/android/app/src/main/java/com/devlab/app/data/model/FcmModels.kt b/android/app/src/main/java/com/devlab/app/data/model/FcmModels.kt
new file mode 100644
index 0000000..577c33c
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/data/model/FcmModels.kt
@@ -0,0 +1,6 @@
+package com.devlab.app.data.model
+
+@kotlinx.serialization.Serializable
+data class FcmRegisterRequest(
+ val token: String
+)
diff --git a/android/app/src/main/java/com/devlab/app/service/ServerManagerFirebaseService.kt b/android/app/src/main/java/com/devlab/app/service/ServerManagerFirebaseService.kt
new file mode 100644
index 0000000..4e5dd69
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/service/ServerManagerFirebaseService.kt
@@ -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"
+ }
+}
diff --git a/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt
index 8da959f..9764107 100644
--- a/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt
+++ b/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt
@@ -6,6 +6,7 @@ import com.devlab.app.ServerManagerApp
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.api.SessionManager
import com.devlab.app.data.repository.AuthRepository
+import com.devlab.app.data.model.FcmRegisterRequest
import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -77,6 +78,7 @@ class LoginViewModel : ViewModel() {
isConnected = true,
error = null
)
+ registerFcmToken()
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
@@ -95,4 +97,14 @@ class LoginViewModel : ViewModel() {
prefs.clear()
}
}
+
+ private suspend fun registerFcmToken() {
+ try {
+ val fcmToken = prefs.getFcmToken()
+ if (fcmToken.isNotBlank()) {
+ val service = RetrofitClient.getApiService()
+ service.registerFcmToken(FcmRegisterRequest(fcmToken))
+ }
+ } catch (_: Exception) { }
+ }
}
diff --git a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt
index d032bfd..a060fb9 100644
--- a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt
+++ b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt
@@ -20,6 +20,7 @@ class PreferencesManager(private val context: Context) {
private val KEY_EMAIL = stringPreferencesKey("email")
private val KEY_ROLE = stringPreferencesKey("role")
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")
}
@@ -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() {
context.dataStore.edit { it.clear() }
}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index c0d2a26..52c62c9 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -2,4 +2,5 @@ plugins {
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.serialization") version "2.3.21" apply false
+ id("com.google.gms.google-services") version "4.4.4" apply false
}
diff --git a/config/config.php b/config/config.php
index 2ef0f40..5813d05 100755
--- a/config/config.php
+++ b/config/config.php
@@ -60,6 +60,10 @@ return [
'rate_limit_window' => (int) ($_ENV['API_RATE_LIMIT_WINDOW'] ?? 60),
],
+ 'fcm' => [
+ 'server_key' => $_ENV['FCM_SERVER_KEY'] ?? '',
+ ],
+
'log' => [
'path' => $_ENV['LOG_PATH'] ?? __DIR__ . '/../logs/',
'level' => $_ENV['LOG_LEVEL'] ?? 'warning',
diff --git a/database/migrations/010_user_fcm_tokens.sql b/database/migrations/010_user_fcm_tokens.sql
new file mode 100644
index 0000000..e77e9b8
--- /dev/null
+++ b/database/migrations/010_user_fcm_tokens.sql
@@ -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;
diff --git a/install/firebase-setup.sh b/install/firebase-setup.sh
new file mode 100644
index 0000000..17c2463
--- /dev/null
+++ b/install/firebase-setup.sh
@@ -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."
diff --git a/public/sysadmin.apk b/public/sysadmin.apk
index 6ce0c72..4884f61 100644
Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ
diff --git a/routes/web.php b/routes/web.php
index 6ebafb1..e30e1d1 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -124,6 +124,7 @@ $router->get('/api/profile', [ApiController::class, 'profile']);
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
$router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']);
$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/uninstall', [ServerController::class, 'agentUninstall'], [AuthMiddleware::class, CSRFMiddleware::class]);
diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php
index 0608d2e..ecb8975 100755
--- a/src/Controllers/AdminController.php
+++ b/src/Controllers/AdminController.php
@@ -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.']);
diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php
index c6b53dd..bc06230 100755
--- a/src/Controllers/ApiController.php
+++ b/src/Controllers/ApiController.php
@@ -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();
diff --git a/src/Services/FCMService.php b/src/Services/FCMService.php
new file mode 100644
index 0000000..4d15c9b
--- /dev/null
+++ b/src/Services/FCMService.php
@@ -0,0 +1,131 @@
+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;
+ }
+}