如何建立帶有自訂參數的 App 路由 QR Code

opoinstall
2026-10-29
5 min read

建立 QR Code 時如何連結自訂參數?為 App 路由連結自訂參數,需要在託管的落地頁連結後方附加結構化 URL 查詢金鑰,透過網頁整合層擷取這些金鑰,並在應用程式被直接喚醒或安裝後啟動時,將驗證後的酬載(payload)分派給原生導航控制器。

透過 QR Code 進行 App 內導航是一種客戶端-伺服器導航架構,會將結構化的目標參數嵌入二維條碼 URL 中。當應用程式已安裝時,透過作業系統的直接深度連結來解析這些參數;若應用程式是剛從應用程式商店全新安裝,則透過平台支援的延遲復原機制來解析。如此一來,工程團隊就能將使用者直接引導至特定的 App 內畫面,而非預設的首頁。

術語 定義 相關實體 搜尋意圖角色
建立應用程式 QR Code 將可執行的路由 URL 編碼為 2D 條碼以供行動導航使用的工程工作流程。 場景還原 資訊型 / 技術型
App QR Code 設定為觸發原生應用程式啟動或商店引導的條碼資產。 通用連結 / 應用程式連結 (Universal Links / App Links) 技術型 / 商業型
動態 QR Code 指向中介路由端點的條碼,其目標酬載可在伺服器端進行更新。 URI 間接尋址 商業型
QR Code 追蹤 衡量路由進站請求與下游 App 內畫面啟動情況的指標。 頻道統計與追蹤 商業型

架構概觀:如何為 App 場景還原建立 QR Code

帶參數的 QR 路由可以還原 App 內的產品詳細資訊畫面。

首頁跳出問題:為什麼一般商店重新導向會中斷高意圖的使用者旅程

實體 QR Code 經常部署在高意圖的情境環境中:零售購物者掃描貨架標籤以檢視產品規格,用餐者掃描桌上立牌以從特定菜單類別點餐,或是玩家掃描邀請條碼以加入多人遊戲大廳。當條碼編碼為一般的應用程式商店連結或未帶參數的網域時,使用者的情境意圖就會在從實體掃描轉換到原生應用程式執行階段的過程中遺失。

在啟動應用程式後,使用者會直接掉落至預設的頂層首頁。強迫使用者手動瀏覽複雜的分類樹、重新輸入搜尋查詢,或輸入房間代號,會帶來相當大的認知與操作阻力。App 內場景還原透過將目標狀態編碼至 QR Code URL 中,並在原生客戶端內以程式碼方式重建該目標檢視,來解決這個跳出率問題。

雙狀態路由機制:直接喚醒與延遲場景還原

已安裝的 App 使用直接連結,而新安裝則需要支援的情境復原。

當使用者掃描 App 路由 QR Code 時,行動裝置會處於兩種安裝狀態之一。完整的路由架構會透過不同的技術路徑來處理這兩種生命週期:

  • 直接喚醒路由(應用程式已安裝):當目標應用程式已存在於裝置上時,掃描 QR Code 會觸發作業系統深度連結——具體來說是 iOS 上的 Apple Universal Links 或 Android 上的 Android App Links。這兩種機制都要求託管經過密碼編譯驗證的網域關聯檔案:iOS 需要 apple-app-site-association (AASA) 檔案搭配 Xcode Associated Domains 權限,而 Android 則需要與應用程式簽署憑證 SHA-256 指紋相符且經過驗證的資訊清單(manifest)意圖篩選器的 /.well-known/assetlinks.json Digital Asset Links 檔案。經過驗證後,作業系統會喚醒應用程式並將 URI 直接傳遞給客戶端的生命週期委派 (delegate)。
  • 延遲場景還原(應用程式未安裝):當應用程式不存在時,使用者會透過中介網頁落地頁被引導至平台的應用程式商店。由於尚未安裝原生二進位檔來接收作業系統意圖,路由參數必須在重新導向至商店之前先暫存,並在安裝完成後於應用程式的首次啟動(第 0 天冷啟動)期間進行復原。

平台專屬的復原界線:Google Play 安裝參考連結、iOS Universal Links 與 SDK 轉接器

在使用者從應用程式商店安裝應用程式後,推薦與路由參數的復原取決於平台以及應用程式或歸因 SDK 所實作的機制。透過網頁 URL 傳遞 route 或 id 參數,本身並不保證該參數能在應用程式商店安裝過程中存活下來:

  • Android 與 Google Play:Google Play Install Referrer API 可以提供與 Play 商店安裝相關的支援推薦後設資料。此機制專門適用於透過官方 Google Play 商店路徑完成的安裝,不適用於任意的第三方 APK 側載或替代商店。應用程式必須設定並測試實際的 referrer 酬載,並驗證預期的參數是否能在重新導向與安裝流程中存活。
  • iOS 與 App Store:Apple App Store 不會自動將任意的網頁落地頁查詢參數轉移到新安裝的應用程式二進位檔中。Universal Links 在應用程式已安裝且相關的網域設定有效時支援直接路由;它們本身並不保證在新安裝後能夠進行延遲參數復原。
  • 歸因 SDK:僅使用針對所部署的確切 SDK 版本和平台所記錄的復原機制。請驗證實作、資料收集與隱私權影響,切勿假設 SDK 能夠跨所有瀏覽器和安裝路徑復原每個路由參數。

iOS 歸因與隱私權界線:遵守 Apple 防指紋追蹤規則

