
Всем привет! Я Никита, тимлид команды Android в Банки.ру.
Недавно, внедряя в приложение новую фичу, мы столкнулись с тем, что она в упор отказывалась работать – но только на Android. Мы дебажили ее полторы недели, перепробовали все возможные варианты и закошмарили всех доступных операторов связи, прежде чем узнали, что все дело было… в энергосбережении.
Бизнес захотел Seamless
Однажды к нам пришел бизнес с такой задачей: пользователь вводит номер телефона – и он уже авторизован, без SMS-кодов, пушей и подтверждений. Выглядит как магия, но на самом деле это фича, которая называется Seamless-авторизация. Не буду долго томить, вот как это выглядит у нас:
Под капотом – HTTP Header Enrichment (HHE): сетевой шлюз мобильного оператора (PGW в LTE-сети) перехватывает HTTP-запросы и инжектирует заголовки – X-MSISDN (номер телефона), X-IMSI и другие идентификаторы абонента. За счет этого целевой сервер получает номер без каких-либо действий пользователя.
HHE работает только по HTTP и только через мобильную сеть. HTTPS-трафик зашифрован TLS – оператор не может модифицировать заголовки, а WiFi-трафик идет напрямую через провайдера, минуя PGW оператора – инжекция невозможна.
Контекст: как у нас устроена авторизация
В нашем приложении два пути авторизации: нативная (внутри приложения) и SSO – через браузер, который открывается в внутреннем браузере Custom Tab. Seamless должен был работать только в рамках SSO-флоу.
Приложение выбирает браузер с поддержкой Custom Tabs – чаще всего это Chrome, но на устройствах без Chrome (например, Huawei) используется другой подходящий браузер, поддерживающий технологию. Пользователь попадает в браузер, проходит авторизацию, его редиректит обратно в приложение.
В этом процессе пять участников: приложение, Custom Tab (ActiveTab), KeyCloak, MobileId и HheUri оператора.
Приложение открывает Custom Tab со страницей авторизации, передавая state
Пользователь вводит номер телефона
KeyCloak отправляет запрос к оператору Mobile ID, получает одноразовую ссылку hhe_uri
Параллельно приложение начинает лонг-поллинг KeyCloak – POST-запрос с тем же state, ожидая получить ссылку
Получив ссылку – приложение делает GET запрос на полученный hhe_uri оператора
Оператор идентифицирует абонента по Header Enrichment
Подтверждение летит обратно: оператор уведомляет наш backend об успехе → сервис авторизации успешно завершает процесс по флоу → пользователь авторизован

Но это если совсем кратко. Если интересно узнать больше про то, как у нас работает SSO-авторизация, то мои коллеги написали про нее отдельную статью.
Проблемы на Android и трудности дебага
Разработка шла параллельно на iOS и Android. И если на iOS все работало более-менее стабильно, то на Android вылезали постоянные ошибки. Когда мы начали их дебажить, столкнулись сразу с несколькими препятствиями.
Логи врут. Точнее – недоговаривают. В логах видишь: запрос ушел, таймаут, ошибка. Но почему таймаут? Оператор не ответил? DNS не резолвнулся? Система обрезала соединение? Логи не различают эти сценарии.
А иногда HHE-ссылка могла вернуть HTTP 200 и при этом не сработать. Срок жизни ссылки 5 секунд – и на медленном мобильном интернете это приводило к тому, что пользователь не успевал по ней перейти, и оператор переключался на другой способ авторизации. У нас в логах 200, мы думаем, что все сработало, а на самом деле пользователь получил обычный пуш или СМС с кодом входа вместо Seamless-магии.Нельзя снифать трафик. Так как Seamless работает только через мобильную сеть, мы не можем использовать Charles Proxy, mitmproxy и другие привычные снифферы, чтобы посмотреть, что происходит с запросами.
Операторы банят. Когда ты отлаживаешь авторизацию, ты делаешь десятки попыток подряд. Оператор видит аномальную активность с одного номера и может отказать в авторизации. Приходилось ротировать SIM-карты, ждать кулдауны, просить коллег тестировать со своих номеров.
Итого: фича, которую нельзя нормально отдебажить, в сети, которую нельзя просниффать, с оператором, который тебя банит за попытки.

