لنفرض أن لديك متجراً إلكترونياً صغيراً يبيع ويوصل الطلبات، وله موقع وتطبيق على Android وiOS. يرسل أحد العملاء رابط منتج لصديقه في WhatsApp، فيضغط الصديق على الرابط فيفتح المتصفح بدلاً من التطبيق المثبت عنده، ويطلب منه الموقع تسجيل الدخول من جديد، فيغلق الصفحة ولا يشتري. وفي نفس الأسبوع يصل لفريق الدعم أن الضغط على إشعار «طلبك خرج للتوصيل» يفتح الشاشة الرئيسية للتطبيق وليس صفحة الطلب، ثم يأتي فريق التسويق بفكرة جديدة: اشتراك شهري يعطي العميل مزايا داخل التطبيق، ويسأل: هل ندفعه من بوابة الدفع التي نستخدمها في الطلبات، أم يجب أن يمر عبر Apple وGoogle؟
هذه الأسئلة الثلاثة تبدو منفصلة، ولكنها في الحقيقة تدور حول شيء واحد: كيف يدخل المستخدم إلى مكان محدد داخل تطبيقك من خارجه، ومن يملك هذا الباب. فالرابط العميق Deep Link هو الباب الذي يدخل منه المستخدم من رسالة أو موقع، والإشعار Notification هو باب آخر يفتحه سيرفرك، والشراء داخل التطبيق In-App Purchase هو باب ثالث يملكه المتجر وليس أنت، ولكل باب قواعده التي يفرضها النظام والمتجر. وسوف نبني في هذا المقال تطبيقاً صغيراً على محاكي Android، ونجعل النظام يتحقق من النطاقات أمامنا ويفشل في التحقق، ونضع تطبيقاً ثانياً يأخذ روابطنا، ونرسل له رابطاً خبيثاً، ثم نفحص ملفات الربط على نطاقات حقيقية مثل Airbnb وYouTube وWikipedia ونرى أن ما يقرؤه iPhone ليس دائماً ما يقوله سيرفرك.
وسوف نناقش في هذا المقال ما يلي:
- ما هو الرابط العميق، والفرق بين المخطط الخاص Custom Scheme وروابط Android App Links وروابط Apple Universal Links.
- كيف يثبت النطاق Domain أنه يملك التطبيق: ملف
assetlinks.jsonوبصمة شهادة التوقيع، وحالات التحقق في Android 12 وما بعده. - ملف
apple-app-site-association، ولماذا يقرؤه iPhone من شبكة Apple وليس من سيرفرك. - تجربة عملية على المحاكي: تحقق يفشل، وموافقة يدوية، وتطبيقان على نفس المخطط، ورابط خبيث، وفحص ملفات حقيقية بأداة كتبناها.
- الروابط المؤجلة Deferred Deep Links، وماذا حدث لخدمة Firebase Dynamic Links.
- الإشعار الذي يفتح الطلب الصحيح: إذن الإشعارات في Android 13 وما بعده، والرابط داخل الإشعار.
- الشراء داخل التطبيق: متى يكون إلزامياً ومتى يكون ممنوعاً، ولماذا يجب أن يتحقق سيرفرك من كل عملية شراء.
- الأخطاء الشائعة، وقائمة تحقق قبل الإطلاق.
وإذا كنت تبحث عن كيفية وصول الإشعار نفسه من سيرفرك إلى الهاتف عبر APNs وFCM، فقد شرحناه بالتفصيل في مقال كيف تعمل الإشعارات الفورية Push Notifications، ولن نكرره هنا، وإنما نأخذ منه الجزء الذي يخص الرابط: ماذا يحدث بعد أن يضغط المستخدم على الإشعار.
ما هو الرابط العميق Deep Link، ولماذا فتح رابط المنتج المتصفح؟
الرابط العميق Deep Link هو رابط يفتح مكاناً محدداً داخل التطبيق، مثل صفحة طلب أو منتج، بدلاً من أن يفتح الشاشة الأولى. والفكرة قديمة في الويب، فكل رابط في الموقع هو رابط عميق بطبيعته، ولكن في الجوال المشكلة مختلفة لأن الرابط يجب أن يعرف أولاً أي تطبيق يفتحه، والنظام هو الذي يقرر ذلك وليس أنت. وعندما يضغط المستخدم على رابط، يسأل النظام سؤالاً واحداً: من سجل أنه يستطيع فتح هذا النوع من الروابط؟ والإجابة تختلف بحسب شكل الرابط، وهنا تظهر ثلاثة أنواع:
- المخطط الخاص Custom Scheme: رابط يبدأ باسم تخترعه أنت مثل
orders://order/42، ويعلن التطبيق في ملفاته أنه يفتح هذا المخطط. - روابط Android App Links: روابط
httpsعادية لنطاقك، يعلن التطبيق أنه يفتحها، ثم يتحقق النظام من النطاق نفسه أنه يوافق على ذلك. - روابط Apple Universal Links: نفس الفكرة في iOS، بملف آخر وطريقة تحقق مختلفة.
والصورة التالية تبين الطرق الثلاثة التي يمكن أن يسلكها نفس الرابط على Android، وكلها خرجت من تجربتنا التي سوف نشرحها لاحقاً:

وبالتالي فرابط المنتج الذي فتح المتصفح في قصتنا هو غالباً رابط https لم يتحقق النظام من نطاقه، أو تحقق منه ثم فشل، وهذا ما سوف نراه بأعيننا في التجربة. ولاحظ أن المتصفح هنا ليس خطأً بحد ذاته، فالرابط ما زال يعمل للجميع، ومن ليس عنده التطبيق يرى صفحة المنتج في الموقع، وهذه أهم ميزة في روابط https مقارنة بالمخطط الخاص الذي لا يفتح شيئاً إذا لم يكن التطبيق مثبتاً.
المخطط الخاص Custom Scheme: أسهل طريقة وأضعفها
أول ما يفعله أغلب المطورين هو المخطط الخاص، لأنه لا يحتاج سيرفراً ولا ملفات على النطاق، فقط سطر في ملف AndroidManifest.xml في Android، أو في ملف Info.plist في iOS، ثم يعمل الرابط فوراً على جهازك. ولكن هناك مشكلة رئيسية في هذه الطريقة: لا يوجد شيء يثبت أن المخطط orders ملك لك، فأي تطبيق آخر يستطيع أن يعلن عن نفس المخطط، وعندها إما أن يسأل النظام المستخدم أي تطبيق يريد، أو يصل الرابط إلى التطبيق الآخر مباشرة. وتقول Apple ذلك صراحة في صفحة تعريف مخطط خاص للتطبيق: استخدام اسم بصيغة النطاق المعكوس Reverse DNS أفضل ممارسة، ولكنه لا يمنع التطبيقات الأخرى من تسجيل نفس المخطط واستقبال روابطه، وتنصح باستخدام Universal Links بدلاً منه، وتطلب منك أن تتحقق من كل معاملات الرابط وترفض الروابط المشوهة.
وفي Android تذكر صفحة مخاطر الروابط العميقة غير الآمنة نفس الشيء: النظام يسمح لعدة تطبيقات بتسجيل نفس الرابط، والحماية من خطف الرابط Deep Link Hijacking تكون بروابط تم التحقق منها عبر الخاصية android:autoVerify. والمشكلة الثانية في المخطط الخاص أنه لا يعمل خارج التطبيق، فإذا أرسلت orders://order/42 لشخص ليس عنده التطبيق فلن يفتح شيئاً، ولا توجد صفحة ويب يرجع إليها، لذلك لا يصلح المخطط الخاص لروابط تشاركها مع الناس.
هل يعني هذا أن المخطط الخاص لا فائدة منه؟ لا، وله استخدام صحيح: العودة إلى التطبيق من صفحة تسجيل الدخول OAuth أو من بوابة دفع داخل المتصفح، حيث يعرف تطبيقك الرابط مسبقاً ولا يشاركه أحد. وحتى هنا يوصي معيار OAuth للتطبيقات الأصلية RFC 8252 باستخدام آلية PKCE، وذلك لأن تطبيقاً آخر قد يستلم الرابط ومعه رمز التفويض، فلا تضع في المخطط الخاص أي شيء لا تريد أن يراه تطبيق آخر، وهذه القاعدة سوف نثبتها في التجربة.
روابط Android App Links: كيف يثبت النطاق ملكيته للتطبيق؟
الحل الذي يغلق باب الخطف هو أن يشهد النطاق نفسه بأن التطبيق تطبيقه، وهذه فكرة روابط Android App Links: يضيف التطبيق فلتر نوايا Intent Filter لروابط https الخاصة بنطاقك مع الخاصية android:autoVerify="true"، ويضع النطاق ملفاً اسمه assetlinks.json يذكر فيه اسم حزمة التطبيق Package Name وبصمة شهادة التوقيع. وعندما يجد النظام أن الطرفين متفقان، يفتح الرابط في التطبيق مباشرة دون أن يسأل المستخدم، ولا يستطيع تطبيق آخر أن يأخذه، لأن التطبيق الآخر لا يملك مفتاح التوقيع الذي في الملف.
والمثال التالي هو فلتر الروابط في تطبيق التجربة، حيث نعلن مخططاً خاصاً في فلتر، وروابط https لنطاقين في فلتر آخر:
<activity android:name=".OrderActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- custom scheme: any app can declare the same one -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="orders" android:host="order" />
</intent-filter>
<!-- App Links: https + autoVerify, the domain must confirm with assetlinks.json -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="shop.example.com" />
<data android:host="www.airbnb.com" />
<data android:pathPrefix="/orders/" />
<data android:pathPrefix="/rooms/" />
</intent-filter>
</activity>في الإعداد أعلاه لاحظ التالي:
- الفئة
BROWSABLEهي التي تسمح للمتصفح وتطبيقات المحادثة بفتح الرابط، وبدونها لا يصل الرابط من خارج التطبيق. - عناصر
dataداخل نفس الفلتر تندمج مع بعضها، فالنطاقان والمساران يصبحان كل التركيبات الممكنة، وهذا سلوك يفاجئ كثيرين عندما يضيفون نطاقاً ثانياً لفلتر موجود. - أضفنا النطاق
www.airbnb.comعمداً، وهو نطاق حقيقي فيه ملفassetlinks.jsonحقيقي، ولكن الملف يذكر تطبيقات Airbnb وليس تطبيقنا، لنرى ماذا يفعل النظام عندما يجد الملف ولا يجد اسمنا فيه.
ملف assetlinks.json وبصمة شهادة التوقيع SHA-256
الملف الذي يضعه النطاق صغير، وهذا هو الملف الذي كنا سوف ننشره لتطبيق التجربة، مع البصمة التي ظهرت في مخرج النظام:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.example.links",
"sha256_cert_fingerprints": ["88:A4:26:AD:43:A4:0A:27:B6:D5:2F:DF:04:02:41:11:1C:33:44:41:37:CA:FB:2D:83:8E:67:C5:1C:1F:56:D0"]
}
}]والعلاقة delegate_permission/common.handle_all_urls معناها أن النطاق يفوض التطبيق في فتح روابطه، والبصمة هي ناتج دالة الهاش SHA-256 على شهادة التوقيع التي وقعت التطبيق، وليست على ملف التطبيق نفسه، وبالتالي تبقى نفس البصمة مع كل إصدار جديد طالما المفتاح نفسه. وتضع صفحة إعداد ملف Digital Asset Links شروطاً واضحة لاستضافة الملف: يجب أن يكون على المسار /.well-known/assetlinks.json، وأن يصل عبر HTTPS حتى لو كان الفلتر يعلن HTTP، وأن يرد بنوع المحتوى application/json، وأن يصل دون أي تحويل Redirect من نوع 301 أو 302، وأن ينشر على كل نطاق في الفلتر وحده.
حالات التحقق Verification States في Android 12 وما بعده
بعد التثبيت يقوم وكيل التحقق في النظام بالعمل في الخلفية: يجمع كل النطاقات من الفلاتر التي فيها autoVerify، ثم يطلب ملف assetlinks.json من كل نطاق، ثم يطابق اسم الحزمة والبصمة، ثم يسجل حالة لكل نطاق على حدة. وتطلب صفحة التحقق من App Links أن تنتظر 20 ثانية على الأقل بعد التثبيت قبل أن تحكم على النتيجة، وتذكر أن Android 11 وما قبله كان يشترط نجاح كل النطاقات في الفلتر معاً، أما من Android 12 فكل نطاق يحكم عليه وحده. والصورة التالية تبين هذه الخطوات:

والحالات التي يمكن أن تراها في مخرج الأمر pm get-app-links بحسب نفس الصفحة هي:
| الحالة | معناها |
|---|---|
verified | نجح التحقق، والرابط يفتح التطبيق مباشرة |
none | لم يسجل شيء بعد، انتظر ثم أعد التحقق |
approved / denied | موافقة أو رفض بالقوة، غالباً بأمر من سطر الأوامر |
legacy_failure | رفضه وكيل تحقق قديم، والسبب الدقيق غير معروف |
restored / migrated | نتيجة سابقة احتفظ بها النظام بعد استعادة بيانات أو ترقية |
system_configured | وافق عليه إعداد الجهاز نفسه |
| رقم 1024 أو أكبر | رمز خطأ خاص بوكيل التحقق في هذا الجهاز |
ولاحظ الحالة legacy_failure لأنها التي ظهرت لنا، فالتوثيق نفسه يقول إن سببها غير معروف، وهذا يجعل تشخيصها صعباً إذا لم تفحص الملف بنفسك من الخارج، وهذا ما سوف نفعله بأداة صغيرة بعد قليل.
روابط Apple Universal Links وملف apple-app-site-association
في iOS تسمى نفس الفكرة Universal Links، وتحتاج طرفين أيضاً: في التطبيق تضيف قدرة النطاقات المرتبطة Associated Domains وتكتب فيها applinks:shop.example.com، وعلى النطاق تضع ملفاً اسمه apple-app-site-association دون أي امتداد، على المسار /.well-known/. وتشترط صفحة دعم النطاقات المرتبطة أن يستضاف الملف عبر https:// بشهادة صالحة ودون أي تحويل. والملف يربط النطاق بمعرف التطبيق، وهو بادئة الفريق Team ID ثم معرف الحزمة Bundle ID، ويحدد أي المسارات تفتح التطبيق وأيها تبقى في المتصفح، مثل هذا الملف لمتجرنا:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.shop"],
"components": [
{ "/": "/orders/*", "comment": "order pages open the app" },
{ "/": "/checkout/*", "exclude": true, "comment": "payment stays in the browser" },
{ "/": "/products/*" }
]
}
]
}
}والمكون components هو الصيغة الحديثة لتحديد المسارات، ويقبل أيضاً شروطاً على معاملات الرابط Query وعلى الجزء بعد العلامة #، وما زالت ملفات كثيرة تستخدم الصيغة القديمة paths كما سوف نرى في ملف YouTube. ولاحظ الاستثناء exclude: ليس كل رابط في موقعك يجب أن يفتح التطبيق، فصفحة الدفع مثلاً قد تكون أفضل في المتصفح إذا كانت بوابة الدفع لا تعمل داخل التطبيق. وإذا كنت تستعد لمقابلة عمل، فالفرق بين هذه الأنواع من الأسئلة المتكررة، وقد جمعنا إجابته المختصرة في أسئلة مقابلة مطور الجوال.
لماذا لا يسأل iPhone سيرفرك؟ شبكة Apple CDN
هنا يختلف iOS عن Android في نقطة مهمة جداً عند التشخيص: منذ iOS 14 وmacOS 11 لا يطلب الجهاز الملف من سيرفرك مباشرة، وإنما من شبكة توزيع محتوى CDN تديرها Apple خاصة بالنطاقات المرتبطة. وبحسب نفس الصفحة فإن الشبكة تطلب الملف من نطاقك خلال 24 ساعة، والأجهزة تتحقق من التحديثات تقريباً مرة في الأسبوع بعد تثبيت التطبيق. وهذا يعني أن تعديل الملف على سيرفرك لا يصل للمستخدمين فوراً، وأن الملف الذي يقرؤه الجهاز هو نسخة Apple المحفوظة، وليس ما يرد به سيرفرك الآن.

