واجهتُ المشكلة في تطبيق أندرويد مبني بـFlutter وFirebase: دخول قوقل يعمل من أول محاولة، والدخول برقم الجوال يفشل عند أول ضغطة على «إرسال رمز التحقق». لا رسالة تصل، ولا خطأ مفهوم — فقط استدعاء لدالة verificationFailed برسالة عامّة، ثم شاشة عالقة.
استغرق تشخيصها أكثر من يوم، وأغلبه ضاع في المكان الخطأ: راجعتُ تفعيل مزوّد الهاتف في الكونسول مرّتين، وأعدتُ توليد ملف google-services.json وتنزيله، وشككتُ في صيغة الرقم الدولية، وقرأتُ سجل الجهاز بحثاً عن استثناء لا وجود له. السبب في النهاية لم يكن في الكود ولا في صيغة الرقم: كان بصمةً واحدة غائبة عن إعدادات المشروع — SHA-256.
الدرس الذي استخلصته وصار قاعدة عندنا: «دخول قوقل يعمل» ليست شهادة أن إعداد التوقيع سليم للهاتف. المسارَان يستعملان بصمة شهادة التوقيع لغرضين مختلفين تماماً، وكلٌّ منهما يطلب خوارزمية بصمة مختلفة. وأسوأ ما في المسألة أنّ ملف الإعدادات نفسه يخفي الفارق. في هذا المقال أشرح ما اكتشفناه في مشروعين حقيقيين — تطبيق أندرويد وموقع ويب — من الفرق الجوهري بين المسارَين، إلى البصمات الثلاث المطلوبة، إلى ضبط تكلفة الرسائل بحدود نصنعها بأنفسنا.
لماذا يختلف الهاتف عن دخول قوقل جذرياً
في دخول قوقل، البصمة تلعب دور هوية: خدمات Play تطابق معرّف الحزمة مع بصمة الشهادة الموقّعة لتتأكّد أن التطبيق الذي يطلب idToken هو نفسه المسجَّل في عميل OAuth. هذه مطابقة تعريفية، ويكفي فيها SHA-1 — ولهذا يُسجَّل هذا النوع من العملاء في ملف الإعدادات بحقل بصمة واحد فقط.
في الهاتف، القصّة أمنية لا تعريفية. Firebase على وشك أن يُنفق مالاً: كل رمز تحقّق يُرسَل رسالةً نصّية فعلية عبر مشغّل. ونقطة الوصول التي تطلب الرسالة يمكن استدعاؤها بمفتاح API العام الموجود أصلاً داخل التطبيق ويمكن استخراجه. فلا يكفي أن تقول «أنا هذا التطبيق»؛ يجب أن تُثبته. وهنا يدخل إثبات سلامة التطبيق (Play Integrity، وقبله SafetyNet): تُصدر خدمات Play حكماً موقّعاً يقول إنّ النسخة العاملة على هذا الجهاز حزمة أصلية غير معدّلة — والحزمة في هذا الحكم تُعرَّف ببصمة SHA-256 لشهادة توقيعها، لا بـSHA-1.
النتيجة العملية: إذا كانت SHA-256 غير مسجَّلة في مشروع Firebase، فالحكم الذي يصل من الجهاز يعود لمفتاح لا يعرفه الخادم، فيُرفض الطلب قبل أن يلمس بوّابة الرسائل. لا رسالة، ولا سبب واضح — لأن الرفض حدث في طبقة الإثبات لا في طبقة المصادقة.
SHA-1 ليعرف من أنت؛ والهاتف يحتاج SHA-256 ليتأكّد أنك أنت فعلاً. سجّل الاثنتين لكل مفتاح توقيع، ولا تستدلّ بنجاح أحد المسارَين على سلامة الآخر.
المصيدة: ملف google-services.json لا يعرف SHA-256
هذه هي الحلقة التي جعلت التشخيص طويلاً. افتح ملف google-services.json وانظر مصفوفة oauth_client: لكل مفتاح توقيع مُسجَّل تظهر مدخلة بـclient_type: 1 تحتوي كائن android_client_info بحقلين — package_name وcertificate_hash. وقيمة هذا الحقل أربعون حرفاً ست عشرياً، أي عشرون بايتاً، وهذا طول SHA-1 حصراً؛ بصمة SHA-256 اثنان وثلاثون بايتاً وتحتاج أربعة وستّين حرفاً. في ملف مشروعنا ثلاث مدخلات client_type: 1 ومدخلة client_type: 3 لعميل الويب — ولا يوجد في الملف كلّه حقل واحد لبصمة SHA-256.
وهنا يقع المطوّر في الفخ: يضيف البصمة في الكونسول، ثم يُعيد تنزيل الملف ليتأكّد، فلا يرى شيئاً جديداً. قارنّا الملف قبل الإضافة وبعدها فلم يتغيّر فيه حرف. فيستنتج أن الإضافة لم تُحفظ، فيحذفها ويعيدها، أو يبحث عن الخلل في مكان آخر تماماً — وهو بالضبط ما فعلناه.
الحقيقة أن SHA-256 ليست إعداداً يُحمَل مع التطبيق، بل قيمة يقرأها الخادم عند التحقّق من حكم السلامة. وهذا يعطي فائدة عملية جيّدة: بعد إضافتها من الكونسول لا تحتاج بناءً جديداً ولا إعادة توليد الملف — إغلاق التطبيق وإعادة فتحه كافيان لتصبح فعّالة. تُضاف من إعدادات المشروع في Firebase، في بطاقة تطبيق أندرويد، بزرّ إضافة بصمة.
البصمات الثلاث — والحساب الذي يكشف الناقص
أي نسخة من تطبيقك تصل إلى جهاز تكون موقّعة بأحد ثلاثة مفاتيح، وكلٌّ منها بصمة مختلفة:
- مفتاح التطوير (
debug): يوقّع ما تشغّله على جهازك أو محاكيك أثناء العمل. - مفتاح الرفع (
upload): مفتاحك الخاص الذي توقّع به حزمة.aabقبل رفعها. - مفتاح توقيع التطبيق من قوقل (
Play App Signing): يملكه قوقل ويُعيد به توقيع نسختك قبل توصيلها للمستخدمين.
وكل مفتاح يحتاج بصمتَيه (SHA-1 + SHA-256). فالحساب صريح: أربع بصمات قبل النشر (تطوير + رفع)، وست بعده. عدّ البصمات في الكونسول هو أسرع فحص للإعداد: إن كان العدد فردياً فهناك مفتاح سُجّلت له بصمة واحدة فقط، وإن بقي أربعاً بعد النشر فبصمتا مفتاح قوقل ناقصتان.
وللمفتاح الثالث عرض مرضيّ مميّز يستحقّ الحفظ: الدخول يعمل عندك ولدى فريقك، ويفشل لدى من يُنزّل من المتجر فقط. السبب أن Play يعيد توقيع الحزمة بمفتاحه، فالنسخة التي تصل للمستخدم ببصمة لم تُسجَّل قط. ولا يمكنك استباق هذه الحالة قبل الرفع لأن المفتاح لا يوجد أصلاً حتى أول عملية رفع؛ تجد بصمتيه بعدها في وحدة تحكّم Play، في قسم الإعداد ثمّ سلامة التطبيق ثمّ توقيع التطبيق. الطريقة الوحيدة لاختبار ما سيصل للمستخدم فعلاً هي مسار الاختبار الداخلي، لأنه يوصّل نسخة موقّعة بمفتاح قوقل لا بمفتاحك. تفاصيل أخرى عن هذه المرحلة في مقال نشر تطبيق على Google Play.
المحاكي يكذب: لماذا الاختبار على جهاز حقيقي شرط
هذه النقطة أهدرت علينا وقتاً بقدر ما أهدرته البصمة، لأن نتيجة الاختبار على المحاكي لا تنفي ولا تُثبت.
ما يحدث على جهاز حقيقي
خدمات Play متوفّرة، فيسير المسار الصحيح: إثبات السلامة ثمّ إرسال الرسالة. وقد يحدث ما لم نتوقّعه في التصميم الأول: يتحقّق النظام من الرمز تلقائياً دون أن تُدخل المستخدمة شيئاً، لأن أندرويد يقرأ الرسالة ويُكمل العملية صامتاً. لذلك بنينا دالة التحقّق عندنا بأربع دوال استدعاء لا ثلاث، ومنها دالة للتحقّق التلقائي: بدونها تبقى المستخدمة أمام شاشة إدخال رمز لن تحتاجه أبداً، وقد أصبحت مسجّلة الدخول فعلاً.
ما يحدث على المحاكي والويب
لا وجود لإثبات سلامة، فيسقط المسار إلى reCAPTCHA: يفتح تحدّياً في نافذة ويب داخلية بدلاً من حكم السلامة. النتيجة أن الرسالة قد تصل على المحاكي وإعدادُ SHA-256 ناقص تماماً، أو تفشل على المحاكي لسبب لا علاقة له بالبصمات. لذلك صار عندنا فصل صريح: تطوير الواجهة على المحاكي، وتأكيد الإعداد على جهاز حقيقي بنسخة إصدار حصراً.
أرقام الاختبار: تطوير بلا رسالة واحدة
الميزة التي كان يجب أن نستعملها من اليوم الأول: أرقام الهواتف التجريبية في الكونسول (المصادقة ← طرق الدخول ← الهاتف). تسجّل رقماً ورمزاً ثابتاً من ستّ خانات، فيقبل Firebase هذا الزوج دون إرسال رسالة، ودون إثبات سلامة، ودون استهلاك أي حصّة. بذلك اختبرنا الشاشتين — إدخال الرقم، وإدخال الرمز — عشرات المرّات مجاناً، بما في ذلك حالات الفشل: رمز خاطئ، ورمز منتهي، وضغط زرّ إعادة الإرسال قبل انتهاء المؤقّت.
التدفّق كما نفّذناه — والأخطاء التي أظهرناها صراحة
على أندرويد، دالة بدء التحقّق تأخذ الرقم بصيغة دولية وأربع دوال استدعاء:
verificationCompleted — تحقّق تلقائي: ادخل مباشرة ببيانات الاعتماد الجاهزة.
verificationFailed — فشل: هنا يصل رمز الخطأ الحقيقي.
codeSent(verificationId) — أُرسِلت الرسالة: انقل المعرّف للشاشة الثانية.
codeAutoRetrievalTimeout — انتهت مهلة القراءة التلقائية.
ثم يُبنى الاعتماد من معرّف التحقّق ورمز المستخدمة عبر PhoneAuthProvider.credential ويُمرَّر إلى signInWithCredential. لاحظ أن معرّف التحقّق هو الحالة الوحيدة التي تنقلها بين الشاشتين؛ إن فقدته (تدوير الشاشة، أو إعادة بناء الواجهة) فالرمز الذي في يد المستخدمة صار بلا قيمة.
قبل كل ذلك: طبّع الرقم بنفسك. المستخدمة تكتب الرقم بفواصل، أو بصفر بادئ، أو بأرقام هندية. عندنا نحذف كل ما ليس رقماً، ونُسقط الصفر البادئ، ونرفض ما دون تسع خانات، ثم نضيف مفتاح الدولة. سبب الإصرار على ذلك أن الرقم غير المطبّع يرجع بخطأ invalid-phone-number بعد رحلة كاملة إلى الخادم، وقد يُحسب ضمن محاولاتك.
والقاعدة التي وفّرت علينا التشخيص لاحقاً: لا تبتلع رمز الخطأ. لدينا خريطة صريحة تترجم كل رمز إلى رسالة عربية دقيقة، أهمّها في هذا السياق: invalid-verification-code وcode-expired وquota-exceeded (نفدت حصّة الرسائل) وbilling-not-enabled (الخدمة غير مفعّلة على الخادم) وoperation-not-allowed (المزوّد معطّل) وcaptcha-check-failed وtoo-many-requests. كل رمز من هذه يشير إلى موضع مختلف تماماً للإصلاح؛ رسالة «حدث خطأ، حاول مجدداً» تخفيها كلّها. المنطق نفسه ينطبق على المسار الآخر، وقد فصّلته في فشل تسجيل الدخول بقوقل على أندرويد.
وعلى الويب درس إضافي: مُتحقّق reCAPTCHA غير المرئي يُنشأ مرّة واحدة، ولكن يجب مسحه وإعادة إنشائه عند أي فشل — وإلا بقي رمزه المستهلك في العنصر فتفشل المحاولة الثانية بـcaptcha-check-failed بلا سبب ظاهر. وقد اضطررنا لإنشاء مُتحقّق منفصل تماماً لتدفّق حذف الحساب (الذي يطلب إعادة تحقّق)، لأن مشاركة العنصر نفسه بين تدفّقين تُفسد حالته.
التكلفة والحدود: سقف تصنعه بنفسك قبل أن يصنعه Firebase
التحقّق بالهاتف هو الخدمة الوحيدة في المصادقة التي تكلّف نقداً لكل استخدام. الحصّة اليومية المجانية صغيرة جداً — بنينا على أساس عشر عمليات في اليوم — وما بعدها يُحاسَب لكل رسالة، بسعر يختلف بحسب البلد. وحين وضعنا الدخول بالجوال في منصّة ويب فعلية، لم نكتف بحصّة المزوّد، لأن الحصّة سقف يوقف الخدمة لا سقف يحمي الميزانية. بنينا ثلاث طبقات:
- مؤقّت ستّين ثانية قبل تمكين زرّ إعادة الإرسال. أكبر مصدر هدر ليس المتلاعبين، بل مستخدمة حقيقية لم تصلها الرسالة فتضغط الزرّ خمس مرّات.
- كابح محلي في تخزين المتصفّح بنافذة ساعة واحدة: ثلاث رسائل للرقم نفسه، وثمانٍ للجهاز. رخيص وفوري، لكنه يُمسح بمسح التخزين — فهو تهذيب للسلوك لا حماية.
- حجز أتومي على الخادم: قبل كل إرسال نفتح معاملة (
transaction) تقرأ وثيقتَي عدّاد — عدّاد يومي عام مفتاحه تاريخ اليوم، وعدّاد لكل رقم مفتاحه الرقم مع التاريخ — ثم تزيد كلتيهما معاً. الحدّ الافتراضي عندنا ثمانٍ يومياً (أقل من حصّة المزوّد بهامش)، وثلاث لكل رقم في اليوم.
تفصيلان تعلّمناهما بالتجربة في الطبقة الثالثة. الأول: كل القراءات قبل كل الكتابات داخل المعاملة — شرط تفرضه قاعدة البيانات، وتجاهله يُفشل المعاملة كاملة. الثاني: المعاملة آمنة بالفشل عن قصد؛ إذا تعذّر الاتصال بها نُكمل الإرسال، لأن حصّة المزوّد تبقى السقف الصلب، والبديل (منع الدخول عند أي عطل شبكي) أسوأ للمستخدمة.
وقواعد الأمان على هذين العدّادين كانت التمرين الأصعب، لأن المستخدمة غير مصادَقة بعد لحظة الحجز — فلا يمكن ربط الصلاحية بهويّة. ما استقرّينا عليه: القراءة المفردة عامّة؛ والإنشاء لا يقبل إلا وثيقة بحقلَي العدد والتاريخ وبقيمة عدد تساوي واحداً؛ والتعديل لا يقبل إلا زيادة العدد بواحد بلا أي حقل آخر — فلا تصفير ولا تخفيض؛ وسرد وثائق عدّاد الأرقام محصور بالمدير لمنع تعداد أرقام الجوال. أقصى تلاعب ممكن بهذا التصميم هو تعطيل الدخول بالجوال عبر تضخيم العدّاد، لا تجاوز التكلفة — وقبلنا هذه المقايضة لأن البديل الكامل (الكتابة عبر دالة سحابية) يعني استدعاءً مدفوعاً قبل كل رسالة. الفلسفة نفسها مشروحة في قواعد Firestore الآمنة عملياً.
وأضفنا أمرين لأن الحدود وحدها لا تكفي: مفتاح إيقاف في إعدادات الموقع يُطفئ الدخول بالجوال فوراً من لوحة الإدارة بلا نشر جديد، وتحويل تلقائي عند بلوغ الحدّ ينقل المستخدمة بعد ثانيتين إلى البريد أو قوقل بدل أن تبقى أمام حائط.
متى يكون OTP الخيار الصحيح ومتى لا يكون
بعد التجربة، صار قرارنا محدّداً. الهاتف يستحقّ العناء حين: جمهورك يحفظ رقمه ولا يتذكّر كلمة مرور بريده (وهذا واقع شريحة واسعة من مستخدمي التطبيقات العربية)؛ أو حين الرقم نفسه بيانات تشغيلية تحتاجها أصلاً — في تطبيق يربط بائعاً بعميل مثلاً — فتجمع التحقّق والحاجة في خطوة واحدة؛ أو حين تريد حاجزاً أمام الحسابات الوهمية، لأن كل رقم إضافي يكلّف صاحبه شيئاً.
ولا يستحقّ أن يكون الطريقة الوحيدة أبداً: الحصّة تنفد، والرسالة قد لا تصل لسبب خارج عن يدك (المشغّل، التجوال، حجب المرسِل)، وتكلفة الرسالة الدولية أعلى بكثير من المحلية. في الحالتين عندنا كان الهاتف خياراً معروضاً بارزاً مع البريد وقوقل حاضرين دائماً.
وملاحظة تصميمية تُنسى كثيراً: حساب الهاتف لا يحمل بريداً ولا اسماً، فتحتاج شاشة إكمال بيانات بعد أول دخول. ومن جهة أخرى يُعتبر حساب الهاتف موثّقاً تلقائياً بخلاف البريد الذي يحتاج رابط تفعيل — فمنطق «هل المستخدم موثّق؟» عندك يجب أن يعامل مسارَي الهاتف وقوقل كموثّقين، وإلا حجبتَ مستخدمين شرعيين عن ميزات مقيّدة.
أسئلة شائعة
أضفتُ SHA-256 ولم يعمل — ما التالي؟ ثلاثة فحوص بالترتيب: أغلق التطبيق وأعد فتحه (البصمة تُقرأ من الخادم، والجلسة القديمة قد تحتفظ بحكم قديم)؛ تأكّد أنك أضفتها للمفتاح الذي وقّع النسخة التي تختبرها فعلاً (نسخة التطوير ونسخة الإصدار مفتاحان مختلفان)؛ ثم عُدّ البصمات — أربع قبل النشر، ست بعده.
هل صارت SHA-1 غير ضرورية؟ لا. دخول قوقل يعتمدها لمطابقة عميل OAuth، وهي البصمة الوحيدة التي يحملها ملف الإعدادات. القاعدة الآمنة: بصمتان لكل مفتاح، دائماً.
هل أحتاج خطة مدفوعة لتشغيل OTP؟ ضمن الحصّة اليومية المجانية لا. لكن أي استخدام يتجاوزها يتطلّب فوترة مفعّلة على المشروع، وحين تكون معطّلة يصل الرمز billing-not-enabled بوضوح — وهذا سبب وجيه لعرض رموز الأخطاء بدل إخفائها.
هل يمكن تجربة الهاتف على المحاكي؟ نعم عبر أرقام الاختبار، وهي كافية تماماً لتطوير الواجهة وحالات الفشل. أما رسالة نصّية حقيقية فتحتاج جهازاً فيه خدمات Play، لأن المحاكي يسقط إلى مسار reCAPTCHA المختلف كلياً.
خلاصة
فشل الدخول برقم الجوال على أندرويد غالباً ليس خطأ برمجياً بل خطأ إعداد صامت. تذكّر أربعة أشياء: الهاتف يعتمد إثبات سلامة التطبيق فيطلب SHA-256 لا SHA-1 فقط؛ ملف الإعدادات لا يخزّن SHA-256 إطلاقاً فلا تستدلّ به على صحّة إعدادك؛ المفاتيح ثلاثة (تطوير، رفع، توقيع قوقل) وأشدّها خفاءً الثالث لأنه يفشل عند مستخدمي المتجر وحدهم؛ والاختبار الحاسم على جهاز حقيقي بينما أرقام الكونسول التجريبية تكفي لكل ما قبله. وأخيراً، لا تُشغّل خدمة تكلّف نقداً لكل محاولة بلا سقف تملكه ورسالةٍ تشرحه وبديلٍ تُحوّل إليه.