4 теории и один вопрос
Так или иначе, дебажить надо, и у нас появилось несколько теорий.
Теория 1: Оператор режет запросы
Мобильный оператор как-то по-разному обрабатывает трафик с Android. Проверили – не подтвердилось.
Теория 2: DNS
Резолвинг падает, адреса не резолвятся вовремя. Так как мы используем полинг, то запросов уходит за короткое время много, и каждый запрос резолвил DNS. Мы внедрили кэширование DNS с TTL 10 минут. Логика простая: первый запрос резолвит адрес, все последующие берут из кеша в течение определенного времени. Это убрало задержку на DNS-резолвинг и немного улучшило статистику. Но ключевую проблему не решило – запросы всё равно обрывались, просто чуть позже.
Теория 3: Разные операторы, разные регионы
Мобильный интернет даже в идеальных условиях – штука непредсказуемая и сильно зависящая от региона и оператора. Из-за этого фичу приходилось тестировать на разных операторах, в разных городах, у разных людей, на разных устройствах. Результаты плавали: где-то чуть лучше, где-то хуже, но нигде не хорошо.
Например, Seamless поддерживают не все операторы связи. В процессе отладки это отлично маскировало реальный баг: ты не знаешь, это твой код виноват или оператор не поддерживает фичу.
В какой-то момент мы завели таблицу: оператор × регион × платформа × результат.
Теория 4: Проблемы на бекэнде
Этот вариант тоже нельзя было исключать, и мы действительно нашли парочку багов. Например, когда Seamless не проходил, SSO переключался на резервный способ, который выбирал – иногда пуш, иногда код в SMS (который еще и приходил дважды почему-то), а иногда ничего, просто таймаут.
Хотя эта информация нам и пригодилась – как и остальные выловленные ошибки – корень проблемы мы так и не нашли.
Не теория, а вопрос, который мы недооценили
В первый же день дебага одна коллега спросила:
– «А ты при этом в background не уходишь? Остаешься в приле?»
– «Ну что за вопрос, конечно, остаюсь, Custom Tab же открыт поверх»
Тут вернемся ещё раз к тому, как происходит авторизация.
Приложение открывает внутренний браузер Custom Tab в своём Activity поверх Activity нашего приложения.
И хоть на экране в этот момент показана авторизация, фактически Activity приложения уже находится в
onStop()и для системы наше приложение висит в состоянии background.Согласно документации Android: когда новая Activity полностью закрывает текущую, текущая получает
onPause() → onStop(). Custom Tab – это отдельная Activity в процессе браузера
А спас нас Huawei
Однажды, почти случайно, мы проверили фичу на Huawei, и на нем все работало стабильно, как на iOS. Так мы поняли, что проблема не в бэкенде, операторе или DNS, а в самой вендорной надстройке Android.
Мы начали копать в эту сторону и пришли к механизмам энергосбережения, но не совсем к тем, что мы ожидали.
Что должно было работать
По документации Custom Tabs, браузер поднимает process importance приложения через bound KeepAliveService:
"Apps launching a Custom Tab won't be evicted by the system during the Tab's use – its importance is raised to the foreground level."
На стоковом Android система видит bound service от браузера, процесс получает повышенный приоритет, сетевые запросы проходят. Согласно документации по resource limits, foreground-процессы не имеют ограничений на сеть.
Важная оговорка: KeepAliveService – механизм Chrome. Другие браузеры с поддержкой Custom Tabs могут реализовывать его иначе или не реализовывать вовсе.
Что происходит на самом деле
Samsung и некоторые другие вендоры (Xiaomi/Redmi) добавляют проприетарные слои оптимизации поверх стандартного Android. Эти слои игнорируют стандартные механизмы защиты процессов.
Samsung App Management убивает процессы по таймеру, не глядя на bound services, active network connections или любые другие стандартные сигналы:
Пользователь нажимает «Войти» – открывается Custom Tab в браузере
Наша Activity уходит в onPause() → onStop()
Браузер (если это Chrome) поднимает process importance через KeepAliveService
Samsung'овская проприетарная оптимизация игнорирует KeepAliveService и начинает убивать процесс
Соединения обрываются: SocketException: Software caused connection abort
В то же время Huawei мониторит network utilization напрямую. Он видит наш активный поллинг (запросы каждые 500мс) и не трогает процесс.
Мы отключили энергосбережение в настройках Samsung, и все заработало.
Решение: ForegroundService
Проблема ясна. Но мы не можем просить каждого пользователя лезть в настройки и отключать оптимизацию батареи.
Мы пришли к ForegroundService с типом shortService. Он стартует в момент открытия Custom Tab для Seamless-авторизации и останавливается, когда приходит ответ.

