Перейти к содержимому
Все статьи
8 мин чтения

Ошибка S.browser_fallback_url, которая незаметно съедает app open

Схема последовательных попыток Android intent:// для разных пакетов, где fallback URL стоит только в самом конце

intent://-ссылки выглядят снисходительно. Вы добавляете S.browser_fallback_url, проверяете один раз со своего телефона, видите, что приложение открывается, и выкатываете это в продакшн. Потом кампания запускается, и app open rate на Android оказывается ровно там, где был бы, если бы ссылка сразу вела в браузер. Никаких ошибок. Никаких крашей в консоли. Ссылка сделала ровно то, что вы ей сказали, — проблема как раз в том, что вы ей сказали.

Что на самом деле говорит intent URL

Android intent-ссылка выглядит так:

intent://host/path#Intent;scheme=https;package=<package>;S.browser_fallback_url=<url>;end

Каждый сегмент — отдельная инструкция для Android, а не украшение:

СегментЧто это говорит Android
intent://host/pathцель, выраженная как opaque intent вместо обычного URL
scheme=httpsкакую схему принимающее приложение должно использовать для разрешения цели
package=<package>закрепляет intent за одним конкретным приложением; без этого поля Android может показать chooser или не показать ничего
S.browser_fallback_url=<url>куда пойдёт посетитель, если package не установлен и не может перехватить intent
endзакрывает блок intent

Если читать отдельно, это выглядит как полная инструкция: попробовать приложение, при неудаче — упасть в браузер. Для одного пакета так и есть. Но это перестаёт быть так в тот момент, когда пакетов становится больше одного.

Случай с одним пакетом снисходителен — именно поэтому баг незаметен

Первая intent-ссылка в большинстве случаев нацелена ровно на одно приложение: один пакет, один fallback, готово. Она работает, её выкатывают в продакшн, и она учит неправильному выводу, потому что режим сбоя, о котором эта статья, возникает только тогда, когда в цепочке появляется второй кандидат.

Проверка нескольких пакетов по очереди — это не крайний случай, а обычная ситуация. Ссылка в Telegram может требовать последовательной проверки Play-сборки и Telegram X. Ссылка в WhatsApp может требовать проверки обычного приложения и Business-версии. Каждый кандидат получает собственный блок intent://, который запускается по очереди, пока один из них не сработает. Естественный инстинкт — оставить тот же S.browser_fallback_url, что и в версии с одним пакетом, и просто повторить блок для каждого пакета. Именно этот инстинкт ломает цепочку.

Почему fallback на первом шаге убивает все последующие

Поставьте fallback URL на первый блок — и вы уже решили, что произойдёт, когда именно этот первый пакет отсутствует: Chrome сразу отправляет посетителя на S.browser_fallback_url, во вкладку браузера, ещё до того, как второй блок intent:// вообще запустится. Неважно, что второй пакет стоит на устройстве, установлен и готов. Его никогда не спросят, потому что цепочка уже завершилась.

✗ Неправильно — fallback на первом шаге завершает цепочку раньше времени
intent://…#Intent;scheme=https;package=com.whatsapp;S.browser_fallback_url=https://example.com;end
intent://…#Intent;scheme=https;package=com.whatsapp.w4b;end

✓ Правильно — ни один шаг не несёт fallback, включая последний
intent://…#Intent;scheme=https;package=com.whatsapp;end
intent://…#Intent;scheme=https;package=com.whatsapp.w4b;end

Это та самая контринтуитивная часть, над которой стоит задержаться: в правильной цепочке нет fallback вообще, даже на последнем кандидате. Привязать fallback к последнему пакету выглядело бы корректно изолированно — этот шаг действительно последний шанс для приложения перехватить intent, — но это приучает думать о fallback как о чём-то, что принадлежит отдельному шагу. Это не так. Fallback принадлежит цепочке целиком, применяется ровно один раз, после того как все кандидаты уже проверены и ни один не сработал. И это решение должна принимать ваша собственная страница, а не какой-то отдельный блок intent://.

Почему market:// тут не спасает

Очевидный обходной путь — направить fallback первого шага на страницу Play Store именно для этого пакета, чтобы отсутствующее приложение хотя бы отправляло посетителя куда-то по делу, а не на общую веб-страницу. Это тоже не работает: S.browser_fallback_url принимает только значения http и https. Значение market:// в этом поле Android просто отбрасывает — магазин не то что не открывается, поле изначально не принимает такую схему. Всё, что вы укажете в S.browser_fallback_url, должно быть реальной http- или https-ссылкой, а это снова возвращает вас в ту же ловушку: любой URL в этом поле завершает цепочку на первом отсутствующем пакете — независимо от того, ведёт он в стор или нет.

Правило про gesture превращает провалившийся переход в призрачное «приложение не установлено»

Поверх проблемы с порядком накладывается ещё одно условие, и именно оно делает первую проблему трудной для диагностики извне. Chrome требует user gesture, чтобы обработать навигацию intent://, — тап, а не редирект, срабатывающий при загрузке страницы, и не таймер, срабатывающий сам по себе. Запустите ту же самую ссылку без gesture позади неё — и Chrome вообще не будет пытаться запустить приложение. Он сразу идёт на S.browser_fallback_url, как будто пакета не было.

