Как перехватывать кастомные URL-схемы при перенаправлениях в Android WebView

opoinstall
2026-09-21
5 min read

Как обрабатывать диплинки и кастомные схемы в Android WebView? Работа с диплинками в Android WebView требует расширения класса WebViewClient, переопределения метода shouldOverrideUrlLoading для проверки свойства isForMainFrame, валидации кастомных схем с помощью списка разрешенных (allowlist) и безопасного запуска авторизованных внешних интентов (Intent), чтобы не допустить попадания не-веб схем в конвейер загрузки WebView.

Перехват диплинков в Android WebView — это программная практика переопределения обратных вызовов навигации в кастомном WebViewClient для захвата протоколов, отличных от HTTP, таких как кастомные URI-схемы и Chrome Intent URIs. Анализируя метаданные запроса и проверяя полезную нагрузку назначения, хост-приложение выполняет запуск нативных Intent, предотвращая ошибки при обработке неопознанных протоколов.

Термин Определение Связанная сущность Роль поискового интента
WebView Встраиваемый компонент на базе Chromium, который отображает веб-контент внутри Android-приложений. Android SDK Информационный / Коммерческий
Кастомная URL-схема Проприетарный протокол URI, позволяющий веб-ссылкам обращаться к нативным приложениям. Маршрутизация диплинков Технический / Информационный
Web to App Процесс перехода пользователей с веб-страниц в интерфейсы нативных приложений. Мобильные диплинки Информационный

Кастомные схемы должны покидать конвейер загрузки WebView до того, как Chromium выдаст ошибку.

Почему Android WebView выдает ошибку net ERR UNKNOWN URL SCHEME

Ограничения протоколов Chromium: почему WebView нативно распознает только стандартные веб-схемы

Компонент Android WebView работает на базе движка рендеринга Chromium, который по умолчанию настроен на парсинг и отображение стандартных протоколов веб-ресурсов: http://, https://, file://, data://, about: и javascript:.