iOS 路由與延遲場景還原實作絕對不能依賴禁止的裝置指紋追蹤來跨應用程式或網站識別使用者或裝置。根據 Apple 使用者隱私權與資料使用指引以及 Apple 開發者計畫授權協議,開發者嚴格禁止為此目的從裝置、瀏覽器、設定、位置或網路訊號中衍生出唯一識別碼。App Tracking Transparency (ATT) 授權並不會讓原本被禁止的指紋追蹤變得合法。

當應用程式已安裝時,請使用 Universal Links 進行已驗證的直接 App 路由。對於 iOS 上的安裝後場景還原,請使用已記錄且其行為已針對所部署的 SDK 版本及適用 Apple 要求進行審查的機制。請審查 SDK 的隱私權揭露聲明以及適用的必要原因 API 宣告(required-reason API declarations),但切勿將隱私權資訊清單 (PrivacyInfo.xcprivacy) 的存在視為歸因實作合規的證明。

如果所選的 SDK 依賴無法被驗證為允許的匹配方法,請勿建議將該方法用於 iOS 部署。在沒有任何允許的機制能夠可靠地建立延遲路由情境的情況下,請保留正常的引導流程,將使用者導向安全的預設首頁,並將安裝標記為未歸因,而不是製造無法驗證的匹配。

將雙狀態 QR 場景還原管線視覺化

以下的架構圖說明了當帶參數的 QR Code 分支為直接作業系統喚醒或延遲商店復原,然後在客戶端路由驗證器與驗證閘道會合時的各個階段:

[使用者掃描 QR Code] ──> URL (?route=product_detail&id=sku_8842&channelCode=pos_01)
          │
     ┌────┴────────────────────────────────────────┐
     ▼ (App 已安裝)                                ▼ (App 未安裝)
[OS Universal Link / App Link]               [中介 H5 落地頁]
     │                                             │
     ▼                                             ▼
[熱/冷啟動喚醒回呼]                             [商店下載與首次啟動]
(onNewIntent / continueUserActivity)         (平台支援的延遲復原)
     │                                             │
     └────────────────────┬────────────────────────┘
                          ▼
             [客戶端路由驗證器與驗證閘道]
                          │
     ┌────────────────────┴────────────────────────┐
     ▼ (已加入白名單且通過驗證)                    ▼ (無效路由 / 未匹配)
[導航至產品詳細資訊畫面]                     [退回預設首頁]

步驟 1:設計自訂路由參數結構描述

建構可執行的路由酬載與被動歸因標籤

在建立用於 App 路由的 QR Code 時,工程團隊必須建立清晰的雙層資料合約,將外部精簡的 URL 查詢金鑰與內部正規化的 JSON 酬載區隔開來:

合約層級 欄位命名慣例 範例表示 架構目的
外部 URL 查詢層 route, id, channelCode ?route=product_detail&id=sku_8842&channelCode=pos_01 最小化 URI 字元長度,以保持列印出的 QR 矩陣精簡
內部正規化層 route_key, target_id, channel_code, fallback_route {"route_key": "product_detail", "target_id": "sku_8842", ...} 經由 H5 與原生客戶端路由器驗證的標準化規範物件
客戶端安全性註冊表 PROTECTED_ROUTES(內部常數) setOf("room_lobby", "promo_campaign") 在內部決定驗證要求;絕不信任來自外部 URL 的驗證要求

QR URL 參數被正規化為獨立的導航與歸因情境。

在此雙層模型中:

  • 被動歸因標籤(channelCode →\to channel_code)由分析管線消耗,用於衡量行銷轉換量而不改變客戶端 UI 狀態。
  • 可執行路由參數(route →\to route_key 以及 id →\to target_id)指示原生客戶端的導航控制器要掛載哪一個畫面以及要擷取哪一個實體。
  • 驗證策略隔離:路由是否需要驗證(requires_auth)必須嚴格從客戶端應用程式內部受信任的路由註冊表中衍生,而不是接受來自外部 QR 查詢參數的設定。

強制執行 IETF RFC 3986 百分比編碼與酬載大小限制