Что он делает: процесс с ForegroundService получает importance level PERCEPTIBLE_APP_ADJ (~200), что достаточно для снятия ограничений на сеть. Из документации Android: "An app is considered to be in the foreground if... It has a foreground service." Даже Samsung'овская оптимизация не трогает foreground services на One UI 6+.
Вот ключевая часть сервиса:
class SeamlessForegroundService : Service() { override fun onCreate() { super.onCreate() try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( NOTIFICATION_ID, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE, ) } else { startForeground(NOTIFICATION_ID, createNotification()) } } catch (_: Exception) { stopSelf() } } companion object { fun start(context: Context) { try { context.startForegroundService( Intent(context, SeamlessForegroundService::class.java), ) } catch (_: Exception) { // ignore – service is best-effort } } fun stop(context: Context) { context.stopService( Intent(context, SeamlessForegroundService::class.java), ) } } }
Пара технических деталей:
shortService – это Android 14+ (API 34). На Android 10-13 работает как обычный ForegroundService без 3-минутного лимита.
shortService имеет жесткий таймаут ~3 минуты на Android 14+. Для нашей задачи (10-15 секунд поллинга) – с запасом.
Весь сервис обернут в try-catch – если запуск не удался, Seamless просто работает без foreground-приоритета (best-effort).
Привязка к жизненному циклу SSO – в SingleActivity. При старте SSO-авторизации запускаем сервис.
override fun openTabSso(url: String) { if (seamlessProcessHandler.isAvailable()) { SeamlessForegroundService.start(this) } // ... открытие Custom Tab, запуск поллинга }
При выходе из внутреннего браузера – останавливаем.
private fun doOnSsoEvents(navigationEvent: Int) { if (navigationEvent == SSO_EXIT) { seamlessProcessHandler.stop() SeamlessForegroundService.stop(this) } }
А вот что удивило нас самих: сервис создаёт уведомление в строке статуса. Но если пользователь запретил уведомления приложению – сервис всё равно запускается. Из документации Android:
"Apps don't need to request the POST_NOTIFICATIONS permission in order to launch a foreground service."
На Android 13+ без разрешения: уведомление невидимо в drawer, видно в Task Manager. Сервис работает. Авторизация проходит.
Мы проверили отдельно: на Android ниже 13 разрешение на уведомления выдаётся автоматически при установке – сервис стартует и показывает плашку. На Android 13+ без разрешения – тоже работает.
После внедрения ForegroundService мы прогнали полный цикл тестирования:

Redmi 9 на Android 10, который еле дышит – и тот прошёл Seamless без запинки.
Побочный квест – оздоровление авторизации
Пока мы полторы недели ковырялись в Seamless, мы основательно прошлись по всему флоу авторизации и нашли много интересного.
Баги, костыли, неочевидное поведение – вещи, которые годами жили в коде и никого не беспокоили, потому что «работает же». Seamless заставил нас залезть в каждый угол SSO-флоу, и мы вытащили на свет проблемы, о которых никто не знал.
Всё найденное мы собрали и отнесли команде авторизации. В итоге юзеры получили не только удобную авторизацию без кода, но и более стабильную работу обычного входа.
Финал – подарок на день рождения
Фикс залетел в сборку 6 мая в мой день рождения. Один коллега написал: «Когда хорошо работает, выглядит как магия конечно».
Тестирование прошло чисто. Коллега написал в чат: «Ну кажется Никита сделал себе подарок на ДР и победил симлес». А ещё самое милое: «Хорошо, что Никита родился».

Полторы недели слепого дебага, ротация SIM-карт, ложные теории, DNS-кеширование, которое помогло, но не спасло – и в итоге один ForegroundService на 78 строк кода.
Выводы и что мы вынесли
Всегда помни про жизненный цикл компонентов. Это базовая база, про которую ты узнаешь сразу заходя в Android разработку и не просто так.
Современная Android разработка требует большого зоопарка устройств. Для локализации проблем лучше сразу проверить гипотезы на нескольких вендорах. Samsung и Huawei ведут себя принципиально по-разному. Samsung убивает по таймеру вслепую, Huawei мониторит сетевую активность.
Не отметай гипотезы, даже если они тебе кажутся нереальными, как получилось у нас с вопросом про background.
Режим энергосбережения – отдельная история, которая может влиять на разные фичи в совсем неочевидных местах. Это надо учитывать при разработке и проектировании.
Не доверяй очевидным теориям. Мы потратили дни на операторов, DNS и регионы. Проблема была в вендорной надстройке Android.
Custom Tab ≠ WebView. Когда ты открываешь Custom Tab, твоя Activity уходит в onStop(). Процесс формально защищён браузером через KeepAliveService, но вендорная оптимизация это не учитывает.
Всегда заранее продумывай, как будешь дебажить и тестировать. Если фича живёт в слепой зоне (cellular only, нет сниффера, оператор банит), трудозатраты на отладку вырастут кратно.
Побочные эффекты имеют ценность. Полторы недели разработки позволили нам оздоровить авторизационный флоу.
Спасибо, что прочитали статью. Надеюсь, было полезно и интересно. Буду рад пообщаться в комментариях!
Ссылки и источники
Android документация
Activity State Changes – onPause/onStop при перекрытии Activity
ForegroundService types – shortService, ограничения, API level
Process Lifecycle – process importance levels
Power Management Resource Limits – таблица ограничений по состоянию приложения
Notification Permission – POST_NOTIFICATIONS и FGS
Chrome Custom Tabs
KeepAliveService README – process importance elevation
Вендорная оптимизация
dontkillmyapp.com – рейтинг агрессивности вендоров
Samsung – #1 worst offender
Huawei – #3, PowerGenie
Samsung App Management – официальная документация
OkHttp / сетевые ошибки
okio#1167 – Jake Wharton о SocketException
okhttp#4107 – Jesse Wilson о background socket kills
