QRコード作成時にカスタムパラメータを紐付けるには? アプリルーティング用のカスタムパラメータを紐付けるには、ホストされたランディングページのリンクに構造化されたURLクエリキーを付与し、ウェブ統合レイヤーでそれらのキーを取得した上で、アプリの直接起動時またはインストール後の初回起動時に、検証済みペイロードをネイティブのナビゲーションコントローラーにディスパッチする必要があります。
QRコードによるアプリ内ルーティングは、構造化された遷移先パラメータを2次元バーコードのURLに埋め込むクライアント・サーバー間のナビゲーションアーキテクチャです。アプリケーションがインストールされている場合はOSの直接的なディープリンクによって、新しくインストールされる場合はプラットフォームがサポートする遅延リカバリによってこれらのパラメータを解決することで、開発チームはユーザーをデフォルトのホーム画面ではなく、特定のアプリ内画面へ直接誘導できます。
| 用語 | 定義 | 関連エンティティ | 検索意図の役割 |
|---|---|---|---|
| アプリ用QRコードの作成 | モバイルナビゲーション用に、実行可能なルーティングURLを2次元バーコードにエンコードする開発ワークフロー。 | シーン復元 | 情報提供型 / 技術的 |
| アプリ用QRコード | ネイティブアプリケーションの起動やストア誘導をトリガーするように設定されたバーコードアセット。 | ユニバーサルリンク / アプリリンク | 技術的 / 商用 |
| 動的QRコード | 遷移先のペイロードをサーバー側で更新できる、中間ルーティングエンドポイントを指すバーコード。 | URIインディレクション | 商用 |
| QRコード追跡 | ルーティングの流入リクエストおよびその後のアプリ内画面アクティベーションの測定。 | チャネル統計とトラッキング | 商用 |
アーキテクチャの概要:アプリシーン復元のためのQRコード作成方法

ホーム画面離脱の問題:一般的なストアリダイレクトが意欲の高いユーザーの導線を損なう理由
物理的なQRコードは、ユーザーの意欲が高い文脈の場で頻繁に展開されます。例えば、店舗の買い物客が商品の仕様を確認するために棚のタグをスキャンしたり、飲食店の利用者が特定のメニューカテゴリから注文するためにテーブルテントをスキャンしたり、プレイヤーがマルチプレイヤーゲームのロビーに参加するために招待用バーコードをスキャンしたりするケースです。バーコードに一般的なアプリストアのリンクやパラメータのないドメインがエンコードされている場合、物理的なスキャンからネイティブアプリケーションの実行環境へと移行する過程で、ユーザーの文脈上の意図が失われてしまいます。
アプリケーションを起動すると、ユーザーはデフォルトのトップレベルのホーム画面に飛ばされます。複雑なカテゴリツリーを手動で辿らせたり、検索クエリを再入力させたり、ルームIDを入力させたりすることは、ユーザーに多大な認知的・機械的な負担(フリクション)を与えます。アプリのシーン復元は、遷移先の状態をQRコードのURLにエンコードし、ネイティブクライアント内でそのターゲットビューをプログラムによって再構築することで、この離脱に対処します。
デュアルステート・ルーティングの仕組み:直接起動 vs 遅延シーン復元

