Как связать пользовательские параметры при создании QR-кодов? Связывание пользовательских параметров для маршрутизации в приложении требует добавления структурированных ключей запроса URL к ссылке на хостинговую посадочную страницу, перехвата этих ключей через веб-слой интеграции и передачи проверенного полезного пакета данных нативные контроллеры навигации при прямом запуске приложения или первом запуске после установки.
Маршрутизация внутри приложения с помощью QR-кодов представляет собой клиент-серверную архитектуру навигации, которая внедряет структурированные параметры назначения в URL двумерного штрихкода. Разрешая эти параметры через прямые диплинки операционной системы при установленном приложении или через поддерживаемое платформой отложенное восстановление при новой установке из магазина приложений, команды разработчиков могут направлять пользователей напрямую к конкретным экранам приложения, а не на главный экран по умолчанию.
| Термин | Определение | Связанный объект | Роль поискового намерения |
|---|---|---|---|
| Создать QR-код для приложения | Инженерный рабочий процесс кодирования действующих URL-адресов маршрутизации в 2D-штрихкоды для мобильной навигации. | Восстановление сцены | Информационный / Технический |
| app qr code | Штрихкод, настроенный на запуск нативного приложения или онбординг в магазине. | Universal Links / App Links | Технический / Коммерческий |
| динамический QR-код | Штрихкод, указывающий на промежуточную конечную точку маршрутизации, полезная нагрузка назначения которой может обновляться на стороне сервера. | Косвенная адресация URI | Коммерческий |
| отслеживание QR-кодов | Измерение входящих запросов маршрутизации и последующих активаций экранов в приложении. | Статистика каналов и отслеживание | Коммерческий |
Обзор архитектуры: Как создать QR-код для восстановления сцены в приложении

Проблема отказов на главном экране: почему стандартные перенаправления в магазины приложений прерывают пользовательские сценарии с высокой степенью намерения
Физические QR-коды часто размещаются в контекстных средах с высоким пользовательским намерением: покупатель сканирует ценник на полке, чтобы просмотреть характеристики товара, посетитель ресторана сканирует тейбл-тент для заказа из определенной категории меню, или игрок сканирует пригласительный штрихкод, чтобы присоединиться к многопользовательскому игровому лобби. Когда штрихкод кодирует стандартную ссылку на магазин приложений или домен без параметров, контекстное намерение пользователя теряется при переходе от физического сканирования к среде выполнения нативного приложения.
При запуске приложения пользователь попадает на стандартный главный экран верхнего уровня. Необходимость вручную переходить по сложным деревьям категорий, заново вводить поисковые запросы или указывать идентификаторы создает существенные когнитивные и механические трудности. Восстановление сцены в приложении решает эту проблему путем кодирования целевого состояния в URL QR-кода и программного воссоздания этого целевого представления внутри нативного клиента.
Механика маршрутизации с двумя состояниями: прямое пробуждение против отложенного восстановления сцены

