كيفية اعتراض مخططات URL المخصصة في عمليات إعادة توجيه Android WebView

opoinstall
2026-09-21
5 min read

كيف تتعامل مع الروابط العميقة (Deep Linking) والمخططات المخصصة في Android WebView؟ تتطلب معالجة الروابط العميقة في Android WebView توسيع فئة WebViewClient، وتجاوز (override) الدالة shouldOverrideUrlLoading للتحقق من isForMainFrame، والتحقق من صحة المخططات المخصصة مقابل قائمة مسموح بها، وإطلاق Intent خارجي مصرح به بأمان لمنع بقاء المخططات غير الخاصة بالويب في مسار تحميل WebView.

إن اعتراض الروابط العميقة في Android WebView هو ممارسة برمجية لتجاوز ردود الاتصال (callbacks) الخاصة بالتنقل داخل WebViewClient مخصص لالتقاط البروتوكولات غير الخاصة بـ HTTP، مثل مخططات URI المخصصة وChrome Intent URIs. من خلال تقييم بيانات الطلب والتحقق من حمولات الوجهة، يقوم التطبيق المضيف بتنفيذ إطلاق Intent محلي مع منع حدوث تعطل في البروتوكولات غير المعالجة.

المصطلح التعريف الكيان ذو الصلة دور نية البحث
WebView مكون عرض مضمن يعتمد على Chromium يقوم بعرض محتوى الويب داخل تطبيقات Android. Android SDK معلوماتي / تجاري
Custom URL Scheme بروتوكول URI خاص يسمح لروابط الويب باستهداف التطبيقات المحلية. توجيه الروابط العميقة تقني / معلوماتي
Web to App عملية نقل المستخدمين من صفحات الويب إلى واجهات التطبيقات المحلية. الروابط العميقة للجوال معلوماتي

يجب أن تخرج المخططات المخصصة من مسار تحميل WebView قبل فشل Chromium.

لماذا تُظهر Android WebViews أخطاء net ERR UNKNOWN URL SCHEME

قيود بروتوكول Chromium: لماذا تتعرف WebViews أصلاً على مخططات الويب القياسية فقط

يعمل مكون Android WebView بواسطة محرك عرض Chromium الأساسي المكون بشكل أساسي لتحليل وعرض بروتوكولات موارد الويب القياسية: http:// وhttps:// وfile:// وdata:// وabout: وjavascript:.

