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; + } +}