Снаружи клика — в дашборде отчётности, в записи экрана редиректа, где угодно, кроме инструментирования самой страницы, — этот результат неотличим от того, что приложение действительно не установлено. Оба случая приземляются на тот же fallback URL тем же путём. На стороне получателя нет сигнала, который сказал бы вам «Chrome отказал, потому что не было gesture», кроме «Android попытался, и пакета действительно не было». Различить их можно только зная на своей стороне, какой именно из ваших шагов редиректа реально сработал от тапа.

Именно поэтому цепочка, которая идеально проходит ручное тестирование, всё равно может провалиться в продакшне. Ручной тест — это последовательность тапов: вы тапаете по ссылке, тапаете дальше по всему, что идёт после, и требование gesture выполняется на каждом шаге незаметно для вас, потому что вы даже не замечаете, что это требование. В тот момент, когда любая часть этой последовательности на вашей стороне автоматизируется — а вся упорядоченная цепочка должна прослеживаться до того же исходного тапа, — app open может исчезнуть без единой записи в логах, которая объяснила бы почему.

Где на самом деле место fallback

Сложите правила вместе, и решение перестаёт быть вопросом вкуса:

ПравилоСледствие
Ни один шаг intent:// не несёт S.browser_fallback_urlотсутствующий пакет переходит к следующему кандидату, а не выходит в браузер
S.browser_fallback_url принимает только http/httpsнет трюка со схемой, который позволил бы fallback в середине цепочки одновременно служить ссылкой на стор
Chrome требует user gesture для навигациився упорядоченная цепочка должна прослеживаться до одного тапа, а не до следующего редиректа
Цепочка должна где-то заканчиватьсяименно ваша страница, а не какой-то блок intent://, решает, когда все кандидаты исчерпаны

Последняя строка и есть настоящая задача: построить упорядоченный список попыток intent:// без поля fallback ни в одной из них и позволить своей странице применить store URLhttps://play.google.com/store/apps/details?id=<package> на Android — только после того, как вся цепочка отработала и ни один пакет не перехватил intent. Именно эту задачу маршрутизации и решают трекеры, построенные для трафика в приложения. Цепочки редиректов DarkCore запускают список пакетов на стороне сервера и применяют store fallback ровно один раз, после последнего кандидата, вместо того чтобы зашивать fallback в шаг, где он преждевременно и незаметно обрывает цепочку.

Как это выглядит в вашей воронке

Ни один из двух режимов сбоя не заявляет о себе. Посетитель, чей пакет первого выбора отсутствует и попадает на fallback уже на первом шаге, выглядит в вашей воронке ровно так же, как посетитель, на устройстве которого действительно нет ни одного из кандидатов. Посетитель, у которого редирект посреди пути потерял user gesture, выглядит так же. Все три случая приземляются на тот же fallback URL, в той же точке флоу, с тем же отсутствием ошибки. Меняется одно агрегированное число — app open rate, — и оно падает равномерно, по популяции, которая включает немало людей с приложением прямо на главном экране.

Именно это делает баг настолько сложным для обнаружения по дашбордам: нет сегмента, который можно выделить. «Приложение не установлено» — не группа посетителей, которую можно отфильтровать, потому что такого поля просто не существует: каждый из этих результатов записывает идентичное fallback-событие. Единственный способ отделить сбои порядка от сбоев gesture от подлинного отсутствия приложения — сознательно проверить каждое условие отдельно: запустить цепочку вручную со всеми установленными кандидатами, убедиться, что каждый срабатывает по очереди, а затем повторить с тем же триггером, которым цепочка реально запускается в продакшне — та же страница, тот же триггер, тот же путь редиректа, — и посмотреть, доживает ли тап до последнего шага.

Перед тем как выкатить следующую цепочку

  • Уберите S.browser_fallback_url из каждого шага в цепочке с несколькими пакетами, включая последний. Решение о fallback принадлежит цепочке, а не отдельному блоку intent://.
  • Никогда не пишите market:// в S.browser_fallback_url. Android тихо отбрасывает это значение — принимаются только http и https, так что fallback со схемой стора — не обходной путь, а значение, которое поле просто не читает.
  • Запускайте всю цепочку от одного user gesture. Всё, что передаёт эстафету посреди цепочки без свежего тапа, может потерять app open без единого объяснения в логах.
  • Относитесь к «приземлению на fallback URL» как к неоднозначному результату по умолчанию. Это означает либо «ни один пакет в цепочке не перехватил», либо «условие gesture не выполнено», и только инструментирование на вашей собственной странице — а не само fallback-событие — может сказать, что именно произошло.
  • Применяйте store fallback один раз, на своей странице, после того как упорядоченный список кандидатов полностью отработал. Это единственная точка флоу, где утверждение «ничего не перехватило» действительно верно.
  • intent url android
  • s.browser_fallback_url
  • схема intent url в android
  • fallback url для intent