افتراضياً، عند عدم تعيين WebViewClient مخصص، يقوم إطار عمل Android بتفويض عناوين URL غير المعالجة إلى مدير النشاط (Activity Manager) في النظام. ومع ذلك، بمجرد أن يوفر التطبيق WebViewClient مخصصاً، يتم توجيه المخططات غير المعتمدة على HTTP(S) (مثل myapp://product/detail/1024 أو alipay:// أو whatsapp://) مباشرة إلى ردود الاتصال الخاصة بالتنقل في العميل. إذا سمح التطبيق المضيف لمخطط مخصص بالبقاء داخل مسار تحميل WebView بدلاً من اعتراضه، يحاول محرك Chromium حل المخطط المخصص كمورد ويب عادي. ولأن البروتوكولات غير القياسية لا يمكن حلها مقابل DNS أو طبقات نقل خادم الويب، فإن المحرك يظهر فشلاً في التنقل على مستوى منخفض.

تشريح التعطل لـ net::ERR_UNKNOWN_URL_SCHEME: من نقرة المستخدم إلى فشل تحميل مورد الويب

يحدث الانتقال من تفاعل المستخدم إلى صفحة الخطأ في تسلسل سريع ومحدد:

  1. مشغل المستخدم: ينقر المستخدم على زر دعوة لاتخاذ إجراء أو رابط تشعبي داخل صفحة هبوط H5 مضمنة.
  2. طلب التنقل: ينشئ محرك الويب طلب تنقل يحتوي على سلسلة URI المستهدفة (مثال: myapp://promo/summer).
  3. تقييم المسار: إذا أرجعت shouldOverrideUrlLoading() القيمة false أو فشلت في اعتراض URI غير HTTP(S)، يعامل محرك Chromium عنوان URL كتحميل مورد ويب قياسي.
  4. إرسال الخطأ: عند عدم العثور على معالج داخلي للبروتوكول، يقوم المحرك بتشغيل onReceivedError() ويستبدل محتوى WebView بقالب خطأ Chromium الافتراضي: net::ERR_UNKNOWN_URL_SCHEME.

توقف صفحة الخطأ هذه رحلة المستخدم، وتمنع تتبع التحويل، وتؤدي إلى تدهور الاستقرار المدرك للتطبيق المضيف.

مخاطر تعطل ActivityNotFoundException غير المعالج عند غياب التطبيق المستهدف

عندما يحاول المطورون لأول مرة حل net::ERR_UNKNOWN_URL_SCHEME، يكون الخطأ الشائع هو تحليل سلسلة URI واستدعاء context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) بدون معالجة استثناءات كافية.

إذا لم يكن لدى المستخدم التطبيق المستهدف مثبتاً على جهازه، لا يمكن لنظام تشغيل Android العثور على Activity مؤهل لحل الـ Intent. في تنفيذ Android القياسي، يؤدي استدعاء startActivity() بدون معالج مطابق إلى طرح android.content.ActivityNotFoundException غير معالج. إذا لم يتم التقاط هذا الاستثناء داخل كتلة try-catch منظمة، تنتهي عملية التطبيق المضيف بالكامل فجأة، مما يؤدي إلى تعطل فادح.

كيف تقوم shouldOverrideUrlLoading باعتراض بروتوكولات الروابط العميقة

تطور الواجهة البرمجية (API): مقارنة تجاوزات WebResourceRequest في API 24+ مع ردود الاتصال النصية القديمة

توفر WebViewClient في Android رد اتصال shouldOverrideUrlLoading للسماح للتطبيقات المضيفة بفحص واعتراض طلبات التنقل قبل أن ينفذها محرك الويب. تطور توقيع وإمكانيات هذه الطريقة عبر مستويات API لـ Android:

  • API 24+ (Android 7.0 وما فوق): يتجاوز shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean. توفر واجهة WebResourceRequest بيانات وصفية سياقية غنية، بما في ذلك عنوان URL المستهدف (request.url)، وطريقة طلب HTTP (request.method)، ورؤوس الطلب، وعلامات تسلسل الإطارات الحرجة (request.isForMainFrame، request.hasGesture()).
  • التجاوز القديم (مهمل في API 24): يتجاوز shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean. يتلقى رد الاتصال القديم هذا سلسلة URL الخام فقط بدون بيانات وصفية تتعلق بالإطار الطالب أو حالة تفاعل المستخدم.

وفقاً لـ مرجع مطوري Android لـ WebViewClient shouldOverrideUrlLoading، يجب على التطبيقات الحديثة تنفيذ تجاوز WebResourceRequest. عند دعم إصدارات Android القديمة، يجب تكوين رد الاتصال النصي المهمل بشكل متحفظ لمنع تجاوزات الأمان.

الأهمية الأمنية لـ request.isForMainFrame: حظر اختطاف الإطارات الفرعية (Subframes) وIframe

عمليات التحقق من الإطار الرئيسي تمنع تنقل iframe من إطلاق مسارات التطبيقات الخارجية.

وفقاً لـ دليل مطوري Android حول WebResourceRequest، يمكن تشغيل رد اتصال shouldOverrideUrlLoading للإطارات الفرعية، وiframes، وعمليات إعادة توجيه الموارد في الخلفية بالإضافة إلى عمليات التنقل في المستند من المستوى الأعلى.

فرض request.isForMainFrame ضروري لأمان التطبيق:

  • عزل حدود الإطار: يضمن التحقق من request.isForMainFrame أن الإطارات الفرعية وiframes لا يمكنها تشغيل عمليات إطلاق تطبيقات خارجية أو Intent مخططات مخصصة. إذا حاول إطار فرعي التنقل، يجب أن تُرجع أحمال HTTP(S) القياسية false للاستمرار في العرض داخل الإطار، بينما يجب أن تُرجع المخططات غير HTTP(S) القيمة true لإيقاف التنفيذ دون طرح أخطاء.
  • التمييز بين إيماءات المستخدم: بينما يؤكد isForMainFrame أن الطلب ينطبق على المستند الأساسي، فإنه لا يثبت تنشيط المستخدم. إذا كانت بنيتك تتطلب التحقق من إيماءة المستخدم، قم بتقييم request.hasGesture() أو فرض نوافذ تأكيد على مستوى التطبيق، مع ملاحظة أن وثائق Android تسلط الضوء على بعض تدفقات المستخدم الصالحة حيث قد تُرجع hasGesture() قيمة false.

التمييز بين تنقل الويب العادي وتسليمات الروابط العميقة: متى تُرجع false مقابل true

تتحكم القيمة المنطقية التي تُرجعها shouldOverrideUrlLoading في دورة حياة عرض WebView:

  • إرجاع false: يبلغ إطار عمل Android أن التطبيق المضيف لم يتعامل مع عنوان URL. يستمر WebView في تنقل الويب القياسي، حيث يقوم بتحميل عنوان URL داخل حاوية Chromium الداخلية الخاصة به. هذه القيمة المرجعة إلزامية لصفحات الويب القياسية http:// وhttps:// التي يجب عرضها داخل المتصفح المضمن.
  • إرجاع true: يبلغ إطار العمل أن التطبيق المضيف قد اعترض وتعامل مع عنوان URL. يقوم WebView فوراً بإلغاء عملية التحميل الداخلية الخاصة به. هذه القيمة المرجعة إلزامية عند إطلاق Intent خارجي، أو التوجيه إلى نشاط محلي، أو قمع مخطط غير مصرح به.

معالجة روابط التطبيقات (App Links) الموثقة للتطبيق المضيف داخل الحاوية المضمنة

عندما يشير رابط داخل WebView إلى نطاق HTTPS موثق خاص بالتطبيق المضيف (مثل https://app.example.com/product/1024)، يتسبب تنفيذ التنقل الافتراضي في قيام WebView بتحميل نسخة الويب من صفحة المنتج.

لتقديم تجربة محلية، يجب على WebViewClient فحص عناوين URL الواردة عبر HTTPS. إذا تطابق المضيف مع النطاق المرتبط بالتطبيق، يقوم العميل باعتراض الطلب، والتحقق من المسار ومعلمات الاستعلام مقابل مدقق مسار داخلي (AppLinkRouteValidator)، وإرسال البيانات مباشرة إلى الموجه المحلي (internalRouter(validatedRoute))، مع إرجاع true لإلغاء عرض الويب الزائد.

هيكلة معالجات Intent الآمنة للمخططات المخصصة وIntent URIs

تشريح بنية Chrome Intent URI: المخططات، والحزم، وS.browser_fallback_url

على Android، تستخدم صفحات الويب بشكل متكرر Chrome Intent URIs لهيكلة الروابط العميقة المحلية مع آليات احتياطية للمتصفح مدمجة. يستخدم Intent URI مخطط intent:// ويقوم بتشفير معلمات التوجيه، وأهداف الحزم، وعناوين URL الاحتياطية في سلسلة متسلسلة:

intent://product/detail/1024#Intent;scheme=myapp;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end

كما هو موثق في دليل Chrome للمطورين حول Android Intent URIs، ينقسم URI إلى مكونات مميزة:

  • المضيف والمسار (product/detail/1024): يحدد وجهة التطبيق الداخلية.
  • scheme=myapp: يحدد البروتوكول المخصص الأساسي.
  • package=com.example.app: يقيد حل intent حصرياً بحزمة Android المحددة.
  • S.browser_fallback_url=...: معلمة إضافية اختيارية (String extra) تحدد عنوان URL ويب أو متجر صريح للتحميل إذا لم يكن التطبيق المستهدف مثبتاً.

خطر تنفيذ Intents الخام المحللة: تخفيف ثغرات حقن Intent

نمط تنفيذي عكسي شائع هو تحليل URI وارد باستخدام Intent.parseUri(url, Intent.URI_INTENT_SCHEME) وتمرير Intent الناتج مباشرة إلى context.startActivity(intent).

وفقاً لـ دليل اختبار أمان تطبيقات الجوال الخاص بـ OWASP حول الروابط العميقة غير الآمنة، يعرض تنفيذ intents المحللة الخام التطبيق لهجمات حقن Intent. يمكن لمهاجم يتحكم في صفحة الويب صياغة Intent URI يحتوي على أهداف مكونات صريحة ضارة (component=com.example.app/.InternalAdminActivity)، أو إجراءات حساسة، أو إضافات intent ذات امتيازات (S.is_admin=true). سيقوم WebView بإلغاء تسلسل هذه المعلمات وإطلاق أنشطة غير مصدرة أو داخلية، متجاوزاً حواجز المصادقة.

يجب التحقق من URIs الخاصة بـ intent المحللة وإعادة بنائها قبل إرسالها في Android.

إعادة بناء Intent الآمنة: تجريد المكونات وقصر الإجراءات على ACTION_VIEW

للقضاء على ثغرات حقن Intent، يجب ألا تقوم التطبيقات المضيفة أبداً بإطلاق Intent خام تم إلغاء تسلسله مباشرة. بدلاً من ذلك، يجب على WebViewClient تحليل URI، واستخراج حمولة البيانات الموثقة فقط، وإعادة بناء Intent نظيف ومخصص:

  1. استخراج البيانات: تحليل URI واستخراج حزمة الوجهة الموثقة وURI البيانات.
  2. التحقق من الحزمة: التحقق من أن الحزمة المعلنة تطابق قائمة بيضاء صريحة من الشركاء المصرح بهم أو الحزم الداخلية.
  3. إعادة البناء: إنشاء Intent(Intent.ACTION_VIEW, validatedDataUri) جديد.
  4. العزل: تطبيق intent.addCategory(Intent.CATEGORY_BROWSABLE) لضمان أن الأنشطة القابلة للتصفح فقط يمكنها حل الطلب، وتعيين علامات صريحة (FLAG_ACTIVITY_NEW_TASK)، والتأكد من بقاء intent.component وintent.selector فارغين.

فرض قوائم بيضاء صارمة للمخطط والمضيف والمسار ومعلمات الاستعلام

يجب أن تمر كل من المخططات المخصصة (myapp://) وIntent URIs المعاد بناؤها عبر مدقق صارم قبل التنفيذ.

يتعامل هذا التنفيذ المرجعي تحديداً مع مخطط myapp:// الخاص بالتطبيق المضيف، مع تثبيت تنفيذ intent بشكل صريح على com.example.app. بالنسبة لأنظمة الشركاء متعددة التطبيقات، يجب على المطورين الحفاظ على تعيين صارم للمخطط إلى الحزمة أو إعطاء الأولوية لروابط تطبيقات HTTPS الموثقة.

يتحقق المدقق من:

  • المخطط: يطابق المخططات المخصصة المصرح بها (myapp).
  • المضيف: يطابق فئات التوجيه المحددة مسبقاً (open، product، event).
  • بادئة المسار: تتوافق مع هياكل المسار المتوقعة (/detail/، /view/، /main/).
  • معلمات الاستعلام: تفرض مفاتيح استعلام مسموح بها (target، id، promo، utm_source) وتتحقق من أن قيم المعلمات تتوافق مع حدود الطول (مثل ≤128\le 128 حرفاً) ومجموعات الأحرف الأبجدية الرقمية.
[المستخدم ينقر على رابط داخل Android WebView]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[التحقق من مخطط الإطار الفرعي]     [فحص مخطط URL]
├─ HTTP(S) -> إرجاع false       │
└─ Non-Web -> إرجاع true   ┌────┴───────────────────────────┐
   (قمع الخطأ)         ▼                                ▼
                     [HTTP / HTTPS]                 [مخطط Non-HTTP(S)]
                            │                                │
                            ▼                                ▼
                   [التحقق من نطاق المضيف]              [CustomSchemeValidator]
                   ├─ رابط تطبيق داخلي             ├─ صالح -> تشغيل Intent مثبت
                   │  -> الموجه الداخلي            └─ Intent -> إعادة بناء & احتياطي
                   └─ ويب خارجي
                      -> إرجاع false

كيفية التعامل مع رؤية الحزمة في Android 11 والاحتياطيات لمتجر التطبيقات

تؤثر رؤية الحزمة على استعلامات ما قبل الرحلة بينما تستخدم حالات فشل الإطلاق احتياطيات موثقة.

تحول رؤية الحزمة في Android 11: لماذا تتطلب استعلامات ما قبل الرحلة (Preflight) إعلاناً

بدءاً من Android 11 (مستوى API 30)، قدمت Google تصفية رؤية الحزمة (Package Visibility) لتعزيز خصوصية المستخدم. في ظل هذا الإطار، يتم تقييد رؤية التطبيق للتطبيقات الأخرى المثبتة افتراضياً.

من المهم فهم التمييز بين إطلاق intent واستعلام intent:

  • إطلاق Intent المباشر: لا تتطلب الاستدعاءات القياسية لـ context.startActivity(intent) إعلانات رؤية الحزمة. إذا كان التطبيق المستهدف مثبتاً، يقوم نظام التشغيل بالتوجيه وإطلاقه بنجاح بغض النظر عما إذا كان مرئياً لاستعلامات الحزمة.
  • استعلامات Intent ما قبل الرحلة: إذا كانت بنية WebViewClient الخاصة بك تقوم بفحص ما قبل الرحلة—مثل استدعاء packageManager.resolveActivity(intent, 0) أو packageManager.queryIntentActivities(intent, 0) للتحقق من وجود التطبيق قبل الإطلاق—فإن الاستعلام يرجع null أو قائمة فارغة ما لم يتم التصريح بالحزمة أو المخطط المستهدف في AndroidManifest.xml.

الإعلان عن عناصر <queries> في AndroidManifest.xml للمخططات المخصصة وأهداف الحزم

وفقاً لـ دليل مطوري Android حول رؤية الحزمة في Android 11، يجب على التطبيقات التي تجري فحوصات ما قبل الرحلة قبل تنفيذ intent التصريح عن حزمها المستهدفة أو توقيعات intent في ملف البيان:

<manifest package="com.example.app">
    <!-- إعلانات استعلام ما قبل الرحلة الاختيارية لاكتشاف الحزمة الصريح -->
    <queries>
        <package android:name="com.example.partner" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>
</manifest>

إذا كانت بنيتك تفضل أنماط الإطلاق القياسية، فإن تنفيذ startActivity() داخل كتلة try-catch التي تصطاد ActivityNotFoundException يوفر تنفيذاً مرناً ومستقلاً عن الاستعلام.

تنفيذ سلاسل الاحتياط (Fallback Chains): التوجيه الرشيق للمتجر عند غياب التطبيق المستهدف

عندما لا يمكن إطلاق مخطط مخصص معترض أو Intent URI لأن التطبيق المستهدف غير مثبت على جهاز المستخدم، يجب على WebViewClient تنفيذ سلسلة احتياطية صريحة:

  1. استخراج وربط عنوان URL الاحتياطي: بالنسبة لـ intent:// URIs، قم باستخراج معلمة سلسلة S.browser_fallback_url والتحقق من أنها تشير إلى وجهة مصرح بها. بالنسبة لعناوين URL الخاصة بـ Google Play، تحقق من أن معلمة id تطابق اسم الحزمة المستهدفة بدقة (id=targetPackage) لمنع استغلالات إعادة التوجيه المفتوحة.
  2. توليد URI المتجر: إذا لم يكن هناك عنوان URL احتياطي موجود، قم بإنشاء intent متجر: market://details?id=com.example.app.
  3. احتياطي ويب متجر Play: إذا تم تعطيل Google Play أو غيابه، قم باصطياد ActivityNotFoundException في intent المتجر وقم بتحميل https://play.google.com/store/apps/details?id=com.example.app.

ربط المعلمات السياقية عبر عمليات إعادة التوجيه الاحتياطية مع OpoInstall

عند توجيه المستخدمين غير المثبتين إلى Google Play، تسقط روابط المتجر الثابتة المعلمات الديناميكية. تقوم OpoInstall، وهي منصة إسناد للجوال وروابط عميقة، بسد هذه الفجوة من خلال التقاط رموز الإحالة وسياق الحملة على خلفية الإسناد قبل إعادة التوجيه إلى المتجر. تحقق من سلوك استرداد المعلمات المؤجلة وواجهات برمجة التطبيقات (APIs) للتهيئة مقابل إصدار OpoInstall Android SDK المنشور في الإنتاج.

عند التثبيت اللاحق وأول إطلاق للتطبيق، يسترد OpoInstall Android SDK المعلمات المخزنة مؤقتاً، مما يمكن التطبيق المضيف من استعادة مشهد المستخدم المستهدف. راجع وثائق تكامل SDK للحصول على تعليمات حول تلقي المعلمات المؤجلة بعد التثبيت.

تنفيذ WebViewClient للإنتاج لاعتراض المخططات بشكل آمن

هيكلة بنية WebViewClient المخصصة في Kotlin

يقوم WebViewClient للإنتاج بتنسيق التحقق من الإطار، والقائمة البيضاء للمخطط، وإعادة بناء intent، واستخراج الاحتياطي، والتوجيه الداخلي. يغلف التنفيذ قواعد التحقق داخل كائنات مدقق مخصصة (AppLinkRouteValidator وCustomSchemeValidator) للحفاظ على نظافة وقابلية صيانة ردود الاتصال الخاصة بالتنقل.

تنفيذ نماذج AppLinkRouteValidator وCustomSchemeValidator

تفرض كائنات المدقق أماناً صارماً (فشل مغلق):

  • AppLinkRouteValidator يتحقق من نطاقات HTTPS الداخلية، وبادئات المسار المسموح بها، ومفاتيح الاستعلام المصرح بها (target، id، promo، utm_source)، ويرجع فئة بيانات ValidatedAppRoute منظمة.
  • CustomSchemeValidator يتحقق من المخططات المخصصة المصرح بها (myapp)، ويتحقق من المضيفين المسموح بهم (open، product، event)، ويفرض بادئات المسار، ويطهر قيم معلمات الاستعلام مقابل مجموعات الأحرف الأبجدية الرقمية وقيود الطول (≤128\le 128 حرفاً).

يوضح تنفيذ الكود أدناه كيفية تكوين إعلانات رؤية حزمة AndroidManifest.xml اختيارية وتنفيذ CustomWebViewClient آمن وخالٍ من مشاكل الخيوط (thread-safe) في Kotlin مع التعامل التلقائي مع الاحتياطيات. يمكن الحصول على ملفات SDK الثنائية المعتمدة ومغلفات التكامل من مركز تنزيل OpoInstall SDK.

<!-- AndroidManifest.xml: إعلانات رؤية حزمة اختيارية لاستعلامات Intent ما قبل الرحلة (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- التصريح عن الحزم الخارجية والمخططات المخصصة فقط في حالة استخدام استعلامات resolveActivity لما قبل الرحلة -->
    <queries>
        <!-- تفويض اكتشاف ما قبل الرحلة لحزم شركاء أو مستهدفة محددة -->
        <package android:name="com.example.partner" />
        
        <!-- تفويض حل ما قبل الرحلة لمخططات الروابط العميقة المخصصة -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- تفويض حل ما قبل الرحلة لنطاقات روابط تطبيقات HTTPS الموثقة -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">
        <!-- مكونات التطبيق -->
    </application>
</manifest>
// Android: WebViewClient مخصص مع أمان حدود الإطار، والتحقق من القائمة البيضاء، وتوجيه الاحتياط
// مثال تكامل مرجعي؛ تحقق من توقيعات الطريقة مقابل بنية Android المنشورة.
package com.example.app.webview

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

data class ValidatedAppRoute(val path: String, val queryParams: Map<String, String>)

object AppLinkRouteValidator {
    private val allowedPrefixes = setOf("/open/", "/product/", "/promo/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): ValidatedAppRoute? {
        val path = uri.path ?: ""
        if (allowedPrefixes.none { path.startsWith(it) }) {
            return null
        }

        val sanitizedParams = mutableMapOf<String, String>()
        for (name in uri.queryParameterNames) {
            // موقف أمني صارم: رفض الطلب إذا كانت هناك مفاتيح استعلام غير معروفة
            if (name !in allowedQueryKeys) {
                return null
            }
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length <= 64 && value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                sanitizedParams[name] = value
            } else {
                return null
            }
        }

        return ValidatedAppRoute(path, sanitizedParams)
    }
}

object CustomSchemeValidator {
    private val allowedSchemes = setOf("myapp")
    private val allowedHosts = setOf("open", "product", "event")
    private val allowedPathPrefixes = setOf("/detail/", "/view/", "/main/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): Uri? {
        val scheme = uri.scheme?.lowercase() ?: return null
        val host = uri.host?.lowercase() ?: return null
        val path = uri.path ?: ""

        if (!allowedSchemes.contains(scheme) || !allowedHosts.contains(host)) {
            return null
        }

        if (allowedPathPrefixes.none { path.startsWith(it) }) {
            return null
        }

        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) return null
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length > 128 || !value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                return null
            }
        }

        return uri
    }
}