По умолчанию, если кастомный WebViewClient не назначен, Android-фреймворк передает необработанные URL системному менеджеру Activity. Однако, если приложение предоставляет свой WebViewClient, схемы, отличные от HTTP(S) (такие как myapp://product/detail/1024, alipay:// или whatsapp://), направляются непосредственно в обратные вызовы навигации клиента. Если хост-приложение позволяет кастомной схеме оставаться в конвейере загрузки WebView вместо того, чтобы перехватить ее, движок Chromium пытается разрешить ее как обычный веб-ресурс. Поскольку нестандартные протоколы невозможно разрешить через DNS или транспортные уровни веб-серверов, движок сообщает о сбое навигации низкого уровня.

Анатомия сбоя net::ERR_UNKNOWN_URL_SCHEME: от клика пользователя до ошибки загрузки веб-ресурса

Переход от взаимодействия пользователя к странице ошибки происходит по быстрой детерминированной последовательности:

  1. Триггер пользователя: Пользователь нажимает кнопку призыва к действию или гиперссылку внутри встроенной H5-страницы.
  2. Запрос навигации: Веб-движок формирует запрос навигации, содержащий строку целевого URI (например, myapp://promo/summer).
  3. Оценка конвейера: Если shouldOverrideUrlLoading() возвращает false или не перехватывает URI, отличный от HTTP(S), движок Chromium обрабатывает URL как обычный запрос веб-ресурса.
  4. Выдача ошибки: Не найдя внутреннего обработчика для протокола, движок вызывает onReceivedError() и заменяет контент WebView стандартным шаблоном ошибки Chromium: net::ERR_UNKNOWN_URL_SCHEME.

Эта страница ошибки прерывает путь пользователя, препятствует отслеживанию конверсий и снижает воспринимаемую стабильность хост-приложения.

Риск сбоев ActivityNotFoundException при отсутствии целевого приложения

Когда разработчики впервые пытаются разрешить ошибку net::ERR_UNKNOWN_URL_SCHEME, распространенной ошибкой является парсинг строки URI и вызов context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) без надлежащей обработки исключений.

Если у пользователя не установлено целевое приложение, операционная система Android не может найти подходящую Activity для выполнения Intent. При стандартном выполнении в Android вызов startActivity() без соответствующего обработчика приводит к необработанному исключению android.content.ActivityNotFoundException. Если это исключение не перехвачено в структурированном блоке try-catch, процесс хост-приложения аварийно завершается, что приводит к критическому сбою.

Как shouldOverrideUrlLoading перехватывает протоколы диплинков

Эволюция API: сравнение перегрузок WebResourceRequest в API 24+ с устаревшими строковыми обратными вызовами

Класс WebViewClient в Android предоставляет обратный вызов shouldOverrideUrlLoading, позволяющий хост-приложениям проверять и перехватывать запросы навигации до того, как веб-движок их выполнит. Сигнатура и возможности этого метода развивались вместе с уровнями API Android:

  • API 24+ (Android 7.0 и выше): Переопределяет shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean. Интерфейс WebResourceRequest предоставляет богатые контекстные метаданные, включая целевой URL (request.url), метод HTTP-запроса (request.method), заголовки запроса и важные флаги иерархии фреймов (request.isForMainFrame, request.hasGesture()).
  • Устаревшая перегрузка (устарела в API 24): Переопределяет shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean. Этот устаревший обратный вызов получает только необработанную строку URL без метаданных о запрашивающем фрейме или состоянии взаимодействия пользователя.

В соответствии с справочником Android для разработчиков по WebViewClient shouldOverrideUrlLoading, современные приложения должны реализовывать перегрузку WebResourceRequest. При поддержке устаревших версий Android следует использовать строковый обратный вызов осторожно, чтобы предотвратить обход системы безопасности.

Критическая важность безопасности request.isForMainFrame: блокировка перехватов подфреймов и iframe

Проверки главного фрейма предотвращают запуск внешних маршрутов приложений через навигацию в iframe.

В соответствии с руководством Android для разработчиков по WebResourceRequest, обратный вызов shouldOverrideUrlLoading может срабатывать как для подфреймов, iframe и перенаправлений фоновых ресурсов, так и для навигации по документам верхнего уровня.

Принудительное использование request.isForMainFrame критически важно для безопасности приложения:

  • Изоляция границ фреймов: Проверка request.isForMainFrame гарантирует, что подфреймы и iframe не смогут инициировать запуск внешних приложений или интентов с кастомными схемами. Если подфрейм пытается выполнить навигацию, стандартные загрузки HTTP(S) должны возвращать false для продолжения рендеринга внутри фрейма, тогда как схемы, отличные от HTTP(S), должны возвращать true для подавления выполнения без возникновения ошибок.
  • Различие жестов пользователя: Хотя isForMainFrame подтверждает, что запрос относится к основному документу, это не доказывает активацию со стороны пользователя. Если ваша архитектура требует верификации жестов пользователя, используйте request.hasGesture() или принудительно внедрите модальные окна подтверждения на уровне приложения, учитывая, что документация Android указывает на определенные валидные пользовательские сценарии, где hasGesture() может возвращать false.

Разграничение обычной веб-навигации и обработки диплинков: когда возвращать false, а когда true

Логическое возвращаемое значение метода shouldOverrideUrlLoading управляет жизненным циклом рендеринга WebView:

  • Возврат false: Сообщает фреймворку Android, что хост-приложение не обрабатывало URL. WebView продолжает стандартную веб-навигацию, загружая URL внутри своего внутреннего контейнера Chromium. Это значение обязательно для стандартных веб-страниц http:// и https://, которые должны отображаться во встроенном браузере.
  • Возврат true: Сообщает фреймворку, что хост-приложение перехватило и обработало URL. WebView немедленно отменяет свой внутренний процесс загрузки. Это значение обязательно при запуске внешнего Intent, перенаправлении на нативную Activity или подавлении неавторизованной схемы.

Обработка проверенных App Links хост-приложения внутри встроенного контейнера

Когда ссылка внутри WebView указывает на собственный подтвержденный HTTPS-домен хост-приложения (например, https://app.example.com/product/1024), выполнение стандартной навигации заставляет WebView загрузить веб-версию страницы продукта.

Чтобы обеспечить нативный опыт, WebViewClient должен проверять входящие HTTPS-ссылки. Если хост совпадает с ассоциированным доменом приложения, клиент перехватывает запрос, проверяет путь и параметры запроса с помощью внутреннего валидатора маршрутов (AppLinkRouteValidator) и передает данные непосредственно нативному маршрутизатору (internalRouter(validatedRoute)), возвращая true для отмены избыточного веб-рендеринга.

Структурирование безопасных обработчиков интентов для кастомных схем и Intent URIs

Разбор синтаксиса Chrome Intent URI: схемы, пакеты и S.browser_fallback_url

На Android веб-страницы часто используют Chrome Intent URIs для структурирования нативных диплинков со встроенными механизмами отката (fallback) к браузеру. Intent URI использует схему intent:// и кодирует параметры маршрутизации, целевые пакеты и fallback-URL в сериализованную строку:

intent://product/detail/1024#Intent;scheme=myapp;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end

Как задокументировано в руководстве Chrome для разработчиков по Android Intent URIs, URI разбивается на отдельные компоненты:

  • Хост и путь (product/detail/1024): Определяет внутреннее назначение в приложении.
  • scheme=myapp: Указывает базовый кастомный протокол.
  • package=com.example.app: Ограничивает разрешение интента исключительно указанным Android-пакетом.
  • S.browser_fallback_url=...: Дополнительный строковый параметр, определяющий явный URL веб-страницы или магазина приложений для загрузки, если целевое приложение не установлено.

Опасность выполнения сырых распарсенных интентов: смягчение уязвимостей внедрения интентов (Intent Injection)

Распространенным антипаттерном реализации является парсинг входящего URI с помощью Intent.parseUri(url, Intent.URI_INTENT_SCHEME) и передача полученного Intent непосредственно в context.startActivity(intent).

В соответствии с рекомендациями OWASP Mobile Application Security Testing Guide по небезопасным диплинкам, выполнение сырых распарсенных интентов подвергает приложение атакам типа Intent Injection. Злоумышленник, контролирующий веб-страницу, может создать Intent URI, содержащий вредоносные целевые компоненты (component=com.example.app/.InternalAdminActivity), конфиденциальные действия или привилегированные дополнительные параметры интента (S.is_admin=true). WebView десериализует эти параметры и запустит неэкспортированные или внутренние Activity, обходя барьеры аутентификации.

Распарсенные Intent URI должны быть проверены и пересобраны перед отправкой в Android.

Безопасная пересборка интентов: удаление компонентов и ограничение действий до ACTION_VIEW

Чтобы устранить уязвимости внедрения интентов, хост-приложения никогда не должны запускать сырой десериализованный Intent напрямую. Вместо этого WebViewClient должен парсить URI, извлекать только проверенную полезную нагрузку данных и пересобирать чистый, специализированный Intent:

  1. Извлечение данных: Парсинг URI и извлечение проверенного целевого пакета и URI данных.
  2. Валидация пакета: Проверка того, что объявленный пакет соответствует явному списку разрешенных партнерских или внутренних пакетов.
  3. Пересборка: Создание нового Intent(Intent.ACTION_VIEW, validatedDataUri).
  4. Изоляция: Применение intent.addCategory(Intent.CATEGORY_BROWSABLE) для гарантии, что только браузерные Activity смогут разрешить запрос, установка явных флагов (FLAG_ACTIVITY_NEW_TASK) и обеспечение того, чтобы intent.component и intent.selector оставались null.

Принудительное использование строгих списков разрешенных схем, хостов, путей и параметров запроса

Как кастомные схемы (myapp://), так и пересобранные Intent URIs должны проходить строгую валидацию перед выполнением.

Эта эталонная реализация обрабатывает собственную схему хост-приложения myapp://, явно привязывая выполнение интента к com.example.app. Для многоприложенческих партнерских экосистем разработчикам следует поддерживать строгое сопоставление схемы с пакетом или отдавать приоритет проверенным HTTPS App Links.

Валидатор проверяет:

  • Схему: Совпадает ли она с разрешенными кастомными схемами (myapp).
  • Хост: Совпадает ли он с предопределенными категориями маршрутизации (open, product, event).
  • Префикс пути: Соответствует ли он ожидаемым структурам маршрутов (/detail/, /view/, /main/).
  • Параметры запроса: Принудительное использование разрешенных ключей параметров запроса (target, id, promo, utm_source) и валидация того, что значения параметров соответствуют ограничениям по длине (например, ≤128\le 128 символов) и алфавитно-цифровым наборам символов.
[Пользователь нажимает ссылку в Android WebView]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[Проверка схемы подфрейма]     [Проверка схемы URL]
├─ HTTP(S) -> вернуть false       │
└─ Не веб-схема -> вернуть true   ┌────┴───────────────────────────┐
   (Подавление ошибки)         ▼                                ▼
                     [HTTP / HTTPS]                 [Схема, отличная от HTTP(S)]
                            │                                │
                            ▼                                ▼
                   [Проверка хост-домена]              [CustomSchemeValidator]
                   ├─ Внутренний App Link             ├─ Валидно -> Запуск привязанного интента
                   │  -> Внутренний маршрутизатор            └─ Intent -> Пересборка & Fallback
                   └─ Внешний веб-сайт
                      -> Вернуть false

Как работать с видимостью пакетов в Android 11 и откат к магазину приложений

Видимость пакетов влияет на префлайт-запросы, тогда как сбои при запуске используют проверенные механизмы отката.

Сдвиг в видимости пакетов Android 11: почему префлайт-запросы требуют декларации

Начиная с Android 11 (уровень API 30), Google ввела фильтрацию видимости пакетов для повышения конфиденциальности пользователей. В рамках этой структуры видимость других установленных приложений для приложения по умолчанию ограничена.

Критически важно понимать различие между запуском интента и запросом интента:

  • Прямой запуск интента: Стандартные вызовы context.startActivity(intent) не требуют деклараций видимости пакетов. Если целевое приложение установлено, операционная система направляет и успешно запускает его, независимо от того, видно ли оно для запросов пакетов.
  • Префлайт-запросы интентов: Если ваша архитектура WebViewClient выполняет префлайт-проверку (например, вызов packageManager.resolveActivity(intent, 0) или packageManager.queryIntentActivities(intent, 0) для проверки присутствия приложения перед запуском), запрос возвращает null или пустой список, если целевой пакет или схема не объявлены в AndroidManifest.xml.

Декларирование элементов <queries> в AndroidManifest.xml для кастомных схем и целевых пакетов

В соответствии с руководством Android для разработчиков по видимости пакетов в Android 11, приложения, выполняющие префлайт-проверки перед выполнением интента, должны объявить свои целевые пакеты или сигнатуры интентов в манифесте:

<manifest package="com.example.app">
    <!-- Дополнительные декларации префлайт-запросов для явного обнаружения пакетов -->
    <queries>
        <package android:name="com.example.partner" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>
</manifest>

Если ваша архитектура предпочитает стандартные шаблоны запуска, выполнение startActivity() внутри блока try-catch, перехватывающего ActivityNotFoundException, обеспечивает устойчивую, не зависящую от запросов реализацию.

Реализация цепочек отката: корректная маршрутизация в магазин приложений при отсутствии целевого приложения

Когда перехваченная кастомная схема или Intent URI не могут быть запущены из-за того, что целевое приложение не установлено на устройстве пользователя, WebViewClient должен выполнить явную цепочку отката:

  1. Извлечение и привязка Fallback URL: Для intent:// URI извлеките строковый параметр S.browser_fallback_url и убедитесь, что он указывает на авторизованное назначение. Для URL Google Play подтвердите, что параметр id строго соответствует имени целевого пакета (id=targetPackage), чтобы предотвратить эксплойты открытых перенаправлений.
  2. Генерация market URI: Если fallback-URL отсутствует, создайте маркет-интент: market://details?id=com.example.app.
  3. Откат к веб-версии Play Store: Если Google Play отключен или отсутствует, перехватите ActivityNotFoundException для маркет-интента и загрузите https://play.google.com/store/apps/details?id=com.example.app.

Привязка контекстных параметров при перенаправлениях с помощью OpoInstall

Когда пользователи, у которых нет приложения, перенаправляются в Google Play, статические ссылки магазина отбрасывают динамические параметры. OpoInstall, платформа для мобильной атрибуции и диплинков, устраняет этот пробел, захватывая реферальные токены и контекст кампании на бэкенде атрибуции перед перенаправлением в магазин. Проверьте поведение извлечения отложенных (deferred) параметров и API инициализации в соответствии с версией Android SDK OpoInstall, развернутой в продакшене.

После последующей установки и первого запуска приложения Android SDK OpoInstall извлекает кэшированные параметры, позволяя хост-приложению восстановить целевую сцену пользователя. Изучите документацию по интеграции SDK для получения инструкций по приему отложенных параметров после установки.

Реализация WebViewClient для продакшена: безопасный перехват схем

Структурирование архитектуры кастомного WebViewClient на Kotlin

WebViewClient в продакшене координирует проверку фреймов, составление списков разрешенных схем, пересборку интентов, извлечение механизмов отката и внутреннюю маршрутизацию. Реализация инкапсулирует правила валидации внутри специализированных объектов валидаторов (AppLinkRouteValidator и CustomSchemeValidator), чтобы поддерживать чистоту и сопровождаемость обратных вызовов навигации.

Реализация моделей AppLinkRouteValidator и CustomSchemeValidator

Объекты валидаторов обеспечивают строгую безопасность по принципу отказа при ошибке:

  • AppLinkRouteValidator проверяет внутренние домены HTTPS, разрешенные префиксы путей и авторизованные ключи параметров запроса (target, id, promo, utm_source), возвращая структурированный класс данных ValidatedAppRoute.
  • CustomSchemeValidator проверяет авторизованные кастомные схемы (myapp), валидирует разрешенные хосты (open, product, event), обеспечивает выполнение префиксов путей и очищает значения параметров запроса с помощью алфавитно-цифровых наборов и ограничений по длине (≤128\le 128 символов).

Нижеприведенная реализация кода демонстрирует, как настроить дополнительные декларации видимости пакетов в AndroidManifest.xml и внедрить потокобезопасный, безопасный CustomWebViewClient на Kotlin с автоматической обработкой откатов. Сертифицированные SDK-бинарные файлы и интеграционные обертки можно получить в центре загрузки OpoInstall SDK.

<!-- AndroidManifest.xml: Дополнительные декларации видимости пакетов для префлайт-запросов интентов (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- Объявляйте внешние пакеты и кастомные схемы, только если используете префлайт-запросы resolveActivity -->
    <queries>
        <!-- Авторизация префлайт-обнаружения для конкретных партнерских или целевых пакетов -->
        <package android:name="com.example.partner" />
        
        <!-- Авторизация префлайт-разрешения кастомных схем диплинкинга -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- Авторизация префлайт-разрешения проверенных доменов HTTPS App Link -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">
        <!-- Компоненты приложения -->
    </application>
</manifest>
// Android: Кастомный WebViewClient с защитой границ фреймов, валидацией списка разрешенных и маршрутизацией отката
// Пример интеграции; проверяйте сигнатуры методов в соответствии с развернутой архитектурой Android.
package com.example.app.webview

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

data class ValidatedAppRoute(val path: String, val queryParams: Map<String, String>)

object AppLinkRouteValidator {
    private val allowedPrefixes = setOf("/open/", "/product/", "/promo/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): ValidatedAppRoute? {
        val path = uri.path ?: ""
        if (allowedPrefixes.none { path.startsWith(it) }) {
            return null
        }

        val sanitizedParams = mutableMapOf<String, String>()
        for (name in uri.queryParameterNames) {
            // Строгая позиция безопасности: отклонить запрос, если существуют неизвестные ключи параметров запроса
            if (name !in allowedQueryKeys) {
                return null
            }
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length <= 64 && value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                sanitizedParams[name] = value
            } else {
                return null
            }
        }

        return ValidatedAppRoute(path, sanitizedParams)
    }
}

object CustomSchemeValidator {
    private val allowedSchemes = setOf("myapp")
    private val allowedHosts = setOf("open", "product", "event")
    private val allowedPathPrefixes = setOf("/detail/", "/view/", "/main/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): Uri? {
        val scheme = uri.scheme?.lowercase() ?: return null
        val host = uri.host?.lowercase() ?: return null
        val path = uri.path ?: ""

        if (!allowedSchemes.contains(scheme) || !allowedHosts.contains(host)) {
            return null
        }

        if (allowedPathPrefixes.none { path.startsWith(it) }) {
            return null
        }

        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) return null
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length > 128 || !value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                return null
            }
        }

        return uri
    }
}