ユーザーがアプリルーティング用のQRコードをスキャンした際、モバイルデバイスは2つのインストール状態のいずれかにあります。完全なルーティングアーキテクチャは、明確な技術的パスを通じて両方のライフサイクルを処理します。
- ダイレクトウェイクアップルーティング(アプリが既にインストールされている場合): 対象のアプリケーションがすでにデバイスに存在している場合、QRコードのスキャンによりOSのディープリンク(iOSではAppleユニバーサルリンク、AndroidではAndroidアプリリンク)がトリガーされます。どちらのメカニズムも、暗号学的に検証されたドメイン関連付けファイルのホスティングが必要です。iOSではXcodeの
Associated Domains機能とペアリングされたapple-app-site-association(AASA)ファイルが必要となり、Androidではアプリケーションの署名証明書のSHA-256フィンガープリントと一致し、マニフェストのインテントフィルターで検証されたデジタルアセットリンクファイル(/.well-known/assetlinks.json)が必要です。検証に成功すると、OSはアプリケーションを起動し、URIをクライアントのライフサイクルデリゲートへ直接渡します。 - 遅延シーン復元(アプリがインストールされていない場合): アプリケーションが存在しない場合、ユーザーは中間ウェブランディングページを経由してプラットフォームのアプリストアに誘導されます。ネイティブバイナリがまだインストールされておらずOSのインテントを受け取ることができないため、ルーティングパラメータはストアへリダイレクトされる前にステージング(保持)され、インストール完了後のアプリケーションの初回起動時(Day 0のコールドスタート)に復元される必要があります。
プラットフォーム固有のリカバリ境界:Google Playインストールリファラー、iOSユニバーサルリンク、およびSDKアダプター
ユーザーがアプリストアからアプリケーションをインストールした後、リファラーおよびルーティングパラメータの復元は、プラットフォームや、アプリケーションまたはアトリビューションSDKによって実装されたメカニズムに依存します。ウェブURLを通じてrouteやidのパラメータを渡すだけでは、そのパラメータがアプリストアのインストールプロセスを生き残ることは保証されません。
- AndroidおよびGoogle Play: Google PlayインストールリファラーAPIは、Google Playストア経由のインストールに関連付けられたサポート対象のリファラーメタデータを提供できます。このメカニズムは、任意のサードパーティ製APKのサイドロードや代替ストアではなく、公式のGoogle Playストア経由で完了したインストールに特化して適用されます。アプリケーションは実際のリファラーペイロードを設定・テストし、期待されるパラメータがリダイレクトおよびインストールのフローを正しく通過することを確認する必要があります。
- iOSおよびApp Store: Apple App Storeは、任意のウェブランディングページのクエリパラメータを、新しくインストールされたアプリケーションバイナリへと自動的に引き継ぎません。ユニバーサルリンクは、アプリケーションがすでにインストールされており、関連するドメイン設定が有効な場合に直接ルーティングをサポートしますが、新規インストール後の遅延パラメータ復元をそれ単体で保証するものではありません。
- アトリビューションSDK: 導入するSDKの正確なバージョンとプラットフォーム向けに文書化されているリカバリメカニズムのみを使用してください。SDKがすべてのブラウザやインストールパスであらゆるルーティングパラメータを復元できると仮定せず、実装内容、データ収集、およびプライバシーへの影響を検証してください。
iOSのアトリビューションとプライバシーの境界:Appleのアンチフィンガープリンティング規則への準拠
iOSのルーティングおよび遅延シーン復元の実装においては、アプリケーションやウェブサイト間でユーザーやデバイスを識別するために、禁止されているデバイスフィンガープリンティングに依存してはなりません。Appleのユーザープライバシーとデータ利用に関するガイダンスおよびAppleデベロッパープログラムライセンス契約に基づき、開発者はこの目的のためにデバイス、ブラウザ、構成、位置情報、またはネットワーク信号から一意の識別子を導出することを固く禁じられています。App Tracking Transparency(ATT)の承認を得たとしても、本来禁止されているフィンガープリンティングが許可されるわけではありません。
アプリがインストールされている場合の検証済み直接アプリルーティングには、ユニバーサルリンクを使用してください。iOSでのインストール後のシーン復元については、導入されたSDKのバージョンおよび適用されるAppleの要件に対して動作が確認されている、文書化されたメカニズムを使用してください。必要に応じてSDKのプライバシー開示内容や必要理由APIの宣言を確認しますが、プライバシーマニフェスト(PrivacyInfo.xcprivacy)が存在していることをもって、アトリビューションの実装が準拠していることの証明とみなさないでください。
選択したSDKが、許可されていると検証できないマッチング方法に依存している場合は、iOSへの導入にその方法を推奨しないでください。信頼性の高い遅延ルーティングコンテキストを確立できる許可されたメカニズムが存在しない場合は、通常のオンボーディングフローを維持し、ユーザーを安全なデフォルトのホーム画面に着地させ、未検証のマッチングを無理に作り出すのではなく、インストールを「アトリビューションなし」としてマークしてください。
デュアルステートQRシーン復元パイプラインの可視化
以下のアーキテクチャ図は、パラメータ化されたQRコードがクライアント側のルートバリデータと認証ゲートで収束する前に、OSの直接起動またはストア経由の遅延リカバリへと分岐するステージを示しています。
[ユーザーがQRコードをスキャン] ──> URL (?route=product_detail&id=sku_8842&channelCode=pos_01)
│
┌────┴────────────────────────────────────────┐
▼ (アプリがすでにインストール済み) ▼ (アプリが未インストール)
[OSユニバーサルリンク / アプリリンク] [中間H5ランディングページ]
│ │
▼ ▼
[ホット/コールド起動コールバック] [ストアダウンロードと初回起動]
(onNewIntent / continueUserActivity) (プラットフォーム対応の遅延復元)
│ │
└────────────────────┬────────────────────────┘
▼
[クライアントのルートバリデータ & 認証ゲート]
│
┌────────────────────┴────────────────────────┐
▼ (許可リスト登録済み & 認証済み) ▼ (無効なルート / マッチせず)
[商品詳細画面へ遷移] [デフォルトのホーム画面へフォールバック]
ステップ1:カスタムルーティングパラメータスキーマの設計
実用的なルーティングペイロードと受動的アトリビューションタグの構造化
アプリルーティング用のQRコードを作成する際、開発チームは、外部のコンパクトなURLクエリキーと内部の正規化されたJSONペイロードを分離する、クリーンな2階層のデータ契約を確立する必要があります。
| コントラクト階層 | フィールド命名規則 | 表現例 | アーキテクチャ上の目的 |
|---|---|---|---|
| 外部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からの値をそのまま信用しない |