وتستطيع أن ترى ما في الشبكة بنفسك على العنوان https://app-site-association.cdn-apple.com/a/v1/ متبوعاً باسم النطاق، وهذا ما تفعله أداتنا في التجربة. وأثناء التطوير، إذا كان السيرفر داخلياً لا يصل إليه الإنترنت، تسمح Apple بما تسميه الوضع البديل Alternate Mode: تضيف ?mode=developer إلى النطاق في ال Entitlement، فيتصل الجهاز بسيرفرك مباشرة متجاوزاً الشبكة، بشرط أن يكون الجهاز في وضع المطور Developer Mode وأن يكون التطبيق موقعاً بملف تطوير. وتذكر صفحة ال Entitlement الخاص بالنطاقات المرتبطة أن هذا الوضع يقبل أي شهادة SSL حتى لو لم يثق بها النظام، لذلك لا تتركه في نسخة الإنتاج.
تجربة عملية Lab: تطبيقان ومحاكي Android وأداة تحقق
الآن سوف نرى كل ما سبق يعمل ويفشل أمامنا. والتجربة من ثلاثة أجزاء: تطبيق المتجر io.example.links الذي رأينا ملف Manifest الخاص به، وتطبيق ثان صغير io.example.other يعلن نفس المخطط orders://order ليمثل تطبيقاً آخر على هاتف العميل، وأداة بلغة Node.js تفحص ملفات الربط على أي نطاق كما يفحصها النظام. وشغلنا التطبيقين على محاكي Android 15 (API 35) بصورة النظام التي فيها Google Play، وكل الملفات والمخرجات موجودة في مجلد lab مع المقال.
والكود الذي يستقبل الرابط داخل التطبيق قصير، وأهم ما فيه أنه لا يثق بالرابط:
private fun handle(intent: Intent): String {
if (intent.getStringExtra("notify") != null) return notifyOrder(intent.getStringExtra("notify")!!)
val uri: Uri = intent.data ?: return "no link"
// Never trust the link: it is input from anyone who can send a URL to the user.
val id = uri.lastPathSegment?.toLongOrNull()
val msg = if (id == null || id <= 0) "rejected link $uri" else "open order $id from ${uri.scheme}://${uri.host}"
Log.i("LINKS", "$msg referrer=${referrer}")
return msg
}لاحظ أن الكود يأخذ آخر جزء من المسار ويحاول تحويله إلى رقم، وإذا فشل التحويل أو كان الرقم صفراً أو سالباً يرفض الرابط كله، أي أن الرابط لا يستطيع أن يطلب من التطبيق إلا شيئاً واحداً: افتح الطلب رقم كذا، ثم يتحقق التطبيق من السيرفر أن هذا الطلب يخص المستخدم الحالي قبل أن يعرض أي شيء. وطبعنا معه referrer لنرى من أرسل الرابط.
التحقق من النطاق Domain Verification: لماذا ظهرت legacy_failure؟
نثبت التطبيقين، وننتظر 20 ثانية كما يطلب التوثيق، ثم نسأل النظام عن حالة النطاقات:
adb install -r app-debug.apk
adb install -r other-debug.apk
adb shell pm get-app-links io.example.linksوالمخرج سوف يكون كما يلي:
io.example.links:
ID: 379d8e9b-a91b-4b3d-871b-3d424e783bd3
Signatures: [88:A4:26:AD:43:A4:0A:27:B6:D5:2F:DF:04:02:41:11:1C:33:44:41:37:CA:FB:2D:83:8E:67:C5:1C:1F:56:D0]
Domain verification state:
www.airbnb.com: legacy_failure
shop.example.com: legacy_failureفي المخرج أعلاه لاحظ التالي:
- السطر
Signaturesهو بصمة SHA-256 لشهادة التوقيع التي وقعت هذا التطبيق، وهي هنا شهادة التصحيح Debug الموجودة في~/.android/debug.keystore، وهذا هو الرقم الذي يجب أن يظهر في ملفassetlinks.jsonعلى النطاق حرفاً بحرف. وهو نفس الرقم الذي أعطانا إياه الأمرkeytool -list -vعلى ملف keystore، فلا داعي لتخمينه. - النطاق
shop.example.comفشل لأنه نطاق للأمثلة ولا يوجد عليه أي ملف، وسوف تؤكد الأداة بعد قليل أن طلب الملف من نطاق الأمثلة يرد بالرمز 404. - النطاق
www.airbnb.comفشل أيضاً، مع أن الملف موجود وسليم كما سوف نرى، والسبب أن الملف يذكر تطبيقات Airbnb ببصماتها ولا يذكرio.example.links، وهذا بالضبط ما يحمي النطاق من أي تطبيق يدعي روابطه. - الحالتان ظهرتا بنفس الاسم
legacy_failure، أي أن النظام لا يفرق لك بين «الملف غير موجود» و«الملف موجود ولا يذكرك»، وهذا سبب كاف لتفحص الملف بنفسك من الخارج بدلاً من الاعتماد على هذه الحالة.
ثم نطلب من النظام أن يعيد التحقق، وهي ميزة متاحة من Android 12، وننتظر 30 ثانية:
adb shell pm verify-app-links --re-verify io.example.links
adb shell pm get-app-links io.example.linksوكانت النتيجة نفس الحالتين legacy_failure، وهذا متوقع لأن شيئاً لم يتغير على النطاقين، ولكن الأمر مفيد جداً عندما تصلح الملف على سيرفرك وتريد أن تتأكد دون أن تحذف التطبيق وتثبته من جديد.
ماذا يرى المستخدم؟ نافذة الاختيار Chooser ثم الموافقة اليدوية set-app-links
نسأل النظام الآن: من يفتح رابط الطلب https؟
adb shell cmd package resolve-activity --brief -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE -d https://shop.example.com/orders/42priority=0 preferredOrder=0 match=0x0 specificIndex=-1 isDefault=false
android/com.android.internal.app.ResolverActivityوالنشاط ResolverActivity هو نافذة الاختيار في النظام، أي أن النظام لا يعرف من يفتح الرابط. ولكن عندما أعدنا حالة التطبيق إلى البداية ثم فتحنا الرابط فعلاً، لم تظهر نافذة اختيار، وإنما فتح المتصفح مباشرة:
adb shell pm set-app-links --package io.example.links 0 all
adb shell am start -W -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE -d https://shop.example.com/orders/42 Domain verification state:
www.airbnb.com: none
shop.example.com: none
Status: ok
Activity: com.android.chrome/org.chromium.chrome.browser.firstrun.FirstRunActivityوالقيمة 0 تعيد كل النطاقات إلى الحالة none، ثم فتح الرابط Chrome، وظهرت شاشة الإعداد الأولى لأن المتصفح لم يفتح قبل ذلك على هذا المحاكي. وهذا هو ما حدث لصديق العميل في قصتنا: الرابط صحيح، والتطبيق مثبت، ولكن النطاق لم يثبت شيئاً، فذهب الرابط إلى المتصفح.
وللاختبار فقط يسمح لك النظام بأن توافق على النطاق بالقوة، فتعرف هل الجزء الخاص بالتطبيق يعمل قبل أن يصبح ملف النطاق جاهزاً:
adb shell pm set-app-links --package io.example.links 2 shop.example.com
adb shell am start -W -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE -d https://shop.example.com/orders/42 Domain verification state:
www.airbnb.com: legacy_failure
shop.example.com: approved
Status: ok
Activity: io.example.links/.OrderActivityوالقيمة 2 تعني الحالة approved، فأصبح الرابط يفتح OrderActivity مباشرة، وفي سجل التطبيق ظهر السطر التالي:
I LINKS : open order 42 from https://shop.example.com referrer=android-app://com.android.shellوالقيمة referrer هنا هي com.android.shell لأننا أرسلنا الرابط من سطر الأوامر، ولو جاء من تطبيق محادثة لظهر اسم حزمته إذا مرره النظام. ولاحظ شيئاً غريباً واجهناه: الأمر resolve-activity بعد الموافقة بقي يطبع ResolverActivity، بينما الأمر am start فتح تطبيقنا فعلاً، وقبل الموافقة طبع نفس الشيء بينما فتح الرابط Chrome. لذلك اعتمد في اختباراتك على am start لأنه يفعل ما يحدث عندما يضغط المستخدم، ولا تعتمد على أمر الاستعلام وحده. وتذكر أن الموافقة اليدوية لا تصل لأي جهاز غير جهازك، فهي أداة تشخيص وليست حلاً.
تطبيقان على مخطط واحد Scheme Collision
الآن نسأل عن المخطط الخاص، وعندنا تطبيقان يعلنانه:
adb shell cmd package query-activities --brief -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE -d orders://order/422 activities found:
Activity #0:
priority=0 preferredOrder=0 match=0x308000 specificIndex=-1 isDefault=true
io.example.links/.OrderActivity
Activity #1:
priority=0 preferredOrder=0 match=0x308000 specificIndex=-1 isDefault=true
io.example.other/.Grabالتطبيقان متساويان تماماً في نظر النظام: نفس الأولوية priority=0، ونفس درجة المطابقة match، وكلاهما isDefault=true، ولا يوجد شيء يقول إن الأول هو صاحب المخطط. وعندما وجهنا الرابط إلى التطبيق الثاني، وهذا ما يحدث إذا اختاره المستخدم مرة من نافذة الاختيار أو أرسله تطبيق ثالث إليه مباشرة، استلمه كاملاً:
I LINKS : OTHER app received orders://order/42
تخيل الآن أن هذا الرابط كان رابط العودة من بوابة الدفع وفيه رقم العملية، أو رابط تسجيل الدخول وفيه رمز مؤقت، فالتطبيق الآخر استلم كل ذلك. وهذا هو سبب القاعدة: لا تقم بوضع أي سر في رابط بمخطط خاص، والسبب أنك لا تتحكم في من يستلمه.
الرابط الخبيث Malicious Link والتحقق من المدخلات Input Validation
حتى رابط https الموثق يأتي من أي شخص يستطيع أن يرسل رسالة للمستخدم، فالتحقق من النطاق يضمن أن الرابط وصل لتطبيقك، ولا يضمن أن محتواه سليم. لذلك أرسلنا رابطاً فيه محاولة للخروج من المسار، حيث الرمز %2F هو الشرطة المائلة بعد ترميزها:
adb shell am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE \
-d "https://shop.example.com/orders/..%2Fadmin"I LINKS : rejected link https://shop.example.com/orders/..%2Fadmin referrer=android-app://com.android.shellرفض التطبيق الرابط لأن آخر جزء من المسار بعد فك الترميز هو ../admin وليس رقماً. ولو كان الكود يبني من الرابط مساراً لملف، أو رابطاً يطلبه من السيرفر، أو اسم شاشة يفتحها، لكان هذا الرابط وسيلة لفتح شيء لم تقصده. ومن هنا جاء السؤال الذي ذكرناه في أسئلة مقابلة مطور الجوال: الرابط الذي يفتح شاشة الدفع بمبلغ مكتوب فيه خطر، لأن المبلغ يجب أن يأتي من السيرفر وليس من الرابط. والقاعدة العامة هي أن الرابط يحمل معرفاً فقط، والتطبيق يجلب كل شيء آخر من السيرفر بصلاحيات المستخدم الحالي، ولا ينفذ أي عملية تغير البيانات دون تأكيد من المستخدم داخل التطبيق.
فحص ملفات الربط Well-known Files على نطاقات حقيقية
لأن الحالة legacy_failure لا تقول لك السبب، كتبنا أداة صغيرة بلغة Node.js تطلب الملفين كما يطلبهما النظام، وتطبق عليهما شروط التوثيق: HTTPS، والرمز 200 دون تحويل، ونوع المحتوى، وصيغة JSON، وشكل البصمات، ثم تطلب نسخة Apple من الشبكة. والأداة لا تحتاج أي حزمة خارجية، وجزؤها الأهم أنها لا تتبع التحويلات، لأن النظامين لا يتبعانها:
async function get(url) {
const res = await fetch(url, { redirect: 'manual', headers: { 'user-agent': 'wellknown-check/1.0' } });
const body = Buffer.from(await res.arrayBuffer());
return { status: res.status, type: res.headers.get('content-type') || '', location: res.headers.get('location'), body };
}node check-wellknown.mjs www.airbnb.com youtube.com www.wikipedia.org example.comوالمخرج للنطاق الأول سوف يكون كما يلي:
[android] https://www.airbnb.com/.well-known/assetlinks.json
ok HTTP 200, 11285 bytes
ok content-type: application/json
info 61 statements, 3 for Android apps, 3 with handle_all_urls
ok com.airbnb.android: 1 fingerprint(s), 1 well-formed
ok com.airbnb.android.development: 2 fingerprint(s), 2 well-formed
ok com.airbnb.android.myflavor.development: 1 fingerprint(s), 1 well-formed
[apple] https://www.airbnb.com/.well-known/apple-app-site-association
ok HTTP 200, 23773 bytes
info size 23.2 KB
info content-type: application/json
ok applinks: 1 entries, 7 app IDs, 1 with components, 0 with legacy paths
info first app ID 5LL7P8E8RA.com.airbnb.app (TeamID.BundleID)
info first components [{"#":"stay_in_browser","exclude":true,"comment":"Callers append this to keep a URL in the browser instead of opening the app."},{"/":"/book/confirmation/stays/*","exclude":true},{"/":"/c/r/*","exclude":true}]
[apple] https://app-site-association.cdn-apple.com/a/v1/www.airbnb.com
ok HTTP 200, 23773 bytesفي المخرج أعلاه لاحظ التالي:
- ملف Android فيه 61 عبارة Statement، ثلاث منها فقط لتطبيقات Android تفوضها بفتح الروابط، والباقي علاقات أخرى. والتطبيقات الثلاثة هي التطبيق الرسمي ونسختان للتطوير، أي أن Airbnb تربط نسخ التطوير بنفس النطاق ببصماتها الخاصة، وهذا يفسر لماذا فشل تطبيقنا: اسمه غير موجود في القائمة.
- ملف Apple يستخدم الصيغة الحديثة
componentsفقط، وأول مكون فيه فكرة جميلة: إذا أضاف أي رابط العلامة#stay_in_browserيبقى في المتصفح، فيستطيع الموقع أن يقرر لكل رابط هل يفتح التطبيق أم لا. - نسخة Apple في الشبكة مطابقة لما يرد به السيرفر، بنفس الحجم 23773 بايت، وهذه هي الحالة الصحيحة.
أما النطاقان التاليان فهما المفاجأة الحقيقية في هذه التجربة:
[android] https://youtube.com/.well-known/assetlinks.json
ok HTTP 200, 2088 bytes
ok content-type: application/json
info 6 statements, 6 for Android apps, 6 with handle_all_urls
[apple] https://youtube.com/.well-known/apple-app-site-association
FAIL HTTP 301 redirect to https://www.youtube.com/.well-known/apple-app-site-association (Apple does not follow redirects)
[apple] https://app-site-association.cdn-apple.com/a/v1/youtube.com
ok HTTP 200, 48011 bytes
ok applinks: 7 entries, 17 app IDs, 3 with components, 4 with legacy paths
[apple] https://www.wikipedia.org/.well-known/apple-app-site-association
FAIL HTTP 404
[apple] https://app-site-association.cdn-apple.com/a/v1/www.wikipedia.org
ok HTTP 200, 79224 bytes
ok applinks: 1 entries, 5 app IDs, 1 with components, 0 with legacy pathsالنطاق youtube.com دون www يرد على مسار ملف Apple بتحويل 301 إلى www.youtube.com، والتوثيق يشترط عدم وجود تحويل، ومع ذلك تعطي شبكة Apple لنفس النطاق نسخة من 48011 بايت، فيها 17 معرف تطبيق، وفيها الصيغتان معاً: components الحديثة وpaths القديمة. والنطاق www.wikipedia.org يرد بالرمز 404، أي أن الملف غير موجود عليه الآن، ومع ذلك تعطي الشبكة نسخة من 79224 بايت. ولكي نفهم من أين جاءت هذه النسخة قارناها بملف النطاق en.wikipedia.org:
404 1958 https://www.wikipedia.org/.well-known/apple-app-site-association
200 79224 https://en.wikipedia.org/.well-known/apple-app-site-association
200 79224 https://app-site-association.cdn-apple.com/a/v1/www.wikipedia.org
identical: en.wikipedia.org file == Apple CDN copy for www.wikipedia.orgالنسخة التي تعطيها Apple للنطاق www.wikipedia.org مطابقة تماماً لملف en.wikipedia.org، بينما النطاق نفسه يرد الآن بالرمز 404. ولا نستطيع أن نعرف من الخارج كيف وصلت هذه النسخة إلى الشبكة، فقد تكون محفوظة من وقت كان النطاق يقدم الملف، ولكن النتيجة العملية واحدة: ما يقرؤه iPhone هو نسخة Apple، وما يرد به سيرفرك الآن قد يكون شيئاً آخر. لذلك عندما يقول لك أحد «الروابط لا تعمل على iPhone مع أن ملفنا سليم»، افحص الشبكة أولاً بنفس الأمر، وإذا عدلت الملف فلا تتوقع أن ترى الأثر قبل أن تجلبه الشبكة من جديد، والتوثيق يقول خلال 24 ساعة.
وفي النهاية أكدت الأداة أن example.com يرد بالرمز 404 للملفين وفي الشبكة أيضاً، وهذا هو نوع الفشل الذي واجهه shop.example.com في تجربتنا، فنطاقات الأمثلة لا تقدم هذه الملفات.
واجهة Digital Asset Links API: هل يربط النطاق تطبيقك؟
توفر Google واجهة عامة تقرأ ملف assetlinks.json وتجيب عن سؤال واحد: هل يفوض هذا النطاق هذا التطبيق بهذه البصمة؟ وهي موثقة في صفحة البدء مع Digital Asset Links، ومفيدة جداً في خط النشر Pipeline قبل إطلاق أي إصدار. سألناها مرتين، مرة عن تطبيقنا ببصمته، ومرة عن تطبيق Airbnb ببصمته المأخوذة من ملفهم:
curl -s "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?source.web.site=https://www.airbnb.com&relation=delegate_permission/common.handle_all_urls&target.android_app.package_name=io.example.links&target.android_app.certificate.sha256_fingerprint=88:A4:...:56:D0"{
"maxAge": "3600s"
}والسؤال الثاني بنفس الشكل مع com.airbnb.android وبصمته:
{
"linked": true,
"maxAge": "1680.762675282s"
}الإجابة الأولى لا تحتوي الحقل linked أصلاً، وهذا معناه «لا»، فالواجهة لا تكتب false وإنما تحذف الحقل، وانتبه لذلك إذا كتبت سكربتاً يبحث عن linked == false لأنه لن يجده أبداً، والصحيح أن تشترط وجود true. والإجابة الثانية تقول إن النطاق يربط com.airbnb.android فعلاً. والحقل maxAge يقول كم ثانية تبقى الإجابة صالحة في ذاكرة الواجهة المؤقتة، أي أن الواجهة هي الأخرى لا تقرأ ملفك مع كل طلب.
ماذا يحدث إذا تغير مفتاح التوقيع Signing Key؟
البصمة في الملف مرتبطة بشهادة التوقيع وليست باسم التطبيق، وقد رأينا ذلك مرتين في التجربة: تطبيقنا يعلن نطاق Airbnb في الفلتر ولكن بصمته ليست في ملفهم فرفضه النظام، والواجهة رفضت نفس الاسم مع بصمتنا. فماذا يحدث في متجرنا إذا نشرنا الإصدار التالي بمفتاح مختلف، أو فعلنا Play App Signing دون أن نحدث الملف؟ يحدث بالضبط ما رأيناه في الحالة الأولى: النظام يحسب البصمة من التطبيق المثبت، ويقارنها بالملف فلا يجدها، فيرجع كل رابط إلى المتصفح، دون أي خطأ يظهر لك في التطبيق ولا في المتجر، وتعرف بالمشكلة من شكاوى المستخدمين. والحل أن تضع في الملف كل البصمات التي توقع بها نسخاً تصل لأجهزة حقيقية، وأن يكون فحص الواجهة جزءاً من خط النشر، فيفشل البناء إذا لم يجد "linked": true لكل نطاق في الفلتر.
الروابط المؤجلة Deferred Deep Links وإيقاف Firebase Dynamic Links
بقيت حالة لم يحلها أي من الأنواع الثلاثة: صديق العميل ليس عنده التطبيق أصلاً، فيضغط الرابط، فيفتح صفحة المنتج في المتصفح، ثم يقرر أن يثبت التطبيق من المتجر، فيفتح التطبيق لأول مرة على الشاشة الرئيسية وقد نسي المنتج. والرابط المؤجل Deferred Deep Link هو أن يتذكر التطبيق بعد التثبيت الرابط الذي جاء منه المستخدم، فيفتح صفحة المنتج مباشرة في أول تشغيل. والمشكلة هنا أن النظام لا ينقل الرابط عبر عملية التثبيت، فالمتجر بين الرابط والتطبيق، وبالتالي تحتاج طريقة أخرى لتربط بين من ضغط الرابط ومن فتح التطبيق.
ولسنوات كانت خدمة Firebase Dynamic Links هي الحل الذي تستخدمه أغلب الفرق لهذا الغرض، ثم أعلنت Google إيقافها، وتقول صفحة أسئلة إيقاف Dynamic Links إن الخدمة توقفت في 25 أغسطس 2025، وإن كل الروابط التي كانت تقدمها، سواءً على نطاقات خاصة أو على النطاقات الفرعية page.link، توقفت عن العمل، ولا يمكن إنشاء روابط جديدة، وتنصح الصفحة بالانتقال إلى App Links وUniversal Links. والدرس هنا أكبر من خدمة واحدة: الروابط التي تطبعها في الإعلانات وترسلها في الرسائل وتضعها على المنتجات تعيش سنوات، فإذا كانت على نطاق لا تملكه فأنت تراهن على بقاء خدمة غيرك. لذلك اجعل كل رابط عام على نطاقك أنت، حتى لو استخدمت خدمة خارجية لتتبع الحملات، فإذا توقفت الخدمة تغير ما خلف الرابط ولا تغير الرابط نفسه.
وفي Android يستطيع التطبيق أن يعرف مصدر التثبيت عبر Play Install Referrer، حيث يحمل رابط المتجر معاملاً يقرؤه التطبيق بعد التثبيت. والحل الأبسط في المنصتين، وهو لا يحتاج أي تتبع، هو أن تبقى صفحة الويب مفيدة بنفسها، وأن يحمل زر «افتح في التطبيق» نفس الرابط، فبعد التثبيت يضغطه المستخدم مرة ثانية فيفتح التطبيق على المنتج. وهذا الحل أقل سحراً من الربط التلقائي، ولكنه لا يحتاج أن تجمع بصمة جهاز المستخدم، ولا يعتمد على خدمة قد تتوقف.
الإشعار الذي يفتح الطلب: إذن الإشعارات Notification Permission والرابط داخل الإشعار
نعود إلى الشكوى الثانية في قصتنا: الضغط على إشعار «طلبك خرج للتوصيل» يفتح الشاشة الرئيسية. والسبب الشائع أن الإشعار يحمل اسم شاشة أو رقماً يفهمه كود خاص في التطبيق، ثم يتغير هذا الكود أو يفتح التطبيق من حالة لم يتوقعها المطور فيضيع الطلب. والحل الذي نستخدمه هو أن يحمل الإشعار نفس رابط https الذي يحمله رابط المشاركة، فيمر بنفس الدالة ونفس التحقق، ويعمل حتى لو فتح المستخدم الرابط في مكان آخر. والصورة التالية تبين الطريق كاملاً:

إذن الإشعارات POST_NOTIFICATIONS في Android 13 وما بعده
منذ Android 13 (API 33) أصبح إرسال الإشعارات إذناً يطلبه التطبيق من المستخدم وقت التشغيل، كما تشرح صفحة إذن الإشعارات وقت التشغيل، وتطبيقنا يستهدف API 36 ويعلن الإذن في ملف Manifest، ولكنه لم يطلبه من المستخدم بعد. فماذا يحدث إذا أرسل إشعاراً؟ هذا ما جربناه:
adb shell dumpsys package io.example.links | grep POST_NOTIFICATIONS
adb shell am start -W -n io.example.links/.OrderActivity --es notify 42
adb shell dumpsys notification --noredact | grep -c "pkg=io.example.links" android.permission.POST_NOTIFICATIONS
android.permission.POST_NOTIFICATIONS: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
0
I LINKS : notifications enabled=falseالإذن معلن ولكنه granted=false، والتطبيق استدعى notify دون أي خطأ، ومع ذلك عدد إشعارات التطبيق في النظام صفر، والدالة areNotificationsEnabled أعادت false. أي أن الإشعار ضاع بصمت، وهذا أخطر ما في الأمر: لا يوجد استثناء ولا رسالة في السجل تقول إن الإشعار لم يظهر، فإذا كان فريقك يعتمد على الإشعار في إبلاغ العميل بخروج الطلب، فلن يعرف أن ثلث المستخدمين مثلاً لا يرونه إلا إذا قاس ذلك بنفسه. ثم منحنا الإذن من سطر الأوامر، وهو ما يحدث عندما يضغط المستخدم «سماح»:
adb shell pm grant io.example.links android.permission.POST_NOTIFICATIONS
adb shell am start -W -n io.example.links/.OrderActivity --es notify 43I LINKS : notifications enabled=true
NotificationRecord(0x05f3758f: pkg=io.example.links user=UserHandle{0} id=43 tag=null importance=3 key=0|io.example.links|43|null|10213: Notification(channel=orders shortcut=null contentView=null vibrate=null sound=null defaults=0 flags=AUTO_CANCEL color=0x00000000 vis=PRIVATE))الآن ظهر سجل الإشعار برقم 43 في القناة orders بأهمية importance=3 وهي الأهمية الافتراضية، ومعه AUTO_CANCEL أي أنه يختفي عند الضغط عليه. والقناة Notification Channel هي ما يراه المستخدم في الإعدادات ويستطيع أن يوقفه وحده، لذلك افصل إشعارات الطلبات عن إشعارات العروض في قناتين، فالمستخدم الذي أزعجته العروض يوقف قناتها ولا يوقف كل إشعارات التطبيق.
والقرار الأهم هنا هو متى تطلب الإذن. فإذا طلبته في أول شاشة قبل أن يعرف المستخدم ما الذي سوف يصله، فالأرجح أن يرفض، ولا تستطيع أن تسأله مرة أخرى بسهولة. والوقت المناسب لمتجرنا هو بعد أن يكمل العميل أول طلب، حيث يكون السؤال منطقياً: هل تريد أن نخبرك عندما يخرج طلبك للتوصيل؟ وفي iOS نفس الفكرة، حيث تشرح صفحة طلب إذن الإشعارات أن التطبيق يطلب التفويض بالدالة requestAuthorization، وأن النظام يعرض السؤال في أول طلب فقط ويسجل الإجابة، فالطلبات التالية لا تظهر للمستخدم شيئاً، وتذكر أيضاً التفويض المؤقت Provisional Authorization الذي يوصل الإشعارات بهدوء دون صوت أو شعار، حتى يجربها المستخدم ثم يقرر هل يسمح بها.
الرابط داخل الإشعار PendingIntent
عندما يضغط المستخدم على الإشعار لا يستدعي النظام كود تطبيقك مباشرة، وإنما ينفذ نية مؤجلة PendingIntent جهزها التطبيق عند إنشاء الإشعار، وهذا جزء الكود الذي أنشأها في تجربتنا:
val open = PendingIntent.getActivity(this, 0,
Intent(Intent.ACTION_VIEW, Uri.parse("https://shop.example.com/orders/$orderId"), this, OrderActivity::class.java),
PendingIntent.FLAG_IMMUTABLE)في الكود أعلاه لاحظ التالي:
- النية صريحة Explicit، أي أنها تحدد
OrderActivityبالاسم، فلا يمر الرابط بنافذة الاختيار ولا يصل لتطبيق آخر، حتى لو لم يتحقق النطاق، لأن الإشعار خرج من تطبيقنا ويعود إليه. - العلامة
FLAG_IMMUTABLEتمنع أي تطبيق آخر من تعديل محتوى النية قبل تنفيذها، وتطلب صفحة PendingIntent تحديد إحدى العلامتينFLAG_IMMUTABLEأوFLAG_MUTABLEللتطبيقات التي تستهدف Android 12 وما بعده، والقاعدة أن تختار غير القابلة للتعديل إلا إذا احتجت العكس فعلاً. - الرابط نفسه هو رابط المشاركة، فإذا وصل نفس الطلب في رسالة نصية أو بريد يفتح نفس الصفحة، وتصف صفحة فتح نشاط من إشعار كيف تبني مع ذلك مكدس الرجوع Back Stack، فيعود المستخدم من صفحة الطلب إلى قائمة الطلبات وليس إلى خارج التطبيق.
والسؤال الذي يأتي هنا: هل يحمل الإشعار نفسه حالة الطلب ليعرضها التطبيق؟ لا، والسبب أن الإشعار قد يصل متأخراً أو مكرراً أو بترتيب مختلف، فالإشعار يقول «هناك شيء جديد في الطلب 43»، والتطبيق يجلب الحالة الحالية من السيرفر عند الفتح، وبالتالي لا يعرض للعميل حالة قديمة إذا وصل إشعاران بترتيب معكوس.
الشراء داخل التطبيق In-App Purchase: متى يكون إلزامياً ومتى يكون ممنوعاً؟
نصل إلى سؤال فريق التسويق: الاشتراك الشهري. وقبل أي كود يجب أن تعرف أن المتجرين لا يتركان طريقة الدفع لك، وأن القاعدة تعتمد على ما يشتريه المستخدم، وليس على حجم شركتك أو رغبتك في تجنب العمولة. وقبل أن نكمل، لاحظ أن هذا الجزء لم نشغله في التجربة، لأن الشراء الحقيقي يحتاج حساباً في Play Console وApp Store Connect ومنتجات معرفة فيهما وحسابات اختبار، وكل ما يلي من التوثيق الرسمي للمتجرين.
ماذا تقول سياسات المتجرين Store Policies؟
في إرشادات مراجعة App Store، بتاريخ آخر تحديث 16 أكتوبر 2026، يقول البند 3.1.1:
«If you want to unlock features or functionality within your app, (by way of example: subscriptions, in-game currencies, game levels, access to premium content, or unlocking a full version), you must use in-app purchase.»
أي إذا أردت أن تفتح ميزات أو وظائف داخل التطبيق، مثل الاشتراكات والعملات داخل الألعاب والمحتوى المميز، فيجب أن تستخدم الشراء داخل التطبيق، ويضيف البند أن التطبيق لا يستطيع أن يستخدم وسائله الخاصة لفتح المحتوى، مثل مفاتيح الترخيص ورموز QR. ولكن في نفس الإرشادات يقول البند 3.1.3(e) العكس تماماً للسلع المادية:
«If your app enables people to purchase physical goods or services that will be consumed outside of the app, you must use purchase methods other than in-app purchase to collect those payments, such as Apple Pay or traditional credit card entry.»
أي أن السلع المادية والخدمات التي تستهلك خارج التطبيق يجب أن تدفع بطرق أخرى غير الشراء داخل التطبيق، مثل Apple Pay أو بطاقة الائتمان. وفي Google Play تقول سياسة المدفوعات إن التطبيقات التي تقبل الدفع مقابل ميزات أو محتوى رقمي يجب أن تستخدم نظام الفوترة في Google Play، إلا في حالات استثنائية تذكرها السياسة، ثم تقول إن نظام الفوترة يجب ألا يستخدم عندما يكون الدفع في الأساس لشراء سلع مادية مثل البقالة والملابس، أو لخدمات مادية، وتذكر منها توصيل الطعام بالاسم.
وبالتالي فطلبات متجرنا نفسها، أي المنتجات التي يوصلها المندوب، تدفع ببوابة الدفع العادية، ولا يجوز أن تمر عبر الشراء داخل التطبيق في المتجرين. أما الاشتراك الشهري فيعتمد على ما يعطيه: إذا كان يعطي توصيلاً مجانياً، فهو خدمة مادية تستهلك خارج التطبيق وتبقى خارج نظام المتجر، وإذا كان يعطي محتوى رقمياً مثل وصفات حصرية أو ميزات في التطبيق نفسه، فهو شراء داخل التطبيق إلزامي. وهذه الحدود تتغير مع الأحكام القضائية والقوانين في كل دولة، فالبند 3.1.3 نفسه يذكر استثناءً خاصاً بمتجر الولايات المتحدة، لذلك اقرأ النص الحالي للمتجر في البلد الذي تنشر فيه، ولا تعتمد على ما فعله تطبيق آخر قبل سنتين.
التحقق من الشراء على السيرفر Server-side Verification
إذا قررت أن الميزة رقمية وتحتاج الشراء داخل التطبيق، فهناك خطأ يتكرر كثيراً: يفتح التطبيق الميزة بمجرد أن تقول له مكتبة المتجر إن الشراء نجح. والمشكلة أن كل ما يحدث داخل التطبيق يمكن تعديله على جهاز مخترق، وأن السيرفر لا يعرف أن هذا المستخدم اشترى، فإذا فتح حسابه من جهاز آخر لا يجد اشتراكه. والطريقة الصحيحة تبينها الصورة التالية:

والخطوات بحسب توثيق دمج مكتبة Play Billing هي: يعرض التطبيق المنتج، ثم يكمل المستخدم الشراء في نافذة المتجر، ثم تتحقق من الشراء على سيرفرك، ثم تعطي المستخدم المحتوى، ثم تقر بالتسليم Acknowledge، وفي المنتجات التي تستهلك مثل الرصيد تستهلكها Consume ليستطيع المستخدم شراءها مرة أخرى. والإقرار ليس خطوة شكلية، فالتوثيق يقول إنه يجب أن يتم خلال ثلاثة أيام، وإلا يسترد المبلغ للمستخدم تلقائياً ويلغى استحقاقه. وفي Apple تعطي مكتبة StoreKit معاملة موقعة من App Store يمكن التحقق منها، ويستطيع سيرفرك أن يسأل App Store Server API عن حالة أي معاملة أو اشتراك مباشرة.
والكلمة المهمة هنا هي الاستحقاق Entitlement: جدول في قاعدة بياناتك يقول إن المستخدم رقم كذا يملك الميزة كذا حتى تاريخ كذا، ومصدره ما تحقق منه السيرفر مع المتجر. والتطبيق يسأل سيرفرك عن الاستحقاق، ولا يسأل مكتبة المتجر على الجهاز، وبهذا يعمل الاشتراك على كل أجهزة المستخدم، وعلى الويب أيضاً إذا أردت.
التجديد والاسترداد Refunds وإشعارات السيرفر Server Notifications
الاشتراك لا ينتهي عند أول دفعة: يتجدد كل شهر، وقد تفشل بطاقة المستخدم فيدخل فترة سماح Grace Period، وقد يلغي، وقد يطلب استرداد المبلغ من المتجر فيعطيه المتجر إياه دون أن يمر بتطبيقك. وكل هذه الأحداث تحدث والتطبيق مغلق، لذلك يرسلها المتجر إلى سيرفرك مباشرة: في Google Play عبر الإشعارات الفورية للمطورين RTDN التي تنشر في موضوع Topic على Cloud Pub/Sub، ويقول التوثيق إن الإشعار يخبرك فقط أن حالة الشراء تغيرت، ولا يعطيك المعلومات الكاملة، فتسأل الواجهة بالرمز purchaseToken الذي فيه. وفيه أنواع مثل SUBSCRIPTION_RENEWED وSUBSCRIPTION_CANCELED وONE_TIME_PRODUCT_PURCHASED. وفي Apple عبر App Store Server Notifications، وهي خدمة من سيرفر إلى سيرفر تقول Apple إنها لتحديث قاعدة بيانات حسابات المستخدمين ومتابعة الاسترداد، والإصدار الأول منها متوقف وعليك استخدام الإصدار الثاني.
وهنا يرجع درس مقال العمل دون اتصال: الإشعار قد يصل مرتين، أو يصل التجديد قبل الشراء الأول بسبب ترتيب الشبكة، فاجعل معالجة الإشعار بحسب الحالة وليس بحسب الحدث، أي أن السيرفر عند كل إشعار يسأل المتجر عن الحالة الحالية للاشتراك ويكتبها كما هي، وبالتالي تكرار الإشعار لا يغير شيئاً.
أخطاء شائعة Common Mistakes
- الاعتماد على المخطط الخاص لروابط تشاركها مع الناس: رأينا تطبيقين يستلمان نفس الرابط، ولا يعمل الرابط عند من ليس عنده التطبيق.
- وضع رمز دخول أو رقم عملية دفع في رابط بمخطط خاص: أي تطبيق يعلن نفس المخطط يستطيع أن يستلمه.
- نشر ملف
assetlinks.jsonببصمة مفتاح الرفع بدلاً من بصمة مفتاح التوقيع في Play App Signing، أو نسيان نسخ التطوير: الروابط ترجع للمتصفح بصمت. - وضع الملف خلف تحويل من النطاق دون
wwwإلى النطاق معwww، أو العكس: النظامان لا يتبعان التحويل، وقد رأينا ذلك فيyoutube.com. - إضافة نطاق جديد إلى فلتر موجود دون نشر الملف عليه: في Android 11 وما قبله يفشل التحقق لكل النطاقات في الفلتر.
- تعديل ملف Apple والاختبار فوراً على iPhone: الجهاز يقرأ نسخة الشبكة التي تتحدث خلال 24 ساعة، والأجهزة تعيد الفحص تقريباً كل أسبوع.
- تنفيذ ما في الرابط دون تحقق: مبلغ، أو مسار، أو اسم شاشة، أو عملية حذف. الرابط مدخل من شخص لا تعرفه حتى لو كان النطاق موثقاً.
- الاعتماد على
resolve-activityوحده في الاختبار: رأينا أنه طبع نافذة الاختيار بينما فتحam startالتطبيق فعلاً. - طلب إذن الإشعارات في أول شاشة، وافتراض أن الإشعار وصل لأن الكود لم يرم خطأ: رأينا عدد الإشعارات صفراً دون أي استثناء.
- إشعار يحمل اسم شاشة بدلاً من رابط، أو يحمل البيانات نفسها بدلاً من معرفها.
- استخدام الشراء داخل التطبيق لسلع مادية، أو بوابة دفع خارجية لميزة رقمية: الأول ممنوع في المتجرين، والثاني سبب شائع للرفض في المراجعة.
- فتح الميزة المدفوعة على الجهاز دون تحقق السيرفر، أو نسيان الإقرار بالشراء في Google Play فيسترد المبلغ بعد ثلاثة أيام.
قائمة تحقق Checklist قبل إطلاق الروابط والإشعارات والشراء
| البند | كيف تتحقق منه |
|---|---|
كل رابط عام بصيغة https على نطاقك، والمخطط الخاص فقط للعودة من OAuth أو الدفع | راجع الروابط في الإعلانات والرسائل والإشعارات |
ملف assetlinks.json على كل نطاق، بنوع application/json، دون تحويل | node check-wellknown.mjs أو أي فحص مماثل |
| البصمة هي بصمة مفتاح التوقيع الذي يصل للمستخدمين | واجهة assetlinks:check تعيد "linked": true |
كل النطاقات verified على جهاز حقيقي بعد التثبيت من المتجر | adb shell pm get-app-links |
ملف apple-app-site-association دون امتداد، عبر HTTPS، دون تحويل | اطلب الملف من سيرفرك ومن app-site-association.cdn-apple.com وقارن |
| المسارات التي يجب أن تبقى في المتصفح مستثناة | exclude في components |
| كل رابط يتحقق منه التطبيق ويرفض المشوه، ويجلب البيانات من السيرفر | اختبر روابط فيها %2F وأرقام سالبة ونصوص |
| إذن الإشعارات يطلب في لحظة مفهومة للمستخدم، وللطلبات قناة منفصلة عن العروض | جرب الرفض ثم تأكد أن التطبيق يعمل دون إشعارات |
الإشعار يحمل رابطاً وPendingIntent صريحاً غير قابل للتعديل | dumpsys notification والضغط على الإشعار |
| السلع المادية ببوابة دفع عادية، والميزات الرقمية بالشراء داخل التطبيق | البند 3.1.1 و3.1.3(e) في Apple وسياسة المدفوعات في Google Play |
| السيرفر يتحقق من كل شراء، ويسجل الاستحقاق، ويقر بالشراء في Play | Play Developer API وApp Store Server API |
| السيرفر يستقبل RTDN وApp Store Server Notifications V2 ويعالجها بحسب الحالة | أرسل إشعار اختبار من لوحتي المتجرين |
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الرابط العميق يحتاج من يثبت أنه لك: المخطط الخاص لا يملكه أحد، ورأينا تطبيقين متساويين تماماً على نفس المخطط، وأحدهما استلم الرابط.
- روابط App Links وUniversal Links هي روابط
httpsعادية يشهد النطاق فيها لتطبيقك بملف، وإذا فشل التحقق يذهب الرابط إلى المتصفح بصمت، كما ذهب رابطنا إلى Chrome. - النظام يعطيك
legacy_failureدون سبب، سواءً كان الملف غير موجود أو موجوداً ولا يذكرك، لذلك افحص الملف بنفسك وبواجهة Digital Asset Links، وتذكر أن غيابlinkedمعناه «لا». - iPhone يقرأ نسخة Apple من ملفك وليس سيرفرك، ورأينا الشبكة تعطي ملفاً لنطاق يرد بالرمز 404 وآخر يرد بتحويل.
- الرابط مدخل من شخص لا تعرفه حتى لو كان موثقاً: معرف فقط، والبيانات من السيرفر.
- الإشعار يضيع بصمت دون إذن المستخدم في Android 13 وما بعده، وإذا وصل فليحمل نفس الرابط عبر
PendingIntentصريح. - السلع المادية لا تمر عبر الشراء داخل التطبيق، والميزات الرقمية يجب أن تمر عبره، وسيرفرك هو الذي يتحقق ويقرر الاستحقاق ويستقبل التجديد والاسترداد.
وفي هذه المرحلة مقالات أخرى عن هندسة تطبيقات الجوال، منها اختيار التقنية ودورة حياة التطبيق والعمل دون اتصال والنشر في المتاجر:
دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.
دليلتطبيق الجوال: أصيل Native أم Flutter أم React Native؟بنينا نفس تطبيق الطلبات ثلاث مرات بلغة Kotlin وبإطاري Flutter وReact Native، وقسنا الحجم وزمن البناء والإقلاع والذاكرة لنعرف من أين يأتي كل فرق وكيف تختار.
دليلدورة حياة تطبيق الجوال Lifecycle والمهام الخلفيةلماذا يجد المستخدم السلة فارغة بعد مكالمة، وما الذي يبقى فعلاً بعد تدوير الشاشة وموت العملية والسبات Doze، بتجربة حقيقية على Android وشرح ما يقابلها في iOS.
دليلتطبيق يعمل دون إنترنت Offline-first: التخزين والمزامنةنبني سيرفراً وجوالين بقواعد SQLite ونرى بأرقام حقيقية كيف يضيع التسليم أو يسجل المبلغ مرتين أو يعود الطلب المحذوف، وكيف يمنع ذلك Outbox ومفتاح منع التكرار ودمج الحقول.
دليلالنشر في App Store و Google Play: التوقيع Signing والمراجعةنوقع إصداراً حقيقياً لتطبيق Android ونرى ماذا يرفض الجهاز ولماذا، ثم نمر على ما تطلبه Google وApple في 2026 وأسباب الرفض الشائعة وكيف تتجنبها قبل أول إرسال.
دليلاختبار تطبيقات الجوال مع Appium وMaestroكيف تختار أداة اختبار تطبيقات الجوال، وأين تشغل الاختبارات، ولماذا تفشل بعض الاختبارات عندما تصبح الواجهة عربية.
دليلتقارير الأعطال Crash Reporting في تطبيقات الجوالكيف يصلك عطل حدث في جوال مستخدم لا تعرفه، وكيف تقرأ ال Stack Trace المشفر، ومتى توقف إصداراً جديداً قبل أن يصل إلى الجميع.
دليلأسئلة مقابلة مطور تطبيقات الجوال من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور تطبيقات الجوال على Android وiOS، لكل سؤال إجابة المبتدئ وإجابة الخبير، مع تمارين عملية شغلناها.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على محاكي Android 15 (API 35) بصورة google_apis_playstore x86_64 مع Android Emulator 35.4.9 على Windows 11، وبناء التطبيقين بأداة Android Gradle Plugin 9.4.1 وGradle 9.8.2، وفحص ملفات الربط بأداة Node.js 22.14 على نطاقات Airbnb وYouTube وWikipedia، ومراجعة إرشادات App Store بتاريخ 16 أكتوبر 2026.