واجهتُ هذا العطل في تطبيق أندرويد مبني بـFlutter وFirebase Auth: يضغط المستخدم زرّ «الدخول بحساب قوقل»، فيفتح منتقي الحسابات الأصلي، ويختار حسابه، ثم تُغلق النافذة… ولا شيء يحدث. لا مستخدم جديد في لوحة Firebase، ولا انتقال إلى الشاشة الرئيسية، ولا سبب واضح — فقط شريط سفلي يقول «تعذّر تسجيل الدخول عبر Google، حاول مجدداً». والأسوأ أن دخول البريد وكلمة المرور كان يعمل بلا أي مشكلة في التطبيق نفسه، فبدا أن الإعداد كلّه سليم وأن العلّة في مكتبة قوقل وحدها.
استغرق تشخيصها ساعتين، وأغلب هذا الوقت ذهب في الاتجاه الخاطئ: أعدتُ تنزيل ملف الإعداد، وأعدتُ بناء المشروع من الصفر، وقارنتُ اسم الحزمة حرفاً حرفاً، وشككتُ في إصدار المكتبة. الحقيقة أن كل ذلك كان سليماً، وأن العطل كان يُخبرني عن نفسه من اللحظة الأولى برمز واضح — لكن كودي كان يبتلع هذا الرمز ويستبدله برسالة عامة. هذا المقال هو ما تعلّمناه من ذلك الملفّ الواحد: الرموز الحقيقية ومعناها، والسبب الخفيّ الذي لا يذكره أغلب الشروح، والترتيب الصحيح للفحص حتى لا تُهدر ساعتين مثلي.
القاعدة الأولى: لا تبتلع رمز الخطأ
في النسخة الأولى من شاشة الدخول كان الكود بهذا المعنى: حاوِل تسجيل الدخول، وإن فشل اعرض رسالة ودّية. المشكلة أن كائن الاستثناء كان يُرمى في سلّة المهملات. رسالة «حاول مجدداً» لا تحمل معلومة واحدة قابلة للتصرّف، ومع ذلك هي الشكل الذي تجده في معظم المشاريع.
أغلب حالات فشل «دخول قوقل» تبقى بلا حلّ لهذا السبب تحديداً: المكتبة تُرجع تشخيصاً دقيقاً، والكود يمحوه قبل أن يراه أحد. لذلك أصبح تعديلنا الأول ليس إصلاحاً للدخول، بل إصلاحاً للرؤية. أعدنا تغليف استثناء المنصّة بحيث يُحمل معه رمزُه الأصلي:
on PlatformException catch (e) { throw FirebaseAuthException(code: 'google-signin-failed', message: 'فشل منتقي حساب Google: ${e.code} — ${e.message}'); }
بهذا السطر تحوّل «فشل مجهول» إلى نصّ صريح فيه ApiException:10. وبعد تلك الرؤية استغرق الحلّ الفعلي دقائق. ثم عمّمنا الفكرة: كل نقطة يمكن أن يتعثّر فيها التدفّق أخذت رمزها الخاصّ بها، فصار الخطأ يخبرنا أين توقّف لا أنه توقّف فقط:
google-signin-failed— فشل منتقي الحسابات الأصلي نفسه، ومعه رمز المنصّة الحقيقي داخل الرسالة.cancelled— رجع المنتقي بحساب فارغ (null)، أي أن المستخدم أغلق النافذة.no-id-token— نجح اختيار الحساب لكن لم يصلidToken، ورسالتُه تذكر صراحةً: تحقّق منserverClientIdوبصمة SHA-1 للنسخة المُختبَرة.
catch يعرض رسالة عامة قبل أن تسجّل e.code وe.message. الرسالة الودّية للمستخدم، والرمز الحقيقي لك — والاثنان لا يتنافسان: واحد على الشاشة والآخر في السجل.
خريطة الرموز: ماذا يعني كل رمز بالضبط
بعد أن صار الرمز مرئياً، لم نعد نُخمّن. هذه دلالة الرموز التي واجهناها فعلياً، وكل واحد منها يقود إلى فحص مختلف تماماً:
ApiException:10(خطأ المطوّر) — النسخة التي تشغّلها موقّعة بمفتاح بصمتُه غير مسجّلة في مشروع Firebase، أو أن اسم الحزمة لا يطابق المسجَّل. الرمز لا يذكر كلمة «بصمة» أبداً، لذا يُقرأ خطأً كعطل في المكتبة. معناه العملي: لا يوجد تطابق بين ما وقّع هذا التطبيق وما تعرفه لوحة Firebase.operation-not-allowed— مزوّد Google معطّل في لوحة المصادقة. لا علاقة له بالكود ولا بالمفاتيح؛ الحلّ نقرة واحدة في الكونسول، ويمكن التحقّق منه دون جهاز كما في القسم قبل الأخير.- غياب
idToken— أخطر الحالات لأنه ليس خطأً في الظاهر: المنتقي عمل، والحساب رجع، وبيانات المستخدم موجودة، لكن الرمز المميّز الذي يحتاجه Firebase فارغ. سببه إماserverClientIdغير ممرَّر، أو حساب مُخبّأ من تشغيل سابق. - رموز الإلغاء —
cancelledوuser-cancelledوpopup-closed-by-userوweb-context-cancelled. هذه ليست أعطالاً بل قرار المستخدم، ويجب استثناؤها من أي رسالة خطأ.
ولاحظ مؤشّراً تشخيصياً مجانياً: دخول البريد وكلمة المرور لا يحتاج أي بصمة توقيع. فإن كان البريد يعمل وقوقل يفشل، فالمشكلة ليست في الاتصال ولا في ملف الإعداد ولا في تهيئة Firebase داخل التطبيق — بل في البصمات أو المزوّد أو الرمز المميّز. هذه الملاحظة وحدها تحذف نصف الاحتمالات قبل أن تبدأ.
السبب الخفيّ الأكثر إرباكاً: حساب مُخبّأ بلا رمز مميّز
هذا هو الجزء الذي لم أجده في أي شرح، وهو أكثر ما أضاع وقتي. مكتبة الدخول تحفظ الحساب المختار في الجهاز لتسريع المرّات القادمة. المشكلة أن الحساب الذي حُفظ في تشغيل سابق — قبل أن نضبط serverClientId — يبقى مخبّأً كما هو، فيُرجع جلسة تبدو صحيحة ولكن بـidToken فارغ. أنت أصلحت الإعداد فعلاً، والكود صار سليماً، والفشل مستمرّ لأن الجهاز يعيد استخدام جلسة قديمة معطوبة.
أعراضه مضلّلة بشكل خاصّ: التطبيق يفشل على جهازك أنت، ثم تعطيه لشخص آخر أو تجرّبه على جهاز نظيف فيعمل من أول مرّة. هنا يبدأ المطوّر بالشكّ في كل شيء إلا السبب الحقيقي. الحلّ سطر واحد: ابدأ التدفّق دائماً من جلسة نظيفة.
try { await google.signOut(); } catch (_) { /* لا يمنع بدء تدفّق جديد */ }
final account = await google.signIn();
لاحظ أمرين في هذا السطر. الأول أن signOut() موضوع داخل try ونتجاهل خطأه عن قصد: إن لم تكن هناك جلسة أصلاً فلا معنى لإيقاف التدفّق بسبب فشل تنظيف. الثاني أن التكلفة الحقيقية لهذه الخطوة هي ظهور منتقي الحسابات في كل مرّة بدلاً من الدخول الصامت — وقد اخترناها بوعي: ثانية إضافية على المستخدم أرخص بكثير من عطل صامت لا يُشخَّص. للسبب نفسه نستدعي إيقاف جلسة قوقل أيضاً عند تسجيل الخروج، وإلّا بقي الحساب السابق ملتصقاً بالتطبيق ولم يستطع المستخدم التبديل إلى حساب آخر.
تمرير serverClientId صراحةً ولا تثق بالحقن التلقائي
على أندرويد، مكتبة الدخول تُرجع رمزاً مميّزاً صالحاً لـFirebase فقط إذا عرفت «عميل الويب» الخاص بمشروعك. الاسم مُربك: عميل ويب في تطبيق أندرويد. والسبب أن الرمز المميّز يجب أن يُصدَر لجهة موثوقة على الخادم — وFirebase هو تلك الجهة — لا لتطبيق الجوال نفسه.
الطريق المعتاد أن يقرأ الإعداد قيمة اسمها default_web_client_id من موارد التطبيق، ويولّدها إضافةُ خدمات قوقل عند البناء من ملف الإعداد. في مشروعنا لم تُحقَن هذه القيمة في الموارد، فبقيت المكتبة بلا عميل ويب، فرجعت بحساب سليم وidToken فارغ — بلا أي رسالة خطأ. وهذا ما يجعل الحالة مؤلمة: كل شيء «نجح» ظاهرياً.
الحلّ الذي اعتمدناه هو التمرير الصريح، مع تعليق يشرح السبب حتى لا يحذفه أحد لاحقاً باعتباره زائداً:
static const _googleWebClientId = '…apps.googleusercontent.com'; // عميل الويب: OAuth type 3
final google = GoogleSignIn(serverClientId: _googleWebClientId);
ولتختار القيمة الصحيحة من ملف الإعداد: القائمة فيها أكثر من عميل، ولكل واحد نوع. النوع 1 هو عميل أندرويد المرتبط باسم الحزمة والبصمة، والنوع 3 هو عميل الويب. أنت تريد النوع 3 — وهو نفس القيمة التي كانت الإضافة ستحقنها لو نجحت. اختيار عميل أندرويد بالخطأ هنا شائع، ونتيجته أن يبقى الرمز المميّز فارغاً كما لو لم تفعل شيئاً.
وتذكّر ما يحتاجه Firebase فعلياً: الاعتماد يُبنى من accessToken وidToken، والثاني هو المطلوب للتحقّق. لذلك لا تحاول «المتابعة» بـaccessToken وحده؛ سيُرفض الاعتماد. الأفضل أن تفحص القيمة صراحةً وترمي خطأً واضحاً كما فعلنا في no-id-token، بدلاً من ترك المكتبة تفشل برسالة غامضة بعد خطوتين.
ثلاثة مفاتيح توقيع لا مفتاح واحد
هذه العلّة تُنتج أغرب تقرير عطل يمكن أن تستلمه: «الدخول بقوقل يعمل عندك ولا يعمل عندي»، مع أن كلاكما على نفس الإصدار. السبب أن التطبيق يمرّ بثلاثة مفاتيح توقيع مختلفة في حياته، وكل مفتاح له بصمة مستقلّة يجب تسجيلها:
- مفتاح التصحيح (debug) — يُوقّع النسخة التي تشغّلها من المحرّر أثناء التطوير. تسجيله يُصلح الدخول على جهازك فقط.
- مفتاح الرفع (upload) — يوقّع حزمة الإصدار التي ترفعها إلى المتجر. تسجيله يُصلح الدخول في النسخة التي توزّعها يدوياً أو عبر الاختبار الداخلي.
- مفتاح توقيع التطبيق من قوقل (Play App Signing) — يملكه قوقل ويعيد به توقيع نسختك قبل توصيلها للمستخدمين. لا يوجد قبل أول رفع، ولهذا يُنسى دائماً.
في مشروعنا كانت لوحة Firebase تحمل أربع بصمات: SHA-1 وSHA-256 لمفتاح التصحيح، ومثلهما لمفتاح الرفع. وهذا سليم تماماً للتطوير والاختبار — ومضلّل تماماً بعد النشر، لأن نسخة المتجر موقّعة بمفتاح ثالث لم يُسجَّل. النتيجة عطل يظهر عند المستخدمين وحدهم، بينما كل جهاز تطوير يعمل بشكل مثالي، وهو أسوأ نوع من الأعطال: لا يمكنك إعادة إنتاجه عندك.
الإصلاح خطوتان بعد أول رفع: انسخ البصمتين من لوحة النشر (الإعداد ← سلامة التطبيق ← توقيع التطبيق)، ثم أضِفهما في إعدادات مشروع Firebase عند تطبيق أندرويد. بعدها تصبح البصمات ستّاً: بصمتان لكل مفتاح من الثلاثة. اجعل هذه الخطوة بنداً ثابتاً في قائمة ما بعد النشر، لأنها الخطوة الوحيدة التي لا يمكن إنجازها قبله. وإن كنت تخطّط للنشر قريباً فراجع أيضاً العقبات غير المتوقّعة في نشر تطبيق على Google Play.
فحوص سريعة بلا جهاز ولا إعادة بناء
أبطأ ما في تشخيص المصادقة على أندرويد ليس التفكير بل حلقة التجربة: تعديل، بناء، تثبيت، دخول — دقائق لكل فرضية. لذلك نبدأ الآن بسؤال خدمة الهوية مباشرةً عبر طلبين لا يحتاجان جهازاً ولا محاكياً، بمفتاح واجهة التطبيق العام الموجود في ملف الإعداد:
GET https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig?key=API_KEY
يُرجع هذا الطلب المزوّدين المُفعّلين والنطاقات المسموحة، فتعرف في ثانية واحدة إن كان مزوّد Google مفعّلاً أصلاً في المشروع الذي يشير إليه تطبيقك — وهو سؤال يستحيل أن تجيب عنه بالتخمين. أما الفحص الحاسم فهو محاولة دخول برمز وهمي متعمّد:
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=API_KEY
وطريقة قراءة النتيجة هي المفتاح، لأن الردّين كلاهما «خطأ» ومع ذلك يعنيان أمرين متعاكسين:
INVALID_IDP_RESPONSE— نتيجة جيّدة: المزوّد مفعّل، والخدمة قبلت الطلب ورفضت الرمز الوهمي فقط (وهو ما يُنتظر). ابحث عن العلّة في البصمات أو في الرمز المميّز.OPERATION_NOT_ALLOWED— المزوّد معطّل. لا تكمل التشخيص في الكود؛ فعّله في الكونسول أولاً.
هذان الطلبان يحوّلان فرضيّتين من «أعد البناء وجرّب» إلى إجابة فورية. ومفتاح الواجهة هنا ليس سرّاً بطبيعته لأنه مضمّن في كل نسخة من تطبيقك، لكن أمانك الحقيقي يقوم على المزوّدين والنطاقات المسموحة وقواعد قاعدة البيانات لا على إخفائه — وهو موضوع قواعد Firestore الآمنة عملياً.
رسالة ودّية للمستخدم وسجلّ صريح لك
بعد كل ما سبق، بقيت مسألة العرض. الخطأ الشائع هو الخلط بين ما يستحقّ رسالة وما لا يستحقّها. الإلغاء ليس عطلاً: إن أغلق المستخدم منتقي الحسابات فهو يعرف ما فعل، وإظهار «تعذّر تسجيل الدخول» له يبدو كخلل في التطبيق. لذلك تعامل شاشتنا مع مجموعة رموز الإلغاء الأربعة بصمت تامّ — لا شريط ولا نافذة — وتتوقّف عند هذا الحدّ.
وما عدا ذلك يُسجَّل بتفاصيله ثم تُعرض رسالة بشرية واحدة:
debugPrint('Google sign-in failed: ${e.code} — ${e.message}');
_showError(context); // شريط سفلي بعبارة واحدة مفهومة
أضفنا كذلك مصيدة أخيرة لأي استثناء ليس من نوع خطأ المصادقة، بوسم مميّز في السجل، لأن أخطاء المنصّة لا تصل دائماً بالشكل المتوقّع؛ وبدون هذه المصيدة يبقى نوع كامل من الأعطال بلا أثر. وقبل أي عرض للواجهة نفحص أن الشاشة ما زالت قائمة — فالمستخدم قد يغادرها أثناء انتظار الشبكة، ومحاولة عرض شريط على شاشة زالت تُنتج عطلاً ثانياً يخفي الأول.
أسئلة شائعة
لماذا يعمل الدخول عندي ويفشل عند من ينزّل التطبيق من المتجر؟ السبب الأول والأرجح: بصمتا مفتاح توقيع التطبيق من قوقل غير مسجّلتين في Firebase. نسخة المتجر موقّعة بمفتاح ثالث لا يعرفه مشروعك، فتفشل هي وحدها بينما تعمل كل نسخ التطوير.
وصل accessToken ولم يصل idToken — أُكمل بدونه؟ لا. Firebase يبني الاعتماد على الرمز المميّز للهوية، وبدونه سيُرفض الاعتماد على أي حال. افحص القيمة صراحةً وارمِ خطأً واضحاً، ثم راجع serverClientId والحساب المُخبّأ بهذا الترتيب.
هل أحتاج بصمة SHA-256 أيضاً أم تكفي SHA-1؟ للدخول بقوقل تكفي SHA-1 عملياً، لكن الدخول برمز الجوال يتطلّب SHA-256 لكل مفتاح لأنه يعتمد على التحقّق من سلامة التطبيق. سجّل الاثنتين دائماً وستتفادى عطلاً قادماً — التفاصيل في رمز التحقّق بالجوال: لماذا SHA-1 لا يكفي.
أضفتُ البصمة الناقصة ولم يتغيّر شيء، ما التالي؟ أزِل الجلسة المُخبّأة: أوقف جلسة قوقل قبل بدء الدخول، أو احذف بيانات التطبيق من إعدادات النظام وأعد المحاولة. الحساب المحفوظ قبل الإصلاح يستمرّ في إرجاع رمز مميّز فارغ رغم صحّة الإعداد الجديد.
خلاصة
فشل الدخول بقوقل على أندرويد نادراً ما يكون غامضاً؛ الغامض هو كودنا الذي يمحو الرمز. الترتيب الذي نتّبعه الآن: (1) أظهر الرمز الحقيقي وسجّله، (2) اقرأ دلالته — ApiException:10 بصمة، وoperation-not-allowed مزوّد معطّل، وغياب الرمز المميّز إعدادٌ أو حساب مُخبّأ، (3) أوقف الجلسة قبل بدء الدخول دائماً، (4) مرّر عميل الويب صراحةً لا اعتماداً على الحقن التلقائي، (5) سجّل بصمات المفاتيح الثلاثة لا مفتاحاً واحداً، (6) افحص المزوّد بطلب واحد قبل أي إعادة بناء. ست خطوات تختصر ساعتين إلى عشر دقائق، وكلّها نتيجة عطل واحد في تطبيق منشور.