Cách chặn các Custom URL Scheme trong chuyển hướng Android WebView

opoinstall
2026-09-21
5 min read

Làm thế nào để xử lý deep link và các custom scheme trong Android WebView? Việc xử lý deep link trong Android WebView đòi hỏi phải mở rộng WebViewClient, ghi đè phương thức shouldOverrideUrlLoading để kiểm tra isForMainFrame, xác thực các custom scheme dựa trên danh sách cho phép (allowlist), và khởi chạy an toàn các intent bên ngoài để ngăn chặn các giao thức không phải web tồn tại trong quy trình tải của WebView.

Chặn deep link trong Android WebView là phương pháp lập trình ghi đè các callback điều hướng bên trong một WebViewClient tùy chỉnh để bắt các giao thức không phải HTTP, chẳng hạn như custom URI scheme và Chrome Intent URI. Bằng cách đánh giá dữ liệu yêu cầu và xác thực tải trọng đích, ứng dụng chủ sẽ thực hiện khởi chạy Intent gốc đồng thời ngăn chặn các lỗi treo do giao thức không được xử lý.

Thuật ngữ Định nghĩa Thực thể liên quan Vai trò tìm kiếm
WebView Một thành phần hiển thị dựa trên Chromium được nhúng để hiển thị nội dung web bên trong ứng dụng Android. Android SDK Thông tin / Thương mại
Custom URL Scheme Một giao thức URI độc quyền cho phép các liên kết web nhắm mục tiêu đến các ứng dụng gốc. Định tuyến Deep Link Kỹ thuật / Thông tin
Web to App Quá trình chuyển người dùng từ trang web vào giao diện ứng dụng gốc. Mobile Deep Linking Thông tin

Các custom scheme phải rời khỏi quy trình tải của WebView trước khi Chromium xảy ra lỗi.

Tại sao Android WebViews gây ra lỗi net ERR UNKNOWN URL SCHEME

Hạn chế về giao thức của Chromium: Tại sao WebViews chỉ nhận diện các giao thức web tiêu chuẩn

Thành phần WebView trên Android được vận hành bởi công cụ kết xuất Chromium, được cấu hình chủ yếu để phân tích và hiển thị các giao thức tài nguyên web tiêu chuẩn: http://, https://, file://, data://, about: và javascript:.

