Tích hợp Li2 SDK (iOS Swift Package) hoặc HTTP API trực tiếp để short link Li2 mở app của bạn, gồm cả deferred deep linking qua clipboard (iOS) và Google Play Install Referrer (Android).
Bạn sẽ học: Cách tích hợp Li2 SDK (iOS, Swift Package) hoặc gọi thẳng HTTP API cho cả hai luồng immediate (app đã cài, Universal Link / App Link) và deferred (app chưa cài, clipboard / Install Referrer). Mọi lời gọi mạng đều tới POST /api/v1/track/open.Tab iOS dùng Li2 SDK + SwiftUI (có phần UIKit riêng). Tab Android dùng Jetpack Compose và HTTP API trực tiếp.
Khuyến nghị. Li2 cung cấp Li2 SDK chính thức — Swift Package, iOS 15+, hỗ trợ cả SwiftUI và UIKit. SDK gói sẵn: lời gọi /track/open, cổng “một lần mỗi lần cài”, cửa sổ chờ 250ms, Li2PasteButton (đọc clipboard không bật alert “Allow Paste”), và việc bóc li2_cid khỏi URL đích. Bạn chỉ viết phần điều hướng và màn hình đồng ý.
Trong Xcode: File ▸ Add Package Dependencies… và nhập URL:
https://github.com/QQuik/li2-swift-sdk
Ghim Up to Next Major Version từ 0.1.0. Thêm library product Li2SDK vào app target.Yêu cầu: iOS 15.0+, Xcode 15+. Li2PasteButton (đọc clipboard không alert) yêu cầu iOS 16; iOS 15 tự động fallback sang beginRawProbeOptIn() (xem phần màn hình đồng ý bên dưới).
import SwiftUIimport Li2SDK@mainstruct MyApp: App { init() { Li2.configure( publishableKey: "li2_pk_your_key", deepLinkDomains: ["app.example.com"] ) } @StateObject private var deepLinks = DeepLinkModel() var body: some Scene { WindowGroup { RootView() .environmentObject(deepLinks) .li2DeepLink(using: deepLinks.resolver) // nối UL + deferred grace .fullScreenCover(isPresented: $deepLinks.showConsent) { ConsentSheet(resolver: deepLinks.resolver) } } }}
Modifier .li2DeepLink(using:) tự nối .onOpenURL, .onContinueUserActivity, và .task { requestFirstLaunchConsentAfterGrace() } — bạn không cần viết lại ba dòng đó.
SDK gọi closure onOutcome cho mọi kết quả. Bạn điều hướng — SDK không bao giờ tự điều hướng.
import SwiftUIimport Combineimport Li2SDK@MainActorfinal class DeepLinkModel: ObservableObject { @Published var route: URL? @Published var showConsent = false private(set) lazy var resolver = Li2DeepLinkResolver { [weak self] outcome in switch outcome { case let .matched(destination, clickId): // Điều hướng theo destination (đã bóc li2_cid). self?.route = destination case let .missed(reason): // Không khớp link nào — hiển thị màn hình chính bình thường. break case let .failed(error): // Lỗi mạng / HTTP / parse — xử lý tùy app. break } } private var bag = Set<AnyCancellable>() init() { // Phản chiếu cờ đồng ý của SDK để điều khiển sheet. resolver.$isConsentPending .receive(on: RunLoop.main) .sink { [weak self] in self?.showConsent = $0 } .store(in: &bag) }}
Outcome
Ý nghĩa
.matched(destination, clickId)
Khớp link; điều hướng tới destination (đã bóc li2_cid)
3. Màn hình đồng ý (copy-paste — đã kiểm chứng trên thiết bị)
Màn hình này là phần bạn tự sở hữu (SDK không cung cấp sẵn UI). Dưới đây là pattern hoàn chỉnh đã được kiểm chứng. Hai chi tiết được đánh dấu không thể bỏ qua — bỏ là sinh bug.
import SwiftUIimport UIKitimport Li2SDKstruct ConsentSheet: View { let resolver: Li2DeepLinkResolver @Environment(\.scenePhase) private var scenePhase /// nil = đang probe, true/false = clipboard có nội dung hay không. @State private var clipboardHasContent: Bool? var body: some View { VStack(spacing: 24) { Spacer() Text("Chào mừng").font(.largeTitle.bold()) Text("Bạn đến từ một link chia sẻ. Tiếp tục để mở đúng nội dung đó.") .multilineTextAlignment(.center) Spacer() primaryButton Button("Bỏ qua") { resolver.submitOptOut() } } .padding(24) .onAppear(perform: probeClipboard) // (b) Re-probe khi foreground: người dùng có thể tắt app, copy link, quay lại. // Không có dòng này, trạng thái nút sẽ bị cũ. .onChange(of: scenePhase) { phase in if phase == .active { probeClipboard() } } } // Chỉ kiểm tra sự có mặt (hasStrings/hasURLs) — không bao giờ bật alert hệ thống. private func probeClipboard() { clipboardHasContent = UIPasteboard.general.hasStrings || UIPasteboard.general.hasURLs } @ViewBuilder private var primaryButton: some View { if #available(iOS 16.0, *) { // iOS 16+: dùng Li2PasteButton — cú chạm của người dùng là sự đồng ý, // iOS không hiện alert "Allow Paste". Li2PasteButton { raw in resolver.submitPasteControlResult(raw) } .frame(height: 52) } else { // iOS 15: beginRawProbeOptIn() đọc clipboard trực tiếp. // iOS sẽ hiện alert "Allow Paste" hoặc banner — đây là hành vi hệ thống // trên iOS 15, không tránh được. Button("Tiếp tục") { resolver.beginRawProbeOptIn() } } }}
beginRawProbeOptIn() có thể dùng trên iOS 16+ không? Có — nhưng iOS sẽ hiện alert “Allow Paste / Don’t Allow”. Dùng Li2PasteButton trên iOS 16+ để tránh alert đó: cú chạm vào nút Paste của hệ thống chính là sự đồng ý, nên iOS bỏ qua bước hỏi thêm.
(a) Xử lý clipboard rỗng: Khi clipboard không có gì, Li2PasteButton tự disable — người dùng không thể chạm. Nếu bạn muốn cung cấp lối thoát rõ ràng (thay vì chỉ có nút “Bỏ qua”), hãy thêm một nhánh kiểm tra clipboardHasContent == false và hiện nút “Tiếp tục” gọi resolver.submitPasteControlEmpty(). Ví dụ:
(a) Empty-clipboard affordance — Li2PasteButton tự disable khi clipboard rỗng, nên nếu không có nút thay thế, người dùng clipboard rỗng bị kẹt và outcome empty không bao giờ được gửi.
(b) scenePhase re-probe — clipboard có thể thay đổi trong khi sheet đang mở (người dùng chuyển sang Safari, copy link, quay lại). Probe chỉ trong onAppear sẽ hiện trạng thái nút bị cũ.
Modifier .li2DeepLink(using:) chỉ dành cho SwiftUI. Với UIKit, nối thủ công từ scene delegate:
import UIKitimport Li2SDKfinal class SceneDelegate: UIResponder, UIWindowSceneDelegate { let resolver = Li2DeepLinkResolver { outcome in // switch outcome — điều hướng như ví dụ SwiftUI } // Universal Link cold launch / continuation func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } resolver.handle(url: url) } // Universal Link / custom scheme khi app đang chạy func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) { for ctx in contexts { resolver.handle(url: ctx.url) } } // Kích hoạt deferred gate — gọi một lần, sau cửa sổ chờ. func sceneDidBecomeActive(_ scene: UIScene) { resolver.requestFirstLaunchConsentAfterGrace() }}
Theo dõi resolver.isConsentPending (là @Published) qua KVO/Combine để biết khi nào hiện màn hình đồng ý, rồi gọi cùng các method submitPasteControlResult / submitPasteControlEmpty / beginRawProbeOptIn / submitOptOut.
Không có zero-grace entry point. Nếu bạn muốn trì hoãn prompt (ví dụ: sau màn hình onboarding), chỉ cần gọi requestFirstLaunchConsentAfterGrace() muộn hơn — các guard nội bộ đảm bảo lời gọi muộn vẫn đúng, và Universal Link đến trong thời gian đó sẽ tự tắt prompt.
WindowGroup { ContentView() .onOpenURL { url in store.handleIncomingURL(url) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in guard let url = activity.webpageURL else { return } store.handleIncomingURL(url) }}
handleIncomingURL chỉ gửi deepLink — đây là luồng immediate, không kèm clipboardStatus:
func handleIncomingURL(_ url: URL) { hasReceivedUniversalLink = true // chặn luồng deferred ở bước 4 Task { let res = try await trackOpen(.init(deepLink: url.absoluteString)) await route(res) }}
Universal Link immediate không mang li2_cid — nên gửi nguyên url.absoluteString là an toàn, server sẽ phân loại đúng là immediate. li2_cid chỉ được Li2 gieo trên đường chưa cài app (clipboard / install referrer); một cú chạm khi app đã cài được iOS chặn ở chính short link gốc, trước khi có bất kỳ redirect nào sinh li2_cid. (Server quyết định immediate vs deferred dựa trên việc deepLinkcóli2_cid hay không — xem bảng định tuyến trong API.)
Khi người dùng chưa cài app, trang trung gian của Li2 đã chép sẵn link đích kèm li2_cid vào clipboard trước khi đẩy sang App Store. Việc của app chỉ là đọc clipboard đó ở lần mở đầu rồi gửi cho /track/open — bạn không tự dựng li2_cid.Hai điều kiện về thời điểm:
Chờ ≈250ms trước để một Universal Link (nếu có) kịp tới — nếu nó tới, hasReceivedUniversalLink được bật và nhánh deferred bị bỏ qua. 250ms chỉ là đệm; cờ hasReceivedUniversalLink mới là chốt chặn thật, không phải con số thời gian.
Chạy đúng một lần mỗi lần cài (cờ firstLaunchRan, lưu bền) để không đọc clipboard / ghi sự kiện lặp ở các lần mở sau.
Mỗi nhánh kết quả map thẳng vào TrackOpenRequest:
func recoverDeferredOnce() { guard !firstLaunchRan, !hasReceivedUniversalLink else { return } firstLaunchRan = true let body: TrackOpenRequest switch clipboardOutcome { // kết quả sau khi người dùng tương tác PasteControl case .read(let copied) where copied.contains("li2_cid"): body = .init(deepLink: copied, clipboardStatus: "read") case .read: // có nội dung nhưng không phải link Li2 body = .init(li2Domains: ["app.example.com"], clipboardStatus: "read") case .empty: body = .init(li2Domains: ["app.example.com"], clipboardStatus: "empty") case .denied: // người dùng chặn quyền dán body = .init(li2Domains: ["app.example.com"], clipboardStatus: "denied") case .optout: // người dùng bỏ qua body = .init(li2Domains: ["app.example.com"], clipboardStatus: "optout") } Task { let res = try await trackOpen(body) await route(res) }}
Lấy clipboardOutcome bằng UIPasteControl (không bật alert hệ thống)
Bốn nhánh ở trên (.read/.empty/.denied/.optout) đến từ màn hình xin đồng ý. Dùng UIPasteControl: chính cú chạm của người dùng là sự đồng ý, nên iOS không hiện alert “Allow Paste”. Đọc thẳng UIPasteboard.general.string thì sẽ bật alert đó — tránh.UIPasteControl không có bản SwiftUI gốc, phải bọc UIKit. Cạm bẫy quyết định: đặt trong fullScreenCover, control sẽ xám/disabled vì nó dò target qua responder chain mà không tới được — phải gán control.target tường minh và override canPaste(_:) / paste(itemProviders:):
struct ClipboardPasteButton: UIViewRepresentable { var onPaste: (String?) -> Void // nil = không có gì dán được func makeUIView(context: Context) -> PasteReceiverView { let receiver = PasteReceiverView() receiver.onPaste = onPaste let config = UIPasteControl.Configuration() config.displayMode = .iconAndLabel config.cornerStyle = .capsule let control = UIPasteControl(configuration: config) control.translatesAutoresizingMaskIntoConstraints = false control.target = receiver // ← dòng quyết định: trỏ target tường minh receiver.addSubview(control) NSLayoutConstraint.activate([ control.topAnchor.constraint(equalTo: receiver.topAnchor), control.bottomAnchor.constraint(equalTo: receiver.bottomAnchor), control.leadingAnchor.constraint(equalTo: receiver.leadingAnchor), control.trailingAnchor.constraint(equalTo: receiver.trailingAnchor), ]) return receiver } func updateUIView(_ v: PasteReceiverView, context: Context) { v.onPaste = onPaste }}final class PasteReceiverView: UIView { var onPaste: ((String?) -> Void)? override init(frame: CGRect) { super.init(frame: frame) pasteConfiguration = UIPasteConfiguration( acceptableTypeIdentifiers: [UTType.url.identifier, UTType.plainText.identifier]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } // Quyết định control bật/tắt — iOS gọi với item providers của clipboard hiện tại. override func canPaste(_ providers: [NSItemProvider]) -> Bool { providers.contains { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) || $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) } } override func paste(itemProviders: [NSItemProvider]) { guard let p = itemProviders.first else { return deliver(nil) } if p.canLoadObject(ofClass: NSURL.self) { p.loadObject(ofClass: NSURL.self) { [weak self] o, _ in self?.deliver((o as? URL)?.absoluteString) } } else if p.canLoadObject(ofClass: NSString.self) { p.loadObject(ofClass: NSString.self) { [weak self] o, _ in self?.deliver((o as? NSString) as String?) } } else { deliver(nil) } } private func deliver(_ v: String?) { DispatchQueue.main.async { [weak self] in self?.onPaste?(v) } }}
Ánh xạ kết quả về clipboardOutcome: người dùng chạm Paste và nhận chuỗi chứa li2_cid → .read; chạm Paste nhưng rỗng/không phải link Li2 → .empty; bỏ qua màn hình (nút “Skip”) → .optout; iOS chặn đọc → .denied.
Cần import UniformTypeIdentifiers cho UTType. Control tự quản label/icon (“Paste”) — bạn chỉ chỉnh được màu, bo góc và display mode.
Khuyến nghị. Thay vì tự viết luồng bên dưới, hãy đưa module :li2deeplink từ Li2 Deep-Link Kit vào build (drop-in, không publish lên Maven; hoặc copy thẳng li2deeplink/src/main/java/ai/li2/deeplink/). Module không phụ thuộc Compose/ViewModel. Kit gói sẵn: lời gọi /track/open, cổng một-lần-mỗi-lần-cài (DataStore ai.li2.firstLaunchRan), cửa sổ chờ 250ms, đọc Google Play Install Referrer (an toàn với process-death), bóc li2_dl và li2_cid. Bạn chỉ viết phần điều hướng.
Kit phát ra Li2DeepLinkOutcome (Matched / Missed / Failed) qua outcomes: SharedFlow. Toàn bộ phần “bạn phải viết”:
// Application:resolver = Li2DeepLinkResolver( context = this, config = Li2DeepLinkConfig( publishableKey = BuildConfig.LI2_PUBLISHABLE_KEY, deepLinkDomains = listOf("your-domain.com"), ),)// Activity:override fun onCreate(b: Bundle?) { super.onCreate(b) intent?.data?.let { resolver.handleAppLink(it) } // immediate App Link}override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); setIntent(intent) intent.data?.let { resolver.handleAppLink(it) }}// NavHost-level composable:LaunchedEffect(Unit) { resolver.handleFirstLaunchDeferred() } // deferred (Install Referrer)LaunchedEffect(Unit) { resolver.outcomes.collect { outcome -> when (outcome) { is Li2DeepLinkOutcome.Matched -> navigate(outcome.destination) // điều hướng của bạn is Li2DeepLinkOutcome.Missed -> showHome() is Li2DeepLinkOutcome.Failed -> showHome() } }}
Bạn vẫn cần khai báo intent-filter autoVerify (mục 1 bên dưới), đặt LI2_PUBLISHABLE_KEY trong local.properties, và cấu hình domain trên dashboard. Phần còn lại của tab này mô tả luồng tự dựng (manual).
Cả luồng immediate lẫn deferred dùng chung một cặp model và một lời gọi:
data class TrackOpenRequest( val deepLink: String? = null, val li2Domains: List<String>? = null, val clipboardStatus: String? = null, // không dùng trên Android val installReferrer: String? = null,)data class TrackOpenResponse( val clickId: String, val link: Link?, // null khi deferred không đối chiếu được val matchMethod: String?, val missReason: String?, val platform: String?,) { data class Link(val id: String, val domain: String, val key: String, val url: String)}interface Li2Api { @POST("track/open") suspend fun trackOpen( @Header("X-Li2-Key") publishableKey: String, // li2_pk_... @Body body: TrackOpenRequest, ): TrackOpenResponse}// baseUrl PHẢI gồm /api/v1/, ví dụ "https://api.li2.ai/api/v1/"val api: Li2Api = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() .create(Li2Api::class.java)// Điều hướng theo phản hồi: khớp → mở đúng màn hình; trượt → fallback + lý doprivate fun route(res: TrackOpenResponse) { res.link?.url?.let { router.open(it) } ?: router.showFallbackBanner(res.missReason)}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) handleIntent(intent)}private fun handleIntent(intent: Intent?) { if (intent?.action == Intent.ACTION_VIEW) { intent.data?.let { deepLinkViewModel.handleAppLink(it) } }}
handleAppLink chỉ gửi deepLink (luồng immediate):
fun handleAppLink(uri: Uri) { hasReceivedAppLink = true // chặn luồng deferred ở bước 4 viewModelScope.launch { val res = api.trackOpen( BuildConfig.LI2_PUBLISHABLE_KEY, TrackOpenRequest(deepLink = uri.toString()), ) route(res) }}
Giống iOS, App Link immediate không mang li2_cid nên gửi nguyên uri.toString() là an toàn — server phân loại đúng là immediate. li2_cid chỉ nằm trong li2_dl của Install Referrer ở luồng chưa cài app.
handleInstallReferrer gửi nguyên chuỗi referrer thô — server tự giải mã tham số li2_dl=<url-mã-hóa> mà Li2 đã ghép vào referrer khi chuyển hướng tới Play Store:
fun handleInstallReferrer(referrer: String) { viewModelScope.launch { val res = api.trackOpen( BuildConfig.LI2_PUBLISHABLE_KEY, TrackOpenRequest(installReferrer = referrer), ) route(res) }}
Referrer được Google Play lưu lúc cài, không phụ thuộc cách mở app. Một hiểu lầm phổ biến là người dùng phải bấm “Open” trên Play Store ngay sau khi cài. Thực tế: Play Store ghi nhận chuỗi referrer tại thời điểm cài và giữ lại để truy xuất — bạn lấy được nó ở lần mở đầu tiên dù app được mở bằng cách nào (nút Open, icon ngoài màn hình chính vài giờ/ngày sau, hay từ thông báo). Không có cửa sổ “mở ngay kẻo mất”.Hai điều kiện thật sự để có deferred match:
Người dùng tới Play Store qua chính link Li2 (URL mang tham số referrer). Nếu họ tự tìm app trên store thì không có li2_dl → trượt với no_candidate.
App gọi InstallReferrerClient ở lần mở đầu (như trên). Hãy truy vấn một lần rồi lưu kết quả lại, đừng phụ thuộc vào việc gọi lại nhiều lần.
Phân giải là idempotent: server đọc bản ghi deferred trên Redis (không tiêu thụ/xóa), nên gọi lại trả về đúng link cũ cho tới khi bản ghi hết hạn (TTL). Một lần thử lại do timeout mạng vẫn khớp như cũ.
Nhưng mỗi lần gọi thành công đều ghi một sự kiện analytics. Không có idempotency key phía client → để tránh đếm trùng, gọi /track/open đúng một lần mỗi lần cài (cờ firstLaunchRan + hasReceived*), và chỉ retry trong cùng một lần mở khi gặp lỗi mạng/5xx — đừng retry xuyên nhiều lần mở.
Không có host sandbox riêng — bạn kiểm thử ngay trên api.li2.ai bằng hạ tầng thật của mình:
1
Chuẩn bị domain & app thật
Custom domain đã Verified + Deep Links Active, file AASA/assetlinks báo “Live & correct” (xem Cấu Hình Domain).
2
Test immediate (app đã cài)
Cài bản dev qua TestFlight (iOS) / internal-test track hoặc cài trực tiếp (Android), rồi chạm một short link trên domain đó. App phải mở thẳng và /track/open trả universal_link / app_link.
3
Test deferred (chưa cài)
Gỡ app → chạm short link → đi qua trang trung gian → cài lại từ store → mở lần đầu. iOS: chạm Paste trên màn hình đồng ý; Android: Install Referrer tự có. Kỳ vọng clipboard / install_referrer.
Android Install Referrer chỉ trả referrer thật khi cài qua Play Store (internal-test track) — không hoạt động với adb install APK cục bộ. Clipboard của iOS thì test được với mọi kiểu cài.
4
Đối chiếu kết quả
Đọc thẳng matchMethod / missReason trong response, hoặc xem Deep Link Analytics (gói Pro) để xác nhận match/miss.