class CustomWebViewClient(
    private val context: Context,
    private val internalRouter: (ValidatedAppRoute) -> Unit
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        request ?: return false

        // متطلب أمني: قصر توجيه التطبيق الخارجي على عمليات تنقل الإطار الرئيسي فقط
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // السماح بتحميل إطار فرعي HTTP(S) قياسي، ولكن قمع المخططات غير الخاصة بالويب لمنع اختطاف iframe والأخطاء
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "تم قمع مخطط غير ويب في إطار فرعي/iframe: $scheme")
                true // استهلاك/إلغاء بدون إطلاق intent خارجي أو إظهار خطأ Chromium
            }
        }

        val url = request.url.toString()
        return handleUrlNavigation(view, url)
    }

    @Deprecated("مهمل في Java")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()

        // يفتقر رد الاتصال القديم إلى بيانات وصفية موثوقة لـ isForMainFrame؛ لا تقم بتوجيه المخططات الخارجية
        return if (scheme == "http" || scheme == "https") {
            false // السماح بتحميل محتوى الويب القياسي
        } else {
            Log.w("WebViewClient", "تم حظر مخطط خارجي في رد الاتصال القديم: $scheme")
            true // قمع أخطاء المخطط غير المعالج بدون إطلاق intents خارجية
        }
    }

    private fun handleUrlNavigation(view: WebView?, url: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase() ?: return false

        // 1. التعامل مع HTTP / HTTPS وروابط التطبيقات الداخلية
        if (scheme == "http" || scheme == "https") {
            // التحقق مما إذا كان URL يطابق نطاق رابط التطبيق الخاص بالتطبيق المضيف
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "مسار HTTPS داخلي موثق. يتم الإرسال إلى الموجه الداخلي.")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "تم رفض رابط تطبيق داخلي مشوه أو غير مصرح به: $uri")
                }
                return true // تمت المعالجة داخلياً؛ إلغاء تحميل webview
            }
            return false // السماح لـ WebView بتحميل محتوى الويب القياسي محلياً
        }

        // 2. التحقق من المخططات المخصصة بقائمة بيضاء صارمة وتثبيت الحزمة لمنع الاختطاف
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // تثبيت حزمة التطبيق المضيف صراحة
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        // 3. التعامل مع Chrome Intent URIs بإعادة بناء آمنة واستخراج احتياطي
        if (scheme == "intent") {
            return try {
                val parsed = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                val targetPackage = parsed.`package`
                val allowedPackages = setOf("com.example.app", "com.example.partner")
                val fallbackUrl = parsed.getStringExtra("browser_fallback_url")

                if (targetPackage != null && allowedPackages.contains(targetPackage)) {
                    val rawDataUri = parsed.data
                    if (rawDataUri != null) {
                        val validatedDataUri = CustomSchemeValidator.validate(rawDataUri)
                        if (validatedDataUri != null) {
                            // إعادة بناء ACTION_VIEW Intent نظيف ومخصص بدلاً من تنفيذ كائن تم تحليله خام
                            val safeIntent = Intent(Intent.ACTION_VIEW, validatedDataUri).apply {
                                `package` = targetPackage
                                addCategory(Intent.CATEGORY_BROWSABLE)
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            }
                            launchSafeIntent(safeIntent, fallbackAction = {
                                handleIntentFallback(view, fallbackUrl, targetPackage)
                            })
                        } else {
                            Log.w("WebViewClient", "تم رفض intent مع URI بيانات غير صالح: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "تم رفض intent يفتقر إلى حمولة URI بيانات")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "تم رفض intent يستهدف حزمة غير موثقة: $targetPackage")
                    true // إلغاء intent حزمة غير موثق
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "فشل تحليل intent URI: $url", e)
                true // قمع أخطاء المخطط غير المعالج
            }
        }

        // رفض المخططات غير المعروفة لتجنب أخطاء net::ERR_UNKNOWN_URL_SCHEME
        Log.w("WebViewClient", "تم حظر تنقل مخطط غير موثق: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "التطبيق المستهدف غير مثبت: ${intent.data}. يتم تشغيل الاحتياط.")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "استثناء أمني أثناء إطلاق intent: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            Log.i("WebViewClient", "التوجيه إلى عنوان URL احتياطي للمتصفح مصرح به: $fallbackUrl")
            view?.loadUrl(fallbackUrl)
        } else {
            handleStoreFallback(view, targetPackage)
        }
    }

    private fun handleStoreFallback(view: WebView?, packageName: String) {
        val marketUri = Uri.parse("market://details?id=$packageName")
        val marketIntent = Intent(Intent.ACTION_VIEW, marketUri).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        try {
            context.startActivity(marketIntent)
        } catch (e: ActivityNotFoundException) {
            val webStoreUrl = "https://play.google.com/store/apps/details?id=$packageName"
            Log.i("WebViewClient", "تطبيق متجر Play غائب. يتم تحميل عنوان URL لمتجر الويب: $webStoreUrl")
            view?.loadUrl(webStoreUrl)
        }
    }

    private fun isAuthorizedFallbackUrl(url: String, targetPackage: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()
        val host = uri.host?.lowercase()

        if (scheme != "https" || host == null) return false

        return when (host) {
            "play.google.com" -> {
                // فرض أن عنوان URL الاحتياطي لـ Google Play يشير تحديداً إلى الحزمة المستهدفة
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                // التحقق من مسار الاحتياطي للنطاق المملوك
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

التنفيذ الخالي من المشاكل (Thread-Safe): إرسال المسارات الداخلية وإدارة حالات دورة الحياة

عندما يؤكد AppLinkRouteValidator مساراً داخلياً صالحاً، يجب أن تنفذ انتقالات واجهة المستخدم على خيط واجهة المستخدم الرئيسي لـ Android. إذا قام internalRouter بتشغيل أجزاء (fragments)، أو انتقالات نشاط، أو تنقل Compose، فقم بتغليف استدعاء الإرسال داخل runOnUiThread أو دورة حياة coroutine للخيط الرئيسي لمنع تعطل الخيوط.

مصفوفة أخطاء إعادة توجيه Android WebView والحلول التشخيصية

دليل استكشاف أخطاء الروابط العميقة في Android WebView الشامل

توضح المصفوفة أدناه توقيعات الأخطاء الشائعة التي تمت مواجهتها عند التعامل مع المخططات المخصصة في Android WebViews، جنباً إلى جنب مع الأسباب الجذرية الأولية والمعالجات الهندسية:

توقيع الخطأ / العرض السبب الجذري الأولي مستويات API المتأثرة نقطة التحقق التشخيصية المعالجة الموصى بها
net::ERR_UNKNOWN_URL_SCHEME مخطط غير ويب غير معالج في محمل WebView جميع مستويات API shouldOverrideUrlLoading مفقود أو يرجع false تجاوز المندوب، اعتراض المخطط، إرجاع true
ActivityNotFoundException التطبيق المستهدف غائب عن الجهاز جميع مستويات API context.startActivity() غير معالج التقاط الاستثناء وتنفيذ S.browser_fallback_url أو مسار متجر Play
اختطاف Intent للإطار Iframe إطار فرعي يشغل intent خارجياً API 24+ request.isForMainFrame غير متحقق منه الحماية بـ if (!request.isForMainFrame) وقمع المخططات غير الخاصة بالويب
استعلام ما قبل الرحلة يرجع Null عامل تصفية رؤية الحزمة في Android 11+ API 30+ (Android 11+) استدعاء resolveActivity بدون إعلان البيان التصريح عن <queries> في البيان أو استخدام startActivity try-catch مباشرة
حلقة إعادة تحميل رابط التطبيق الداخلي رابط تطبيق مضيف معاد توجيهه إلى WebView جميع مستويات API نطاق المضيف غير معالج في المندوب اعتراض النطاق الخاص، التحقق من المسار، الإرسال إلى الموجه المحلي

الأسئلة الشائعة (FAQ)

لماذا يتعطل Android WebView الخاص بي عند فتح مخططات URL المخصصة؟
ينتج المخطط المخصص خطأ `net::ERR_UNKNOWN_URL_SCHEME` عندما يظل عنوان URL في مسار تحميل WebView بدلاً من اعتراضه بواسطة التطبيق المضيف. مع `WebViewClient` مخصص، أرجع `true` بعد التعامل بأمان أو قمع مخطط غير ويب؛ أرجع `false` فقط عندما يجب أن يستمر WebView في تنقل الويب العادي. إذا كان التطبيق المستهدف غائباً، فإن استدعاء `startActivity()` بدون كتلة `try-catch` سيطرح `ActivityNotFoundException` غير معالج.
كيف أتعامل بأمان مع intent:// URLs في WebView بدون ثغرات أمنية؟
للتعامل مع `intent://` URIs بأمان، قم بتحليل URI باستخدام `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`، وتحقق من أن حزمة الوجهة وURI البيانات يطابقان القوائم البيضاء الصارمة، وقم بإنشاء `Intent(Intent.ACTION_VIEW, safeDataUri)` جديد ونظيف. أضف `Intent.CATEGORY_BROWSABLE` صراحة لضمان أن الأنشطة القابلة للتصفح فقط هي التي تتعامل مع الـ intent، وقم بتجريد المكونات الخطيرة، وقم بتنفيذ معالجة عنوان URL احتياطي موثقة مرتبطة بالحزمة المستهدفة.
كيف تؤثر رؤية الحزمة في Android 11 على اعتراض الروابط العميقة في WebViews؟
لا تمنع رؤية الحزمة في Android 11 `startActivity()` من إطلاق الأنشطة الخارجية المطابقة. ومع ذلك، إذا كان `WebViewClient` الخاص بك يقوم بإجراء فحوصات ما قبل الرحلة باستخدام `resolveActivity()` أو `queryIntentActivities()` قبل محاولة الإطلاق، فيجب عليك التصريح باسم الحزمة المستهدفة أو مخطط intent المخصص داخل عنصر `<queries>` في ملف `AndroidManifest.xml` الخاص بك.

إطار الملخص والقرار

تتطلب معالجة الروابط العميقة والمخططات المخصصة داخل Android WebViews سد الفجوة بين صناديق رمال عرض الويب وتوجيه Intent لنظام التشغيل المحلي. يؤدي الاعتماد على سلوك WebView الافتراضي إلى أخطاء بروتوكول غير معالجة، وتجربة مستخدم سيئة، وثغرات أمنية محتملة.

من خلال تنفيذ WebViewClient قوي يتحقق من حدود الإطار، ويتحقق من المخططات المخصصة مقابل قوائم بيضاء صريحة، ويعيد بناء intents ACTION_VIEW آمنة، ويتعامل مع احتياطيات المتجر الرشيقة، تقلل فرق الهندسة من حالات فشل التعامل مع مخططات WebView وتدعم تنقلاً أكثر مرونة. إن دمج توجيه موثوق من جانب العميل مع أطر عمل إسناد الهاتف المحمول الشاملة يقلل من احتكاك التوجيه عبر تدفقات الإسناد ويدعم رحلات مستخدم أكثر اتساقاً.

لاستكشاف بنيات الروابط العميقة وتوجيه المعلمات لنظام Android المحلي، راجع وثائق تكامل SDK، وقم بتنزيل مكتبات العميل من مركز تنزيل OpoInstall SDK، واستكشف مرجع تنفيذ إسناد الجوال، أو قم بتسجيل تطبيقك على وحدة تحكم مطوري OpoInstall.

المواد ذات الصلة

Share this article