Theo mặc định, khi không có WebViewClient tùy chỉnh nào được chỉ định, khung Android sẽ ủy quyền các URL không được xử lý cho Trình quản lý Hoạt động của hệ thống. Tuy nhiên, khi một ứng dụng cung cấp WebViewClient tùy chỉnh, các scheme không phải HTTP(S) (ví dụ: myapp://product/detail/1024, alipay:// hoặc whatsapp://) sẽ được định tuyến trực tiếp vào các callback điều hướng của client. Nếu ứng dụng chủ cho phép một custom scheme tồn tại trong quy trình tải của WebView thay vì chặn nó, công cụ Chromium sẽ cố gắng phân giải scheme tùy chỉnh đó như một tài nguyên web thông thường. Vì các giao thức phi tiêu chuẩn không thể được phân giải thông qua DNS hoặc lớp truyền tải máy chủ web, công cụ sẽ hiển thị lỗi điều hướng mức thấp.

Giải phẫu lỗi net::ERR_UNKNOWN_URL_SCHEME: Từ thao tác người dùng đến lỗi tải tài nguyên

Quá trình chuyển từ thao tác của người dùng sang trang lỗi diễn ra theo trình tự nhanh chóng và có tính quyết định:

  1. Người dùng kích hoạt: Người dùng nhấn vào nút kêu gọi hành động hoặc siêu liên kết bên trong trang landing H5 được nhúng.
  2. Yêu cầu điều hướng: Công cụ web xây dựng một yêu cầu điều hướng chứa chuỗi URI đích (ví dụ: myapp://promo/summer).
  3. Đánh giá quy trình: Nếu shouldOverrideUrlLoading() trả về false hoặc không chặn được URI không phải HTTP(S), công cụ Chromium sẽ coi URL đó như một tải tài nguyên web tiêu chuẩn.
  4. Gửi lỗi: Không tìm thấy trình xử lý nội bộ cho giao thức, công cụ kích hoạt onReceivedError() và thay thế nội dung của web view bằng mẫu lỗi mặc định của Chromium: net::ERR_UNKNOWN_URL_SCHEME.

Trang lỗi này ngăn cản hành trình người dùng, làm gián đoạn việc theo dõi chuyển đổi và làm giảm tính ổn định cảm nhận của ứng dụng chủ.

Rủi ro gây treo ứng dụng do ActivityNotFoundException khi ứng dụng đích không tồn tại

Khi các nhà phát triển lần đầu cố gắng giải quyết lỗi net::ERR_UNKNOWN_URL_SCHEME, một sai lầm phổ biến là phân tích chuỗi URI và gọi context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) mà không xử lý ngoại lệ đầy đủ.

Nếu người dùng chưa cài đặt ứng dụng đích trên thiết bị, hệ điều hành Android không thể tìm thấy Activity phù hợp để phân giải Intent. Trong thực thi Android tiêu chuẩn, việc gọi startActivity() mà không có trình xử lý tương ứng sẽ gây ra android.content.ActivityNotFoundException không được xử lý. Nếu ngoại lệ này không được bắt trong khối try-catch có cấu trúc, toàn bộ tiến trình ứng dụng chủ sẽ đột ngột kết thúc, dẫn đến lỗi treo nghiêm trọng.

Làm thế nào shouldOverrideUrlLoading chặn được các giao thức Deep Link

Sự phát triển của API: So sánh các overload WebResourceRequest của API 24+ với các callback dạng chuỗi cũ

Callback shouldOverrideUrlLoading của WebViewClient trên Android cho phép ứng dụng chủ kiểm tra và chặn các yêu cầu điều hướng trước khi công cụ web thực thi chúng. Chữ ký và khả năng của phương thức này đã phát triển qua các cấp độ API Android:

  • API 24+ (Android 7.0 trở lên): Ghi đè shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean. Giao diện WebResourceRequest cung cấp siêu dữ liệu ngữ cảnh phong phú, bao gồm URL đích (request.url), phương thức yêu cầu HTTP (request.method), tiêu đề yêu cầu và các cờ phân cấp khung quan trọng (request.isForMainFrame, request.hasGesture()).
  • Overload cũ (Đã loại bỏ trong API 24): Ghi đè shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean. Callback cũ này chỉ nhận chuỗi URL thô mà không có siêu dữ liệu về khung yêu cầu hoặc trạng thái tương tác của người dùng.

Theo Tài liệu tham khảo cho nhà phát triển Android về WebViewClient shouldOverrideUrlLoading, các ứng dụng hiện đại phải thực hiện overload WebResourceRequest. Khi hỗ trợ các phiên bản Android cũ, callback dạng chuỗi đã lỗi thời nên được cấu hình một cách thận trọng để tránh bỏ qua bảo mật.

Tính chất quan trọng về bảo mật của request.isForMainFrame: Chặn việc chiếm quyền điều khiển của subframe và iframe

Kiểm tra khung chính (main-frame) ngăn chặn điều hướng iframe khỏi việc khởi chạy các luồng ứng dụng bên ngoài.

Theo Hướng dẫn của nhà phát triển Android về WebResourceRequest, callback shouldOverrideUrlLoading có thể được kích hoạt cho các subframe, iframe và chuyển hướng tài nguyên nền cũng như các điều hướng tài liệu cấp cao nhất.

Việc thực thi request.isForMainFrame là rất cần thiết cho bảo mật ứng dụng:

  • Cô lập ranh giới khung: Việc kiểm tra request.isForMainFrame đảm bảo rằng các subframe và iframe không thể kích hoạt việc khởi chạy ứng dụng bên ngoài hoặc các intent theo scheme tùy chỉnh. Nếu một subframe cố gắng điều hướng, các tải HTTP(S) tiêu chuẩn sẽ trả về false để tiếp tục kết xuất trong khung, trong khi các scheme không phải HTTP(S) phải trả về true để ngăn chặn thực thi mà không gây ra lỗi.
  • Phân biệt cử chỉ người dùng: Mặc dù isForMainFrame xác nhận yêu cầu áp dụng cho tài liệu chính, nó không chứng minh được sự kích hoạt của người dùng. Nếu kiến trúc của bạn yêu cầu xác minh cử chỉ người dùng, hãy đánh giá request.hasGesture() hoặc thực thi các modal xác nhận ở cấp ứng dụng, lưu ý rằng tài liệu Android chỉ ra một số luồng người dùng hợp lệ mà hasGesture() có thể trả về false.

Phân biệt điều hướng web bình thường với Deep Link: Khi nào nên trả về false so với true

Giá trị trả về kiểu boolean của shouldOverrideUrlLoading kiểm soát vòng đời kết xuất của WebView:

  • Trả về false: Thông báo cho khung Android rằng ứng dụng chủ chưa xử lý URL đó. WebView tiếp tục điều hướng web tiêu chuẩn, tải URL trong vùng chứa Chromium nội bộ của nó. Giá trị trả về này là bắt buộc đối với các trang web http:// và https:// tiêu chuẩn cần hiển thị trong trình duyệt nhúng.
  • Trả về true: Thông báo cho khung rằng ứng dụng chủ đã chặn và xử lý URL đó. WebView ngay lập tức hủy quy trình tải nội bộ của nó. Giá trị trả về này là bắt buộc khi khởi chạy Intent bên ngoài, định tuyến đến một activity gốc hoặc chặn một scheme trái phép.

Xử lý các App Link đã xác minh của ứng dụng chủ bên trong vùng chứa nhúng

Khi một liên kết bên trong WebView trỏ đến tên miền HTTPS đã xác minh của chính ứng dụng chủ (chẳng hạn như https://app.example.com/product/1024), việc thực thi điều hướng mặc định sẽ khiến WebView tải phiên bản web của trang sản phẩm.

Để mang lại trải nghiệm gốc (native), WebViewClient nên kiểm tra các URL HTTPS đến. Nếu host khớp với tên miền liên kết của ứng dụng, client sẽ chặn yêu cầu, xác thực đường dẫn và tham số truy vấn dựa trên trình xác thực tuyến đường nội bộ (AppLinkRouteValidator), và gửi dữ liệu trực tiếp đến bộ định tuyến gốc (internalRouter(validatedRoute)), trả về true để hủy bỏ việc kết xuất web dư thừa.

Cấu trúc các trình xử lý Intent an toàn cho Custom Schemes và Intent URIs

Phân tích cú pháp Chrome Intent URI: Scheme, gói và S.browser_fallback_url

Trên Android, các trang web thường sử dụng Chrome Intent URIs để cấu trúc các deep link gốc với các cơ chế dự phòng trình duyệt tích hợp sẵn. Một Intent URI sử dụng scheme intent:// và mã hóa các tham số định tuyến, đích gói và URL dự phòng thành một chuỗi tuần tự:

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

Như được ghi lại trong Hướng dẫn của Chrome cho nhà phát triển về Android Intent URIs, URI được chia thành các thành phần riêng biệt:

  • Host và Path (product/detail/1024): Định nghĩa đích đến ứng dụng nội bộ.
  • scheme=myapp: Chỉ định giao thức tùy chỉnh cơ bản.
  • package=com.example.app: Hạn chế phân giải intent chỉ dành riêng cho gói Android đã chỉ định.
  • S.browser_fallback_url=...: Một tham số String tùy chọn định nghĩa một URL web hoặc cửa hàng rõ ràng để tải nếu ứng dụng đích chưa được cài đặt.

Sự nguy hiểm của việc thực thi Intent đã phân tích thô: Giảm thiểu lỗ hổng tiêm nhiễm Intent (Intent Injection)

Một mẫu chống đối (anti-pattern) triển khai phổ biến là phân tích URI đến bằng Intent.parseUri(url, Intent.URI_INTENT_SCHEME) và chuyển Intent kết quả trực tiếp cho context.startActivity(intent).

Theo Hướng dẫn kiểm thử bảo mật ứng dụng di động của OWASP về Deep Link không an toàn, việc thực thi các intent đã phân tích thô sẽ khiến ứng dụng bị tấn công Tiêm nhiễm Intent. Một kẻ tấn công kiểm soát trang web có thể tạo ra một Intent URI chứa các đích thành phần tường minh độc hại (component=com.example.app/.InternalAdminActivity), các hành động nhạy cảm hoặc các intent extra đặc quyền (S.is_admin=true). WebView sẽ giải mã các tham số này và khởi chạy các activity không được xuất hoặc nội bộ, bỏ qua các rào cản xác thực.

Các URI intent đã phân tích phải được xác thực và xây dựng lại trước khi gửi đi trên Android.

Tái cấu trúc Intent an toàn: Loại bỏ các thành phần và giới hạn hành động ở ACTION_VIEW

Để loại bỏ các lỗ hổng tiêm nhiễm Intent, các ứng dụng chủ không bao giờ được khởi chạy trực tiếp một Intent đã giải mã thô. Thay vào đó, WebViewClient phải phân tích URI, trích xuất chỉ tải trọng dữ liệu đã xác minh và xây dựng lại một Intent sạch, chuyên dụng:

  1. Trích xuất dữ liệu: Phân tích URI và trích xuất gói đích đã xác minh và URI dữ liệu.
  2. Xác thực gói: Xác minh rằng gói đã khai báo khớp với danh sách cho phép (allowlist) rõ ràng của các đối tác được ủy quyền hoặc các gói nội bộ.
  3. Tái cấu trúc: Khởi tạo một Intent(Intent.ACTION_VIEW, validatedDataUri) mới.
  4. Cô lập: Áp dụng intent.addCategory(Intent.CATEGORY_BROWSABLE) để đảm bảo chỉ các activity có thể duyệt mới có thể giải quyết yêu cầu, đặt các cờ rõ ràng (FLAG_ACTIVITY_NEW_TASK), và đảm bảo intent.component và intent.selector vẫn là null.

Thực thi danh sách cho phép (Allowlist) nghiêm ngặt về Scheme, Host, Path và tham số truy vấn

Cả custom scheme (myapp://) và Intent URI đã tái cấu trúc đều phải thông qua trình xác thực nghiêm ngặt trước khi thực thi.

Việc triển khai tham chiếu này xử lý cụ thể scheme myapp:// của chính ứng dụng chủ, ghim việc thực thi intent một cách rõ ràng cho com.example.app. Đối với các hệ sinh thái đối tác đa ứng dụng, các nhà phát triển nên duy trì một bản đồ scheme-to-package nghiêm ngặt hoặc ưu tiên các HTTPS App Link đã xác minh.

Trình xác thực kiểm tra:

  • Scheme: Khớp với các custom scheme được ủy quyền (myapp).
  • Host: Khớp với các danh mục định tuyến được xác định trước (open, product, event).
  • Path Prefix: Phù hợp với các cấu trúc tuyến đường dự kiến (/detail/, /view/, /main/).
  • Tham số truy vấn: Thực thi các khóa truy vấn được cho phép (target, id, promo, utm_source) và xác thực rằng các giá trị tham số tuân thủ giới hạn độ dài (ví dụ: ≤ 128 ký tự) và tập ký tự chữ và số.
[Người dùng nhấn liên kết bên trong Android WebView]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[Kiểm tra scheme subframe]     [Kiểm tra scheme URL]
├─ HTTP(S) -> trả về false       │
└─ Non-Web -> trả về true   ┌────┴───────────────────────────┐
   (Chặn lỗi)                ▼                                ▼
                     [HTTP / HTTPS]                 [Scheme không phải HTTP(S)]
                            │                                │
                            ▼                                ▼
                   [Kiểm tra Host Domain]              [CustomSchemeValidator]
                   ├─ Internal App Link             ├─ Hợp lệ -> Khởi chạy Intent đã ghim
                   │  -> Trình định tuyến nội bộ       └─ Intent -> Tái cấu trúc & Dự phòng
                   └─ External Web
                      -> Trả về false

Làm thế nào để xử lý quyền hiển thị gói trên Android 11 và các dự phòng trên App Store

Quyền hiển thị gói ảnh hưởng đến các truy vấn kiểm tra trước (preflight), trong khi các lỗi khởi chạy sử dụng các cơ chế dự phòng đã xác thực.

Sự thay đổi về quyền hiển thị gói (Package Visibility) trên Android 11: Tại sao các truy vấn kiểm tra trước yêu cầu khai báo

Bắt đầu từ Android 11 (API cấp 30), Google đã giới thiệu bộ lọc Quyền hiển thị gói để tăng cường quyền riêng tư của người dùng. Theo khuôn khổ này, khả năng nhìn thấy các ứng dụng đã cài đặt khác của một ứng dụng bị hạn chế theo mặc định.

Điều quan trọng là phải hiểu sự khác biệt giữa khởi chạy intent và truy vấn intent:

  • Khởi chạy Intent trực tiếp: Các cuộc gọi tiêu chuẩn đến context.startActivity(intent) không yêu cầu khai báo quyền hiển thị gói. Nếu ứng dụng đích đã được cài đặt, hệ điều hành sẽ định tuyến và khởi chạy thành công bất kể nó có hiển thị với các truy vấn gói hay không.
  • Truy vấn Intent kiểm tra trước (Preflight): Nếu kiến trúc WebViewClient của bạn thực hiện kiểm tra trước—chẳng hạn như gọi packageManager.resolveActivity(intent, 0) hoặc packageManager.queryIntentActivities(intent, 0) để kiểm tra sự hiện diện của ứng dụng trước khi khởi chạy—truy vấn sẽ trả về null hoặc danh sách trống trừ khi gói hoặc scheme đích được khai báo trong AndroidManifest.xml.

Khai báo phần tử <queries> trong AndroidManifest.xml cho các Custom Scheme và đích gói

Theo Hướng dẫn của nhà phát triển Android về Quyền hiển thị gói trên Android 11, các ứng dụng thực hiện kiểm tra trước khi thực thi intent nên khai báo các gói đích hoặc chữ ký intent của chúng trong tệp manifest:

<manifest package="com.example.app">
    <!-- Khai báo truy vấn kiểm tra trước tùy chọn cho việc khám phá gói rõ ràng -->
    <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>

Nếu kiến trúc của bạn ưu tiên các mẫu khởi chạy tiêu chuẩn, việc thực thi startActivity() trong khối try-catch bắt ActivityNotFoundException sẽ cung cấp một triển khai bền bỉ, không phụ thuộc vào truy vấn.

Triển khai chuỗi dự phòng: Định tuyến cửa hàng một cách linh hoạt khi ứng dụng đích không tồn tại

Khi một custom scheme hoặc Intent URI bị chặn không thể khởi chạy do ứng dụng đích chưa được cài đặt trên thiết bị của người dùng, WebViewClient phải thực hiện một chuỗi dự phòng rõ ràng:

  1. Trích xuất và liên kết URL dự phòng: Đối với các URI intent://, trích xuất tham số chuỗi S.browser_fallback_url và xác thực rằng nó trỏ đến một đích đến được ủy quyền. Đối với các URL Google Play, xác minh rằng tham số id khớp chính xác với tên gói đích (id=targetPackage) để ngăn chặn các khai thác chuyển hướng mở.
  2. Tạo URI thị trường (Market): Nếu không có URL dự phòng, hãy xây dựng một intent thị trường: market://details?id=com.example.app.
  3. Dự phòng web Play Store: Nếu Google Play bị vô hiệu hóa hoặc không tồn tại, hãy bắt ngoại lệ ActivityNotFoundException trên intent thị trường và tải https://play.google.com/store/apps/details?id=com.example.app.

Liên kết các tham số ngữ cảnh qua các chuyển hướng dự phòng với OpoInstall

Khi những người dùng chưa cài đặt ứng dụng được chuyển hướng đến Google Play, các liên kết cửa hàng tĩnh sẽ làm mất các tham số động. OpoInstall, một nền tảng phân bổ di động và deep linking, thu hẹp khoảng cách này bằng cách nắm bắt các token giới thiệu và ngữ cảnh chiến dịch trên backend phân bổ trước khi chuyển hướng đến cửa hàng. Hãy xác minh hành vi truy xuất tham số trì hoãn và các API khởi tạo dựa trên phiên bản OpoInstall Android SDK được triển khai trong môi trường sản xuất.

Sau khi cài đặt thành công và khởi chạy ứng dụng lần đầu, OpoInstall Android SDK sẽ truy xuất các tham số đã lưu trong bộ nhớ cache, cho phép ứng dụng chủ khôi phục phân cảnh đích của người dùng. Hãy xem lại tài liệu tích hợp SDK để biết hướng dẫn về việc nhận các tham số trì hoãn sau khi cài đặt.

Triển khai WebViewClient cho môi trường sản xuất để chặn scheme an toàn

Cấu trúc kiến trúc WebViewClient tùy chỉnh trong Kotlin

Một WebViewClient trong môi trường sản xuất sẽ điều phối việc xác minh khung, lập danh sách cho phép (allowlist) cho scheme, tái cấu trúc intent, trích xuất dự phòng và định tuyến nội bộ. Việc triển khai đóng gói các quy tắc xác thực bên trong các đối tượng xác thực chuyên dụng (AppLinkRouteValidator và CustomSchemeValidator) để giữ cho các callback điều hướng được gọn gàng và dễ bảo trì.

Triển khai các mô hình AppLinkRouteValidator và CustomSchemeValidator

Các đối tượng xác thực thực thi bảo mật nghiêm ngặt theo kiểu fail-closed:

  • AppLinkRouteValidator xác thực các tên miền HTTPS nội bộ, các tiền tố đường dẫn được cho phép và các khóa truy vấn được ủy quyền (target, id, promo, utm_source), trả về một lớp dữ liệu ValidatedAppRoute có cấu trúc.
  • CustomSchemeValidator kiểm tra các custom scheme được ủy quyền (myapp), xác thực các host được cho phép (open, product, event), thực thi các tiền tố đường dẫn và làm sạch các giá trị tham số truy vấn dựa trên tập ký tự chữ và số và các ràng buộc độ dài (≤ 128 ký tự).

Việc triển khai mã bên dưới minh họa cách định cấu hình các khai báo quyền hiển thị gói AndroidManifest.xml tùy chọn và triển khai một CustomWebViewClient an toàn, an toàn với luồng (thread-safe) trong Kotlin với khả năng xử lý dự phòng tự động. Các tệp nhị phân SDK được chứng nhận và các trình bao bọc tích hợp có thể được lấy từ trung tâm tải xuống OpoInstall SDK.

<!-- AndroidManifest.xml: Các khai báo quyền hiển thị gói tùy chọn cho các truy vấn Intent kiểm tra trước (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- Chỉ khai báo các gói bên ngoài và custom scheme nếu sử dụng các truy vấn preflight resolveActivity -->
    <queries>
        <!-- Ủy quyền khám phá kiểm tra trước cho các gói đối tác hoặc gói đích cụ thể -->
        <package android:name="com.example.partner" />
        
        <!-- Ủy quyền phân giải kiểm tra trước cho các scheme deep linking tùy chỉnh -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- Ủy quyền phân giải kiểm tra trước cho các tên miền HTTPS App Link đã xác minh -->
        <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">
        <!-- Các thành phần ứng dụng -->
    </application>
</manifest>
// Android: Custom WebViewClient với bảo mật ranh giới khung, xác thực Allowlist và định tuyến dự phòng
// Ví dụ tích hợp tham chiếu; xác minh chữ ký phương thức dựa trên kiến trúc Android đã triển khai.
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) {
            // Tư thế bảo mật nghiêm ngặt: từ chối yêu cầu nếu tồn tại các khóa truy vấn không xác định
            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

        // Yêu cầu bảo mật: Hạn chế định tuyến ứng dụng bên ngoài chỉ đối với các điều hướng khung chính (main frame)
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // Cho phép tải subframe HTTP(S) tiêu chuẩn, nhưng chặn các scheme không phải web để ngăn chặn hijack iframe & lỗi
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "Đã chặn scheme không phải web trong subframe/iframe: $scheme")
                true // Tiêu thụ/hủy mà không khởi chạy intent bên ngoài hoặc hiển thị lỗi Chromium
            }
        }

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

    @Deprecated("Deprecated in Java")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

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

        // Callback cũ thiếu siêu dữ liệu isForMainFrame đáng tin cậy; không định tuyến các scheme bên ngoài
        return if (scheme == "http" || scheme == "https") {
            false // Cho phép tải nội dung web tiêu chuẩn
        } else {
            Log.w("WebViewClient", "Đã chặn scheme bên ngoài trên callback cũ: $scheme")
            true // Ngăn chặn các lỗi scheme không được xử lý mà không khởi chạy intent bên ngoài
        }
    }

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

        // 1. Xử lý HTTP / HTTPS và Internal App Links
        if (scheme == "http" || scheme == "https") {
            // Kiểm tra xem URL có khớp với tên miền App Link của ứng dụng chủ không
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "Đã xác thực tuyến HTTPS nội bộ. Đang gửi đến bộ định tuyến nội bộ.")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "Đã từ chối App Link nội bộ không hợp lệ hoặc trái phép: $uri")
                }
                return true // Đã xử lý nội bộ; hủy tải webview
            }
            return false // Cho phép WebView tải nội dung web tiêu chuẩn một cách tự nhiên
        }

        // 2. Xác thực custom scheme với allowlist nghiêm ngặt và ghim gói để ngăn chặn hijack
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // Ghim rõ ràng gói ứng dụng chủ
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        // 3. Xử lý Chrome Intent URIs với tái cấu trúc an toàn và trích xuất dự phòng
        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) {
                            // Tái cấu trúc một ACTION_VIEW Intent sạch, chuyên dụng thay vì thực thi đối tượng đã phân tích thô
                            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", "Đã từ chối intent với URI dữ liệu không hợp lệ: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "Đã từ chối intent thiếu tải trọng URI dữ liệu")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "Đã từ chối intent nhắm mục tiêu vào gói chưa xác minh: $targetPackage")
                    true // Hủy intent gói chưa xác minh
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "Không thể phân tích Intent URI: $url", e)
                true // Ngăn chặn các lỗi scheme không được xử lý
            }
        }

        // Từ chối các scheme không xác định để tránh lỗi net::ERR_UNKNOWN_URL_SCHEME
        Log.w("WebViewClient", "Đã chặn điều hướng scheme chưa xác minh: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "Ứng dụng mục tiêu chưa được cài đặt: ${intent.data}. Đang kích hoạt dự phòng.")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "Ngoại lệ bảo mật khi khởi chạy intent: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            Log.i("WebViewClient", "Đang định tuyến đến URL dự phòng trình duyệt được ủy quyền: $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", "Ứng dụng Play Store không tồn tại. Đang tải URL cửa hàng web: $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" -> {
                // Thực thi rằng URL dự phòng Google Play trỏ cụ thể đến gói mục tiêu
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                // Xác thực tuyến dự phòng tên miền sở hữu
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

Thực thi an toàn với luồng (Thread-Safe): Gửi các tuyến nội bộ và quản lý trạng thái vòng đời

Khi AppLinkRouteValidator xác nhận một tuyến nội bộ hợp lệ, các chuyển đổi UI phải thực thi trên luồng giao diện người dùng chính của Android. Nếu internalRouter kích hoạt các fragment, chuyển đổi hoạt động hoặc điều hướng Compose, hãy gói lệnh gọi gửi bên trong runOnUiThread hoặc một coroutine vòng đời luồng chính để ngăn chặn lỗi treo luồng.

Ma trận giải pháp chẩn đoán và lỗi chuyển hướng Android WebView

Hướng dẫn khắc phục sự cố Deep Linking trong Android WebView toàn diện

Ma trận dưới đây chi tiết các chữ ký lỗi phổ biến gặp phải khi xử lý các custom scheme trong Android WebViews, cùng với các nguyên nhân gốc rễ chính và các biện pháp khắc phục kỹ thuật:

Chữ ký lỗi / Triệu chứng Nguyên nhân gốc rễ chính API bị ảnh hưởng Điểm chẩn đoán Khắc phục khuyến nghị
net::ERR_UNKNOWN_URL_SCHEME Scheme không phải web không được xử lý trong trình tải WebView Tất cả các API shouldOverrideUrlLoading bị thiếu hoặc trả về false Ghi đè delegate, chặn scheme, trả về true
ActivityNotFoundException Ứng dụng mục tiêu không tồn tại trên thiết bị Tất cả các API context.startActivity() không được xử lý Bắt ngoại lệ và thực thi S.browser_fallback_url hoặc tuyến Play Store
Iframe Intent Hijacking Subframe kích hoạt intent bên ngoài API 24+ request.isForMainFrame không được kiểm tra Bảo vệ bằng if (!request.isForMainFrame) và chặn các scheme không phải web
Truy vấn kiểm tra trước trả về Null Bộ lọc quyền hiển thị gói trên Android 11+ API 30+ (Android 11+) Gọi resolveActivity mà không khai báo manifest Khai báo <queries> trong manifest hoặc sử dụng startActivity với try-catch
Vòng lặp tải lại Internal App Link App Link của ứng dụng chủ được định tuyến lại đến WebView Tất cả các API Tên miền chủ không được xử lý trong delegate Chặn tên miền của riêng mình, xác thực tuyến, gửi đến bộ định tuyến gốc

Các câu hỏi thường gặp (FAQ)

Tại sao Android WebView của tôi bị treo khi mở các custom URL scheme?
Một custom scheme gây ra lỗi `net::ERR_UNKNOWN_URL_SCHEME` khi URL vẫn nằm trong quy trình tải của WebView thay vì bị ứng dụng chủ chặn lại. Với một `WebViewClient` tùy chỉnh, hãy trả về `true` sau khi xử lý hoặc chặn an toàn một scheme không phải web; chỉ trả về `false` khi WebView nên tiếp tục điều hướng web bình thường. Nếu ứng dụng đích không tồn tại, việc gọi `startActivity()` mà không có khối `try-catch` sẽ gây ra ngoại lệ `ActivityNotFoundException` không được xử lý.
Làm thế nào để xử lý an toàn các URL intent:// trong WebView mà không gây ra lỗ hổng bảo mật?
Để xử lý an toàn các URI `intent://`, hãy phân tích URI bằng `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`, xác thực rằng gói đích và URI dữ liệu khớp với danh sách cho phép nghiêm ngặt, và xây dựng một `Intent(Intent.ACTION_VIEW, safeDataUri)` mới, sạch sẽ. Thêm rõ ràng `Intent.CATEGORY_BROWSABLE` để đảm bảo chỉ các activity có thể duyệt mới xử lý intent, loại bỏ các thành phần nguy hiểm và triển khai xử lý URL dự phòng đã xác thực gắn với gói mục tiêu.
Quyền hiển thị gói trên Android 11 ảnh hưởng như thế nào đến việc chặn deep link trong WebView?
Quyền hiển thị gói trên Android 11 không ngăn `startActivity()` khởi chạy các activity bên ngoài tương ứng. Tuy nhiên, nếu `WebViewClient` của bạn thực hiện kiểm tra trước bằng `resolveActivity()` hoặc `queryIntentActivities()` trước khi cố gắng khởi chạy, bạn phải khai báo tên gói đích hoặc custom intent scheme bên trong phần tử `<queries>` trong tệp `AndroidManifest.xml` của bạn.

Tóm tắt và Khung quyết định

Việc xử lý deep link và các custom scheme bên trong Android WebView đòi hỏi sự kết nối giữa các sandbox kết xuất web và định tuyến intent của hệ điều hành gốc. Việc dựa vào hành vi mặc định của WebView dẫn đến các lỗi giao thức không được xử lý, trải nghiệm người dùng kém và các lỗ hổng bảo mật tiềm ẩn.

Bằng cách triển khai một WebViewClient mạnh mẽ giúp xác minh ranh giới khung, xác thực custom scheme dựa trên danh sách cho phép rõ ràng, tái cấu trúc các intent ACTION_VIEW an toàn và xử lý dự phòng cửa hàng một cách linh hoạt, các nhóm kỹ thuật sẽ giảm thiểu các lỗi xử lý scheme của WebView và hỗ trợ điều hướng bền bỉ hơn. Việc tích hợp định tuyến phía client đáng tin cậy với các khung phân bổ di động toàn diện sẽ giúp giảm bớt ma sát điều hướng xuyên suốt các luồng phân bổ và hỗ trợ các hành trình người dùng nhất quán hơn.

Để khám phá các kiến trúc định tuyến tham số và deep linking gốc trên Android, hãy tham khảo tài liệu tích hợp SDK, tải xuống các thư viện client từ trung tâm tải xuống OpoInstall SDK, khám phá tài liệu tham khảo triển khai phân bổ di động hoặc đăng ký ứng dụng của bạn trên bảng điều khiển dành cho nhà phát triển OpoInstall.

Tài liệu liên quan

Share this article