الإشعارات الفورية والخدمات الخلفية

إرسال رسائل FCM من الخادم والاختبار الشامل

16 دقيقة الدرس 11 من 11

إرسال رسائل FCM من الخادم والاختبار الشامل

حتى الآن كنت تُشغّل إشعارات تجريبية من وحدة تحكم Firebase. في بيئة الإنتاج، يتولى خادمك مهمة إرسال الرسائل — إلى أجهزة بعينها، أو شرائح من المستخدمين، أو موضوعات (topics) كاملة. يغطي هذا الدرس كيفية إرسال رسائل FCM برمجياً باستخدام Firebase Admin SDK (Node.js / Python / Java / Go) وواجهة برمجة التطبيقات HTTP v1 لـ FCM، وكيفية اشتراك أجهزة Flutter في موضوعات مسماة، والتحقق من سلسلة التسليم الكاملة عبر حالات التطبيق الثلاث: المقدمة (foreground)، والخلفية (background)، والمُنهى (terminated).

ملاحظة: واجهة برمجة التطبيقات القديمة لـ FCM (HTTP) مهملة. استبدلتها Google بـ FCM HTTP v1 API التي تستخدم رموز وصول OAuth 2.0 قصيرة الأمد بدلاً من مفتاح الخادم الثابت. استخدم دائماً واجهة v1 أو Admin SDK (الذي يُغلّفها) في المشاريع الجديدة.

الحصول على بيانات الاعتماد لـ Admin SDK

يتوثق Admin SDK بواسطة مفتاح خاص لـ حساب الخدمة بدلاً من مفتاح الخادم القديم. للحصول عليه:

  • افتح Firebase Console → إعدادات المشروع → حسابات الخدمة
  • انقر إنشاء مفتاح خاص جديد وحمّل ملف JSON
  • احفظه خارج شجرة المصدر وحمّله عبر متغير بيئة (لا تُودعه أبداً في git)

ثبّت Admin SDK في Node.js بـ npm install firebase-admin، ثم هيّئه مرة واحدة في العملية:

Node.js — تهيئة Firebase Admin SDK

const admin = require('firebase-admin');
const serviceAccount = require(process.env.FIREBASE_CREDENTIALS_PATH);

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

const messaging = admin.messaging();

إرسال رسالة موجَّهة (بناءً على Token)

يحصل كل جهاز Flutter على رمز تسجيل FCM فريد عبر FirebaseMessaging.instance.getToken(). يجب أن يُرسل تطبيقك هذا الرمز إلى الخادم (مثلاً عند تسجيل الدخول أو تحديث الرمز) حتى يتمكن الخادم من توجيه الإشعارات إلى مستخدمين محددين.

Node.js — إرسال إشعار إلى رمز جهاز واحد

async function sendToDevice(token, title, body, data = {}) {
  const message = {
    token,                          // رمز تسجيل FCM من تطبيق Flutter
    notification: {
      title,
      body,
    },
    data,                           // أزواج مفتاح-قيمة نصية اختيارية
    android: {
      priority: 'high',
      notification: {
        channelId: 'default_channel',
        clickAction: 'FLUTTER_NOTIFICATION_CLICK',
      },
    },
    apns: {
      payload: {
        aps: {
          sound: 'default',
          badge: 1,
        },
      },
    },
  };

  const response = await messaging.send(message);
  console.log('تم إرسال الرسالة:', response);   // يعيد سلسلة معرّف الرسالة
  return response;
}

الرسائل القائمة على الموضوعات (Topics)

تتيح الموضوعات البث إلى مجموعات من الأجهزة دون إدارة الرموز الفردية. تشترك الأجهزة باستدعاء FirebaseMessaging.instance.subscribeToTopic() على جانب Flutter، ويرسل خادمك رسالة واحدة موجَّهة إلى اسم الموضوع.

Dart — الاشتراك وإلغاء الاشتراك في موضوع

import 'package:firebase_messaging/firebase_messaging.dart';

Future<void> subscribeToTopic(String topic) async {
  await FirebaseMessaging.instance.subscribeToTopic(topic);
  print('تم الاشتراك في الموضوع: $topic');
}

Future<void> unsubscribeFromTopic(String topic) async {
  await FirebaseMessaging.instance.unsubscribeFromTopic(topic);
  print('تم إلغاء الاشتراك من الموضوع: $topic');
}

على جانب الخادم توجّه الرسالة إلى /topics/<name>. في Admin SDK يكون الكود:

Node.js — إرسال رسالة إلى موضوع

async function sendToTopic(topic, title, body) {
  const message = {
    topic,                          // مثال: 'breaking-news' (بدون شرطة مائلة)
    notification: { title, body },
    android: { priority: 'high' },
  };
  const response = await messaging.send(message);
  console.log('تم إرسال رسالة الموضوع:', response);
}
نصيحة: يجوز أن تحتوي أسماء الموضوعات على حروف وأرقام وشرطات وشرطات سفلية ونقاط فقط. وهي حساسة لحالة الأحرف. يمكن اشتراك الجهاز في ما يصل إلى 2000 موضوع. تُخزَّن الاشتراكات على خوادم FCM وليس في قاعدة بياناتك.

