Bỏ qua để đến nội dung

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.

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/123

Mong muốn:

Tình huốngKết quả mong muốn
Người nhận đã cài app MyShopBấ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 appLink mở trang sản phẩm trên web như bình thường
Người nhận mở trên máy tínhLink 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.

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 đọc
https://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 → https hay shop.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:

Terminal window
curl -i https://shop.example.com/.well-known/assetlinks.json
curl -i https://shop.example.com/.well-known/apple-app-site-association

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.

Trong Xcode: chọn target → Signing & Capabilities → + Capability → Associated Domains, thêm:

applinks:shop.example.com

Xcode 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.

Đế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.

Terminal window
# Android: giả lập bấm link
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://shop.example.com/products/123"
# iOS Simulator
xcrun 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.

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.

Ý 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.

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.json thay 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.

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:

Terminal window
curl -i https://app-site-association.cdn-apple.com/a/v1/shop.example.com

Developer 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.

Android App LinksiOS Universal Links
File trên web/.well-known/assetlinks.json/.well-known/apple-app-site-association
Khai báo trong appintent-filter với autoVerify="true"Capability Associated Domains, applinks:<domain>
Định danh app trong filePackage name + SHA-256 chứng chỉ ký appTeam ID + Bundle ID
Ai tải fileThiết bị (qua Google services)CDN của Apple
Luật đường dẫnManifest; 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ớiCó, với Dynamic App Links trên Android 15+Có

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 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.com và www.example.com là 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.com nhưng mỗi subdomain vẫn phải có file của nó.
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.

Terminal window
# Xem trạng thái xác minh từng domain của app
adb 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.myshop

Domain ở 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_urls

Android Studio cũng có App Links Assistant (Tools → App Links Assistant) giúp sinh intent-filter, sinh file assetlinks.json và kiểm tra.

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/>
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).

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.