الجوال والشاشات

الرسائل الفورية (Realtime)

التطبيق المصغّر كان يسأل ولا يُخبَر. كل قدرة قبل هذا الرقم هي أن ينادي الصندوقُ المضيفَ وينتظر جواباً، ولا مسار للمضيف أن ينادي إلى الداخل. فتطبيقٌ يريد أن يعرف متى قال خادمُه شيئاً لم يكن أمامه إلا الاستطلاع: شاشة الفصل تسأل كل ثانيتين «هل نُودي على أحد؟» طوال اليوم الدراسي، عن حدث يقع مرّتين في الحصّة. القناة تعكس الاتجاه — تطبيقك يدفع خبراً إلى أسطحه هو.

نصفان، وواحدٌ فقط رفع العقد — والفرق ليس تفصيلاً

الإرسال لم يحتج رفع عقد. النشر نداء API عادي على مسار مسموح أصلاً: Okta.post('/api/apps/realtime/publish', …) يعمل على العقد 21 بلا رمز جديد، لأن Okta.post تُرسل منذ العقد 1 وسطح الشركاء محكوم بالنطاقات لا بالرموز.

والاستقبال لا يمكن التعبير عنه أصلاً. لا رمز في المكتبة المحقونة يستقبل مُعالِجاً، ورمزٌ جديد في مكتبة محقونة هو تغيير عقد: تطبيق ينادي Okta.onMessage على مضيف 21 لا يفشل فشلاً لطيفاً، بل يموت على الجهاز بـ Cannot find static method Okta.onMessage — مسمّياً ملفاً لم يكتبه صاحبه. لذلك 21 ← 22.

وماذا يعني الرقم لك عملياً: تطبيق يُعلن minContract: 22 يُرفَض على مضيف أقدم بشاشة «حدِّث التطبيق» — وهو الرفض الصحيح، لأن البديل موتٌ في الترجمة على تلفاز في مدرسة. ومضيف 22 يشغّل كل ما سبق كما هو؛ لا شيء في العقد 21 تغيّر.

أعلِن minContract: 22 فقط إن كنت تستقبل. تطبيق ينشر ولا يستمع يبقى على أرضيته الحالية ويعمل على كل جهاز في الميدان — ورفعُ الأرضية بلا حاجة لا يجعل تطبيقك أحدث، يجعله يرفض أجهزة كان يعمل عليها.

النشر — POST /api/apps/realtime/publish

خلف النطاق realtime.messages.write. أعلِنه في scopes كأي نطاق آخر.

await Okta.post('/api/apps/realtime/publish', {
  'event': 'class.called',
  'data': {'section': 'أ-٣'},
});

الجسم يقبل مفتاحين لا ثالث لهما: event وdata. و202 يعيد:

{
  "published": true,
  "channel": "private-app.01M1TTHDSWKEXCCATPVA7MP8N5.roll-call",
  "sent_at": "2026-09-06T07:39:46+00:00"
}

وما يصل أسطحك على القناة:

{
  "event": "class.called",
  "data": {"section": "أ-٣"},
  "sent_at": "2026-09-06T07:39:46+00:00",
  "from": "server"
}

اسمك لا يصير اسم حدث البثّ أبداً. اسم حدث البثّ هو الثابت app.message لكل رسالة من كل تطبيق، وclass.called يسافر داخل الحمولة في مفتاح event. لو بُثّ باسمك لاستطاع تطبيقٌ أن يبثّ device.command.queued — أو أيّ اسم تربط عليه أسطح المنصّة — فيتصرّف عميلٌ على رسالة كتبها شريك. الإطار واحد: العميل يربط مرّة، ومفرداتك أنت بيانات.

الرفض — وما تفعله بكلٍّ منه

الرمز error ما حدث، وما تفعله
403 scope_not_granted النطاق realtime.messages.write غير ممنوح لهذا التثبيت. أعلِنه وأعد النشر؛ الجهة تمنحه عند التحديث.
422 invalid_event اسم الحدث لا يطابق ^[a-z][a-z0-9_.:-]{0,63}$ — حروف صغيرة، يبدأ بحرف، 64 محرفاً على الأكثر.
422 payload_too_large كتلة data تجاوزت 8192 بايت من JSON. أرسِل مرجعاً ودع السطح يقرأ الباقي عبر الـ API.
429 rate_limited تجاوزت 60 رسالة في الدقيقة لهذا التثبيت. الجسم يحمل retry_after بالثواني، ويحمله الردّ كذلك في ترويسة Retry-After.

نصوص الرفض تقول لك الإصلاح لا العطل فقط:

{"error":"payload_too_large","message":"The `data` object is 9008 bytes of JSON; the limit is 8192. Send a reference and let the surface fetch the rest over the API."}
{"error":"rate_limited","message":"This installation has published 60 messages in the last minute. Retry in 48s, or batch what you are sending into fewer messages.","retry_after":48}

الحدّان، ولماذا هما حيث هما