استراتيجية الاختبار الشامل (End-to-End)

يجب أن يتحقق الاختبار الكامل من ثلاثة مسارات تسليم مختلفة لأن Flutter يستخدم مسار كود مختلفاً لكل منها:

  • المقدمة (Foreground) — التطبيق مفتوح وقيد التشغيل؛ يستدعي FCM الحدث FirebaseMessaging.onMessage، ولا تظهر إشعارات شريط الحالة تلقائياً على Android (يجب عرضها يدوياً عبر flutter_local_notifications).
  • الخلفية (Background) — التطبيق يعمل لكنه غير مُركَّز؛ يُسلَّم FCM بصمت ويستدعي معالج الخلفية عند نقر المستخدم على الإشعار.
  • المُنهى (Terminated) — التطبيق مغلق كلياً؛ يُسلَّم FCM عبر نظام التشغيل؛ عند النقر، يُطلق التطبيق بارداً وتقرأ الرسالة الأولية عبر FirebaseMessaging.instance.getInitialMessage().

Dart — معالجة حالات التطبيق الثلاث

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

// معالج الخلفية (يجب أن يكون خارج أي فئة)
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // أعد تهيئة Firebase لأن هذا يعمل في Isolate منفصل
  await Firebase.initializeApp();
  print('رسالة خلفية: ${message.notification?.title}');
}

class NotificationService {
  final FlutterLocalNotificationsPlugin _localNotifications =
      FlutterLocalNotificationsPlugin();

  Future<void> init() async {
    // سجّل معالج الخلفية قبل runApp()
    FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);

    // رسائل المقدمة
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      _showLocalNotification(message);
    });

    // فُتح التطبيق من نقرة إشعار الخلفية
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      _handleNavigation(message);
    });

    // أُطلق التطبيق من الحالة المُنهاة عبر نقرة إشعار
    final RemoteMessage? initial =
        await FirebaseMessaging.instance.getInitialMessage();
    if (initial != null) {
      _handleNavigation(initial);
    }
  }

  void _showLocalNotification(RemoteMessage message) {
    // اعرض إشعار شريط الحالة عندما يكون التطبيق في المقدمة
    final notification = message.notification;
    if (notification == null) return;
    _localNotifications.show(
      notification.hashCode,
      notification.title,
      notification.body,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'default_channel',
          'Default',
          importance: Importance.high,
          priority: Priority.high,
        ),
      ),
    );
  }

  void _handleNavigation(RemoteMessage message) {
    final route = message.data['route'];
    if (route != null) {
      // انتقل إلى الشاشة المحددة في حمولة الإشعار
      navigatorKey.currentState?.pushNamed(route);
    }
  }
}

قائمة التحقق للاختبار

اتبع هذا التسلسل للتأكد من عمل جميع مسارات التسليم قبل الإطلاق في الإنتاج:

  • استرداد الرمز: سجّل نتيجة getToken() وتأكد من الحصول على سلسلة غير فارغة على Android وiOS.
  • اختبار المقدمة: أبقِ التطبيق مفتوحاً، أرسل رسالة موجَّهة من الخادم؛ تحقق من ظهور الإشعار المحلي وتنشيط onMessage.
  • اختبار الخلفية: اضغط زر الرجوع للخلفية، أرسل الرسالة؛ تحقق من ظهور إشعار شريط الحالة وأن النقر عليه ينقل صحيحاً عبر onMessageOpenedApp.
  • اختبار الإنهاء: أغلق التطبيق كلياً، أرسل الرسالة؛ أعد التشغيل بالنقر على الإشعار؛ تحقق من أن getInitialMessage() تُعيد الرسالة وأن التنقل يعمل.
  • اختبار الموضوع: اشترك الجهاز في موضوع، أرسل رسالة موضوع، وكرر اختبارات الحالات الثلاث أعلاه.
تحذير: على iOS تُتجاهَل الإشعارات بصمت إذا لم يمنح المستخدم إذناً عبر requestPermission() أو إذا كانت بيانات اعتماد APNs مفقودة في Firebase Console → إعدادات المشروع → Cloud Messaging → إعداد تطبيق Apple. تأكد دائماً من إعداد APNs قبل الاختبار على أجهزة iOS الحقيقية.

ملخص

أصبح بإمكانك الآن إرسال رسائل FCM موجَّهة من خادم Node.js باستخدام Admin SDK، والبث إلى مجموعات الأجهزة عبر الموضوعات، وكتابة كود Flutter يتعامل بشكل صحيح مع تسليم المقدمة والخلفية والإنهاء. يعد الاختبار الشامل المنظم الذي يغطي الحالات الثلاث ضرورياً قبل شحن الإشعارات للمستخدمين الحقيقيين — إغفال مسار واحد يؤدي إلى فشل صامت يصعب إعادة إنتاجه في الإنتاج.