feat: Firebase Cloud Messaging push notifications #61
@@ -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")
|
||||
}
|
||||
|
||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal 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"
|
||||
}
|
||||
@@ -42,6 +42,14 @@
|
||||
android:foregroundServiceType="dataSync"
|
||||
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
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -79,4 +79,7 @@ interface ApiService {
|
||||
|
||||
@GET("api/notifications/unread-count")
|
||||
suspend fun getUnreadCount(): UnreadCountResponse
|
||||
|
||||
@POST("api/fcm/register")
|
||||
suspend fun registerFcmToken(@Body request: FcmRegisterRequest): ApiResponse<Map<String, Boolean>>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.devlab.app.data.model
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class FcmRegisterRequest(
|
||||
val token: String
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
10
database/migrations/010_user_fcm_tokens.sql
Normal file
10
database/migrations/010_user_fcm_tokens.sql
Normal 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
46
install/firebase-setup.sh
Normal 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.
@@ -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]);
|
||||
|
||||
@@ -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.']);
|
||||
|
||||
@@ -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
131
src/Services/FCMService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user