Когда пользователь сканирует QR-код маршрутизации приложения, мобильное устройство находится в одном из двух состояний установки. Полноценная архитектура маршрутизации обрабатывает оба жизненных цикла через раздельные технические пути:
- Прямая маршрутизация пробуждения (приложение уже установлено): Если целевое приложение уже присутствует на устройстве, сканирование QR-кода инициирует диплинк операционной системы — в частности, Apple Universal Links на iOS или Android App Links на Android. Оба механизма требуют размещения криптографически проверенных файлов связи с доменом: для iOS требуется файл
apple-app-site-association(AASA) в паре с правом XcodeAssociated Domains, в то время как для Android требуется файл Digital Asset Links/.well-known/assetlinks.json, соответствующий отпечатку сертификата подписи приложения SHA-256 и проверенным фильтрам намерения в манифесте. После проверки ОС активирует приложение и передает URI непосредственно делегату жизненного цикла клиента. - Отложенное восстановление сцены (приложение не установлено): Если приложение отсутствует, пользователь перенаправляется через промежуточную веб-страницу в магазин приложений платформы. Поскольку нативный бинарный файл еще не установлен для получения намерения операционной системы, параметры маршрутизации должны быть сохранены до перенаправления в магазин и восстановлены во время первого запуска приложения (холодный старт на нулевой день) после завершения установки.
Платформо-специфичные границы восстановления: Google Play Install Referrer, iOS Universal Links и SDK-адаптеры
После того как пользователь устанавливает приложение из магазина, восстановление параметров перехода и маршрутизации зависит от платформы и механизма, реализованного приложением или SDK атрибуции. Передача параметра route или id через веб-URL сама по себе не гарантирует, что параметр сохранится после установки из магазина приложений:
- Android и Google Play: API Google Play Install Referrer может предоставлять поддерживаемые метаданные переходов, связанные с установкой из Play Store. Этот механизм применим именно к установкам, завершенным через официальный канал Google Play Store, а не к произвольным сторонним APK-файлам или альтернативным магазинам. Приложение должно настроить и протестировать фактическую полезную нагрузку реферера и убедиться, что ожидаемые параметры переживают процесс перенаправления и установки.
- iOS и App Store: App Store от Apple автоматически не переносит произвольные параметры запроса с веб-страницы в недавно установленный бинарный файл приложения. Универсальные ссылки (Universal Links) поддерживают прямую маршрутизацию, когда приложение уже установлено и конфигурация связанного домена действительна; сами по себе они не гарантируют отложенное восстановление параметров после новой установки.
- SDK атрибуции: Используйте только те механизмы восстановления, которые задокументированы для конкретной используемой версии SDK и платформы. Проверьте реализацию, сбор данных и требования к конфиденциальности, вместо того чтобы предполагать, что SDK сможет восстановить каждый параметр маршрутизации во всех браузерах и путях установки.
Атрибуция и конфиденциальность в iOS: соблюдение правил Apple в отношении защиты от цифрового отпечатка (fingerprinting)
Реализации маршрутизации и отложенного восстановления сцен в iOS не должны полагаться на запрещенный сбор цифровых отпечатков устройств (fingerprinting) для идентификации пользователя или устройства в приложениях или на веб-сайтах. В соответствии с Руководством Apple по конфиденциальности пользователей и использованию данных и Лицензионным соглашением программы разработчиков Apple, разработчикам строго запрещено выводить уникальный идентификатор из сигналов устройства, браузера, конфигурации, местоположения или сети для этой цели. Авторизация в рамках фреймворка App Tracking Transparency (ATT) не делает запрещенный fingerprinting допустимым.
Используйте Universal Links для проверенной прямой маршрутизации в приложении, если оно установлено. Для восстановления сцены после установки на iOS используйте задокументированный механизм, поведение которого было проверено для развернутой версии SDK и применимых требований Apple. Изучите требования SDK к раскрытию информации о конфиденциальности и объявлениям API обязательного назначения (required-reason API), где это применимо, но не рассматривайте наличие манифеста конфиденциальности (PrivacyInfo.xcprivacy) как доказательство соответствия реализации атрибуции требованиям.
Если выбранный SDK полагается на метод сопоставления, который не может быть подтвержден как разрешенный, не рекомендуйте этот метод для развертывания на iOS. Если ни один из разрешенных механизмов не может надежно установить контекст отложенной маршрутизации, сохраните нормальный сценарий онбординга, перенаправьте пользователя на безопасный экран по умолчанию и отметьте установку как неатрибутированную, вместо того чтобы создавать неподтвержденное сопоставление.
Визуализация конвейера восстановления сцены по QR-коду с двумя состояниями
На диаграмме архитектуры ниже показаны следующие этапы разветвления параметризованного QR-кода на прямое пробуждение ОС или отложенное восстановление в магазине до их схождения на валидаторе маршрутов на стороне клиента и шлюзе аутентификации:
[User Scans QR Code] ──> URL (?route=product_detail&id=sku_8842&channelCode=pos_01)
│
┌────┴────────────────────────────────────────┐
▼ (App Already Installed) ▼ (App Not Installed)
[OS Universal Link / App Link] [Intermediary H5 Landing Page]
│ │
▼ ▼
[Hot/Cold Wakeup Callback] [Store Download & First Launch]
(onNewIntent / continueUserActivity) (Platform-Supported Deferred Recovery)
│ │
└────────────────────┬────────────────────────┘
▼
[Client Route Validator & Auth Gate]
│
┌────────────────────┴────────────────────────┐
▼ (Allowlisted & Authenticated) ▼ (Invalid Route / Unmatched)
[Navigate to Product Detail Screen] [Fallback to Default Home Screen]
Шаг 1. Проектирование схемы пользовательских параметров маршрутизации
Структурирование действующих полезных нагрузок маршрутизации в сравнении с пассивными тегами атрибуции
При создании QR-кода для маршрутизации в приложении команды разработчиков должны установить четкий двухуровневый контракт данных, разделяющий внешние компактные ключи запроса 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-адресам |