附加到 QR 目標 URL 的所有路由參數都必須遵循 IETF RFC 3986 中指定的 URI 通用語法:

  • 避免巢狀分隔符號:請勿將原始 JSON 字串(?data={"route":"product","id":123})直接嵌入 URL 查詢字串中。未跳脫的大括號、引號和冒號經常會破壞 App 內瀏覽器的 URL 解析器,並膨脹條碼模組密度。
  • 扁平化鍵值對:將外部路由情境表示為扁平、百分比編碼的查詢參數(https://promo.example.com/open?route=product_detail&id=sku_8842&channelCode=pos_01)。
  • 強制執行嚴格的字元範圍:將 route 和 id 值限制為 URL 安全的英數字元、連字號和底線(^[A-Za-z0-9_-]{1,64}$)。

定義客戶端路由白名單合約

一旦外部 URL 參數被擷取並正規化為內部酬載結構,它們就必須符合嚴格的結構描述合約。如果 route_key 無法辨識,或者實體專屬路由(product_detail、room_lobby、promo_campaign)缺少必要的 target_id,正規化器必須將酬載降級為 home_default 並帶有 target_id: null。

底下的 JSON Schema 定義了正規化場景還原酬載的規範結構:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "QrSceneRestorationPayload",
  "description": "Canonical, normalized routing payload. Authentication policy is derived separately from the application's trusted route registry.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "schema_version",
    "route_key",
    "target_id",
    "fallback_route"
  ],
  "properties": {
    "schema_version": {
      "const": "1.0.0"
    },
    "route_key": {
      "type": "string",
      "enum": [
        "product_detail",
        "room_lobby",
        "promo_campaign",
        "home_default"
      ]
    },
    "target_id": {
      "type": ["string", "null"],
      "pattern": "^[A-Za-z0-9_-]{1,64}$"
    },
    "channel_code": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_.-]{1,64}$"
    },
    "fallback_route": {
      "const": "home_default"
    }
  },
  "allOf": [
    {
      "if": {
        "properties": {
          "route_key": {
            "enum": [
              "product_detail",
              "room_lobby",
              "promo_campaign"
            ]
          }
        },
        "required": ["route_key"]
      },
      "then": {
        "properties": {
          "target_id": {
            "type": "string"
          }
        }
      }
    },
    {
      "if": {
        "properties": {
          "route_key": {
            "const": "home_default"
          }
        },
        "required": ["route_key"]
      },
      "then": {
        "properties": {
          "target_id": {
            "type": "null"
          }
        }
      }
    }
  ],
  "examples": [
    {
      "schema_version": "1.0.0",
      "route_key": "product_detail",
      "target_id": "sku_8842",
      "channel_code": "pos_01",
      "fallback_route": "home_default"
    }
  ]
}

步驟 2:在落地頁上擷取與轉發路由情境

在 H5 落地頁上解析白名單路由金鑰

當未安裝應用程式的使用者掃描 QR Code 時,行動瀏覽器會開啟託管的中介 H5 落地頁。落地頁指令碼會擷取外部查詢金鑰(route、id、channelCode)、拒絕模稜兩可的重複參數,並將其正規化為步驟 1 中定義的規範結構。

以下的 JavaScript 正規化器強制執行單一金鑰基數、正規表示式邊界以及條件式 home_default 降級,同時將重複的保留參數視為無效:

const query = new URLSearchParams(window.location.search);

const routeValues = query.getAll("route");
const idValues = query.getAll("id");
const channelValues = query.getAll("channelCode");

const allowedRoutes = new Set([
  "product_detail",
  "room_lobby",
  "promo_campaign",
  "home_default"
]);

const idPattern = /^[A-Za-z0-9_-]{1,64}$/;
const channelPattern = /^[A-Za-z0-9_.-]{1,64}$/;

function normalizeQrRoutingPayload() {
  // Reject duplicate keys to defend against parameter pollution
  if (routeValues.length > 1 || idValues.length > 1 || channelValues.length > 1) {
    return {
      schema_version: "1.0.0",
      route_key: "home_default",
      target_id: null,
      fallback_route: "home_default"
    };
  }

  const rawRoute = routeValues.length === 1 ? routeValues[0] : null;
  const rawId = idValues.length === 1 ? idValues[0] : null;
  const rawChannel = channelValues.length === 1 ? channelValues[0] : null;

  let routeKey = allowedRoutes.has(rawRoute)
    ? rawRoute
    : "home_default";

  let targetId =
    rawId && idPattern.test(rawId)
      ? rawId
      : null;

  if (routeKey === "home_default" || targetId === null) {
    routeKey = "home_default";
    targetId = null;
  }

  const payload = {
    schema_version: "1.0.0",
    route_key: routeKey,
    target_id: targetId,
    fallback_route: "home_default"
  };

  if (rawChannel && channelPattern.test(rawChannel)) {
    payload.channel_code = rawChannel;
  }

  return payload;
}

const routingPayload = normalizeQrRoutingPayload();

透過版本驗證的轉接器整合網頁 SDK

落地頁協調即時 App 喚醒嘗試與延遲參數暫存。在部署歸因網頁 SDK(例如 opoinstall.js)之前,請針對所部署 SDK 版本的官方說明文件,確認確切的 JavaScript 套件 URL、初始化簽章、就緒回呼、喚醒方法以及參數結構描述。

將 SDK 專屬行為保持在一個小型應用程式轉接器後方,該轉接器接受已正規化的 routingPayload 物件。如需有關網頁整合與頻道參數設定的詳細規格,請參閱 OpoInstall 開發者說明文件。

跨直接商店備用連結保留路由參數

如果網頁 JS SDK 不可用或被瀏覽器安全性控制阻擋,落地頁會退回至直接商店重新導向連結。在使用 Google Play 的 Android 部署上,備用連結可以將正規化的路由金鑰編碼到 Play 商店的 referrer 參數中,以便原生客戶端在透過官方 Play 商店流程安裝時,能透過 Google Play Install Referrer API 復原路由情境:

// Example Android Play Store fallback URL preserving normalized routing context
const referrerString = `route_key=${routingPayload.route_key}&target_id=${routingPayload.target_id || ""}&channel_code=${routingPayload.channel_code || ""}`;
const playStoreFallbackUrl = `https://play.google.com/store/apps/details?id=com.example.app&referrer=${encodeURIComponent(referrerString)}`;

步驟 3:實作原生客戶端路由分派與場景還原

處理 Android Activity 啟動模式:為什麼 singleTask 需要同時具備 onCreate 與 onNewIntent

在 Android 深度連結架構中,Activity 如何接收路由意圖取決於其設定的啟動模式與導航圖表設計。根據任務堆疊(task-stack)的需求,開發者可能會使用搭配 Jetpack Navigation 的標準啟動模式、singleTop 或 singleTask。

