Мы делаем RCQ, мессенджер со сквозным шифрованием и открытым кодом (Android, iOS). Google-сервисы в нашем основном регионе недоступны, значит FCM отпадает, а без пуш-уведомлений мессенджер это не мессенджер: приложение, о котором узнаёшь, только когда сам его открыл, никому не нужно.
Стандартный ответ на эту задачу известен и выглядит красиво: UnifiedPush. Открытый контракт, никакого Google, пользователь сам выбирает, через кого получать сигналы. Мы так и сделали в июне, всё заработало на тестовых устройствах, и мы поехали дальше.
Полтора месяца спустя пошли жалобы, что уведомления не приходят. Мы полезли в логи прода и обнаружили, что четыре из пяти попыток разбудить устройство заканчивались отказом, и так было с первого дня работы фичи. Ниже разбор: почему так, почему платный тариф это не лечит, и что мы в итоге написали сами. Цифры настоящие, из журнала боевого сервера.
Как устроен UnifiedPush, в двух абзацах
Если коротко: UnifiedPush разносит доставку на три роли. Есть ваше приложение, есть ваш сервер и есть дистрибьютор, отдельная программа на телефоне, которая держит связь с каким-то пуш-сервером.
Приложение через системный бродкаст просит у дистрибьютора эндпоинт. Дистрибьютор отвечает обычным HTTPS-адресом, приложение отправляет этот адрес вашему серверу. Дальше сервер, когда надо разбудить телефон, просто делает POST на этот адрес. Пуш-сервер доставляет байты дистрибьютору, дистрибьютор системным бродкастом отдаёт их приложению. Всё, никакого Google в цепочке.
Самый популярный дистрибьютор это ntfy, а самый популярный пуш-сервер это его публичный экземпляр ntfy.sh. Именно на этой связке и стоит по умолчанию почти всё, что использует UnifiedPush. Мы тоже начали с неё.
Что показали логи
Наш сервер логирует каждую попытку разбудить устройство. Вот срез за 36 часов, только по Android-эндпоинтам:
исход попытки |
сколько |
|---|---|
доставлено |
100 |
|
351 |
|
69 |
|
19 |
То есть на каждую успешную доставку приходилось больше четырёх отказов.
Первая реакция была «что-то сломалось на той стороне». Мы подняли весь журнал, который был на сервере, и оказалось хуже: отказы идут с 15 июня, то есть со дня, когда фича вышла. Ничего не ломалось, оно никогда толком и не работало, просто на паре тестовых телефонов, которые всё время на связи, это было незаметно.
Дальше три кода, три разные причины. Разбираю каждую, потому что все три неочевидны и все три бьют именно по продакшену, а не по тестам.
507: «сначала подпишись, потом публикуй»
Код 507 для публикации выглядит странно, пока не прочитаешь текст ошибки:
cannot publish to UnifiedPush topic without previously active subscriber
Идём в исходники ntfy, в обработчик публикации:
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil { // UnifiedPush clients must subscribe before publishing to allow proper // subscriber-based rate limiting. return nil, errHTTPInsufficientStorageUnifiedPush.With(t) }
Читается так: если это UnifiedPush-топик, и на сервере включено лимитирование по подписчику, и прямо сейчас на этот топик нет зарегистрированного подписчика, то публикация отвергается. Не откладывается, не кэшируется, а отвергается. Сообщения не существует.
Теперь следите за руками: официальное приложение ntfy для топиков на ntfy.sh в сборке из Google Play использует Firebase. Постоянное соединение (в интерфейсе это называется instant delivery) там опциональное, всегда включено оно только в сборке с F-Droid. Логика разработчиков ntfy понятна и разумна: зачем жечь батарею постоянным сокетом, если есть FCM.
Собираем вместе. В регионе, где FCM не работает, пользователь ставит ntfy из Play, тот ждёт сигнала через мёртвый Firebase и постоянного соединения с ntfy.sh не держит. С точки зрения сервера подписчика на топике нет. Наш POST получает 507. Пуш не просто теряется, он даже не сохраняется, чтобы дойти позже.
То есть связка «UnifiedPush + ntfy из Play + ntfy.sh» в регионе без Google по умолчанию не работает вообще, и узнать об этом можно только из кода сервера, потому что снаружи всё выглядит настроенным.
429: лимит списывается не с вас, а с получателя
Дальше интереснее. Обычный 429 от чужого сервиса читается однозначно: вы слишком часто стучитесь, притормозите. Мы честно сели считать, укладываемся ли мы в лимиты ntfy.sh (по умолчанию это ведро на 60 запросов, которое пополняется со скоростью один запрос в 5 секунд), и получили, что при нашей нагрузке должны укладываться с запасом.
Оказалось, ведро не наше. Смотрим middleware, через который проходит публикация:
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { t, err := s.topicFromPath(v, r.URL.Path) if err != nil { return err } vrate := v if rateVisitor := t.RateVisitor(); rateVisitor != nil { vrate = rateVisitor } ... } else if !vrate.RequestAllowed() { return errHTTPTooManyRequestsLimitRequests }
v это тот, кто делает запрос, то есть мы. Но если у топика есть rate visitor, то есть подписчик, лимит проверяется у подписчика. Это осмысленное решение со стороны ntfy: иначе один отправитель мог бы выесть общий лимит на всех. Но для нас последствия оказались тяжёлыми.
Подписчик опознаётся по IP. А наши пользователи это в основном мобильный интернет, где сотни абонентов сидят за одним NAT оператора. Для ntfy.sh все они один и тот же посетитель с одним ведром на 60 запросов. Достаточно одного сообщения в группу, чтобы наш сервер отправил пачку пушей, ведро схлопнулось, и дальше отказ получают все, кто оказался за тем же NAT, включая тех, кому мы вообще ничего не слали.
Отдельно отмечу симптом, по которому это можно опознать у себя: у нас 37 из 59 проблемных эндпоинтов в течение одних суток получали и 429, и 507. Состояние мигает, поэтому «то работает, то нет» в жалобах пользователей это не преувеличение, а точное описание.
400: обязательный заголовок, о котором легко не знать
Третий код бил только по части эндпоинтов, и все они были на updates.push.services.mozilla.com. Это значит, что у пользователя стоял не ntfy, а дистрибьютор на базе WebPush.
WebPush это RFC 8030, и там заголовок TTL в запросе обязателен. Не «желателен», а обязателен, сервер вправе отказать без него. Мы его не отправляли, потому что ntfy на него не смотрит, а больше мы ничего и не пробовали. В результате у этих пользователей пуши не работали никогда, ни одной секунды, а в логах это выглядело как редкий безобидный 400.
Мораль простая: если вы публикуете в UnifiedPush-эндпоинт, вы не знаете, что там за пуш-сервер на другом конце. Отправляйте корректный с точки зрения WebPush запрос всегда.
Content-Type: application/json TTL: 86400 Urgency: high
Почему платный тариф не спасает
Первая мысль после такого разбора: ну хорошо, купим у ntfy.sh платный аккаунт и будем ходить с токеном, там лимиты выше.
Не поможет. Посмотрите ещё раз на оба фрагмента кода выше. Проверка на 507 смотрит только на наличие подписчика. Проверка лимита смотрит на rate visitor, то есть снова на подписчика. Ни там, ни там нет исключения для авторизованного отправителя. Платный тариф поднимет лимиты вашего посетителя, а вас режет чужой, тот, кому вы шлёте.
Это, кстати, хороший общий вывод: перед тем как платить за проблему, найдите в коде то место, которое вас отвергает, и проверьте, смотрит ли оно вообще на деньги.
Что мы сделали: свой пуш-сервер
Вывод из всего перечисленного получился неутешительный: публичный ntfy.sh нашей аудитории служить не может, и никакими настройками на нашей стороне это не чинится. Значит, нужен свой пуш-сервер, где обе ручки наши.
Сам ntfy для этого прекрасно подходит, он для того и open source. Развернули рядом с бэкендом, вот значимая часть конфига:
base-url: "https://push.example.app" listen-http: "127.0.0.1:2586" behind-proxy: true # Держим сигнал для устройства, которое спит или без сети cache-file: "/var/lib/ntfy/cache.db" cache-duration: "12h" # Вот это и есть источник 507. Выключено. visitor-subscriber-rate-limiting: false # Ведро, рассчитанное на групповую рассылку, # и наш собственный адрес, освобождённый от него совсем visitor-request-limit-burst: 200 visitor-request-limit-replenish: "1s" visitor-request-limit-exempt-hosts: "127.0.0.1, БЛАБЛА_АДРЕС" visitor-message-daily-limit: 0 # Веб-морда и аккаунты нам не нужны, это лишняя поверхность web-root: "disable" enable-signup: false enable-login: false
Проверили ровно те три сценария, на которых горели:
публикация в топик, на котором никогда не было подписчика, отвечает
200, а не507, и сообщение лежит в кэше, пока телефон не появится;120 публикаций подряд одним потоком дают 120 ответов
200, ни одного429;сигнал, отправленный пока телефон в авиарежиме, доезжает после возвращения сети.
Что мы сделали: свой дистрибьютор внутри приложения
Своего сервера мало. Пользователю по-прежнему надо поставить отдельное приложение, зайти в его настройки и вписать туда наш адрес. Для мессенджера, который человек ставит, чтобы просто переписываться, это стена, о которую молча теряется половина людей.
Поэтому дистрибьютор мы написали свой, прямо внутри приложения. Спецификация UnifiedPush это разрешает явно, такой вариант называется embedded distributor.
Приятная новость: писать надо мало, потому что контракт это несколько бродкастов. Приложение шлёт дистрибьютору:
org.unifiedpush.android.distributor.REGISTERс полямиapplicationиtoken;...UNREGISTERсtoken;...MESSAGE_ACKсtokenиid.
Дистрибьютор отвечает приложению:
org.unifiedpush.android.connector.NEW_ENDPOINTсtokenиendpoint;...MESSAGEсtoken,bytesMessageиid;...UNREGISTERED,...REGISTRATION_FAILED.
Практическая деталь для тех, кто полезет повторять: точные имена полей мы в итоге доставали из самой библиотеки-коннектора, распаковав .aar и вытащив строковые константы из класс-файлов. Это быстрее, чем сверять документацию с реальностью.
Дальше два компонента. Первый это обычный BroadcastReceiver, объявленный в манифесте с действием REGISTER (это заодно то, что делает приложение видимым в списке дистрибьюторов, приложение имеет право быть дистрибьютором самому себе):
val topic = ensureTopic(ctx, token) // up + 16 случайных символов из SecureRandom val endpoint = "$PUSH_HOST/$topic?up=1" ctx.sendBroadcast(Intent(ACTION_NEW_ENDPOINT).apply { `package` = ctx.packageName putExtra(EXTRA_TOKEN, token) putExtra(EXTRA_ENDPOINT, endpoint) })
Обратите внимание, что путь эндпоинта и есть весь секрет: кто знает имя топика, тот может разбудить это устройство. Поэтому топик генерируется криптостойким генератором, а не Random.
Второй компонент это foreground-служба с одним WebSocket до своего пуш-сервера. Постоянное уведомление в шторке это цена вопроса, Android не разрешает держать фоновое соединение без него, и ровно за это же платит ntfy.
// ntfy отдаёт события построчным JSON, нас интересует одно when (str("event")) { "message" -> { val body = str("message") ?: return deliver(body, str("id")) // тот самый MESSAGE-бродкаст в приложение } else -> Unit // open и keepalive просто подтверждают, что сокет жив }
Важная мелочь, которая экономит пользователю пропущенные сообщения: при переподключении мы передаём серверу since с идентификатором последнего доставленного сообщения. Тогда всё, что прилетело, пока телефон был офлайн, доезжает из кэша сервера, а не теряется.
Ниже по цепочке в приложении не изменилось ничего. Сигнал приходит тем же бродкастом, что раньше приходил от ntfy, через ту же библиотеку-коннектор, в тот же обработчик. Это, пожалуй, главный аргумент за embedded-дистрибьютор вместо своего велосипеда с нуля: вы меняете только транспорт, а весь код, который строит уведомления и разбирает payload, остаётся как был. И пользователь при этом не теряет выбор: ntfy остаётся в списке и переключиться можно в любую сторону.
Два бага, которые видно только на устройстве
Оба выглядят очевидно в пересказе и оба не видны при чтении кода. Мы поймали их на эмуляторе за десять минут живого теста.
Смена топика. Повторная регистрация выдаёт новый топик, а служба продолжала слушать старый. Сокет живой, логи чистые, состояние «подключено», и ни одного сигнала. Лечится сравнением активного топика с сохранённым при каждом старте службы, но искать это по логам «всё хорошо» неприятно.
Реконнект-двойник. Когда мы намеренно закрываем устаревший сокет, у него срабатывает onClosed. Обработчик не отличал это от обрыва сети и планировал переподключение поверх только что созданного. Получалось два живых сокета и каждое уведомление приходило дважды. Лечится счётчиком поколений: слушатель знает свой номер и молча игнорирует всё, если он уже не актуален.
Если будете делать похожее, заложите время на прогон именно на устройстве. Ни один из этих двух багов не ловится ни компилятором, ни юнит-тестом на чистых функциях.
Пуш-сокет должен ходить теми же путями, что и всё остальное
Последний пункт, до которого мы дошли не сразу, и он важен всем, кто работает в условиях блокировок.
У нашего приложения есть встроенный обход блокировок, весь трафик при необходимости уходит в туннель. А пуш-сокет мы сначала сделали обычным клиентом, и он оказался единственным соединением в приложении, прибитым к прямому маршруту. То есть при блокировке пуш-домена уведомления умерли бы первыми, ровно в тот момент, когда человеку важнее всего узнать, что ему написали.
Тонкость реализации: OkHttp фиксирует маршрут в момент создания клиента, поэтому недостаточно один раз спросить «включён ли туннель» при старте службы. Клиент надо пересоздавать при каждой смене транспорта, плюс уметь переподключаться по требованию, потому что туннель поднимается на секунду позже, чем сокет успевает набрать при запуске приложения.
Честно про границы
Всё вышеописанное не делает пуши на Android без Google такими же надёжными, как FCM. Стоит понимать, за что вы платите.
Постоянное уведомление в шторке никуда не денется. Его можно посадить на отдельный тихий канал с минимальной важностью, но убрать нельзя, это требование системы.
Агрессивное энергосбережение остаётся вашим противником. Doze и фирменные «оптимизаторы» некоторых вендоров усыпляют соединение, и это ровно та же проблема, что у любого дистрибьютора, включая ntfy. Force stop убивает вас так же, как всех.
Свой пуш-сервер видит метаданные: какой топик будят, когда и как часто. У нас он стоит рядом с бэкендом, который и так это знает, так что новой стороны в цепочке не появляется. Если вы поднимаете его отдельно, помните, что это ещё один наблюдатель.
И, разумеется, свой пуш-домен это ещё один адрес, который могут заблокировать. Именно поэтому предыдущий пункт про туннель не косметика.
Что стоит забрать из этого текста
Замерьте, сколько пушей у вас реально доходит. Не «работает ли уведомление на телефоне разработчика», а доля успешных ответов по всем эндпоинтам за сутки. Мы полтора месяца были уверены, что фича работает, а отказов было вчетверо больше, чем доставок.
Прочитайте код чужого сервиса, прежде чем платить ему. Оба отказа, которые нас убивали, объясняются двумя фрагментами по пять строк, и оба игнорируют деньги.
Свой дистрибьютор это меньше работы, чем кажется. Несколько бродкастов, одна foreground-служба и один WebSocket. Зато исчезают и чужие лимиты, и требование поставить второе приложение, а весь код обработки уведомлений остаётся нетронутым.
Всё описанное живёт в нашем мессенджере RCQ, Android-клиент открыт: github.com/rcq-messenger/rcq-android. Смотреть имеет смысл каталог push/embedded, там ровно те три файла, о которых речь.
Если вы держите UnifiedPush в проде и мерили доставку, расскажите, какие цифры видите вы. Особенно интересно, как ведут себя дистрибьюторы на WebPush, у нас их слишком мало, чтобы делать выводы.
Комментарии (33)

dyadyaSerezha
31.07.2026 21:52Как вы помните мы делаем мессенджер.
Нет, мы не помним. Нас тут десятки тысяч и все не следят за вашими статьями. А вам трудно дать в каждой статье хотя бы ссылку на сайт или репо вашего мессенджера? Или вам не нужны клиенты? Я понимаю политику “кому надо, тот сам всё разроет и сам разберётся”, но нужна ли вам именно эта политика?
Кстати, зашёл на репо. В главной папке нет readme. Почему?
При установке на Андроид, на одном из начальных экранов написано: работает через Bluetooth и Wi-Fi direct. А через стандартный инет работает? Непонятно.

rcq Автор
31.07.2026 21:52По всем трём пунктам справедливо, спасибо. По порядку.
Про «как вы помните»: вы правы, так писать нельзя, читатель видит нас впервые. Поправили начало статьи, ссылка на сайт и на репозитории теперь в первом абзаце, а не в футере.
Про README: полез проверять и нашёл кое-что похуже. В репозиториях клиентов README есть, но у Android он был написан задолго до текущего состояния и сообщал, что это pre-alpha, в Play не выкладывается и кросс-платформенная совместимость ещё допиливается. Всё это давно неправда. Устаревший README хуже отсутствующего, потому что он отвечает на вопрос, и отвечает неверно. Переписали: что это, где скачать, что где лежит в коде, как собрать. Заодно нашли публичный репозиторий со спекой, у которого README действительно не было, добавили. Если вы заходили в какой-то третий репозиторий, скажите в какой, посмотрим и его.
Про Bluetooth и Wi-Fi Direct: это целиком наш косяк в тексте, а не ваше невнимание. Обычная переписка идёт через интернет, как в любом мессенджере. Bluetooth и Wi-Fi Direct это отдельный режим (Радиочат) на случай, когда сети нет совсем: устройства рядом находят друг друга и передают сообщения напрямую. Слайд онбординга был написан так, что читался как описание всего продукта. Текст переписали, в следующей сборке он начинается со слов о том, что обычная переписка идёт через интернет.

dyadyaSerezha
31.07.2026 21:52Спасибо за оперативный ответ и даже фиксы.
Про readme, имелась ввиду папка выше Андроида, то есть, главная папка проекта. Там нет readme.
Что пофиксили и обновили описания, это хорошо.

rcq Автор
31.07.2026 21:52Точно, и это оказалось хуже, чем я сначала подумал. У организации действительно не было страницы: GitHub рисует её из специального репозитория
.github, а его у нас просто не существовало. Человек приходил на организацию и видел список из восьми репозиториев без единого слова о том, что это и с чего начинать.Сделали: github.com/rcq-messenger теперь открывается описанием проекта, таблицей «хочу сделать это, идти сюда» по всем репозиториям, ссылками на спеку, приватность и условия.
Спасибо, что дожали, сами бы мы на это ещё долго не посмотрели: когда живёшь внутри проекта, точка входа для постороннего человека это последнее, что замечаешь. Приносим изменения и еще раз благодарим за внимательность.
flashmozzg
31.07.2026 21:52Комннты читаются как нейрослоп =(

Lagovi
31.07.2026 21:52Они и есть нейрослоп(
Я понимаю желание оптимизировать и автоматизировать все вокруг, но как же кровоточат глаза...

flashmozzg
31.07.2026 21:52Сначала подумал, что мб просто через сетку прогоняют для вычитывания или какие-то куски скопипастили, но почитал остальные ответы и к сожалению да, все полимеры потеряны =(

vobinoy987
31.07.2026 21:52Как же знакомо все) Да, firebase - единственная возможность получить пуш, если приложение смахнуто. Тоже прошли через все эти костыли и остановились на таком варианте: по умолчанию сообщение дублируется на почту пользователя если не прочитано в течении 10 минут. клиенты обычно в фоне норм работают и доставка у них более стабильна

rcq Автор
31.07.2026 21:52Но с «firebase единственная возможность, если приложение смахнуто» я поспорю, тут важная разница, на которую мы сами не сразу посмотрели внимательно.
Смахивание из недавних и force stop это разные вещи. Force stop убивает всё, включая доставку через FCM: система перестаёт будить приложение, пока пользователь сам его не откроет. А обычное смахивание из списка недавних на стоковом Android не убивает foreground-сервис, и постоянное соединение переживает его спокойно. Именно так живёт ntfy, и именно так теперь живём мы: свой сокет в foreground-сервисе, ценой постоянного уведомления в шторке. Firebase выигрывает не в том, что он один умеет пережить смахивание, а в том, что при нём вашему приложению вообще не надо держать соединение и тратить батарею. Отдельная история это вендорские «оптимизаторы», вот они убивают и то, и другое, и с ними не воюет никто.
Дублирование на почту через 10 минут это честное инженерное решение, и оно закрывает ровно ту дыру, которая остаётся: сигнал не дошёл, но информация всё равно доехала. Нам этот путь закрыт по построению, у нас нет почты пользователя, её отсутствие это и есть продукт. Плюс для сквозного шифрования второй канал в открытом виде это шаг назад: содержимое уходит из системы к почтовому провайдеру.
Мы решили ту же задачу иначе: пуш-сервер держит сигнал в кэше 12 часов, а клиент при переподключении просит всё, что пришло после последнего доставленного, и догоняет пропущенное. Уведомление в этом случае приходит поздно, но не теряется. По сути та же гарантия, что у вас, только вторым каналом служит не почта, а само переподключение.
Интересно, как люди реагируют на дубли. Со стороны кажется, что это ровно та функция, которую часть пользователей попросит выключить, а часть не заметит вовсе. У вас есть переключатель?

durnoy
31.07.2026 21:52Именно поэтому предыдущий пункт про туннель не косметика.
Во-первых, непонятно, что за туннель из предыдущего пункта.
Во-вторых, от этого фрагмента и всей статьи ужасно сквозит текстом, написанным ИИ.

nemo_user
31.07.2026 21:52Ответы автора в комментариях тоже сквозят ИИ. Так что есть сомнения в замыслах авторов "проекта".

rcq Автор
31.07.2026 21:52Замыслы удобнее проверять не по стилю комментариев, а по тому, что можно открыть и посмотреть:
клиенты под AGPL-3.0, сервер тоже открыт и поднимается у себя, аккаунт не обязан жить у нас;
протокол выложен отдельным документом, а не «доверьтесь нам»;
при регистрации не спрашивается ни телефон, ни почта, ни имя. Это проверяется за минуту установкой;
ни рекламы, ни аналитических SDK в сборке, проект живёт на донатах.
Если конкретное утверждение из статьи вызывает сомнение, назовите его, и я покажу коммит, строчку в чужих исходниках или живой ответ сервера. В таком формате спор имеет смысл, в формате «текст звучит не так» его нет.

rcq Автор
31.07.2026 21:52По первому пункту вы правы, это дыра в тексте. Слово «туннель» я использую так, будто читатель уже знает, о чём речь, а он не знает. Дописал абзац: внутри приложения живёт sing-box, он поднимает локальный прокси и уводит трафик через наши релеи по VLESS + Reality или Hysteria2, когда прямое соединение до сервера не проходит. Отдельного VPN ставить не надо, системного профиля не появляется. Спасибо, поправил.
По второму. Да, тексты мы пишем с ИИ и скрывать это не вижу смысла. Часть команды по-русски не пишет вообще, материал собирается на английском и переводится, отсюда ровный и слегка стерильный тон, который вы и почувствовали. Инструмент при этом остаётся инструментом: он не делает утверждение верным и не делает его ложным. Спорить сегодня о том, пользоваться им или нет, мне кажется примерно так же содержательно, как спорить о подсветке синтаксиса.
Поэтому единственный честный ответ на «пахнет ИИ» это «проверьте». В статье почти всё проверяемо без нас:
оба фрагмента, на которых держится весь разбор, лежат в исходниках ntfy:
limitRequestsWithTopicвserver/server_middleware.goи проверка на 507 вhandlePublishInternal. Откройте и убедитесь сами, что лимит списывается с подписчика, а не с отправителя;обязательность заголовка TTL это RFC 8030, раздел 5.2, читается за минуту;
код дистрибьютора, о котором речь, лежит в
push/embeddedв github.com/rcq-messenger/rcq-android, там же вся история коммитов с датами;пуш-сервер, о котором речь, отвечает прямо сейчас:
curlhttps://push.rcq.app/v1/health;в описании релиза v0.75 лежат sha256 всех APK, те же файлы отдаёт rcq.app, хеши обязаны совпасть. Если не совпадут, это будет гораздо более интересная новость, чем стиль статьи.
Чего проверить нельзя, скажу прямо: цифры отказов взяты из журнала нашего прода, и тут остаётся верить на слово. Всё остальное открыто.

n0isy
31.07.2026 21:52Горшочек-chatgpt не вари. Хватит отвечать через ИИ. Не поленитесь ответить хуману ручками.

rcq Автор
31.07.2026 21:52Предлагаете использовать четки вместо калькулятора? Печально.

Lagovi
31.07.2026 21:52Вас просят уместно использовать инструменты.
Если от генерированного текста воротит, значит инструмент задачу коммуникации выполняет плохо. Либо потому что вы им не умеете пользоваться (а это так, потому что правильным промптом можно добиться человеческого стиля), либо потому что инструмент не дорос (и это то-же так, рано или поздно неестественность пофиксят).

rcq Автор
31.07.2026 21:52Уже объясняли в других ветках, что большая часть команды не владеет языком, а также это экономит уйму времени, как в статье так и в комментариях все написано по делу, вопрос эстетики - это оффтоп.
Anselm_nn
Я не погромист, но считаю себя умеющим писать ТЗ. С помощью антигравити создал рабочий проект по передачи пушей с одного устройства на другое. Суть проекта в том, что если у человека много телефонов, собирать пуши с них на основной, где, например, часы привязаны. Или как вариант прямо на часы.
Разумеется, нужно было обходиться без всякого кривого gms, который и работает не понятно как, и не везде есть, а на часах и нет. Поэтому было выбрано решение использовать отдельный сервер (оно написало его на go) и mttq протокол. Приложения успешно объединяются в группу, есть фильтры и прочее. Сама передача пушей работает отлично даже при проблемах с gms. В качестве идентификатора группы устройств используются guid, регистрация не нужна.
Что думаете о таком концепте?
rcq Автор
Концепт рабочий, и вы, по сути, независимо пришли к той же архитектуре, что и UnifiedPush: приложение получает у дистрибьютора адрес, сервер шлёт туда, дистрибьютор будит устройство. Разница в транспорте (у вас MQTT, у нас HTTP плюс WebSocket) и в том, что у вас адресом служит GUID группы, а у нас топик.
MQTT тут выбран удачнее, чем кажется на первый взгляд. У него дешёвый keepalive и нативный fan-out на нескольких подписчиков, а «раздать одно уведомление на телефон, планшет и часы» это ровно pub/sub. Мы остались на WebSocket только потому, что переиспользовали готовый ntfy, а если бы писали с нуля именно под несколько устройств одного человека, MQTT был бы честным кандидатом.
Три вещи, о которых стоит подумать, если проект выйдет за пределы личного пользования.
Первое. GUID у вас одновременно и адрес, и пароль: кто его узнал, тот и читает ваши уведомления, и подсовывает свои. У нас та же модель для топика, и это осознанный размен, но содержимое до сервера уже зашифровано, сервер видит шифротекст. У вас, если я правильно понял, текст уведомления идёт через сервер как есть, и это единственное место, где стоит навести порядок в первую очередь.
Второе. Дедупликация. Когда на одну группу подписаны телефон и часы, полезно, чтобы прочтение на одном гасило уведомление на другом, иначе часы будут вибрировать тем, что вы уже посмотрели.
Третье. Doze и вендорские «оптимизаторы» убивают долгоживущее соединение с большим удовольствием, и MQTT тут не защищён ничем по сравнению с WebSocket. Держите соединение foreground-сервисом и смотрите на интервал keepalive: слишком короткий ест батарею, слишком длинный даёт минуты задержки после выхода из сна.
Встречный вопрос, самому интересно: как вы снимаете уведомления с исходных телефонов, через NotificationListenerService? И что делаете с пачками, когда мессенджер присылает десяток уведомлений подряд, схлопываете или льёте как есть?
Anselm_nn
приложение построено по принципу минимальной проблемности. никакой регистрации, почты, паролей, которые никто не запоминает, ставит одинаковыые и забывает, достаточно длинная случайная строка лучше 90% паролей обычного пользователя и дает быстрый старт (отдельно ниже). планирую добавить поддержку ssl между сервером и приложениями, этого достаточно, так как сервер можно использовать свой
существующая модель осуществляет связь на основе принципа основного устройства, то есть главный телефон (мастер), на котором висят часы, или, собственно, сами часы, получает уведомления с группы. Так что отмечать прочитанность на другом устройстве вроде как не обязательно. Из плюшек есть история, поиск по истории, выбор приложений для захвата, выбор ключевых слов (черный и белый список). конкретно под часы оптимизированное приложение еще не собирал, планируется в нем функция второго мастера (когда на часах интернет через телефон, мастер-телефон, когда через esim-часы) что б меньше разряжать часы
приложение выбрасывает и сервисное уведомление а так вносится в список защищенных (тестилось на ху/хо конкретно), проблем с выгрузкой нет, возможно в фоне что-то ходит, в явном виде это я не описывал
схлопывания нет, наоборот пришлось доработать так, что б не было сообщений вида "whatsapp 2 новых сообщения". используется стандартное разрешение на захват приложений, как приложение часов
теперь про принцип быстрого старта:
на мастере указывается адрес сервера или используется дефолтный и нажимается кнопка "создать", это приводит к созданию группы и guid, на экране отображается qr содержащий сервер и guid
клиент просто сканирует qr
в настройках выбираются параметры захвата (приложения, ключевые слова)
голову не сношаем, почты не требуем, данные не собираем. сервер на go жрет 60мб только, работает без зависимостей. приложение на flutter, 80мб, не так много, как сейчас обычно, но есть желание заставить переписать на pure java/kotlin что б на часах не жрало