> ## Documentation Index
> Fetch the complete documentation index at: https://docs.li2.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Tích Hợp Deep Link Trên iOS & Android

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

# Tích Hợp Deep Link Trên iOS & Android

<Info>
  **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.
</Info>

<Tabs>
  <Tab title="iOS (Swift)" icon="apple">
    #### Preflight — kiểm 4 thứ này trước khi viết một dòng code

    <Check>Custom domain đã **Verified** + huy hiệu **Deep Links Active** (không phải `*.li2.link`).</Check>
    <Check>File **AASA live**: `curl -s https://your-domain.com/.well-known/apple-app-site-association | jq` trả đúng `<TeamID>.<BundleID>`.</Check>
    <Check>Có **publishable key** dạng `li2_pk_...` (Settings → Analytics → Publishable Key) — **không** phải server API key.</Check>
    <Check>**Associated Domains** trong Xcode khớp đúng domain (`applinks:your-domain.com`).</Check>

    <Note>
      Bỏ qua bước này là nguồn của \~80% sự cố thường gặp (401, "app không mở"). Nếu vướng, xem [Khắc phục sự cố](./troubleshooting).
    </Note>

    #### SDK lo gì — bạn lo gì

    | Li2 SDK gói sẵn                                        | Bạn tự viết                                   |
    | ------------------------------------------------------ | --------------------------------------------- |
    | Gọi `/track/open` (immediate + deferred)               | **Điều hướng** theo outcome                   |
    | Cổng "một lần mỗi lần cài" + cửa sổ chờ 250ms          | **Màn hình đồng ý** (UI)                      |
    | `Li2PasteButton` đọc clipboard không bật alert         | Quyết định fallback khi `.missed` / `.failed` |
    | Bóc `li2_cid` khỏi URL đích; cầu nối `Li2.lastClickId` | —                                             |

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

    <Note>
      **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 ý**.
    </Note>

    #### Cài đặt (Swift Package Manager)

    Trong Xcode: **File ▸ Add Package Dependencies…** và nhập URL:

    ```text theme={null}
    https://github.com/QQuik/li2-swift-sdk
    ```

    Ghim **Up to Next Major Version** từ `0.2.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

    ```swift theme={null}
    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.

    ```swift theme={null}
    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.

    ```swift theme={null}
    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, *), clipboardHasContent != false {
                // 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 {
                // (a) Clipboard rỗng (Li2PasteButton tự disable): phải có nút thay thế —
                // không thì người dùng bị kẹt và outcome `empty` không bao giờ được gửi.
                Button("Tiếp tục") { resolver.submitPasteControlEmpty() }
            }
        }
    }
    ```

    **Hai chi tiết không thể bỏ qua:**

    * **(a) Empty-clipboard affordance** — `Li2PasteButton` tự disable khi clipboard rỗng. Không có nút thay thế (`submitPasteControlEmpty()`), 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 khi sheet đang mở (người dùng sang Safari, copy link, quay lại). Probe chỉ trong `onAppear` sẽ hiện trạng thái nút bị cũ.

    <Accordion title="Hỗ trợ iOS 15 (không có Li2PasteButton)">
      `Li2PasteButton` yêu cầu iOS 16. Trên **iOS 15**, dùng `beginRawProbeOptIn()` — nó đọc clipboard trực tiếp, nên iOS **sẽ hiện alert "Allow Paste"** (hành vi hệ thống của iOS 15, không tránh được). Thêm nhánh fallback vào `primaryButton`:

      ```swift theme={null}
      // Đặt TRƯỚC nhánh else, sau nhánh iOS 16:
      } else if clipboardHasContent != false {
          // iOS 15: đọc trực tiếp — iOS hiện alert "Allow Paste".
          Button("Tiếp tục") { resolver.beginRawProbeOptIn() }
      } else {
          Button("Tiếp tục") { resolver.submitPasteControlEmpty() }
      }
      ```

      Ngược lại, **trên iOS 16+ đừng dùng `beginRawProbeOptIn()`** — nó cũng bật alert "Allow Paste", đúng thứ mà `Li2PasteButton` sinh ra để tránh.
    </Accordion>

    #### Kiểm chứng nhanh: in response, đọc `matchMethod`

    Sau khi nối xong, chạy thử và **in response của `/track/open`** ra log. Đọc `matchMethod` (`universal_link` / `clipboard` / …) hoặc `missReason` để biết SDK vừa làm gì — đây là công cụ tự chẩn đoán chính. Bảng đầy đủ: [matchMethod & missReason](./track-open-api#giá-trị-matchmethod), cây quyết định theo triệu chứng: [Khắc phục sự cố](./troubleshooting).

    #### UIKit integration

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

    ```swift theme={null}
    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`.

    <Note>
      **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.
    </Note>

    #### Gặp sự cố?

    Các triệu chứng thường gặp — 401 mọi lời gọi, Universal Link không mở app, `Li2PasteButton` luôn xám, sheet hiện lại sau UL — được tra theo triệu chứng tại trang riêng:

    <Card title="Khắc phục sự cố Deep Link" icon="wrench" href="./troubleshooting">
      Chẩn đoán theo triệu chứng + cách đọc `matchMethod` / `missReason` để tự gỡ lỗi.
    </Card>

    ***

    #### Đo lường chuyển đổi (Conversion Tracking)

    Sau khi deep link đã được phân giải, bạn có thể gắn các **sự kiện kinh doanh thật** — đăng ký, mua hàng — về đúng cú click đã dẫn dắt chúng. Từ `0.2.0`, SDK gói sẵn `trackLead`, `trackSale` và `identify`; một outcome `.matched` tự lưu `Li2.lastClickId` (TTL 30 ngày) để các phương thức attributed đọc lại — bạn không phải tự truyền click id.

    <Card title="Đo Lường Chuyển Đổi Cho Deep Link" icon="chart-line" href="./conversion">
      Hướng dẫn đầy đủ: chọn đúng `trackDirect`/`trackAnonymous`/`trackAttributed`, hành trình ẩn danh ➜ định danh, và cách Android tự gọi HTTP `/track/lead` + `/track/sale`.
    </Card>

    ***

    ### Tích hợp thủ công (nâng cao) — KHÔNG dùng SDK

    <Warning>
      **Đây là đường THAY THẾ, không phải bước tiếp theo.** Nếu bạn đã dùng Li2 SDK ở trên, **bỏ qua toàn bộ phần này** — SDK đã làm hết. Chỉ đọc tiếp nếu bạn **không** dùng SDK và muốn tự gọi HTTP, hoặc cần tùy biến sâu hơn những gì SDK cung cấp. Phần này tự dựng lại chính xác những gì `Li2PasteButton` + resolver đã gói sẵn.
    </Warning>

    #### 1. Khai báo Associated Domains

    Thêm domain Li2 của bạn vào file `.entitlements`:

    ```xml theme={null}
    <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:

    ```swift theme={null}
    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)
        }
    }
    ```

    #### 3. Xử lý Universal Link (app đã cài — immediate)

    Trong entry point SwiftUI:

    ```swift theme={null}
    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`:

    ```swift theme={null}
    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)
        }
    }
    ```

    <Note>
      **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` **có** `li2_cid` hay không — xem bảng định tuyến trong [API](./track-open-api#server-chọn-đường-xử-lý-như-thế-nào).)
    </Note>

    #### 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`.

    <Note>
      **Nhắc lại "Ba định danh".** `li2_cid` là token 16 ký tự định danh **cú click gốc** — Li2 *gieo* nó vào link trên clipboard, bạn chỉ **đọc**. Đừng nhầm với `clickId` trong response (server trả về). Bảng đầy đủ ba định danh: [Tổng Quan → Ba định danh](./overview#ba-định-danh-bạn-sẽ-gặp).
    </Note>

    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`:

    ```swift theme={null}
    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:)`:

    ```swift theme={null}
    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 nonisolated func deliver(_ v: String?) {
            Task { @MainActor [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`.

    <Tip>
      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.
    </Tip>
  </Tab>

  <Tab title="Android (Kotlin)" icon="android">
    ### Cách nhanh nhất: dùng Li2 Deep-Link Kit

    <Note>
      **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**.
    </Note>

    **Cài đặt (Gradle — clone):**

    ```bash theme={null}
    git clone https://github.com/QQuik/li2-deeplink-kit-android
    ```

    Thêm vào `settings.gradle.kts`:

    ```kotlin theme={null}
    include(":li2deeplink")
    project(":li2deeplink").projectDir = file("../li2-deeplink-kit-android/li2deeplink")
    ```

    Hoặc dùng **JitPack** (thêm `maven("https://jitpack.io")` vào `repositories`):

    ```kotlin theme={null}
    implementation("com.github.QQuik:li2-deeplink-kit-android:master-SNAPSHOT")
    ```

    Kit phát ra `Li2DeepLinkOutcome` (`Matched` / `Missed` / `Failed`) qua `outcomes: SharedFlow`. Toàn bộ phần "bạn phải viết":

    ```kotlin theme={null}
    // 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)**.

    ***

    ### 1. Khai báo App Link intent-filter

    Trong `AndroidManifest.xml`:

    ```xml theme={null}
    <activity android:launchMode="singleTask" ...>
        <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="your-domain.com" />
        </intent-filter>
    </activity>
    ```

    `android:autoVerify="true"` yêu cầu Android xác minh `assetlinks.json` lúc cài đặt. Thiếu nó, Android sẽ hiện hộp chọn ứng dụng thay vì mở thẳng app.

    ### 2. Định nghĩa model & API dùng chung

    Cả luồng immediate lẫn deferred dùng chung một cặp model và một lời gọi:

    ```kotlin theme={null}
    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ý do
    private fun route(res: TrackOpenResponse) {
        res.link?.url?.let { router.open(it) }
            ?: router.showFallbackBanner(res.missReason)
    }
    ```

    ### 3. Xử lý App Link (app đã cài — immediate)

    ```kotlin theme={null}
    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):

    ```kotlin theme={null}
    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)
        }
    }
    ```

    <Note>
      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**.
    </Note>

    ### 4. Khôi phục deferred qua Google Play Install Referrer

    Trong lần mở đầu tiên, nếu **không** có App Link tới, gọi Install Referrer (chỉ một lần — cờ `firstLaunchRan` lưu trong DataStore):

    ```kotlin theme={null}
    LaunchedEffect(Unit) {
        delay(250L)
        if (!deepLinkViewModel.hasReceivedAppLink) {
            installReferrerManager.queryOnce()?.let { referrer ->
                deepLinkViewModel.handleInstallReferrer(referrer)
            }
        }
    }
    ```

    `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:

    ```kotlin theme={null}
    fun handleInstallReferrer(referrer: String) {
        viewModelScope.launch {
            val res = api.trackOpen(
                BuildConfig.LI2_PUBLISHABLE_KEY,
                TrackOpenRequest(installReferrer = referrer),
            )
            route(res)
        }
    }
    ```

    <Note>
      **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.
    </Note>

    ### 5. Publishable key trong local.properties

    ```properties theme={null}
    LI2_PUBLISHABLE_KEY=li2_pk_xxxxxxxxxxxxxxxxxxxx
    ```

    `build.gradle.kts` đọc giá trị này và inject vào `BuildConfig`.

    <Warning>
      **Lỗi phổ biến nhất**: thiếu `LI2_PUBLISHABLE_KEY` trong `local.properties` → `BuildConfig.LI2_PUBLISHABLE_KEY` rỗng → **mọi** lời gọi `/track/open` trả về **401 Unauthenticated**. Thêm key rồi build lại.
    </Warning>

    ### 6. Buộc xác minh App Link

    Sau khi cài hoặc sau khi cập nhật `assetlinks.json`:

    ```bash theme={null}
    adb shell pm verify-app-links --re-verify your.package.name
    adb shell pm get-app-links your.package.name
    # Phải hiển thị: your-domain.com → verified
    ```

    Nếu thấy `1024` (chưa xác minh), SHA-256 trong dashboard không khớp chứng chỉ ký của APK đang cài.
  </Tab>
</Tabs>

## 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` 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ở.

## 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:

<Steps>
  <Step title="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](./domain-setup)).
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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`.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="Đối chiếu kết quả">
    Đọc thẳng `matchMethod` / `missReason` trong response, hoặc xem [Deep Link Analytics](/vi/guide/deep-links/xem-phan-tich-deep-link-li2) (gói Pro) để xác nhận match/miss.
  </Step>
</Steps>

## Bước tiếp theo

<CardGroup cols={2}>
  <Card title="Đo Lường Chuyển Đổi" icon="chart-line" href="./conversion">
    Gắn lead/sale về đúng click sau khi deep link đã phân giải.
  </Card>

  <Card title="Khắc phục sự cố" icon="wrench" href="./troubleshooting">
    Tra theo triệu chứng + đọc matchMethod / missReason.
  </Card>

  <Card title="API: POST /track/open" icon="code" href="./track-open-api">
    Chi tiết đầy đủ về request, response, matchMethod và missReason.
  </Card>

  <Card title="Cấu Hình Domain" icon="globe" href="./domain-setup">
    Đảm bảo file AASA / assetlinks đã phục vụ đúng trước khi test.
  </Card>
</CardGroup>