當 Activity 設定為 singleTop 或 singleTask 以防止在深度連結喚醒期間重複實例堆疊時,必須處理兩種不同的生命週期路徑:

  • 冷啟動 (onCreate):當應用程式程序未執行時,掃描 QR Code 會從頭啟動 Activity。收到的 Intent 會被傳遞至 onCreate(savedInstanceState: Bundle?)。
  • 熱啟動 (onNewIntent):當作業系統在設定的啟動模式與任務堆疊條件下重複使用現有的 Activity 實例時,收到的深度連結 Intent 會被傳遞至 onNewIntent()。在 singleTop 下,只有當目標 Activity 已經在相關任務堆疊的最上方時才會重複使用;否則,可能會建立新的 Activity 實例。請實作 onCreate() 與 onNewIntent() 的處理常式,並驗證應用程式導航架構的實際行為。

為了在不永久封鎖在工作階段後期合法掃描同一個 QR Code 第二次的使用者的情況下可靠地處理這兩種狀態,Activity 應該用消耗旗標(EXTRA_ROUTE_CONSUMED)標記每個處理過的 Intent 實例,並忽略僅由歷史 App 切換器啟動(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)重新遞送的意圖。

處理 iOS 生命週期進入點:AppDelegate 與 UISceneDelegate Continuation Activities

在 iOS 上,直接 Universal Link 路由與延遲參數擷取必須與應用程式設定的 UI 生命週期保持一致:

  • 基於場景的生命週期 (UISceneDelegate):在採用 UIScene 的應用程式中(自 iOS 13 起成為標準),冷啟動 Universal Links 會透過 connectionOptions.userActivities 到達 scene(_:willConnectTo:options:),而熱恢復 Universal Links 則會到達 scene(_:continue:)。
  • 應用程式委派生命週期 (UIApplicationDelegate):在未使用場景委派的應用程式中,Universal Links 會被傳遞至 application(_:continue:restorationHandler:)。
  • 延遲首次啟動復原:獨立於 Universal Link continuation 委派之外,延遲安裝參數查詢會在根視圖階層就緒後的初始應用程式啟動期間執行一次。

開發者可以透過 OpoInstall SDK 下載中心下載平台 SDK 套件並檢視生命週期整合範本。

將直接喚醒參數與首次啟動延遲參數統整至單一導航路由器

為了維持一致的安全性與導航行為,客戶端架構會在將直接喚醒 URI 與延遲安裝 JSON 酬載分派給 UI 路由器之前,將其正規化為單一的 NavigationTarget 模型:

  1. 來源驗證(直接意圖):驗證收到的 Intent.ACTION_VIEW URI 是否使用 https 通訊協定、是否符合應用程式經過驗證的關聯主機(promo.example.com)與路徑(/open),且不包含互相衝突的重複查詢金鑰。
  2. 結構描述正規化與白名單檢查:將外部 route / id 金鑰或內部 route_key / target_id 欄位對應至規範模型,並拒絕未列入白名單的路由或格式錯誤的 ID。請注意,客戶端 Kotlin 解析邏輯運作為說明性質的防禦性解析骨架;在生產系統中,請在分派前根據完整的規範結構描述規則解析酬載。
  3. 單次飛行重入防護 (Single-Flight Re-Entrancy Guard):使用原子的每個遞送防護(per-delivery guard),確保並行回呼不會觸發重疊的 Fragment 交易。

管理驗證閘道:將待處理的深度連結路由排隊,直到使用者登入完成

受保護的 QR 路由可以在驗證後恢復,而無效的目標則安全地退回。

當新使用者透過 QR Code 從應用程式商店安裝應用程式並首次啟動時,使用者通常尚未登入。如果 QR Code 的目標是受保護的畫面(例如 room_lobby 或 promo_campaign),且路由器嘗試立即導航,驗證閘道會將使用者重新導向至登入畫面並遺失深度連結情境。

為了跨驗證邊界保留場景還原:

  • 內部路由策略查詢:透過檢查客戶端內部的 PROTECTED_ROUTES 註冊表來判斷路由是否需要驗證,而不是信任外部酬載旗標。
  • 原子暫存:如果使用者未登入,請將已驗證的 NavigationTarget 儲存在原子的、工作階段範圍的 PendingRouteStore(AtomicReference)中,並將使用者引導至登入流程。請注意,記憶體中的 PendingRouteStore 適用於作用中的應用程式程序;如果登入流程會跨越程序終止,請使用加密的、具時間限制的本機儲存空間,並在切換帳戶時清除狀態。
  • 驗證後重新驗證與分派:當登入完成時,以原子方式消耗暫存目標(getAndSet(null))、驗證工作階段現在已登入、重新驗證路由參數,並執行畫面轉換。

導航主機就緒與狀態還原

onNavigationGraphReady() 回呼必須連接到應用程式實際的導航主機(navigation-host)初始化。當導航圖表定義在 XML 託管的 NavHostFragment 中時,Activity 可以在主機及其目標就緒後建立此連接。如果圖表是以非同步方式安裝的,請僅在圖表初始化完成後才叫用回呼。

僅儲存在 Activity 欄位中的待處理導航請求可能會在設定變更期間遺失。對於生產實作,請將未消耗的路由狀態保留在具備生命感知能力的 ViewModel 中,並在需要跨系統驅動的 Activity 重新建立或程序終止進行還原時使用 SavedStateHandle。此處顯示的程式碼為架構範例,需要應用程式專屬的導航與工作階段整合。