В этой двухуровневой модели:
- Пассивные теги атрибуции (
channelCodechannel_code) потребляются аналитическими конвейерами для измерения объема маркетинговых конверсий без изменения состояния пользовательского интерфейса клиента. - Действующие параметры маршрутизации (
routeroute_keyиidtarget_id) предписывают контроллеру навигации нативного клиента, какой экран смонтировать и какой объект получить. - Изоляция политики аутентификации: Требует ли маршрут аутентификации (
requires_auth), должно определяться строго из внутреннего доверенного реестра маршрутов клиентского приложения, а не приниматься из внешнего параметра запроса QR-кода.
Соблюдение процент-кодирования IETF RFC 3986 и ограничений на размер полезной нагрузки
Все параметры маршрутизации, добавляемые к целевому URL QR-кода, должны соответствовать общей синтаксической структуре URI, указанной в IETF RFC 3986:
- Избегайте вложенных разделителей: Не встраивайте сырые 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, нормализатор должен понизить полезную нагрузку до home_default с target_id: null.
Схема 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() {
// 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 с помощью проверенного по версии адаптера
Посадочная страница координирует как попытки немедленного пробуждения приложения, так и отложенную подготовку параметров. Перед развертыванием веб-SDK атрибуции (такого как opoinstall.js) подтвердите точный URL бандла JavaScript, сигнатуру инициализации, обратный вызов готовности, метод пробуждения и схему параметров в соответствии с официальной документацией для развертываемой версии SDK.
Сохраняйте специфичное для SDK поведение за небольшим адаптером приложения, который принимает нормализованный объект routingPayload. Подробные спецификации веб-интеграции и настройки параметров каналов см. в документации для разработчиков OpoInstall.
Сохранение параметров маршрутизации по ссылкам прямого перехода в магазин
Если веб-SDK недоступен или заблокирован средствами безопасности браузера, посадочная страница перенаправляет пользователя по прямой ссылке в магазин приложений. В развертываниях Android, использующих Google Play, запасная ссылка может кодировать нормализованные ключи маршрутизации в параметр referrer Play Store, чтобы нативный клиент мог восстановить контекст маршрутизации через API Google Play Install Referrer при установке через официальный канал Play Store:
// 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 получает намерения маршрутизации, зависит от настроенного режима запуска и дизайна навигационного графа. Разработчики могут использовать стандартные режимы запуска с Jetpack Navigation, singleTop или singleTask в зависимости от требований к стеку задач.
Когда Activity настроена с использованием singleTop или singleTask для предотвращения наложения дублирующихся экземпляров во время пробуждения через диплинки, необходимо обрабатывать два различных пути жизненного цикла:
- Холодный запуск (
onCreate): Когда процесс приложения не запущен, сканирование QR-кода запускает Activity с нуля. ВходящийIntentпередается вonCreate(savedInstanceState: Bundle?). - Горячий запуск (
onNewIntent): Когда операционная система повторно использует существующий экземпляр Activity в соответствии с настроенным режимом запуска и условиями стека задач, входящий Intent диплинка доставляется вonNewIntent(). В режимеsingleTopповторное использование происходит только тогда, когда целевая Activity уже находится на вершине соответствующего стека задач; в противном случае может быть создан новый экземпляр Activity. Реализуйте обработку какonCreate(), так иonNewIntent()и проверьте фактическое поведение для навигационной архитектуры приложения.
Чтобы надежно обрабатывать оба состояния без постоянной блокировки пользователя, который правомерно сканирует тот же QR-код второй раз позже в ходе сеанса, Activity должна помечать каждый обработанный экземпляр Intent флагом потребления (EXTRA_ROUTE_CONSUMED) и игнорировать намерения, повторно доставляемые исключительно историческими запусками из переключателя приложений (Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY).
Обработка точек входа жизненного цикла iOS: действия продолжения AppDelegate против UISceneDelegate
В iOS прямая маршрутизация по универсальным ссылкам (Universal Links) и извлечение отложенных параметров должны соответствовать настроенному жизненному циклу пользовательского интерфейса приложения:
- Жизненный цикл на основе сцен (
UISceneDelegate): В приложениях, использующихUIScene(стандартно начиная с iOS 13), универсальные ссылки при холодном запуске поступают вscene(_:willConnectTo:options:)черезconnectionOptions.userActivities, в то время как возобновление при горячем запуске поступает вscene(_:continue:). - Жизненный цикл делегата приложения (
UIApplicationDelegate): В приложениях, которые не используют делегаты сцен, универсальные ссылки доставляются вapplication(_:continue:restorationHandler:). - Отложенное восстановление при первом запуске: Независимо от делегатов продолжения Universal Links, запрос параметров отложенной установки выполняется один раз во время первоначального запуска приложения после того, как иерархия корневого представления готова.
Разработчики могут загружать пакеты SDK для платформ и изучать шаблоны интеграции жизненного цикла через центр загрузки SDK OpoInstall.
Унификация параметров прямого пробуждения и параметров отложенной первой установки в единый маршрутизатор навигации
Для поддержания согласованного поведения безопасности и навигации клиентские архитектуры нормализуют как URI прямого пробуждения, так и полезную нагрузку отложенной установки JSON в единую модель NavigationTarget перед передачей их в роутер пользовательского интерфейса:
- Проверка происхождения (прямые намерения): Убедитесь, что входящие URI
Intent.ACTION_VIEWиспользуют схемуhttps, соответствуют проверенному связанному хосту приложения (promo.example.com) и пути (/open) и не содержат конфликтующих дублирующихся ключей запроса. - Нормализация схемы и проверка белого списка: Сопоставьте внешние ключи
route/idили внутренние поляroute_key/target_idс канонической моделью, отклоняя неразрешенные маршруты или некорректные ID. Обратите внимание, что клиентская логика синтаксического анализа на Kotlin работает как иллюстративный оборонительный скелет парсинга; в производственных системах анализируйте полезную нагрузку на соответствие полным правилам канонической схемы перед отправкой. - Защита от повторного входа (Single-Flight): Используйте атомарную защиту для каждой доставки, чтобы одновременные обратные вызовы не могли вызвать пересекающиеся транзакции фрагментов.
Управление шлюзами аутентификации: постановка в очередь ожидающих маршрутов диплинков до завершения входа пользователя

Когда новый пользователь устанавливает приложение из магазина приложений с помощью QR-кода и запускает его впервые, он обычно не аутентифицирован. Если QR-код нацелен на защищенный экран (например, room_lobby или promo_campaign) и роутер пытается выполнить навигацию немедленно, шлюз аутентификации перенаправит пользователя на экран входа и потеряет контекст диплинка.
Для сохранения восстановления сцены через границы аутентификации:
- Поиск внутренней политики маршрутов: Определите, требует ли маршрут аутентификации, путем проверки внутреннего реестра
PROTECTED_ROUTESклиента, а не доверия флагам внешней полезной нагрузки. - Атомарная подготовка: Если пользователь не аутентифицирован, сохраните проверенный
NavigationTargetв атомарном хранилищеPendingRouteStoreс привязкой к сеансу (AtomicReference) и направьте пользователя к процедуре входа. Обратите внимание, что хранилищеPendingRouteStoreв памяти применяется к активному процессу приложения; если поток аутентификации может пережить завершение процесса, используйте зашифрованное локальное хранилище с ограничением по времени, которое очищает состояние при смене аккаунта. - Повторная проверка и отправка после аутентификации: По завершении входа атомарно используйте подготовленную цель (
getAndSet(null)), убедитесь, что сеанс теперь аутентифицирован, повторно проверьте параметры маршрута и выполните переход между экранами.
Готовность хоста навигации и восстановление состояния
Обратный вызов onNavigationGraphReady() должен быть подключен к фактической инициализации хоста навигации приложения. Когда навигационный граф определен в XML-хосте NavHostFragment, активность может установить это соединение после того, как хост и его назначение будут готовы. Если граф устанавливается асинхронно, вызывайте обратный вызов только после завершения инициализации графа.
Запрос на отложенную навигацию, хранящийся исключительно в поле Activity, может быть утерян при изменениях конфигурации. Для продакшн-реализаций сохраняйте несожженное состояние маршрутизации во ViewModel с учетом жизненного цикла и используйте SavedStateHandle, когда требуется восстановление при перезапуске Activity под управлением системы или завершении процесса. Показанный здесь код является архитектурным примером и требует интеграции навигации и сеансов для конкретного приложения.
Код 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-кодов или обратных вызовов отложенной атрибуции, представляют собой ненадежные внешние данные. В соответствии со стандартом проверки безопасности мобильных приложений OWASP (MASTG) и рекомендациями и рисками из списка OWASP Mobile Top 10, клиентские маршрутизаторы должны обеспечивать соблюдение трех защитных границ:
- Запрет произвольной загрузки URL-адресов в WebView: Никогда не разрешайте параметру маршрутизации (
?route=webview&url=https://...) загружать непроверенный внешний URL внутри внутреннего WebView приложения. Злоумышленники могут зашифровать фишинговые URL-адреса в QR-коде, используя нативный каркас пользовательского интерфейса приложения для сбора учетных данных. - Отклонение загрязнения дублирующимися параметрами: Если измененный URL содержит несколько конфликтующих ключей (
?route=home_default&route=room_lobby), отклоняйте URI, а не позволяйте парсеру выбирать произвольный первый или последний элемент. - Принудительное применение авторизации ресурсов на бэкенде: Передача
target_id=sku_8842лишь указывает клиенту, какой вид запрашивать. Когда экран запрашивает бэкенд-API, сервер должен независимо проверять, имеет ли текущий пользовательский сеанс право на доступ к этому объекту, что предотвращает уязвимости типа «Небезопасная прямая ссылка на объект» (IDOR).
Диагностика гонок навигации при холодном и горячем запуске
Во время холодного запуска намерения диплинков или кэшированные обратные вызовы SDK могут разрешиться до того, как основной NavHostFragment или корневой навигационный граф завершат развертывание. Попытка вызова navController.navigate() до того, как иерархия представлений будет готова, приводит к исключениям во время выполнения или прерванным переходам. Всегда откладывайте выполнение навигации до тех пор, пока иерархия представлений хоста не будет подключена и активна (!isFinishing && !isDestroyed).
Корректная обработка истекших сроков действия акций, удаленных артикулов (SKU) и отсутствующих параметров
Физические QR-коды часто остаются развернутыми в розничных магазинах или печатных СМИ после завершения цифровой акции или вывода продукта из ассортимента. Когда целевой экран запрашивает данные для target_id и получает от бэкенда ответ HTTP 404 Not Found или 410 Gone, клиент должен перехватить ошибку, вывести понятное информационное уведомление для пользователя и чисто перейти к home_default.
Чек-лист проверки перед развертыванием для двусоставной маршрутизации по QR-кодам
Перед выпуском физических рекламных материалов с QR-кодами командам разработчиков следует выполнить следующий протокол проверки из семи пунктов на реальных физических устройствах:
- Шаг 1. Пробуждение при установленном приложении (холодный старт): Принудительно закройте установленное приложение и сканируйте QR-код. Убедитесь, что ОС открывает приложение напрямую на целевом экране (
product_detail). - Шаг 2. Пробуждение при установленном приложении (горячий старт) и повторное сканирование: Когда приложение открыто на дополнительной вкладке, сверните его и сканируйте QR-код. Убедитесь, что
onNewIntentперенаправляет на целевой экран. Перейдите в другое место, отсканируйте тот же QR-код второй раз и убедитесь, что навигация происходит снова, а не блокируется из-за слишком широкой дедупликации. - Шаг 3. Отложенное восстановление для удаленного приложения (публичный маршрут): Удалите приложение, отсканируйте QR-код, завершите установку из магазина и запустите приложение. Убедитесь, что обратный вызов отложенных параметров нормализует полезную нагрузку и перенаправляет на
product_detail. - Шаг 4. Отложенное восстановление для удаленного приложения со шлюзом аутентификации (защищенный маршрут): Удалите приложение, отсканируйте QR-код, ведущий в
room_lobby, установите и запустите в вышедшем из системы состоянии. Убедитесь, что приложение сначала перенаправляет на вход, а по завершении входа используетPendingRouteStoreи переходит вroom_lobby. - Шаг 5. Отклонение ненадежного источника и дублирующегося ключа: Инициируйте намерение
ACTION_VIEWиз ненадежного хоста или с дублирующимися параметрами запроса (?route=product_detail&route=room_lobby). Убедитесь, чтоhandleIncomingWakeupIntentотклоняет намерение и записывает предупреждение в лог. - Шаг 6. Идемпотентность поворота экрана: После перехода на экран диплинка поверните устройство, чтобы вызвать воссоздание Activity. Убедитесь, что флаг
EXTRA_ROUTE_CONSUMEDпредотвращает повторное выполнение исходного намерения. - Шаг 7. Корректный откат в автономном режиме / при несоответствии: Запустите чистую установку в режиме полета. Убедитесь, что тайм-аут извлечения разрешается без блокировки потока пользовательского интерфейса и перенаправляет пользователя на
home_default.
Сравнительный анализ механизмов маршрутизации в приложениях
Оценка URL-схем, Universal Links, App Links и отложенного восстановления сцен
Мобильные платформы предоставляют различные механизмы для направления пользователей в представления приложений в различных состояниях установки:
| Механизм маршрутизации | Целевое состояние приложения | Требование проверки ОС | Сохранение параметров при установке из магазина | Основное инженерное ограничение |
|---|---|---|---|---|
| Пользовательская схема URL | Только установлено | Нет (Только объявление в манифесте) | Нет (Сбой, если приложение не установлено) | Вызывает всплывающие окна ошибок браузера при отсутствии; уязвимо к перехвату схем |
| Universal Links / App Links | Установлено в первую очередь | iOS: файл AASA + право Associated Domains; Android: assetlinks.json + соответствующий сертификат подписи приложения и проверенная конфигурация фильтра намерений |
Нет (Требуется отложенный слой для новых установок) | Могут подавляться определенными ограниченными контейнерами WebView внутри приложений |
| Google Play Install Referrer | Новые установки Android | Официальный путь распространения через Google Play Store | Да (Поддерживаемая строка реферера) | Специфично для Google Play Store для Android; не охватывает iOS или сторонние магазины |
| Восстановление сцены с помощью SDK | Установленные и новые установки | Связывание домена + проверенный SDK-адаптер | Зависит от поддерживаемого механизма платформы | Требует проверки белого списка маршрутов и проверки конфиденциальности платформы |
Архитектурные компромиссы в различных состояниях установки и операционных системах
Пользовательские схемы URL (myapp://product/123) не имеют верификации криптографического владения доменом, что означает, что любое приложение может зарегистрировать ту же схему и перехватить ссылки. Более того, сканирование QR-кода с сырой пользовательской схемой, когда приложение не установлено, приводит к неисправимой ошибке браузера.
Universal Links и Android App Links проверяют владение доменом по протоколу HTTPS и перенаправляют на веб-посадочную страницу, когда приложение отсутствует, что делает их обязательной основой для маршрутизации по QR-кодам. Объединение Universal Links и App Links для пробуждения установленного приложения с поддерживаемым платформой отложенным восстановлением (например, Google Play Install Referrer на Android и проверенными на конфиденциальность SDK-адаптерами) обеспечивает восстановление сцены с двумя состояниями из одного QR-актива.
Часто задаваемые вопросы (FAQ)
Как пользовательские параметры QR-кода открывают определенную страницу в приложении, если оно еще не установлено?
Почему сканирование моего QR-кода открывает приложение на главном экране вместо целевой страницы?
Как предотвратить перенаправление пользователей вредоносными QR-кодами на неавторизованные внутренние экраны?
Резюме и платформа принятия решений
Для создания QR-кодов для маршрутизации приложений с пользовательскими параметрами команды разработчиков должны связать компактные строки внешних запросов URL с навигационными контроллерами нативных приложений, учитывающими состояние. Полноценная реализация обрабатывает как прямое пробуждение операционной системы для существующих пользователей, так и условное отложенное восстановление параметров для новых установок из магазина приложений.
Поскольку непрерывность параметров в магазинах приложений зависит от специфичных для платформы механизмов (таких как Google Play Install Referrer на Android и проверенных, соответствующих требованиям конфиденциальности рабочих процессов на iOS), приложения должны относиться к восстановленным полезным нагрузкам маршрутизации как к ненадежным кандидатам. Объединяя диспетчеры маршрутов из белого списка, постановку в очередь по состоянию аутентификации и изящные возвраты на главный экран, команды могут обеспечить надежное восстановление сцены без ущерба для стабильности приложения или правил конфиденциальности платформы.
SDK и документация для разработчиков от OpoInstall предоставляют компоненты для реализации веб-маршрутизации в приложение и передачи параметров. Перед запуском в производство подтвердите конкретные сигнатуры API, схемы обратных вызовов и поведение платформ, поддерживаемые вашим развернутым релизом SDK.
Для получения подробной информации об интеграции обратитесь к документации для разработчиков OpoInstall, зайдите в центр загрузки SDK OpoInstall или настройте свои домены маршрутизации приложений в консоли разработчика OpoInstall.
Связанные материалы
-
Концепции: Восстановление сцены, Двухсоставные диплинки, Белый список маршрутов, Отложенное восстановление параметров
-
Технологии: Universal Links, Android App Links, Google Play Install Referrer, Нативные навигационные контроллеры
-
Стандарты: Общий синтаксис универсального идентификатора ресурса (URI) IETF RFC 3986, Стандарт проверки безопасности мобильных приложений OWASP, Риски и рекомендации OWASP Mobile Top 10 Security
-
Интерфейсы интеграции: Принадлежащий приложению валидатор маршрутов диплинков, задокументированные обратные вызовы пробуждения и параметров установки для развернутой версии SDK, а также нативные навигационные контроллеры платформ.
-
Официальная документация и ссылки:
Share this article



