Universal Links và App Links
Bài này đi từ một ví dụ cụ thể trước: dựng một link chạy được trên cả hai nền tảng. Sau đó mới quay lại giải thích vì sao phải làm từng bước như vậy.
Ví dụ: link sản phẩm của app MyShop
Phần tiêu đề “Ví dụ: link sản phẩm của app MyShop”Kịch bản
Phần tiêu đề “Kịch bản”Bạn làm app bán hàng MyShop, có cả website https://shop.example.com. Một người dùng gửi cho bạn bè link sản phẩm qua tin nhắn:
https://shop.example.com/products/123Mong muốn:
| Tình huống | Kết quả mong muốn |
|---|---|
| Người nhận đã cài app MyShop | Bấm link mở thẳng màn hình sản phẩm 123 trong app, không qua trình duyệt, không hỏi “Mở bằng ứng dụng nào?” |
| Người nhận chưa cài app | Link mở trang sản phẩm trên web như bình thường |
| Người nhận mở trên máy tính | Link mở trang web như bình thường |
Chỉ cần một link duy nhất cho mọi trường hợp. Đó chính là thứ Universal Links (iOS) và App Links (Android) mang lại.
Thông tin của app trong ví dụ:
- Android package name:
com.example.myshop - iOS bundle ID:
com.example.myshop, Apple Team ID:ABCDE12345 - Chỉ các link
/products/...và/orders/...mở app; các trang khác (blog, chính sách…) vẫn mở web.
Bước 1: Đặt 2 file xác minh lên website
Phần tiêu đề “Bước 1: Đặt 2 file xác minh lên website”Website phải “xác nhận” rằng app MyShop được phép mở link của domain này. Mỗi nền tảng đọc một file riêng, cùng nằm trong thư mục /.well-known/:
https://shop.example.com/.well-known/assetlinks.json ← Android đọchttps://shop.example.com/.well-known/apple-app-site-association ← iOS đọc (không có đuôi .json)assetlinks.json (Android):
[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.myshop", "sha256_cert_fingerprints": [ "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" ] } }]sha256_cert_fingerprints là fingerprint SHA-256 của key ký app. Nếu app phát hành qua Google Play thì lấy ở trang Play App Signing, mục App signing key certificate, không phải upload key (xem bài Google Play App Signing). Có thể thêm cả fingerprint của debug key vào mảng để test lúc dev.
apple-app-site-association (iOS):
{ "applinks": { "details": [ { "appIDs": ["ABCDE12345.com.example.myshop"], "components": [ { "/": "/products/*", "comment": "Trang sản phẩm" }, { "/": "/orders/*", "comment": "Trang đơn hàng" } ] } ] }}appIDs có dạng <Team ID>.<Bundle ID>. components liệt kê các đường dẫn được mở bằng app.
Yêu cầu chung cho cả hai file:
- Phục vụ qua HTTPS với chứng chỉ hợp lệ.
- Trả về HTTP 200 trực tiếp, không redirect (kể cả redirect
http→httpshayshop.example.com→www.shop.example.com). Content-Type: application/json.- Không yêu cầu đăng nhập, không bị chặn bởi tường lửa/chống bot.
Kiểm tra nhanh:
curl -i https://shop.example.com/.well-known/assetlinks.jsoncurl -i https://shop.example.com/.well-known/apple-app-site-associationBước 2: Khai báo trong app Android
Phần tiêu đề “Bước 2: Khai báo trong app Android”Trong AndroidManifest.xml (với Flutter là android/app/src/main/AndroidManifest.xml), thêm intent-filter vào activity chính:
<activity android:name=".MainActivity" ...> <!-- ... intent-filter MAIN/LAUNCHER sẵn có ... -->
<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:pathPrefix="/products/" /> <data android:pathPrefix="/orders/" /> </intent-filter></activity>android:autoVerify="true" là thứ biến một deep link thường thành App Link: khi cài app, Android sẽ tải assetlinks.json để xác minh domain. Chi tiết về thuộc tính này xem bài Thuộc tính android:autoVerify.
Bước 3: Khai báo trong app iOS
Phần tiêu đề “Bước 3: Khai báo trong app iOS”Trong Xcode: chọn target → Signing & Capabilities → + Capability → Associated Domains, thêm:
applinks:shop.example.comXcode sẽ ghi vào file entitlements (với Flutter là ios/Runner/Runner.entitlements):
<key>com.apple.developer.associated-domains</key><array> <string>applinks:shop.example.com</string></array>App ID trên Apple Developer cũng phải bật capability Associated Domains, và provisioning profile phải được tạo lại sau khi bật (nếu dùng automatic signing thì Xcode tự động quản lý và tải về nếu cần). Chi tiết về capability này (các service khác như tự điền mật khẩu, passkey, CDN của Apple, alternate mode) xem bài Associated Domains.
Bước 4: Xử lý link trong app
Phần tiêu đề “Bước 4: Xử lý link trong app”Đến đây hệ điều hành đã biết mở app khi người dùng bấm link. Việc còn lại là app đọc URL và điều hướng tới đúng màn hình.
Flutter với go_router: từ Flutter 3.27, Flutter tự nhận deep link và chuyển URL cho router, nên chỉ cần khai báo route trùng với path:
final router = GoRouter( routes: [ GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute( path: '/products/:id', builder: (context, state) => ProductScreen(id: state.pathParameters['id']!), ), GoRoute( path: '/orders/:id', builder: (context, state) => OrderScreen(id: state.pathParameters['id']!), ), ],);Android native: đọc intent.data trong onCreate (app đang tắt) và onNewIntent (app đang chạy):
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleLink(intent)}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleLink(intent)}
private fun handleLink(intent: Intent) { val uri = intent.data ?: return // https://shop.example.com/products/123 val productId = uri.pathSegments.getOrNull(1) // điều hướng tới màn hình sản phẩm}iOS native (SwiftUI):
WindowGroup { ContentView() .onOpenURL { url in // url = https://shop.example.com/products/123 // điều hướng tới màn hình sản phẩm }}Với UIKit, link đến qua NSUserActivity có activityType == NSUserActivityTypeBrowsingWeb, trong scene(_:willConnectTo:options:) (app đang tắt) và scene(_:continue:) (app đang chạy); URL nằm ở userActivity.webpageURL.
Bước 5: Thử link
Phần tiêu đề “Bước 5: Thử link”# Android: giả lập bấm linkadb shell am start -a android.intent.action.VIEW \ -c android.intent.category.BROWSABLE \ -d "https://shop.example.com/products/123"
# iOS Simulatorxcrun simctl openurl booted "https://shop.example.com/products/123"Trên máy thật, cách thử đáng tin nhất là gửi link vào một ứng dụng ghi chú hoặc tin nhắn rồi bấm vào. Không gõ link vào thanh địa chỉ của trình duyệt (lý do ở phần lưu ý).
Xong ví dụ. Phần dưới giải thích vì sao lại cần đủ các bước trên.
Lý thuyết
Phần tiêu đề “Lý thuyết”Deep link, custom scheme và link https
Phần tiêu đề “Deep link, custom scheme và link https”Deep link là link mở thẳng vào một màn hình cụ thể trong app, thay vì chỉ mở màn hình đầu. Có hai cách làm:
1. Custom URL scheme, ví dụ myshop://products/123. Cách cũ, khai báo đơn giản, nhưng có nhiều nhược điểm:
- Chưa cài app thì link chết: trình duyệt báo lỗi hoặc không làm gì.
- Không mở được trên máy tính, không chia sẻ được như link web bình thường.
- Không ai sở hữu scheme. App nào cũng khai báo được
myshop://. Một app độc hại có thể đăng ký cùng scheme để chặn link (ví dụ link chứa token đăng nhập hay mã OAuth). - Android thường hiện hộp thoại hỏi chọn app; iOS hiện hộp thoại “Mở trong MyShop?”.
2. Link https đã xác minh domain: đó là Universal Links trên iOS và Android App Links trên Android. Link là URL web bình thường, nên:
- Có app thì mở app, không có app thì mở web. Một link dùng được ở mọi nơi.
- Chỉ chủ domain mới gắn được app với link, vì phải đặt file xác minh lên chính domain đó. App khác không thể giả mạo.
- Mở thẳng vào app, không hỏi.
Custom scheme vẫn có chỗ dùng: callback từ SDK bên thứ ba, mở app từ app khác trong cùng hệ sinh thái… Nhưng với link cho người dùng chia sẻ, link trong email, thông báo, quảng cáo, nên dùng Universal Links / App Links.
Cơ chế xác minh: liên kết hai chiều
Phần tiêu đề “Cơ chế xác minh: liên kết hai chiều”Ý tưởng cốt lõi của cả hai nền tảng giống nhau: liên kết phải được khai báo ở cả hai phía.
App ──── "tôi muốn mở link của shop.example.com" ────▶ Website (intent-filter autoVerify / Associated Domains)
App ◀──── "tôi cho phép app có ID này mở link" ───── Website (assetlinks.json / apple-app-site-association)App tự khai báo thì ai cũng làm được; file trên website chứng minh chủ domain đồng ý. Hệ điều hành chỉ coi link là đã xác minh khi cả hai phía khớp nhau.
Android App Links
Phần tiêu đề “Android App Links”Khi nào xác minh: khi cài app hoặc cập nhật app, hệ thống tải assetlinks.json của từng host khai báo trong intent-filter có autoVerify="true", rồi so package_name và fingerprint chứng chỉ ký app với file.
Khác biệt theo phiên bản:
- Android 11 trở xuống: nếu một host bất kỳ trong app xác minh thất bại thì tất cả đều thất bại. Link khi đó rơi về chế độ deep link thường (hiện hộp thoại chọn app).
- Android 12 trở lên: xác minh riêng từng host. Link https chưa xác minh mặc định mở trình duyệt, không còn hộp thoại chọn app. Nghĩa là trên máy mới, cấu hình sai thì link luôn mở web, không có dấu hiệu gì báo lỗi.
- Android 15 trở lên (có Google services): Dynamic App Links. Có thể khai báo luật đường dẫn ngay trong
assetlinks.jsonthay vì trong manifest, đổi luật mà không cần phát hành bản app mới:
[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.myshop", "sha256_cert_fingerprints": ["14:6D:E9:..."] }, "relation_extensions": { "delegate_permission/common.handle_all_urls": { "dynamic_app_link_components": [ { "/": "/products/*/reviews", "exclude": true }, { "/": "/products/*" }, { "/": "/orders/*" } ] } } }]Luật xét theo thứ tự, gặp luật khớp đầu tiên là dừng; không luật nào khớp thì link mở web. Luật động chỉ thu hẹp được phạm vi đã khai báo trong manifest, không mở rộng được. Vì vậy cách làm được khuyên dùng là manifest chỉ khai báo scheme và host, còn chia đường dẫn thì để ở assetlinks.json. Thiết bị Android 14 trở xuống bỏ qua phần này và dùng luật trong manifest.
Người dùng có thể tắt: trong Settings → Apps → MyShop → Open by default, người dùng có thể tắt việc mở link bằng app.
iOS Universal Links
Phần tiêu đề “iOS Universal Links”Khi nào xác minh: khi cài hoặc cập nhật app. Từ iOS 14, thiết bị không tải file trực tiếp từ server của bạn mà lấy qua CDN của Apple. CDN này định kỳ tải apple-app-site-association từ website và lưu cache. Hệ quả:
- Server phải truy cập được từ server của Apple trên Internet (domain nội bộ, staging sau VPN sẽ không chạy).
- Sửa file trên server thì cần thời gian để CDN cập nhật, không có hiệu lực ngay.
Có thể xem bản Apple đang cache:
curl -i https://app-site-association.cdn-apple.com/a/v1/shop.example.comDeveloper mode: khi dev với domain nội bộ hoặc muốn bỏ qua cache của CDN, thêm ?mode=developer vào entitlement (applinks:staging.example.com?mode=developer) và bật Settings → Developer → Associated Domains Development trên máy test. Khi đó thiết bị tải file trực tiếp từ server. Chế độ này chỉ áp dụng cho bản build dev.
Cú pháp components: ngoài "/" (path), còn có "?" (query) và "#" (fragment), và "exclude": true để loại trừ. Cũng xét theo thứ tự, luật khớp đầu tiên thắng:
"components": [ { "/": "/products/*/reviews", "exclude": true }, { "/": "/products/*" }, { "/": "/search", "?": { "q": "?*" } }]Các file AASA cũ dùng "apps": [] và "paths": [...]. Định dạng đó vẫn được hỗ trợ cho iOS đời cũ, nhưng app mới nên dùng appIDs và components.
So sánh nhanh
Phần tiêu đề “So sánh nhanh”| Android App Links | iOS Universal Links | |
|---|---|---|
| File trên web | /.well-known/assetlinks.json | /.well-known/apple-app-site-association |
| Khai báo trong app | intent-filter với autoVerify="true" | Capability Associated Domains, applinks:<domain> |
| Định danh app trong file | Package name + SHA-256 chứng chỉ ký app | Team ID + Bundle ID |
| Ai tải file | Thiết bị (qua Google services) | CDN của Apple |
| Luật đường dẫn | Manifest; từ Android 15 có thể đặt trong file (Dynamic App Links) | Trong file AASA |
| Đổi luật đường dẫn không cần ra bản app mới | Có, với Dynamic App Links trên Android 15+ | Có |
Lưu ý và lỗi hay gặp
Phần tiêu đề “Lưu ý và lỗi hay gặp”Fingerprint sai key
Phần tiêu đề “Fingerprint sai key”Lỗi số một với Android: đặt SHA-256 của upload key hoặc debug key vào assetlinks.json, trong khi bản trên Google Play ký bằng app signing key. Bản build local thì chạy, bản tải từ Play Store thì luôn mở web. Lấy đúng fingerprint ở trang Play App Signing. Play Console cũng có trang Deep links cho từng app, liệt kê domain và trạng thái xác minh, và có thể sinh sẵn nội dung assetlinks.json đúng.
Redirect và cấu hình server
Phần tiêu đề “Redirect và cấu hình server”- Redirect bất kỳ cho file xác minh đều làm xác minh thất bại. Hay gặp nhất: domain gốc redirect sang
www, hoặc server tự thêm dấu/vào cuối. - Dịch vụ chống bot (như chế độ chặn bot của CDN/WAF) có thể chặn request của Google hoặc Apple mà không ai biết. Nên bỏ chặn cho đường dẫn
/.well-known/. example.comvàwww.example.comlà hai host khác nhau. Muốn cả hai mở app thì khai báo cả hai, và cả hai đều phải phục vụ file xác minh.- Mỗi subdomain cần file riêng. iOS cho khai báo wildcard
applinks:*.example.comnhưng mỗi subdomain vẫn phải có file của nó.
Vì sao link không mở app trên iOS dù cấu hình đúng?
Phần tiêu đề “Vì sao link không mở app trên iOS dù cấu hình đúng?”Universal Links chỉ kích hoạt khi người dùng bấm vào link. Các trường hợp sau sẽ mở web, không phải lỗi:
- Gõ hoặc dán link vào thanh địa chỉ Safari.
- Bấm link trỏ tới cùng domain khi đang ở trên trang web của chính domain đó trong Safari (Apple coi là người dùng muốn ở lại web).
- Người dùng từng chọn mở bằng Safari (nhấn giữ link → Open in Safari, hoặc bấm vào tên domain ở góc phải thanh trạng thái khi app vừa mở). iOS ghi nhớ lựa chọn này cho domain. Để bật lại: nhấn giữ link và chọn Open in “MyShop”.
- Một số trình duyệt nhúng trong app (in-app browser của các mạng xã hội, ứng dụng nhắn tin) tự mở link trong WebView của nó thay vì chuyển cho hệ điều hành.
- Link được gọi qua JavaScript chuyển trang tự động, không do người dùng bấm.
Vì những điều trên, trang web nên có nút “Mở trong app” làm phương án dự phòng.
Kiểm tra trạng thái xác minh trên Android
Phần tiêu đề “Kiểm tra trạng thái xác minh trên Android”# Xem trạng thái xác minh từng domain của appadb shell pm get-app-links com.example.myshop
# Yêu cầu xác minh lại (sau khi sửa assetlinks.json)adb shell pm verify-app-links --re-verify com.example.myshopDomain ở trạng thái verified là đạt. Nếu là none hoặc mã lỗi, kiểm tra lại file trên server. Có thể kiểm tra file bằng API Digital Asset Links của Google:
https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://shop.example.com&relation=delegate_permission/common.handle_all_urlsAndroid Studio cũng có App Links Assistant (Tools → App Links Assistant) giúp sinh intent-filter, sinh file assetlinks.json và kiểm tra.
Flutter: tránh xung đột khi dùng plugin deep link
Phần tiêu đề “Flutter: tránh xung đột khi dùng plugin deep link”Từ Flutter 3.27, cơ chế deep link có sẵn của Flutter được bật mặc định. Nếu dự án dùng plugin xử lý deep link riêng (app_links, Branch, AppsFlyer, Adjust…), hai bên sẽ cùng nhận link và có thể điều hướng hai lần hoặc lỗi. Khi đó tắt cơ chế của Flutter:
<!-- Android: AndroidManifest.xml, trong thẻ <activity> --><meta-data android:name="flutter_deeplinking_enabled" android:value="false" /><!-- iOS: Info.plist --><key>FlutterDeepLinkingEnabled</key><false/>Firebase Dynamic Links đã ngừng hoạt động
Phần tiêu đề “Firebase Dynamic Links đã ngừng hoạt động”Firebase Dynamic Links đã ngừng dịch vụ từ ngày 25/8/2025. Các link dạng *.page.link không còn hoạt động. App nào còn dùng thì phải chuyển sang Universal Links / App Links trên domain của mình, hoặc dùng dịch vụ bên thứ ba nếu cần các tính năng như deferred deep link (mở đúng màn hình sau khi người dùng cài app lần đầu từ link).
Không tin tưởng dữ liệu trong link
Phần tiêu đề “Không tin tưởng dữ liệu trong link”Link ai cũng tạo được, kể cả khi domain đã xác minh. Xử lý URL trong app như dữ liệu đầu vào không đáng tin: kiểm tra tham số, không thực hiện hành động nhạy cảm (thanh toán, đổi mật khẩu, xoá dữ liệu) chỉ vì mở một link mà không có bước xác nhận của người dùng, và luôn kiểm tra đăng nhập trước khi hiển thị màn hình cần đăng nhập.