以下的 Kotlin 程式碼提供了一個具說明性的 Android 路由骨架,其中包含應用程式擁有的 SDK 與導航轉接器,展示了這些控制項:

<!-- Android Manifest Configuration for App Link Verification -->
<!-- File: app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">

        <activity
            android:name=".SceneRestorationActivity"
            android:exported="true"
            android:launchMode="singleTask">

            <!-- Verified Android App Link Intent Filter -->
            <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" />

                <!-- Origin must match /.well-known/assetlinks.json SHA-256 fingerprint -->
                <data
                    android:scheme="https"
                    android:host="promo.example.com"
                    android:pathPrefix="/open" />
            </intent-filter>
        </activity>
    </application>
</manifest>
// Android Routing Activity Implementation
// File: app/src/main/java/com/example/app/SceneRestorationActivity.kt
//
// INTEGRATION NOTE:
// This example is an architectural routing skeleton, not a drop-in production implementation.
// Bind AttributionRoutingAdapter and NavController initialization to your verified SDK release,
// session manager, and NavHostFragment before deployment.

package com.example.app

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

/**
 * Validated navigation target model representing an allowlisted in-app destination.
 * Authentication requirement is derived strictly from the internal route registry.
 */
data class NavigationTarget(
    val eventKey: String,
    val routeKey: String,
    val targetId: String?,
    val requiresAuth: Boolean
)

/**
 * Process-scoped atomic store holding a pending route when an unauthenticated user
 * triggers a protected deep link during cold start or first-launch onboarding.
 */
object PendingRouteStore {
    private val pending = AtomicReference<NavigationTarget?>(null)

    fun stage(target: NavigationTarget) {
        pending.set(target)
    }

    fun consume(): NavigationTarget? {
        return pending.getAndSet(null)
    }
}

class SceneRestorationActivity : AppCompatActivity() {

    companion object {
        private const val TAG = "SceneRestorationRouter"
        private const val TRUSTED_HOST = "promo.example.com"
        private const val TRUSTED_PATH = "/open"
        private const val EXTRA_ROUTE_CONSUMED = "com.example.app.EXTRA_ROUTE_CONSUMED"

        private const val ROUTE_HOME = "home_default"
        private const val ROUTE_PRODUCT = "product_detail"
        private const val ROUTE_LOBBY = "room_lobby"
        private const val ROUTE_PROMO = "promo_campaign"

        private val ALLOWED_ROUTES = setOf(ROUTE_HOME, ROUTE_PRODUCT, ROUTE_LOBBY, ROUTE_PROMO)
        private val PROTECTED_ROUTES = setOf(ROUTE_LOBBY, ROUTE_PROMO)
        private val SAFE_ID_REGEX = Regex("^[A-Za-z0-9_-]{1,64}$")

        // Process-level guard ensuring deferred install recovery is queried only once per cold start
        private val hasQueriedDeferredInstall = AtomicBoolean(false)
    }

    // Per-delivery re-entrancy guard; allows legitimate subsequent scans of the same QR code
    private val processingIntent = AtomicBoolean(false)

    // Staging container for routes that arrive before the UI/NavController is mounted
    private val pendingUiTarget = AtomicReference<NavigationTarget?>(null)
    private var navigationReady = false