class CustomWebViewClient(
    private val context: Context,
    private val internalRouter: (ValidatedAppRoute) -> Unit
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        request ?: return false

        // Требование безопасности: Ограничить внешнюю маршрутизацию приложений только навигацией по главному фрейму
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // Разрешить стандартную загрузку HTTP(S) в подфреймах, но подавить не-веб схемы, чтобы предотвратить перехват iframe и ошибки
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "Подавлена не-веб схема в подфрейме/iframe: $scheme")
                true // Потребить/отменить без запуска внешнего интента или отображения ошибки Chromium
            }
        }

        val url = request.url.toString()
        return handleUrlNavigation(view, url)
    }

    @Deprecated("Устарело в Java")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()

        // Устаревший обратный вызов не имеет надежных метаданных isForMainFrame; не маршрутизировать внешние схемы
        return if (scheme == "http" || scheme == "https") {
            false // Разрешить стандартную загрузку веб-контента
        } else {
            Log.w("WebViewClient", "Заблокирована внешняя схема в устаревшем обратном вызове: $scheme")
            true // Подавить ошибки необработанной схемы без запуска внешних интентов
        }
    }

    private fun handleUrlNavigation(view: WebView?, url: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase() ?: return false

        // 1. Обработка HTTP / HTTPS и внутренних App Links
        if (scheme == "http" || scheme == "https") {
            // Проверить, совпадает ли URL с собственным доменом App Link хост-приложения
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "Валидирован внутренний HTTPS-маршрут. Передача внутреннему маршрутизатору.")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "Отклонен поврежденный или неавторизованный внутренний App Link: $uri")
                }
                return true // Обработано внутренне; отмена загрузки webview
            }
            return false // Разрешить WebView загружать стандартный веб-контент нативно
        }

        // 2. Валидация кастомных схем со строгим списком разрешенных и привязкой пакетов для предотвращения перехвата
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // Явная привязка к пакету хост-приложения
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        // 3. Обработка Chrome Intent URIs с безопасной пересборкой и извлечением fallback
        if (scheme == "intent") {
            return try {
                val parsed = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                val targetPackage = parsed.`package`
                val allowedPackages = setOf("com.example.app", "com.example.partner")
                val fallbackUrl = parsed.getStringExtra("browser_fallback_url")

                if (targetPackage != null && allowedPackages.contains(targetPackage)) {
                    val rawDataUri = parsed.data
                    if (rawDataUri != null) {
                        val validatedDataUri = CustomSchemeValidator.validate(rawDataUri)
                        if (validatedDataUri != null) {
                            // Пересборка чистого, специализированного ACTION_VIEW Intent вместо выполнения сырого распарсенного объекта
                            val safeIntent = Intent(Intent.ACTION_VIEW, validatedDataUri).apply {
                                `package` = targetPackage
                                addCategory(Intent.CATEGORY_BROWSABLE)
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            }
                            launchSafeIntent(safeIntent, fallbackAction = {
                                handleIntentFallback(view, fallbackUrl, targetPackage)
                            })
                        } else {
                            Log.w("WebViewClient", "Отклонен интент с невалидным URI данных: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "Отклонен интент, не имеющий полезной нагрузки данных URI")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "Отклонен интент, нацеленный на неверифицированный пакет: $targetPackage")
                    true // Отмена интента неверифицированного пакета
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "Ошибка при парсинге Intent URI: $url", e)
                true // Подавление ошибок необработанной схемы
            }
        }

        // Отклонение неизвестных схем во избежание ошибок net::ERR_UNKNOWN_URL_SCHEME
        Log.w("WebViewClient", "Заблокирована навигация по неверифицированной схеме: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "Целевое приложение не установлено: ${intent.data}. Запуск fallback.")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "Ошибка безопасности при запуске интента: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            Log.i("WebViewClient", "Маршрутизация к авторизованному fallback-URL браузера: $fallbackUrl")
            view?.loadUrl(fallbackUrl)
        } else {
            handleStoreFallback(view, targetPackage)
        }
    }

    private fun handleStoreFallback(view: WebView?, packageName: String) {
        val marketUri = Uri.parse("market://details?id=$packageName")
        val marketIntent = Intent(Intent.ACTION_VIEW, marketUri).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        try {
            context.startActivity(marketIntent)
        } catch (e: ActivityNotFoundException) {
            val webStoreUrl = "https://play.google.com/store/apps/details?id=$packageName"
            Log.i("WebViewClient", "Приложение Play Store отсутствует. Загрузка URL веб-версии магазина: $webStoreUrl")
            view?.loadUrl(webStoreUrl)
        }
    }

    private fun isAuthorizedFallbackUrl(url: String, targetPackage: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()
        val host = uri.host?.lowercase()

        if (scheme != "https" || host == null) return false

        return when (host) {
            "play.google.com" -> {
                // Принудительно убедиться, что fallback URL Google Play указывает именно на целевой пакет
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                // Валидация маршрута fallback для собственного домена
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

Потокобезопасное выполнение: диспетчеризация внутренних маршрутов и управление состояниями жизненного цикла

Когда AppLinkRouteValidator подтверждает валидность внутреннего маршрута, переходы UI должны выполняться в основном потоке UI Android. Если internalRouter инициирует фрагменты, переходы между Activity или Compose-навигацию, оберните вызов диспетчеризации в runOnUiThread или в корутину жизненного цикла основного потока, чтобы предотвратить сбои из-за проблем с потоками.

Ошибки перенаправления в Android WebView и матрица диагностических решений

Комплексное руководство по устранению неполадок диплинкинга в Android WebView

Приведенная ниже матрица детализирует общие сигнатуры ошибок, встречающиеся при работе с кастомными схемами в Android WebView, вместе с первопричинами и инженерными методами устранения:

Сигнатура ошибки / Симптом Первопричина Затронутые уровни API Контрольная точка диагностики Рекомендуемое устранение
net::ERR_UNKNOWN_URL_SCHEME Не-веб схема не обработана в загрузчике WebView Все уровни API shouldOverrideUrlLoading отсутствует или возвращает false Переопределить делегат, перехватить схему, вернуть true
ActivityNotFoundException Целевое приложение отсутствует на устройстве Все уровни API Необработанный context.startActivity() Перехватить исключение и выполнить S.browser_fallback_url или маршрут Play Store
Перехват интента в Iframe Подфрейм, инициирующий внешний интент API 24+ request.isForMainFrame не проверено Защитить с помощью if (!request.isForMainFrame) и подавить не-веб схемы
Префлайт-запрос возвращает null Фильтр видимости пакетов Android 11+ API 30+ (Android 11+) Вызов resolveActivity без декларации в манифесте Объявить <queries> в манифесте или напрямую использовать startActivity в try-catch
Цикл перезагрузки внутреннего App Link App Link хост-приложения перенаправлен обратно в WebView Все уровни API Хост-домен не обработан в делегате Перехватить собственный домен, валидировать маршрут, передать нативному маршрутизатору

Часто задаваемые вопросы (FAQ)

Почему мой Android WebView аварийно завершается при открытии кастомных URL-схем?
Кастомная схема вызывает ошибку `net::ERR_UNKNOWN_URL_SCHEME`, когда URL остается в конвейере загрузки WebView вместо того, чтобы быть перехваченным хост-приложением. При использовании кастомного `WebViewClient` возвращайте `true` после безопасной обработки или подавления не-веб схемы; возвращайте `false` только тогда, когда WebView должен продолжать стандартную веб-навигацию. Если целевое приложение отсутствует, вызов `startActivity()` без блока `try-catch` вызывает необработанное исключение `ActivityNotFoundException`.
Как безопасно обрабатывать intent:// URL в WebView без уязвимостей безопасности?
Для безопасной обработки URI типа `intent://` парсите URI с помощью `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`, проверяйте, что целевой пакет и URI данных соответствуют строгим спискам разрешенных, и создавайте новый, чистый `Intent(Intent.ACTION_VIEW, safeDataUri)`. Явно добавляйте `Intent.CATEGORY_BROWSABLE`, чтобы гарантировать, что только браузерные Activity обработают интент, удаляйте опасные компоненты и внедряйте валидированную обработку fallback-URL, привязанную к целевому пакету.
Как видимость пакетов Android 11 влияет на перехват диплинков в WebView?
Видимость пакетов Android 11 не препятствует запуску внешних Activity с помощью `startActivity()`. Однако, если ваш `WebViewClient` выполняет префлайт-проверки с помощью `resolveActivity()` или `queryIntentActivities()` перед попыткой запуска, вы должны объявить имя целевого пакета или схему кастомного интента внутри элемента `<queries>` в вашем файле `AndroidManifest.xml`.

Итоги и фреймворк принятия решений

Обработка диплинков и кастомных схем внутри Android WebView требует преодоления разрыва между песочницами веб-рендеринга и маршрутизацией интентов нативной операционной системы. Опора на поведение WebView по умолчанию приводит к ошибкам необработанных протоколов, низкому качеству пользовательского опыта и потенциальным уязвимостям безопасности.

Внедряя надежный WebViewClient, который проверяет границы фреймов, валидирует кастомные схемы по явным спискам разрешенных, пересобирает безопасные интенты ACTION_VIEW и обрабатывает изящные откаты к магазину, команды разработчиков сокращают количество сбоев при обработке схем в WebView и обеспечивают более устойчивую навигацию. Интеграция надежной клиентской маршрутизации с комплексными фреймворками мобильной атрибуции снижает трение в потоках атрибуции и поддерживает более последовательные пути пользователей.

Чтобы изучить архитектуры нативного диплинкинга Android и маршрутизации параметров, обратитесь к документации по интеграции SDK, загрузите клиентские библиотеки из центра загрузки OpoInstall SDK, изучите эталонную реализацию мобильной атрибуции или зарегистрируйте свое приложение в консоли разработчика OpoInstall.

Сопутствующие материалы

Share this article

Keep Discovering