8 كيلوبايت على data كي لا تصير القناة طريقاً ثانياً للبيانات: ما يحتاج بيانات يقرؤها من الـ API بنطاقه المعتاد، فتبقى حراسة النطاقات على باب واحد. أرسِل «الشعبة أ-٣ نودي عليها» لا كشف الشعبة كاملاً.

60 رسالة في الدقيقة لكل تثبيت — أي لكل زوج (جهة، تطبيق). ولا هي لكل تطبيق ولا لكل جهة، لأن الزوج هو نصف قطر انفجار الحلقة بالضبط: حدٌّ لكل تطبيق يجعل حلقةً في نسخة مدرسةٍ واحدة تُسكِت تطبيقك في كل مدرسة أخرى، وحدٌّ لكل جهة يجعل تطبيقاً ثرثاراً ينفق ميزانية بقيّة تطبيقات مدرسته.

ما لا يوجد في الجسم، عمداً

لا tenant_id ولا module. الجهة والتطبيق يُقرآن من سياق التثبيت على الخادم وحده، ولا مكان في الجسم لكتابتهما. حقلٌ كهذا كان سيكون ادّعاءً، واحترامه يجعل تطبيقاً مثبَّتاً في مدرسة يخاطب شاشات مدرسة أخرى — وهو الشيء الوحيد الذي بُني نموذج التثبيت كلّه ليمنعه. أرسِلها إن شئت؛ لا تغيّر شيئاً.

ولا Idempotency-Key. تلك الترويسة تعيد ردّاً مخزَّناً 24 ساعة كي لا تقع كتابة مرّتين، ولا شيء يُكتب هنا. ورسالةٌ فورية تُعاد من الكاش رسالةٌ لم تصل والمستدعي يُقال له إنها وصلت — وهو أسوأ من خطأ ظاهر.

والنشر الذي يفشل بثُّه يبقى 202 ويُسجَّل تحذيراً. المستدعي أنجز العمل الحقيقي — سجّل الحضور، نادى الفصل — والرسالة تُعلن عنه فقط؛ فإسقاط الطلب كان يتراجع عن شيء وقع من أجل إعلانٍ لم يقع.

الاستقبال — Okta.onMessage (عقد 22)

Okta.onMessage((String event, Map data) {
  if (event == 'class.called') {
    setState(() { _section = '${data['section']}'; });
  }
});

مُعالِج واحد، والنداء الثاني يستبدل. لا قائمة مُعالِجين ولا unsubscribe، وكلا الغيابين مقصود:

  • قائمة مُعالِجين تطرح سؤالاً لا يستطيع الصندوق الإجابة عنه: أيّهما يملك هذه الرسالة؟ التطبيق واحد وقناته واحدة، فالمُعالِج واحد.
  • إلغاء الاشتراك لا شيء يفعله: المُعالِج يموت مع التطبيق المُركَّب، لأن المضيف هو من يفتح المقبس عند التركيب ويغلقه عند الإزالة. رمزٌ يلغي الاشتراك كان سيضيف طريقة واحدة لا غير — أن تُخطئ في العمر فيصمت تطبيقك وهو على الشاشة.

ولا رمز إرسال. Okta.post تُرسل منذ العقد 1، ورمزٌ ثانٍ لنفس الفعل مسارُ كتابة ثانٍ ينحرف.

والمقبس للتطبيق المُركَّب وحده. لكل هدف delegate خاص، ولكل delegate مقبسه وقناته — فلا مقبس مشترك يُسرَّب من خلاله شيء، ولا يسمع تطبيقٌ رسائل غيره.

واسم القناة يأتي من الإطلاق لا من الحزمة. الحزمة تُعاد استخدامها ما دام payload_version ثابتاً، فقناةٌ محمولة عليها تكون قناة الجهة التي فُتحت قبل تبديل المدرسة — مقبسٌ يُصادَق عليه بنظافة ويسلّم رسائل مدرسة أخرى. الإطلاق يُعاد سؤاله في كل فتح، ومنه يأخذ المضيف القناة. أنت لا تلمس هذا: تنادي onMessage فحسب.

والاستماع لا يحتاج نطاقاً — التثبيت هو الإذن. النطاق ما تمنحه الجهة لتطبيق كي يصل بياناتها، والقناة لا تحمل إلا ما أرسله تطبيقك إلى أسطحه هو في الجهة التي ثبّتته. ما يجب أن يصحّ هو أن التثبيت قائمٌ ونشط، ويُعاد فحصه عند كل اشتراك: الإزالة أو التعليق هو ما يُغلق القناة. ولذلك لا يوجد realtime.messages.read ولن يوجد — منحةٌ تُطلب ولا تشتري شيئاً منحةٌ يظنّ الشريك أنه يحتاجها.

القاعدة التي تُنجي تطبيقك: لا تنتظر رسالةً لترسم

مقبس ميّت لا يغيّر شيئاً على الشاشة. كتلة realtime في حمولة الإقلاع اختيارية، وغيابها حالة عادية لا عطل: نشرٌ بلا بثّاث، خادم أقدم من الميزة، منصّة لا يُفتح فيها المقبس. وكل فشل بعدها مُبتلَع ومُسجَّل: تصريح مرفوض، مضيف ميّت، إطار مشوَّه، مُعالِج يرمي.

