Chuyển đến nội dung chính

Tích Hợp Deep Link Trên iOS & 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.

Cách nhanh nhất: dùng Li2 SDK

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ướngmàn hình đồng ý.

Cài đặt (Swift Package Manager)

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

1. Cấu hình SDK một lần khi khởi động

import SwiftUI
import Li2SDK

@main
struct 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 đó.

2. Sở hữu một resolver; điều hướng theo outcome

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 SwiftUI
import Combine
import Li2SDK

@MainActor
final 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)
.missed(reason)Không khớp; hiển thị màn hình chính
.failed(error)Lỗi mạng/HTTP/parse; xử lý graceful

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 SwiftUI
import UIKit
import Li2SDK

struct 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ụ:
if #available(iOS 16.0, *) {
    if clipboardHasContent == false {
        Button("Tiếp tục") { resolver.submitPasteControlEmpty() }
    } else {
        Li2PasteButton { resolver.submitPasteControlResult($0) }.frame(height: 52)
    }
}
Tại sao hai chi tiết trên quan trọng:
  • (a) Empty-clipboard affordanceLi2PasteButton 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ũ.

UIKit integration

Modifier .li2DeepLink(using:) chỉ dành cho SwiftUI. Với UIKit, nối thủ công từ scene delegate:
import UIKit
import Li2SDK

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

Troubleshooting

Triệu chứngNguyên nhân / cách sửa
401 mọi lời gọiSai loại key. Dùng publishable key (li2_pk_…), không phải server API key.
Universal Link không mở appCache AASA của Apple CDN (~24h) hoặc thiếu entitlement applinks:. Kiểm tra domain trên dashboard.
Li2PasteButton luôn disabledClipboard đang rỗng (behavior đúng) — thêm nhánh clipboardHasContent == false như hướng dẫn ở trên.
Màn hình đồng ý xuất hiện lại sau Universal LinkBình thường nếu UL tới trong cửa sổ grace — UL thắng và sheet tự dismiss.

Tích hợp thủ công (nâng cao)

Phần dưới đây dành cho trường hợp bạn không dùng SDK và muốn tự gọi HTTP API trực tiếp, hoặc cần tùy biến sâu ngoài những gì SDK cung cấp.

1. Khai báo Associated Domains

Thêm domain Li2 của bạn vào file .entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:app.example.com</string>
</array>
Domain này phải trùng với domain đã cấu hình trên dashboard và file AASA mà Li2 phục vụ.

2. Định nghĩa model & lời gọi API dùng chung

Cả luồng immediate lẫn deferred đều gọi cùng một endpoint với cùng một cặp model. Định nghĩa một lần rồi tái sử dụng:
struct TrackOpenRequest: Encodable {
    var deepLink: String? = nil
    var li2Domains: [String]? = nil
    var clipboardStatus: String? = nil   // "read" | "empty" | "denied" | "optout"
    var installReferrer: String? = nil   // chỉ dùng cho Android
}

struct TrackOpenResponse: Decodable {
    struct Link: Decodable {
        let id: String
        let domain: String
        let key: String
        let url: String
    }
    let clickId: String
    let link: Link?          // nil khi deferred không đối chiếu được
    let matchMethod: String?
    let missReason: String?
    let platform: String?
}

// baseURL PHẢI gồm /api/v1, ví dụ "https://api.li2.ai/api/v1"
func trackOpen(_ body: TrackOpenRequest) async throws -> TrackOpenResponse {
    var req = URLRequest(url: URL(string: "\(baseURL)/track/open")!)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.setValue(publishableKey, forHTTPHeaderField: "X-Li2-Key")  // li2_pk_...
    req.httpBody = try JSONEncoder().encode(body)

    let (data, _) = try await URLSession.shared.data(for: req)
    return try JSONDecoder().decode(TrackOpenResponse.self, from: data)
}

// Điều hướng theo phản hồi: khớp → mở đúng màn hình; trượt → fallback + lý do
@MainActor
func route(_ res: TrackOpenResponse) {
    if let dest = res.link?.url {
        router.open(dest)
    } else {
        router.showFallbackBanner(missReason: res.missReason)
    }
}
Trong entry point SwiftUI:
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 deepLink li2_cid hay không — xem bảng định tuyến trong API.)

4. Khôi phục deferred trong lần mở đầu tiên

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.

Idempotency & thử lại

  • 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 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ở.

Kiểm thử trên production

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.

Bước tiếp theo

API: POST /track/open

Chi tiết đầy đủ về request, response, matchMethod và missReason.

Cấu Hình Domain

Đảm bảo file AASA / assetlinks đã phục vụ đúng trước khi test trên thiết bị.