    // Application-owned adapter isolating vendor SDK and session calls
    private val attributionAdapter = AttributionRoutingAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_scene_restoration)

        // Connect NavHost initialization when defined in layout
        connectNavigationHost()

        // Activity recreation (e.g., screen rotation) is not a new install or new QR scan
        if (savedInstanceState != null) {
            return
        }

        val launchIntent = intent
        val isDirectLinkLaunch =
            launchIntent?.action == Intent.ACTION_VIEW &&
            launchIntent.data != null

        if (isDirectLinkLaunch) {
            processIncomingIntentOnce(launchIntent) { candidate ->
                handleIncomingWakeupIntent(candidate)
            }
            // Do not start deferred recovery after a direct-link launch,
            // even if the direct route was rejected.
            return
        }

        // Only initial normal application launches may request deferred recovery
        if (hasQueriedDeferredInstall.compareAndSet(false, true)) {
            queryDeferredSceneParameters()
        }
    }

    /**
     * Connect this method to the actual NavHost initialization.
     * Uses currentDestination check and destination listener to safely verify graph readiness
     * without throwing IllegalStateException on unset controller.graph.
     */
    private fun connectNavigationHost() {
        val navHost = supportFragmentManager
            .findFragmentById(R.id.nav_host_fragment) as? NavHostFragment

        if (navHost == null) {
            Log.w(TAG, "NavHost not attached; route remains queued.")
            // For an asynchronously attached NavHost, call this
            // method again after the host has been attached.
            return
        }

        val controller = navHost.navController

        // currentDestination is nullable before a destination has been established.
        // Do not access controller.graph merely to check whether setGraph() has been called.
        if (controller.currentDestination != null) {
            onNavigationGraphReady()
            return
        }

        // If the graph/destination becomes available later,
        // mark navigation ready when a destination is established.
        val listener = object : androidx.navigation.NavController.OnDestinationChangedListener {
            override fun onDestinationChanged(
                navController: androidx.navigation.NavController,
                destination: androidx.navigation.NavDestination,
                arguments: Bundle?
            ) {
                navController.removeOnDestinationChangedListener(this)
                onNavigationGraphReady()
            }
        }

        controller.addOnDestinationChangedListener(listener)
    }

    /**
     * Captures warm-start QR scans when the Activity is reused under singleTop or singleTask.
     */
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        processIncomingIntentOnce(intent) { candidate ->
            handleIncomingWakeupIntent(candidate)
        }
    }

    private fun processIncomingIntentOnce(incomingIntent: Intent?, process: (Intent) -> Unit) {
        val candidate = incomingIntent ?: return
        // Ignore historical intents re-delivered from recent-apps switcher or configuration changes
        if ((candidate.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return
        if (candidate.getBooleanExtra(EXTRA_ROUTE_CONSUMED, false)) return

        if (!processingIntent.compareAndSet(false, true)) return
        try {
            process(candidate)
            candidate.putExtra(EXTRA_ROUTE_CONSUMED, true)
        } finally {
            processingIntent.set(false)
        }
    }

    private fun handleIncomingWakeupIntent(incomingIntent: Intent?): Boolean {
        if (incomingIntent?.action != Intent.ACTION_VIEW) return false
        val uri: Uri = incomingIntent.data ?: return false

        // 1. Verify trusted HTTPS origin and path
        val isTrusted =
            uri.scheme.equals("https", ignoreCase = true) &&
            uri.host.equals(TRUSTED_HOST, ignoreCase = true) &&
            uri.path == TRUSTED_PATH

        if (!isTrusted) {
            Log.w(TAG, "Rejected untrusted deep-link origin: $uri")
            return false
        }

        // 2. Reject missing or conflicting duplicate query parameters
        if (uri.getQueryParameters("route").size != 1 ||
            uri.getQueryParameters("id").size != 1) {
            Log.w(TAG, "Rejected missing or duplicate routing keys in URI")
            return false
        }

        val rawRoute = uri.getQueryParameter("route")
        val rawId = uri.getQueryParameter("id")

        val target = validateAndBuildTarget("wakeup", rawRoute, rawId)
        if (target.routeKey != ROUTE_HOME) {
            dispatchNavigationTarget(target)
            return true
        }
        return false
    }

    private fun queryDeferredSceneParameters() {
        // Application-defined timeout configuration; tune for deployed service behavior
        attributionAdapter.fetchDeferredInstallParams(timeoutMs = 5000L) { jsonPayload ->
            if (jsonPayload == null) {
                Log.d(TAG, "No deferred routing parameters recovered; remaining on default view.")
                return@fetchDeferredInstallParams
            }
            val target = parseJsonRoutingPayload("deferred_install", jsonPayload)
            runOnUiThread {
                dispatchNavigationTarget(target)
            }
        }
    }

    private fun parseJsonRoutingPayload(sourcePrefix: String, rawJson: String): NavigationTarget {
        return try {
            val obj = JSONObject(rawJson)
            // Defensive normalization: map canonical 'route_key'/'target_id' with external 'route'/'id' fallback
            val rawRoute = obj.optString("route_key", "").ifEmpty { obj.optString("route", "") }
            val rawId = if (obj.has("target_id") && !obj.isNull("target_id")) {
                obj.optString("target_id", "")
            } else {
                obj.optString("id", "")
            }
            validateAndBuildTarget(sourcePrefix, rawRoute, rawId)
        } catch (e: Exception) {
            Log.w(TAG, "Malformed routing JSON payload: ${e.message}")
            NavigationTarget(sourcePrefix, ROUTE_HOME, null, false)
        }
    }

    /**
     * Enforces strict allowlist and regex bounds on untrusted routing parameters.
     */
    private fun validateAndBuildTarget(
        sourcePrefix: String,
        rawRoute: String?,
        rawId: String?
    ): NavigationTarget {
        val cleanRoute = if (rawRoute != null && ALLOWED_ROUTES.contains(rawRoute)) {
            rawRoute
        } else {
            ROUTE_HOME
        }

        if (cleanRoute == ROUTE_HOME) {
            return NavigationTarget(sourcePrefix, ROUTE_HOME, null, false)
        }

        // Entity-specific routes strictly require a valid, sanitized targetId
        val cleanId = if (rawId != null && SAFE_ID_REGEX.matches(rawId)) {
            rawId
        } else {
            null
        }

        if (cleanId == null) {
            Log.w(TAG, "Route $cleanRoute rejected due to missing or invalid target_id.")
            return NavigationTarget(sourcePrefix, ROUTE_HOME, null, false)
        }

        val needsAuth = PROTECTED_ROUTES.contains(cleanRoute)
        return NavigationTarget("${sourcePrefix}_${cleanRoute}_${cleanId}", cleanRoute, cleanId, needsAuth)
    }

    /**
     * Invoked when the application's NavHost / NavController graph is attached and ready.
     */
    fun onNavigationGraphReady() {
        navigationReady = true
        flushPendingNavigation()
    }

    private fun flushPendingNavigation() {
        if (!navigationReady || isFinishing || isDestroyed) return
        val target = pendingUiTarget.getAndSet(null) ?: return
        dispatchReadyNavigationTarget(target)
    }

    private fun dispatchNavigationTarget(target: NavigationTarget) {
        if (isFinishing || isDestroyed) return

        // Stage route if the UI navigation graph has not finished inflating
        if (!navigationReady) {
            pendingUiTarget.set(target)
            return
        }

        dispatchReadyNavigationTarget(target)
    }

    private fun dispatchReadyNavigationTarget(target: NavigationTarget) {
        if (target.routeKey == ROUTE_HOME) {
            navigateToHomeScreen()
            return
        }

        // Check authentication gate for protected destinations
        if (target.requiresAuth && !attributionAdapter.isUserAuthenticated()) {
            Log.d(TAG, "Protected route ${target.routeKey} staged pending user authentication.")
            PendingRouteStore.stage(target)
            navigateToLoginScreen()
            return
        }

        executeScreenTransition(target.routeKey, target.targetId)
    }

    /**
     * Invoked by the authentication controller after login or registration completes.
     */
    fun onUserAuthenticationCompleted() {
        val pending = PendingRouteStore.consume()
        if (pending == null) {
            navigateToHomeScreen()
            return
        }

        if (isFinishing || isDestroyed) return

        if (!attributionAdapter.isUserAuthenticated()) {
            PendingRouteStore.stage(pending)
            navigateToLoginScreen()
            return
        }

        val validated = validateAndBuildTarget(
            pending.eventKey,
            pending.routeKey,
            pending.targetId
        )

        if (validated.routeKey == ROUTE_HOME) {
            navigateToHomeScreen()
            return
        }

        executeScreenTransition(validated.routeKey, validated.targetId)
    }

    private fun executeScreenTransition(routeKey: String, targetId: String?) {
        // Implementation: perform backend-authorized resource fetch and invoke NavController.navigate()
        Log.d(TAG, "Dispatching verified screen transition: route=$routeKey, id=$targetId")
    }

    private fun navigateToLoginScreen() {
        // Implementation: transition to LoginFragment / LoginActivity
    }

    private fun navigateToHomeScreen() {
        // Implementation: display default home screen
    }
}

/**
 * Application-owned adapter skeleton. Replace stubbed methods with verified OpoInstall SDK
 * callbacks, Google Play Install Referrer queries, and your production session manager.
 */
class AttributionRoutingAdapter {
    fun fetchDeferredInstallParams(timeoutMs: Long, callback: (String?) -> Unit) {
        // Bind to verified SDK install parameter API or Play Install Referrer in production
        callback(null)
    }

    fun isUserAuthenticated(): Boolean {
        // Bind to production authentication session state
        return false
    }
}

安全性與診斷:防止路由注入與導航遺失

根據 OWASP 指南防範深度連結路由注入與開放重新導向漏洞

從 QR Code 或延遲歸因回呼擷取的參數代表不受信任的外部輸入。根據 OWASP 行動應用程式安全性驗證標準與 OWASP 行動應用程式十大安全風險與指南,客戶端路由器必須強制執行三道防禦界線:

  • 禁止載入任意 WebView URL:絕對不允許路由參數(?route=webview&url=https://...)在應用程式內部的 WebView 中載入未驗證的外部 URL。攻擊者可以將網路釣魚 URL 編碼到 QR Code 中,利用應用程式的原生 UI 框架來竊取憑證。
  • 拒絕重複參數污染:如果被竄改的 URL 包含多個互相衝突的金鑰(?route=home_default&route=room_lobby),應拒絕該 URI,而不是讓解析器挑選任意的第一個或最後一個元素。
  • 強制執行後端資源授權:傳遞 target_id=sku_8842 只是指示客戶端要請求哪一個檢視。當畫面查詢後端 API 時,伺服器必須獨立驗證目前的使用者工作階段是否獲授權存取該實體,以防止不安全的直接物件參照 (IDOR) 漏洞。

診斷冷啟動與熱啟動導航競賽條件 (Race Conditions)

在冷啟動期間,深度連結意圖或快取的 SDK 回呼可能會在主要的 NavHostFragment或根導航圖表完成載入之前解析。在視圖階層就緒之前嘗試呼叫 navController.navigate() 會導致執行階段例外狀況或遺失轉換。務必延遲導航執行,直到主機視圖階層已附加且處於作用中狀態(!isFinishing && !isDestroyed)。

妥善處理過期的促銷活動、已刪除的 SKU 以及遺失的參數

實體 QR Code 通常在數位促銷活動結束或產品 SKU 下架後,仍然部署在零售商店或平面媒體中。當目標畫面擷取 target_id 的資料並從後端收到 HTTP 404 Not Found 或 410 Gone 回應時,客戶端必須捕捉該錯誤,向使用者顯示清晰的資訊通知,並乾淨俐落地導航至 home_default。

雙狀態 QR 路由部署前驗證檢查清單

在發布實體 QR宣傳品之前,工程團隊應在實體裝置上執行以下七點驗證協定:

  • 步驟 1:已安裝的冷啟動喚醒:強制關閉已安裝的 App 並掃描 QR Code。確認作業系統直接將 App 打開至目標畫面(product_detail)。
  • 步驟 2:已安裝的熱啟動喚醒與重複掃描:在次要頁籤開啟 App 的情況下,將 App 置於背景並掃描 QR Code。確認 onNewIntent 會路由至目標畫面。導航離開後,第二次掃描同一個 QR Code,並確認它會再次導航,而不會被過於寬泛的去重複機制所阻擋。
  • 步驟 3:未安裝的延遲復原(公開路由):解除安裝 App、掃描 QR Code、完成商店安裝並啟動。確認延遲參數回呼會將酬載正規化並路由至 product_detail。
  • 步驟 4:帶有驗證閘道的未安裝延遲復原(受保護路由):解除安裝 App、掃描目標為 room_lobby 的 QR Code、安裝並在登出狀態下啟動。確認 App 會先路由至登入畫面,且在完成登入後會消耗 PendingRouteStore 並導航至 room_lobby。
  • 步驟 5:不受信任的來源與重複金鑰拒絕:從不受信任的主機或透過重複的查詢參數(?route=product_detail&route=room_lobby)觸發 ACTION_VIEW 意圖。確認 handleIncomingWakeupIntent 會拒絕該意圖並記錄警告。
  • 步驟 6:螢幕旋轉冪等性:抵達深度連結畫面後,旋轉裝置以觸發 Activity 重新建立。確認 EXTRA_ROUTE_CONSUMED 旗標可防止原始意圖再次執行。
  • 步驟 7:離線 / 未匹配優雅退回:在飛航模式下啟動乾淨安裝。確認擷取逾時會在不阻塞 UI執行緒的情況下解析,並將使用者引導至 home_default。

App 內路由機制比較分析

評估 URL Schemes、Universal Links 與 App Links 以及延遲場景還原

行動平台提供了不同的機制,可將使用者在不同安裝狀態下引導至應用程式檢視中:

路由機制 目標 App 狀態 作業系統驗證要求 跨商店安裝的參數存活率 主要工程限制
自訂 URL Scheme 僅限已安裝 無(僅限 Manifest 宣告) 否(若未安裝 App 則會失敗) 若不存在會觸發瀏覽器錯誤彈出視窗;容易遭到 Scheme 劫持
Universal Links / App Links 已安裝優先 iOS:AASA 檔案 + Associated Domains 權限;Android:assetlinks.json + 相符的 App 簽署憑證與已驗證的意圖篩選器設定 否(新安裝需要延遲層) 可能會被某些受限制的 App 內 webview 容器所抑制
Google Play Install Referrer Android 新安裝 官方 Google Play 商店散布路徑 是(支援的 referrer 字串) Android Play 商店專屬;不涵蓋 iOS 或非 Play 商店
SDK 輔助的場景還原 已安裝與新安裝 網域關聯 + 已驗證的 SDK 轉接器 取決于支援的平台機制 需要路由白名單驗證與平台隱私權審查

跨安裝狀態與作業系統的架構取捨

自訂 URL Schemes(myapp://product/123)缺乏密碼編譯網域所有權驗證,這意味著任何應用程式都可以註冊相同的 Scheme 並攔截連結。此外,當未安裝 App 時掃描原始自訂 Scheme QR Code 會導致無法復原的瀏覽器錯誤。

Universal Links 與 Android App Links 透過 HTTPS 驗證網域所有權,並在 App 不存在時退回到網頁落地頁,這使其成為 QR 路由的必要基礎。將 Universal Links 與 App Links 用於已安裝的喚醒,並結合平台支援的延遲復原(例如 Android 上的 Google Play Install Referrer 與經隱私權審查的 SDK 轉接器),就能從單一 QR 資產實現雙狀態場景還原。

常見問題 (FAQ)

如果尚未安裝 App,自訂 QR 參數如何開啟特定的 App 內頁面?
當尚未安裝 App 時,QR Code 會開啟一個網頁落地頁,在將使用者引導至應用程式商店之前記錄路由參數。在安裝後的首次啟動時,應用程式會嘗試透過平台支援的延遲復原機制來復原參數、驗證目標路由,並導航至指定的畫面。
為什麼掃描我的 QR Code 會將 App 打開至首頁,而不是目標頁面?
掉落至首頁通常是由於三個原因:Android Activity 被設定為 <code>singleTop</code> 或 <code>singleTask</code> 但沒有覆寫 <code>onNewIntent</code> 來擷取熱啟動意圖、導航路由器在主 UI 圖表完成掛載之前執行,或者目標畫面需要使用者驗證並在登入流程中丟棄了待處理的路由。
如何防止惡意 QR Code 將使用者引導至未經授權的內部畫面?
切勿將原始 URL 路徑直接傳遞到導航控制器中。請驗證收到的 URI 通訊協定、主機與路徑,強制執行允許路由金鑰的嚴格客戶端白名單,使用正規表示式驗證實體 ID 格式,並在呈現受保護畫面之前,驗證客戶端驗證狀態與伺服器端資源權限。

總結與決策框架

若要建立帶有自訂參數的 App 路由 QR Code,工程團隊必須將精簡的外部 URL 查詢字串與具備狀態感知的原生導航控制器連結起來。完整的實作可處理現有使用者的直接作業系統喚醒,以及新應用程式商店安裝的條件式延遲參數復原。

由於跨應用程式商店的參數連續性取決於平台專屬機制(例如 Android 上的 Google Play Install Referrer 以及 iOS 上經過驗證且符合隱私權規範的工作流程),應用程式必須將復原的路由酬載視為不受信任的候選者。透過結合白名單路由分派器、驗證狀態排隊以及優雅的首頁退回機制,團隊能夠提供可靠的場景還原,而不會損及應用程式穩定性或平台隱私權規則。

OpoInstall 的 SDK 與開發者說明文件提供了用於實作網頁至 App 路由與參數傳遞的元件。在推出生產環境之前,請確認您所部署的 SDK 版本所支援的特定 API 簽章、回呼結構描述與平台行為。

如需整合詳細資料,請參閱 OpoInstall 開發者說明文件、存取 OpoInstall SDK 下載中心,或在 OpoInstall 開發者主控台中設定您的應用程式路由網域。

相關資料

Share this article