ومن داخل الصندوق، مضيفٌ لا ينادي المُعالِج أبداً وقناةٌ صامتة الشيءُ نفسه — لا سبيل لك للتفريق بينهما. فالقاعدة واحدة:

اقرأ الحالة بـ Okta.get، ثم دع الرسالة تحدّثها. تطبيقٌ يرسم شاشة فارغة وينتظر أول رسالة يعرض فراغاً إلى الأبد على أي جهاز خارج التغطية — وهو بالضبط ما يبدو عليه تلفاز في مدرسة بشبكة متعثّرة.

فمقبس لا يتصل يكلّفك طزاجةً ولا شيء غير ذلك. والمضيف يعيد المحاولة عنك: تراجعٌ يبدأ بثانيتين ويتضاعف حتى سقف خمس دقائق، بجَور ±٢٠٪، ويُصفَّر العدّاد على نجاح الاشتراك لا على نجاح الاتصال.

ولا ترمِ داخل المُعالِج. هو كود مُفسَّر يُستدعى من نداء مقبس — خارج أي build، فلا حدود خطأ تحته. المضيف يلفّه مرّتين احتياطاً، لكن اجعل جسمه دفاعياً: اقرأ من data بحذر، ولا تفترض مفتاحاً.

وأنت الناشر: أصدِر كل حقل، وnull حين يكون فارغاً. مفتاحٌ غائب من الخريطة المفكوكة هو null خام في dart_eval، وis ترمي عليه، و OktaJson.str نفسها ترمي. اجعل حمولتك ثابتة الشكل ومُلئ الفراغ فيها null صراحةً — أنت الطرفان هنا، فالعقد بينك وبين نفسك.

مثال كامل: «نداء الفصل»

تطبيق واحد بسطحين: المعلّم على هاتفه ينادي شعبة، وشاشة الفصل تُحدَّث بلا استطلاع.

سطح الهاتف — ينشر بعد أن يفعل الفعل الحقيقي:

Future<void> callSection(String section) async {
  // 1) الفعل الحقيقي أولاً: يُسجَّل في خادمك أنت.
  final res = await Okta.post('/api/roll-call/calls', {'section': section});
  if (res.status < 200 || res.status >= 300) {
    Okta.toast('تعذّر تسجيل النداء');
    return;
  }

  // 2) ثم الإعلان. فشلُه لا يُبطل ما وقع في (1).
  await Okta.post('/api/apps/realtime/publish', {
    'event': 'class.called',
    'data': {'section': section, 'called_at': '${DateTime.now()}'},
  });

  Okta.playSound('success');
}

سطح الشاشة — يرسم من الحالة، ثم تحدّثه الرسالة:

class Board extends StatefulWidget {
  const Board({super.key});
  @override
  State<Board> createState() => _BoardState();
}

class _BoardState extends State<Board> {
  String _section = '';

  @override
  void initState() {
    super.initState();

    // أولاً: ارسم ما هو قائم الآن. هذه هي الشاشة على جهاز
    // لم يتّصل مقبسه أبداً — وهي شاشة صحيحة.
    _loadCurrent();

    // ثم: دع الرسالة تحدّثها. لا شيء هنا شرطٌ للرسم.
    Okta.onMessage((String event, Map data) {
      if (event != 'class.called') return;
      final dynamic v = data['section'];
      setState(() { _section = '$v'; });
    });
  }

  Future<void> _loadCurrent() async {
    final res = await Okta.get('/api/roll-call/calls/current');
    if (res.status != 200) return;
    final dynamic v = res.json()['section'];
    setState(() { _section = '$v'; });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        _section.isEmpty ? 'لا نداء الآن' : 'نُودي على: $_section',
      ),
    );
  }
}

وفي بيانك، على حزمة الشاشة وحدها إن كانت هي المستقبِلة:

"mobile": {
  "minContract": 22,
  "screen": {
    "enabled": true,
    "title": "نداء الفصل",
    "places": [
      { "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" }
    ]
  }
}

لاحظ ما ليس في المثال: لا حلقة استطلاع، ولا Timer، ولا مؤقّت Future.delayed يعيد السؤال. وإن انقطع المقبس فالشاشة تبقى على آخر ما قرأته من _loadCurrent() — قديمةً، لا فارغة.

كتلة realtime في حمولة الإقلاع

حمولة الإقلاع تحمل كتلة realtime (وعلى الشاشة كذلك) فيها الـ driver والمفتاح والمضيف والمنفذ واسم القناة ونقطة التصريح. أنت لا تستعملها — المضيف هو من يستهلكها ويفتح المقبس؛ ذكرناها هنا لأنها تشرح لماذا القناة تأتي من الإطلاق لا من الحزمة.

وحين لا يكون البثّاث مضبوطاً تصل الكتلة null لا مفتاحاً غائباً: الغياب كان سيجبر العميل على الخلط بين «خادم أقدم من الميزة» و«الميزة مطفأة» — حقيقتان بسلوكين.

عرض الدليل كاملاً في صفحة واحدة · ↧ Markdown خام