この2階層モデルでは:
- 受動的アトリビューションタグ(
channelCodechannel_code)は、クライアントのUI状態を変更することなく、マーケティングのコンバージョン数を測定するための分析パイプラインによって消費されます。 - 実用的なルーティングパラメータ(
routeroute_key、およびidtarget_id)は、どの画面をマウントし、どのエンティティを取得すべきかをネイティブクライアントのナビゲーションコントローラーに指示します。 - 認証ポリシーの分離:ルートに認証が必要かどうか(
requires_auth)は、外部のQRクエリパラメータから受け取るのではなく、クライアントアプリケーション内部の信頼できるルートレジストリから厳密に導出されなければなりません。
IETF RFC 3986 パーセントエンコーディングおよびペイロードサイズ制限の適用
QR遷移先URLに付与されるすべてのルーティングパラメータは、IETF RFC 3986で規定されているURIジェネリック構文に準拠している必要があります:
- ネストされた区切り文字の回避:生データのJSON文字列(
?data={"route":"product","id":123})をURLクエリ文字列に直接埋め込まないでください。エスケープされていない中括弧、引用符、コロンは、インアプリブラウザの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が欠落している場合、正規化処理はペイロードをtarget_id: nullを指定したhome_defaultへとダウングレードする必要があります。
以下のJSONスキーマは、正規化されたシーン復元ペイロードの正準構造を定義しています。
{
"$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コードをスキャンすると、モバイルブラウザはホストされている中間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() {
// パラメータ汚染を防ぐため、重複キーを拒否する
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();
バージョン検証済みアダプターを介したWeb SDKの統合
ランディングページは、即座のアプリ起動試行と遅延パラメータのステージングの両方をコーディネートします。アトリビューションWeb SDK(opoinstall.jsなど)を導入する前に、導入するSDKバージョンの公式ドキュメントに対して、正確なJavaScriptバンドルURL、初期化シグネチャ、準備完了コールバック、起動メソッド、およびパラメータスキーマを確認してください。
SDK固有の動作は、正規化されたroutingPayloadオブジェクトを受け取る小規模なアプリケーションアダプターの背後に隠蔽してください。ウェブ統合やチャネルパラメータ設定の詳細な仕様については、OpoInstall開発者ドキュメントをご参照ください。
ダイレクトストアフォールバックリンクを通じたルーティングパラメータの保持
Web JS SDKが利用できない場合やブラウザのセキュリティ制御によってブロックされている場合、ランディングページはダイレクトストアリダイレクトリンクにフォールバックします。Google Playを利用するAndroidのデプロイでは、公式のPlay Storeフローを通じてインストールされた際にネイティブクライアントがGoogle PlayインストールリファラーAPIを介してルーティングコンテキストを復元できるように、フォールバックリンクに正規化されたルーティングキーをPlay Storeのreferrerパラメータとしてエンコードできます。
// 正規化されたルーティングコンテキストを保持するAndroid Play StoreフォールバックURLの例
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アクティビティ起動モードの処理:singleTaskがonCreateとonNewIntentの両方を必要とする理由
Androidのディープリンクアーキテクチャでは、アクティビティがルーティングインテントを受信する仕組みは、設定された起動モードとナビゲーションダッシュボードの設計に依存します。開発者はタスクスタックの要件に応じて、Jetpack Navigation、singleTop、またはsingleTaskを用いた標準的な起動モードを使用できます。
ディープリンク起動時に同一インスタンスが重複して積み重なるのを防ぐため、アクティビティがsingleTopまたはsingleTaskで設定されている場合、2つの異なるライフサイクルパスを処理する必要があります。
- コールドスタート(
onCreate): アプリケーションプロセスが実行されていない場合、QRコードをスキャンするとアクティビティが最初から起動します。受信したIntentはonCreate(savedInstanceState: Bundle?)に配信されます。 - ウォームスタート(
onNewIntent): 設定された起動モードおよびタスクスタックの条件下でOSが既存のアクティビティインスタンスを再利用する場合、受信したディープリンクのIntentはonNewIntent()に配信されます。singleTopの場合、対象のアクティビティがすでに該当するタスクスタックの一番上にいる場合にのみ再利用が発生し、そうでない場合は新しいアクティビティインスタンスが作成されます。onCreate()とonNewIntent()の両方のハンドリングを実装し、アプリケーションのナビゲーションアーキテクチャにおける実際の動作を検証してください。
セッションの後半で同じQRコードを正当に再度スキャンしたユーザーを永続的にブロックすることなく、両方の状態を信頼性高く処理するため、アクティビティは処理済みの各Intentインスタンスに消費フラグ(EXTRA_ROUTE_CONSUMED)をマークし、履歴からのアプリスイッチャー起動(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)によって単に再配信されたインテントは無視する必要があります。
iOSライフサイクルエントリーポイントの処理:AppDelegate vs. UISceneDelegate継続アクティビティ
iOSでは、直接のユニバーサルリンクルーティングおよび遅延パラメータの取得は、アプリケーションの設定されたUIライフサイクルに合わせる必要があります:
- シーンベースのライフサイクル(
UISceneDelegate):UISceneを採用しているアプリケーション(iOS 13以降で標準)では、コールドローンチのユニバーサルリンクはconnectionOptions.userActivitiesを介してscene(_:willConnectTo:options:)に到着し、ウォームレジュームのユニバーサルリンクはscene(_:continue:)に到着します。 - アプリケーションデリゲートライフサイクル(
UIApplicationDelegate): シーンデリゲートを使用しないアプリケーションでは、ユニバーサルリンクはapplication(_:continue:restorationHandler:)に配信されます。 - 遅延初回起動リカバリ: ユニバーサルリンク継続デリゲートとは独立して、遅延インストールパラメータのクエリは、ルートビュー階層の準備が整った後の最初のアプリケーション起動時に一度だけ実行されます。
開発者は、OpoInstall SDKダウンロードセンターからプラットフォームSDKパッケージをダウンロードし、ライフサイクル統合テンプレートを確認できます。
直接起動パラメータと初回起動の遅延パラメータを単一のナビゲーションルーターに統合する
一貫したセキュリティとナビゲーション動作を維持するため、クライアントアーキテクチャでは、直接起動のURIと遅延インストールのJSONペイロードの両方をUIルーターにディスパッチする前に、単一のNavigationTargetモデルに正規化します。
- オリジンの検証(ダイレクトインテント): 受信した
Intent.ACTION_VIEWのURIがhttpsスキームを使用しており、アプリケーションの検証済み関連付けホスト(promo.example.com)およびパス(/open)に一致し、競合する重複クエリキーが含まれていないことを検証します。 - スキーマの正規化と許可リストのチェック: 外部の
route/idキーまたは内部のroute_key/target_idフィールドを正準モデルにマッピングし、許可リストにないルートや不正な形式のIDを拒否します。クライアントのKotlinパースロジックは説明用の防御的パーススケルトンとして機能します。本番システムでは、ディスパッチの前に完全な正準スキーマ規則に照らし合わせてペイロードをパースしてください。 - 単一実行のリアントランシー(再入)ガード: アトミックな配信ごとのガードを使用し、並行コールバックが重複するフラグメントトランザクションをトリガーしないようにします。
認証ゲートの管理:ユーザーのログインが完了するまで保留中のディープリンクルートをキューイングする

新規ユーザーがQRコード経由でアプリストアからアプリケーションをインストールし、初めて起動した場合、そのユーザーは通常未認証の状態です。QRコードが保護された画面(room_lobbyやpromo_campaignなど)をターゲットにしており、ルーターが即座に遷移を試みた場合、認証ゲートがユーザーをログイン画面にリダイレクトし、ディープリンクのコンテキストが失われてしまいます。
認証境界を越えてシーン復元を維持するには:
- 内部ルートポリシーのルックアップ: 外部ペイロードフラグを信頼するのではなく、クライアントの内部
PROTECTED_ROUTESレジストリを確認して、ルートに認証が必要かどうかを判断します。 - アトミックなステージング: ユーザーが未認証の場合、検証済みの
NavigationTargetをアトミックかつセッションスコープのPendingRouteStore(AtomicReference)に保存し、ユーザーをログインフローに誘導します。インメモリのPendingRouteStoreはアクティブなアプリケーションプロセスに適用される点に注意してください。認証フローがプロセス終了をまたぐ可能性がある場合は、アカウント切り替え時に状態をクリアする暗号化された時間制限付きのローカルストアを使用してください。 - 認証後の再検証とディスパッチ: ログインが完了したら、ステージングされたターゲットをアトミックに消費(
getAndSet(null))し、セッションが現在認証されていることを確認し、ルートパラメータを再検証した上で、画面遷移を実行します。
ナビゲーションホストの準備完了と状態復元
onNavigationGraphReady()コールバックは、アプリケーション実際のナビゲーションホスト初期化に接続されている必要があります。ナビゲーショングラフがXMLでホストされたNavHostFragmentで定義されている場合、アクティビティはこの接続をホストとその遷移先(destination)の準備が整った後に確立できます。グラフが非同期でインストールされる場合は、グラフの初期化が完了した後にのみコールバックを呼び出してください。
アクティビティフィールドにのみ保存された保留中のナビゲーションリクエストは、構成の変更(画面回転など)時に失われる可能性があります。本番実装では、未消費のルーティング状態をライフサイクルアウェアなViewModelに保持し、システム駆動によるアクティビティの再作成やプロセスの終了を跨いだ復元が必要な場合にはSavedStateHandleを使用してください。ここに示されているコードはアーキテクチャの例であり、アプリケーション固有のナビゲーションおよびセッション統合が必要です。
以下のKotlinコードは、これらの制御を示す、アプリケーション所有のSDKおよびナビゲーションアダプターを備えた実用的なAndroidルーティングのスケルトンを提供します。
<!-- アプリリンク検証のためのAndroidマニフェスト設定 -->
<!-- ファイル: 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">
<!-- 検証済みAndroidアプリリンクインテントフィルター -->
<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" />
<!-- オリジンは /.well-known/assetlinks.json の SHA-256 フィンガープリントと一致する必要があります -->
<data
android:scheme="https"
android:host="promo.example.com"
android:pathPrefix="/open" />
</intent-filter>
</activity>
</application>
</manifest>
// Androidルーティングアクティビティの実装
// ファイル: app/src/main/java/com/example/app/SceneRestorationActivity.kt
//
// 統合の注意点:
// このサンプルはアーキテクチャ上のルーティングスケルトンであり、そのまま本番実装としてドロップインできるものではありません。
// デプロイ前に、AttributionRoutingAdapterおよびNavControllerの初期化を、検証済みのSDKリリース、
// セッションマネージャー、およびNavHostFragmentにバインドしてください。
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
/**
* 許可リストに登録されたアプリ内遷移先を表す、検証済みのナビゲーションターゲットモデル。
* 認証要件は、内部のルートレジストリから厳密に導出されます。
*/
data class NavigationTarget(
val eventKey: String,
val routeKey: String,
val targetId: String?,
val requiresAuth: Boolean
)
/**
* コールドスタート時や初回インストールのオンボーディング中に、未認証ユーザーが保護されたディープリンクを
* トリガーした際に、保留中のルートを保持するプロセススコープのアトミックストア。
*/
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}$")
// 遅延インストールリカバリのクエリがコールドスタートごとに1回だけ実行されるように保証するプロセスレベルのガード
private val hasQueriedDeferredInstall = AtomicBoolean(false)
}
// 配信ごとの再入ガード。同じQRコードのその後の正当なスキャンを許可します
private val processingIntent = AtomicBoolean(false)
// UI/NavControllerがマウントされる前に到着したルートのためのステージングコンテナ
private val pendingUiTarget = AtomicReference<NavigationTarget?>(null)
private var navigationReady = false
// ベンダーSDKおよびセッション呼び出しをカプセル化するアプリケーション所有のアダプター
private val attributionAdapter = AttributionRoutingAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scene_restoration)
// レイアウトで定義されている場合、NavHostの初期化を接続する
connectNavigationHost()
// アクティビティの再作成(画面回転など)は、新規インストールや新しいQRスキャンではありません
if (savedInstanceState != null) {
return
}
val launchIntent = intent
val isDirectLinkLaunch =
launchIntent?.action == Intent.ACTION_VIEW &&
launchIntent.data != null
if (isDirectLinkLaunch) {
processIncomingIntentOnce(launchIntent) { candidate ->
handleIncomingWakeupIntent(candidate)
}
// ダイレクトリンク起動後は、直接ルートが拒否された場合であっても、
// 遅延リカバリを開始しないでください。
return
}
// 初回の通常のアプリケーション起動のみが、遅延リカバリを要求できます
if (hasQueriedDeferredInstall.compareAndSet(false, true)) {
queryDeferredSceneParameters()
}
}
/**
* このメソッドを実際のNavHost初期化に接続します。
* unsetなコントローラーグラフでIllegalStateExceptionをスローすることなく、
* currentDestinationのチェックとデリゲートリスナーを使用してグラフの準備完了を安全に検証します。
*/
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.")
// 非同期でアタッチされるNavHostの場合、ホストがアタッチされた後に
// このメソッドを再度呼び出してください。
return
}
val controller = navHost.navController
// currentDestinationは、遷移先が確立される前はnullableです。
// setGraph()が呼び出されたかどうかを確認するためだけにcontroller.graphにアクセスしないでください。
if (controller.currentDestination != null) {
onNavigationGraphReady()
return
}
// グラフや遷移先が後で利用可能になった場合、
// 遷移先が確立された時点でナビゲーションの準備完了をマークします。
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)
}
/**
* singleTopまたはsingleTaskの下でアクティビティが再利用された際に、ウォームスタートのQRスキャンをキャプチャします。
*/
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
// 最近使ったアプリの切り替え画面や構成変更から再配信された履歴上のインテントを無視する
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. 信頼されたHTTPSオリジンとパスを検証する
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. 欠落しているクエリパラメータや競合する重複クエリパラメータを拒否する
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() {
// アプリケーション定義のタイムアウト設定。デプロイされたサービスの動作に合わせて調整してください
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)
// 防御的正規化: 正準の 'route_key'/'target_id' を、外部の 'route'/'id' フォールバックとともにマッピングする
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)
}
}
/**
* 信頼されていないルーティングパラメータに対して、厳格な許可リストおよび正規表現による境界を強制します。
*/
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)
}
// エンティティ固有のルートには、有効でサニタイズされた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)
}
/**
* アプリケーションのNavHost / NavControllerグラフがアタッチされ、準備完了になったときに呼び出されます。
*/
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
// UIナビゲーショングラフのインフレートが完了していない場合はルートをステージングする
if (!navigationReady) {
pendingUiTarget.set(target)
return
}
dispatchReadyNavigationTarget(target)
}
private fun dispatchReadyNavigationTarget(target: NavigationTarget) {
if (target.routeKey == ROUTE_HOME) {
navigateToHomeScreen()
return
}
// 保護された遷移先に対する認証ゲートをチェックする
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)
}
/**
* ログインまたは登録が完了した後に、認証コントローラーによって呼び出されます。
*/
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?) {
// 実装: バックエンドで認可されたリソースの取得を実行し、NavController.navigate()を呼び出す
Log.d(TAG, "Dispatching verified screen transition: route=$routeKey, id=$targetId")
}
private fun navigateToLoginScreen() {
// 実装: LoginFragment / LoginActivityへの遷移
}
private fun navigateToHomeScreen() {
// 実装: デフォルトのホーム画面を表示する
}
}
/**
* アプリケーション所有のアダプターのスケルトン。スタブ化されたメソッドを、検証済みのOpoInstall SDK
* コールバック、Google Playインストールリファラークエリ、および本番セッションマネージャーに置き換えてください。
*/
class AttributionRoutingAdapter {
fun fetchDeferredInstallParams(timeoutMs: Long, callback: (String?) -> Unit) {
// 本番環境では、検証済みのSDKインストールパラメータAPIまたはPlayインストールリファラーにバインドする
callback(null)
}
fun isUserAuthenticated(): Boolean {
// 本番認証セッションの状態にバインドする
return false
}
}
セキュリティと診断:ルートインジェクションとナビゲーション離脱の防止
OWASPガイドラインに基づくディープリンクルートインジェクションおよびオープンリダイレクト脆弱性からの防御
QRコードや遅延アトリビューションコールバックから抽出されたパラメータは、信頼されていない外部入力を表します。OWASPモバイルアプリケーションセキュリティ検証標準(MASVS)およびOWASPモバイルトップ10のセキュリティリスクとガイドラインに準拠し、クライアントルーターは3つの防御境界を強制する必要があります:
- 任意のWebView URL読み込みの禁止:ルーティングパラメータ(
?route=webview&url=https://...)を使用して、検証されていない外部URLをアプリケーション内部のWebView内で読み込ませないでください。攻撃者がQRコードにフィッシングURLをエンコードし、アプリケーションのネイティブUIフレームを悪用して認証情報を収集する恐れがあります。 - 重複パラメータ汚染の拒否:操作されたURLに複数の競合するキー(
?route=home_default&route=room_lobby)が含まれている場合、パーサーに任意最初または最後の要素を選ばせるのではなく、URIを拒否します。 - バックエンドリソース認可の強制:
target_id=sku_8842を渡すことは、どのビューをリクエストすべきかをクライアントに指示しているに過ぎません。画面がバックエンドAPIにクエリを実行する際、サーバーは現在のユーザーセッションがそのエンティティへのアクセスを許可されていることを独自に検証し、インセキュア・ダイレクト・オブジェクト・リファレンス(IDOR)脆弱性を防止しなければなりません。
コールドスタートとウォームスタートにおけるナビゲーションの競合状態(レースコンディション)の診断
コールドスタート時には、ディープリンクのインテントやキャッシュされたSDKコールバックが、メインのNavHostFragmentやルートナビゲーショングラフのインフレートが完了する前に解決される場合があります。ビュー階層の準備が整う前にnavController.navigate()を呼び出そうとすると、ランタイム例外や遷移のドロップが発生します。ホストビュー階層がアタッチされ、アクティブ(!isFinishing && !isDestroyed)になるまで、常にナビゲーションの実行を遅延させてください。
有効期限切れのプロモーション、削除されたSKU、および欠落しているパラメータの適切な処理
物理的なQRコードは、デジタルプロモーションが終了したり、商品のSKUが廃止されたりした後も、小売店や印刷物にそのまま残り続けることがよくあります。ターゲット画面がtarget_idのデータをフェッチし、バックエンドからHTTPの404 Not Foundまたは410 Goneレスポンスを受信した場合、クライアントはエラーを捕捉し、ユーザーに明確な情報通知を表示した上で、home_defaultへクリーンに遷移させる必要があります。
デュアルステートQRルーティングのためのデプロイ前検証チェックリスト
物理的なQRコラテラルをリリースする前に、開発チームは物理デバイス上で以下の7項目の検証プロトコルを実行する必要があります:
- ステップ1:インストール済みアプリのコールドスタート起動:インストール済みアプリを強制終了し、QRコードをスキャンします。OSがアプリを直接ターゲット画面(
product_detail)に開くことを確認します。 - ステップ2:インストール済みアプリのウォームスタート起動と再スキャン:アプリをセカンダリータブで開いた状態でアプリをバックグラウンドに回し、QRコードをスキャンします。
onNewIntentがターゲット画面にルーティングすることを確認します。別の画面に移動してから、まったく同じQRコードを2回目にスキャンし、過度に広範な重複排除によってブロックされるのではなく、再度遷移することを確認します。 - ステップ3:未インストール状態からの遅延復元(パブリックルート):アプリをアンインストールし、QRコードをスキャンし、ストアのインストールを完了してから起動します。遅延パラメータコールバックがペイロードを正規化し、
product_detailにルーティングすることを確認します。 - ステップ4:認証ゲート付きの未インストール状態からの遅延復元(保護されたルート):アプリをアンインストールし、
room_lobbyをターゲットにしたQRコードをスキャンし、インストールし、ログアウト状態で起動します。アプリが最初にログインにルーティングし、ログイン完了後にPendingRouteStoreを消費してroom_lobbyに遷移することを確認します。 - ステップ5:信頼されていないオリジンおよび重複キーの拒否:信頼されていないホストから、または重複するクエリパラメータ(
?route=product_detail&route=room_lobby)を指定してACTION_VIEWインテントをトリガーします。handleIncomingWakeupIntentがインテントを拒否し、警告をログに記録することを確認します。 - ステップ6: 画面回転のべき等性:ディープリンクされた画面に到着した後、デバイスを回転させてアクティビティの再作成をトリガーします。
EXTRA_ROUTE_CONSUMEDフラグによって元のインテントが再実行されるのを防ぐことを確認します。 - ステップ7: オフライン / マッチしない場合の正常なフォールバック:機内モードでクリーンインストールを起動します。取得タイムアウトがUIスレッドをブロックすることなく解決され、ユーザーが
home_defaultに着地することを確認します。
アプリ内ルーティングメカニズムの比較分析
URLスキーム、ユニバーサルリンク / アプリリンク、および遅延シーン復元の評価
モバイルプラットフォームは、異なるインストール状態をまたいでユーザーをアプリケーションのビューにルーティングするための明確なメカニズムを提供しています。
| ルーティングメカニズム | ターゲットアプリの状態 | OS検証要件 | ストアインストールをまたいだパラメータの保持 | 主なエンジニアリング上の制約 |
|---|---|---|---|---|
| カスタムURLスキーム | インストール済みのみ | なし(マニフェスト宣言のみ) | いいえ(アプリが未インストールの場合は失敗) | 不在時にブラウザのエラーポップアップをトリガーする。スキームハイジャックに対して脆弱 |
| ユニバーサルリンク / アプリリンク | インストール済みが基本 | iOS: AASAファイル + Associated Domains機能; Android: assetlinks.json + 一致するアプリ署名証明書および検証済みインテントフィルター設定 |
いいえ(新規インストールには遅延レイヤーが必要) | 特定の制限されたインアプリwebviewコンテナによって抑制される場合がある |
| Google Playインストールリファラー | 新規Androidインストール | 公式Google Playストア配信パス | はい(サポート対象のリファラー文字列) | Android Playストア限定。iOSや非Playストアはカバーしない |
| SDK支援によるシーン復元 | インストール済み & 新規インストール | ドメイン関連付け + 検証済みSDKアダプター | サポートされているプラットフォームメカニズムに条件付きで依存 | ルート許可リストの検証とプラットフォームプライバシーレビューが必要 |
インストール状態およびオペレーティングシステムをまたぐアーキテクチャ上のトレードオフ
カスタムURLスキーム(myapp://product/123)には暗号学的なドメイン所有権の検証がないため、任意のアプリケーションが同じスキームを登録してリンクを傍受することができます。さらに、アプリがインストールされていない状態で生のカスタムスキームQRコードをスキャンすると、復元不可能なブラウザエラーが発生します。
ユニバーサルリンクおよびAndroidアプリリンクは、HTTPSを介してドメインの所有権を検証し、アプリが存在しない場合はウェブランディングページにフォールバックするため、QRルーティングの必須の基礎となります。インストール済み状態からの起動にはユニバーサルリンクとアプリリンクを、プラットフォーム対応の遅延リカバリ(AndroidのGoogle Playインストールリファラーやプライバシーレビュー済みのSDKアダプターなど)と組み合わせることで、単一のQRアセットからデュアルステートのシーン復元が可能になります。
よくある質問 (FAQ)
アプリがまだインストールされていない場合、カスタムQRパラメータはどのように特定のアプリ内ページを開くのですか?
QRコードをスキャンしても、ターゲットページではなくホーム画面が開いてしまうのはなぜですか?
悪意のあるQRコードによってユーザーが許可されていない内部画面にルーティングされるのを防ぐにはどうすればよいですか?
まとめと決定フレームワーク
カスタムパラメータを使用してアプリルーティング用のQRコードを作成するには、開発チームはコンパクトな外部URLクエリ文字列と、状態を認識するネイティブナビゲーションコントローラーを橋渡しする必要があります。完全な実装では、既存ユーザー向けのエージェントによる直接のOS起動と、新規アプリストアインストール向けの条件付き遅延パラメータ復元の両方を処理します。
アプリストアをまたいだパラメータの継続性は、AndroidのGoogle PlayインストールリファラーやiOS上の検証済みでプライバシーに準拠したワークフローなどのプラットフォーム固有のメカニズムに依存するため、アプリケーションは復元されたルーティングペイロードを信頼されていない候補として扱わなければなりません。許可リストに登録されたルートディスパッチ、認証状態のキューイング、およびスムーズなホーム画面フォールバックを組み合わせることで、チームはアプリケーションの安定性やプラットフォームのプライバシー規則を損なうことなく、信頼性の高いシーン復元を提供できます。
OpoInstallのSDKおよび開発者ドキュメントは、ウェブからアプリへのルーティングやパラメータ受け渡しを実装するためのコンポーネントを提供しています。本番ロールアウトの前に、導入するSDKリリースでサポートされている特定のAPIシグネチャ、コールバックスキーマ、およびプラットフォームの動作を確認してください。
統合の詳細については、OpoInstall開発者ドキュメントをご参照いただくか、OpoInstall SDKダウンロードセンターにアクセスするか、OpoInstall開発者コンソールでアプリケーションルーティングドメインを設定してください。
関連資料
-
概念: シーン復元、デュアルステートディープリンク、ルート許可リスト、遅延パラメータ復元
-
技術: ユニバーサルリンク、Androidアプリリンク、Google Playインストールリファラー、ネイティブナビゲーションコントローラー
-
標準: IETF RFC 3986 ユニフォームリソース識別子 (URI) ジェネリック構文、OWASPモバイルアプリケーションセキュリティ検証標準、OWASPモバイルトップ10のセキュリティリスクとガイドライン
-
統合インターフェース: アプリケーション所有のディープリンクルートバリデータ、導入されたSDKバージョンの文書化された起動およびインストールパラメータコールバック、ならびにプラットフォームネイティブのナビゲーションコントローラー。
-
公式ドキュメント & リファレンス:
Share this article



